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/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-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`. |
|
| `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`. |
|
| `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/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 |
|
| `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
|
situation?: // categorical hint for the UI
|
||||||
| "plugin_managed" // ~/.claude/plugins/cache — /apply delegates
|
| "plugin_managed" // ~/.claude/plugins/cache — /apply delegates
|
||||||
| "release_available" // fast-forward onto the release tag
|
| "release_available" // fast-forward onto the release tag
|
||||||
|
| "diverged_from_release" // HEAD has commits the release lacks — fetch only, no apply
|
||||||
| "tracking_canonical"
|
| "tracking_canonical"
|
||||||
| "fork_or_diverged_tracking"
|
| "fork_or_diverged_tracking"
|
||||||
| "feature_branch"
|
| "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. |
|
| 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, 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. |
|
| 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. |
|
| 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
|
### Why Self-Apply Stays Narrow
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
* subset of CommonMark + GFM that actually appears in Claude Code transcripts:
|
* subset of CommonMark + GFM that actually appears in Claude Code transcripts:
|
||||||
* fenced code blocks, ATX headings, ordered/unordered lists, task lists, blockquotes,
|
* fenced code blocks, ATX headings, ordered/unordered lists, task lists, blockquotes,
|
||||||
* horizontal rules, simple tables, inline code, bold, italic, strikethrough, links,
|
* 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
|
* 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
|
* is escaped by React. Code blocks delegate to <CodeBlock /> for syntax highlighting and
|
||||||
@@ -244,6 +246,71 @@ function parseBlocks(src: string): Block[] {
|
|||||||
return blocks;
|
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). */
|
/** Render inline markdown (bold/italic/code/strikethrough/links/auto-links). */
|
||||||
function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
||||||
const out: React.ReactNode[] = [];
|
const out: React.ReactNode[] = [];
|
||||||
@@ -267,11 +334,18 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
|||||||
// Inline code: `...`
|
// Inline code: `...`
|
||||||
const codeM = rest.match(/^`([^`\n]+)`/);
|
const codeM = rest.match(/^`([^`\n]+)`/);
|
||||||
if (codeM) {
|
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">
|
<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]}
|
{codeM[1]}
|
||||||
</code>
|
</code>
|
||||||
);
|
);
|
||||||
|
push(
|
||||||
|
PATH_RE.exec(codeM[1]!)?.[0] === codeM[1] ? (
|
||||||
|
<PathLink path={codeM[1]!}>{code}</PathLink>
|
||||||
|
) : (
|
||||||
|
code
|
||||||
|
)
|
||||||
|
);
|
||||||
i += codeM[0].length;
|
i += codeM[0].length;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -330,22 +404,21 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Auto-link
|
// Auto-link
|
||||||
const urlM = rest.match(/^https?:\/\/[^\s<>()]+[^\s<>().,!?;:'"]/);
|
const urlM = rest.match(URL_RE);
|
||||||
if (urlM) {
|
if (urlM) {
|
||||||
push(
|
push(<UrlLink url={urlM[0]} />);
|
||||||
<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>
|
|
||||||
);
|
|
||||||
i += urlM[0].length;
|
i += urlM[0].length;
|
||||||
continue;
|
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]!;
|
buf += text[i]!;
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,10 @@
|
|||||||
* MarkdownContent for prose. Used by ConversationView as the main body of
|
* MarkdownContent for prose. Used by ConversationView as the main body of
|
||||||
* the Conversation tab on the Session detail page. An image the prompt box
|
* 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
|
* 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
|
* 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
|
* 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`). A thinking block that carries text is
|
||||||
@@ -124,7 +127,7 @@ const SENDER_STYLES: Record<
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
import { ToolCallBlock, buildSummary, renderInput, renderResult } from "./ToolCallBlock";
|
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 { fmt, formatModelName, formatMs, parseDate } from "../../lib/format";
|
||||||
import { parseTuiSegments, stripAnsi, hasTuiTags, type TuiSegment } from "./tuiSegments";
|
import { parseTuiSegments, stripAnsi, hasTuiTags, type TuiSegment } from "./tuiSegments";
|
||||||
|
|
||||||
@@ -440,7 +443,26 @@ export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[]
|
|||||||
endTurn(key);
|
endTurn(key);
|
||||||
return items.push({ kind: "user", key, msg });
|
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) => {
|
msg.content.forEach((block, b) => {
|
||||||
const last = items[items.length - 1];
|
const last = items[items.length - 1];
|
||||||
if (block.type === "tool_use" && EDIT_TOOLS.has(block.name ?? "")) edits.push(block);
|
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 (
|
return (
|
||||||
<div className="mx-auto w-full max-w-[max(48rem,70%)] space-y-4 px-4 py-4">
|
<OpenFileContext.Provider value={onOpenFile}>
|
||||||
{items.map((item) => {
|
<div className="mx-auto w-full max-w-[max(48rem,70%)] space-y-4 px-4 py-4">
|
||||||
switch (item.kind) {
|
{items.map((item) => {
|
||||||
case "event":
|
switch (item.kind) {
|
||||||
return (
|
case "event":
|
||||||
<SessionEventRow
|
return (
|
||||||
key={item.key}
|
<SessionEventRow
|
||||||
title={item.msg.title}
|
key={item.key}
|
||||||
timestamp={item.msg.timestamp}
|
title={item.msg.title}
|
||||||
/>
|
timestamp={item.msg.timestamp}
|
||||||
);
|
/>
|
||||||
case "row":
|
);
|
||||||
return <MessageRow key={item.key} msg={item.msg} toolResultMap={toolResultMap} />;
|
case "row":
|
||||||
case "user":
|
return <MessageRow key={item.key} msg={item.msg} toolResultMap={toolResultMap} />;
|
||||||
return <UserBubble key={item.key} msg={item.msg} />;
|
case "user":
|
||||||
case "text":
|
return <UserBubble key={item.key} msg={item.msg} />;
|
||||||
return <AssistantText key={item.key} msg={item.msg} text={item.text} />;
|
case "text":
|
||||||
case "work":
|
return <AssistantText key={item.key} msg={item.msg} text={item.text} />;
|
||||||
return <WorkGroup key={item.key} blocks={item.blocks} toolResultMap={toolResultMap} />;
|
case "work":
|
||||||
case "edits":
|
return (
|
||||||
// The card closes a finished turn; one still working has none yet.
|
<WorkGroup key={item.key} blocks={item.blocks} toolResultMap={toolResultMap} />
|
||||||
if (pending && item === items[items.length - 1]) return null;
|
);
|
||||||
return (
|
case "edits":
|
||||||
<EditedFiles
|
// The card closes a finished turn; one still working has none yet.
|
||||||
key={item.key}
|
if (pending && item === items[items.length - 1]) return null;
|
||||||
blocks={item.blocks}
|
return (
|
||||||
toolResultMap={toolResultMap}
|
<EditedFiles
|
||||||
onOpenFile={onOpenFile}
|
key={item.key}
|
||||||
/>
|
blocks={item.blocks}
|
||||||
);
|
toolResultMap={toolResultMap}
|
||||||
}
|
onOpenFile={onOpenFile}
|
||||||
})}
|
/>
|
||||||
{pending && <RunningStep block={pending.block} since={pending.since} />}
|
);
|
||||||
</div>
|
}
|
||||||
|
})}
|
||||||
|
{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";
|
const meta = "text-[10px] font-mono text-fg-muted";
|
||||||
|
|
||||||
// What the prompt box sends for an attached image: its saved path under the
|
// 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 =
|
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[] } {
|
export function splitDropImages(text: string): { text: string; images: string[] } {
|
||||||
const images: string[] = [];
|
const images: string[] = [];
|
||||||
const rest = text.replace(DROP_IMAGE, (_all, name: string) => {
|
const rest = text.replace(DROP_IMAGE, (_all, name: string) => {
|
||||||
images.push(name);
|
images.push(name);
|
||||||
return "";
|
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 }) {
|
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);
|
const plain = !hasTuiTags(text) && !isSkillContent(text) && !isTaskNotification(text);
|
||||||
return plain ? (
|
return plain ? (
|
||||||
<p key={b} className="whitespace-pre-wrap break-words leading-relaxed">
|
<p key={b} className="whitespace-pre-wrap break-words leading-relaxed">
|
||||||
{stripAnsi(text).trim()}
|
{linkify(stripAnsi(text).trim())}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
renderTextBlock(text, b)
|
renderTextBlock(text, b)
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
* @description An image the prompt box attached reaches the transcript as its
|
* @description An image the prompt box attached reaches the transcript as its
|
||||||
* saved drop path; the user's turn shows the image (served by
|
* 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
|
* `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>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect } from "vitest";
|
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([
|
expect(splitDropImages("C:\\Temp\\agenthub-drops--1\\0123456789abcdef.webp").images).toEqual([
|
||||||
"0123456789abcdef.webp",
|
"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.
|
// Not a drop, or not an image: left as typed.
|
||||||
for (const text of [
|
for (const text of [
|
||||||
"/home/me/shot.png",
|
"/home/me/shot.png",
|
||||||
@@ -51,4 +58,56 @@ describe("dropped images in a user turn", () => {
|
|||||||
fireEvent.click(img);
|
fireEvent.click(img);
|
||||||
expect(screen.getByTestId("image-lightbox").parentElement).toBe(document.body);
|
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,
|
onMenuAction,
|
||||||
onRenamed,
|
onRenamed,
|
||||||
onSourcesChange,
|
onSourcesChange,
|
||||||
|
sourcesVersion = 0,
|
||||||
}: {
|
}: {
|
||||||
lane: Lane;
|
lane: Lane;
|
||||||
/** Row-menu items the page carries out (its modals); rename is done here. */
|
/** Row-menu items the page carries out (its modals); rename is done here. */
|
||||||
@@ -127,6 +128,9 @@ function HomeBody({
|
|||||||
onRenamed?: () => void;
|
onRenamed?: () => void;
|
||||||
/** Called with the sources after a folder or file is added or removed. */
|
/** Called with the sources after a folder or file is added or removed. */
|
||||||
onSourcesChange?: (view: LaneSourcesView) => void;
|
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 { t } = useTranslation("lanes");
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -314,6 +318,19 @@ function HomeBody({
|
|||||||
};
|
};
|
||||||
}, [folderOpen, lane.id, prefs.showUntracked, prefs.hideSources, view?.sources]);
|
}, [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>) => {
|
const run = async (fn: () => Promise<LaneSourcesView>) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ export interface PreviewScope {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const IMAGE_RE = /\.(png|jpe?g|gif|webp)$/i;
|
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;
|
export const baseName = (p: string) => p.split("/").pop() || p;
|
||||||
/** `report.v2.xlsx` → `XLSX`; none → "". */
|
/** `report.v2.xlsx` → `XLSX`; none → "". */
|
||||||
|
|||||||
@@ -48,6 +48,8 @@
|
|||||||
"changes.bin": "bin",
|
"changes.bin": "bin",
|
||||||
"changes.binary": "Binary file — not shown.",
|
"changes.binary": "Binary file — not shown.",
|
||||||
"changes.collapse": "Collapse the changes panel",
|
"changes.collapse": "Collapse the changes panel",
|
||||||
|
"changes.closeRepo": "Close repository",
|
||||||
|
"changes.closeOtherRepos": "Close other repositories",
|
||||||
"changes.collapseAll": "Collapse every file",
|
"changes.collapseAll": "Collapse every file",
|
||||||
"changes.collapseAllFolders": "Collapse all folders",
|
"changes.collapseAllFolders": "Collapse all folders",
|
||||||
"changes.collapseContext": "Show fewer surrounding lines",
|
"changes.collapseContext": "Show fewer surrounding lines",
|
||||||
@@ -295,7 +297,6 @@
|
|||||||
"menu.remove": "Delete workspace…",
|
"menu.remove": "Delete workspace…",
|
||||||
"panel.changes": "Changes",
|
"panel.changes": "Changes",
|
||||||
"panel.analyst": "Analyst",
|
"panel.analyst": "Analyst",
|
||||||
"panel.folder": "Folder to show changes for",
|
|
||||||
"replay.placeholder": "Message to continue this session…",
|
"replay.placeholder": "Message to continue this session…",
|
||||||
"replay.hint": "Sending resumes the session in a free pane.",
|
"replay.hint": "Sending resumes the session in a free pane.",
|
||||||
"replay.send": "Send",
|
"replay.send": "Send",
|
||||||
|
|||||||
@@ -48,6 +48,8 @@
|
|||||||
"changes.bin": "bin",
|
"changes.bin": "bin",
|
||||||
"changes.binary": "File nhị phân — không hiển thị.",
|
"changes.binary": "File nhị phân — không hiển thị.",
|
||||||
"changes.collapse": "Thu gọn bảng thay đổi",
|
"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.collapseAll": "Thu gọn tất cả file",
|
||||||
"changes.collapseAllFolders": "Thu gọn tất cả thư mục",
|
"changes.collapseAllFolders": "Thu gọn tất cả thư mục",
|
||||||
"changes.collapseContext": "Bớt dòng xung quanh",
|
"changes.collapseContext": "Bớt dòng xung quanh",
|
||||||
@@ -295,7 +297,6 @@
|
|||||||
"menu.remove": "Xóa workspace…",
|
"menu.remove": "Xóa workspace…",
|
||||||
"panel.changes": "Thay đổi",
|
"panel.changes": "Thay đổi",
|
||||||
"panel.analyst": "Analyst",
|
"panel.analyst": "Analyst",
|
||||||
"panel.folder": "Thư mục để xem thay đổi",
|
|
||||||
"replay.placeholder": "Nhắn để tiếp tục session này…",
|
"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.hint": "Gửi tin sẽ resume session trong một pane trống.",
|
||||||
"replay.send": "Gửi",
|
"replay.send": "Gửi",
|
||||||
|
|||||||
@@ -1344,6 +1344,7 @@ export interface UpdateStatusPayload {
|
|||||||
situation?:
|
situation?:
|
||||||
| "plugin_managed"
|
| "plugin_managed"
|
||||||
| "release_available"
|
| "release_available"
|
||||||
|
| "diverged_from_release"
|
||||||
| "tracking_canonical"
|
| "tracking_canonical"
|
||||||
| "fork_or_diverged_tracking"
|
| "fork_or_diverged_tracking"
|
||||||
| "feature_branch"
|
| "feature_branch"
|
||||||
|
|||||||
+187
-98
@@ -55,7 +55,15 @@ import { runWorktreeSession, type RunWorktree } from "../lib/runWorktree";
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
|
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { api } from "../lib/api";
|
||||||
import type {
|
import type {
|
||||||
CwdSuggestion,
|
CwdSuggestion,
|
||||||
@@ -66,6 +74,7 @@ import type {
|
|||||||
} from "../lib/api";
|
} from "../lib/api";
|
||||||
import type { Lane, LaneFeature, ProofFeature, Session } from "../lib/types";
|
import type { Lane, LaneFeature, ProofFeature, Session } from "../lib/types";
|
||||||
import { toast } from "../lib/toast";
|
import { toast } from "../lib/toast";
|
||||||
|
import { ContextMenu, MenuItem, useContextMenu } from "../components/ContextMenu";
|
||||||
import { confirmDialog, confirmDiscard } from "../lib/confirm";
|
import { confirmDialog, confirmDiscard } from "../lib/confirm";
|
||||||
import { keepIfSame } from "../lib/keepIfSame";
|
import { keepIfSame } from "../lib/keepIfSame";
|
||||||
import { useWorkLanes } from "../hooks/useLanes";
|
import { useWorkLanes } from "../hooks/useLanes";
|
||||||
@@ -709,9 +718,27 @@ export function Workspace() {
|
|||||||
? sessionParam
|
? sessionParam
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
// The workspace's extra folders: each is its own repo the Changes panel can
|
// An unsaved whole-file edit in a Changes panel section, by section key. While
|
||||||
// show instead (`?source=`), picked in its header. Read per workspace, and
|
// one is open the panel stays on the tree it was opened in — moving it
|
||||||
// again from the home's Folder list whenever it adds or removes one.
|
// (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>(
|
const [gitFolders, setGitFolders] = useState<{ laneId: number; items: LaneSource[] } | null>(
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
@@ -723,35 +750,37 @@ export function Workspace() {
|
|||||||
}),
|
}),
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
const panelLaneId = panelAt?.laneId ?? null;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedLaneId === null) return;
|
if (panelLaneId === null) return;
|
||||||
api.lanes.sources(selectedLaneId).then(
|
api.lanes.sources(panelLaneId).then(
|
||||||
(v) => takeGitFolders(selectedLaneId, v),
|
(v) => takeGitFolders(panelLaneId, v),
|
||||||
() => setGitFolders({ laneId: selectedLaneId, items: [] })
|
() => setGitFolders({ laneId: panelLaneId, items: [] })
|
||||||
);
|
);
|
||||||
}, [selectedLaneId, takeGitFolders]);
|
}, [panelLaneId, takeGitFolders]);
|
||||||
const folders = gitFolders?.laneId === selectedLaneId ? gitFolders.items : [];
|
const folders = gitFolders?.laneId === panelLaneId ? gitFolders.items : [];
|
||||||
const [gitSourcePick, setGitSourcePick] = useState<{ laneId: number; id: number } | null>(null);
|
// Sections folded shut ("" = the workspace folder, else the folder's id).
|
||||||
// A pick holds for its own workspace, while that folder is still listed.
|
const [foldedSources, setFoldedSources] = useState<Set<string>>(new Set());
|
||||||
const gitSource =
|
// Right-click on a section: close extra folders — which REMOVES them from the
|
||||||
gitSourcePick?.laneId === selectedLaneId && folders.some((f) => f.id === gitSourcePick.id)
|
// workspace (the folder stays on disk). The workspace folder never closes.
|
||||||
? gitSourcePick.id
|
const sourceCtx = useContextMenu<string>();
|
||||||
: undefined;
|
// Bumped after a close here, so the home's Folder list re-reads.
|
||||||
|
const [sourcesVersion, setSourcesVersion] = useState(0);
|
||||||
// An unsaved whole-file edit in the Changes panel. While it is open the
|
const closeSources = async (laneId: number, keys: string[]) => {
|
||||||
// panel stays on the tree it was opened in — moving it (another workspace,
|
if (keys.some((k) => dirtyBy[k]) && !(await confirmDiscard())) return;
|
||||||
// session, worktree or folder) would remount it and drop the edit — until
|
try {
|
||||||
// the user says to discard it, or saves.
|
let view: LaneSourcesView | null = null;
|
||||||
const [changesDirty, setChangesDirty] = useState(false);
|
for (const k of keys) view = await api.lanes.removeSource(laneId, Number(k));
|
||||||
const wantPanel =
|
if (view) takeGitFolders(laneId, view);
|
||||||
selectedLaneId === null
|
} catch (err) {
|
||||||
? null
|
toast.error(err instanceof Error ? err.message : String(err));
|
||||||
: { laneId: selectedLaneId, session: gitSession, source: gitSource };
|
api.lanes.sources(laneId).then(
|
||||||
const keyOf = (p: typeof wantPanel) =>
|
(v) => takeGitFolders(laneId, v),
|
||||||
p ? `${p.laneId}:${p.session ?? ""}:${p.source ?? ""}` : "";
|
() => undefined
|
||||||
const wantPanelKey = keyOf(wantPanel);
|
);
|
||||||
const [panelAt, setPanelAt] = useState(wantPanel);
|
}
|
||||||
const panelKey = keyOf(panelAt);
|
setSourcesVersion((n) => n + 1);
|
||||||
|
};
|
||||||
const asking = useRef(false);
|
const asking = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (wantPanelKey === panelKey) return;
|
if (wantPanelKey === panelKey) return;
|
||||||
@@ -764,7 +793,7 @@ export function Workspace() {
|
|||||||
void confirmDiscard().then((ok) => {
|
void confirmDiscard().then((ok) => {
|
||||||
asking.current = false;
|
asking.current = false;
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
setChangesDirty(false);
|
setDirtyBy({});
|
||||||
setPanelAt(wantPanel);
|
setPanelAt(wantPanel);
|
||||||
});
|
});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the keys
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the keys
|
||||||
@@ -780,8 +809,12 @@ export function Workspace() {
|
|||||||
const onOpen = (e: Event) => {
|
const onOpen = (e: Event) => {
|
||||||
const { laneId, path } = (e as CustomEvent<OpenInChanges>).detail;
|
const { laneId, path } = (e as CustomEvent<OpenInChanges>).detail;
|
||||||
if (laneId !== panelLaneRef.current) return;
|
if (laneId !== panelLaneRef.current) return;
|
||||||
// Its path is the workspace folder's: back from an extra folder first.
|
// Its path is the workspace folder's: unfold that section.
|
||||||
setGitSourcePick(null);
|
setFoldedSources((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete("");
|
||||||
|
return next;
|
||||||
|
});
|
||||||
setChangesOpen(true);
|
setChangesOpen(true);
|
||||||
setChangesRequest((r) => ({ path, nonce: (r?.nonce ?? 0) + 1 }));
|
setChangesRequest((r) => ({ path, nonce: (r?.nonce ?? 0) + 1 }));
|
||||||
};
|
};
|
||||||
@@ -792,10 +825,7 @@ export function Workspace() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!changesOpen) setChangesRequest(null);
|
if (!changesOpen) setChangesRequest(null);
|
||||||
}, [changesOpen]);
|
}, [changesOpen]);
|
||||||
// Not on a folder pick: the request above makes one, to land in the panel
|
useEffect(() => setChangesRequest(null), [panelKey]);
|
||||||
// it remounts; the picker drops the request itself.
|
|
||||||
const panelTreeKey = panelAt ? `${panelAt.laneId}:${panelAt.session ?? ""}` : "";
|
|
||||||
useEffect(() => setChangesRequest(null), [panelTreeKey]);
|
|
||||||
/** Runs `fn` once an unsaved edit (if any) may be dropped. */
|
/** Runs `fn` once an unsaved edit (if any) may be dropped. */
|
||||||
const afterDrop = (fn: () => void) => {
|
const afterDrop = (fn: () => void) => {
|
||||||
if (!changesDirty) return fn();
|
if (!changesDirty) return fn();
|
||||||
@@ -1036,6 +1066,7 @@ export function Workspace() {
|
|||||||
lane={currentLane}
|
lane={currentLane}
|
||||||
onRenamed={() => void refreshLanes()}
|
onRenamed={() => void refreshLanes()}
|
||||||
onSourcesChange={(v) => takeGitFolders(currentLane.id, v)}
|
onSourcesChange={(v) => takeGitFolders(currentLane.id, v)}
|
||||||
|
sourcesVersion={sourcesVersion}
|
||||||
onMenuAction={(action) => {
|
onMenuAction={(action) => {
|
||||||
if (action === "new-here") {
|
if (action === "new-here") {
|
||||||
setAddLanePrefill({ sourceRepo: currentLane.source_repo || currentLane.cwd });
|
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">
|
<span className="px-1 text-xs font-medium text-fg-secondary">
|
||||||
{tLanes("panel.changes")}
|
{tLanes("panel.changes")}
|
||||||
</span>
|
</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. */}
|
{/* The opened session's analysis page — a page of its own. */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1350,30 +1353,114 @@ export function Workspace() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex min-h-0 flex-1 flex-col px-3 pt-2">
|
<div className="flex min-h-0 flex-1 flex-col px-3 pt-2">
|
||||||
{panelAt && (
|
{panelAt &&
|
||||||
<LaneChangesPanel
|
[
|
||||||
// A new tree is a new panel: no read for the previous one
|
{ key: "", source: undefined as number | undefined },
|
||||||
// can land in it. `panelAt` holds still while an edit is
|
...folders.map((f) => ({ key: String(f.id), source: f.id })),
|
||||||
// unsaved (see above).
|
].map(({ key, source }, i) => {
|
||||||
key={panelKey}
|
const panel = (
|
||||||
laneId={panelAt.laneId}
|
<LaneChangesPanel
|
||||||
session={panelAt.session}
|
// A new tree is a new panel: no read for the previous
|
||||||
source={panelAt.source}
|
// one can land in it. `panelAt` holds still while an
|
||||||
full={changesFull}
|
// edit is unsaved (see above).
|
||||||
wide={changesRatio(changesSize) >= 0.45}
|
key={`${panelKey}:${key}`}
|
||||||
onDirtyChange={setChangesDirty}
|
laneId={panelAt.laneId}
|
||||||
openRequest={panelAt.source ? null : changesRequest}
|
session={panelAt.session}
|
||||||
// A sibling worktree forks from the lane's SOURCE repo when
|
source={source}
|
||||||
// it has one (this lane is itself a worktree of it); an
|
full={changesFull}
|
||||||
// adopted lane is its own repo.
|
wide={changesRatio(changesSize) >= 0.45}
|
||||||
onNewLane={(branch) => {
|
onDirtyChange={dirtyFor(key)}
|
||||||
setAddLanePrefill({
|
openRequest={source ? null : changesRequest}
|
||||||
sourceRepo: currentLane.source_repo || currentLane.cwd,
|
// A sibling worktree forks from the lane's SOURCE repo
|
||||||
branch,
|
// when it has one (this lane is itself a worktree of
|
||||||
});
|
// it); an adopted lane is its own repo.
|
||||||
setAddLaneOpen(true);
|
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>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -1397,14 +1484,16 @@ export function Workspace() {
|
|||||||
{tLanes("changes.title")}
|
{tLanes("changes.title")}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{panelAt && (
|
{panelAt &&
|
||||||
<LaneGitRailCounts
|
[undefined, ...folders.map((f) => f.id)].map((source) => (
|
||||||
laneId={panelAt.laneId}
|
<LaneGitRailCounts
|
||||||
session={panelAt.session}
|
key={source ?? ""}
|
||||||
source={panelAt.source}
|
laneId={panelAt.laneId}
|
||||||
onShowAll={() => setChangesOpen(true)}
|
session={panelAt.session}
|
||||||
/>
|
source={source}
|
||||||
)}
|
onShowAll={() => setChangesOpen(true)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</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: [] }),
|
gitTree: r({ entries: [] }),
|
||||||
// A file opened in the panel on a chat's request.
|
// A file opened in the panel on a chat's request.
|
||||||
changes: r({ available: false }),
|
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");
|
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 () => {
|
it("stacks every extra folder's repo under the workspace folder's, and only its 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"]);
|
|
||||||
vi.mocked(api.lanes.git).mockClear();
|
vi.mocked(api.lanes.git).mockClear();
|
||||||
fireEvent.change(pick, { target: { value: "7" } });
|
await openPanel();
|
||||||
await settle();
|
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 });
|
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(() => {
|
act(() => {
|
||||||
openInChanges(1, "/workspace/alpha", "/workspace/alpha/src/b.ts");
|
openInChanges(1, "/workspace/alpha", "/workspace/alpha/src/b.ts");
|
||||||
});
|
});
|
||||||
await settle();
|
await settle();
|
||||||
expect(pick.value).toBe("");
|
expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||||
expect(api.lanes.gitFile).toHaveBeenCalledWith(1, "src/b.ts", "work");
|
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(
|
render(
|
||||||
<MemoryRouter initialEntries={["/workspace?lane=2"]}>
|
<MemoryRouter initialEntries={["/workspace?lane=2"]}>
|
||||||
<Workspace />
|
<Workspace />
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
);
|
);
|
||||||
await settle();
|
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 () => {
|
it("collapses from its narrow width too, back to the rail", async () => {
|
||||||
|
|||||||
@@ -7174,7 +7174,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
|||||||
<span
|
<span
|
||||||
class="text-status-warning/90"
|
class="text-status-warning/90"
|
||||||
>
|
>
|
||||||
UI build v2.6.0 (rebuild client to match)
|
UI build v2.6.1 (rebuild client to match)
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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
|
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
|
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`
|
`lane_sources` ROW (`sourceFolder` in `server/lib/lane-sources.js`): a `folder`
|
||||||
row of THIS workspace that is still a directory — otherwise `404 ENOSOURCE`
|
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
|
(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
|
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
|
```bash
|
||||||
curl http://localhost:4820/api/sessions/sess_abc123/outline
|
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" }],
|
"progress": [{ "id": "1", "subject": "Build the report", "status": "completed" }],
|
||||||
"outputs": ["/home/me/work/Progress report.xlsx"],
|
"outputs": ["/home/me/work/Progress report.xlsx"],
|
||||||
"files": ["/home/me/work/data/kpi.csv"],
|
"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"],
|
"memory": ["/home/me/.claude/projects/-home-me-work/memory/launch.md"],
|
||||||
"skills": ["xlsx"],
|
"skills": ["xlsx"],
|
||||||
"mcp": ["vercel_vercel"]
|
"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",
|
"name": "agenthub",
|
||||||
"version": "2.6.0",
|
"version": "2.6.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.6.0",
|
"version": "2.6.1",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.6.0",
|
"version": "2.6.1",
|
||||||
"bin": {
|
"bin": {
|
||||||
"agenthub": "bin/agenthub.js"
|
"agenthub": "bin/agenthub.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3100,7 +3100,7 @@ describe("GET /api/sessions/:id/outline", () => {
|
|||||||
hook_type: "UserPromptSubmit",
|
hook_type: "UserPromptSubmit",
|
||||||
data: {
|
data: {
|
||||||
session_id: sid,
|
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";
|
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" });
|
await pre("Write", { file_path: mem, content: "x" });
|
||||||
|
|
||||||
const { body } = await fetch(`/api/sessions/${sid}/outline`);
|
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.
|
// 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.files, ["/w/src/c.ts"]);
|
||||||
assert.deepEqual(body.memory, [mem]);
|
assert.deepEqual(body.memory, [mem]);
|
||||||
|
|||||||
@@ -185,8 +185,8 @@ describe("lane-sources instructions and launch", () => {
|
|||||||
let argv = null;
|
let argv = null;
|
||||||
tmux.__setExecImpl((args) => {
|
tmux.__setExecImpl((args) => {
|
||||||
if (args[0] === "has-session") throw Object.assign(new Error("gone"), { status: 1 });
|
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).
|
// 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("--") + 5);
|
if (args[0] === "new-session") argv = args.slice(args.indexOf("--") + 6);
|
||||||
return "";
|
return "";
|
||||||
});
|
});
|
||||||
pty.spawnRun({ laneId: lane.id, cwd: ws, initialPrompt: "-c fix it" });
|
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("-s") + 1], `agenthub-lane-${id}-1`);
|
||||||
assert.equal(created[created.indexOf("-c") + 1], cwd);
|
assert.equal(created[created.indexOf("-c") + 1], cwd);
|
||||||
// After `--` only the fixed size-wait wrapper that execs the user's own
|
// 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);
|
const argv = created.slice(created.indexOf("--") + 1);
|
||||||
assert.deepEqual(argv.slice(0, 2), ["sh", "-c"]);
|
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.match(argv[2], /exec "\$SHELL" -l$/);
|
||||||
assert.ok(!created.includes("ignored"));
|
assert.ok(!created.includes("ignored"));
|
||||||
const lane = (await request("GET", `/api/lanes/${id}`)).body.lane;
|
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 path = require("node:path");
|
||||||
const fs = require("node:fs");
|
const fs = require("node:fs");
|
||||||
const os = require("node:os");
|
const os = require("node:os");
|
||||||
|
const { execFileSync } = require("node:child_process");
|
||||||
|
|
||||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "pty-run-test-"));
|
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "pty-run-test-"));
|
||||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||||
@@ -80,7 +81,7 @@ describe("pty-run", () => {
|
|||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const t0 = Date.now();
|
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}` },
|
env: { ...process.env, SHELL: "/bin/echo", PATH: `${bin}:${process.env.PATH}` },
|
||||||
});
|
});
|
||||||
let out = "";
|
let out = "";
|
||||||
@@ -168,7 +169,7 @@ describe("pty-run", () => {
|
|||||||
const respawn = calls.find((c) => c[0] === "respawn-pane");
|
const respawn = calls.find((c) => c[0] === "respawn-pane");
|
||||||
assert.ok(respawn, "expected the idle shell to be replaced by the argv");
|
assert.ok(respawn, "expected the idle shell to be replaced by the argv");
|
||||||
const argv = respawn.slice(respawn.indexOf("--") + 1);
|
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"]);
|
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
|
/** 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) {
|
function paneArgv(newSessionCall) {
|
||||||
const argv = newSessionCall.slice(newSessionCall.indexOf("--") + 1);
|
const argv = newSessionCall.slice(newSessionCall.indexOf("--") + 1);
|
||||||
assert.deepEqual([argv[0], argv[1], argv[3]], ["sh", "-c", "sh"]);
|
assert.deepEqual([argv[0], argv[1], argv[3]], ["sh", "-c", "sh"]);
|
||||||
assert.match(argv[2], /session_attached.*exec "\$@"$/);
|
assert.match(argv[2], /^cd -- "\$1".*session_attached.*exec "\$@"$/);
|
||||||
return argv.slice(4);
|
return argv.slice(5);
|
||||||
}
|
}
|
||||||
|
|
||||||
it("runs a Windows pane's command itself — no sh there — and a Terminal as tmux's default shell", () => {
|
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"];
|
const argv = ["C:\\Users\\u\\.local\\bin\\claude.exe", "--model", "opus"];
|
||||||
assert.deepEqual(pty.paneArgv(argv, "win32"), argv);
|
assert.deepEqual(pty.paneArgv(argv, "C:\\w", "win32"), argv);
|
||||||
assert.deepEqual(pty.paneArgv(null, "win32"), []);
|
assert.deepEqual(pty.paneArgv(null, "C:\\w", "win32"), []);
|
||||||
assert.equal(pty.paneArgv(argv, "linux")[0], "sh");
|
assert.equal(pty.paneArgv(argv, "/w", "linux")[0], "sh");
|
||||||
assert.deepEqual(pty.paneArgv(argv, "linux").slice(-3), argv);
|
assert.deepEqual(pty.paneArgv(argv, "/w", "linux").slice(-3), argv);
|
||||||
assert.match(pty.paneArgv(null, "linux")[2], /exec "\$SHELL" -l$/);
|
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) => {
|
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),
|
...call.slice(call.indexOf("--") + 2, call.indexOf("--") + 4),
|
||||||
"sh",
|
"sh",
|
||||||
|
os.tmpdir(),
|
||||||
"printf",
|
"printf",
|
||||||
"%s|",
|
"%s|",
|
||||||
"x y",
|
"x y",
|
||||||
|
|||||||
@@ -291,6 +291,39 @@ describe("getUpdatesStatus — release drives update_available", () => {
|
|||||||
assert.match(status.manual_command, /git merge --ff-only v1\.6\.0/);
|
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 () => {
|
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 repo = makeVersionedRepo(tmpDir, "level", "1.5.0", "v1.5.0");
|
||||||
const status = await getUpdatesStatus(repo, {
|
const status = await getUpdatesStatus(repo, {
|
||||||
|
|||||||
+21
-12
@@ -81,20 +81,29 @@ const WAIT_FOR_CLIENT =
|
|||||||
const CLIENT_WAIT_MS = 2000;
|
const CLIENT_WAIT_MS = 2000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A pane's tmux command: `argv` behind {@link WAIT_FOR_CLIENT}, or — with no
|
* `sh` that moves into `$1` itself. tmux applies `-c` only while its server's
|
||||||
* `argv` — the login shell behind it. Windows has no `sh` (a WinGet tmux runs
|
* own cwd still exists: the server is forked from the dashboard's cwd — a
|
||||||
* native programs), and an `sh -c` there died at once and took the session
|
* plugin cache dir, deleted by `pruneRetiredPluginRoots` after an update the
|
||||||
* with it, so it gets the command itself, or nothing for tmux's own default
|
* server outlives — and from then on every pane started in that deleted dir,
|
||||||
* shell.
|
* 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
|
* 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.
|
* 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 || [];
|
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
|
return argv
|
||||||
? ["sh", "-c", `${WAIT_FOR_CLIENT} exec "$@"`, "sh", ...argv]
|
? ["sh", "-c", `${IN_CWD} ${WAIT_FOR_CLIENT} exec "$@"`, "sh", cwd, ...argv]
|
||||||
: ["sh", "-c", `${WAIT_FOR_CLIENT} exec "$SHELL" -l`];
|
: ["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_SANDBOXES = new Set(["read-only", "workspace-write", "danger-full-access"]);
|
||||||
const CODEX_APPROVALS = new Set(["on-request", "never"]);
|
const CODEX_APPROVALS = new Set(["on-request", "never"]);
|
||||||
@@ -614,7 +623,7 @@ function spawnRun(args) {
|
|||||||
|
|
||||||
if (!tmux.hasSession(id)) {
|
if (!tmux.hasSession(id)) {
|
||||||
codexPool?.place();
|
codexPool?.place();
|
||||||
tmux.newSession({ name: id, cwd, argv: paneArgv(paneCmd), env });
|
tmux.newSession({ name: id, cwd, argv: paneArgv(paneCmd, cwd), env });
|
||||||
record();
|
record();
|
||||||
schedulePaste();
|
schedulePaste();
|
||||||
} else if (SHELL_COMMANDS.has(tmux.paneCommand(id) || "")) {
|
} 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
|
// still answered 200. Run the argv in the pane the user already sees
|
||||||
// instead of erroring or opening a second session.
|
// instead of erroring or opening a second session.
|
||||||
codexPool?.place();
|
codexPool?.place();
|
||||||
tmux.sendCommand(id, paneCmd, env, cwd);
|
tmux.sendCommand(id, paneArgv(paneCmd, cwd), env, cwd);
|
||||||
record();
|
record();
|
||||||
schedulePaste();
|
schedulePaste();
|
||||||
}
|
}
|
||||||
@@ -677,7 +686,7 @@ function spawnShell({ laneId, slot = 0, cwd }) {
|
|||||||
const paneEnv = member ? require("./run-as").memberPaneEnv(member, env) : env;
|
const paneEnv = member ? require("./run-as").memberPaneEnv(member, env) : env;
|
||||||
// Started once the pane has its real size (WAIT_FOR_CLIENT, issue #55), as
|
// 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).
|
// 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({
|
dashboardRuns?.recordRun({
|
||||||
id,
|
id,
|
||||||
sessionId: null,
|
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
|
// try/catch and treats failure as "no session" — piping tmux's own
|
||||||
// "error connecting to ..." noise to the process's stderr just spams the
|
// "error connecting to ..." noise to the process's stderr just spams the
|
||||||
// journal on every poll while no tmux server is running yet.
|
// 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);
|
let execImpl = (args, opts = EXEC_OPTS) => execFileSync("tmux", args, opts);
|
||||||
|
|
||||||
// Linux: the tmux server lives in the cgroup of whatever started it — the
|
// 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}'), ` +
|
`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 — ` +
|
`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.`;
|
`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) {
|
} else if (release.ok && releaseIsNewer) {
|
||||||
// Move onto the release TAG rather than the branch tip: the tag is what
|
// 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
|
// 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
|
// A file the composer attached (`/api/run/drop-*` names it), as its path is
|
||||||
// pasted into the prompt; and a Claude Code memory file.
|
// 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 =
|
const UPLOAD_RE =
|
||||||
/(?:^|\s)(\S*\/agenthub-drops-\d+\/[0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.[A-Za-z0-9]+)/g;
|
/(?:^|\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+\//.test(p);
|
const isUpload = (p) => /\/(?:agenthub-drops-\d+|drops)\/[0-9a-f]{16}/.test(p);
|
||||||
const isMemory = (p) => /\/\.claude\/.*\/memory\//.test(p);
|
const isMemory = (p) => /\/\.claude\/.*\/memory\//.test(p);
|
||||||
/** Records `p` once, newest last. */
|
/** Records `p` once, newest last. */
|
||||||
const touch = (set, p) => {
|
const touch = (set, p) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user