Skip to content

Commit f90c722

Browse files
fix(P0): incremental streaming, resume feedback, error boundary, slash command mapping, model confirmation
1 parent 65c1f21 commit f90c722

8 files changed

Lines changed: 148 additions & 7 deletions

File tree

crates/session/src/claude.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -223,16 +223,17 @@ impl ClaudeSession {
223223
let events: Vec<AgentEvent> = match event_type {
224224
"ready" => {
225225
// Sidecar is initialised. Emit SessionReady with no session ID yet.
226-
vec![AgentEvent::SessionReady { claude_session_id: None }]
226+
vec![AgentEvent::SessionReady { claude_session_id: None, model: None }]
227227
}
228228
"session_ready" => {
229229
let sid = obj.get("sessionId").and_then(|s| s.as_str()).map(|s| s.to_string());
230+
let confirmed_model = obj.get("model").and_then(|m| m.as_str()).map(|m| m.to_string());
230231
if let Some(ref s) = sid {
231232
if let Ok(mut guard) = session_id_clone.lock() {
232233
*guard = Some(s.clone());
233234
}
234235
}
235-
vec![AgentEvent::SessionReady { claude_session_id: sid }]
236+
vec![AgentEvent::SessionReady { claude_session_id: sid, model: confirmed_model }]
236237
}
237238
"turn_started" => {
238239
vec![AgentEvent::TurnStarted { turn_id: "sidecar".to_string() }]

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 { claude_session_id: None });
156+
let _ = event_tx.send(AgentEvent::SessionReady { claude_session_id: None, model: None });
157157

158158
Ok((session, event_rx))
159159
}

crates/session/src/types.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ pub enum AgentEvent {
4646
/// The Claude CLI session ID (for `--resume`), if available.
4747
#[serde(skip_serializing_if = "Option::is_none")]
4848
claude_session_id: Option<String>,
49+
/// The model confirmed by the SDK.
50+
#[serde(skip_serializing_if = "Option::is_none")]
51+
model: Option<String>,
4952
},
5053
/// An error occurred in the session.
5154
SessionError { message: String },

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@ async function handleQuery(cmd) {
190190
options.signal = abort.signal;
191191

192192
let capturedSessionId = sessionId || null;
193+
const expectedResumeId = options.resume || null;
193194
let turnEmitted = false;
194195

195196
// Reset incremental streaming counters for new query.
@@ -210,7 +211,12 @@ async function handleQuery(cmd) {
210211
if (msgType === "system") {
211212
if (message.subtype === "init" && message.session_id) {
212213
capturedSessionId = message.session_id;
213-
emit({ type: "session_ready", sessionId: message.session_id });
214+
// Detect resume failure: expected to resume a specific session but got a different one
215+
if (expectedResumeId && message.session_id !== expectedResumeId) {
216+
emit({ type: "error", message: "Session resume failed, starting fresh" });
217+
}
218+
const confirmedModel = message.model || model || null;
219+
emit({ type: "session_ready", sessionId: message.session_id, model: confirmedModel });
214220
}
215221
continue;
216222
}

crates/tauri-app/frontend/src/App.tsx

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Show, createSignal, onMount, onCleanup } from "solid-js";
1+
import { Show, ErrorBoundary, createSignal, onMount, onCleanup } from "solid-js";
22
import { appStore } from "./stores/app-store";
33
import { WelcomeScreen } from "./components/shared/WelcomeScreen";
44
import { SetupWizard } from "./components/onboarding/SetupWizard";
@@ -186,6 +186,19 @@ export function App() {
186186
};
187187

188188
return (
189+
<ErrorBoundary fallback={(err, reset) => (
190+
<div class="error-boundary">
191+
<div class="error-boundary-content">
192+
<h2 class="error-boundary-title">Something went wrong</h2>
193+
<p class="error-boundary-message">{err?.message || String(err)}</p>
194+
<details class="error-boundary-details">
195+
<summary>Stack trace</summary>
196+
<pre class="error-boundary-stack">{err?.stack || "No stack trace available"}</pre>
197+
</details>
198+
<button class="error-boundary-reload" onClick={() => location.reload()}>Reload</button>
199+
</div>
200+
</div>
201+
)}>
189202
<>
190203
<Show when={showSetup()}>
191204
<SetupWizard onComplete={() => setShowSetup(false)} />
@@ -265,6 +278,73 @@ export function App() {
265278
<WelcomeScreen onDismiss={() => setShowWelcome(false)} />
266279
</Show>
267280

281+
<style>{`
282+
.error-boundary {
283+
position: fixed;
284+
inset: 0;
285+
display: flex;
286+
align-items: center;
287+
justify-content: center;
288+
background: var(--bg-base, #1a1a2e);
289+
z-index: 9999;
290+
}
291+
.error-boundary-content {
292+
max-width: 520px;
293+
width: 90%;
294+
padding: 32px;
295+
background: var(--bg-card, #22223a);
296+
border: 1px solid var(--border, #333);
297+
border-radius: 12px;
298+
text-align: center;
299+
}
300+
.error-boundary-title {
301+
font-size: 18px;
302+
font-weight: 600;
303+
color: var(--red, #f87171);
304+
margin: 0 0 12px;
305+
}
306+
.error-boundary-message {
307+
font-size: 14px;
308+
color: var(--text, #e0e0e0);
309+
margin: 0 0 16px;
310+
word-break: break-word;
311+
}
312+
.error-boundary-details {
313+
text-align: left;
314+
margin-bottom: 20px;
315+
}
316+
.error-boundary-details summary {
317+
cursor: pointer;
318+
font-size: 12px;
319+
color: var(--text-secondary, #999);
320+
margin-bottom: 8px;
321+
}
322+
.error-boundary-stack {
323+
font-size: 11px;
324+
color: var(--text-tertiary, #777);
325+
background: var(--bg-base, #1a1a2e);
326+
padding: 12px;
327+
border-radius: 6px;
328+
overflow-x: auto;
329+
max-height: 200px;
330+
white-space: pre-wrap;
331+
word-break: break-all;
332+
}
333+
.error-boundary-reload {
334+
padding: 10px 24px;
335+
background: var(--primary, #6b7cff);
336+
color: white;
337+
border: none;
338+
border-radius: 6px;
339+
font-size: 13px;
340+
font-weight: 600;
341+
cursor: pointer;
342+
}
343+
.error-boundary-reload:hover {
344+
filter: brightness(1.1);
345+
}
346+
`}</style>
347+
268348
<style>{`
269349
.main-panel {
270350
flex: 1;
@@ -352,5 +432,6 @@ export function App() {
352432
}
353433
`}</style>
354434
</>
435+
</ErrorBoundary>
355436
);
356437
}

crates/tauri-app/frontend/src/components/composer/ModelSelector.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,15 @@ export function ModelSelector() {
2323
return preset ? preset.label : store.selectedModel;
2424
};
2525

26+
/** Show the confirmed model from the SDK, if it differs from the selected alias. */
27+
const confirmedLabel = () => {
28+
if (!store.activeModel) return null;
29+
const selected = store.selectedModel;
30+
// Only show if it adds info (e.g. alias "opus" resolved to "claude-opus-4-6")
31+
if (store.activeModel === selected) return null;
32+
return store.activeModel;
33+
};
34+
2635
function select(value: string | null) {
2736
setStore("selectedModel", value);
2837
setOpen(false);
@@ -67,6 +76,9 @@ export function ModelSelector() {
6776
<path d="M2 12l10 5 10-5" />
6877
</svg>
6978
{currentLabel()}
79+
<Show when={confirmedLabel()}>
80+
<span class="model-confirmed">{confirmedLabel()}</span>
81+
</Show>
7082
<svg class="chevron" width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><polyline points="6 9 12 15 18 9" /></svg>
7183
</button>
7284

@@ -234,6 +246,12 @@ if (!document.getElementById("model-selector-styles")) {
234246
border-radius: var(--radius-sm);
235247
}
236248
.model-custom-clear:hover { color: var(--text-secondary); background: var(--bg-hover); }
249+
.model-confirmed {
250+
font-size: 9px;
251+
color: var(--text-tertiary);
252+
font-family: var(--font-mono);
253+
opacity: 0.7;
254+
}
237255
`;
238256
document.head.appendChild(style);
239257
}

crates/tauri-app/frontend/src/stores/app-store.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export interface AppStore {
4646
threadMessagesLoading: Record<string, boolean>;
4747
projectGitStatus: Record<string, "none" | "git" | "github">;
4848
projectPrMap: Record<string, Record<string, number>>;
49+
activeModel: string | null;
4950
}
5051

5152
function createAppStore() {
@@ -85,6 +86,7 @@ function createAppStore() {
8586
threadMessagesLoading: {},
8687
projectGitStatus: {},
8788
projectPrMap: {},
89+
activeModel: null,
8890
});
8991

9092
async function loadData() {
@@ -228,11 +230,36 @@ function createAppStore() {
228230
});
229231
}
230232

233+
/** Map slash commands to natural language prompts the agent can act on. */
234+
function resolveSlashCommand(input: string): string {
235+
if (!input.startsWith("/")) return input;
236+
const cmdName = input.split(" ")[0];
237+
const cmdArgs = input.slice(cmdName.length).trim();
238+
const mappings: Record<string, string> = {
239+
"/commit": "Create a git commit with a descriptive message for the current changes",
240+
"/review-pr": "Review the current pull request",
241+
"/compact": "Summarize the conversation so far",
242+
"/help": "Show what you can help with",
243+
"/fix": "Fix the issues in the current code",
244+
"/test": "Run the tests and fix any failures",
245+
"/lint": "Run the linter and fix any issues",
246+
"/refactor": "Refactor the current code for better readability and maintainability",
247+
};
248+
const mapped = mappings[cmdName];
249+
if (mapped) {
250+
return cmdArgs ? `${mapped}: ${cmdArgs}` : mapped;
251+
}
252+
return input;
253+
}
254+
231255
async function sendUserMessage() {
232-
const text = store.composerText.trim();
256+
let text = store.composerText.trim();
233257
const atts = [...store.attachments];
234258
if ((!text && atts.length === 0) || !store.activeTab) return;
235259

260+
// Resolve slash commands to natural language prompts
261+
text = resolveSlashCommand(text);
262+
236263
const threadId = store.activeTab;
237264
setStore("composerText", "");
238265
setStore("attachments", []);
@@ -564,6 +591,10 @@ function createAppStore() {
564591
if (store.sessionStatuses[thread_id] !== "generating") {
565592
setStore("sessionStatuses", thread_id, "ready");
566593
}
594+
// Capture the confirmed model from the SDK
595+
if (payload.model) {
596+
setStore("activeModel", payload.model);
597+
}
567598
break;
568599
case "session_error": {
569600
setStore("sessionStatuses", thread_id, "error");

crates/tauri-app/src/streaming.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ pub fn spawn_event_forwarder(
185185
..default_payload()
186186
}
187187
}
188-
AgentEvent::SessionReady { claude_session_id } => {
188+
AgentEvent::SessionReady { claude_session_id, ref model } => {
189189
// Persist session record via spawn_blocking
190190
let db_clone = db.clone();
191191
let csid = claude_session_id.clone();
@@ -218,6 +218,7 @@ pub fn spawn_event_forwarder(
218218
session_id: session_id.to_string(),
219219
thread_id: thread_id.to_string(),
220220
event_type: "session_ready".into(),
221+
model: model.clone(),
221222
..default_payload()
222223
}
223224
}

0 commit comments

Comments
 (0)