fix: v2.6.1 — panes start in their folder again after an update (#69), Changes panel stacks every repo, clickable paths in chat
- #69: tmux applies a pane's `-c` only while its server's own cwd exists. The server was forked from the dashboard's cwd (the plugin cache dir), outlives updates, and the old cache dir is pruned — after that every new pane started in a deleted directory, where `claude` died at once ("run not found") and a shell printed getcwd errors. tmux calls now run from `/`, and the pane's sh wrapper cds into its folder itself, which also heals a tmux server that is already stuck. - Changes panel: a workspace's extra folders are sections stacked under the workspace folder (VS Code Source Control style), with Close repository / Close other repositories on a header's right-click. - Chat: URLs and absolute paths are links; Claude Code's `[Image: source:]` line folds into its turn; uploads under `<data dir>/drops/` are listed. - Update check: a checkout holding commits a newer release lacks reports `diverged_from_release` instead of offering a fast-forward that can only fail.
This commit is contained in:
+3
-1
@@ -374,7 +374,7 @@ graph TD
|
||||
| `lib/tmux.js` | Wrapper around tmux CLI for session management. `createSession(sessionName, cwd)` creates a new tmux session in the specified working directory. `sendCommand(sessionName, command)` sends a command into the session. `killSession(sessionName)` terminates the session. `listSessions()` returns all active sessions. Session management is the foundation for the PTY transport layer |
|
||||
| `lib/pty-run.js` | PTY lifecycle for tmux-backed runs. Manages one tmux session per lane, named `agenthub-lane-<lane.id>`. Exports `startRun()` to create/attach a session and return a `runId` opaque handle; internally uses tmux to manage the pseudoterminal. Spawned `claude` processes run inside the session and fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, the analytics, the Kanban board, and the Workflows page automatically. The PTY frames (terminal input/output deltas) are streamed to the client over `/ws-pty/:runId` (see `server/websocket.js`) at binary frame granularity rather than as JSON envelopes; the client's xterm.js terminal widget renders these raw PTY updates live. Every spawn also mirrors itself into `dashboard_runs` (cwd, model, prompt preview, `resume_session_id`, lane id, start time) — that row is the ONLY source `publicRun` has for those fields, since the handle itself is recomputed from tmux on every read, so a dropped insert renders as a live run with no cwd, model or start date. `mode` is NOT NULL in that table and `recordRun` swallows insert errors by design, which is why this module passes a literal `"interactive"` rather than null. **Create-or-adopt has one refusal:** a pane already running an agent is adopted silently only when the request asked for nothing but a pane — a `resumeSessionId` or an initial prompt cannot be typed over a live agent, so it throws `ERUNLIVE` (409) rather than reporting a resume that never spawned |
|
||||
| `lib/pty-attach.js` | Client-side PTY attachment via WebSocket. Establishes a `/ws-pty/:runId` connection, receives binary PTY frames, and feeds them to an xterm.js terminal instance. Handles reconnection, resize events (sending `TIOCSWINSZ` ioctl to the tmux pane), and cleanup on disconnect. A single tmux session can have many simultaneous PTY clients (browser Workspace, `agenthub lanes shell` CLI, etc.), all synced live The server side (`server/lib/pty-attach.js`) runs `tmux attach` with the LIVE tmux server's own binary (`/proc/<tmux.serverPid()>/exe`, else `tmux` on PATH): a tmux upgraded while its server runs leaves a newer client on PATH that the old server refuses with `open terminal failed: not a terminal`. |
|
||||
| `routes/run.js` | HTTP surface for the tmux+PTY Run feature. **Same-origin guard** on every route — browser requests must come from a localhost-ish Origin (`localhost`, `127.0.0.1`, `::1`, `0.0.0.0`); missing-Origin (curl/CLI) requests pass. When `DASHBOARD_TOKEN` is configured it is **also** required on these routes (same as the rest of `/api/*`). `GET /api/run` lists all live runs (computed fresh from tmux state via `tmux list-sessions`). `GET /api/run/tmux` reports whether `tmux` is installed and on PATH (required for the feature to work). `GET /api/run/binary` probes whether `claude` is on `PATH`. `GET /api/run/cwds` suggests cwds (dashboard + home + recent from sessions table). `GET /api/run/history?laneId=...` returns persisted run history, optionally scoped to one lane. `GET /api/run/files?cwd=&q=` powers the Workspace page's `@`-file autocomplete: scoped fuzzy search inside `cwd` skipping `node_modules`, `.git`, `dist`, `build`, `.next`, `.cache`, `coverage`, `vendor`, etc., capped result count, ranked by basename match. `POST /api/run/drop-image` saves an image dropped on the terminal (raw body, four image types, random name under `<tmpdir>/agenthub-drops-<uid>/`) and returns the path the terminal pastes. `POST /api/run` requires `laneId` in the body and starts/attaches a tmux-backed run. `GET /api/run/:id` returns the run handle. `DELETE /api/run/:id` kills the tmux session (sends SIGTERM to the pane, escalates to SIGKILL after 5 s). The PTY frames are streamed to the client over `/ws-pty/:runId` as binary frames (not JSON), rendering a real interactive terminal in xterm.js on the Workspace page. `tmux` must be installed on the dashboard server's machine (same as better-sqlite3's native-module requirements) |
|
||||
| `routes/run.js` | HTTP surface for the tmux+PTY Run feature. **Same-origin guard** on every route — browser requests must come from a localhost-ish Origin (`localhost`, `127.0.0.1`, `::1`, `0.0.0.0`); missing-Origin (curl/CLI) requests pass. When `DASHBOARD_TOKEN` is configured it is **also** required on these routes (same as the rest of `/api/*`). `GET /api/run` lists all live runs (computed fresh from tmux state via `tmux list-sessions`). `GET /api/run/tmux` reports whether `tmux` is installed and on PATH (required for the feature to work). `GET /api/run/binary` probes whether `claude` is on `PATH`. `GET /api/run/cwds` suggests cwds (dashboard + home + recent from sessions table). `GET /api/run/history?laneId=...` returns persisted run history, optionally scoped to one lane. `GET /api/run/files?cwd=&q=` powers the Workspace page's `@`-file autocomplete: scoped fuzzy search inside `cwd` skipping `node_modules`, `.git`, `dist`, `build`, `.next`, `.cache`, `coverage`, `vendor`, etc., capped result count, ranked by basename match. `POST /api/run/drop-image` saves an image dropped on the terminal (raw body, four image types, random name under `<data dir>/drops/` (a member's `~/.agenthub/handoff/drops/` under S3)) and returns the path the terminal pastes. `POST /api/run` requires `laneId` in the body and starts/attaches a tmux-backed run. `GET /api/run/:id` returns the run handle. `DELETE /api/run/:id` kills the tmux session (sends SIGTERM to the pane, escalates to SIGKILL after 5 s). The PTY frames are streamed to the client over `/ws-pty/:runId` as binary frames (not JSON), rendering a real interactive terminal in xterm.js on the Workspace page. `tmux` must be installed on the dashboard server's machine (same as better-sqlite3's native-module requirements) |
|
||||
| `routes/lanes.js` | Durable-lane API. `POST /api/lanes/worktree`, `PATCH /api/lanes/:id`, destructive actions, and `DELETE /api/lanes/:id` use the Run route's same-origin guard. Worktree provisioning validates an absolute source git repository, persists a managed lane as `provisioning`, returns `202`, then uses the per-lane lock to resolve the base and add the worktree. Completion broadcasts the existing `lane_update` payload as `idle`; a git failure leaves a row that the non-destructive delete route can forget. `GET /api/lanes/:id/preflight?action=reset\|remove\|purge` produces counted confirmation facts. Confirmed `POST /:id/{reset,remove,purge}` actions require a complete `expect`, run under the same lock, kill a recorded run and wait for the spawner's actual child-exit timestamp (or return `500 ERUNTIMEOUT` before git), clear `run_id`, reject changed facts with `409 ESTALE` including expected/current diagnostics, and require `force` for unpushed managed reset/remove work. Reset and managed removal call the worktree's independent managed-kind, realpath-within-`LANES_ROOT`, and listed-worktree guard; adopted reset is refused, while adopted remove only forgets its row and never modifies its directory, and a managed lane whose directory was deleted by hand takes a prune path that still enforces the managed-kind and inside-`LANES_ROOT` checks. `start` returns `409 ERUNLIVE` rather than overwriting a live `run_id` and orphaning its child. `kind`, `source_repo`, `slug`, `base_branch`, `slot` and `ports` are not patchable — provisioning writes them through `lanesLib.setProvisioningFacts`. Worktree provisioning also runs `lane-runtime.js:provisionLane` (A2) when the repo declares a `.agenthub/profile` — seed `.env`, `bootstrap`, create the database, migrate, seed — before the lane reports `idle`; `reset`/`remove` likewise call `resetLaneData`/`removeLaneData`, with `reset` accepting a body `keepDb: true` to skip the whole drop-recreate-migrate-reseed block. The runtime routes (`GET /:id/runtime`, `POST /:id/up`, `POST /:id/down`, `POST /:id/hook/:name`, `GET /:id/logs/:svc`) are registered **before** the `/:id/:action` catch-all so `up`/`down` are not swallowed as unknown actions, and are deliberately kept out of it: that catch-all drives a lane's Claude run, these drive the application the lane is working on. `GET /api/lanes/pipeline-stats` aggregates per-node pass counts over every lane and every archived feature. The git surface (#34/#35) sits under `/:id/git/*`, again before the catch-all: `GET /:id/git/changes` (ranges `work`/`branch`/`commit`, with `context`), `/:id/git/log`, `/:id/git/file`, `/:id/git/ls`, `/:id/git/tree` and `/:id/git/branches` are read-only and unguarded like `/:id/git`, while `POST /:id/git/:action` is same-origin guarded and runs under the lane lock — and its per-file actions (`stage`, `unstage`, `discard`) accept only paths `git status` reports as changed, with `discard` additionally requiring `confirm: true`. |
|
||||
| `lib/ports.js` | TCP probing for runtime allocation. `isListening(port)` connects rather than binds (binding races with the hook about to bind, and says nothing about a listener held by another user); a connect timeout counts as occupied. `listenerPids(port)` shells to `lsof`, falls back to `ss`, and returns `[]` with a one-time warning when neither exists — a missing tool must never fail a lane operation |
|
||||
| `lib/lane-slots.js` | Slot and port allocation — the numbering Shipyard gets free from fixed `lane1..lane9` directories and AgentHub, keyed by `cwd`, must allocate. `allocateSlot` takes the lowest free of `LANE_MAX_SLOTS` (default 9) under the per-lane lock, with a partial unique index on `lanes.slot` as the backstop; allocation is **lazy**, so a lane that is only watched never consumes one. `releaseSlot` runs on remove but never on reset (moving a lane's ports mid-feature is a silent failure, not a fresh start). `resolvePorts` prefers `PORT_BASE_<name> + slot`, then steps `+100` at a time so the last digit still reads as the slot, skipping anything listening, recorded by another lane, or already taken in the same boot. `slotDirs` puts run/log state under `LANES_ROOT/.state/lane<slot>/` — outside the worktree, because `reset`'s `git clean -fd` would otherwise sweep live pid files. `dbName`/`dataFacts` (A2) derive the same kind of slot-based fact one layer up: database name, `DATABASE_URL`/`TEST_DATABASE_URL`, a Redis logical index, and the upload directory — each `null` when its owning profile declaration (`DB_PREFIX`, `REDIS`, `UPLOAD_SUBDIR`) is absent |
|
||||
@@ -2244,6 +2244,7 @@ interface UpdateStatusPayload {
|
||||
situation?: // categorical hint for the UI
|
||||
| "plugin_managed" // ~/.claude/plugins/cache — /apply delegates
|
||||
| "release_available" // fast-forward onto the release tag
|
||||
| "diverged_from_release" // HEAD has commits the release lacks — fetch only, no apply
|
||||
| "tracking_canonical"
|
||||
| "fork_or_diverged_tracking"
|
||||
| "feature_branch"
|
||||
@@ -2309,6 +2310,7 @@ The same shape is used by `GET /status`, `POST /check`, and the `update_status`
|
||||
| Plugin `/apply` after a failed release check | 409 `{reason:"plugin_managed", release_check:"unreachable"\|"no_local_version"\|...}` | No chain is spawned, and the message says the check did not succeed rather than claiming nothing newer exists — an unknown reported as a negative is how a broken check looks identical to an up-to-date install. |
|
||||
| Healthy, behind, on canonical branch | `{update_available:true, situation:"tracking_canonical", manual_command:"...git pull --ff-only..."}` | Modal opens with `git pull` flow + restart hint. |
|
||||
| Healthy, behind, fork (origin = fork, upstream = canonical) | `{update_available:true, situation:"fork_or_diverged_tracking", manual_command:"...git fetch upstream && git merge --ff-only upstream/master..."}` | Modal opens with merge flow + restart hint + `situation_note` explaining the divergence. |
|
||||
| Newer release, but HEAD holds commits the release lacks (e.g. local wip the release squashed) | `{update_available:true, situation:"diverged_from_release", manual_command:"...git fetch <remote> --tags"}` | Modal opens with no **Update now**; `situation_note` says how many commits diverge and offers `git reset --keep <tag>` (when they are already in the release) or merge/rebase. `/apply` answers `not_fast_forwardable` instead of a raw `merge --ff-only` failure. |
|
||||
| Healthy, behind, on a feature branch | `{update_available:true, situation:"feature_branch", manual_command:"...git fetch <remote>"}` | Modal opens; `situation_note` explains the user is off the canonical branch; restart hint suppressed because the working tree isn't being changed. |
|
||||
|
||||
### Why Self-Apply Stays Narrow
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
* subset of CommonMark + GFM that actually appears in Claude Code transcripts:
|
||||
* fenced code blocks, ATX headings, ordered/unordered lists, task lists, blockquotes,
|
||||
* horizontal rules, simple tables, inline code, bold, italic, strikethrough, links,
|
||||
* and auto-linked URLs.
|
||||
* and auto-linked URLs. An absolute file path (bare or in inline code) opens
|
||||
* through `OpenFileContext` when the page provides one; `linkify` does the
|
||||
* same links for plain text.
|
||||
*
|
||||
* Output is built as a React element tree (no dangerouslySetInnerHTML) so user content
|
||||
* is escaped by React. Code blocks delegate to <CodeBlock /> for syntax highlighting and
|
||||
@@ -244,6 +246,71 @@ function parseBlocks(src: string): Block[] {
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/** Opens a file a message names; provided by a view that can show one. */
|
||||
export const OpenFileContext = React.createContext<((path: string) => void) | undefined>(undefined);
|
||||
|
||||
const LINK_CLASS =
|
||||
"text-violet-700 dark:text-violet-300 hover:text-violet-800 dark:hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70 break-all";
|
||||
const URL_RE = /^https?:\/\/[^\s<>()]+[^\s<>().,!?;:'"]/;
|
||||
// Two segments at least, so "/" or "/s" stays text; a trailing full stop is prose.
|
||||
const PATH_RE = /^(?:\/[^\s/<>()'"`,;:]*[^\s/<>()'"`,;:.!?]){2,}/;
|
||||
// A link starts a word: "and/or" or "a/b/c" stays text.
|
||||
const atWordStart = (text: string, i: number) => i === 0 || /[\s([{"'`]/.test(text[i - 1]!);
|
||||
|
||||
function UrlLink({ url }: { url: string }) {
|
||||
return (
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" className={LINK_CLASS}>
|
||||
{url}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/** A path as a button that opens it, or as it was when nothing can. */
|
||||
function PathLink({ path, children }: { path: string; children: React.ReactNode }) {
|
||||
const open = React.useContext(OpenFileContext);
|
||||
if (!open) return <>{children}</>;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={path}
|
||||
onClick={() => open(path)}
|
||||
className={`${LINK_CLASS} text-left`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Plain text with its URLs and absolute paths as links — nothing else parsed. */
|
||||
export function linkify(text: string): React.ReactNode[] {
|
||||
const out: React.ReactNode[] = [];
|
||||
let buf = "";
|
||||
for (let i = 0; i < text.length;) {
|
||||
const rest = text.slice(i);
|
||||
const url = atWordStart(text, i) && URL_RE.exec(rest)?.[0];
|
||||
const path = !url && atWordStart(text, i) && PATH_RE.exec(rest)?.[0];
|
||||
const hit = url || path;
|
||||
if (!hit) {
|
||||
buf += text[i++];
|
||||
continue;
|
||||
}
|
||||
if (buf) out.push(buf);
|
||||
buf = "";
|
||||
out.push(
|
||||
url ? (
|
||||
<UrlLink key={i} url={url} />
|
||||
) : (
|
||||
<PathLink key={i} path={hit}>
|
||||
{hit}
|
||||
</PathLink>
|
||||
)
|
||||
);
|
||||
i += hit.length;
|
||||
}
|
||||
if (buf) out.push(buf);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Render inline markdown (bold/italic/code/strikethrough/links/auto-links). */
|
||||
function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
||||
const out: React.ReactNode[] = [];
|
||||
@@ -267,11 +334,18 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
||||
// Inline code: `...`
|
||||
const codeM = rest.match(/^`([^`\n]+)`/);
|
||||
if (codeM) {
|
||||
push(
|
||||
const code = (
|
||||
<code className="rounded bg-surface-4 border border-surface-3 px-1.5 py-0.5 font-mono text-[12.5px] text-status-warning">
|
||||
{codeM[1]}
|
||||
</code>
|
||||
);
|
||||
push(
|
||||
PATH_RE.exec(codeM[1]!)?.[0] === codeM[1] ? (
|
||||
<PathLink path={codeM[1]!}>{code}</PathLink>
|
||||
) : (
|
||||
code
|
||||
)
|
||||
);
|
||||
i += codeM[0].length;
|
||||
continue;
|
||||
}
|
||||
@@ -330,22 +404,21 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
||||
}
|
||||
|
||||
// Auto-link
|
||||
const urlM = rest.match(/^https?:\/\/[^\s<>()]+[^\s<>().,!?;:'"]/);
|
||||
const urlM = rest.match(URL_RE);
|
||||
if (urlM) {
|
||||
push(
|
||||
<a
|
||||
href={urlM[0]}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-violet-700 dark:text-violet-300 hover:text-violet-800 dark:hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70 break-all"
|
||||
>
|
||||
{urlM[0]}
|
||||
</a>
|
||||
);
|
||||
push(<UrlLink url={urlM[0]} />);
|
||||
i += urlM[0].length;
|
||||
continue;
|
||||
}
|
||||
|
||||
// An absolute path at a word start
|
||||
const pathM = atWordStart(text, i) ? PATH_RE.exec(rest) : null;
|
||||
if (pathM) {
|
||||
push(<PathLink path={pathM[0]}>{pathM[0]}</PathLink>);
|
||||
i += pathM[0].length;
|
||||
continue;
|
||||
}
|
||||
|
||||
buf += text[i]!;
|
||||
i++;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
* MarkdownContent for prose. Used by ConversationView as the main body of
|
||||
* the Conversation tab on the Session detail page. An image the prompt box
|
||||
* attached reaches the transcript as its saved path; a user turn shows the
|
||||
* image instead (`GET /api/run/drop/:name`). Each assistant turn that changed
|
||||
* image instead (`GET /api/run/drop/:name`), and Claude Code's own
|
||||
* `[Image: source: <drop>]` line for it joins that turn rather than showing as
|
||||
* a System row; URLs and absolute paths in a turn are links (a path opens
|
||||
* 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
|
||||
@@ -124,7 +127,7 @@ const SENDER_STYLES: Record<
|
||||
},
|
||||
};
|
||||
import { ToolCallBlock, buildSummary, renderInput, renderResult } from "./ToolCallBlock";
|
||||
import { MarkdownContent } from "./MarkdownContent";
|
||||
import { MarkdownContent, OpenFileContext, linkify } from "./MarkdownContent";
|
||||
import { fmt, formatModelName, formatMs, parseDate } from "../../lib/format";
|
||||
import { parseTuiSegments, stripAnsi, hasTuiTags, type TuiSegment } from "./tuiSegments";
|
||||
|
||||
@@ -440,7 +443,26 @@ export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[]
|
||||
endTurn(key);
|
||||
return items.push({ kind: "user", key, msg });
|
||||
}
|
||||
if (sender !== "assistant") return items.push({ kind: "row", key, msg });
|
||||
if (sender !== "assistant") {
|
||||
// Claude Code's line for the image a prompt attached: the image joins that prompt.
|
||||
const paths = sender === "system" ? imageSourcePaths(msg) : null;
|
||||
const prompt = paths ? [...items].reverse().find((i) => i.kind === "user") : undefined;
|
||||
if (paths && prompt?.kind === "user") {
|
||||
prompt.msg = {
|
||||
...prompt.msg,
|
||||
content: [
|
||||
...prompt.msg.content.map((c) =>
|
||||
c.type === "text" && c.text
|
||||
? { ...c, text: c.text.replace(/\[Image #\d+\]/g, "") }
|
||||
: c
|
||||
),
|
||||
{ type: "text", text: paths.join("\n") },
|
||||
],
|
||||
};
|
||||
return;
|
||||
}
|
||||
return items.push({ kind: "row", key, msg });
|
||||
}
|
||||
msg.content.forEach((block, b) => {
|
||||
const last = items[items.length - 1];
|
||||
if (block.type === "tool_use" && EDIT_TOOLS.has(block.name ?? "")) edits.push(block);
|
||||
@@ -546,40 +568,44 @@ export function MessageList({ messages, loading, onOpenFile, live = false }: Mes
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[max(48rem,70%)] space-y-4 px-4 py-4">
|
||||
{items.map((item) => {
|
||||
switch (item.kind) {
|
||||
case "event":
|
||||
return (
|
||||
<SessionEventRow
|
||||
key={item.key}
|
||||
title={item.msg.title}
|
||||
timestamp={item.msg.timestamp}
|
||||
/>
|
||||
);
|
||||
case "row":
|
||||
return <MessageRow key={item.key} msg={item.msg} toolResultMap={toolResultMap} />;
|
||||
case "user":
|
||||
return <UserBubble key={item.key} msg={item.msg} />;
|
||||
case "text":
|
||||
return <AssistantText key={item.key} msg={item.msg} text={item.text} />;
|
||||
case "work":
|
||||
return <WorkGroup key={item.key} blocks={item.blocks} toolResultMap={toolResultMap} />;
|
||||
case "edits":
|
||||
// The card closes a finished turn; one still working has none yet.
|
||||
if (pending && item === items[items.length - 1]) return null;
|
||||
return (
|
||||
<EditedFiles
|
||||
key={item.key}
|
||||
blocks={item.blocks}
|
||||
toolResultMap={toolResultMap}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
{pending && <RunningStep block={pending.block} since={pending.since} />}
|
||||
</div>
|
||||
<OpenFileContext.Provider value={onOpenFile}>
|
||||
<div className="mx-auto w-full max-w-[max(48rem,70%)] space-y-4 px-4 py-4">
|
||||
{items.map((item) => {
|
||||
switch (item.kind) {
|
||||
case "event":
|
||||
return (
|
||||
<SessionEventRow
|
||||
key={item.key}
|
||||
title={item.msg.title}
|
||||
timestamp={item.msg.timestamp}
|
||||
/>
|
||||
);
|
||||
case "row":
|
||||
return <MessageRow key={item.key} msg={item.msg} toolResultMap={toolResultMap} />;
|
||||
case "user":
|
||||
return <UserBubble key={item.key} msg={item.msg} />;
|
||||
case "text":
|
||||
return <AssistantText key={item.key} msg={item.msg} text={item.text} />;
|
||||
case "work":
|
||||
return (
|
||||
<WorkGroup key={item.key} blocks={item.blocks} toolResultMap={toolResultMap} />
|
||||
);
|
||||
case "edits":
|
||||
// The card closes a finished turn; one still working has none yet.
|
||||
if (pending && item === items[items.length - 1]) return null;
|
||||
return (
|
||||
<EditedFiles
|
||||
key={item.key}
|
||||
blocks={item.blocks}
|
||||
toolResultMap={toolResultMap}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
{pending && <RunningStep block={pending.block} since={pending.since} />}
|
||||
</div>
|
||||
</OpenFileContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -646,18 +672,37 @@ function RunningStep({ block, since }: { block: TranscriptContent | null; since:
|
||||
const meta = "text-[10px] font-mono text-fg-muted";
|
||||
|
||||
// What the prompt box sends for an attached image: its saved path under the
|
||||
// drop dir (`<tmp>/agenthub-drops-<uid>/<16 hex>[-name].png`, server/routes/run.js).
|
||||
// drop dir (`<data dir>/drops/<16 hex>[-name].png`, server/routes/run.js; the
|
||||
// older `<tmp>/agenthub-drops-<uid>/` still in past transcripts).
|
||||
const DROP_IMAGE =
|
||||
/(?<=^|\s)\S*[\\/]agenthub-drops--?\d+[\\/]([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.(?:png|jpe?g|gif|webp))(?=\s|$)/gi;
|
||||
/(?<=^|\s)\S*[\\/](?:agenthub-drops--?\d+|drops)[\\/]([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.(?:png|jpe?g|gif|webp))(?=\s|$)/gi;
|
||||
// Claude Code's own line for an image it read off a pasted path (`isMeta`).
|
||||
const IMAGE_SOURCE = /^\[Image: source: (.+)\]$/;
|
||||
|
||||
/** A user turn's text without its dropped-image paths, and those images' names. */
|
||||
/** A user turn's text without its dropped-image paths (and Claude Code's
|
||||
* `[Image #N]` placeholders for them), and those images' names. */
|
||||
export function splitDropImages(text: string): { text: string; images: string[] } {
|
||||
const images: string[] = [];
|
||||
const rest = text.replace(DROP_IMAGE, (_all, name: string) => {
|
||||
images.push(name);
|
||||
return "";
|
||||
});
|
||||
return { text: images.length ? rest.replace(/[ \t]+$/gm, "").trim() : text, images };
|
||||
return {
|
||||
text: images.length
|
||||
? rest
|
||||
.replace(/\[Image #\d+\]/g, "")
|
||||
.replace(/[ \t]+$/gm, "")
|
||||
.trim()
|
||||
: text,
|
||||
images,
|
||||
};
|
||||
}
|
||||
|
||||
/** The drop paths of a meta message made only of `[Image: source: <drop>]` lines. */
|
||||
function imageSourcePaths(msg: TranscriptMessage): string[] | null {
|
||||
const lines = msg.content.flatMap((c) => (c.type === "text" ? (c.text ?? "").split("\n") : []));
|
||||
const paths = lines.map((l) => IMAGE_SOURCE.exec(l.trim())?.[1] ?? "");
|
||||
return paths.length && paths.every((p) => splitDropImages(p).images.length) ? paths : null;
|
||||
}
|
||||
|
||||
function DropImage({ name }: { name: string }) {
|
||||
@@ -739,7 +784,7 @@ const UserBubble = memo(function UserBubble({ msg }: { msg: TranscriptMessage })
|
||||
const plain = !hasTuiTags(text) && !isSkillContent(text) && !isTaskNotification(text);
|
||||
return plain ? (
|
||||
<p key={b} className="whitespace-pre-wrap break-words leading-relaxed">
|
||||
{stripAnsi(text).trim()}
|
||||
{linkify(stripAnsi(text).trim())}
|
||||
</p>
|
||||
) : (
|
||||
renderTextBlock(text, b)
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
* @description An image the prompt box attached reaches the transcript as its
|
||||
* saved drop path; the user's turn shows the image (served by
|
||||
* `GET /api/run/drop/:name`), opens it full size on the page, and keeps any
|
||||
* other path as typed.
|
||||
* other path as typed. Claude Code turns a pasted drop path into an image
|
||||
* block plus an `[Image: source: <path>]` meta line: the image joins the turn.
|
||||
* URLs and absolute paths in a turn are links.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
@@ -22,6 +24,11 @@ describe("dropped images in a user turn", () => {
|
||||
expect(splitDropImages("C:\\Temp\\agenthub-drops--1\\0123456789abcdef.webp").images).toEqual([
|
||||
"0123456789abcdef.webp",
|
||||
]);
|
||||
// The drop dir since the data-dir move.
|
||||
expect(splitDropImages(`[Image #1]fix this /home/me/.claude/agenthub/drops/${SHOT}`)).toEqual({
|
||||
text: "fix this",
|
||||
images: [SHOT],
|
||||
});
|
||||
// Not a drop, or not an image: left as typed.
|
||||
for (const text of [
|
||||
"/home/me/shot.png",
|
||||
@@ -51,4 +58,56 @@ describe("dropped images in a user turn", () => {
|
||||
fireEvent.click(img);
|
||||
expect(screen.getByTestId("image-lightbox").parentElement).toBe(document.body);
|
||||
});
|
||||
|
||||
it("folds Claude Code's [Image: source:] meta line into the turn", () => {
|
||||
const messages = [
|
||||
{
|
||||
type: "user",
|
||||
sender: "user",
|
||||
timestamp: "2026-10-09T09:43:00.000Z",
|
||||
content: [{ type: "text", text: "[Image #1]Hãy giúp tôi fix lỗi" }],
|
||||
},
|
||||
{
|
||||
type: "user",
|
||||
sender: "system",
|
||||
timestamp: "2026-10-09T09:43:11.000Z",
|
||||
content: [
|
||||
{ type: "text", text: `[Image: source: /home/me/.claude/agenthub/drops/${SHOT}]` },
|
||||
],
|
||||
},
|
||||
] as TranscriptMessage[];
|
||||
render(<MessageList messages={messages} loading={false} />);
|
||||
|
||||
expect(screen.queryByText("System")).toBeNull();
|
||||
const bubble = screen.getByTestId("chat-user");
|
||||
expect(bubble).toHaveTextContent("Hãy giúp tôi fix lỗi");
|
||||
expect(bubble).not.toHaveTextContent("[Image #1]");
|
||||
expect(screen.getByRole("img", { name: SHOT }).getAttribute("src")).toBe(
|
||||
`/api/run/drop/${SHOT}`
|
||||
);
|
||||
});
|
||||
|
||||
it("makes URLs and absolute paths clickable", () => {
|
||||
const opened: string[] = [];
|
||||
const messages = [
|
||||
{
|
||||
type: "user",
|
||||
sender: "user",
|
||||
timestamp: "2026-10-09T09:43:00.000Z",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "xem (https://git.example.com/a/issues/69) và /home/me/repo/server/x.js.",
|
||||
},
|
||||
],
|
||||
},
|
||||
] as TranscriptMessage[];
|
||||
render(<MessageList messages={messages} loading={false} onOpenFile={(p) => opened.push(p)} />);
|
||||
|
||||
const link = screen.getByRole("link", { name: "https://git.example.com/a/issues/69" });
|
||||
expect(link.getAttribute("href")).toBe("https://git.example.com/a/issues/69");
|
||||
expect(link.getAttribute("target")).toBe("_blank");
|
||||
fireEvent.click(screen.getByRole("button", { name: "/home/me/repo/server/x.js" }));
|
||||
expect(opened).toEqual(["/home/me/repo/server/x.js"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -119,6 +119,7 @@ function HomeBody({
|
||||
onMenuAction,
|
||||
onRenamed,
|
||||
onSourcesChange,
|
||||
sourcesVersion = 0,
|
||||
}: {
|
||||
lane: Lane;
|
||||
/** Row-menu items the page carries out (its modals); rename is done here. */
|
||||
@@ -127,6 +128,9 @@ function HomeBody({
|
||||
onRenamed?: () => void;
|
||||
/** Called with the sources after a folder or file is added or removed. */
|
||||
onSourcesChange?: (view: LaneSourcesView) => void;
|
||||
/** Bumped when the folder list changed elsewhere (the Changes panel's
|
||||
* Close repository): the Folder list re-reads. */
|
||||
sourcesVersion?: number;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const navigate = useNavigate();
|
||||
@@ -314,6 +318,19 @@ function HomeBody({
|
||||
};
|
||||
}, [folderOpen, lane.id, prefs.showUntracked, prefs.hideSources, view?.sources]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sourcesVersion) return;
|
||||
let live = true;
|
||||
api.lanes
|
||||
.sources(lane.id)
|
||||
.then((v) => live && setView(v))
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- on a bump only
|
||||
}, [sourcesVersion]);
|
||||
|
||||
const run = async (fn: () => Promise<LaneSourcesView>) => {
|
||||
setError(null);
|
||||
try {
|
||||
|
||||
@@ -31,7 +31,9 @@ export interface PreviewScope {
|
||||
}
|
||||
|
||||
const IMAGE_RE = /\.(png|jpe?g|gif|webp)$/i;
|
||||
const DROP_RE = /\/agenthub-drops-\d+\/([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.[A-Za-z0-9]+)$/;
|
||||
// The drop dir (`<data dir>/drops/`), or the older `<tmp>/agenthub-drops-<uid>/`.
|
||||
const DROP_RE =
|
||||
/\/(?:agenthub-drops-\d+|drops)\/([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.[A-Za-z0-9]+)$/;
|
||||
|
||||
export const baseName = (p: string) => p.split("/").pop() || p;
|
||||
/** `report.v2.xlsx` → `XLSX`; none → "". */
|
||||
|
||||
@@ -48,6 +48,8 @@
|
||||
"changes.bin": "bin",
|
||||
"changes.binary": "Binary file — not shown.",
|
||||
"changes.collapse": "Collapse the changes panel",
|
||||
"changes.closeRepo": "Close repository",
|
||||
"changes.closeOtherRepos": "Close other repositories",
|
||||
"changes.collapseAll": "Collapse every file",
|
||||
"changes.collapseAllFolders": "Collapse all folders",
|
||||
"changes.collapseContext": "Show fewer surrounding lines",
|
||||
@@ -295,7 +297,6 @@
|
||||
"menu.remove": "Delete workspace…",
|
||||
"panel.changes": "Changes",
|
||||
"panel.analyst": "Analyst",
|
||||
"panel.folder": "Folder to show changes for",
|
||||
"replay.placeholder": "Message to continue this session…",
|
||||
"replay.hint": "Sending resumes the session in a free pane.",
|
||||
"replay.send": "Send",
|
||||
|
||||
@@ -48,6 +48,8 @@
|
||||
"changes.bin": "bin",
|
||||
"changes.binary": "File nhị phân — không hiển thị.",
|
||||
"changes.collapse": "Thu gọn bảng thay đổi",
|
||||
"changes.closeRepo": "Đóng repository",
|
||||
"changes.closeOtherRepos": "Đóng các repository khác",
|
||||
"changes.collapseAll": "Thu gọn tất cả file",
|
||||
"changes.collapseAllFolders": "Thu gọn tất cả thư mục",
|
||||
"changes.collapseContext": "Bớt dòng xung quanh",
|
||||
@@ -295,7 +297,6 @@
|
||||
"menu.remove": "Xóa workspace…",
|
||||
"panel.changes": "Thay đổi",
|
||||
"panel.analyst": "Analyst",
|
||||
"panel.folder": "Thư mục để xem thay đổi",
|
||||
"replay.placeholder": "Nhắn để tiếp tục session này…",
|
||||
"replay.hint": "Gửi tin sẽ resume session trong một pane trống.",
|
||||
"replay.send": "Gửi",
|
||||
|
||||
@@ -1344,6 +1344,7 @@ export interface UpdateStatusPayload {
|
||||
situation?:
|
||||
| "plugin_managed"
|
||||
| "release_available"
|
||||
| "diverged_from_release"
|
||||
| "tracking_canonical"
|
||||
| "fork_or_diverged_tracking"
|
||||
| "feature_branch"
|
||||
|
||||
+187
-98
@@ -55,7 +55,15 @@ import { runWorktreeSession, type RunWorktree } from "../lib/runWorktree";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PanelLeftClose, PanelRightClose, Maximize2, Minimize2, ChartColumn } from "lucide-react";
|
||||
import {
|
||||
PanelLeftClose,
|
||||
PanelRightClose,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
ChartColumn,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import type {
|
||||
CwdSuggestion,
|
||||
@@ -66,6 +74,7 @@ import type {
|
||||
} from "../lib/api";
|
||||
import type { Lane, LaneFeature, ProofFeature, Session } from "../lib/types";
|
||||
import { toast } from "../lib/toast";
|
||||
import { ContextMenu, MenuItem, useContextMenu } from "../components/ContextMenu";
|
||||
import { confirmDialog, confirmDiscard } from "../lib/confirm";
|
||||
import { keepIfSame } from "../lib/keepIfSame";
|
||||
import { useWorkLanes } from "../hooks/useLanes";
|
||||
@@ -709,9 +718,27 @@ export function Workspace() {
|
||||
? sessionParam
|
||||
: undefined;
|
||||
|
||||
// The workspace's extra folders: each is its own repo the Changes panel can
|
||||
// show instead (`?source=`), picked in its header. Read per workspace, and
|
||||
// again from the home's Folder list whenever it adds or removes one.
|
||||
// An unsaved whole-file edit in a Changes panel section, by section key. While
|
||||
// one is open the panel stays on the tree it was opened in — moving it
|
||||
// (another workspace, session or worktree) would remount it and drop the
|
||||
// edit — until the user says to discard it, or saves.
|
||||
const [dirtyBy, setDirtyBy] = useState<Record<string, boolean>>({});
|
||||
const changesDirty = Object.values(dirtyBy).some(Boolean);
|
||||
const dirtyFor = useCallback(
|
||||
(key: string) => (dirty: boolean) => setDirtyBy((d) => ({ ...d, [key]: dirty })),
|
||||
[]
|
||||
);
|
||||
const wantPanel =
|
||||
selectedLaneId === null ? null : { laneId: selectedLaneId, session: gitSession };
|
||||
const keyOf = (p: typeof wantPanel) => (p ? `${p.laneId}:${p.session ?? ""}` : "");
|
||||
const wantPanelKey = keyOf(wantPanel);
|
||||
const [panelAt, setPanelAt] = useState(wantPanel);
|
||||
const panelKey = keyOf(panelAt);
|
||||
|
||||
// The workspace's extra folders: each is its own repo, stacked under the
|
||||
// workspace folder's in the Changes panel (`?source=`). Read for the
|
||||
// workspace the panel shows, and again from the home's Folder list whenever
|
||||
// it adds or removes one.
|
||||
const [gitFolders, setGitFolders] = useState<{ laneId: number; items: LaneSource[] } | null>(
|
||||
null
|
||||
);
|
||||
@@ -723,35 +750,37 @@ export function Workspace() {
|
||||
}),
|
||||
[]
|
||||
);
|
||||
const panelLaneId = panelAt?.laneId ?? null;
|
||||
useEffect(() => {
|
||||
if (selectedLaneId === null) return;
|
||||
api.lanes.sources(selectedLaneId).then(
|
||||
(v) => takeGitFolders(selectedLaneId, v),
|
||||
() => setGitFolders({ laneId: selectedLaneId, items: [] })
|
||||
if (panelLaneId === null) return;
|
||||
api.lanes.sources(panelLaneId).then(
|
||||
(v) => takeGitFolders(panelLaneId, v),
|
||||
() => setGitFolders({ laneId: panelLaneId, items: [] })
|
||||
);
|
||||
}, [selectedLaneId, takeGitFolders]);
|
||||
const folders = gitFolders?.laneId === selectedLaneId ? gitFolders.items : [];
|
||||
const [gitSourcePick, setGitSourcePick] = useState<{ laneId: number; id: number } | null>(null);
|
||||
// A pick holds for its own workspace, while that folder is still listed.
|
||||
const gitSource =
|
||||
gitSourcePick?.laneId === selectedLaneId && folders.some((f) => f.id === gitSourcePick.id)
|
||||
? gitSourcePick.id
|
||||
: undefined;
|
||||
|
||||
// An unsaved whole-file edit in the Changes panel. While it is open the
|
||||
// panel stays on the tree it was opened in — moving it (another workspace,
|
||||
// session, worktree or folder) would remount it and drop the edit — until
|
||||
// the user says to discard it, or saves.
|
||||
const [changesDirty, setChangesDirty] = useState(false);
|
||||
const wantPanel =
|
||||
selectedLaneId === null
|
||||
? null
|
||||
: { laneId: selectedLaneId, session: gitSession, source: gitSource };
|
||||
const keyOf = (p: typeof wantPanel) =>
|
||||
p ? `${p.laneId}:${p.session ?? ""}:${p.source ?? ""}` : "";
|
||||
const wantPanelKey = keyOf(wantPanel);
|
||||
const [panelAt, setPanelAt] = useState(wantPanel);
|
||||
const panelKey = keyOf(panelAt);
|
||||
}, [panelLaneId, takeGitFolders]);
|
||||
const folders = gitFolders?.laneId === panelLaneId ? gitFolders.items : [];
|
||||
// Sections folded shut ("" = the workspace folder, else the folder's id).
|
||||
const [foldedSources, setFoldedSources] = useState<Set<string>>(new Set());
|
||||
// Right-click on a section: close extra folders — which REMOVES them from the
|
||||
// workspace (the folder stays on disk). The workspace folder never closes.
|
||||
const sourceCtx = useContextMenu<string>();
|
||||
// Bumped after a close here, so the home's Folder list re-reads.
|
||||
const [sourcesVersion, setSourcesVersion] = useState(0);
|
||||
const closeSources = async (laneId: number, keys: string[]) => {
|
||||
if (keys.some((k) => dirtyBy[k]) && !(await confirmDiscard())) return;
|
||||
try {
|
||||
let view: LaneSourcesView | null = null;
|
||||
for (const k of keys) view = await api.lanes.removeSource(laneId, Number(k));
|
||||
if (view) takeGitFolders(laneId, view);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
api.lanes.sources(laneId).then(
|
||||
(v) => takeGitFolders(laneId, v),
|
||||
() => undefined
|
||||
);
|
||||
}
|
||||
setSourcesVersion((n) => n + 1);
|
||||
};
|
||||
const asking = useRef(false);
|
||||
useEffect(() => {
|
||||
if (wantPanelKey === panelKey) return;
|
||||
@@ -764,7 +793,7 @@ export function Workspace() {
|
||||
void confirmDiscard().then((ok) => {
|
||||
asking.current = false;
|
||||
if (!ok) return;
|
||||
setChangesDirty(false);
|
||||
setDirtyBy({});
|
||||
setPanelAt(wantPanel);
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the keys
|
||||
@@ -780,8 +809,12 @@ export function Workspace() {
|
||||
const onOpen = (e: Event) => {
|
||||
const { laneId, path } = (e as CustomEvent<OpenInChanges>).detail;
|
||||
if (laneId !== panelLaneRef.current) return;
|
||||
// Its path is the workspace folder's: back from an extra folder first.
|
||||
setGitSourcePick(null);
|
||||
// Its path is the workspace folder's: unfold that section.
|
||||
setFoldedSources((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete("");
|
||||
return next;
|
||||
});
|
||||
setChangesOpen(true);
|
||||
setChangesRequest((r) => ({ path, nonce: (r?.nonce ?? 0) + 1 }));
|
||||
};
|
||||
@@ -792,10 +825,7 @@ export function Workspace() {
|
||||
useEffect(() => {
|
||||
if (!changesOpen) setChangesRequest(null);
|
||||
}, [changesOpen]);
|
||||
// Not on a folder pick: the request above makes one, to land in the panel
|
||||
// it remounts; the picker drops the request itself.
|
||||
const panelTreeKey = panelAt ? `${panelAt.laneId}:${panelAt.session ?? ""}` : "";
|
||||
useEffect(() => setChangesRequest(null), [panelTreeKey]);
|
||||
useEffect(() => setChangesRequest(null), [panelKey]);
|
||||
/** Runs `fn` once an unsaved edit (if any) may be dropped. */
|
||||
const afterDrop = (fn: () => void) => {
|
||||
if (!changesDirty) return fn();
|
||||
@@ -1036,6 +1066,7 @@ export function Workspace() {
|
||||
lane={currentLane}
|
||||
onRenamed={() => void refreshLanes()}
|
||||
onSourcesChange={(v) => takeGitFolders(currentLane.id, v)}
|
||||
sourcesVersion={sourcesVersion}
|
||||
onMenuAction={(action) => {
|
||||
if (action === "new-here") {
|
||||
setAddLanePrefill({ sourceRepo: currentLane.source_repo || currentLane.cwd });
|
||||
@@ -1268,34 +1299,6 @@ export function Workspace() {
|
||||
<span className="px-1 text-xs font-medium text-fg-secondary">
|
||||
{tLanes("panel.changes")}
|
||||
</span>
|
||||
{/* Which repo the panel shows: the workspace's folder or
|
||||
one of its extra folders. Only when it has any. */}
|
||||
{folders.length > 0 && (
|
||||
<select
|
||||
data-testid="panel-git-folder"
|
||||
value={gitSource ?? ""}
|
||||
onChange={(e) => {
|
||||
setChangesRequest(null);
|
||||
setGitSourcePick(
|
||||
e.target.value
|
||||
? { laneId: currentLane.id, id: Number(e.target.value) }
|
||||
: null
|
||||
);
|
||||
}}
|
||||
aria-label={tLanes("panel.folder")}
|
||||
title={tLanes("panel.folder")}
|
||||
className="min-w-0 max-w-[12rem] truncate rounded-md border border-border bg-surface-2 px-1.5 py-0.5 text-xs text-fg-primary"
|
||||
>
|
||||
<option value="">
|
||||
{currentLane.cwd.split(/[\\/]/).filter(Boolean).pop() || currentLane.cwd}
|
||||
</option>
|
||||
{folders.map((f) => (
|
||||
<option key={f.id} value={f.id}>
|
||||
{f.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{/* The opened session's analysis page — a page of its own. */}
|
||||
<button
|
||||
type="button"
|
||||
@@ -1350,30 +1353,114 @@ export function Workspace() {
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col px-3 pt-2">
|
||||
{panelAt && (
|
||||
<LaneChangesPanel
|
||||
// A new tree is a new panel: no read for the previous one
|
||||
// can land in it. `panelAt` holds still while an edit is
|
||||
// unsaved (see above).
|
||||
key={panelKey}
|
||||
laneId={panelAt.laneId}
|
||||
session={panelAt.session}
|
||||
source={panelAt.source}
|
||||
full={changesFull}
|
||||
wide={changesRatio(changesSize) >= 0.45}
|
||||
onDirtyChange={setChangesDirty}
|
||||
openRequest={panelAt.source ? null : changesRequest}
|
||||
// A sibling worktree forks from the lane's SOURCE repo when
|
||||
// it has one (this lane is itself a worktree of it); an
|
||||
// adopted lane is its own repo.
|
||||
onNewLane={(branch) => {
|
||||
setAddLanePrefill({
|
||||
sourceRepo: currentLane.source_repo || currentLane.cwd,
|
||||
branch,
|
||||
});
|
||||
setAddLaneOpen(true);
|
||||
}}
|
||||
/>
|
||||
{panelAt &&
|
||||
[
|
||||
{ key: "", source: undefined as number | undefined },
|
||||
...folders.map((f) => ({ key: String(f.id), source: f.id })),
|
||||
].map(({ key, source }, i) => {
|
||||
const panel = (
|
||||
<LaneChangesPanel
|
||||
// A new tree is a new panel: no read for the previous
|
||||
// one can land in it. `panelAt` holds still while an
|
||||
// edit is unsaved (see above).
|
||||
key={`${panelKey}:${key}`}
|
||||
laneId={panelAt.laneId}
|
||||
session={panelAt.session}
|
||||
source={source}
|
||||
full={changesFull}
|
||||
wide={changesRatio(changesSize) >= 0.45}
|
||||
onDirtyChange={dirtyFor(key)}
|
||||
openRequest={source ? null : changesRequest}
|
||||
// A sibling worktree forks from the lane's SOURCE repo
|
||||
// when it has one (this lane is itself a worktree of
|
||||
// it); an adopted lane is its own repo.
|
||||
onNewLane={(branch) => {
|
||||
setAddLanePrefill({
|
||||
sourceRepo: currentLane.source_repo || currentLane.cwd,
|
||||
branch,
|
||||
});
|
||||
setAddLaneOpen(true);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
// No extra folders: the one panel, as it always was.
|
||||
if (!folders.length) return panel;
|
||||
// Else every repo is a section, stacked like VS Code's
|
||||
// Source Control; the open ones share the height. A
|
||||
// folded one stays mounted (CSS), so an unsaved edit in
|
||||
// it survives.
|
||||
const folded = foldedSources.has(key);
|
||||
const name = source
|
||||
? folders[i - 1]!.name
|
||||
: currentLane.cwd.split(/[\\/]/).filter(Boolean).pop() || currentLane.cwd;
|
||||
return (
|
||||
<section
|
||||
key={key}
|
||||
data-testid="panel-git-source"
|
||||
className={`flex flex-col border-b border-border-light ${
|
||||
folded ? "shrink-0" : "min-h-0 flex-1"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={!folded}
|
||||
onContextMenu={(e) => sourceCtx.open(e, key)}
|
||||
onClick={() =>
|
||||
setFoldedSources((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (!next.delete(key)) next.add(key);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
className="flex shrink-0 items-center gap-1 py-1 text-left text-xs font-medium text-fg-secondary hover:text-fg-primary"
|
||||
>
|
||||
{folded ? (
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
|
||||
) : (
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{name}</span>
|
||||
</button>
|
||||
<div className={folded ? "hidden" : "flex min-h-0 flex-1 flex-col"}>
|
||||
{panel}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
{panelAt && sourceCtx.menu && (
|
||||
<ContextMenu
|
||||
x={sourceCtx.menu.x}
|
||||
y={sourceCtx.menu.y}
|
||||
onClose={sourceCtx.close}
|
||||
testId="panel-git-source-menu"
|
||||
>
|
||||
{sourceCtx.menu.item !== "" && (
|
||||
<MenuItem
|
||||
testId="panel-git-source-close"
|
||||
onSelect={() => {
|
||||
const key = sourceCtx.menu!.item;
|
||||
sourceCtx.close();
|
||||
void closeSources(panelAt.laneId, [key]);
|
||||
}}
|
||||
>
|
||||
{tLanes("changes.closeRepo")}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem
|
||||
testId="panel-git-source-close-others"
|
||||
disabled={!folders.some((f) => String(f.id) !== sourceCtx.menu!.item)}
|
||||
onSelect={() => {
|
||||
const key = sourceCtx.menu!.item;
|
||||
sourceCtx.close();
|
||||
void closeSources(
|
||||
panelAt.laneId,
|
||||
folders.map((f) => String(f.id)).filter((k) => k !== key)
|
||||
);
|
||||
}}
|
||||
>
|
||||
{tLanes("changes.closeOtherRepos")}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
@@ -1397,14 +1484,16 @@ export function Workspace() {
|
||||
{tLanes("changes.title")}
|
||||
</span>
|
||||
</button>
|
||||
{panelAt && (
|
||||
<LaneGitRailCounts
|
||||
laneId={panelAt.laneId}
|
||||
session={panelAt.session}
|
||||
source={panelAt.source}
|
||||
onShowAll={() => setChangesOpen(true)}
|
||||
/>
|
||||
)}
|
||||
{panelAt &&
|
||||
[undefined, ...folders.map((f) => f.id)].map((source) => (
|
||||
<LaneGitRailCounts
|
||||
key={source ?? ""}
|
||||
laneId={panelAt.laneId}
|
||||
session={panelAt.session}
|
||||
source={source}
|
||||
onShowAll={() => setChangesOpen(true)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -95,6 +95,10 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
: [],
|
||||
})
|
||||
),
|
||||
// Closing a folder's section removes it from the workspace.
|
||||
removeSource: r({
|
||||
sources: [{ id: 8, kind: "file", path: "/d/notes.md", name: "notes.md", missing: false }],
|
||||
}),
|
||||
gitTree: r({ entries: [] }),
|
||||
// A file opened in the panel on a chat's request.
|
||||
changes: r({ available: false }),
|
||||
@@ -244,32 +248,50 @@ describe("Workspace — the Changes panel's width", () => {
|
||||
expect(api.lanes.gitFile).toHaveBeenCalledWith(1, "src/a.ts", "work");
|
||||
});
|
||||
|
||||
it("shows an extra folder's repo when picked, and only a workspace's own folders", async () => {
|
||||
await openPanel();
|
||||
const pick = screen.getByTestId("panel-git-folder") as HTMLSelectElement;
|
||||
// The workspace folder, then its folders — never a context file.
|
||||
expect([...pick.options].map((o) => o.text)).toEqual(["alpha", "shop"]);
|
||||
it("stacks every extra folder's repo under the workspace folder's, and only its own folders", async () => {
|
||||
vi.mocked(api.lanes.git).mockClear();
|
||||
fireEvent.change(pick, { target: { value: "7" } });
|
||||
await settle();
|
||||
await openPanel();
|
||||
const sections = screen.getAllByTestId("panel-git-source");
|
||||
// The workspace folder, then its folders — never a context file.
|
||||
expect(sections.map((s) => s.querySelector("button")!.textContent)).toEqual(["alpha", "shop"]);
|
||||
expect(api.lanes.git).toHaveBeenCalledWith(1);
|
||||
expect(api.lanes.git).toHaveBeenCalledWith({ lane: 1, source: 7 });
|
||||
// A chat's file is the workspace folder's: the panel goes back there.
|
||||
// A folded section keeps its panel mounted, only hidden.
|
||||
const toggle = sections[0]!.querySelector("button")!;
|
||||
fireEvent.click(toggle);
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
expect(sections[0]!.querySelector('[data-testid="lane-changes"]')).not.toBeNull();
|
||||
// A chat's file is the workspace folder's: its section unfolds.
|
||||
act(() => {
|
||||
openInChanges(1, "/workspace/alpha", "/workspace/alpha/src/b.ts");
|
||||
});
|
||||
await settle();
|
||||
expect(pick.value).toBe("");
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||
expect(api.lanes.gitFile).toHaveBeenCalledWith(1, "src/b.ts", "work");
|
||||
});
|
||||
|
||||
it("has no folder picker for a workspace with no extra folders", async () => {
|
||||
it("closes an extra folder from its right-click menu, removing it from the workspace — never the workspace folder", async () => {
|
||||
await openPanel();
|
||||
const [main, shop] = screen.getAllByTestId("panel-git-source");
|
||||
fireEvent.contextMenu(main!.querySelector("button")!);
|
||||
expect(screen.queryByTestId("panel-git-source-close")).toBeNull();
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
fireEvent.contextMenu(shop!.querySelector("button")!);
|
||||
fireEvent.click(screen.getByTestId("panel-git-source-close"));
|
||||
await settle();
|
||||
expect(api.lanes.removeSource).toHaveBeenCalledWith(1, 7);
|
||||
// The last folder gone: back to the one plain panel.
|
||||
expect(screen.queryByTestId("panel-git-source")).toBeNull();
|
||||
});
|
||||
|
||||
it("is one plain panel for a workspace with no extra folders", async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/workspace?lane=2"]}>
|
||||
<Workspace />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await settle();
|
||||
expect(screen.queryByTestId("panel-git-folder")).toBeNull();
|
||||
expect(screen.queryByTestId("panel-git-source")).toBeNull();
|
||||
});
|
||||
|
||||
it("collapses from its narrow width too, back to the rail", async () => {
|
||||
|
||||
@@ -7174,7 +7174,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<span
|
||||
class="text-status-warning/90"
|
||||
>
|
||||
UI build v2.6.0 (rebuild client to match)
|
||||
UI build v2.6.1 (rebuild client to match)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+3
-3
@@ -698,7 +698,7 @@ otherwise `400 ENOWORKTREE`, never a fall back to the main folder. With it,
|
||||
|
||||
Every `/api/lanes/:id/git…` route also takes `?source=<id>`: the read or write
|
||||
then works in one of the workspace's extra folders (Context → Folder), each
|
||||
its own repo — the Changes panel's folder picker. The tree is resolved from the
|
||||
its own repo — a section of its own in the Changes panel. The tree is resolved from the
|
||||
`lane_sources` ROW (`sourceFolder` in `server/lib/lane-sources.js`): a `folder`
|
||||
row of THIS workspace that is still a directory — otherwise `404 ENOSOURCE`
|
||||
(another workspace's row and a context-file row included), never a fall back
|
||||
@@ -1571,7 +1571,7 @@ curl http://localhost:4820/api/sessions/sess_abc123
|
||||
GET /api/sessions/:id/outline
|
||||
```
|
||||
|
||||
The session's plan, the files it changed and what it used, read off its successful tool calls (`PostToolUse` events) and its prompts (`UserPromptSubmit`) — the activity panel of a Workspace Chat pane shown alone. `progress` follows `TaskCreate`/`TaskUpdate` by task id (a `TodoWrite` replaces the list wholesale); `outputs` are the paths written or edited with `Write`/`Edit`/`MultiEdit`/`NotebookEdit`; `files` the paths it only `Read` (a file it later changed moves to `outputs`); `uploads` the files attached to its prompts through the composer (paths under the `agenthub-drops-<uid>` dir that `POST /api/run/drop-image|drop-file` made); `memory` the Claude Code memory files (`.claude/**/memory/`) it read or wrote, kept out of the other lists. The path lists are newest first. `skills` are `Skill` calls; `mcp` are MCP server names (`mcp__<server>__<tool>`, a `plugin_` prefix dropped). A file made by a Bash command is not an output here. 404 for an unknown session.
|
||||
The session's plan, the files it changed and what it used, read off its successful tool calls (`PostToolUse` events) and its prompts (`UserPromptSubmit`) — the activity panel of a Workspace Chat pane shown alone. `progress` follows `TaskCreate`/`TaskUpdate` by task id (a `TodoWrite` replaces the list wholesale); `outputs` are the paths written or edited with `Write`/`Edit`/`MultiEdit`/`NotebookEdit`; `files` the paths it only `Read` (a file it later changed moves to `outputs`); `uploads` the files attached to its prompts through the composer (paths under the `<data dir>/drops/` dir — `agenthub-drops-<uid>` in older transcripts — that `POST /api/run/drop-image|drop-file` made); `memory` the Claude Code memory files (`.claude/**/memory/`) it read or wrote, kept out of the other lists. The path lists are newest first. `skills` are `Skill` calls; `mcp` are MCP server names (`mcp__<server>__<tool>`, a `plugin_` prefix dropped). A file made by a Bash command is not an output here. 404 for an unknown session.
|
||||
|
||||
```bash
|
||||
curl http://localhost:4820/api/sessions/sess_abc123/outline
|
||||
@@ -1582,7 +1582,7 @@ curl http://localhost:4820/api/sessions/sess_abc123/outline
|
||||
"progress": [{ "id": "1", "subject": "Build the report", "status": "completed" }],
|
||||
"outputs": ["/home/me/work/Progress report.xlsx"],
|
||||
"files": ["/home/me/work/data/kpi.csv"],
|
||||
"uploads": ["/tmp/agenthub-drops-1000/3f9a0c1d2e4b5a6c-minutes.xlsx"],
|
||||
"uploads": ["/home/me/.claude/agenthub/drops/3f9a0c1d2e4b5a6c-minutes.xlsx"],
|
||||
"memory": ["/home/me/.claude/projects/-home-me-work/memory/launch.md"],
|
||||
"skills": ["xlsx"],
|
||||
"mcp": ["vercel_vercel"]
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.6.0",
|
||||
"version": "2.6.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenthub",
|
||||
"version": "2.6.0",
|
||||
"version": "2.6.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.6.0",
|
||||
"version": "2.6.1",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
|
||||
@@ -3100,7 +3100,7 @@ describe("GET /api/sessions/:id/outline", () => {
|
||||
hook_type: "UserPromptSubmit",
|
||||
data: {
|
||||
session_id: sid,
|
||||
prompt: `compare these ${drop}\n/tmp/agenthub-drops-1000/fedcba9876543210.png`,
|
||||
prompt: `compare these ${drop}\n/home/u/.claude/agenthub/drops/fedcba9876543210.png`,
|
||||
},
|
||||
});
|
||||
const mem = "/home/u/.claude/projects/-w/memory/release.md";
|
||||
@@ -3113,7 +3113,7 @@ describe("GET /api/sessions/:id/outline", () => {
|
||||
await pre("Write", { file_path: mem, content: "x" });
|
||||
|
||||
const { body } = await fetch(`/api/sessions/${sid}/outline`);
|
||||
assert.deepEqual(body.uploads, ["/tmp/agenthub-drops-1000/fedcba9876543210.png", drop]);
|
||||
assert.deepEqual(body.uploads, ["/home/u/.claude/agenthub/drops/fedcba9876543210.png", drop]);
|
||||
// a.ts was read, then edited: an output, no longer a file it only used.
|
||||
assert.deepEqual(body.files, ["/w/src/c.ts"]);
|
||||
assert.deepEqual(body.memory, [mem]);
|
||||
|
||||
@@ -185,8 +185,8 @@ describe("lane-sources instructions and launch", () => {
|
||||
let argv = null;
|
||||
tmux.__setExecImpl((args) => {
|
||||
if (args[0] === "has-session") throw Object.assign(new Error("gone"), { status: 1 });
|
||||
// The CLI rides behind `sh -c <wait for a client> sh` (pty-run.js).
|
||||
if (args[0] === "new-session") argv = args.slice(args.indexOf("--") + 5);
|
||||
// The CLI rides behind `sh -c <cd; wait for a client> sh <cwd>` (pty-run.js).
|
||||
if (args[0] === "new-session") argv = args.slice(args.indexOf("--") + 6);
|
||||
return "";
|
||||
});
|
||||
pty.spawnRun({ laneId: lane.id, cwd: ws, initialPrompt: "-c fix it" });
|
||||
|
||||
@@ -482,10 +482,11 @@ describe("lane start input", () => {
|
||||
assert.equal(created[created.indexOf("-s") + 1], `agenthub-lane-${id}-1`);
|
||||
assert.equal(created[created.indexOf("-c") + 1], cwd);
|
||||
// After `--` only the fixed size-wait wrapper that execs the user's own
|
||||
// default shell (issue #55) — nothing from the request.
|
||||
// default shell (issue #55) in the lane's folder (#69) — nothing from
|
||||
// the request.
|
||||
const argv = created.slice(created.indexOf("--") + 1);
|
||||
assert.deepEqual(argv.slice(0, 2), ["sh", "-c"]);
|
||||
assert.equal(argv.length, 3);
|
||||
assert.deepEqual(argv.slice(3), ["sh", cwd]);
|
||||
assert.match(argv[2], /exec "\$SHELL" -l$/);
|
||||
assert.ok(!created.includes("ignored"));
|
||||
const lane = (await request("GET", `/api/lanes/${id}`)).body.lane;
|
||||
|
||||
@@ -10,6 +10,7 @@ const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "pty-run-test-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
@@ -80,7 +81,7 @@ describe("pty-run", () => {
|
||||
});
|
||||
try {
|
||||
const t0 = Date.now();
|
||||
const child = spawn("sh", ["-c", argv[2]], {
|
||||
const child = spawn("sh", ["-c", argv[2], "sh", os.tmpdir()], {
|
||||
env: { ...process.env, SHELL: "/bin/echo", PATH: `${bin}:${process.env.PATH}` },
|
||||
});
|
||||
let out = "";
|
||||
@@ -168,7 +169,7 @@ describe("pty-run", () => {
|
||||
const respawn = calls.find((c) => c[0] === "respawn-pane");
|
||||
assert.ok(respawn, "expected the idle shell to be replaced by the argv");
|
||||
const argv = respawn.slice(respawn.indexOf("--") + 1);
|
||||
assert.equal(argv[0], "claude");
|
||||
assert.deepEqual(argv.slice(3, 6), ["sh", "/tmp/repo", "claude"]);
|
||||
assert.deepEqual(argv.slice(-2), ["--resume", "abc12345"]);
|
||||
});
|
||||
|
||||
@@ -507,21 +508,32 @@ describe("pty-run", () => {
|
||||
}
|
||||
|
||||
/** The CLI's own command line: what tmux passes after the `--`, less the
|
||||
* `sh -c '<wait for a client>; exec "$@"' sh` wrapper spawnRun puts first. */
|
||||
* `sh -c '<cd; wait for a client>; exec "$@"' sh <cwd>` wrapper spawnRun
|
||||
* puts first. */
|
||||
function paneArgv(newSessionCall) {
|
||||
const argv = newSessionCall.slice(newSessionCall.indexOf("--") + 1);
|
||||
assert.deepEqual([argv[0], argv[1], argv[3]], ["sh", "-c", "sh"]);
|
||||
assert.match(argv[2], /session_attached.*exec "\$@"$/);
|
||||
return argv.slice(4);
|
||||
assert.match(argv[2], /^cd -- "\$1".*session_attached.*exec "\$@"$/);
|
||||
return argv.slice(5);
|
||||
}
|
||||
|
||||
it("runs a Windows pane's command itself — no sh there — and a Terminal as tmux's default shell", () => {
|
||||
const argv = ["C:\\Users\\u\\.local\\bin\\claude.exe", "--model", "opus"];
|
||||
assert.deepEqual(pty.paneArgv(argv, "win32"), argv);
|
||||
assert.deepEqual(pty.paneArgv(null, "win32"), []);
|
||||
assert.equal(pty.paneArgv(argv, "linux")[0], "sh");
|
||||
assert.deepEqual(pty.paneArgv(argv, "linux").slice(-3), argv);
|
||||
assert.match(pty.paneArgv(null, "linux")[2], /exec "\$SHELL" -l$/);
|
||||
assert.deepEqual(pty.paneArgv(argv, "C:\\w", "win32"), argv);
|
||||
assert.deepEqual(pty.paneArgv(null, "C:\\w", "win32"), []);
|
||||
assert.equal(pty.paneArgv(argv, "/w", "linux")[0], "sh");
|
||||
assert.deepEqual(pty.paneArgv(argv, "/w", "linux").slice(-3), argv);
|
||||
assert.match(pty.paneArgv(null, "/w", "linux")[2], /exec "\$SHELL" -l$/);
|
||||
});
|
||||
|
||||
it("starts a pane in its folder even where tmux skips -c (issue #69)", () => {
|
||||
// A tmux server whose own cwd was deleted (a pruned plugin cache dir)
|
||||
// ignores `-c` and hands every pane that deleted cwd.
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pane-cwd-"));
|
||||
const argv = pty.paneArgv(["pwd"], dir, "linux");
|
||||
const out = execFileSync(argv[0], argv.slice(1), { cwd: "/", encoding: "utf8" });
|
||||
assert.equal(fs.realpathSync(out.trim()), fs.realpathSync(dir));
|
||||
fs.rmSync(dir, { recursive: true });
|
||||
});
|
||||
|
||||
it("hands a long prompt to the pane through a file, never tmux's argv (issue #68)", (t) => {
|
||||
@@ -578,6 +590,7 @@ describe("pty-run", () => {
|
||||
[
|
||||
...call.slice(call.indexOf("--") + 2, call.indexOf("--") + 4),
|
||||
"sh",
|
||||
os.tmpdir(),
|
||||
"printf",
|
||||
"%s|",
|
||||
"x y",
|
||||
|
||||
@@ -291,6 +291,39 @@ describe("getUpdatesStatus — release drives update_available", () => {
|
||||
assert.match(status.manual_command, /git merge --ff-only v1\.6\.0/);
|
||||
});
|
||||
|
||||
it("does not offer a fast-forward when HEAD holds commits the newer release lacks", async () => {
|
||||
// The release squashed the local wip commits into a new one: neither side
|
||||
// contains the other, so `merge --ff-only` could only fail.
|
||||
const repo = makeVersionedRepo(tmpDir, "diverged", "1.5.0", null);
|
||||
git(repo, ["checkout", "-b", "rel"]);
|
||||
fs.writeFileSync(
|
||||
path.join(repo, "package.json"),
|
||||
JSON.stringify({ name: "fx", version: "1.6.0" })
|
||||
);
|
||||
git(repo, [...IDENT, "commit", "-am", "release"]);
|
||||
git(repo, ["tag", "v1.6.0"]);
|
||||
git(repo, ["checkout", "main"]);
|
||||
fs.writeFileSync(path.join(repo, "wip.txt"), "wip");
|
||||
git(repo, [...IDENT, "add", "."]);
|
||||
git(repo, [...IDENT, "commit", "-m", "wip"]);
|
||||
const status = await getUpdatesStatus(repo, {
|
||||
skipFetch: true,
|
||||
latestRelease: {
|
||||
ok: true,
|
||||
tag: "v1.6.0",
|
||||
name: "1.6.0",
|
||||
url: "u",
|
||||
published_at: null,
|
||||
notes: null,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(status.update_available, true);
|
||||
assert.equal(status.situation, "diverged_from_release");
|
||||
assert.doesNotMatch(status.manual_command, /--ff-only/);
|
||||
assert.match(status.situation_note, /v1\.6\.0/);
|
||||
});
|
||||
|
||||
it("is up to date — not a dev build — when local version equals the release and no commits are ahead", async () => {
|
||||
const repo = makeVersionedRepo(tmpDir, "level", "1.5.0", "v1.5.0");
|
||||
const status = await getUpdatesStatus(repo, {
|
||||
|
||||
+21
-12
@@ -81,20 +81,29 @@ const WAIT_FOR_CLIENT =
|
||||
const CLIENT_WAIT_MS = 2000;
|
||||
|
||||
/**
|
||||
* A pane's tmux command: `argv` behind {@link WAIT_FOR_CLIENT}, or — with no
|
||||
* `argv` — the login shell behind it. Windows has no `sh` (a WinGet tmux runs
|
||||
* native programs), and an `sh -c` there died at once and took the session
|
||||
* with it, so it gets the command itself, or nothing for tmux's own default
|
||||
* shell.
|
||||
* `sh` that moves into `$1` itself. tmux applies `-c` only while its server's
|
||||
* own cwd still exists: the server is forked from the dashboard's cwd — a
|
||||
* plugin cache dir, deleted by `pruneRetiredPluginRoots` after an update the
|
||||
* server outlives — and from then on every pane started in that deleted dir,
|
||||
* where `claude` dies at once and a shell prints `getcwd` errors (issue #69).
|
||||
*/
|
||||
const IN_CWD = 'cd -- "$1" 2>/dev/null; shift;';
|
||||
|
||||
/**
|
||||
* A pane's tmux command, run in `cwd`: `argv` behind {@link WAIT_FOR_CLIENT},
|
||||
* or — with no `argv` — the login shell behind it. Windows has no `sh` (a
|
||||
* WinGet tmux runs native programs), and an `sh -c` there died at once and
|
||||
* took the session with it, so it gets the command itself, or nothing for
|
||||
* tmux's own default shell.
|
||||
* ponytail: no wait for the attach on Windows, so a pane may first draw at
|
||||
* 80×24 (issue #55); port the wait to PowerShell if that shows.
|
||||
*/
|
||||
function paneArgv(argv, platform = process.platform) {
|
||||
function paneArgv(argv, cwd, platform = process.platform) {
|
||||
if (platform === "win32") return argv || [];
|
||||
// argv rides as sh's positional parameters, never inside the script text.
|
||||
// cwd and argv ride as sh's positional parameters, never inside the script text.
|
||||
return argv
|
||||
? ["sh", "-c", `${WAIT_FOR_CLIENT} exec "$@"`, "sh", ...argv]
|
||||
: ["sh", "-c", `${WAIT_FOR_CLIENT} exec "$SHELL" -l`];
|
||||
? ["sh", "-c", `${IN_CWD} ${WAIT_FOR_CLIENT} exec "$@"`, "sh", cwd, ...argv]
|
||||
: ["sh", "-c", `${IN_CWD} ${WAIT_FOR_CLIENT} exec "$SHELL" -l`, "sh", cwd];
|
||||
}
|
||||
const CODEX_SANDBOXES = new Set(["read-only", "workspace-write", "danger-full-access"]);
|
||||
const CODEX_APPROVALS = new Set(["on-request", "never"]);
|
||||
@@ -614,7 +623,7 @@ function spawnRun(args) {
|
||||
|
||||
if (!tmux.hasSession(id)) {
|
||||
codexPool?.place();
|
||||
tmux.newSession({ name: id, cwd, argv: paneArgv(paneCmd), env });
|
||||
tmux.newSession({ name: id, cwd, argv: paneArgv(paneCmd, cwd), env });
|
||||
record();
|
||||
schedulePaste();
|
||||
} else if (SHELL_COMMANDS.has(tmux.paneCommand(id) || "")) {
|
||||
@@ -625,7 +634,7 @@ function spawnRun(args) {
|
||||
// still answered 200. Run the argv in the pane the user already sees
|
||||
// instead of erroring or opening a second session.
|
||||
codexPool?.place();
|
||||
tmux.sendCommand(id, paneCmd, env, cwd);
|
||||
tmux.sendCommand(id, paneArgv(paneCmd, cwd), env, cwd);
|
||||
record();
|
||||
schedulePaste();
|
||||
}
|
||||
@@ -677,7 +686,7 @@ function spawnShell({ laneId, slot = 0, cwd }) {
|
||||
const paneEnv = member ? require("./run-as").memberPaneEnv(member, env) : env;
|
||||
// Started once the pane has its real size (WAIT_FOR_CLIENT, issue #55), as
|
||||
// the login shell tmux would have run (tmux sets SHELL in the pane).
|
||||
tmux.newSession({ name: id, cwd, argv: paneArgv(null), env: paneEnv });
|
||||
tmux.newSession({ name: id, cwd, argv: paneArgv(null, cwd), env: paneEnv });
|
||||
dashboardRuns?.recordRun({
|
||||
id,
|
||||
sessionId: null,
|
||||
|
||||
+3
-1
@@ -38,7 +38,9 @@ const { execFile, execFileSync } = require("node:child_process");
|
||||
// try/catch and treats failure as "no session" — piping tmux's own
|
||||
// "error connecting to ..." noise to the process's stderr just spams the
|
||||
// journal on every poll while no tmux server is running yet.
|
||||
const EXEC_OPTS = { encoding: "utf8", stdio: ["pipe", "pipe", "ignore"] };
|
||||
// cwd "/": a tmux server is forked from the call that starts it and keeps
|
||||
// that cwd; once it is deleted, tmux ignores every pane's `-c` (issue #69).
|
||||
const EXEC_OPTS = { encoding: "utf8", stdio: ["pipe", "pipe", "ignore"], cwd: "/" };
|
||||
let execImpl = (args, opts = EXEC_OPTS) => execFileSync("tmux", args, opts);
|
||||
|
||||
// Linux: the tmux server lives in the cgroup of whatever started it — the
|
||||
|
||||
@@ -698,6 +698,16 @@ async function getUpdatesStatus(gitRoot = DEFAULT_ROOT, options = {}) {
|
||||
`This dashboard runs from the Claude Code plugin cache (marketplace '${pluginInstall.marketplace}'), ` +
|
||||
`so it can't update itself. Run the commands above, then '/reload-plugins' in Claude Code — ` +
|
||||
`or just run '/agenthub-update', which does both and rebuilds the runtime.`;
|
||||
} else if (release.ok && releaseIsNewer && commitsAheadOfRelease > 0) {
|
||||
// HEAD carries commits the release lacks (e.g. local wip that the release
|
||||
// squashed into a new commit): `merge --ff-only` can only fail, and how to
|
||||
// reconcile is the user's call, so fetch the tag and say why.
|
||||
situation = "diverged_from_release";
|
||||
manualParts = [`cd "${root}"`, `git fetch ${canonicalRemote} --tags`];
|
||||
situationNote =
|
||||
`Your checkout has ${commitsAheadOfRelease} commit(s) that release ${release.tag} doesn't contain, ` +
|
||||
`so it can't be fast-forwarded. If they're already in the release, move onto it with ` +
|
||||
`'git reset --keep ${release.tag}'; otherwise merge or rebase onto ${release.tag}.`;
|
||||
} else if (release.ok && releaseIsNewer) {
|
||||
// Move onto the release TAG rather than the branch tip: the tag is what
|
||||
// was published, and a fast-forward keeps the tracking branch intact (a
|
||||
|
||||
@@ -379,9 +379,10 @@ const outlineEvents = db.prepare(
|
||||
);
|
||||
// A file the composer attached (`/api/run/drop-*` names it), as its path is
|
||||
// pasted into the prompt; and a Claude Code memory file.
|
||||
// The drop dir is `<data dir>/drops/`; older transcripts name `<tmp>/agenthub-drops-<uid>/`.
|
||||
const UPLOAD_RE =
|
||||
/(?:^|\s)(\S*\/agenthub-drops-\d+\/[0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.[A-Za-z0-9]+)/g;
|
||||
const isUpload = (p) => /\/agenthub-drops-\d+\//.test(p);
|
||||
/(?:^|\s)(\S*\/(?:agenthub-drops-\d+|drops)\/[0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.[A-Za-z0-9]+)/g;
|
||||
const isUpload = (p) => /\/(?:agenthub-drops-\d+|drops)\/[0-9a-f]{16}/.test(p);
|
||||
const isMemory = (p) => /\/\.claude\/.*\/memory\//.test(p);
|
||||
/** Records `p` once, newest last. */
|
||||
const touch = (set, p) => {
|
||||
|
||||
Reference in New Issue
Block a user