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")} +

+ ) : ( +
+