Skip to content

Commit 538cfa9

Browse files
perf: optimistic thread creation, smooth animations, batch settings, loading skeletons, content-visibility
1 parent d72bb67 commit 538cfa9

17 files changed

Lines changed: 542 additions & 170 deletions

File tree

crates/persistence/src/db.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,14 @@ impl Database {
1313
/// Open (or create) a database file at the given path and run migrations.
1414
pub fn open(path: &Path) -> anyhow::Result<Self> {
1515
let conn = Connection::open(path)?;
16-
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?;
16+
conn.execute_batch(
17+
"PRAGMA journal_mode=WAL;
18+
PRAGMA foreign_keys=ON;
19+
PRAGMA synchronous=NORMAL;
20+
PRAGMA cache_size=-8000;
21+
PRAGMA mmap_size=268435456;
22+
PRAGMA busy_timeout=5000;",
23+
)?;
1724
run_migrations(&conn)?;
1825
Ok(Self { conn })
1926
}

crates/persistence/src/migrations.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,5 +83,17 @@ pub fn run_migrations(conn: &Connection) -> rusqlite::Result<()> {
8383
conn.execute_batch("ALTER TABLE sessions ADD COLUMN claude_session_id TEXT;")?;
8484
}
8585

86+
// Performance indexes — idempotent via IF NOT EXISTS
87+
conn.execute_batch(
88+
"
89+
CREATE INDEX IF NOT EXISTS idx_threads_project_id ON threads(project_id);
90+
CREATE INDEX IF NOT EXISTS idx_messages_thread_id ON messages(thread_id);
91+
CREATE INDEX IF NOT EXISTS idx_messages_thread_created ON messages(thread_id, created_at);
92+
CREATE INDEX IF NOT EXISTS idx_sessions_thread_id ON sessions(thread_id);
93+
CREATE INDEX IF NOT EXISTS idx_sessions_thread_claude ON sessions(thread_id, claude_session_id);
94+
CREATE INDEX IF NOT EXISTS idx_usage_logs_thread_id ON usage_logs(thread_id);
95+
",
96+
)?;
97+
8698
Ok(())
8799
}

crates/persistence/src/queries.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,35 @@ pub fn set_setting(conn: &Connection, key: &str, value: &str) -> anyhow::Result<
311311
Ok(())
312312
}
313313

