diff --git a/app/e2e/resume-picker.spec.ts b/app/e2e/resume-picker.spec.ts new file mode 100644 index 00000000..645b36ce --- /dev/null +++ b/app/e2e/resume-picker.spec.ts @@ -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(); +}); diff --git a/app/src/components/conversation/command-menu.tsx b/app/src/components/conversation/command-menu.tsx index 1226f8c7..d40d0038 100644 --- a/app/src/components/conversation/command-menu.tsx +++ b/app/src/components/conversation/command-menu.tsx @@ -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"; @@ -286,7 +286,10 @@ function PromptChip({ text, command, prefixes, who, side }: { text: string; comm words = prefixes?.["#"] ?? "Saves to memory"; icon = #; } else if (command) { - if (command.screen) { + if (command.app) { + icon = ; + words = `${command.name} opens a list of ${who}'s past conversations to carry on here`; + } else if (command.screen) { icon = ; 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`; diff --git a/app/src/components/conversation/conversation-pane.tsx b/app/src/components/conversation/conversation-pane.tsx index 076c6413..755c467a 100644 --- a/app/src/components/conversation/conversation-pane.tsx +++ b/app/src/components/conversation/conversation-pane.tsx @@ -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"; @@ -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"; @@ -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(); 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)); @@ -527,9 +538,15 @@ export function ConversationPane({ box, session, agent: remembered, visible, onS ); } + const picker = resuming !== undefined && setResuming(undefined)} />; if (!shown.length && (mock || feed === "ready" || feed === "none") && state !== "running" && state !== "waiting" && !live.show) { const wt = s ? worktreeOf(locations, s) : undefined; - return ; + return ( + <> + + {picker} + + ); } return ( @@ -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} ); } diff --git a/app/src/components/conversation/conversation-view.tsx b/app/src/components/conversation/conversation-view.tsx index b5b44964..b97b39e6 100644 --- a/app/src/components/conversation/conversation-view.tsx +++ b/app/src/components/conversation/conversation-view.tsx @@ -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"; @@ -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(", "); } @@ -777,8 +778,8 @@ function Tools({ it, edits }: { it: Extract; 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 (