Skip to content

Commit 062ff35

Browse files
feat: add session resume, interrupt, error recovery, permissions, context window, notifications, git UI, and PR review
- Session resume: store Claude CLI session_id in DB, use --resume flag on reconnect - Interrupt: SIGINT on first stop click, force kill on second (two-stage) - Error recovery: edit/resend, retry, and regenerate buttons on messages - Tool result fix: proper tool_id matching, stdout/stderr separation in ToolUseCard - Permission mode: configurable via settings (auto, acceptEdits, bypassPermissions, plan) - Context window: token usage accumulator with color-coded progress bar in toolbar - Desktop notifications: Web Notification API for background thread completion/errors - Git management: status, staging, commit, branch switch, create, push, log panel - PR review: full review panel with overview, files, checks, comments, and review submission
1 parent 2096d09 commit 062ff35

27 files changed

Lines changed: 2881 additions & 85 deletions

File tree

crates/app/src/handlers/agent.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ fn handle_event(
125125
description,
126126
});
127127
}
128-
AgentEvent::SessionReady => {
128+
AgentEvent::SessionReady { .. } => {
129129
state
130130
.session_states
131131
.insert(session_id, SessionState::Ready);

crates/persistence/src/migrations.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,5 +73,15 @@ pub fn run_migrations(conn: &Connection) -> rusqlite::Result<()> {
7373
",
7474
)?;
7575

76+
// Add claude_session_id column to sessions if missing (used for --resume)
77+
let has_claude_session_id: bool = conn
78+
.prepare("PRAGMA table_info(sessions)")?
79+
.query_map([], |row| row.get::<_, String>(1))?
80+
.any(|col| col.as_deref() == Ok("claude_session_id"));
81+
82+
if !has_claude_session_id {
83+
conn.execute_batch("ALTER TABLE sessions ADD COLUMN claude_session_id TEXT;")?;
84+
}
85+
7686
Ok(())
7787
}

crates/persistence/src/queries.rs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,25 @@ pub fn delete_messages_by_thread(conn: &Connection, thread_id: Uuid) -> anyhow::
184184
Ok(())
185185
}
186186

187+
pub fn delete_messages_after(
188+
conn: &Connection,
189+
thread_id: Uuid,
190+
message_id: Uuid,
191+
) -> anyhow::Result<u64> {
192+
// Get the created_at of the reference message
193+
let created_at: String = conn.query_row(
194+
"SELECT created_at FROM messages WHERE id = ?1 AND thread_id = ?2",
195+
params![message_id.to_string(), thread_id.to_string()],
196+
|row| row.get(0),
197+
)?;
198+
// Delete all messages in the thread created after this message (exclusive)
199+
let deleted = conn.execute(
200+
"DELETE FROM messages WHERE thread_id = ?1 AND created_at > ?2",
201+
params![thread_id.to_string(), created_at],
202+
)?;
203+
Ok(deleted as u64)
204+
}
205+
187206
// ---------------------------------------------------------------------------
188207
// Sessions
189208
// ---------------------------------------------------------------------------
@@ -234,6 +253,35 @@ pub fn update_session_status(conn: &Connection, id: Uuid, status: &str) -> anyho
234253
Ok(())
235254
}
236255

