feat(sagi): spawn a real claude session for Sagi's Ask handoff

Unmatched Ask questions now adopt a dedicated "Sagi" lane (POST
/api/lanes/ensure, cwd defaults to $HOME) and spawn a tmux-backed claude
run there instead of just prefilling the Run page composer.
This commit is contained in:
2026-08-24 09:39:28 +07:00
parent 510f015fdf
commit b1bca07c47
6 changed files with 99 additions and 26 deletions
+26 -5
View File
@@ -78,6 +78,7 @@ import { useSagiBrain } from "./useSagiBrain";
import { useSagiPosition, SAGI_SIZE } from "./useSagiPosition";
import { matchIntent } from "./intents";
import { sagiPrefs } from "./prefs";
import { api } from "../../lib/api";
import "./sagi.css";
const FLYOUT_GAP = 10; // px between avatar and flyout
@@ -196,11 +197,31 @@ export function Sagi() {
(query: string): string | null => {
const result = matchIntent(query, brain.status);
if (result.kind === "answer") return result.text;
// Handoff: spawn a real claude via the existing Run page. `autostart=1`
// tells Run to fire the prompt automatically once it's prefilled, so the
// question is actually sent instead of just dropped into the composer.
navigate(`/run?prompt=${encodeURIComponent(result.prompt)}&autostart=1`);
setOpen(false);
// Handoff: ensure the "Sagi" lane exists for this dashboard, spawn a
// fresh claude session in it carrying the user's prompt as its first
// message, then jump to the Run page so the live pane is observable.
// Fire-and-forget — the panel already clears its composer on null and
// closes; errors surface as an answer instead of silently swallowing.
const prompt = result.prompt;
brain.setThinking(true);
(async () => {
try {
const { lane } = await api.lanes.ensure({ title: "Sagi" });
await api.run.start({
laneId: lane.id,
cwd: lane.cwd,
initialPrompt: prompt,
permissionMode: "acceptEdits",
});
navigate(`/run?laneId=${lane.id}&autostart=1`);
setOpen(false);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
brain.setBubble(`couldn't start that session: ${msg}`);
} finally {
brain.setThinking(false);
}
})();
return null;
},
[brain, navigate]
+29 -5
View File
@@ -87,6 +87,10 @@ export interface SagiBrain {
status: SagiStatus;
bubble: string | null;
dismissBubble: () => void;
/** Imperative bubble setter for one-off messages from the shell (e.g. spawn
* errors). Bypasses the throttle because the caller decided this message
* must be seen right now. Auto-clears after the standard bubble TTL. */
setBubble: (text: string) => void;
muted: boolean;
toggleMute: () => void;
clearAlerts: () => void;
@@ -103,7 +107,7 @@ export function useSagiBrain(): SagiBrain {
connected: true,
}));
const [tick, setTick] = useState(now0);
const [bubble, setBubble] = useState<string | null>(null);
const [bubble, setBubbleState] = useState<string | null>(null);
const [muted, setMuted] = useState<boolean>(() => sagiPrefs.getMuted());
const bubbleTimer = useRef<ReturnType<typeof setTimeout>>();
@@ -121,8 +125,8 @@ export function useSagiBrain(): SagiBrain {
if (!force && t - lastBubbleAt.current < BUBBLE_THROTTLE_MS) return;
lastBubbleAt.current = t;
clearTimeout(bubbleTimer.current);
setBubble(text);
bubbleTimer.current = setTimeout(() => setBubble(null), BUBBLE_MS);
setBubbleState(text);
bubbleTimer.current = setTimeout(() => setBubbleState(null), BUBBLE_MS);
}, []);
// Seed from the REST snapshot on mount so counts are accurate immediately -
@@ -176,7 +180,7 @@ export function useSagiBrain(): SagiBrain {
const dismissBubble = useCallback(() => {
clearTimeout(bubbleTimer.current);
setBubble(null);
setBubbleState(null);
}, []);
const toggleMute = useCallback(() => {
@@ -193,5 +197,25 @@ export function useSagiBrain(): SagiBrain {
[]
);
return { mood, status, bubble, dismissBubble, muted, toggleMute, clearAlerts, setThinking };
// One-shot bubble: clears itself after BUBBLE_MS. Used by the shell for
// imperative messages (e.g. spawn errors) that must appear now and fade,
// bypassing the throttled mood-driven showBubble path.
const setBubble = useCallback((text: string) => {
if (!text) return;
clearTimeout(bubbleTimer.current);
setBubbleState(text);
bubbleTimer.current = setTimeout(() => setBubbleState(null), BUBBLE_MS);
}, []);
return {
mood,
status,
bubble,
dismissBubble,
setBubble,
muted,
toggleMute,
clearAlerts,
setThinking,
};
}
+4 -2
View File
@@ -1832,10 +1832,12 @@ export const api = {
list: () => request<{ lanes: Lane[]; counts: LaneCounts }>("/lanes"),
/**
* POST /api/lanes/ensure — ensure a lane exists for the given cwd.
* @param body The cwd and optional title.
* @param body The cwd and optional title. When `cwd` is omitted the server
* falls back to the user's home directory — useful for lanes whose
* location isn't tied to a specific project (e.g. Sagi's mascot lane).
* @returns `{ lane, created }` — the lane (newly created or existing) and whether it was created.
*/
ensure: (body: { cwd: string; title?: string; pipeline?: string }) =>
ensure: (body: { cwd?: string; title?: string; pipeline?: string }) =>
request<{ lane: Lane; created: boolean }>("/lanes/ensure", {
method: "POST",
body: JSON.stringify(body),