314+
pub fn get_settings_batch(
315+
conn: &Connection,
316+
keys: &[String],
317+
) -> anyhow::Result<std::collections::HashMap<String, String>> {
318+
if keys.is_empty() {
319+
return Ok(std::collections::HashMap::new());
320+
}
321+
// Build a parameterized IN clause
322+
let placeholders: Vec<String> = (1..=keys.len()).map(|i| format!("?{i}")).collect();
323+
let sql = format!(
324+
"SELECT key, value FROM settings WHERE key IN ({})",
325+
placeholders.join(", ")
326+
);
327+
let mut stmt = conn.prepare(&sql)?;
328+
let params: Vec<&dyn rusqlite::types::ToSql> =
329+
keys.iter().map(|k| k as &dyn rusqlite::types::ToSql).collect();
330+
let rows = stmt.query_map(params.as_slice(), |row| {
331+
Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
332+
})?;
333+
let mut map = std::collections::HashMap::new();
334+
for row in rows {
335+
let (key, value) = row?;
336+
if let Some(v) = value {
337+
map.insert(key, v);
338+
}
339+
}
340+
Ok(map)
341+
}
342+
314343
pub fn delete_setting(conn: &Connection, key: &str) -> anyhow::Result<()> {
315344
conn.execute("DELETE FROM settings WHERE key = ?1", params![key])?;
316345
Ok(())

crates/session/src/claude.rs

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -103,20 +103,36 @@ impl ClaudeSession {
103103
permission_mode: Option<&str>,
104104
session_id: Option<&str>,
105105
) -> Result<(Self, mpsc::UnboundedReceiver<AgentEvent>)> {
106-
// Resolve the sidecar script path. Walk up from `cwd` looking for
107-
// the workspace root (identified by a top-level Cargo.toml that
108-
// contains `[workspace]`), then join the relative sidecar path.
109-
let sidecar_path = find_workspace_root(cwd)
110-
.map(|root| root.join(SIDECAR_SCRIPT))
111-
.unwrap_or_else(|| {
112-
// Fallback: try a few common locations.
113-
let candidate = cwd.join(SIDECAR_SCRIPT);
106+
// Resolve the sidecar script path relative to the executable, not cwd.
107+
// In dev mode the exe is in target/debug/, so we walk up to find the workspace root.
108+
// In production the sidecar would be bundled alongside the binary.
109+
let sidecar_path = std::env::current_exe()
110+
.ok()
111+
.and_then(|exe| {
112+
// Walk up from exe path to find workspace root
113+
let mut dir = exe.parent()?;
114+
for _ in 0..10 {
115+
let candidate = dir.join(SIDECAR_SCRIPT);
116+
if candidate.exists() {
117+
return Some(candidate);
118+
}
119+
dir = dir.parent()?;
120+
}
121+
None
122+
})
123+
.or_else(|| {
124+
// Fallback: try from CARGO_MANIFEST_DIR (set at compile time).
125+
// This crate is at crates/session/, sidecar is at crates/tauri-app/agent-sidecar/
126+
let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
127+
// Go up to workspace root (crates/session -> crates -> root)
128+
let workspace_root = manifest_dir.parent()?.parent()?;
129+
let candidate = workspace_root.join(SIDECAR_SCRIPT);
114130
if candidate.exists() {
115-
return candidate;
131+
return Some(candidate);
116132
}
117-
// Assume we're inside the workspace already.
118-
std::path::PathBuf::from(SIDECAR_SCRIPT)
119-
});
133+
None
134+
})
135+
.unwrap_or_else(|| std::path::PathBuf::from(SIDECAR_SCRIPT));
120136

121137
debug!("Spawning agent sidecar: node {}", sidecar_path.display());
122138

@@ -125,13 +141,27 @@ impl ClaudeSession {
125141
.current_dir(cwd)
126142
.stdin(std::process::Stdio::piped())
127143
.stdout(std::process::Stdio::piped())
128-
.stderr(std::process::Stdio::null())
144+
.stderr(std::process::Stdio::piped())
129145
.kill_on_drop(true)
130146
.spawn()
131147
.context("Failed to spawn node agent sidecar")?;
132148

133149
let stdout = child.stdout.take().context("Failed to capture stdout")?;
134150
let stdin = child.stdin.take().context("Failed to capture stdin")?;
151+
let stderr = child.stderr.take();
152+
153+
// Stderr reader — log sidecar errors
154+
if let Some(stderr) = stderr {
155+
tokio::spawn(async move {
156+
let reader = BufReader::new(stderr);
157+
let mut lines = reader.lines();
158+
while let Ok(Some(line)) = lines.next_line().await {
159+
if !line.trim().is_empty() {
160+
tracing::warn!("sidecar stderr: {line}");
161+
}
162+
}
163+
});
164+
}
135165

136166
let (event_tx, event_rx) = mpsc::unbounded_channel();
137167
let (stdin_tx, mut stdin_rx) = mpsc::unbounded_channel::<String>();
@@ -204,6 +234,9 @@ impl ClaudeSession {
204234
}
205235
vec![AgentEvent::SessionReady { claude_session_id: sid }]
206236
}
237+
"turn_started" => {
238+
vec![AgentEvent::TurnStarted { turn_id: "sidecar".to_string() }]
239+
}
207240
"text_delta" => {
208241
let text = obj.get("text").and_then(|t| t.as_str()).unwrap_or("").to_string();
209242
if text.is_empty() {

crates/tauri-app/agent-sidecar/index.mjs

Lines changed: 117 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,41 @@ async function handleQuery(cmd) {
136136
options.resume = sessionId;
137137
}
138138

139+
// canUseTool callback — sends approval requests to Rust, waits for response
140+
options.canUseTool = async (toolName, input) => {
141+
const requestId = String(++approvalCounter);
142+
143+
// AskUserQuestion — forward to frontend
144+
if (toolName === "AskUserQuestion") {
145+
emit({
146+
type: "ask_user_question",
147+
requestId,
148+
questions: input.questions || [],
149+
});
150+
} else {
151+
// Regular tool approval
152+
emit({
153+
type: "approval_request",
154+
requestId,
155+
toolName,
156+
input,
157+
});
158+
}
159+
160+
// Wait for the response from Rust
161+
return new Promise((resolve) => {
162+
pendingApprovals.set(requestId, {
163+
resolve: (resp) => {
164+
if (resp.decision === "allow") {
165+
resolve({ behavior: "allow", updatedInput: input });
166+
} else {
167+
resolve({ behavior: "deny", message: resp.message || "User denied this action" });
168+
}
169+
},
170+
});
171+
});
172+
};
173+
139174
// Create an AbortController for this query.
140175
const abort = new AbortController();
141176
currentAbort = abort;
@@ -144,13 +179,18 @@ async function handleQuery(cmd) {
144179
let capturedSessionId = sessionId || null;
145180
let turnEmitted = false;
146181

182+
// Emit turn_started so the frontend shows "generating" state
183+
emit({ type: "turn_started" });
184+
147185
try {
148186
for await (const message of query({ prompt, options })) {
149187
// Abort was requested while iterating.
150188
if (abort.signal.aborted) break;
151189

190+
const msgType = message.type;
191+
152192
// ── system messages ──
153-
if (message.type === "system") {
193+
if (msgType === "system") {
154194
if (message.subtype === "init" && message.session_id) {
155195
capturedSessionId = message.session_id;
156196
emit({ type: "session_ready", sessionId: message.session_id });
@@ -159,17 +199,44 @@ async function handleQuery(cmd) {
159199
}
160200

161201
// ── result message (final) ──
162-
if ("result" in message) {
163-
// Extract usage if available.
164-
if (message.usage) {
202+
if (msgType === "result" || "result" in message) {
203+
// Extract usage from various possible locations
204+
const usage = message.usage || message.modelUsage;
205+
const costUsd = message.total_cost_usd ?? message.cost_usd ?? 0;
206+
const modelName = message.model ?? model ?? "unknown";
207+
208+
if (usage) {
209+
// SDK may nest usage per-model or flat
210+
let inputTokens = 0, outputTokens = 0, cacheRead = 0, cacheWrite = 0;
211+
212+
if (typeof usage === "object" && !Array.isArray(usage)) {
213+
// Check if it's a flat usage object or per-model
214+
if (usage.input_tokens != null || usage.inputTokens != null) {
215+
inputTokens = usage.input_tokens ?? usage.inputTokens ?? 0;
216+
outputTokens = usage.output_tokens ?? usage.outputTokens ?? 0;
217+
cacheRead = usage.cache_read_input_tokens ?? usage.cacheReadInputTokens ?? 0;
218+
cacheWrite = usage.cache_creation_input_tokens ?? usage.cacheCreationInputTokens ?? 0;
219+
} else {
220+
// Per-model usage: { "claude-...": { inputTokens, ... } }
221+
for (const [, modelUsage] of Object.entries(usage)) {
222+
if (typeof modelUsage === "object") {
223+
inputTokens += modelUsage.inputTokens ?? 0;
224+
outputTokens += modelUsage.outputTokens ?? 0;
225+
cacheRead += modelUsage.cacheReadInputTokens ?? 0;
226+
cacheWrite += modelUsage.cacheCreationInputTokens ?? 0;
227+
}
228+
}
229+
}
230+
}
231+
165232
emit({
166233
type: "usage",
167-
inputTokens: message.usage.input_tokens ?? 0,
168-
outputTokens: message.usage.output_tokens ?? 0,
169-
cacheRead: message.usage.cache_read_input_tokens ?? message.usage.cache_read_tokens ?? 0,
170-
cacheWrite: message.usage.cache_creation_input_tokens ?? message.usage.cache_write_tokens ?? 0,
171-
costUsd: message.cost_usd ?? message.total_cost_usd ?? 0,
172-
model: message.model ?? model ?? "unknown",
234+
inputTokens,
235+
outputTokens,
236+
cacheRead,
237+
cacheWrite,
238+
costUsd,
239+
model: modelName,
173240
});
174241
}
175242

@@ -181,17 +248,53 @@ async function handleQuery(cmd) {
181248
// ── streaming content messages ──
182249
// The SDK yields various message shapes. We normalise them to our protocol.
183250

184-
const msgType = message.type;
185-
186251
// stream_event wrapping Anthropic API SSE events
187252
if (msgType === "stream_event" && message.event) {
188253
handleStreamEvent(message.event);
189254
continue;
190255
}
191256

192-
// assistant message snapshots (full content)
257+
// assistant message — extract content blocks
193258
if (msgType === "assistant" && message.message?.content) {
194-
// We prefer stream_event deltas; skip snapshot processing.
259+
for (const block of message.message.content) {
260+
if (block.type === "text" && block.text) {
261+
emit({ type: "text_delta", text: block.text });
262+
} else if (block.type === "tool_use") {
263+
emit({
264+
type: "tool_use_start",
265+
toolId: block.id ?? "",
266+
toolName: block.name ?? "tool",
267+
});
268+
if (block.input) {
269+
emit({
270+
type: "tool_use_input",
271+
toolId: block.id ?? "",
272+
inputJson: JSON.stringify(block.input),
273+
});
274+
}
275+
} else if (block.type === "thinking" && block.thinking) {
276+
emit({ type: "thinking_delta", text: block.thinking });
277+
}
278+
}
279+
continue;
280+
}
281+
282+
// user message — contains tool results
283+
if (msgType === "user" && message.message?.content) {
284+
for (const block of message.message.content) {
285+
if (block.type === "tool_result") {
286+
const content = typeof block.content === "string"
287+
? block.content
288+
: JSON.stringify(block.content);
289+
emit({
290+
type: "tool_result",
291+
toolId: block.tool_use_id ?? "",
292+
toolName: "",
293+
content,
294+
isError: !!block.is_error,
295+
});
296+
}
297+
}
195298
continue;
196299
}
197300

0 commit comments

Comments
 (0)