Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions app/e2e/resume-picker.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { expect, mockOnly, test } from "./fixtures";

// A structured chat's /resume (the demo's ?structured: search-perf's Claude
// runs through its stream). Its agent has no screen to show its own picker,
// so the chat opens the app's: Claude's past conversations in the folder,
// newest first, and picking one carries it on in the same pane.

test.beforeEach(async ({ app }) => {
mockOnly();
await app.open({ params: { view: "conversation", structured: "1" } });
await app.openWorktree("devl/search-perf");
});

test("/resume opens the picker and carries the picked conversation on here", async ({ app, page }) => {
const box = app.composer.getByRole("textbox", { name: "Reply" });
await box.fill("/resume");
// The chip says what it does before it is sent.
await expect(app.composer.getByRole("status")).toContainText("past conversations");
await box.press("Enter");

const picker = page.getByRole("dialog");
await expect(picker.getByPlaceholder("Resume one of Claude's conversations…")).toBeVisible();
const rows = picker.getByRole("option");
await expect(rows).toHaveCount(5);
// The chat's own conversation is marked, and one running in another
// session can't be picked here.
await expect(rows.nth(0)).toContainText("this chat");
await expect(rows.filter({ hasText: "Cache facet counts in Redis" })).toHaveAttribute("aria-disabled", "true");
await expect(rows.filter({ hasText: "Cache facet counts in Redis" })).toContainText("running in search-perf-claude-2");
await picker.getByRole("combobox").fill("mobile");
await expect(rows).toHaveCount(1);
await rows.first().click();
await expect(picker).toBeHidden();
// Nothing was sent to Claude: the reply box is empty, and no prompt shows.
await expect(box).toHaveValue("");
await expect(app.chat.getByText("/resume", { exact: true })).toHaveCount(0);
// The tab is named after the conversation it carries on now.
await expect(page.getByText("Find why search is slow on mobile").first()).toBeVisible();

// Asked again, that one is this chat's.
await box.fill("/resume");
await box.press("Enter");
await expect(rows.filter({ hasText: "Find why search is slow on mobile" })).toContainText("this chat");
});

test("/resume with words opens the picker filtered by them", async ({ app, page }) => {
const box = app.composer.getByRole("textbox", { name: "Reply" });
await box.fill("/resume skeleton");
await box.press("Enter");
const picker = page.getByRole("dialog");
await expect(picker.getByRole("combobox")).toHaveValue("skeleton");
await expect(picker.getByRole("option")).toHaveCount(1);
await expect(picker.getByRole("option")).toContainText("Add a search-results skeleton");
await page.keyboard.press("Escape");
await expect(picker).toBeHidden();
});

test("a skill the agent loaded is a step, not a message from the person", async ({ app }) => {
const fold = app.chat.getByRole("button", { name: /used 1 skill/ }).first();
await expect(fold).toBeVisible();
await fold.click();
await expect(app.chat.getByText("Used the acme-perf-budget skill")).toBeVisible();
});
7 changes: 5 additions & 2 deletions app/src/components/conversation/command-menu.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { FileIcon, SlashIcon, SquareTerminalIcon, TerminalIcon } from "lucide-react";
import { FileIcon, HistoryIcon, SlashIcon, SquareTerminalIcon, TerminalIcon } from "lucide-react";
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";

