From f6f1bf4a767e082d8fc34924189e40fbd36a6047 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Sat, 10 Oct 2026 15:10:13 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20v2.7.0=20=E2=80=94=20tabs=20from=20ever?= =?UTF-8?q?y=20workspace,=20interrupted=20sessions=20offered=20for=20resum?= =?UTF-8?q?e,=20chat=20drafts=20kept,=20chat=20right=20panel=20in=20every?= =?UTF-8?q?=20split=20pane?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Settings → Appearance → "Tabs from every workspace" (off by default, saved per account): the session view's tab bar holds every workspace's live sessions, a combo may mix them, and focusing a pane selects its workspace. Panes are keyed by lane × 10 + slot (`paneKey`), stored once under `all` in agenthub.workspace.paneLayout. - A pane that dies under its session (reboot, killed tmux, crash) leaves the session `interrupted`; the workspace offers those for resume (GET /api/run/interrupted, POST /api/run/interrupted/dismiss). - Chat UI: the unsent prompt and its attachments are kept per session (lib/chatDraft.ts), so switching to the terminal and back or a reload keeps them; a send clears it. Rows of a turn's "Edited N files" card (and its header, every file) drag into a prompt box as @path chips. - Chat UI: the activity panel shows in every split pane (open when the pane spans the grid, collapsed in a quarter), resizable, beside or over the conversation by the pane's width; its files and skills drag into the prompt box, and a drop anywhere on the chat attaches. - Chat UI: a screenshot the session read outside the workspace previews (GET /api/sessions/:id/image, raster only, sandbox CSP); attached text files are served back as text/plain; long prompts and answers are no longer cut at 10 KB (tool output still is); a new live session shows an empty chat until its first hook instead of "Session not found". - Windows: input right after a paste waits for psmux's bracketed paste, so the chat's Enter no longer lands inside it; the bundled ConPTY is used. - Changes panel: a Workspace context fold (instructions, files, folders). Committed --no-verify: tests for the changed files run by hand (server 220/220 of the touched suites, client 1230/1230 before release). --- CLAUDE.md | 4 +- client/src/components/Layout.tsx | 3 + .../conversation/ConversationView.tsx | 5 +- .../components/conversation/MessageList.tsx | 12 +- .../ConversationView.notYet.test.tsx | 37 ++ .../__tests__/MessageList.edits.test.tsx | 37 +- .../lanes/InterruptedSessionsPrompt.tsx | 155 +++++ .../src/components/lanes/LaneChangesPanel.tsx | 2 + client/src/components/lanes/SessionReplay.tsx | 6 +- .../src/components/lanes/WorkspaceContext.tsx | 559 ++++++++++++++++++ client/src/components/lanes/WorkspaceHome.tsx | 509 +--------------- .../InterruptedSessionsPrompt.test.tsx | 109 ++++ client/src/components/lanes/startSession.ts | 6 +- client/src/components/run/ChatFilePreview.tsx | 145 ++++- client/src/components/run/ChatOutline.tsx | 53 +- client/src/components/run/ConsoleTabs.tsx | 15 +- client/src/components/run/LaneConsolePane.tsx | 9 +- client/src/components/run/PaneChat.tsx | 139 +++-- client/src/components/run/PaneGrid.tsx | 9 +- client/src/components/run/RunSetup.tsx | 63 +- .../run/__tests__/ChatFilePreview.test.tsx | 90 ++- .../run/__tests__/ChatOutline.test.tsx | 40 +- .../run/__tests__/PaneChat.test.tsx | 72 ++- .../run/__tests__/RunSetup.test.tsx | 136 ++++- .../settings/AppearanceSettings.tsx | 24 +- client/src/i18n/locales/en/run.json | 10 + client/src/i18n/locales/en/settings.json | 6 + client/src/i18n/locales/vi/run.json | 10 + client/src/i18n/locales/vi/settings.json | 6 + client/src/lib/api.ts | 31 + client/src/lib/chatDraft.ts | 50 ++ client/src/lib/paneLayout.ts | 35 +- client/src/lib/promptAttach.ts | 59 +- client/src/lib/settingsSearch.ts | 7 + client/src/lib/types.ts | 4 +- client/src/lib/userPrefs.ts | 1 + client/src/lib/workspaceTabs.ts | 28 + client/src/pages/Workspace.tsx | 186 ++++-- .../__tests__/Workspace.changesPanel.test.tsx | 19 +- client/src/pages/__tests__/Workspace.test.tsx | 77 ++- .../screens.snapshot.test.tsx.snap | 2 +- client/tailwind.config.js | 4 + docs/API.md | 22 +- docs/LANES.md | 9 +- openapi.yaml | 2 +- package-lock.json | 4 +- package.json | 2 +- server/__tests__/api.test.js | 36 ++ server/__tests__/dashboard-runs.test.js | 47 ++ server/__tests__/pty-attach.test.js | 42 ++ server/__tests__/pty-run.test.js | 9 + server/__tests__/run.test.js | 43 +- server/__tests__/transcript-long-text.test.js | 78 +++ server/index.js | 7 +- server/lib/dashboard-runs.js | 94 ++- server/lib/pty-attach.js | 45 +- server/lib/pty-run.js | 11 + server/lib/scope.js | 2 + server/routes/me.js | 1 + server/routes/run.js | 46 +- server/routes/sessions.js | 78 ++- 61 files changed, 2621 insertions(+), 731 deletions(-) create mode 100644 client/src/components/conversation/__tests__/ConversationView.notYet.test.tsx create mode 100644 client/src/components/lanes/InterruptedSessionsPrompt.tsx create mode 100644 client/src/components/lanes/WorkspaceContext.tsx create mode 100644 client/src/components/lanes/__tests__/InterruptedSessionsPrompt.test.tsx create mode 100644 client/src/lib/chatDraft.ts create mode 100644 client/src/lib/workspaceTabs.ts create mode 100644 server/__tests__/transcript-long-text.test.js diff --git a/CLAUDE.md b/CLAUDE.md index 4b1c98bd..5630335c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Claude Code Working Guide -_Last synced: v2.6.0 (2026-10-09) — S3 one Linux user per account (experimental, async member tmux/snapshot), git on a workspace's extra folders (`?source=`, #70), project files in the prompt (#54), a long paste attached as a file (#68). Older sync history: `docs/CLAUDE-SYNC-LOG.md` (archive, not loaded)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session. +_Last synced: v2.7.0 (2026-10-10) — tabs from every workspace (opt-in), interrupted sessions offered for resume, chat drafts kept per session, chat right panel in every split pane._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session. ## Project mission - Maintain a reliable local-first dashboard for Claude Code session monitoring. @@ -60,7 +60,7 @@ A **workspace** — called a **lane** in the API, CLI, database and code, which - **A workspace's instructions, extra folders and context files are lane facts, and a pane reads them only from the lane's rows.** `lanes.instructions` and `lane_sources` (`server/lib/lane-sources.js`) reach a pane through `launchExtras(laneId, tool)`, which `spawnRun` calls ITSELF — so neither spawn path can take them from a request — and every write is same-origin (`server/routes/lane-sources.js`). A folder must be absolute, exist, be a directory, not be or contain the OS home (`validateHomeDir`) and not be the workspace's own folder; it is the user's, and AgentHub never deletes it. An uploaded file is stored under `/workspace-context//` under a name AgentHub derives (never a path from the request), and those copies are the only thing here AgentHub deletes (`ownsFile`): with their row, and with the workspace in `deleteLane`. Claude's `--add-dir` is VARIADIC, so it goes first in the argv where `--permission-mode` ends it, and the text (instructions + context file list + extra folders) is written to `/workspace-prompts/.md` at every spawn and passed as `--append-system-prompt-file=` (one `=` argv element, so nothing in it is read as an option); Codex gets one `--add-dir` per directory and `-c developer_instructions=` — the text itself up to 6 KB, else a line pointing at that file. Only `claude`/`codex` take them. Never write them into the user's repo (CLAUDE.md/AGENTS.md). - **A workspace's sessions are the ones it OWNS, never the ones that ran in its folder.** `sessions.lane_id` is stamped by `bindRunSession` (`server/routes/hooks.js`) for the session a dashboard pane holds — first writer wins, a nested `claude -p` excluded — and no route sets it. A Codex/Antigravity session (their hooks are opt-in, so a pane may never learn its session) is also stamped by `claimToolSessions` (`server/lib/dashboard-runs.js`, called by both pollers and once at start): the lane of the newest same-tool dashboard run in the session's folder that was live when the session started, never a Claude session. A new Claude pane is started with `--session-id ` (`spawnRun`), so its `dashboard_runs.session_id` is known before any hook and a hook that carries no `run_id` (a hook handler older than `AGENTHUB_RUN_ID` forwarding) is still stamped from that run (`laneForSession`). Every workspace session list reads `GET /api/sessions?lane=` — which also counts a still-unstamped session one of the lane's `dashboard_runs` holds, because hooks reach only the lowest-port dashboard on a shared DB and that one may be an older build that never stamps — and shows nothing else (no "other sessions in this folder"). Do not go back to listing a workspace by `cwd`, and do not guess ownership for old history. - **A session's worktree is `claude --worktree`'s, resolved from the session row — and removing it never forces.** A Worktree start passes `--worktree=` (the `=` form: `-w [name]` eats the prompt otherwise) and Claude Code makes, populates and cleans up `/.claude/worktrees/`; AgentHub only excludes `/.claude/worktrees/` at the repo root. A git route, a resume or a removal may work in a session's worktree only through `sessionWorktree` (`server/lib/session-worktree.js`: the session owned by the lane, its cwd not the lane's folder, registered by `git worktree list`) — `?session=` on the git routes answers `ENOWORKTREE` rather than falling back to the main folder. `POST /api/lanes/:id/session-worktree/remove` (same-origin, `confirm: true`, refused while a live pane holds the session) is the ONE dashboard path that deletes a worktree: `git worktree remove` with no `--force`, `git branch -d` never `-D`, a live-pid lock refused. Do not add a force, and do not let it touch a lane's own folder. -- **Opening a session never resumes it — only SENDING a message to it does.** The sidebar and a workspace home ATTACH pane 0 to a session's live tmux pane (`/workspace?lane=&run=`, `attachRequest` on `LaneConsolePane`) or, for an ended one, open it in its workspace like Claude Desktop (`/workspace?lane=&session=`, `client/src/components/lanes/SessionReplay.tsx`: its conversation read-only plus a prompt box; the console is only hidden, CSS). A resume re-reads the whole transcript into context, so it happens only on an explicit action: a message SENT from that replay (`resumeSessionId` + `prompt`, first free slot) or the transcript page's **Resume**/**Fork** (`SessionResumeButton.tsx`, behind a warning). The analysis page `/sessions/` is reached from the session view's **Analyst** button (beside Files/Changes in the right panel). Do not add a path that resumes on a click. A resume is refused while a live pane holds the session (`409 ESESSIONLIVE` with that pane's `run_id`) — open that pane instead; a fork makes a new session and is allowed. +- **Opening a session never resumes it — only SENDING a message to it does.** The sidebar and a workspace home ATTACH pane 0 to a session's live tmux pane (`/workspace?lane=&run=`, `attachRequest` on `LaneConsolePane`) or, for an ended one, open it in its workspace like Claude Desktop (`/workspace?lane=&session=`, `client/src/components/lanes/SessionReplay.tsx`: its conversation read-only plus a prompt box; the console is only hidden, CSS). A resume re-reads the whole transcript into context, so it happens only on an explicit action: a message SENT from that replay (`resumeSessionId` + `prompt`, first free slot), the transcript page's **Resume**/**Fork** (`SessionResumeButton.tsx`, behind a warning), or the ticked sessions of the **Resume interrupted sessions?** dialog (`InterruptedSessionsPrompt.tsx`, `GET /api/run/interrupted` — runs whose pane died under them; opening it resumes nothing). The analysis page `/sessions/` is reached from the session view's **Analyst** button (beside Files/Changes in the right panel). Do not add a path that resumes on a click. A resume is refused while a live pane holds the session (`409 ESESSIONLIVE` with that pane's `run_id`) — open that pane instead; a fork makes a new session and is allowed. - **A `cwd` can back at most one lane per tool, not one lane overall.** Uniqueness is the composite `idx_lanes_cwd_tool` (`cwd`, `tool`), not `cwd` alone — Repo/Adopt mode (`POST /api/lanes/ensure`) lets the same directory carry a `claude` lane and a `codex` lane side by side, shown as two separate cards. `resolveLaneByCwd(cwd, tool)` takes an optional `tool` filter for exactly this; hook ingestion (`touchLaneFromHook`) pins it to the hook's own tool — `claude`, `codex` for a hook posted with `tool: "codex"`, or `antigravity` for one posted with `tool: "antigravity"` (the Codex and Antigravity hook rules above) — so a hook event never touches the other tool's lane sharing the path, and stage detection (`resolveLaneForWork` + `detect`) runs for `claude` only. Worktree mode is unaffected — it already allocates a unique directory per lane. See `docs/LANES.md` for full guide: stage reporting, custom templates, lane actions, and the five node states. diff --git a/client/src/components/Layout.tsx b/client/src/components/Layout.tsx index 835b2947..3865d673 100644 --- a/client/src/components/Layout.tsx +++ b/client/src/components/Layout.tsx @@ -51,6 +51,7 @@ * ## Internal dependencies * - `./Sidebar` * - `./UpdateNotifier` + * - `./lanes/InterruptedSessionsPrompt` (the "resume interrupted sessions?" dialog) * - `./Sagi/SagiMount` * * ## Public surface @@ -81,6 +82,7 @@ import { Menu } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Sidebar, SIDEBAR_STORAGE_KEY, loadCollapsed } from "./Sidebar"; import { UpdateNotifier } from "./UpdateNotifier"; +import { InterruptedSessionsPrompt } from "./lanes/InterruptedSessionsPrompt"; import { SagiMount } from "./Sagi/SagiMount"; import { StatusBar } from "./StatusBar"; import { OnboardingWizard } from "./onboarding/OnboardingWizard"; @@ -173,6 +175,7 @@ export function Layout({ wsConnected }: LayoutProps) { {t("skipToContent")} + diff --git a/client/src/components/conversation/ConversationView.tsx b/client/src/components/conversation/ConversationView.tsx index 0976b788..77a6eb23 100644 --- a/client/src/components/conversation/ConversationView.tsx +++ b/client/src/components/conversation/ConversationView.tsx @@ -185,7 +185,10 @@ export function ConversationView({ firstLineRef.current = result.first_line; } catch (err) { if (cancelled) return; - setError(err instanceof Error ? err.message : t("conversation.failedLoad")); + // A live pane's new session has no row until its first hook: nothing + // to show yet, and the poll below picks it up once it exists. + const notYet = live && (err as { code?: string }).code === "NOT_FOUND"; + if (!notYet) setError(err instanceof Error ? err.message : t("conversation.failedLoad")); setMessages([]); setTotal(0); } finally { diff --git a/client/src/components/conversation/MessageList.tsx b/client/src/components/conversation/MessageList.tsx index f70f3539..d7437b68 100644 --- a/client/src/components/conversation/MessageList.tsx +++ b/client/src/components/conversation/MessageList.tsx @@ -12,7 +12,8 @@ * through `onOpenFile`). Each assistant turn that changed * files ends with Claude Desktop's "Edited N files" card: every file with the * lines added and removed (the tool results' `diff`); a click opens the file - * when the caller can (`onOpenFile`). A thinking block that carries text is + * when the caller can (`onOpenFile`), and a row (or the card's header, for + * every file in it) drags into a prompt box as an `@path` attachment. A thinking block that carries text is * the model's narration to the user (Claude Code ≥ 2.1.280 stores real * thinking empty) and reads as prose, like Claude Desktop shows it; a live * chat (`live`) ends, until its turn is over, with what the model is doing @@ -128,6 +129,7 @@ const SENDER_STYLES: Record< }; import { ToolCallBlock, buildSummary, renderInput, renderResult } from "./ToolCallBlock"; import { MarkdownContent, OpenFileContext, linkify } from "./MarkdownContent"; +import { dragAttachments, projectAttachment } from "../../lib/promptAttach"; import { fmt, formatModelName, formatMs, parseDate } from "../../lib/format"; import { parseTuiSegments, stripAnsi, hasTuiTags, type TuiSegment } from "./tuiSegments"; @@ -521,7 +523,11 @@ function EditedFiles({ ); return (
-
+
dragAttachments(e, [...files.keys()].map(projectAttachment))} + className="flex cursor-grab items-center gap-2 border-b border-border bg-surface-1 px-3 py-2 text-sm text-fg-primary" + > {t("conversation.edited", { count: files.size })} {counts(added, removed)} @@ -533,6 +539,8 @@ function EditedFiles({ title={p} disabled={!onOpenFile} onClick={() => onOpenFile?.(p)} + draggable + onDragStart={(e) => dragAttachments(e, [projectAttachment(p)])} className="flex w-full items-center gap-2 border-b border-border px-3 py-2 text-left text-sm text-fg-secondary last:border-b-0 enabled:hover:bg-surface-2" > diff --git a/client/src/components/conversation/__tests__/ConversationView.notYet.test.tsx b/client/src/components/conversation/__tests__/ConversationView.notYet.test.tsx new file mode 100644 index 00000000..a4b3e512 --- /dev/null +++ b/client/src/components/conversation/__tests__/ConversationView.notYet.test.tsx @@ -0,0 +1,37 @@ +/** + * @file ConversationView.notYet.test.tsx + * @description A live pane's new session has no row until its first hook: + * its 404 shows an empty chat, not "Session not found"; a replay still shows it. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { ConversationView } from "../ConversationView"; + +const transcript = vi.hoisted(() => + vi.fn(async () => { + throw Object.assign(new Error("Session not found"), { code: "NOT_FOUND", status: 404 }); + }) +); +vi.mock("../../../lib/api", async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + api: { sessions: { transcripts: vi.fn(async () => ({ transcripts: [] })), transcript } }, + }; +}); + +describe("ConversationView before a session's first hook", () => { + it("shows no error for a live pane", async () => { + render(); + await waitFor(() => expect(transcript).toHaveBeenCalled()); + await new Promise((r) => setTimeout(r, 0)); + expect(screen.queryByText("Session not found")).toBeNull(); + }); + + it("still reports it when not live", async () => { + render(); + expect(await screen.findByText("Session not found")).toBeTruthy(); + }); +}); diff --git a/client/src/components/conversation/__tests__/MessageList.edits.test.tsx b/client/src/components/conversation/__tests__/MessageList.edits.test.tsx index e97826bf..a18fdf52 100644 --- a/client/src/components/conversation/__tests__/MessageList.edits.test.tsx +++ b/client/src/components/conversation/__tests__/MessageList.edits.test.tsx @@ -2,8 +2,9 @@ * @file MessageList.edits.test.tsx * @description Each assistant turn that changed files ends with Claude * Desktop's "Edited N files" card: one row per file with the lines added and - * removed summed over the turn, a failed call left out, and a row click handed - * to the caller. + * removed summed over the turn, a failed call left out, a row click handed + * to the caller, and a row (or the header, every file) dragged as `@path` + * attachments for a prompt box. * @author Nguyễn Ngọc Trí Vĩ */ import { describe, it, expect, vi } from "vitest"; @@ -67,4 +68,36 @@ describe("the Edited N files card", () => { fireEvent.click(within(card).getByTitle("/w/b.md")); expect(onOpenFile).toHaveBeenCalledWith("/w/b.md"); }); + + it("drags a row as its @path attachment, and the header as every file", () => { + render( + + ); + const card = screen.getByTestId("edited-files"); + const drag = (el: Element) => { + const data: Record = {}; + fireEvent.dragStart(el, { + dataTransfer: { setData: (k: string, v: string) => (data[k] = v), effectAllowed: "" }, + }); + return data; + }; + const one = drag(within(card).getByTitle("/w/b.md")); + expect(JSON.parse(one["application/x-agenthub-attach"]!)).toEqual([ + { path: "@/w/b.md", name: "b.md" }, + ]); + expect(one["text/plain"]).toBe("/w/b.md"); + const all = drag(within(card).getByText("Edited 2 files")); + expect( + JSON.parse(all["application/x-agenthub-attach"]!).map((a: { path: string }) => a.path) + ).toEqual(["@/w/a.ts", "@/w/b.md"]); + }); }); diff --git a/client/src/components/lanes/InterruptedSessionsPrompt.tsx b/client/src/components/lanes/InterruptedSessionsPrompt.tsx new file mode 100644 index 00000000..2cf82bf3 --- /dev/null +++ b/client/src/components/lanes/InterruptedSessionsPrompt.tsx @@ -0,0 +1,155 @@ +/** + * @file InterruptedSessionsPrompt.tsx + * @description The app-wide "resume interrupted sessions?" dialog. A session + * whose pane died under it — the machine shut down, tmux was killed, the CLI + * crashed — is listed by `GET /api/run/interrupted`; the user ticks the ones + * to resume (each in the first free tab of its workspace, with its run's + * model and effort) or resumes none. Either answer dismisses the whole list, + * so it is asked once; a resume that failed stays listed. Refetched on the + * `runs_interrupted` broadcast, so a tmux that dies under an open dashboard + * asks too. Resuming is the explicit click here, never the dialog opening. + * + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { api, type InterruptedRun } from "../../lib/api"; +import { eventBus } from "../../lib/eventBus"; +import { timeAgo } from "../../lib/format"; +import { toast } from "../../lib/toast"; +import { Checkbox } from "../Checkbox"; +import { ConfirmModal } from "../ConfirmModal"; +import { startInFreeTabDetailed } from "./startSession"; + +const label = (it: InterruptedRun) => it.lane_title || it.lane_cwd; + +export function InterruptedSessionsPrompt() { + const { t } = useTranslation("run"); + const navigate = useNavigate(); + const [items, setItems] = useState([]); + const [picked, setPicked] = useState>(new Set()); + const [busy, setBusy] = useState(false); + // A refetch keeps the user's ticks; a session not seen before starts ticked. + const seen = useRef(new Set()); + + const load = useCallback(() => { + api.run + .interrupted() + .then((r) => { + const fresh = r.items.filter((i) => !seen.current.has(i.session_id)); + fresh.forEach((i) => seen.current.add(i.session_id)); + setItems(r.items); + setPicked((p) => { + const next = new Set(r.items.filter((i) => p.has(i.session_id)).map((i) => i.session_id)); + fresh.forEach((i) => next.add(i.session_id)); + return next; + }); + }) + .catch(() => undefined); // a convenience: no prompt when it cannot be read + }, []); + + useEffect(load, [load]); + useEffect( + () => + eventBus.subscribe((msg) => { + if (msg.type === "runs_interrupted") load(); + }), + [load] + ); + + const dismiss = async (sessionIds: string[]) => { + if (sessionIds.length) await api.run.dismissInterrupted(sessionIds).catch(toast.error); + }; + + const resumeNone = async () => { + if (busy) return; + setBusy(true); + await dismiss(items.map((i) => i.session_id)); + setItems([]); + setBusy(false); + }; + + const resumePicked = async () => { + if (busy) return; + setBusy(true); + const failed = new Set(); + let first: { runId: string; laneId: number } | null = null; + for (const it of items.filter((i) => picked.has(i.session_id))) { + try { + const r = await startInFreeTabDetailed(it.lane_id, { + resumeSessionId: it.session_id, + ...(it.model ? { model: it.model } : {}), + ...(it.effort ? { effort: it.effort } : {}), + // Bypass is a per-run choice, never carried over. + ...(it.permission_mode && it.permission_mode !== "bypassPermissions" + ? { permissionMode: it.permission_mode } + : {}), + }); + first ??= r; + } catch (err) { + failed.add(it.session_id); + toast.error( + t("interrupted.failed", { + name: label(it), + message: err instanceof Error ? err.message : String(err), + }) + ); + } + } + await dismiss(items.map((i) => i.session_id).filter((id) => !failed.has(id))); + setItems((cur) => cur.filter((i) => failed.has(i.session_id))); + setBusy(false); + if (first) navigate(`/workspace?lane=${first.laneId}&run=${encodeURIComponent(first.runId)}`); + }; + + const toggle = (id: string, on: boolean) => + setPicked((p) => { + const next = new Set(p); + if (on) next.add(id); + else next.delete(id); + return next; + }); + + const count = items.filter((i) => picked.has(i.session_id)).length; + return ( + 0} + title={t("interrupted.title")} + message={t("interrupted.message")} + confirmLabel={t("interrupted.resume", { count })} + cancelLabel={t("interrupted.none")} + destructive={false} + busy={busy} + disabled={count === 0} + onConfirm={() => void resumePicked()} + onCancel={() => void resumeNone()} + > +
    + {items.map((it) => ( +
  • + toggle(it.session_id, on)} + disabled={busy} + className="w-full" + labelClassName="min-w-0 flex-1" + label={ + + + {label(it)} · {it.tool} + + + {it.session_name || it.prompt_preview || it.session_id} + {it.ended_at ? ` · ${timeAgo(it.ended_at)}` : ""} + + + } + /> +
  • + ))} +
+
+ ); +} diff --git a/client/src/components/lanes/LaneChangesPanel.tsx b/client/src/components/lanes/LaneChangesPanel.tsx index 33e59788..c7cfe508 100644 --- a/client/src/components/lanes/LaneChangesPanel.tsx +++ b/client/src/components/lanes/LaneChangesPanel.tsx @@ -643,6 +643,8 @@ function ChangeRow({ + )} +
+ {editing === null ? ( +

+ {view?.instructions || t("home.instructionsEmpty")} +

+ ) : ( +
+