diff --git a/client/src/components/HtmlViewerModal.tsx b/client/src/components/HtmlViewerModal.tsx new file mode 100644 index 00000000..e96a1245 --- /dev/null +++ b/client/src/components/HtmlViewerModal.tsx @@ -0,0 +1,109 @@ +/** + * @file HtmlViewerModal.tsx + * @description An HTML file shown as the page in a modal over the whole + * screen — the one viewer the File Explorer and the Changes panel's Preview + * open (the Chat's file preview opens its own panel as a modal instead, for + * its versions and Code tab). The page itself is `HtmlFrame`: the chat + * artifact's sandbox, its relative files read through the caller's guarded + * `fetchFile`. Keys and clicks stop at the modal, so a shortcut of the view + * underneath (the explorer's Delete) never fires from inside it. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { useRef, type ReactNode } from "react"; +import { createPortal } from "react-dom"; +import { useTranslation } from "react-i18next"; +import { Download, X } from "lucide-react"; +import { useDialog } from "../hooks/useDialog"; +import { HtmlFrame } from "./HtmlFrame"; +import type { FetchFile } from "../lib/htmlInline"; + +export function HtmlViewerModal({ + path, + html, + fetchFile, + onDownload, + onClose, + testId = "html-viewer", +}: { + /** The file's path relative to the place `fetchFile` reads from; also its title. */ + path: string; + /** The page's text; read through `fetchFile` when absent. */ + html?: string; + fetchFile?: FetchFile | null; + onDownload?: () => void; + onClose: () => void; + testId?: string; +}) { + const { t } = useTranslation(["lanes"]); + const panelRef = useRef(null); + useDialog(true, panelRef, onClose); + const name = path.slice(path.lastIndexOf("/") + 1); + return createPortal( +
{ + e.stopPropagation(); + onClose(); + }} + onKeyDown={(e) => { + // Nothing reaches the view underneath — so Esc, which useDialog's + // document listener would never see, closes here. + e.stopPropagation(); + if (e.key === "Escape") onClose(); + }} + role="presentation" + > +
e.stopPropagation()} + > +
+ + {path} + + {onDownload && ( + + + + )} + + + +
+ +
+
, + document.body + ); +} + +function HeadButton({ + label, + onClick, + children, +}: { + label: string; + onClick: () => void; + children: ReactNode; +}) { + return ( + + ); +} diff --git a/client/src/components/lanes/FileExplorer.tsx b/client/src/components/lanes/FileExplorer.tsx index 975f68d5..153171d1 100644 --- a/client/src/components/lanes/FileExplorer.tsx +++ b/client/src/components/lanes/FileExplorer.tsx @@ -91,7 +91,8 @@ import { } from "../../lib/api"; import type { Lane } from "../../lib/types"; import { useDialog } from "../../hooks/useDialog"; -import { HtmlFrame, fetchVia } from "../HtmlFrame"; +import { fetchVia } from "../HtmlFrame"; +import { HtmlViewerModal } from "../HtmlViewerModal"; import { toast } from "../../lib/toast"; import { confirmDialog } from "../../lib/confirm"; import { formatBytes } from "../../lib/format"; @@ -1935,10 +1936,8 @@ export function FileExplorer({ ); } -/** An HTML file shown as the page, over the explorer: its relative CSS, - * scripts and images read through the same download route (`HtmlFrame`). - * Keys and clicks stop here — through the portal they would otherwise reach - * the explorer's own shortcuts (Delete would trash the selection). */ +/** An HTML file as the page, in the shared modal viewer (`HtmlViewerModal`): + * its relative CSS, scripts and images read through the same download route. */ function HtmlViewer({ entry, place, @@ -1950,49 +1949,20 @@ function HtmlViewer({ onDownload: () => void; onClose: () => void; }) { - const { t } = useTranslation(["lanes"]); - const panelRef = useRef(null); - useDialog(true, panelRef, onClose); const placeKey = JSON.stringify(place); const fetchFile = useMemo( () => fetchVia((p) => api.files.downloadUrl(place, p)), // eslint-disable-next-line react-hooks/exhaustive-deps [placeKey] ); - return createPortal( -
{ - e.stopPropagation(); - onClose(); - }} - onKeyDown={(e) => e.stopPropagation()} - role="presentation" - > -
e.stopPropagation()} - > -
- - {entry.path} - - - - - - - -
- -
-
, - document.body + return ( + ); } diff --git a/client/src/components/lanes/LaneChangesPanel.tsx b/client/src/components/lanes/LaneChangesPanel.tsx index 2f86f96e..ff07aa7e 100644 --- a/client/src/components/lanes/LaneChangesPanel.tsx +++ b/client/src/components/lanes/LaneChangesPanel.tsx @@ -14,9 +14,9 @@ * is unsaved): Changes folds the unchanged lines away, Whole file * shows them; any other file opens on its read-only diff; a * whole markdown file can also be read rendered (Preview), an HTML - * file shown as the page by Preview beside Whole file - * (`HtmlFrame`, its relative CSS, scripts and images read like - * downloads), and an + * file's Preview beside Whole file opens the page in a modal + * (`HtmlViewerModal`, its relative CSS, scripts and images read + * like downloads), and an * image file is shown as the picture (`GET …/git/raw`) * history — recent commits; opening one shows that commit's diff * browse — quick-open by path, or the repo's tracked file tree (the @@ -139,7 +139,8 @@ import { LaneConflictView } from "./LaneConflictView"; import { FileExplorer } from "./FileExplorer"; import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu"; import { MarkdownPreview, isMarkdown } from "../MarkdownPreview"; -import { HtmlFrame, fetchVia } from "../HtmlFrame"; +import { fetchVia } from "../HtmlFrame"; +import { HtmlViewerModal } from "../HtmlViewerModal"; import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader"; import { changeTree, treeFiles, type ChangeTreeDir } from "../../lib/changeTree"; import { dragProjectPath } from "../../lib/promptAttach"; @@ -198,7 +199,7 @@ function Delta({ ); } -/** An HTML page, which the viewer can show rendered (`HtmlFrame`). */ +/** An HTML page, which Preview opens as the page (`HtmlViewerModal`). */ const isHtml = (p: string | null | undefined) => !!p && /\.html?$/i.test(p); /** What the viewer shows as a picture (`readImageAt`'s list on the server). */ @@ -890,11 +891,13 @@ export function LaneChangesPanel({ const [layout, setLayout] = useState(wide ? "split" : "unified"); useEffect(() => setLayout(wide ? "split" : "unified"), [wide]); const [whole, setWhole] = useState(false); - // A whole markdown or HTML file shown rendered instead of as source; per - // file, markdown opening as Settings → Appearance says - // (`lib/markdownView.ts`), an HTML page only once Preview is clicked. + // A whole markdown file shown rendered instead of as source; per file, + // opening as Settings → Appearance says (`lib/markdownView.ts`). const [mdPreview, setMdPreview] = useState(readMarkdownPreview); - useEffect(() => setMdPreview(!isHtml(openFile) && readMarkdownPreview()), [openFile]); + useEffect(() => setMdPreview(readMarkdownPreview()), [openFile]); + // An HTML file's page, open in the modal viewer (Preview beside Whole file). + const [htmlOpen, setHtmlOpen] = useState(false); + useEffect(() => setHtmlOpen(false), [openFile, mode]); const [data, setData] = useState(null); // A folder that is not a repo (an empty new workspace, a parent of clones) // has no changes, review or history — only its files, which Files reads @@ -2059,10 +2062,8 @@ export function LaneChangesPanel({ }); const dirty = draft !== null && draft !== text?.lines.join("\n"); - // Rendered only where the toggle is offered: a whole markdown or HTML file. - const mdShown = mdPreview && whole && (isMarkdown(openFile) || isHtml(openFile)); - // The HTML page is showing (the Preview view of the Changes / Whole file / Preview group). - const htmlShown = mdShown && isHtml(openFile); + // Rendered only where the toggle is offered: a whole markdown file. + const mdShown = mdPreview && whole && isMarkdown(openFile); // An HTML page's relative CSS/scripts/images, read like a download — only // from the working tree: a commit's page would get today's files. const htmlFetch = useMemo( @@ -2562,11 +2563,8 @@ export function LaneChangesPanel({ ))} - {/* An HTML file's third view: the page itself. */} - {isHtml(openFile) && ( - - )} )} + {/* An HTML file opens as the page in a modal over the screen; + a commit's page once its text is read (Whole file). */} + {mode === "file" && isHtml(openFile) && ( + + )} + {htmlOpen && openFile && ( + download(openFile) : undefined} + onClose={() => setHtmlOpen(false)} + /> + )} )} @@ -3164,17 +3167,7 @@ export function LaneChangesPanel({ ) ) : whole ? ( - text && mdShown && isHtml(openFile) ? ( - - ) : text && mdShown ? ( + text && mdShown ? (
diff --git a/client/src/components/lanes/__tests__/LaneChangesPanel.test.tsx b/client/src/components/lanes/__tests__/LaneChangesPanel.test.tsx index f207a4fb..3e7727b0 100644 --- a/client/src/components/lanes/__tests__/LaneChangesPanel.test.tsx +++ b/client/src/components/lanes/__tests__/LaneChangesPanel.test.tsx @@ -544,7 +544,7 @@ describe("LaneChangesPanel", () => { expect(screen.queryByTestId("lane-file-editor")).toBeNull(); }); - it("opens a whole HTML file as its source, the page after Preview, the source after Whole file", async () => { + it("opens a whole HTML file as its source, the page in a modal after Preview", async () => { vi.mocked(api.lanes.gitTree).mockResolvedValue({ available: true, path: "", @@ -560,22 +560,31 @@ describe("LaneChangesPanel", () => { lines: ["

deck

"], sha: "c".repeat(40), }); - const user = userEvent.setup(); - render(); - const sidebar = await screen.findByTestId("lane-changes-sidebar"); - await user.click(await within(sidebar).findByText("deck.html")); - expect(await screen.findByTestId("lane-file-editor")).toHaveValue("

deck

"); - expect(screen.queryByTestId("html-frame")).toBeNull(); - await user.click(screen.getByTestId("lane-file-preview")); - const frame = await screen.findByTestId("html-frame"); - expect(frame.getAttribute("srcdoc")).toContain("

deck

"); - expect(screen.getByRole("button", { name: "Preview", pressed: true })).toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: "Whole file", pressed: false })); - expect(await screen.findByTestId("lane-file-editor")).toHaveValue("

deck

"); - expect(screen.queryByTestId("html-frame")).toBeNull(); + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(new Response("

deck

", { status: 200 })); + try { + const user = userEvent.setup(); + render(); + const sidebar = await screen.findByTestId("lane-changes-sidebar"); + await user.click(await within(sidebar).findByText("deck.html")); + expect(await screen.findByTestId("lane-file-editor")).toHaveValue("

deck

"); + expect(screen.queryByTestId("html-frame")).toBeNull(); + await user.click(screen.getByTestId("lane-file-preview")); + const modal = await screen.findByTestId("html-viewer"); + const frame = await within(modal).findByTestId("html-frame"); + expect(frame.getAttribute("srcdoc")).toContain("

deck

"); + expect(frame.getAttribute("sandbox")).not.toContain("allow-same-origin"); + await user.keyboard("{Escape}"); + await waitFor(() => expect(screen.queryByTestId("html-viewer")).toBeNull()); + // The source stayed where it was, under the modal. + expect(screen.getByTestId("lane-file-editor")).toHaveValue("

deck

"); + } finally { + fetchSpy.mockRestore(); + } }); - it("opens a changed HTML file on its Changes, the page only after Preview", async () => { + it("offers Preview on a changed HTML file's Changes, opening the page in a modal", async () => { vi.mocked(api.lanes.changes).mockResolvedValue( changes({ files: [ @@ -583,28 +592,21 @@ describe("LaneChangesPanel", () => { ], }) ); - vi.mocked(api.lanes.gitFile).mockResolvedValue({ - available: true, - path: "deck.html", - rev: "work", - binary: false, - truncated: false, - lines: ["

deck

"], - sha: "c".repeat(40), - }); - const user = userEvent.setup(); - render(); - await user.click(await screen.findByTitle("deck.html")); - await screen.findByRole("button", { name: "Changes", pressed: true }); - expect(screen.queryByTestId("html-frame")).toBeNull(); - await user.click(await screen.findByTestId("lane-file-preview")); - const frame = await screen.findByTestId("html-frame"); - expect(frame.getAttribute("srcdoc")).toContain("

deck

"); - expect(frame.getAttribute("sandbox")).not.toContain("allow-same-origin"); - // Back to Changes: the diff again, not the page. - await user.click(screen.getByRole("button", { name: "Changes", pressed: false })); - await screen.findByRole("button", { name: "Changes", pressed: true }); - await waitFor(() => expect(screen.queryByTestId("html-frame")).toBeNull()); + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(new Response("

deck

", { status: 200 })); + try { + const user = userEvent.setup(); + render(); + await user.click(await screen.findByTitle("deck.html")); + await screen.findByRole("button", { name: "Changes", pressed: true }); + await user.click(await screen.findByTestId("lane-file-preview")); + const modal = await screen.findByTestId("html-viewer"); + expect(await within(modal).findByTestId("html-frame")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Changes", pressed: true })).toBeInTheDocument(); + } finally { + fetchSpy.mockRestore(); + } }); it("shows an image file as the picture, with no edit control", async () => { diff --git a/client/src/components/run/ChatFilePreview.tsx b/client/src/components/run/ChatFilePreview.tsx index 25c991b6..571b569d 100644 --- a/client/src/components/run/ChatFilePreview.tsx +++ b/client/src/components/run/ChatFilePreview.tsx @@ -12,6 +12,8 @@ * text file it read, wrote or named (a generated report) anywhere else through * `GET /api/sessions/:id/file`. * Anything else (or a file those routes refuse) shows its path only. A Windows path's `\` counts as `/` throughout. + * `FilePreview` with `modal` (an HTML page, from PaneChat) is the same view in + * a modal over the whole screen. * `SkillListModal` is the same dialog over the session's skills, each one's * SKILL.md found by name (`plugin:name` from the plugin list, else the user's * or the project's); a built-in skill has no file and shows by name only. @@ -357,6 +359,7 @@ export function FilePreview({ onClose, className = "", messages, + modal = false, }: { path: string; scope: PreviewScope; @@ -364,12 +367,16 @@ export function FilePreview({ className?: string; /** The conversation, for the versions its Write/Edit calls left. */ messages?: TranscriptMessage[]; + /** A modal over the whole screen (backdrop, Esc closes) instead of a panel. */ + modal?: boolean; }) { const { t } = useTranslation("run"); const kind = artifactKind(path); const loaded = useContent(path, scope); const [view, setView] = useState(() => defaultView(path)); const [full, setFull] = useState(false); + const modalRef = useRef(null); + useDialog(modal, modalRef, onClose); const [sharing, setSharing] = useState(false); const versions = useMemo( () => (messages ? artifactVersions(messages, path) : []), @@ -424,7 +431,9 @@ export function FilePreview({
)} - + {!modal && ( + + )}
); + if (modal) { + return createPortal( +
{ + // Nothing reaches the view underneath — so Esc, which useDialog's + // document listener would never see, closes here. + e.stopPropagation(); + if (e.key === "Escape") onClose(); + }} + role="presentation" + > +
e.stopPropagation()} + > + {panel} +
+
, + document.body + ); + } // Full screen: over the whole page, where a pane's edges cannot clip it. return full ? createPortal(panel, document.body) : panel; } diff --git a/client/src/components/run/PaneChat.tsx b/client/src/components/run/PaneChat.tsx index bb2bb9d2..066fa304 100644 --- a/client/src/components/run/PaneChat.tsx +++ b/client/src/components/run/PaneChat.tsx @@ -35,7 +35,9 @@ * of files or the used skills a list with a preview (`FileListModal`, * `SkillListModal`). Anything there drags into the prompt box. A file in a turn's * "Edited N files" card opens in the Workspace's Changes panel - * (`openInChanges`) when it lies in the pane's folder. The unsent prompt and + * (`openInChanges`) when it lies in the pane's folder. An HTML file — a card, + * an Outputs row, a path in a reply — opens instead as the page, the same + * preview in a modal over the whole screen. The unsent prompt and * its attachments are kept per session (`lib/chatDraft.ts`), so a reload * leaves them as typed; switching to the terminal pastes them into its input * box and back brings that box's text here (`ChatToggle`). A send scrolls the @@ -582,6 +584,12 @@ export const PaneChat = memo(function PaneChat({ setPreview(null); }; const [preview, setPreview] = useState(null); + // An HTML file shown as the page, in a modal over the screen. + const [htmlView, setHtmlView] = useState(null); + const show = useCallback( + (path: string) => (/\.html?$/i.test(path) ? setHtmlView(path) : setPreview(path)), + [] + ); // The side panel's width as a fraction of the pane: the dragged one, else // a default per content (a preview wants more room than the panel). const [dragged, setDragged] = useState(storedSide); @@ -757,20 +765,21 @@ export const PaneChat = memo(function PaneChat({ // not the workspace's Changes panel a plain file link goes to. const openArtifact = useCallback( (named: string) => { - setPreview(resolveIn(cwd, named)); + show(resolveIn(cwd, named)); }, - [cwd] + [cwd, show] ); const openFile = useCallback( (named: string) => { // A relative path in a reply (`server/lib/x.js`) is relative to this pane's folder. const path = resolveIn(cwd, named); + if (/\.html?$/i.test(path)) return show(path); // The workspace's Changes panel when the file is in its folder; // else this view's own preview. if (laneId !== null && openInChanges(laneId, cwd, path)) return; setPreview(path); }, - [laneId, cwd] + [laneId, cwd, show] ); // Before the session starts the CLI may be asking in the terminal (trust @@ -984,7 +993,7 @@ export const PaneChat = memo(function PaneChat({ openOutline(false)} - onOpenFile={setPreview} + onOpenFile={show} onOpenList={setList} /> ) @@ -1006,6 +1015,15 @@ export const PaneChat = memo(function PaneChat({ /> )} + {htmlView && ( + setHtmlView(null)} + modal + /> + )} {list === "skills" && ( { cleanup(); }); + it("shows a page as a modal over the screen, closed by Esc", async () => { + const onClose = vi.fn(); + render(); + const dialog = await screen.findByRole("dialog", { name: "page.html" }); + expect(dialog.parentElement?.parentElement).toBe(document.body); + expect(await screen.findByTestId("artifact-frame")).toBeInTheDocument(); + expect(screen.queryByTestId("artifact-expand")).toBeNull(); + fireEvent.keyDown(screen.getByTestId("artifact-tab-code"), { key: "Escape" }); + expect(onClose).toHaveBeenCalled(); + }); + it("steps back to an older version the conversation wrote", async () => { html(["

two

"]); const tool = (id: string, input: Record, name = "Write") => ({ diff --git a/docs/LANES.md b/docs/LANES.md index f0abb8b0..46624381 100644 --- a/docs/LANES.md +++ b/docs/LANES.md @@ -397,7 +397,7 @@ The dashboard web UI merges workspaces and runs into a single **Workspace** page - **Stop archives, Sleep keeps** — every UI path that ends a live pane goes through `endRuns` (`client/src/lib/stopSession.ts`). **Stop** — a tab's **×**, a pane header's **×**, **Close tab** in a right-click menu — kills the pane AND archives the session it held (`DELETE /api/run/:id?archive=1`, which sets `sessions.archived_at` server-side and broadcasts `session_updated`), so a session the user closed leaves the workspace's list (the home's archived fold brings it back with **Unarchive**). It only hides: a session worktree is NOT removed here, unlike the row's own Archive button. **Sleep** — the moon left of each × (AI panes only), and **Sleep** in the tab, pane and session right-click menus — kills the pane but keeps the session listed, so it can be opened and resumed later by sending it a message. Both ask first for an AI pane (it may be mid-turn); a shell closes at once. A pane that dies on its own (exit, crash, reboot) is not archived, and the CLI's `DELETE` without `archive` keeps its old behaviour. - **Right-click menus** — every item that has actions opens them on a right-click too, in one shell (`client/src/components/ContextMenu.tsx`: placed at the pointer, kept inside the viewport, closed by Escape, a click outside or an item). The buttons stay; the menu repeats what they do and nothing more. A session row — in the sidebar, the home's Recents and the archived fold (`lanes/SessionMenu.tsx`) — offers Open, Open in split (live), Rename and Archive (sidebar), Unarchive (archived), Session details, Copy session ID, Sleep and Close tab (live); the sidebar's **New session** row Open, Open in split, Sleep and Close tab. A console tab offers Open, Open in split, Session details, Copy session ID, Sleep and Close tab; a pane header (split view) Show here, Split right/down with a new terminal, Close pane, Sleep and Stop session. Sleep is offered only for an AI pane (a shell holds no session). In the Changes panel a changed file (overview row or review header) offers Open, Stage/Unstage, Discard — two clicks, as on its trash button — or the conflict's Keep mine / Take theirs / Mark resolved, and Copy relative path; a Files tree entry Open and Copy relative path; a History commit View commit, Copy commit SHA and Copy commit message. The home's title opens the row menu, and a Context file or extra folder offers Copy path and Remove. Outside the workspace the same shell serves the Sessions table, the Dashboard's agent cards and recent events, the Activity feed and Workflow runs (`components/SessionRowMenu.tsx`: Open session details, Open in its workspace when it has one, Copy session ID, Copy folder path), an attached prompt image (View / Remove), the managed workspace card (Reset), every Settings list (Git credentials, AI Proxy, Pricing, alert rules, webhooks, remote sources, AI tools) and every Claude Code / Codex config list (skills, agents, commands, output styles and memory files via `FileItemMenu` in `components/config/MdItemList.tsx`; plugins, MCP servers, marketplaces, auth profiles), an Artifacts card (Details, its buttons' actions, plus Copy link for a page — also on its `⋯` button) and a Users row (Edit and Open home in Files for an admin, Copy username / email). In the File Explorer (`FileExplorer.tsx`), besides an item and the empty area: a sidebar workspace (Open, Copy path) or home (Open, Open Trash), a path-bar crumb (Open, Download as zip, Paste into this folder, Copy path), a Trash row (Restore, Copy path, Delete forever), the Trash's empty area (Refresh, Empty Trash) and an admin's per-user Trash folder (Open, Empty Trash — asked first, off for an empty one). A delete or remove there takes the button's own confirm — a modal, or two clicks. Text areas (the terminal, a conversation, a diff) keep the browser's own menu. The shell stops the menu's clicks from reaching the card it was opened from. - **Chat view of a live pane** — each SESSION of a Claude, Codex, omp, Cursor or Antigravity pane (`CHAT_TOOLS` in `client/src/lib/chatUi.ts`; the last three learn their session id from their pollers' `claimToolSessions`, and the transcript route reads omp's journal, Cursor's agent transcript and agy's `transcript_full.jsonl` step log) shows either a chat or its terminal, its own choice: the chat/terminal button left of the pane's split buttons (`ChatToggle` in `client/src/components/run/PaneGrid.tsx`) switches it, and the choice is kept in this browser's localStorage by session id (`agenthub.chatUi.sessions`, the newest 200; `useSessionChat` in `client/src/lib/chatUi.ts`). **Settings → Appearance → Chat UI** (`ChatUiSettings.tsx`, saved per ACCOUNT on the server: `GET/PUT /api/me/prefs`, key `agenthub.chatUi`) is only the default — written onto a session the first time it is shown, so changing it later leaves every session already seen as it is. The chat needs the session id, so a pane shows it only once its session is known (`client/src/components/run/LaneConsolePane.tsx`). Chat (`client/src/components/run/PaneChat.tsx`) shows the session's conversation the way Claude Desktop does — the shared `ConversationView` in its `fill` layout: one centred column, your messages as bubbles, the reply as plain prose, every run of tool calls folded into one line named by what it did, in Claude Desktop's fixed order ("Ran 3 commands, edited a file, used a tool ›", `workLabel`) — a thinking block that carries text is the model's narration (Claude Code ≥ 2.1.280 stores real thinking empty) and reads as prose between those lines, as Claude Desktop shows it — that opens onto a framed list of its steps — each titled by its own description, the call in a capped scroll box, its output one click further — an Agent/Task step shows instead one muted line — its status as an icon, then type · model · time · tokens · tool calls — and its report as prose, with **View conversation ›**, which puts that subagent's own conversation in the report's place as a thread hung under the step by a line on the left (a nested `ConversationView` in its `compact` layout: its task as the prompt bubble, every step, its reply; as tall as it is up to 24rem, then it scrolls) (`client/src/components/conversation/AgentDetails.tsx`; the agent is found by the result's `agent` facts, the `agentId:` it names or the `tool_use_id` of `GET /api/sessions/:id/transcripts`, and a background one is closed by its ``), as does a click on a line of the background-agents card, under that line — and a round ↓ back to the end — above a prompt box that grows with its text up to 40% of the screen, then scrolls. The conversation stays at its end while you are there: a new line, the running step, an image loading or the pane shown again after being hidden keeps the newest in sight (a `ResizeObserver` in `ConversationView`), until you scroll up; sending a message takes it back to the end. It lies OVER the terminal, which stays mounted and attached (the console never unmounts a terminal); a message is sent through that terminal as a bracketed paste and Enter (`submitToRun` in `client/src/lib/panePaste.ts`), exactly as if typed, so no new route or server write exists for it. On a Windows dashboard (psmux) the attach (`server/lib/pty-attach.js`) holds any input that follows a paste until 700 ms after it (`PASTE_HOLD_MS`): psmux delays a paste ~350 ms and wrote an Enter sent meanwhile INSIDE it (psmux#766), so the prompt sat in Claude's composer as `[Pasted text #1]` instead of being sent. While the session's main agent is `working` (its `UserPromptSubmit` hook seen, no `Stop` yet — `GET /api/agents?session_id=&status=working`, re-read on `agent_updated`) — or while the terminal itself shows the turn at work (its own busy line, `BUSY_LINE`/`readRunBusy` in `client/src/lib/panePaste.ts`: Claude's and Codex's `esc to interrupt`, agy's `Thinking… (… esc to cancel)`; for omp and Cursor, whose line is unknown, output drawn in the last 2 s), read every 700 ms while the pane is on screen, so a tool that sends no hooks gets it too —, an empty box shows **Stop** in Send's place, like Claude Desktop: it presses Esc in the pane (`interruptRun` in the same file), the key `claude` and `codex` stop a turn with; typing a message brings Send back (Claude Code queues it). Stop also ends the conversation's live line (`stopped` on `ConversationView`/`MessageList`) until the transcript gains a newer line: an Esc before the model wrote anything puts the prompt back in Claude Code's input box and writes no `[Request interrupted by user]` line, so the transcript still ends on the prompt and would read as **Thinking…** for 15 minutes (issue #74). An Esc pressed in the terminal itself is not seen by the chat. An image attached in the box reaches the transcript as its saved path — in a Claude pane as an `@` mention (`claudeMention` in `client/src/lib/promptAttach.ts`, which Claude reads at submit, an image as an image; a trailing space keeps its `@` suggester from taking the Enter), as is a file dropped or pasted on a Claude terminal and every file a new or resumed Claude session is started with: a bare pasted path became `[Image #n]` only when the TUI caught the paste in time, and stayed a plain `C:\…\drops\…png` on a Windows dashboard (psmux), while a path in the command-line prompt is never an image; the user's turn shows the image itself (`GET /api/run/drop/:name`) — any other attached file as a chip (name · EXT) that opens its preview like an output — and opens it full size over the whole page (`ImageLightbox`, rendered into `document.body`, so nothing stacked over a pane covers it). The box carries Claude Desktop's controls: a `+` menu (`ChatAddMenu.tsx`, also in the workspace home's prompt box) — Upload or add files; what already exists, attached like an upload (a chip, its path sent with the prompt): **Project files** (a search of the box's folder, folders too — `GET /api/run/files?dirs=1` — sent as `@path`), **Context files** (the workspace's own, `GET /api/lanes/:id/sources`, sent as `@`) and **Recent uploads** (files uploaded through a prompt box in the last day, kept in this browser's localStorage `agenthub.prompt.recentUploads` — the server prunes an upload after a day); a file or folder dragged onto anywhere in a prompt box (not only its text area) attaches the same way — from the Changes panel's Files tree, its search, a Changes-list row, or a workspace's Context files and Folder list; a row from an extra folder's section (`data-drag-root`) and a context file or folder carry their absolute path, since they lie outside the pane's folder (`client/src/lib/promptAttach.ts`); Skills (yours and the project's), Plugins → a plugin → the skills and commands it ships, each put at the front of the message as `/` / `/:`, and Manage links to the Claude config page — and, for a Claude pane, the Model/Effort chip, a change typed into the pane as `/model ` / `/effort `, and the permission mode beside `+` in the box; a Codex pane has **Permission mode** and **Model** buttons that type `/permissions` and `/model` — Codex's own numbered pickers (the model, then its reasoning level), answered on the menu card below, each row's description column read as its hint. A menu opened in a split pane (a `Select`, the Model chip) flips up or down by the room inside ITS pane (`data-menu-bounds` on the pane, `menuRoom` in `client/src/components/Select.tsx`), never the window's — a menu running past the pane's edge is painted over by the pane below. A running session changes mode only by Shift+Tab, so the server steps it and reads each step back off the pane (`POST /api/run/:id/permission-mode`, `server/lib/pane-mode.js`): it lands on exactly the mode picked, presses nothing while a menu hides the mode line, and returns to where it began when the mode is not in that session's cycle (Bypass only when the session was started with it). The mode belongs to that session only. A menu the CLI asks with — AskUserQuestion (one question or several, multiSelect too), a permission or trust prompt, Codex's approval, a first-start login menu — is answered from the chat: it is read off the terminal underneath every 700 ms while the pane is on screen (`parsePaneMenu`/`readRunMenu` in `client/src/lib/panePaste.ts`: two numbered rows or more from 1, the `❯`/`›` cursor on one of them, no `─` rule right above the first — a reply's numbered list has no cursor, a list typed into Claude's input box sits under its rule) and shown in the prompt box's place as a card of buttons (`PaneMenuCard` in `PaneChat.tsx`, with each option's description and a multiSelect box's state; the box is hidden with CSS, never unmounted, so its draft survives the question); a permission prompt — a menu with a **Yes…** and a **No…** row: Claude's tool approval, Codex's command approval, the folder-trust question — is laid out as its own card (`PermissionMenuCard`): the tool and its command (Claude draws them over a dashed `╌` rule above the question, read as the menu's `detail`), the CLI's own answers as buttons, and **Deny with a note**, which picks the "No, and tell … differently" row and then sends the note as the next message once the CLI is back at its prompt; a pick presses its digit in the pane (which picks it, or ticks its box), Claude's unnumbered **Submit** row — **Next** while more questions follow — is reached with the arrows and Enter; an AskUserQuestion that asks several questions shows its question tabs above the card, each ticked once answered, with ‹ › buttons that press ←/→ in the pane (`stepRunMenu`), **Cancel (Esc)** presses Esc, and the **Type something** row opens a field inside the card whose text, on Enter, is typed there as the answer (`answerRunMenu`: the arrows to that row, Ctrl+U over any text already in it, the text as a bracketed paste, Enter — none in a multiSelect, where Submit is still to pick). That row presses no key: a second press of its digit would type the digit into the CLI's own field. Once text is typed in that row its label is the text, so it is found as the last numbered row above **Chat about this**. Like Send, it only presses keys in the pane, so no new route exists for it. A menu with no numbers (the first-start theme picker) still needs the terminal. A live pane's chat ends, until its turn is over, with what the model is doing now and — like the terminal's status line — how long the turn has run since your prompt and the output tokens it has made so far, e.g. `Thinking… (2m 8s · ↑ 3.5K tokens)` (`workingOn` and `turnStats` in `MessageList.tsx`; Claude Code repeats a call's usage on each of its lines, so consecutive lines with the same prompt size count once), the words lit by a shimmer that sweeps a few times and rests (`.text-shimmer`, stopped under reduced motion) and the token count rolling up rather than jumping. It names every call of the turn still waiting for its result — the oldest first and **+N more** for the rest, a click listing them all, since Claude Code writes parallel calls on separate lines and their results arrive in any order — by its description (Claude Code's Bash `description`) or tool and target, with that call's own time once it has run 30 s; else **Thinking…**. A line with no new transcript line for 3 minutes turns amber (the step may be stuck). While the CLI asks you something — the main agent awaiting input on a `Notification` hook (a permission prompt; `awaiting_reason = 'notification'`, read with the Stop button's agent poll) — it reads **Waiting for your approval: ** in amber with an **Open terminal** button (`showTerminal` in `PaneGrid.tsx`, the chat/terminal toggle's own switch: the unsent prompt goes into the CLI's input box); the menu card above answers it from the chat. AskUserQuestion fires no `Notification`, so a menu read off the pane counts as waiting too — for this line and the hidden tab's **● Waiting**. Before the session has started — no session row yet, because Claude Code runs no hook until its folder is trusted — the chat says so instead of the empty-transcript text, with the same **Open terminal** button (`notStarted` in `ConversationView.tsx`), and Send and every control typed into the pane (`/model`, `/effort`) ask first (`confirmDialog`, **Send anyway**): an Enter there would answer the trust prompt, whose preselected choice is **No, exit** (issue #73). It asks rather than refuses because the row appears only with the session's first hook, and a hard lock would strand the chat if none came right after the answer. A turn that ended (`end_turn`) closes with **✓ Done in 2m 8s · ↑ 3.5K tokens** — no cost: pricing one turn would need a second cost formula. A subagent launched in the background returns at once (its result is "Async agent launched…"), so the turn ends while it still works: until a `` names its call, a live chat lists it under the conversation — its type, its description, how long it has run and, once its own transcript was read, the step it is on and its context size (↓, its newest prompt), like the terminal's footer (`backgroundAgents`/`BackgroundAgents`/`agentProgress` in `MessageList.tsx`; the tail is re-read every 4 s through the existing `GET /api/sessions/:id/transcript?agent_id=`, only the lines after those already read, and only while the card is on screen) — and the last turn reads **Replied in … · N agents still running** with a spinner instead of Done (issue #81). A launch older than 6 hours with no notification is taken as ended (a CLI that quit, a resumed old session). The prompt box shows how full the context is (`ContextMeter` in `PaneChat.tsx`): the newest reply's prompt size (input + cache) against the model's window — 200K for Claude's 4.5 line and older, 1M for every model since (`contextWindow`, from LiteLLM's list; none for another vendor) — amber from 75 %, red from 90 %, the numbers on hover. While the browser tab is hidden its title is marked **● Waiting** while a chat pane's session waits on you and **✓ Done** once a turn ended (`client/src/lib/tabBadge.ts`); showing the tab drops the Done mark. Thinking is shown (after your prompt, after a tool result, or after narration whose `stop_reason` is `tool_use`). The transcript route passes each assistant line's `stop_reason`; `end_turn` ends the turn, so does an interrupt, and a turn with no new line for 15 minutes and no call running is treated as dead. The chat re-reads the transcript every 3 s while the page is visible and the pane is on screen (`useOnScreen` in `client/src/hooks/useOnScreen.ts`: a pane of another workspace or tab stays mounted but `display:none`, and polls nothing until it is shown again, then catches up once), and on a `new_event` of its session at most every 1.5 s (`EVENT_FETCH_MS`), and within 300 ms of a `transcript_changed` of its session (`PUSH_FETCH_MS`: the server's projects-tree watcher saw that Claude transcript grow, `pushTranscript` in `server/index.js`, at most every 250 ms per session), and at most once a second while the terminal underneath draws (`notePaneOutput`/`onPaneOutput` in `client/src/lib/panePaste.ts`, fed by `TerminalView` — any tool, hooks or not) — so a reply appears as soon as Claude writes it, not at the next poll. The TUI draws no reply as it streams (it shows the spinner and a token count until the block is done), so the chat has no streaming text. The tail read (`?after=`) skips the lines the chat already has before parsing them. While the chat covers the terminal, the terminal is moved out of the viewport with a transform (`covered` on `TerminalView`), which stops xterm drawing without changing its size — the menu reading below still works. Each message's hover line shows ↓ the whole prompt it was answered from (input + cache read + cache write; the split on hover) and ↑ its output. An AskUserQuestion is not folded into the collapsed work line: it is its own card (`AskCard`) showing each question with the option the user picked or what they typed — the `answers` the transcript route reads off Claude Code's `toolUseResult` —, "declined" when they refused it. Each assistant turn that changed files ends, like Claude Desktop's, with an **Edited N files** card (`EditedFiles` in `client/src/components/conversation/MessageList.tsx`): one row per file with the lines it added and removed over the turn — the `diff` the transcript route reads off Claude Code's `structuredPatch` — and, in a live pane's chat, a click on a row opens that file in the workspace's Changes panel when it lies in the pane's folder, else its preview (beside the chat, or over it when the pane is narrow). It has no Undo: reverting a file is a write the dashboard does not take from a click. The conversation column and the prompt box are `max(48rem, 70%)` of the pane wide — never narrower than Claude's fixed 48rem, wider on a large screen. Every chat pane, split or not, has Claude Desktop's activity panel (`client/src/components/run/ChatOutline.tsx`, `GET /api/sessions/:id/outline`, re-read every 5 s while the tab is visible and the pane is on screen; the server keeps the last answer per session until its events change). It starts open in a pane that spans the grid's full width or height (`spanning` in `ConsoleArea`, `client/src/pages/Workspace.tsx`, from `layoutRects`) once the session has an output, and otherwise — no output yet, or a quarter-sized pane (e.g. four panes at once) — collapsed into a file button with the number of outputs at the chat's top right. The user's own open or close is kept per session for the browser tab (`sessionStorage` `agenthub.chat.panel.`), so switching the pane to its terminal and back keeps it; a split or a close that changes the pane's shape drops it and brings the default back. Whether it sits beside the conversation or floats over it is decided by the PANE's width, not the window's (a CSS container query on `PaneChat`'s root, 40rem): beside it, a splitter on its left edge (`Divider` from `PaneGrid.tsx`, arrow keys too) drags the panel — and a file preview in its place — between 20% and 70% of the pane, remembered per browser (`localStorage` `agenthub.chat.panelWidth`, one fraction for every pane). Open, it shows **Progress** — the session's `TaskCreate`/`TaskUpdate` (or `TodoWrite`) plan, only when there is one (Claude Code offers those tools to its newer models only with `CLAUDE_CODE_ENABLE_TODO_TOOLS=1`, so `spawnRun` puts it in every Claude pane's env; a user's `settings.json` `env` can still set `"0"`): how many steps are done of how many (`3/7`) over a bar, done items struck through and the step in progress highlighted; **Outputs** — files it wrote or edited (`Write`/`Edit`/`MultiEdit`/`NotebookEdit`), newest first, each with its extension tag (a file made by a Bash command is not listed); and **Used in this session** — **Uploads** (files attached through the box), **Files read**, **Memory** (Claude Code memory files), **Skills** and **Connectors** (MCP servers). A file path in a reply or your message is a link — absolute, or relative with a folder and an extension (`server/lib/x.js`, resolved against the pane's folder by `resolveIn` in `client/src/lib/resolvePath.ts`), a `:line` suffix included, bare, in inline code or as a markdown link's target (`[x](src/x.ts#L12)`; `MarkdownContent.tsx`) — and opens like an Edited-files row: in the Changes panel when it lies in the workspace's folder, else in the preview. A click on an output opens its preview in the panel's place (`FilePreview` in `client/src/components/run/ChatFilePreview.tsx`: name · EXT, Copy path, Close back to the panel); Uploads, Files read and Memory open their list with a preview beside it (`FileListModal`), and Skills opens the same dialog over the session's skills, each previewed from its SKILL.md (`SkillListModal`: a plugin's `plugin:name` from `GET /api/cc-config/plugins`, else the user's or the project's skill; a built-in skill has no file and shows by name); Connectors opens the Claude config page. Every row but Connectors also drags onto the prompt box (`dragAttachments`/`dragSkill` in `client/src/lib/promptAttach.ts`): an output, or a whole Uploads/Files read/Memory group, attaches its files like the `+` menu does (an upload by its bare path, anything else as `@`); a skill becomes the message's leading `/` — the Skills row only when the session used one skill (a prompt takes one command), else drag it from its list. A row dragged out of one of those list dialogs closes the dialog, so the box is reachable. A preview reads only through routes that already guard it — a file under the pane's folder through `GET /api/lanes/:id/git/file` (an image through `/git/raw`), a memory file under `~/.claude` through `GET /api/cc-config/file`, an attached image or text file (`.txt`, `.md`, `.csv`, `.log`, `.json`… — a long paste is one) through `GET /api/run/drop/:name` — and shows just the path for anything else (an attached spreadsheet, a file outside the workspace, one since deleted). A Windows path (`C:\…\.claude\…`) is matched like a POSIX one. The ended-session replay and the Analyst page's conversation use the same layout. The chat's unsent prompt and its attachments are kept per session in this browser (`agenthub.chat.draft.`, `client/src/lib/chatDraft.ts`), so switching the pane to its terminal and back, or reloading the page, leaves them as typed — an attached image keeps its path, not its preview; a send clears it. Switching carries the unsent prompt across: to the terminal, the draft (its files as paths) is pasted into the CLI's input box without Enter and the draft is cleared; to the chat, the text in the CLI's input box is read off the screen (`parsePromptInput` in `client/src/lib/panePaste.ts`: Claude's `❯` box between its `─` rules, Codex's last `›` row up to its footer; a soft-wrapped row is joined back) into the draft and deleted there with Delete/Backspace — never Ctrl+C or Esc, which would stop a working turn. A box holding `[Pasted text #N]` or `[Image #N]` is left as it is, since the screen does not show what it stands for. A row of a turn's **Edited N files** card drags into the prompt box as an `@path` attachment (its header drags every file in the card); dropped elsewhere it is the plain path. -- **Artifacts in the chat** — like Claude Desktop, a page, document, diagram, table or React component the model wrote (`isArtifact` in `client/src/lib/artifactView.ts`: `.svg`, `.md/.markdown/.mdx`, `.mmd/.mermaid`, `.csv/.tsv`, and a `.html/.htm` or `.jsx/.tsx` written whole by a Write — one it merely edited is project code, and so is an HTML page of the project itself: any `index.html`, or one under a `src/`, `public/`, `templates/`, `views/`, `pages/`, `app/`, `static/`, `dist/`, `build/`, `components/` or `layouts/` folder; those stay in **Edited N files**) gets an **artifact card** in the conversation (`ArtifactCard` in `MessageList.tsx`: an icon by kind, its name, "Web page / Document / Diagram / React component / Table · Click to open"), shown as soon as the file is made, even mid-turn; the turn's **Edited N files** card lists the other files. A click opens it in the chat's own side panel (`onOpenArtifact` → `PaneChat`, not the workspace's Changes panel), which the Outputs list opens too: `FilePreview` in `client/src/components/run/ChatFilePreview.tsx` with **Preview | Code** (Code is the file highlighted with line numbers, `CodeBlock`), its **versions** (‹ Version n of m ›, rebuilt from the conversation's Write/Edit/MultiEdit inputs by `artifactVersions` — the transcript keeps those inputs up to 256 KB, `server/lib/transcript-message.js`; an edit to text the conversation never showed is a version of unknown content, the file on disk standing in for the newest), **Copy** (the text), **Download**, **Save to Artifacts** (a workspace file through `POST /api/artifacts/file` by its path, anything else — an older version, a file outside the workspace — through `POST /api/artifacts/file/upload`; shared to others from the Artifacts page), **Full screen** (over the whole page) and Close; clicking the name copies its path. Preview renders Markdown, a CSV as a table, an image, and runs HTML, Mermaid (pinned `mermaid@11.4.1`) and React (`react@18.3.1` + `@babel/standalone@7.26.4` + Tailwind's play CDN, a default-exported component, `react`/`react-dom` the only imports) in an `