256+
pub fn update_session_claude_id(
257+
conn: &Connection,
258+
id: Uuid,
259+
claude_session_id: &str,
260+
) -> anyhow::Result<()> {
261+
conn.execute(
262+
"UPDATE sessions SET claude_session_id = ?1 WHERE id = ?2",
263+
params![claude_session_id, id.to_string()],
264+
)?;
265+
Ok(())
266+
}
267+
268+
/// Get the most recent Claude session ID for a thread (for --resume).
269+
pub fn get_latest_claude_session_id(
270+
conn: &Connection,
271+
thread_id: Uuid,
272+
) -> anyhow::Result<Option<String>> {
273+
let mut stmt = conn.prepare(
274+
"SELECT claude_session_id FROM sessions WHERE thread_id = ?1 AND claude_session_id IS NOT NULL ORDER BY created_at DESC LIMIT 1",
275+
)?;
276+
let mut rows = stmt.query_map(params![thread_id.to_string()], |row| {
277+
row.get::<_, Option<String>>(0)
278+
})?;
279+
match rows.next() {
280+
Some(r) => Ok(r?),
281+
None => Ok(None),
282+
}
283+
}
284+
237285
pub fn delete_session(conn: &Connection, id: Uuid) -> anyhow::Result<()> {
238286
conn.execute(
239287
"DELETE FROM sessions WHERE id = ?1",

crates/session/src/claude.rs

Lines changed: 107 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use std::path::Path;
2+
use std::sync::{Arc, Mutex};
23

34
use anyhow::{Context, Result};
45
use serde_json::Value;
@@ -12,30 +13,70 @@ use crate::types::AgentEvent;
1213
pub struct ClaudeSession {
1314
child: Child,
1415
stdin_tx: mpsc::UnboundedSender<String>,
16+
/// The Claude CLI session ID captured from the `system.init` event.
17+
claude_session_id: Arc<Mutex<Option<String>>>,
1518
}
1619

1720
impl ClaudeSession {
1821
pub async fn start(
1922
cwd: &Path,
2023
model: Option<&str>,
24+
permission_mode: Option<&str>,
2125
) -> Result<(Self, mpsc::UnboundedReceiver<AgentEvent>)> {
26+
let perm_mode = permission_mode.unwrap_or("bypassPermissions");
27+
let mut args = vec![
28+
"-p",
29+
"--output-format", "stream-json",
30+
"--verbose",
31+
"--input-format", "stream-json",
32+
"--include-partial-messages",
33+
"--permission-mode", perm_mode,
34+
];
35+
if let Some(m) = model {
36+
args.push("--model");
37+
args.push(m);
38+
}
39+
40+
Self::spawn_with_args(cwd, &args).await
41+
}
42+
43+
/// Resume a previous Claude CLI session using `--resume`.
44+
pub async fn resume(
45+
cwd: &Path,
46+
claude_session_id: &str,
47+
model: Option<&str>,
48+
) -> Result<(Self, mpsc::UnboundedReceiver<AgentEvent>)> {
49+
let resume_id = claude_session_id.to_string();
2250
let mut args = vec![
2351
"-p",
2452
"--output-format", "stream-json",
2553
"--verbose",
2654
"--input-format", "stream-json",
2755
"--include-partial-messages",
28-
// bypassPermissions since there's no TTY for Claude Code's
29-
// own approval TUI — commands would silently hang otherwise.
3056
"--permission-mode", "bypassPermissions",
57+
"--resume",
3158
];
59+
// Push the resume ID; we need the String to live long enough.
60+
args.push(&resume_id);
3261
if let Some(m) = model {
3362
args.push("--model");
3463
args.push(m);
3564
}
3665

66+
Self::spawn_with_args(cwd, &args).await
67+
}
68+
69+
/// Return the captured Claude CLI session ID, if available.
70+
pub fn claude_session_id(&self) -> Option<String> {
71+
self.claude_session_id.lock().ok().and_then(|g| g.clone())
72+
}
73+
74+
async fn spawn_with_args(
75+
cwd: &Path,
76+
args: &[&str],
77+
) -> Result<(Self, mpsc::UnboundedReceiver<AgentEvent>)> {
3778
let mut child = Command::new("claude")
38-
.args(&args)
79+
.args(args)
3980
.current_dir(cwd)
4081
.stdin(std::process::Stdio::piped())
4182
.stdout(std::process::Stdio::piped())
@@ -49,6 +90,7 @@ impl ClaudeSession {
4990

5091
let (event_tx, event_rx) = mpsc::unbounded_channel();
5192
let (stdin_tx, mut stdin_rx) = mpsc::unbounded_channel::<String>();
93+
let claude_session_id: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
5294

5395
// Stdin writer
5496
tokio::spawn(async move {
@@ -62,6 +104,7 @@ impl ClaudeSession {
62104

63105
// Stdout reader
64106
let tx = event_tx;
107+
let session_id_clone = claude_session_id.clone();
65108
tokio::spawn(async move {
66109
let reader = BufReader::new(stdout);
67110
let mut lines = reader.lines();
@@ -72,6 +115,10 @@ impl ClaudeSession {
72115
let mut got_stream_deltas = false;
73116
// Track active content blocks by their stream index for tool use / thinking.
74117
let mut active_blocks = std::collections::HashMap::<u64, BlockInfo>::new();
118+
// Map tool_id -> tool_name persisted across the turn so we can
119+
// look up names when the "user" tool_result event arrives (after
120+
// content_block_end has already removed the block from active_blocks).
121+
let mut tool_names = std::collections::HashMap::<String, String>::new();
75122
// Track model name from message_start so we can use it in result.
76123
let mut current_model: Option<String> = None;
77124

@@ -89,13 +136,26 @@ impl ClaudeSession {
89136
let events: Vec<AgentEvent> = match event_type {
90137
"system" => {
91138
if obj.get("subtype").and_then(|s| s.as_str()) == Some("init") {
92-
vec![AgentEvent::SessionReady]
139+
// Capture the session_id from the init event
140+
let sid = obj.get("session_id").and_then(|s| s.as_str()).map(|s| s.to_string());
141+
if let Some(ref s) = sid {
142+
if let Ok(mut guard) = session_id_clone.lock() {
143+
*guard = Some(s.clone());
144+
}
145+
}
146+
vec![AgentEvent::SessionReady { claude_session_id: sid }]
93147
} else {
94148
vec![]
95149
}
96150
}
97151
"stream_event" => {
98152
let evts = handle_stream_event(&obj, &mut turn_started, &mut active_blocks, &mut current_model);
153+
// Record tool_id -> tool_name for later lookup
154+
for evt in &evts {
155+
if let AgentEvent::ToolUseStart { tool_id, tool_name } = evt {
156+
tool_names.insert(tool_id.clone(), tool_name.clone());
157+
}
158+
}
99159
// Mark that we got real streaming deltas so we skip
100160
// the `assistant` snapshot which would duplicate them.
101161
if evts.iter().any(|e| matches!(e, AgentEvent::ContentDelta { .. }
@@ -141,6 +201,7 @@ impl ClaudeSession {
141201
turn_started = false;
142202
got_stream_deltas = false;
143203
active_blocks.clear();
204+
tool_names.clear();
144205
let evts = handle_result(&obj, &current_model);
145206
current_model = None;
146207
evts
@@ -155,34 +216,59 @@ impl ClaudeSession {
155216

156217
if let Some(blocks) = content {
157218
let mut evts = Vec::new();
219+
220+
// Extract stdout/stderr from top-level tool_use_result
221+
let tool_use_result = obj.get("tool_use_result");
222+
let stdout = tool_use_result
223+
.and_then(|r| r.get("stdout"))
224+
.and_then(|s| s.as_str())
225+
.unwrap_or("");
226+
let stderr = tool_use_result
227+
.and_then(|r| r.get("stderr"))
228+
.and_then(|s| s.as_str())
229+
.unwrap_or("");
230+
158231
for block in blocks {
159232
if block.get("type").and_then(|t| t.as_str()) == Some("tool_result") {
160233
let tool_use_id = block
161234
.get("tool_use_id")
162235
.and_then(|id| id.as_str())
163236
.unwrap_or("")
164237
.to_string();
165-
let result_content = block
166-
.get("content")
167-
.map(|c| {
168-
if let Some(s) = c.as_str() {
169-
s.to_string()
170-
} else {
171-
serde_json::to_string_pretty(c).unwrap_or_default()
172-
}
173-
})
174-
.unwrap_or_default();
238+
239+
// Build the result content: prefer stdout/stderr
240+
// from tool_use_result, fall back to block content
241+
let result_content = if !stdout.is_empty() || !stderr.is_empty() {
242+
let mut parts = Vec::new();
243+
if !stdout.is_empty() {
244+
parts.push(stdout.to_string());
245+
}
246+
if !stderr.is_empty() {
247+
parts.push(format!("[stderr]\n{stderr}"));
248+
}
249+
parts.join("\n")
250+
} else {
251+
block
252+
.get("content")
253+
.map(|c| {
254+
if let Some(s) = c.as_str() {
255+
s.to_string()
256+
} else {
257+
serde_json::to_string_pretty(c).unwrap_or_default()
258+
}
259+
})
260+
.unwrap_or_default()
261+
};
262+
175263
let is_error = block
176264
.get("is_error")
177265
.and_then(|e| e.as_bool())
178266
.unwrap_or(false);
179267

180-
// Try to get the tool name from tool_use_result
181-
let tool_name = obj
182-
.get("tool_use_result")
183-
.and_then(|r| r.get("stdout"))
184-
.and_then(|s| s.as_str())
185-
.map(|_| "Bash".to_string())
268+
// Look up tool name from the turn's tool_names map
269+
let tool_name = tool_names
270+
.get(&tool_use_id)
271+
.cloned()
186272
.unwrap_or_default();
187273

188274
evts.push(AgentEvent::ToolResult {
@@ -213,7 +299,7 @@ impl ClaudeSession {
213299
debug!("Claude stdout reader finished");
214300
});
215301

216-
Ok((Self { child, stdin_tx }, event_rx))
302+
Ok((Self { child, stdin_tx, claude_session_id }, event_rx))
217303
}
218304

219305
pub fn send_message(&self, text: &str) -> Result<()> {

crates/session/src/codex.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ impl CodexSession {
153153
.await
154154
.context("Codex thread/start failed")?;
155155

156-
let _ = event_tx.send(AgentEvent::SessionReady);
156+
let _ = event_tx.send(AgentEvent::SessionReady { claude_session_id: None });
157157

158158
Ok((session, event_rx))
159159
}

crates/session/src/manager.rs

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,13 @@ impl SessionManager {
4141
provider: Provider,
4242
cwd: &Path,
4343
model: Option<&str>,
44+
permission_mode: Option<&str>,
4445
) -> Result<(SessionId, mpsc::UnboundedReceiver<AgentEvent>)> {
4546
let id = uuid::Uuid::new_v4();
4647

4748
let (active, event_rx) = match provider {
4849
Provider::ClaudeCode => {
49-
let (session, rx) = ClaudeSession::start(cwd, model)
50+
let (session, rx) = ClaudeSession::start(cwd, model, permission_mode)
5051
.await
5152
.context("Failed to start Claude Code session")?;
5253
(ActiveSession::Claude(session), rx)
@@ -63,6 +64,33 @@ impl SessionManager {
6364
Ok((id, event_rx))
6465
}
6566

67+
/// Resume a previous Claude Code session using `--resume`.
68+
///
69+
/// Returns the new internal session ID and event receiver.
70+
pub async fn resume_session(
71+
&mut self,
72+
claude_session_id: &str,
73+
cwd: &Path,
74+
model: Option<&str>,
75+
) -> Result<(SessionId, mpsc::UnboundedReceiver<AgentEvent>)> {
76+
let id = uuid::Uuid::new_v4();
77+
78+
let (session, rx) = ClaudeSession::resume(cwd, claude_session_id, model)
79+
.await
80+
.context("Failed to resume Claude Code session")?;
81+
82+
self.sessions.insert(id, ActiveSession::Claude(session));
83+
Ok((id, rx))
84+
}
85+
86+
/// Get the Claude CLI session ID for an active session, if available.
87+
pub fn claude_session_id(&self, session_id: SessionId) -> Option<String> {
88+
match self.sessions.get(&session_id) {
89+
Some(ActiveSession::Claude(s)) => s.claude_session_id(),
90+
_ => None,
91+
}
92+
}
93+
6694
/// Send a message to an existing session.
6795
pub async fn send_message(&mut self, session_id: SessionId, text: &str) -> Result<()> {
6896
let session = self

crates/session/src/types.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,11 @@ pub enum AgentEvent {
4242
description: String,
4343
},
4444
/// The session is ready to accept input.
45-
SessionReady,
45+
SessionReady {
46+
/// The Claude CLI session ID (for `--resume`), if available.
47+
#[serde(skip_serializing_if = "Option::is_none")]
48+
claude_session_id: Option<String>,
49+
},
4650
/// An error occurred in the session.
4751
SessionError { message: String },
4852
/// Usage/cost report for a completed turn.

crates/session/tests/claude_integration.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ async fn claude_code_send_and_receive() {
4141
match timeout(Duration::from_secs(30), rx.recv()).await {
4242
Ok(Some(event)) => {
4343
match &event {
44-
AgentEvent::SessionReady => {
44+
AgentEvent::SessionReady { .. } => {
4545
println!("[OK] SessionReady");
4646
got_session_ready = true;
4747
}

0 commit comments

Comments
 (0)