import { Badge } from "@/components/ui/badge";
Expand Down Expand Up @@ -286,7 +286,10 @@ function PromptChip({ text, command, prefixes, who, side }: { text: string; comm
words = prefixes?.["#"] ?? "Saves to memory";
icon = <span className="font-mono text-[0.625rem]">#</span>;
} else if (command) {
if (command.screen) {
if (command.app) {
icon = <HistoryIcon />;
words = `${command.name} opens a list of ${who}'s past conversations to carry on here`;
} else if (command.screen) {
icon = <SquareTerminalIcon />;
words = `${command.name} opens ${who}'s own screen: answer it in the terminal that opens below`;
} else if (command.local) words = `${command.name} runs in ${who} itself: its output shows here`;
Expand Down
20 changes: 19 additions & 1 deletion app/src/components/conversation/conversation-pane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { PixelLoader } from "@/components/pixel-loader";
import { useComposerMenu } from "@/components/conversation/command-menu";
import { LiveScreen, useLiveScreen } from "@/components/conversation/live-screen";
import { QuestionsContext } from "@/components/conversation/question-form";
import { ResumePicker } from "@/components/conversation/resume-picker";
import { toastError } from "@/components/error-note";
import { UpgradeBox } from "@/components/upgrade-box";
import { RetryLine } from "@/components/workspace/pane-state";
Expand All @@ -32,6 +33,8 @@ import { ApiError, boxApi, type QueuedPrompt } from "@/lib/api";
import { tryNow } from "@/lib/reconnect";
import { type AttachTarget, withAttachments } from "@/lib/attachments";
import { joinDraft, listenForQuotes, type QuoteFill } from "@/lib/chat-quote";
import { canPickPast } from "@/lib/commands";
import { resumeWords } from "@/lib/resume-words";
import { keyOf, useConversations } from "@/lib/conversation-store";
import { agentLabel, agentOf, firstPrompt, guessAgent, sessionState, worktreeOf } from "@/lib/derive";
import type { NextStep } from "@/lib/errors";
Expand Down Expand Up @@ -393,7 +396,15 @@ export function ConversationPane({ box, session, agent: remembered, visible, onS
}
};

// A structured chat's /resume is the app's picker: its agent has no
// screen to show its own.
const [resuming, setResuming] = useState<string>();
const reply = async (text: string) => {
const resumeQuery = structured && canPickPast(box) ? resumeWords(text) : undefined;
if (resumeQuery !== undefined) {
setResuming(resumeQuery);
return;
}
if (mock && state !== "running") {
useConversations.getState().push(key, { kind: "user", id: `u${Date.now()}`, text });
void mockConversation().then((m) => m.finishTurn(box, session));
Expand Down Expand Up @@ -527,9 +538,15 @@ export function ConversationPane({ box, session, agent: remembered, visible, onS
</PaneEmpty>
);
}
const picker = resuming !== undefined && <ResumePicker box={box} session={session} who={who} query={resuming} onClose={() => setResuming(undefined)} />;
if (!shown.length && (mock || feed === "ready" || feed === "none") && state !== "running" && state !== "waiting" && !live.show) {
const wt = s ? worktreeOf(locations, s) : undefined;
return <FirstPrompt box={box} session={session} agent={agent} name={wt ? (wt.worktree.main ? wt.location.name : wt.worktree.name) : session} branch={wt?.worktree.branch} onSend={reply} onFail={fail} />;
return (
<>
<FirstPrompt box={box} session={session} agent={agent} name={wt ? (wt.worktree.main ? wt.location.name : wt.worktree.name) : session} branch={wt?.worktree.branch} onSend={reply} onFail={fail} />
{picker}
</>
);
}

return (
Expand Down Expand Up @@ -589,6 +606,7 @@ export function ConversationPane({ box, session, agent: remembered, visible, onS
confirm={s?.ask?.tool ? "Send anyway" : "Send as the answer"}
onConfirm={() => (confirm ? sendNow(confirm, true) : undefined)}
/>
{picker}
</div>
);
}
Expand Down
13 changes: 7 additions & 6 deletions app/src/components/conversation/conversation-view.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CheckIcon, ChevronRightIcon, ClockIcon, CornerDownRightIcon, FileTextIcon, GitCompareArrowsIcon, PencilLineIcon, RotateCwIcon, SearchIcon, SendHorizontalIcon, TerminalIcon, XIcon } from "lucide-react";
import { CheckIcon, ChevronRightIcon, ClockIcon, CornerDownRightIcon, FileTextIcon, GitCompareArrowsIcon, PencilLineIcon, RotateCwIcon, SearchIcon, SendHorizontalIcon, SparklesIcon, TerminalIcon, XIcon } from "lucide-react";
import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";

import { PixelGrid, PixelLoader } from "@/components/pixel-loader";
Expand Down Expand Up @@ -428,18 +428,19 @@ function foldTurns(items: TranscriptItem[], c: FoldCache = newFoldCache()): Bloc
}

function workSummary(steps: TranscriptItem[]): string {
let run = 0, read = 0, search = 0, other = 0, helpers = 0;
let run = 0, read = 0, search = 0, other = 0, helpers = 0, skills = 0;
for (const s of steps) {
if (s.kind === "tools") {
const n = s.items?.length ?? 0;
if (s.verb === "Run") run += n;
else if (s.verb === "Read") read += n;
else if (s.verb === "Search") search += n;
else if (s.verb === "Skill") skills += n;
else other += n;
} else if (s.kind === "crew") helpers += s.names?.length ?? 1;
}
const n = (k: number, one: string, many: string) => (k ? [`${k === 1 ? one.replace("#", "1") : many.replace("#", String(k))}`] : []);
const parts = [...n(run, "ran # command", "ran # commands"), ...n(read, "read # file", "read # files"), ...n(search, "searched # time", "searched # times"), ...n(helpers, "started # helper", "started # helpers"), ...n(other, "used # tool", "used # tools")];
const parts = [...n(run, "ran # command", "ran # commands"), ...n(read, "read # file", "read # files"), ...n(search, "searched # time", "searched # times"), ...n(helpers, "started # helper", "started # helpers"), ...n(skills, "used # skill", "used # skills"), ...n(other, "used # tool", "used # tools")];
return parts.join(", ");
}

Expand Down Expand Up @@ -777,8 +778,8 @@ function Tools({ it, edits }: { it: Extract<TranscriptItem, { kind: "tools" }>;
const t = window.setTimeout(() => setOpen(false), 1600);
return () => window.clearTimeout(t);
}, [it.done]);
const Icon = it.verb === "Search" ? SearchIcon : it.verb === "Run" ? TerminalIcon : FileTextIcon;
const doing = it.verb === "Read" ? "Reading" : it.verb === "Search" ? "Searching" : "Running";
const Icon = it.verb === "Search" ? SearchIcon : it.verb === "Run" ? TerminalIcon : it.verb === "Skill" ? SparklesIcon : FileTextIcon;
const doing = it.verb === "Read" ? "Reading" : it.verb === "Search" ? "Searching" : it.verb === "Skill" ? "Loading a skill" : "Running";
return (
<div className="cv-in -my-1">
<button type="button" onClick={() => setOpen((o) => !o)} aria-expanded={open} className="-ml-1 inline-flex items-center gap-1.5 rounded-md px-1 py-0.5 text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring">
Expand Down Expand Up @@ -897,7 +898,7 @@ function Thinking({ since, label, elapsed, meta, step }: { since: number; label?
}, []);
const s = Math.max(0, Math.round((now - since) / 1000));
const took = elapsed && !step ? elapsed : s >= 60 ? `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s` : `${s}s`;
const doing = step ? (step.verb === "Read" ? "Reading" : step.verb === "Search" ? "Searching" : step.verb === "Edit" ? "Editing" : "Running") : undefined;
const doing = step ? (step.verb === "Read" ? "Reading" : step.verb === "Search" ? "Searching" : step.verb === "Edit" ? "Editing" : step.verb === "Skill" ? "Loading skill" : "Running") : undefined;
const word = label ?? `${HARBOUR_WORDS[Math.floor(now / 4000) % HARBOUR_WORDS.length]}…`;
return (
<div className="cv-in flex min-w-0 items-center gap-2">
Expand Down
104 changes: 104 additions & 0 deletions app/src/components/conversation/resume-picker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { HistoryIcon } from "lucide-react";
import { useEffect, useState } from "react";

import { AgentIcon } from "@/components/agent-glyph";
import { Command, CommandCollection, CommandDialog, CommandDialogPopup, CommandEmpty, CommandFooter, CommandGroup, CommandGroupLabel, CommandInput, CommandItem, CommandList, CommandPanel } from "@/components/ui/command";
import { Kbd } from "@/components/ui/kbd";
import { Spinner } from "@/components/ui/spinner";
import { toastManager } from "@/components/ui/toast";
import { boxApi, type PastSession } from "@/lib/api";
import { plainError } from "@/lib/errors";
import { ago, errorMessage } from "@/lib/format";
import { scheduleRefresh, useStore } from "@/lib/store";

// A structured chat's agent has no screen for its own /resume picker, so
// the chat draws one: the agent's past conversations in this folder (those
// had in its terminal too, for Claude Code), newest first. Picking one
// carries it on in this pane; the conversation it leaves stays in the
// worktree's "Resume a conversation".

interface Item {
value: string;
past: PastSession;
label: string;
}

export function ResumePicker({ box, session, who, query: initial, onClose }: { box: string; session: string; who: string; query: string; onClose(): void }) {
const client = useStore((s) => s.client);
const [list, setList] = useState<{ agent: string; items: Item[]; note?: string }>();
const [error, setError] = useState<string>();
const [query, setQuery] = useState(initial);
useEffect(() => {
if (!client) return;
let alive = true;
boxApi.pastSessions(client, box, session).then(
(r) => {
if (!alive) return;
const items = r.sessions.map((p) => ({ value: p.id, past: p, label: p.title || `Untitled ${p.id.slice(0, 8)}` }));
setList({ agent: r.agent, items, note: r.note });
},
(err) => alive && setError(plainError(err)),
);
return () => {
alive = false;
};
}, [client, box, session]);

const pick = async (p: PastSession) => {
onClose();
if (!client || p.current) return;
try {
await boxApi.resumeSession(client, box, session, p.id);
scheduleRefresh(box, ["sessions"]);
} catch (err) {
toastManager.add({ type: "error", title: "Couldn't resume it", description: errorMessage(err) });
}
};

const items = list?.items ?? [];
const groups = [{ value: "past", items }];
const empty = error ?? list?.note ?? (list ? `${who} has no past conversations in this folder.` : undefined);
return (
<CommandDialog open onOpenChange={(o) => !o && onClose()}>
<CommandDialogPopup aria-label={`Resume one of ${who}'s conversations`}>
<Command items={groups} value={query} onValueChange={setQuery} itemToStringValue={(i: unknown) => `${(i as Item).label} ${(i as Item).past.id}`}>
<CommandInput placeholder={`Resume one of ${who}'s conversations…`} />
<CommandPanel>
{!list && !error ? (
<div className="flex items-center gap-2 px-3 py-3 text-muted-foreground text-sm">
<Spinner className="size-3.5" /> Reading {who}'s conversations…
</div>
) : (
<CommandEmpty>{items.length ? "No conversation matches." : empty}</CommandEmpty>
)}
<CommandList>
{(group: (typeof groups)[number]) => (
<CommandGroup key={group.value} items={group.items}>
{group.items.length > 0 && <CommandGroupLabel>In this folder, newest first</CommandGroupLabel>}
<CommandCollection>
{(item: Item) => (
<CommandItem key={item.value} value={item} disabled={!!item.past.session} className="gap-2" onClick={() => void pick(item.past)}>
<AgentIcon agent={list?.agent} className="size-3.5 shrink-0" />
<span className="min-w-0 truncate">{item.label}</span>
{item.past.current && <span className="shrink-0 rounded bg-accent px-1 py-px text-[10px] text-muted-foreground">this chat</span>}
<span className="ml-auto min-w-0 shrink-0 truncate text-muted-foreground text-xs tabular-nums">{item.past.session ? `running in ${item.past.session}` : item.past.updated ? ago(item.past.updated) : ""}</span>
</CommandItem>
)}
</CommandCollection>
</CommandGroup>
)}
</CommandList>
</CommandPanel>
<CommandFooter className="text-[11px] text-muted-foreground">
<span className="flex items-center gap-1">
<Kbd>↵</Kbd> carry it on here
</span>
<span className="flex items-center gap-1">
<HistoryIcon className="size-3" /> This chat's conversation stays resumable <Kbd>esc</Kbd>
</span>
</CommandFooter>
</Command>
</CommandDialogPopup>
</CommandDialog>
);
}
10 changes: 8 additions & 2 deletions app/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type {
WaitResult,
WorktreeService,
Location,
PastSessions,
PluginInfo,
Service,
Session,
Expand Down Expand Up @@ -280,8 +281,13 @@ export const boxApi = {
// Carries a conversation on: in its session, started again if it ended,
// or in a new one when that is gone.
resumeConversation: (c: Client, box: string, id: string) => c.box<Session>(box, "POST", `conversations/${encodeURIComponent(id)}/resume`),
// Starts a structured session whose agent ended again, on its own record.
resumeSession: (c: Client, box: string, name: string) => c.box<Session>(box, "POST", `sessions/${encodeURIComponent(name)}/resume`),
// Starts a structured session whose agent ended again, on its own record;
// given one of its agent's past conversations (pastSessions), carries
// that one on in the session instead.
resumeSession: (c: Client, box: string, name: string, agentSessionId?: string) =>
c.box<Session>(box, "POST", `sessions/${encodeURIComponent(name)}/resume`, agentSessionId ? { agent_session_id: agentSessionId } : undefined),
// The agent's own past conversations in a structured session's folder.
pastSessions: (c: Client, box: string, name: string) => c.box<PastSessions>(box, "GET", `sessions/${encodeURIComponent(name)}/past`),
// Takes a structured session to Claude Code's own terminal, or back to its
// chat (Session.switch).
switchSession: (c: Client, box: string, name: string, to: "terminal" | "chat") =>
Expand Down
16 changes: 15 additions & 1 deletion app/src/lib/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export interface AgentCommand {
local?: boolean;
// Opens the agent's own picker or dialog.
screen?: boolean;
// Answered by the app itself, never sent: a structured chat's /resume.
app?: boolean;
source?: string;
}

Expand Down Expand Up @@ -55,7 +57,8 @@ export function useCommandCatalog(box: string | undefined, session: string | und
useEffect(() => {
if (!box || !session) return;
if (mock) {
setCat(MOCK_CATALOG);
const structured = !!useStore.getState().boxes[box]?.sessions?.find((s) => s.name === session)?.driver;
setCat(structured ? withResumePicker(MOCK_CATALOG) : MOCK_CATALOG);
return;
}
if (!client || !supported) {
Expand Down Expand Up @@ -111,6 +114,17 @@ export function commandIn(cat: CommandCatalog | undefined, text: string): AgentC
return cat.commands.find((c) => c.name === name || c.aliases?.includes(name));
}

// canPickPast says whether a box lists a structured session's past
// conversations (berthd new enough).
export const canPickPast = (box: string) => !!useStore.getState().boxes[box]?.info?.capabilities?.includes("sessions.past");

// withResumePicker is a structured chat's catalogue, as the box gives it
// (internal/box/commands.go): its /resume is the app's picker.
function withResumePicker(cat: CommandCatalog): CommandCatalog {
const resume: AgentCommand = { name: "/resume", description: "Pick a past conversation and carry it on here", kind: "builtin", args: "[conversation]", local: true, app: true, aliases: ["/continue"] };
return { ...cat, commands: [resume, ...cat.commands.filter((c) => c.name !== "/resume")] };
}

export const KIND_LABEL: Record<CommandKind, string> = { builtin: "Built-in", custom: "Custom", skill: "Skill", plugin: "Plugin", mcp: "MCP" };

// The demo's agent, with a few of each kind.
Expand Down
Loading
Loading