feat: workspaces own their sessions; desktop parity; sidebar and settings cleanup
Workspace sessions (docs/plans/desktop-parity, phases 1-5 and 7): - sessions.lane_id: a workspace lists only the sessions its panes held, stamped by bindRunSession (first writer wins, nested `claude -p` excluded); GET /api/sessions?lane=&archived= - session row state dot, double-click rename, archive (sessions.archived_at, hides only); the activity bar's duplicate Sessions tab removed - Fork beside Resume (--fork-session, Claude only), Ctrl/Cmd+click a live session opens it in a second pane, a diff line's comment is pasted into the workspace's last-focused pane (lib/panePaste.ts) dashboard_runs stability: - a pane's previous run is archived as <pane>@<started_at> instead of being overwritten by INSERT OR REPLACE - a run is flagged abandoned only once its tmux session is gone, at boot and on the run-heal beat (panes outlive a server restart) UI: - sidebar: no footer, language or theme block; small collapse button in the brand corner; version + update check in the connection details modal, a status-bar dot while a release is out; language in Settings -> General - one Switch component for every switch, the themed Checkbox for every checkbox (no native checkboxes) Also: AI Tools catalog trimmed to 10 built-ins (custom-file prompt modes tested through fixtures), and the workspace plans under docs/plans/ (the multi-user server plan lives in the AgentHub-Server repo).
@@ -2101,7 +2101,7 @@ The version signal is the forge's own release list, read over REST at `/api/v1/r
|
|||||||
|
|
||||||
Two states the release model makes visible that a commit count cannot:
|
Two states the release model makes visible that a commit count cannot:
|
||||||
|
|
||||||
- **`dev_build`** — the install is strictly ahead of the newest release and missing nothing from it. It is a build off the branch, not a shipped version, and the Sidebar says so instead of "Up to date".
|
- **`dev_build`** — the install is strictly ahead of the newest release and missing nothing from it. It is a build off the branch, not a shipped version, and the connection details modal says so instead of "Up to date".
|
||||||
- **`plugin_managed`** — the install runs from `~/.claude/plugins/cache/`. Claude Code owns that directory and replaces it wholesale, so `applyUpdate` will not rewrite it: it refuses with `reason: "plugin_managed"`, decided from the path before any status lookup so the refusal holds offline too. A self-pull would leave a cache directory whose name (a commit sha) no longer matched its contents. **This branch is evaluated before the `.git` probe, and must stay there.** An archive-sourced cache directory is not a clone, so a `.git` check reached first answers "not a git clone; check manually" and skips the release layer entirely — which is exactly what 1.5.1 shipped: `update_available` pinned false however many releases were published, `plugin_marketplace`/`plugin_name` never set, so `/apply`'s plugin branch could never fire and its 409 claimed nothing newer was published. It reads as "up to date", so it stays invisible while the install happens to be current. **The button still works here**, by a different route: `POST /api/updates/apply` catches that refusal and hands the job to the detached chain in `server/lib/plugin-update.js` — `claude plugin marketplace update <mkt>` → `claude plugin update <plugin>@<mkt> -y` → `plugin-bootstrap.js --force` **from the new cache directory**, which stops this server and starts the new one. It answers `200 {applied:true, mode:"plugin"}`. The one thing no background process can do is `/reload-plugins`: that is a REPL action scoped to the session that runs it, so an already-open Claude Code session still needs it for new skills/commands/hooks. The dashboard does not, because `plugin-bootstrap.js` derives `PLUGIN_ROOT` from its own `__dirname` rather than from `${CLAUDE_PLUGIN_ROOT}`.
|
- **`plugin_managed`** — the install runs from `~/.claude/plugins/cache/`. Claude Code owns that directory and replaces it wholesale, so `applyUpdate` will not rewrite it: it refuses with `reason: "plugin_managed"`, decided from the path before any status lookup so the refusal holds offline too. A self-pull would leave a cache directory whose name (a commit sha) no longer matched its contents. **This branch is evaluated before the `.git` probe, and must stay there.** An archive-sourced cache directory is not a clone, so a `.git` check reached first answers "not a git clone; check manually" and skips the release layer entirely — which is exactly what 1.5.1 shipped: `update_available` pinned false however many releases were published, `plugin_marketplace`/`plugin_name` never set, so `/apply`'s plugin branch could never fire and its 409 claimed nothing newer was published. It reads as "up to date", so it stays invisible while the install happens to be current. **The button still works here**, by a different route: `POST /api/updates/apply` catches that refusal and hands the job to the detached chain in `server/lib/plugin-update.js` — `claude plugin marketplace update <mkt>` → `claude plugin update <plugin>@<mkt> -y` → `plugin-bootstrap.js --force` **from the new cache directory**, which stops this server and starts the new one. It answers `200 {applied:true, mode:"plugin"}`. The one thing no background process can do is `/reload-plugins`: that is a REPL action scoped to the session that runs it, so an already-open Claude Code session still needs it for new skills/commands/hooks. The dashboard does not, because `plugin-bootstrap.js` derives `PLUGIN_ROOT` from its own `__dirname` rather than from `${CLAUDE_PLUGIN_ROOT}`.
|
||||||
|
|
||||||
A **shallow clone** (`--depth`) is the third special case, and the plugin cache is one: history is grafted, so every commit-distance number is a graft artefact rather than a distance. All `commits_*_release` fields are `null` there and `dev_build` is never asserted; the version string is the only signal used.
|
A **shallow clone** (`--depth`) is the third special case, and the plugin cache is one: history is grafted, so every commit-distance number is a graft artefact rather than a distance. All `commits_*_release` fields are `null` there and `dev_build` is never asserted; the version string is the only signal used.
|
||||||
@@ -2194,7 +2194,7 @@ sequenceDiagram
|
|||||||
| **`server/routes/updates.js`** | Three endpoints: `GET /status` (read-only check), `POST /check` (check + broadcast), `POST /apply`. `/apply` has **two modes**, chosen by how this copy was installed, never by the request body: **checkout** — fast-forward onto the release tag, `npm run setup`, `npm run build` in production, then `scheduleRestart()`; **plugin** — `applyUpdate`'s `plugin_managed` refusal is caught and `schedulePluginUpdate()` runs instead, answering `200 {applied:true, mode:"plugin"}`. The plugin branch fires only when there is genuinely something to pull (`update_available` plus a marketplace and plugin name parsed from the cache path) — a `plugin_managed` refusal with nothing newer published stays a 409 rather than spawning a chain that would stop the server for nothing. Otherwise 409 `UPDATE_NOT_APPLICABLE` with `reason` `"up_to_date"`, `"not_fast_forwardable"`, or `"plugin_managed"`. Both modes write the response BEFORE anything stops the process. Mounted under `/api/*`, so all three ride the default loopback bind as their trust boundary and are gated by `DASHBOARD_TOKEN` when one is set. |
|
| **`server/routes/updates.js`** | Three endpoints: `GET /status` (read-only check), `POST /check` (check + broadcast), `POST /apply`. `/apply` has **two modes**, chosen by how this copy was installed, never by the request body: **checkout** — fast-forward onto the release tag, `npm run setup`, `npm run build` in production, then `scheduleRestart()`; **plugin** — `applyUpdate`'s `plugin_managed` refusal is caught and `schedulePluginUpdate()` runs instead, answering `200 {applied:true, mode:"plugin"}`. The plugin branch fires only when there is genuinely something to pull (`update_available` plus a marketplace and plugin name parsed from the cache path) — a `plugin_managed` refusal with nothing newer published stays a 409 rather than spawning a chain that would stop the server for nothing. Otherwise 409 `UPDATE_NOT_APPLICABLE` with `reason` `"up_to_date"`, `"not_fast_forwardable"`, or `"plugin_managed"`. Both modes write the response BEFORE anything stops the process. Mounted under `/api/*`, so all three ride the default loopback bind as their trust boundary and are gated by `DASHBOARD_TOKEN` when one is set. |
|
||||||
| **`server/lib/plugin-update.js`** + **`scripts/plugin-update-helper.js`** | The plugin-cache update chain. The lib only spawns the helper detached (cwd = the runtime dir, **not** the plugin root, which is about to be superseded) and clears any previous verdict first so a stale success can't be read back as this run's result. All work is in the helper because its last step SIGTERMs the server that asked for the update — the same split as `self-restart.js`, one step further. The helper re-validates the marketplace and plugin names against `/^[A-Za-z0-9][\w.-]*$/` (they reach an argv across a process boundary), resolves `claude` on PATH or at `~/.local/bin/claude` (a hook-spawned server does not inherit the login shell's PATH), picks the newest cache directory that isn't the old root and does contain `scripts/plugin-bootstrap.js`, and records **every** outcome — including an unexpected throw — to `plugin-update.json` under `~/.claude/agenthub/runtime/`, outside the cache so it survives the swap. Steps: `validate`, `resolve_claude`, `marketplace_update`, `plugin_update`, `already_current` (`ok:true`; nothing was pulled and the old server was left running), `bootstrap`, `done`. `readLastPluginUpdate()` surfaces it as `last_plugin_update` on the status payload — without it a failed chain is indistinguishable from a slow one. |
|
| **`server/lib/plugin-update.js`** + **`scripts/plugin-update-helper.js`** | The plugin-cache update chain. The lib only spawns the helper detached (cwd = the runtime dir, **not** the plugin root, which is about to be superseded) and clears any previous verdict first so a stale success can't be read back as this run's result. All work is in the helper because its last step SIGTERMs the server that asked for the update — the same split as `self-restart.js`, one step further. The helper re-validates the marketplace and plugin names against `/^[A-Za-z0-9][\w.-]*$/` (they reach an argv across a process boundary), resolves `claude` on PATH or at `~/.local/bin/claude` (a hook-spawned server does not inherit the login shell's PATH), picks the newest cache directory that isn't the old root and does contain `scripts/plugin-bootstrap.js`, and records **every** outcome — including an unexpected throw — to `plugin-update.json` under `~/.claude/agenthub/runtime/`, outside the cache so it survives the swap. Steps: `validate`, `resolve_claude`, `marketplace_update`, `plugin_update`, `already_current` (`ok:true`; nothing was pulled and the old server was left running), `bootstrap`, `done`. `readLastPluginUpdate()` surfaces it as `last_plugin_update` on the status payload — without it a failed chain is indistinguishable from a slow one. |
|
||||||
| **`UpdateNotifier.tsx`** | Modal. Hydrates from `api.updates.status()` on mount and mirrors the payload back into the local `eventBus` so the Sidebar can listen without a second git fetch. Subscribes to `update_status` WS frames for ongoing sync. Keeps the dismissal key in `localStorage` (`agent-monitor-update-dismissed-sha`) and in React state — the **release tag** when one was read, else the upstream SHA, so a newer release re-opens the prompt while a dismissed one stays dismissed; a window event `dashboard:reset-update-dismissal` from the Sidebar clears both. ESC / backdrop click dismisses. |
|
| **`UpdateNotifier.tsx`** | Modal. Hydrates from `api.updates.status()` on mount and mirrors the payload back into the local `eventBus` so the Sidebar can listen without a second git fetch. Subscribes to `update_status` WS frames for ongoing sync. Keeps the dismissal key in `localStorage` (`agent-monitor-update-dismissed-sha`) and in React state — the **release tag** when one was read, else the upstream SHA, so a newer release re-opens the prompt while a dismissed one stays dismissed; a window event `dashboard:reset-update-dismissal` from the Sidebar clears both. ESC / backdrop click dismisses. |
|
||||||
| **`Sidebar.tsx`** | Always-visible "Check for updates" button in the footer. Subscribes to `update_status` (no own fetch). On click: clears dismissed SHA in localStorage, dispatches `dashboard:reset-update-dismissal`, then calls `api.updates.check()`. Visual state: emerald badge when `update_available`, amber when `fetch_error`, neutral otherwise. Label has four resting states, not two — `"<tag> available"`, `"Dev build · N ahead"` (when `dev_build`), `"Up to date"`, or `"Check for updates"` before the first result. |
|
| **`Sidebar.tsx`** | The update state and its **Check for updates** button live in the connection details modal the sidebar owns (opened from the status bar; the sidebar has no footer); the status bar shows a green dot beside the version while a release is out. Subscribes to `update_status` (no own fetch). On click: clears dismissed SHA in localStorage, dispatches `dashboard:reset-update-dismissal`, then calls `api.updates.check()`. Visual state: emerald badge when `update_available`, amber when `fetch_error`, neutral otherwise. Label has four resting states, not two — `"<tag> available"`, `"Dev build · N ahead"` (when `dev_build`), `"Up to date"`, or `"Check for updates"` before the first result. |
|
||||||
|
|
||||||
### Payload Shape
|
### Payload Shape
|
||||||
|
|
||||||
@@ -2263,7 +2263,7 @@ The same shape is used by `GET /status`, `POST /check`, and the `update_status`
|
|||||||
| No remotes configured | `{git_repo:true, update_available:false, message:"No git remotes configured..."}` | Same as above. |
|
| No remotes configured | `{git_repo:true, update_available:false, message:"No git remotes configured..."}` | Same as above. |
|
||||||
| `git fetch` failed (offline, auth) | `{git_repo:true, update_available:false, canonical_remote, fetch_error:"<stderr>"}` | Sidebar button goes amber; modal stays suppressed until a successful check. |
|
| `git fetch` failed (offline, auth) | `{git_repo:true, update_available:false, canonical_remote, fetch_error:"<stderr>"}` | Sidebar button goes amber; modal stays suppressed until a successful check. |
|
||||||
| Canonical default branch unresolvable | `{git_repo:true, update_available:false, canonical_remote, message:"Could not resolve <remote>/master..."}` | Modal suppressed. |
|
| Canonical default branch unresolvable | `{git_repo:true, update_available:false, canonical_remote, message:"Could not resolve <remote>/master..."}` | Modal suppressed. |
|
||||||
| Healthy, up to date | `{git_repo:true, update_available:false, commits_behind:0, situation:"tracking_canonical"\|...}` | Sidebar neutral, modal suppressed. |
|
| Healthy, up to date | `{git_repo:true, update_available:false, commits_behind:0, situation:"tracking_canonical"\|...}` | Connection modal neutral, no status-bar dot, update modal suppressed. |
|
||||||
| Release lookup failed (offline, non-HTTP remote, no releases) | `{release_check:"unreachable"\|"remote_not_http"\|"no_releases"\|..., latest_release:null}` | Detection falls back to the commit count; the modal states the reason so "up to date" is not read as "release checked". |
|
| Release lookup failed (offline, non-HTTP remote, no releases) | `{release_check:"unreachable"\|"remote_not_http"\|"no_releases"\|..., latest_release:null}` | Detection falls back to the commit count; the modal states the reason so "up to date" is not read as "release checked". |
|
||||||
| Ahead of the newest release | `{update_available:false, dev_build:true, commits_ahead_of_release:N}` | Sidebar reads `Dev build · N ahead`; modal suppressed (a dev build is not an available update). |
|
| Ahead of the newest release | `{update_available:false, dev_build:true, commits_ahead_of_release:N}` | Sidebar reads `Dev build · N ahead`; modal suppressed (a dev build is not an available update). |
|
||||||
| Shallow clone (incl. the plugin cache) | `{shallow_clone:true, commits_behind_release:null, commits_ahead_of_release:null, dev_build:false}` | No commit badge is rendered at all — the numbers would be graft artefacts. Version compare still decides `update_available`. |
|
| Shallow clone (incl. the plugin cache) | `{shallow_clone:true, commits_behind_release:null, commits_ahead_of_release:null, dev_build:false}` | No commit badge is rendered at all — the numbers would be graft artefacts. Version compare still decides `update_available`. |
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ The client is a single-page application (SPA) built with modern web technologies
|
|||||||
- **React 18.3** - Component-based UI with hooks and concurrent features
|
- **React 18.3** - Component-based UI with hooks and concurrent features
|
||||||
- **TypeScript 5.7** - Full type safety across components, utilities, and API contracts
|
- **TypeScript 5.7** - Full type safety across components, utilities, and API contracts
|
||||||
- **Vite 6.1** - Lightning-fast HMR during development, optimized production builds
|
- **Vite 6.1** - Lightning-fast HMR during development, optimized production builds
|
||||||
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (next to the language switcher in the sidebar, and in Settings → Appearance; `useTheme` hook) re-themes the whole app by flipping one class. A **named theme** per mode is one more attribute: `data-theme` on `<html>` (absent for `default`) selects a CSS-variable block in `index.css` — `.dark[data-theme="darcula|apple-dark|orca-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas|orca-light"]` — that overrides only the tokens it changes. `--tint-base` is `var(--surface-3)` and light `--syn-plain` is `var(--fg-primary)`, so a theme never restates them. A new theme is a block there plus an id in `THEMES` (`hooks/useTheme.ts`) and a label in `components/settings/AppearanceSettings.tsx`; its Settings preview needs no hex, because the swatch re-scopes the same variables on its own element. Storage: `theme` = `dark|light|system`, `agenthub.theme.dark` / `agenthub.theme.light` = theme ids (unknown ⇒ `default`); state is read through `useSyncExternalStore`, never cached in the module, so every consumer and every tab agree. An inline `<script>` at the top of `client/index.html` applies the same keys before first paint (no flash of the stock dark palette); it does not validate ids — the hook does, on mount
|
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (Settings → Appearance; `useTheme` hook — the sidebar has no theme or language controls, the language lives in Settings → General) re-themes the whole app by flipping one class. A **named theme** per mode is one more attribute: `data-theme` on `<html>` (absent for `default`) selects a CSS-variable block in `index.css` — `.dark[data-theme="darcula|apple-dark|orca-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas|orca-light"]` — that overrides only the tokens it changes. `--tint-base` is `var(--surface-3)` and light `--syn-plain` is `var(--fg-primary)`, so a theme never restates them. A new theme is a block there plus an id in `THEMES` (`hooks/useTheme.ts`) and a label in `components/settings/AppearanceSettings.tsx`; its Settings preview needs no hex, because the swatch re-scopes the same variables on its own element. Storage: `theme` = `dark|light|system`, `agenthub.theme.dark` / `agenthub.theme.light` = theme ids (unknown ⇒ `default`); state is read through `useSyncExternalStore`, never cached in the module, so every consumer and every tab agree. An inline `<script>` at the top of `client/index.html` applies the same keys before first paint (no flash of the stock dark palette); it does not validate ids — the hook does, on mount
|
||||||
|
|
||||||
**The surface ramp means elevation, and it runs the same direction in both themes: a higher index is LIGHTER in dark and LIGHTER in light.** So in light mode the page is the *darkest* step (`surface-0`, `#e8e5da`) and a `.card` is brighter than the page it sits on (`surface-3`, `#f5f3ec`), which is also what makes every `hover:bg-surface-N` lift rather than dim. Light mode originally used Radix slate in its native direction — index up meant darker — which inverted the whole language: a near-white page (the glare complaint) under grey cards that read as recessed. Never "fix" a light-mode colour by swapping which step a component uses; fix the value of the step.
|
**The surface ramp means elevation, and it runs the same direction in both themes: a higher index is LIGHTER in dark and LIGHTER in light.** So in light mode the page is the *darkest* step (`surface-0`, `#e8e5da`) and a `.card` is brighter than the page it sits on (`surface-3`, `#f5f3ec`), which is also what makes every `hover:bg-surface-N` lift rather than dim. Light mode originally used Radix slate in its native direction — index up meant darker — which inverted the whole language: a near-white page (the glare complaint) under grey cards that read as recessed. Never "fix" a light-mode colour by swapping which step a component uses; fix the value of the step.
|
||||||
|
|
||||||
|
|||||||
@@ -74,26 +74,44 @@ export interface CheckboxProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
/** Classes applied to the label `<span>` when `label` is set. */
|
/** Classes applied to the label `<span>` when `label` is set. */
|
||||||
labelClassName?: string;
|
labelClassName?: string;
|
||||||
|
/** `danger` / `warning` for an opt-in that makes an action riskier
|
||||||
|
* (bypass permissions, purge data, force). */
|
||||||
|
tone?: "accent" | "danger" | "warning";
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TONE = {
|
||||||
|
accent: "bg-accent border-accent",
|
||||||
|
danger: "bg-status-danger border-status-danger",
|
||||||
|
warning: "bg-status-warning border-status-warning",
|
||||||
|
} as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Themed checkbox control.
|
* Themed checkbox control — the app's ONE checkbox; never a native
|
||||||
|
* `<input type="checkbox">`, which renders as the browser's white box.
|
||||||
* @param props See {@link CheckboxProps}.
|
* @param props See {@link CheckboxProps}.
|
||||||
*/
|
*/
|
||||||
export function Checkbox({ checked, onChange, label, className, labelClassName }: CheckboxProps) {
|
export function Checkbox({
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
label,
|
||||||
|
className,
|
||||||
|
labelClassName,
|
||||||
|
tone = "accent",
|
||||||
|
disabled = false,
|
||||||
|
}: CheckboxProps) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="checkbox"
|
role="checkbox"
|
||||||
aria-checked={checked}
|
aria-checked={checked}
|
||||||
|
disabled={disabled}
|
||||||
onClick={() => onChange(!checked)}
|
onClick={() => onChange(!checked)}
|
||||||
className={`group inline-flex items-center gap-2 text-left ${className ?? ""}`}
|
className={`group inline-flex items-center gap-2 text-left disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none ${className ?? ""}`}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-[5px] border transition-colors ${
|
className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-[5px] border transition-colors group-focus-visible:ring-2 group-focus-visible:ring-accent/40 ${
|
||||||
checked
|
checked ? TONE[tone] : "bg-surface-2 border-border group-hover:border-border-light"
|
||||||
? "bg-accent border-accent"
|
|
||||||
: "bg-surface-2 border-border group-hover:border-border-light"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{checked && <Check className="h-3 w-3 text-white" strokeWidth={3} />}
|
{checked && <Check className="h-3 w-3 text-white" strokeWidth={3} />}
|
||||||
|
|||||||
@@ -86,6 +86,7 @@
|
|||||||
*
|
*
|
||||||
* ----------------------------------------------------------------------------- */
|
* ----------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
import { Checkbox } from "./Checkbox";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Search, X, Filter } from "lucide-react";
|
import { Search, X, Filter } from "lucide-react";
|
||||||
@@ -376,18 +377,14 @@ function ChipGroup({
|
|||||||
const checked = selected.includes(opt);
|
const checked = selected.includes(opt);
|
||||||
const display = labels?.[opt] ?? opt;
|
const display = labels?.[opt] ?? opt;
|
||||||
return (
|
return (
|
||||||
<label
|
<Checkbox
|
||||||
key={opt}
|
key={opt}
|
||||||
className="flex items-center gap-2 px-2 py-1 text-[11px] text-fg-secondary rounded hover:bg-surface-3 cursor-pointer"
|
checked={checked}
|
||||||
>
|
onChange={() => onToggle(opt)}
|
||||||
<input
|
label={display}
|
||||||
type="checkbox"
|
className="flex w-full px-2 py-1 rounded hover:bg-surface-3"
|
||||||
checked={checked}
|
labelClassName="font-mono break-all text-[11px] text-fg-secondary"
|
||||||
onChange={() => onToggle(opt)}
|
/>
|
||||||
className="accent-accent"
|
|
||||||
/>
|
|
||||||
<span className="font-mono break-all">{display}</span>
|
|
||||||
</label>
|
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export function Layout({ wsConnected }: LayoutProps) {
|
|||||||
const { t } = useTranslation("nav");
|
const { t } = useTranslation("nav");
|
||||||
const [collapsed, setCollapsed] = useState(loadCollapsed);
|
const [collapsed, setCollapsed] = useState(loadCollapsed);
|
||||||
const [connectionOpen, setConnectionOpen] = useState(false);
|
const [connectionOpen, setConnectionOpen] = useState(false);
|
||||||
|
const [updateAvailable, setUpdateAvailable] = useState(false);
|
||||||
const sidebarWidth = collapsed ? "4.25rem" : "15rem";
|
const sidebarWidth = collapsed ? "4.25rem" : "15rem";
|
||||||
|
|
||||||
const toggle = useCallback(() => {
|
const toggle = useCallback(() => {
|
||||||
@@ -111,6 +112,7 @@ export function Layout({ wsConnected }: LayoutProps) {
|
|||||||
onToggle={toggle}
|
onToggle={toggle}
|
||||||
connectionOpen={connectionOpen}
|
connectionOpen={connectionOpen}
|
||||||
onConnectionOpenChange={setConnectionOpen}
|
onConnectionOpenChange={setConnectionOpen}
|
||||||
|
onUpdateAvailableChange={setUpdateAvailable}
|
||||||
/>
|
/>
|
||||||
<main
|
<main
|
||||||
id="main-content"
|
id="main-content"
|
||||||
@@ -132,6 +134,7 @@ export function Layout({ wsConnected }: LayoutProps) {
|
|||||||
wsConnected={wsConnected}
|
wsConnected={wsConnected}
|
||||||
left={sidebarWidth}
|
left={sidebarWidth}
|
||||||
onConnectionClick={() => setConnectionOpen(true)}
|
onConnectionClick={() => setConnectionOpen(true)}
|
||||||
|
updateAvailable={updateAvailable}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -57,6 +57,7 @@
|
|||||||
*
|
*
|
||||||
* ----------------------------------------------------------------------------- */
|
* ----------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
import { Checkbox } from "./Checkbox";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
@@ -544,17 +545,12 @@ export function RemoteSources() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
<Checkbox
|
||||||
<input
|
checked={!!form.enabled}
|
||||||
type="checkbox"
|
onChange={(v) => setForm((f) => ({ ...f, enabled: v }))}
|
||||||
className="accent-accent"
|
label={t("remoteSources.fieldEnabled", "Sync automatically in the background")}
|
||||||
checked={!!form.enabled}
|
labelClassName="text-sm text-fg-secondary"
|
||||||
onChange={(e) => setForm((f) => ({ ...f, enabled: e.target.checked }))}
|
/>
|
||||||
/>
|
|
||||||
<span className="text-sm text-fg-secondary">
|
|
||||||
{t("remoteSources.fieldEnabled", "Sync automatically in the background")}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
{formError && (
|
{formError && (
|
||||||
<div className="text-xs text-status-danger bg-status-danger/10 border border-status-danger/25 rounded-lg px-3 py-2">
|
<div className="text-xs text-status-danger bg-status-danger/10 border border-status-danger/25 rounded-lg px-3 py-2">
|
||||||
{formError}
|
{formError}
|
||||||
@@ -723,22 +719,16 @@ export function RemoteSources() {
|
|||||||
<p className="text-xs text-fg-secondary mb-2">
|
<p className="text-xs text-fg-secondary mb-2">
|
||||||
{t("remoteSources.confirmDelete", "Remove this source?")}
|
{t("remoteSources.confirmDelete", "Remove this source?")}
|
||||||
</p>
|
</p>
|
||||||
<label className="flex items-center gap-2 mb-3 cursor-pointer">
|
<Checkbox
|
||||||
<input
|
className="mb-3"
|
||||||
type="checkbox"
|
tone="danger"
|
||||||
className="accent-red-500"
|
checked={confirmDelete.purge}
|
||||||
checked={confirmDelete.purge}
|
onChange={(v) => setConfirmDelete((c) => c && { ...c, purge: v })}
|
||||||
onChange={(e) =>
|
label={t(
|
||||||
setConfirmDelete((c) => c && { ...c, purge: e.target.checked })
|
"remoteSources.purgeData",
|
||||||
}
|
"Also delete the sessions imported from this source (cannot be undone)"
|
||||||
/>
|
)}
|
||||||
<span className="text-xs text-fg-secondary">
|
/>
|
||||||
{t(
|
|
||||||
"remoteSources.purgeData",
|
|
||||||
"Also delete the sessions imported from this source (cannot be undone)"
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={doDelete}
|
onClick={doDelete}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* @file Sidebar.tsx
|
* @file Sidebar.tsx
|
||||||
* @description Defines the Sidebar component that provides navigation links to different sections of the application, displays the connection status, and includes a toggle button for collapsing or expanding the sidebar. The component uses React Router's NavLink for navigation and Lucide icons for visual representation. The collapsed state of the sidebar is stored in localStorage to persist user preferences across sessions.
|
* @description The app's left sidebar: the brand with a small collapse/expand button in its corner, the navigation links and the workspace list. It also owns the connection details modal (opened from the status bar), which carries the version and the update check — the sidebar itself has no footer, language or theme controls (those live in Settings → General / Appearance). The collapsed state is kept in localStorage.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
/* =============================================================================
|
/* =============================================================================
|
||||||
@@ -55,13 +55,11 @@ import {
|
|||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Play,
|
|
||||||
Settings,
|
Settings,
|
||||||
Wifi,
|
Wifi,
|
||||||
WifiOff,
|
WifiOff,
|
||||||
PanelLeftClose,
|
PanelLeftClose,
|
||||||
PanelLeftOpen,
|
PanelLeftOpen,
|
||||||
Languages,
|
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
X,
|
X,
|
||||||
Plug,
|
Plug,
|
||||||
@@ -69,15 +67,11 @@ import {
|
|||||||
Gauge,
|
Gauge,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Sun,
|
|
||||||
Monitor,
|
|
||||||
Moon,
|
|
||||||
FolderTree,
|
FolderTree,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
import { eventBus } from "../lib/eventBus";
|
import { eventBus } from "../lib/eventBus";
|
||||||
import { useTheme } from "../hooks/useTheme";
|
|
||||||
import { WorkspaceTree } from "./WorkspaceTree";
|
import { WorkspaceTree } from "./WorkspaceTree";
|
||||||
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
|
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
|
||||||
|
|
||||||
@@ -88,7 +82,6 @@ function isUpdatePayload(x: unknown): x is UpdateStatusPayload {
|
|||||||
const NAV_KEYS = [
|
const NAV_KEYS = [
|
||||||
{ to: "/", icon: LayoutDashboard, key: "nav:dashboard" },
|
{ to: "/", icon: LayoutDashboard, key: "nav:dashboard" },
|
||||||
{ to: "/sessions", icon: FolderOpen, key: "nav:sessions" },
|
{ to: "/sessions", icon: FolderOpen, key: "nav:sessions" },
|
||||||
{ to: "/workspace", icon: Play, key: "nav:workspace" },
|
|
||||||
{ to: "/settings", icon: Settings, key: "nav:settings" },
|
{ to: "/settings", icon: Settings, key: "nav:settings" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -102,8 +95,6 @@ const BRAND_MARK = "https://smartgift.vn/favicon.ico";
|
|||||||
const STORAGE_KEY = "sidebar-collapsed";
|
const STORAGE_KEY = "sidebar-collapsed";
|
||||||
const STATS_STORAGE_KEY = "sidebar-connection-stats";
|
const STATS_STORAGE_KEY = "sidebar-connection-stats";
|
||||||
const RECENT_EVENTS_CAP = 8;
|
const RECENT_EVENTS_CAP = 8;
|
||||||
const SUPPORTED_LANGUAGES = ["en", "vi"] as const;
|
|
||||||
type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number];
|
|
||||||
|
|
||||||
interface PersistedStats {
|
interface PersistedStats {
|
||||||
eventCount: number;
|
eventCount: number;
|
||||||
@@ -162,14 +153,6 @@ function loadStats(): PersistedStats {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeLanguage(language: string): SupportedLanguage {
|
|
||||||
const base = language.toLowerCase().split("-")[0];
|
|
||||||
if (base === "vi" || base === "en") {
|
|
||||||
return base;
|
|
||||||
}
|
|
||||||
return "en";
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
wsConnected: boolean;
|
wsConnected: boolean;
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
@@ -177,6 +160,8 @@ interface SidebarProps {
|
|||||||
/** The connection details modal, opened from here or from the status bar. */
|
/** The connection details modal, opened from here or from the status bar. */
|
||||||
connectionOpen: boolean;
|
connectionOpen: boolean;
|
||||||
onConnectionOpenChange: (open: boolean) => void;
|
onConnectionOpenChange: (open: boolean) => void;
|
||||||
|
/** Told whether a newer release is out, for the status bar's dot. */
|
||||||
|
onUpdateAvailableChange?: (available: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({
|
export function Sidebar({
|
||||||
@@ -185,9 +170,9 @@ export function Sidebar({
|
|||||||
onToggle,
|
onToggle,
|
||||||
connectionOpen,
|
connectionOpen,
|
||||||
onConnectionOpenChange,
|
onConnectionOpenChange,
|
||||||
|
onUpdateAvailableChange,
|
||||||
}: SidebarProps) {
|
}: SidebarProps) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { theme, mode, setMode, toggleTheme } = useTheme();
|
|
||||||
// Track whether nav items are clipped by overflow so we can render
|
// Track whether nav items are clipped by overflow so we can render
|
||||||
// chevron affordances pointing toward the hidden items. Recomputed on
|
// chevron affordances pointing toward the hidden items. Recomputed on
|
||||||
// scroll, resize, and any structural change (e.g. collapse toggle).
|
// scroll, resize, and any structural change (e.g. collapse toggle).
|
||||||
@@ -395,24 +380,9 @@ export function Sidebar({
|
|||||||
: updateStatus
|
: updateStatus
|
||||||
? t("nav:upToDate")
|
? t("nav:upToDate")
|
||||||
: t("nav:checkForUpdates");
|
: t("nav:checkForUpdates");
|
||||||
const currentLanguage = normalizeLanguage(i18n.resolvedLanguage ?? i18n.language);
|
useEffect(() => {
|
||||||
const currentIndex = SUPPORTED_LANGUAGES.indexOf(currentLanguage);
|
onUpdateAvailableChange?.(updateAvailable);
|
||||||
const nextLanguage = SUPPORTED_LANGUAGES[(currentIndex + 1) % SUPPORTED_LANGUAGES.length];
|
}, [updateAvailable, onUpdateAvailableChange]);
|
||||||
const switchLanguageTitle = t("nav:switchLanguage", {
|
|
||||||
language: t(`nav:languageNames.${nextLanguage}`),
|
|
||||||
});
|
|
||||||
const nextTheme = theme === "dark" ? "light" : "dark";
|
|
||||||
const switchThemeTitle = t("nav:switchTheme", { theme: t(`nav:themeNames.${nextTheme}`) });
|
|
||||||
|
|
||||||
const toggleLang = () => {
|
|
||||||
i18n.changeLanguage(nextLanguage);
|
|
||||||
};
|
|
||||||
|
|
||||||
const changeLanguage = (language: SupportedLanguage) => {
|
|
||||||
if (language !== currentLanguage) {
|
|
||||||
i18n.changeLanguage(language);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
@@ -420,9 +390,26 @@ export function Sidebar({
|
|||||||
collapsed ? "w-[4.25rem]" : "w-60"
|
collapsed ? "w-[4.25rem]" : "w-60"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Brand */}
|
{/* Brand, with the collapse toggle as a small button in its corner (or
|
||||||
<div className="px-3 py-4 border-b border-border flex-shrink-0">
|
under the mark on the rail) rather than a full-width row. */}
|
||||||
<div className="flex flex-col items-center justify-center">
|
<div className="relative px-3 py-4 border-b border-border flex-shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
data-testid="sidebar-toggle"
|
||||||
|
title={collapsed ? t("nav:expand") : t("nav:collapse")}
|
||||||
|
aria-label={collapsed ? t("nav:expand") : t("nav:collapse")}
|
||||||
|
className={`z-10 inline-flex h-6 w-6 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary ${
|
||||||
|
collapsed ? "absolute bottom-1 left-1/2 -translate-x-1/2" : "absolute right-1.5 top-1.5"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{collapsed ? (
|
||||||
|
<PanelLeftOpen className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<PanelLeftClose className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<div className={`flex flex-col items-center justify-center ${collapsed ? "pb-4" : ""}`}>
|
||||||
{collapsed ? (
|
{collapsed ? (
|
||||||
<img src={BRAND_MARK} alt={t("nav:brandSub")} className="w-8 h-8 flex-shrink-0" />
|
<img src={BRAND_MARK} alt={t("nav:brandSub")} className="w-8 h-8 flex-shrink-0" />
|
||||||
) : (
|
) : (
|
||||||
@@ -512,222 +499,6 @@ export function Sidebar({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Language + theme controls. Theme sits next to language: same icon
|
|
||||||
row when collapsed, same row as the EN/VI buttons when expanded -
|
|
||||||
both are 2-state click-to-flip toggles, so they read as one family
|
|
||||||
of controls rather than two unrelated settings. */}
|
|
||||||
<div className="px-2 pb-2 flex-shrink-0">
|
|
||||||
{collapsed ? (
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<button
|
|
||||||
onClick={toggleLang}
|
|
||||||
className="w-full h-9 rounded-lg border border-border bg-surface-2 text-fg-secondary hover:bg-surface-3 hover:text-fg-primary transition-colors flex flex-col items-center justify-center gap-0.5"
|
|
||||||
title={switchLanguageTitle}
|
|
||||||
aria-label={switchLanguageTitle}
|
|
||||||
>
|
|
||||||
<Languages className="w-3.5 h-3.5" />
|
|
||||||
<span className="text-[10px] font-semibold leading-none">
|
|
||||||
{t(`nav:languageShort.${currentLanguage}`)}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={toggleTheme}
|
|
||||||
className="w-full h-9 rounded-lg border border-border bg-surface-2 text-fg-secondary hover:bg-surface-3 hover:text-fg-primary transition-colors flex flex-col items-center justify-center gap-0.5"
|
|
||||||
title={switchThemeTitle}
|
|
||||||
aria-label={switchThemeTitle}
|
|
||||||
>
|
|
||||||
{theme === "dark" ? (
|
|
||||||
<Moon className="w-3.5 h-3.5" />
|
|
||||||
) : (
|
|
||||||
<Sun className="w-3.5 h-3.5" />
|
|
||||||
)}
|
|
||||||
<span className="text-[10px] font-semibold leading-none">
|
|
||||||
{t(`nav:themeNames.${theme}`).slice(0, 1)}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="rounded-lg border border-border bg-surface-2 p-2 space-y-2">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className="px-1 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
|
||||||
{t("nav:language")}
|
|
||||||
</p>
|
|
||||||
{/* One column per language: a wider grid squeezed each button
|
|
||||||
below the width of its own label once Theme grew a third. */}
|
|
||||||
<div className="mt-2 grid grid-cols-2 gap-1">
|
|
||||||
{SUPPORTED_LANGUAGES.map((language) => {
|
|
||||||
const active = language === currentLanguage;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={language}
|
|
||||||
onClick={() => changeLanguage(language)}
|
|
||||||
aria-pressed={active}
|
|
||||||
aria-label={t(`nav:languageNames.${language}`)}
|
|
||||||
title={t(`nav:languageNames.${language}`)}
|
|
||||||
className={`rounded-md px-1 py-1.5 text-center text-[11px] font-semibold transition-colors ${
|
|
||||||
active
|
|
||||||
? "bg-accent/20 text-accent border border-accent/30"
|
|
||||||
: "bg-surface-1 text-fg-secondary border border-border hover:bg-surface-3 hover:text-fg-primary"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t(`nav:languageShort.${language}`)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="px-1 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
|
||||||
{t("nav:theme")}
|
|
||||||
</p>
|
|
||||||
<div className="mt-2 grid grid-cols-3 gap-1">
|
|
||||||
{(["dark", "light", "system"] as const).map((option) => {
|
|
||||||
const active = option === mode;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={option}
|
|
||||||
onClick={() => setMode(option)}
|
|
||||||
aria-pressed={active}
|
|
||||||
aria-label={t(`nav:themeNames.${option}`)}
|
|
||||||
title={t(`nav:themeNames.${option}`)}
|
|
||||||
className={`rounded-md px-2 py-1.5 text-[11px] font-semibold transition-colors flex items-center justify-center ${
|
|
||||||
active
|
|
||||||
? "bg-accent/20 text-accent border border-accent/30"
|
|
||||||
: "bg-surface-1 text-fg-secondary border border-border hover:bg-surface-3 hover:text-fg-primary"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{option === "dark" ? (
|
|
||||||
<Moon className="w-3.5 h-3.5" />
|
|
||||||
) : option === "light" ? (
|
|
||||||
<Sun className="w-3.5 h-3.5" />
|
|
||||||
) : (
|
|
||||||
<Monitor className="w-3.5 h-3.5" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Collapse toggle */}
|
|
||||||
<div className="px-2 py-2 flex-shrink-0">
|
|
||||||
<button
|
|
||||||
onClick={onToggle}
|
|
||||||
className={`w-full h-10 rounded-lg border border-border bg-surface-2 transition-colors ${
|
|
||||||
collapsed
|
|
||||||
? "flex items-center justify-center text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
|
|
||||||
: "flex items-center gap-2.5 px-3 text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
|
|
||||||
}`}
|
|
||||||
title={collapsed ? t("nav:expand") : t("nav:collapse")}
|
|
||||||
aria-label={collapsed ? t("nav:expand") : t("nav:collapse")}
|
|
||||||
>
|
|
||||||
{collapsed ? (
|
|
||||||
<PanelLeftOpen className="w-4 h-4 flex-shrink-0" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<PanelLeftClose className="w-4 h-4 flex-shrink-0" />
|
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wide">
|
|
||||||
{t("nav:collapseShort")}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div
|
|
||||||
className={`px-3 pt-3 pb-4 border-t border-border space-y-2.5 flex-shrink-0 ${collapsed ? "px-2" : ""}`}
|
|
||||||
>
|
|
||||||
{collapsed && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onConnectionOpenChange(true)}
|
|
||||||
aria-label={t("nav:connectionDetails")}
|
|
||||||
title={t("nav:connectionDetails")}
|
|
||||||
className={`rounded-lg border border-border bg-surface-2 hover:bg-surface-3 transition-colors text-left cursor-pointer ${
|
|
||||||
collapsed
|
|
||||||
? "w-8 h-8 mx-auto flex items-center justify-center p-0"
|
|
||||||
: "block w-full px-2.5 py-2"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`flex items-center text-xs ${collapsed ? "justify-center" : "justify-between gap-2"}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`inline-flex items-center gap-2 ${
|
|
||||||
wsConnected ? "text-status-success" : "text-fg-muted"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{wsConnected ? (
|
|
||||||
<Wifi className="w-3.5 h-3.5 flex-shrink-0" />
|
|
||||||
) : (
|
|
||||||
<WifiOff className="w-3.5 h-3.5 flex-shrink-0" />
|
|
||||||
)}
|
|
||||||
{!collapsed && (
|
|
||||||
<span className="font-medium">
|
|
||||||
{wsConnected ? t("nav:live") : t("nav:disconnected")}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
{!collapsed && (
|
|
||||||
<span className="text-[11px] font-medium text-fg-muted">{`v${__APP_VERSION__}`}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{collapsed ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onCheckUpdates}
|
|
||||||
disabled={checking}
|
|
||||||
title={checkTitle}
|
|
||||||
aria-label={checkTitle}
|
|
||||||
className={`relative w-8 h-8 mx-auto flex items-center justify-center rounded-lg border bg-surface-2 transition-colors disabled:opacity-60 ${
|
|
||||||
updateAvailable
|
|
||||||
? "border-status-success/40 text-status-success hover:bg-status-success/10"
|
|
||||||
: checkError
|
|
||||||
? "border-status-warning/40 text-status-warning hover:bg-status-warning/10"
|
|
||||||
: "border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<RefreshCw className={`w-3.5 h-3.5 ${checking ? "animate-spin" : ""}`} aria-hidden />
|
|
||||||
{updateAvailable && !checking && (
|
|
||||||
<span className="absolute top-1 right-1 w-1.5 h-1.5 rounded-full bg-status-success" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onCheckUpdates}
|
|
||||||
disabled={checking}
|
|
||||||
title={checkTitle}
|
|
||||||
className={`w-full rounded-lg border bg-surface-2 px-2.5 py-2 text-xs transition-colors disabled:opacity-60 flex items-center justify-between gap-2 ${
|
|
||||||
updateAvailable
|
|
||||||
? "border-status-success/40 text-status-success hover:bg-status-success/10"
|
|
||||||
: checkError
|
|
||||||
? "border-status-warning/40 text-status-warning hover:bg-status-warning/10"
|
|
||||||
: "border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="inline-flex items-center gap-2 truncate">
|
|
||||||
<RefreshCw
|
|
||||||
className={`w-3.5 h-3.5 flex-shrink-0 ${checking ? "animate-spin" : ""}`}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
<span className="font-medium truncate">{checkTitle}</span>
|
|
||||||
</span>
|
|
||||||
{updateAvailable && !checking && (
|
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-status-success flex-shrink-0" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ConnectionStatusModal
|
<ConnectionStatusModal
|
||||||
open={connectionOpen}
|
open={connectionOpen}
|
||||||
onClose={() => onConnectionOpenChange(false)}
|
onClose={() => onConnectionOpenChange(false)}
|
||||||
@@ -739,6 +510,13 @@ export function Sidebar({
|
|||||||
eventTimestampsRef={eventTimestampsRef}
|
eventTimestampsRef={eventTimestampsRef}
|
||||||
typeCountRef={typeCountRef}
|
typeCountRef={typeCountRef}
|
||||||
recentEventsRef={recentEventsRef}
|
recentEventsRef={recentEventsRef}
|
||||||
|
update={{
|
||||||
|
title: checkTitle,
|
||||||
|
checking,
|
||||||
|
available: updateAvailable,
|
||||||
|
failed: checkError,
|
||||||
|
onCheck: onCheckUpdates,
|
||||||
|
}}
|
||||||
onResetStats={() => {
|
onResetStats={() => {
|
||||||
eventCountRef.current = 0;
|
eventCountRef.current = 0;
|
||||||
peakPerSecRef.current = 0;
|
peakPerSecRef.current = 0;
|
||||||
@@ -772,6 +550,15 @@ interface ConnectionStatusModalProps {
|
|||||||
eventTimestampsRef: React.MutableRefObject<number[]>;
|
eventTimestampsRef: React.MutableRefObject<number[]>;
|
||||||
typeCountRef: React.MutableRefObject<Map<string, number>>;
|
typeCountRef: React.MutableRefObject<Map<string, number>>;
|
||||||
recentEventsRef: React.MutableRefObject<Array<{ type: string; at: number }>>;
|
recentEventsRef: React.MutableRefObject<Array<{ type: string; at: number }>>;
|
||||||
|
/** The release check: its one-line state ("Up to date", "Dev build · N
|
||||||
|
* ahead", a release out…) and the button that re-runs it. */
|
||||||
|
update: {
|
||||||
|
title: string;
|
||||||
|
checking: boolean;
|
||||||
|
available: boolean;
|
||||||
|
failed: boolean;
|
||||||
|
onCheck: () => void;
|
||||||
|
};
|
||||||
onResetStats: () => void;
|
onResetStats: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -786,6 +573,7 @@ function ConnectionStatusModal({
|
|||||||
eventTimestampsRef,
|
eventTimestampsRef,
|
||||||
typeCountRef,
|
typeCountRef,
|
||||||
recentEventsRef,
|
recentEventsRef,
|
||||||
|
update,
|
||||||
onResetStats,
|
onResetStats,
|
||||||
}: ConnectionStatusModalProps) {
|
}: ConnectionStatusModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -894,6 +682,42 @@ function ConnectionStatusModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-5 py-4 space-y-5 overflow-y-auto">
|
<div className="px-5 py-4 space-y-5 overflow-y-auto">
|
||||||
|
{/* Version + release check */}
|
||||||
|
<Section title={t("nav:version")} icon={RefreshCw}>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-mono text-sm text-fg-primary">{`v${__APP_VERSION__}`}</p>
|
||||||
|
<p
|
||||||
|
data-testid="update-status"
|
||||||
|
className={`mt-0.5 inline-flex items-center gap-1.5 text-xs ${
|
||||||
|
update.available
|
||||||
|
? "text-status-success"
|
||||||
|
: update.failed
|
||||||
|
? "text-status-warning"
|
||||||
|
: "text-fg-muted"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{update.available && (
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-status-success" />
|
||||||
|
)}
|
||||||
|
{update.title}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={update.onCheck}
|
||||||
|
disabled={update.checking}
|
||||||
|
className="btn-ghost shrink-0 text-xs disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={`h-3.5 w-3.5 ${update.checking ? "animate-spin" : ""}`}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
{t("nav:checkForUpdates")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
{/* KPI row */}
|
{/* KPI row */}
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
<KpiTile
|
<KpiTile
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* @file StatusBar — the one-line bar along the bottom of the app shell, on
|
* @file StatusBar — the one-line bar along the bottom of the app shell, on
|
||||||
* every page: the live-connection state (click: the connection details modal,
|
* every page: the live-connection state (click: the connection details modal,
|
||||||
* which the sidebar owns), the version, how many workspaces are running and how
|
* which the sidebar owns and which also carries the update check), the version
|
||||||
|
* — with a green dot while a newer release is out —, how many workspaces are running and how
|
||||||
* many need the user (from the shared lane list, no request of its own), and
|
* many need the user (from the shared lane list, no request of its own), and
|
||||||
* today's spend — the `daily_costs` bucket for the local date from
|
* today's spend — the `daily_costs` bucket for the local date from
|
||||||
* `GET /api/pricing/cost`, the Dashboard's own cost call, re-read every 30 s
|
* `GET /api/pricing/cost`, the Dashboard's own cost call, re-read every 30 s
|
||||||
@@ -27,11 +28,14 @@ export function StatusBar({
|
|||||||
wsConnected,
|
wsConnected,
|
||||||
left,
|
left,
|
||||||
onConnectionClick,
|
onConnectionClick,
|
||||||
|
updateAvailable = false,
|
||||||
}: {
|
}: {
|
||||||
wsConnected: boolean;
|
wsConnected: boolean;
|
||||||
/** Sidebar width, so the bar starts where the main column does. */
|
/** Sidebar width, so the bar starts where the main column does. */
|
||||||
left: string;
|
left: string;
|
||||||
onConnectionClick: () => void;
|
onConnectionClick: () => void;
|
||||||
|
/** A newer release is out: a dot beside the version. */
|
||||||
|
updateAvailable?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("nav");
|
const { t } = useTranslation("nav");
|
||||||
const { lanes } = useLanes();
|
const { lanes } = useLanes();
|
||||||
@@ -73,6 +77,13 @@ export function StatusBar({
|
|||||||
{wsConnected ? <Wifi className="h-3 w-3" /> : <WifiOff className="h-3 w-3" />}
|
{wsConnected ? <Wifi className="h-3 w-3" /> : <WifiOff className="h-3 w-3" />}
|
||||||
{wsConnected ? t("live") : t("disconnected")}
|
{wsConnected ? t("live") : t("disconnected")}
|
||||||
<span className="text-fg-muted">{`v${__APP_VERSION__}`}</span>
|
<span className="text-fg-muted">{`v${__APP_VERSION__}`}</span>
|
||||||
|
{updateAvailable && (
|
||||||
|
<span
|
||||||
|
data-testid="status-bar-update"
|
||||||
|
title={t("updateAvailable")}
|
||||||
|
className="h-1.5 w-1.5 rounded-full bg-status-success"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
<span data-testid="status-bar-counts">
|
<span data-testid="status-bar-counts">
|
||||||
{t("statusBar.running", { count: running })}
|
{t("statusBar.running", { count: running })}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* @file Switch.tsx
|
||||||
|
* @description The app's ONE on/off switch (`role="switch"`): an accent track
|
||||||
|
* with a white knob, the same size everywhere. Settings rows, webhook targets
|
||||||
|
* and the AI Tools list all draw this — do not hand-roll another one; a
|
||||||
|
* yes/no choice inside a form or list is a `Checkbox` instead.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function Switch({
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
label,
|
||||||
|
title,
|
||||||
|
disabled = false,
|
||||||
|
}: {
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (v: boolean) => void;
|
||||||
|
/** Accessible name — a switch usually sits beside its visible label. */
|
||||||
|
label?: string;
|
||||||
|
title?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={checked}
|
||||||
|
aria-label={label}
|
||||||
|
title={title}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onChange(!checked)}
|
||||||
|
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||||
|
checked ? "bg-accent" : "bg-surface-4"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={`inline-block h-4 w-4 rounded-full bg-white shadow transition-transform ${
|
||||||
|
checked ? "translate-x-[18px]" : "translate-x-0.5"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -58,6 +58,7 @@
|
|||||||
*
|
*
|
||||||
* ----------------------------------------------------------------------------- */
|
* ----------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
import { Switch } from "./Switch";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
@@ -132,34 +133,6 @@ function defaultsFor(provider: WebhookProvider | undefined): Record<string, stri
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Toggle({
|
|
||||||
checked,
|
|
||||||
onChange,
|
|
||||||
label,
|
|
||||||
}: {
|
|
||||||
checked: boolean;
|
|
||||||
onChange: (v: boolean) => void;
|
|
||||||
label?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="switch"
|
|
||||||
aria-checked={checked}
|
|
||||||
aria-label={label}
|
|
||||||
onClick={() => onChange(!checked)}
|
|
||||||
className={`relative inline-flex h-5 w-9 flex-shrink-0 items-center rounded-full transition-colors ${
|
|
||||||
checked ? "bg-blue-600" : "bg-surface-4"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform"
|
|
||||||
style={{ transform: checked ? "translateX(18px)" : "translateX(3px)" }}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WebhookSettings() {
|
export function WebhookSettings() {
|
||||||
const { t } = useTranslation("settings");
|
const { t } = useTranslation("settings");
|
||||||
const [targets, setTargets] = useState<WebhookTarget[]>([]);
|
const [targets, setTargets] = useState<WebhookTarget[]>([]);
|
||||||
@@ -452,7 +425,7 @@ export function WebhookSettings() {
|
|||||||
{timeAgo(target.last_delivery.created_at)}
|
{timeAgo(target.last_delivery.created_at)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<Toggle
|
<Switch
|
||||||
checked={target.enabled}
|
checked={target.enabled}
|
||||||
onChange={() => onToggle(target)}
|
onChange={() => onToggle(target)}
|
||||||
label={t("webhooks.enabled")}
|
label={t("webhooks.enabled")}
|
||||||
@@ -772,7 +745,7 @@ export function WebhookSettings() {
|
|||||||
|
|
||||||
<div className="flex items-center gap-3 pt-1">
|
<div className="flex items-center gap-3 pt-1">
|
||||||
<label className="inline-flex items-center gap-2 text-[11px] text-fg-secondary cursor-pointer">
|
<label className="inline-flex items-center gap-2 text-[11px] text-fg-secondary cursor-pointer">
|
||||||
<Toggle checked={form.enabled} onChange={(v) => set({ enabled: v })} />
|
<Switch checked={form.enabled} onChange={(v) => set({ enabled: v })} />
|
||||||
{t("webhooks.enabledOnSave")}
|
{t("webhooks.enabledOnSave")}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,24 +1,28 @@
|
|||||||
/**
|
/**
|
||||||
* @file Sidebar.test.tsx
|
* @file Sidebar.test.tsx
|
||||||
* @description Unit tests for the Sidebar component, which is responsible for rendering the application's sidebar navigation. The tests cover rendering of the brand name, subtitle, navigation links, WebSocket connection status, and version number. The tests use React Testing Library and Vitest for assertions and mocking.
|
* @description Unit tests for the Sidebar: the brand, the navigation links, the small collapse button in the brand's corner, no footer / language / theme controls (those moved to the status bar and Settings), and the connection details modal carrying the version and the release check.
|
||||||
* @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, vi } from "vitest";
|
||||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
import { act, render, screen, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { Sidebar } from "../Sidebar";
|
import { Sidebar } from "../Sidebar";
|
||||||
import { eventBus } from "../../lib/eventBus";
|
import { eventBus } from "../../lib/eventBus";
|
||||||
|
|
||||||
function renderSidebar(wsConnected: boolean, collapsed = false) {
|
function renderSidebar(
|
||||||
|
wsConnected: boolean,
|
||||||
|
collapsed = false,
|
||||||
|
extra: { onToggle?: () => void; connectionOpen?: boolean } = {}
|
||||||
|
) {
|
||||||
return render(
|
return render(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<Sidebar
|
<Sidebar
|
||||||
wsConnected={wsConnected}
|
wsConnected={wsConnected}
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
onToggle={() => {}}
|
onToggle={extra.onToggle ?? (() => {})}
|
||||||
connectionOpen={false}
|
connectionOpen={extra.connectionOpen ?? false}
|
||||||
onConnectionOpenChange={() => {}}
|
onConnectionOpenChange={() => {}}
|
||||||
/>
|
/>
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
@@ -51,17 +55,29 @@ describe("Sidebar", () => {
|
|||||||
expect(screen.queryByText("Activity Feed")).toBeNull();
|
expect(screen.queryByText("Activity Feed")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
// The live/version line moved to the status bar (StatusBar.test.tsx); the
|
// The live/version line lives in the status bar and the update check in the
|
||||||
// rail keeps an icon-only button for the connection details.
|
// connection details modal; language and theme live in Settings.
|
||||||
it("keeps the connection button in the rail only", () => {
|
it("has no footer, language or theme controls", () => {
|
||||||
renderSidebar(true);
|
for (const collapsed of [false, true]) {
|
||||||
expect(screen.queryByText("Live")).toBeNull();
|
const { unmount } = renderSidebar(true, collapsed);
|
||||||
expect(screen.queryByRole("button", { name: "Connection details" })).toBeNull();
|
expect(screen.queryByText("Live")).toBeNull();
|
||||||
|
expect(screen.queryByRole("button", { name: "Connection details" })).toBeNull();
|
||||||
|
expect(screen.queryByRole("button", { name: "English" })).toBeNull();
|
||||||
|
expect(screen.queryByRole("button", { name: "Dark" })).toBeNull();
|
||||||
|
expect(screen.queryByText("Up to date")).toBeNull();
|
||||||
|
unmount();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("offers the connection details from the collapsed rail", () => {
|
it("collapses and expands from a small button in the brand's corner", async () => {
|
||||||
renderSidebar(false, true);
|
const onToggle = vi.fn();
|
||||||
expect(screen.getByRole("button", { name: "Connection details" })).toBeInTheDocument();
|
const user = userEvent.setup();
|
||||||
|
const { unmount } = renderSidebar(true, false, { onToggle });
|
||||||
|
await user.click(screen.getByRole("button", { name: "Collapse sidebar" }));
|
||||||
|
unmount();
|
||||||
|
renderSidebar(true, true, { onToggle });
|
||||||
|
await user.click(screen.getByRole("button", { name: "Expand sidebar" }));
|
||||||
|
expect(onToggle).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the workspace list when expanded and its icon in the rail", () => {
|
it("renders the workspace list when expanded and its icon in the rail", () => {
|
||||||
@@ -81,39 +97,7 @@ describe("Sidebar", () => {
|
|||||||
expect(hrefs).not.toContain("/activity");
|
expect(hrefs).not.toContain("/activity");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should render both language options in expanded mode", () => {
|
describe("update status line, in the connection details modal", () => {
|
||||||
renderSidebar(true);
|
|
||||||
expect(screen.getByRole("button", { name: "English" })).toBeInTheDocument();
|
|
||||||
expect(screen.getByRole("button", { name: "Vietnamese" })).toBeInTheDocument();
|
|
||||||
// Chinese and Korean were dropped; offering them would render raw keys.
|
|
||||||
expect(screen.queryByRole("button", { name: "Chinese" })).toBeNull();
|
|
||||||
expect(screen.queryByRole("button", { name: "Korean" })).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should switch to Vietnamese when Vietnamese option is clicked", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
renderSidebar(true);
|
|
||||||
|
|
||||||
await user.click(screen.getByRole("button", { name: "Vietnamese" }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText("Tổng quan")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should cycle language in collapsed mode", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
renderSidebar(true, true);
|
|
||||||
|
|
||||||
expect(screen.getByText("EN")).toBeInTheDocument();
|
|
||||||
await user.click(screen.getByRole("button", { name: "Switch to Vietnamese" }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText("VI")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("update status line", () => {
|
|
||||||
const plugin = {
|
const plugin = {
|
||||||
git_repo: false,
|
git_repo: false,
|
||||||
install_kind: "plugin",
|
install_kind: "plugin",
|
||||||
@@ -124,32 +108,36 @@ describe("Sidebar", () => {
|
|||||||
act(() => eventBus.publish({ type: "update_status", data } as never));
|
act(() => eventBus.publish({ type: "update_status", data } as never));
|
||||||
|
|
||||||
it("does not claim up to date when a plugin install could not read the release", () => {
|
it("does not claim up to date when a plugin install could not read the release", () => {
|
||||||
renderSidebar(true);
|
renderSidebar(true, false, { connectionOpen: true });
|
||||||
publish({ ...plugin, update_available: false, release_check: "unreachable" });
|
publish({ ...plugin, update_available: false, release_check: "unreachable" });
|
||||||
expect(screen.queryByText("Up to date")).toBeNull();
|
expect(screen.queryByText("Up to date")).toBeNull();
|
||||||
expect(screen.getByText("Could not check for updates")).toBeInTheDocument();
|
expect(screen.getByTestId("update-status")).toHaveTextContent("Could not check for updates");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("names a build installed beside the running one", () => {
|
it("names a build installed beside the running one", () => {
|
||||||
renderSidebar(true);
|
renderSidebar(true, false, { connectionOpen: true });
|
||||||
publish({
|
publish({
|
||||||
...plugin,
|
...plugin,
|
||||||
update_available: true,
|
update_available: true,
|
||||||
release_check: "unreachable",
|
release_check: "unreachable",
|
||||||
installed_not_running: { root: "/x", version: "1.8.12" },
|
installed_not_running: { root: "/x", version: "1.8.12" },
|
||||||
});
|
});
|
||||||
expect(screen.getByText("1.8.12 installed, not running")).toBeInTheDocument();
|
expect(screen.getByTestId("update-status")).toHaveTextContent(
|
||||||
|
"1.8.12 installed, not running"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("still says up to date when the release was read and matches", () => {
|
it("still says up to date when the release was read and matches", () => {
|
||||||
renderSidebar(true);
|
renderSidebar(true, false, { connectionOpen: true });
|
||||||
publish({
|
publish({
|
||||||
...plugin,
|
...plugin,
|
||||||
update_available: false,
|
update_available: false,
|
||||||
release_check: "ok",
|
release_check: "ok",
|
||||||
latest_release: "v1.8.10",
|
latest_release: "v1.8.10",
|
||||||
});
|
});
|
||||||
expect(screen.getByText("Up to date")).toBeInTheDocument();
|
const dialog = screen.getByRole("dialog");
|
||||||
|
expect(within(dialog).getByTestId("update-status")).toHaveTextContent("Up to date");
|
||||||
|
expect(within(dialog).getByRole("button", { name: "Check for updates" })).toBeEnabled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
/**
|
|
||||||
* @file Sidebar.theme.test.tsx
|
|
||||||
* @description Tests for the Sidebar's theme controls: the expanded panel's Dark / Light / System buttons and the collapsed one-click toggle, both writing through useTheme.
|
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach } from "vitest";
|
|
||||||
import { render, screen } from "@testing-library/react";
|
|
||||||
import userEvent from "@testing-library/user-event";
|
|
||||||
import { MemoryRouter } from "react-router-dom";
|
|
||||||
import { Sidebar } from "../Sidebar";
|
|
||||||
|
|
||||||
function renderSidebar(collapsed = false) {
|
|
||||||
return render(
|
|
||||||
<MemoryRouter>
|
|
||||||
<Sidebar
|
|
||||||
wsConnected
|
|
||||||
collapsed={collapsed}
|
|
||||||
onToggle={() => {}}
|
|
||||||
connectionOpen={false}
|
|
||||||
onConnectionOpenChange={() => {}}
|
|
||||||
/>
|
|
||||||
</MemoryRouter>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Sidebar theme controls", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
localStorage.clear();
|
|
||||||
document.documentElement.classList.remove("dark");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("expanded panel offers Dark, Light and System with Dark pressed by default", () => {
|
|
||||||
renderSidebar();
|
|
||||||
expect(screen.getByRole("button", { name: "Dark" })).toHaveAttribute("aria-pressed", "true");
|
|
||||||
expect(screen.getByRole("button", { name: "Light" })).toHaveAttribute("aria-pressed", "false");
|
|
||||||
expect(screen.getByRole("button", { name: "System" })).toHaveAttribute("aria-pressed", "false");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clicking System sets the mode and presses only System", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
renderSidebar();
|
|
||||||
await user.click(screen.getByRole("button", { name: "System" }));
|
|
||||||
expect(localStorage.getItem("theme")).toBe("system");
|
|
||||||
expect(screen.getByRole("button", { name: "System" })).toHaveAttribute("aria-pressed", "true");
|
|
||||||
expect(screen.getByRole("button", { name: "Dark" })).toHaveAttribute("aria-pressed", "false");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clicking Light applies light mode to <html>", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
renderSidebar();
|
|
||||||
await user.click(screen.getByRole("button", { name: "Light" }));
|
|
||||||
expect(localStorage.getItem("theme")).toBe("light");
|
|
||||||
expect(document.documentElement.classList.contains("dark")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("collapsed toggle flips to the opposite of the resolved mode", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
localStorage.setItem("theme", "light");
|
|
||||||
renderSidebar(true);
|
|
||||||
await user.click(screen.getByRole("button", { name: "Switch to Dark" }));
|
|
||||||
expect(localStorage.getItem("theme")).toBe("dark");
|
|
||||||
expect(screen.getByRole("button", { name: "Switch to Light" })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -2,7 +2,9 @@
|
|||||||
* @file WorkspaceTree.test.tsx
|
* @file WorkspaceTree.test.tsx
|
||||||
* @description Tests for the sidebar's workspace list: one flat row per
|
* @description Tests for the sidebar's workspace list: one flat row per
|
||||||
* workspace in the stored order, a row expanding to its sessions (remembered),
|
* workspace in the stored order, a row expanding to its sessions (remembered),
|
||||||
* a live session opening its pane and an ended one its transcript, selection
|
* a live session opening its pane and an ended one its transcript, the
|
||||||
|
* workspace's OWN sessions only, a session's state dot, rename and archive,
|
||||||
|
* selection
|
||||||
* by URL, Alt+arrow reordering kept in localStorage, double-click rename, the
|
* by URL, Alt+arrow reordering kept in localStorage, double-click rename, the
|
||||||
* search, the row menu's items by kind and tool, and the status dot.
|
* search, the row menu's items by kind and tool, and the status dot.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
@@ -57,6 +59,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
|||||||
list: vi.fn(async () => ({
|
list: vi.fn(async () => ({
|
||||||
sessions: [session("s-live", "Live one"), session("s-old", "Old one")],
|
sessions: [session("s-live", "Live one"), session("s-old", "Old one")],
|
||||||
})),
|
})),
|
||||||
|
patch: vi.fn(async () => ({ session: {} })),
|
||||||
},
|
},
|
||||||
run: {
|
run: {
|
||||||
list: vi.fn(async () => ({
|
list: vi.fn(async () => ({
|
||||||
@@ -105,6 +108,12 @@ const rowIds = () => screen.getAllByTestId(/^workspace-row-\d+$/).map((el) => el
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(api.sessions.list).mockImplementation(async () => ({
|
||||||
|
sessions: [session("s-live", "Live one"), session("s-old", "Old one")],
|
||||||
|
total: 2,
|
||||||
|
limit: 20,
|
||||||
|
offset: 0,
|
||||||
|
}));
|
||||||
lanes = [
|
lanes = [
|
||||||
lane(1, "/r", { title: "Repo" }),
|
lane(1, "/r", { title: "Repo" }),
|
||||||
lane(2, "/r/sub", { title: "Sub" }),
|
lane(2, "/r/sub", { title: "Sub" }),
|
||||||
@@ -128,12 +137,50 @@ describe("WorkspaceTree", () => {
|
|||||||
await user.click(await screen.findByTestId("workspace-toggle-1"));
|
await user.click(await screen.findByTestId("workspace-toggle-1"));
|
||||||
const list = await screen.findByTestId("workspace-sessions-1");
|
const list = await screen.findByTestId("workspace-sessions-1");
|
||||||
expect(within(list).getByText("Live one")).toBeInTheDocument();
|
expect(within(list).getByText("Live one")).toBeInTheDocument();
|
||||||
expect(api.sessions.list).toHaveBeenCalledWith({ cwd: "/r", tool: "claude", limit: 20 });
|
// Its OWN sessions only — never the rest of its folder.
|
||||||
|
expect(api.sessions.list).toHaveBeenCalledTimes(1);
|
||||||
|
expect(api.sessions.list).toHaveBeenCalledWith({ lane: 1, archived: false, limit: 20 });
|
||||||
expect(JSON.parse(localStorage.getItem("agenthub.sidebar.projects")!).expanded).toEqual({
|
expect(JSON.parse(localStorage.getItem("agenthub.sidebar.projects")!).expanded).toEqual({
|
||||||
"1": true,
|
"1": true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renames a session on double-click + Enter, and archives one from its row", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderTree();
|
||||||
|
await user.click(await screen.findByTestId("workspace-toggle-1"));
|
||||||
|
await user.dblClick(await screen.findByTestId("workspace-session-s-old"));
|
||||||
|
const input = screen.getByTestId("workspace-session-rename-s-old");
|
||||||
|
await user.clear(input);
|
||||||
|
await user.type(input, "Better name{Enter}");
|
||||||
|
expect(api.sessions.patch).toHaveBeenCalledWith("s-old", { name: "Better name" });
|
||||||
|
await user.click(screen.getByTestId("workspace-session-archive-s-live"));
|
||||||
|
expect(api.sessions.patch).toHaveBeenCalledWith("s-live", { archived: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dots a session waiting for the user amber and a failed one red", async () => {
|
||||||
|
vi.mocked(api.sessions.list).mockImplementation(async () => ({
|
||||||
|
sessions: [
|
||||||
|
{
|
||||||
|
...session("s-wait", "Waiting"),
|
||||||
|
status: "active",
|
||||||
|
awaiting_input_since: "2026-09-30T10:00:00Z",
|
||||||
|
} as Session,
|
||||||
|
{ ...session("s-bad", "Broke"), status: "error" } as Session,
|
||||||
|
],
|
||||||
|
total: 2,
|
||||||
|
limit: 20,
|
||||||
|
offset: 0,
|
||||||
|
}));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderTree();
|
||||||
|
await user.click(await screen.findByTestId("workspace-toggle-1"));
|
||||||
|
expect(await screen.findByTestId("workspace-session-dot-s-wait")).toHaveClass(
|
||||||
|
"bg-status-warning"
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("workspace-session-dot-s-bad")).toHaveClass("bg-status-danger");
|
||||||
|
});
|
||||||
|
|
||||||
it("opens a live session's pane and an ended session's transcript, resuming nothing", async () => {
|
it("opens a live session's pane and an ended session's transcript, resuming nothing", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderTree();
|
renderTree();
|
||||||
@@ -142,6 +189,11 @@ describe("WorkspaceTree", () => {
|
|||||||
await waitFor(() => expect(live).toHaveAttribute("data-live", "true"));
|
await waitFor(() => expect(live).toHaveAttribute("data-live", "true"));
|
||||||
await user.click(live);
|
await user.click(live);
|
||||||
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1&run=agenthub-lane-1-2");
|
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1&run=agenthub-lane-1-2");
|
||||||
|
// Ctrl+click asks for a second pane.
|
||||||
|
fireEvent.click(live, { ctrlKey: true });
|
||||||
|
expect(screen.getByTestId("where").textContent).toBe(
|
||||||
|
"/workspace?lane=1&run=agenthub-lane-1-2&pane=1"
|
||||||
|
);
|
||||||
await user.click(screen.getByTestId("workspace-session-s-old"));
|
await user.click(screen.getByTestId("workspace-session-s-old"));
|
||||||
expect(screen.getByTestId("where").textContent).toBe("/sessions/s-old");
|
expect(screen.getByTestId("where").textContent).toBe("/sessions/s-old");
|
||||||
expect(api.lanes.action).not.toHaveBeenCalled();
|
expect(api.lanes.action).not.toHaveBeenCalled();
|
||||||
@@ -225,7 +277,7 @@ describe("WorkspaceTree", () => {
|
|||||||
it("finds a workspace by one of its session names, showing only the matches", async () => {
|
it("finds a workspace by one of its session names, showing only the matches", async () => {
|
||||||
vi.mocked(api.sessions.list).mockImplementation(async (params) => ({
|
vi.mocked(api.sessions.list).mockImplementation(async (params) => ({
|
||||||
sessions:
|
sessions:
|
||||||
(params as { cwd?: string }).cwd === "/r/sub"
|
(params as { lane?: number }).lane === 2
|
||||||
? [session("s-x", "Fix login bug"), session("s-y", "Docs")]
|
? [session("s-x", "Fix login bug"), session("s-y", "Docs")]
|
||||||
: [session("s-live", "Live one"), session("s-old", "Old one")],
|
: [session("s-live", "Live one"), session("s-old", "Old one")],
|
||||||
total: 2,
|
total: 2,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { Checkbox } from "../Checkbox";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ConfirmModal } from "../ConfirmModal";
|
import { ConfirmModal } from "../ConfirmModal";
|
||||||
@@ -210,15 +211,14 @@ export function DestructiveLaneModal({
|
|||||||
</p>
|
</p>
|
||||||
))}
|
))}
|
||||||
{requiresForce && (
|
{requiresForce && (
|
||||||
<label className="mt-3 flex items-start gap-2 text-xs text-status-warning">
|
<Checkbox
|
||||||
<input
|
className="mt-3 items-start"
|
||||||
type="checkbox"
|
tone="warning"
|
||||||
checked={force}
|
checked={force}
|
||||||
onChange={(event) => setForce(event.target.checked)}
|
onChange={setForce}
|
||||||
className="mt-0.5"
|
label={t("destructive.force")}
|
||||||
/>
|
labelClassName="text-xs text-status-warning"
|
||||||
{t("destructive.force")}
|
/>
|
||||||
</label>
|
|
||||||
)}
|
)}
|
||||||
{action === "reset" && (
|
{action === "reset" && (
|
||||||
<p className="mt-3 text-xs text-fg-secondary">{t("destructive.reset.survives")}</p>
|
<p className="mt-3 text-xs text-fg-secondary">{t("destructive.reset.survives")}</p>
|
||||||
|
|||||||
@@ -29,6 +29,10 @@
|
|||||||
* file is conflicted) and **Abort merge** (two clicks, `confirm: true`), and
|
* file is conflicted) and **Abort merge** (two clicks, `confirm: true`), and
|
||||||
* conflicted files get their own group with Keep mine / Take theirs / Mark
|
* conflicted files get their own group with Keep mine / Take theirs / Mark
|
||||||
* resolved instead of stage — staging one blind would mark it resolved.
|
* resolved instead of stage — staging one blind would mark it resolved.
|
||||||
|
*
|
||||||
|
* A diff line's number opens a comment box (LaneDiffView `onComment`); the
|
||||||
|
* comment is pasted, as `<path>:<line> — <text>`, into the workspace's pane
|
||||||
|
* focused last (`lib/panePaste.ts`) and sent only when the user presses Enter.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -56,6 +60,7 @@ import {
|
|||||||
UnfoldVertical,
|
UnfoldVertical,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
|
import { pasteToLane } from "../../lib/panePaste";
|
||||||
import { LaneDiffView, LaneFileView, type DiffLayout, type LaneDiffChange } from "./LaneDiffView";
|
import { LaneDiffView, LaneFileView, type DiffLayout, type LaneDiffChange } from "./LaneDiffView";
|
||||||
import { LaneConflictView } from "./LaneConflictView";
|
import { LaneConflictView } from "./LaneConflictView";
|
||||||
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
|
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
|
||||||
@@ -436,6 +441,8 @@ export function LaneChangesPanel({
|
|||||||
// One read of the working copy, shared by the header and every write below:
|
// One read of the working copy, shared by the header and every write below:
|
||||||
// a stage, a discard or a commit changes the counts the header shows.
|
// a stage, a discard or a commit changes the counts the header shows.
|
||||||
const [git, refreshGit] = useLaneGitFacts(laneId);
|
const [git, refreshGit] = useLaneGitFacts(laneId);
|
||||||
|
// A diff line's comment goes into this workspace's pane, as a paste.
|
||||||
|
const comment = useCallback((text: string) => pasteToLane(laneId, text), [laneId]);
|
||||||
// Bumped by Refresh and by a git bar action: a pull or a switch moves
|
// Bumped by Refresh and by a git bar action: a pull or a switch moves
|
||||||
// history and the tree too, not just the diff `load` re-reads.
|
// history and the tree too, not just the diff `load` re-reads.
|
||||||
const [reread, setReread] = useState(0);
|
const [reread, setReread] = useState(0);
|
||||||
@@ -1558,6 +1565,7 @@ export function LaneChangesPanel({
|
|||||||
layout={layout}
|
layout={layout}
|
||||||
fill
|
fill
|
||||||
hunkActions={whole ? undefined : hunkActions(current)}
|
hunkActions={whole ? undefined : hunkActions(current)}
|
||||||
|
onComment={comment}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -1637,7 +1645,12 @@ export function LaneChangesPanel({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!shut && (
|
{!shut && (
|
||||||
<LaneDiffView file={f} layout={layout} hunkActions={hunkActions(f)} />
|
<LaneDiffView
|
||||||
|
file={f}
|
||||||
|
layout={layout}
|
||||||
|
hunkActions={hunkActions(f)}
|
||||||
|
onComment={comment}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -262,7 +262,28 @@ export function analyseHunk(hunk: LaneDiffHunk): Analysed {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Gutter({ n }: { n: number | null }) {
|
/** Set by a LaneDiffView given `onComment`: a click on a NEW line's number
|
||||||
|
* opens the comment box for that line. */
|
||||||
|
const CommentLine = createContext<{
|
||||||
|
open: (line: number) => void;
|
||||||
|
title: (line: number) => string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
function Gutter({ n, commentable = false }: { n: number | null; commentable?: boolean }) {
|
||||||
|
const comment = useContext(CommentLine);
|
||||||
|
if (commentable && comment && n != null) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid={`diff-comment-line-${n}`}
|
||||||
|
onClick={() => comment.open(n)}
|
||||||
|
title={comment.title(n)}
|
||||||
|
className="w-10 shrink-0 select-none px-1 text-right text-fg-muted/60 hover:bg-accent/20 hover:text-accent"
|
||||||
|
>
|
||||||
|
{n}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<span className="w-10 shrink-0 select-none px-1 text-right text-fg-muted/60">{n ?? ""}</span>
|
<span className="w-10 shrink-0 select-none px-1 text-right text-fg-muted/60">{n ?? ""}</span>
|
||||||
);
|
);
|
||||||
@@ -462,7 +483,7 @@ function UnifiedLine({
|
|||||||
return (
|
return (
|
||||||
<Row tint={rowTint(line.type)} style={style} anchor={anchor}>
|
<Row tint={rowTint(line.type)} style={style} anchor={anchor}>
|
||||||
<Gutter n={line.oldLine} />
|
<Gutter n={line.oldLine} />
|
||||||
<Gutter n={line.newLine} />
|
<Gutter n={line.newLine} commentable />
|
||||||
<span className="whitespace-pre px-2">
|
<span className="whitespace-pre px-2">
|
||||||
<span className={`select-none ${markerClass(line.type)}`}>
|
<span className={`select-none ${markerClass(line.type)}`}>
|
||||||
{line.type === "add" ? "+" : line.type === "del" ? "-" : " "}
|
{line.type === "add" ? "+" : line.type === "del" ? "-" : " "}
|
||||||
@@ -534,7 +555,10 @@ function SplitLine({
|
|||||||
const anchor = useAnchor(start);
|
const anchor = useAnchor(start);
|
||||||
return (
|
return (
|
||||||
<Row tint={rowTint(line?.type ?? null)} style={style} anchor={anchor}>
|
<Row tint={rowTint(line?.type ?? null)} style={style} anchor={anchor}>
|
||||||
<Gutter n={(side === "left" ? line?.oldLine : line?.newLine) ?? null} />
|
<Gutter
|
||||||
|
n={(side === "left" ? line?.oldLine : line?.newLine) ?? null}
|
||||||
|
commentable={side === "right"}
|
||||||
|
/>
|
||||||
<span className="whitespace-pre px-1">
|
<span className="whitespace-pre px-1">
|
||||||
{line ? (
|
{line ? (
|
||||||
<Code
|
<Code
|
||||||
@@ -700,6 +724,7 @@ export function LaneDiffView({
|
|||||||
layout,
|
layout,
|
||||||
fill = false,
|
fill = false,
|
||||||
hunkActions,
|
hunkActions,
|
||||||
|
onComment,
|
||||||
}: {
|
}: {
|
||||||
file: LaneChangeFile;
|
file: LaneChangeFile;
|
||||||
layout: DiffLayout;
|
layout: DiffLayout;
|
||||||
@@ -707,9 +732,35 @@ export function LaneDiffView({
|
|||||||
fill?: boolean;
|
fill?: boolean;
|
||||||
/** Controls drawn in each change block's review bar. */
|
/** Controls drawn in each change block's review bar. */
|
||||||
hunkActions?: (change: LaneDiffChange) => ReactNode;
|
hunkActions?: (change: LaneDiffChange) => ReactNode;
|
||||||
|
/** Makes each new line's number a button that writes a comment on that line
|
||||||
|
* and hands `<path>:<line> — <text>` here; false = it reached no pane. */
|
||||||
|
onComment?: (text: string) => boolean;
|
||||||
}) {
|
}) {
|
||||||
const root = useRef<HTMLDivElement>(null);
|
const root = useRef<HTMLDivElement>(null);
|
||||||
const { t } = useTranslation(["lanes"]);
|
const { t } = useTranslation(["lanes"]);
|
||||||
|
const [commentAt, setCommentAt] = useState<number | null>(null);
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
const [undelivered, setUndelivered] = useState(false);
|
||||||
|
const openComment = useMemo(
|
||||||
|
() =>
|
||||||
|
onComment
|
||||||
|
? {
|
||||||
|
open: (line: number) => {
|
||||||
|
setCommentAt(line);
|
||||||
|
setUndelivered(false);
|
||||||
|
},
|
||||||
|
title: (line: number) => t("changes.commentLine", { n: line }),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
[onComment, t]
|
||||||
|
);
|
||||||
|
const sendComment = () => {
|
||||||
|
const text = draft.trim();
|
||||||
|
if (!text || commentAt == null || !onComment) return;
|
||||||
|
if (!onComment(`${file.path}:${commentAt} — ${text}`)) return setUndelivered(true);
|
||||||
|
setCommentAt(null);
|
||||||
|
setDraft("");
|
||||||
|
};
|
||||||
const lang = langFor(file.path);
|
const lang = langFor(file.path);
|
||||||
const hunks = file.hunks;
|
const hunks = file.hunks;
|
||||||
const items = useMemo(() => (hunks ? flatten(hunks, "unified") : []), [hunks]);
|
const items = useMemo(() => (hunks ? flatten(hunks, "unified") : []), [hunks]);
|
||||||
@@ -755,50 +806,96 @@ export function LaneDiffView({
|
|||||||
return <p className="px-2 py-1 text-[10px] text-fg-muted">{t("changes.noHunks")}</p>;
|
return <p className="px-2 py-1 text-[10px] text-fg-muted">{t("changes.noHunks")}</p>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HunkActions.Provider value={review}>
|
<CommentLine.Provider value={openComment}>
|
||||||
<div
|
<HunkActions.Provider value={review}>
|
||||||
ref={root}
|
<div
|
||||||
data-testid="lane-diff-view"
|
ref={root}
|
||||||
data-layout={layout}
|
data-testid="lane-diff-view"
|
||||||
onScroll={(e) => win.onScroll(e.currentTarget)}
|
data-layout={layout}
|
||||||
className={`flex flex-col font-mono text-[11px] leading-[1.45] ${
|
onScroll={(e) => win.onScroll(e.currentTarget)}
|
||||||
layout === "split"
|
className={`flex flex-col font-mono text-[11px] leading-[1.45] ${
|
||||||
? boxHeight(fill, splitWindowed)
|
layout === "split"
|
||||||
: `overflow-auto ${boxHeight(fill, win.on)}`
|
? boxHeight(fill, splitWindowed)
|
||||||
}`}
|
: `overflow-auto ${boxHeight(fill, win.on)}`
|
||||||
>
|
}`}
|
||||||
{layout === "split" ? (
|
>
|
||||||
<SplitView hunks={file.hunks} lang={lang} fill={fill} />
|
{layout === "split" ? (
|
||||||
) : win.on ? (
|
<SplitView hunks={file.hunks} lang={lang} fill={fill} />
|
||||||
<Windowed count={items.length} range={win.range} minWidth={minWidth}>
|
) : win.on ? (
|
||||||
{items
|
<Windowed count={items.length} range={win.range} minWidth={minWidth}>
|
||||||
.slice(win.range.start, win.range.end)
|
{items
|
||||||
.map(({ hunk, i }, k) =>
|
.slice(win.range.start, win.range.end)
|
||||||
i < 0 ? (
|
.map(({ hunk, i }, k) =>
|
||||||
<HunkHeader key={win.range.start + k} hunk={hunk} fixed />
|
i < 0 ? (
|
||||||
) : (
|
<HunkHeader key={win.range.start + k} hunk={hunk} fixed />
|
||||||
<UnifiedLine
|
) : (
|
||||||
key={win.range.start + k}
|
<UnifiedLine
|
||||||
line={hunk.lines[i]!}
|
key={win.range.start + k}
|
||||||
lang={lang}
|
line={hunk.lines[i]!}
|
||||||
emph={analyseHunk(hunk).emph.get(i)}
|
lang={lang}
|
||||||
style={FIXED}
|
emph={analyseHunk(hunk).emph.get(i)}
|
||||||
bar={barAtLine(hunk, i)}
|
style={FIXED}
|
||||||
start={startAtLine(hunk, i)}
|
bar={barAtLine(hunk, i)}
|
||||||
/>
|
start={startAtLine(hunk, i)}
|
||||||
)
|
/>
|
||||||
)}
|
)
|
||||||
</Windowed>
|
)}
|
||||||
) : (
|
</Windowed>
|
||||||
file.hunks.map((hunk, i) => (
|
) : (
|
||||||
<UnifiedHunk key={`${hunk.header}:${i}`} hunk={hunk} lang={lang} />
|
file.hunks.map((hunk, i) => (
|
||||||
))
|
<UnifiedHunk key={`${hunk.header}:${i}`} hunk={hunk} lang={lang} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
{file.truncated && (
|
||||||
|
<p className="px-2 py-1 text-[10px] text-status-warning">
|
||||||
|
{t("changes.fileTruncated")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{commentAt != null && (
|
||||||
|
<form
|
||||||
|
data-testid="diff-comment-box"
|
||||||
|
className="flex flex-col gap-1 border-t border-border bg-surface-2 p-2"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
sendComment();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label className="text-[11px] text-fg-muted">
|
||||||
|
{t("changes.commentOn", { at: `${file.path}:${commentAt}` })}
|
||||||
|
<textarea
|
||||||
|
autoFocus
|
||||||
|
rows={2}
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Escape") setCommentAt(null);
|
||||||
|
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) sendComment();
|
||||||
|
}}
|
||||||
|
className="mt-1 block w-full rounded border border-border bg-surface-1 px-2 py-1 font-sans text-xs text-fg-primary"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{undelivered && (
|
||||||
|
<p role="alert" className="text-[11px] text-status-danger">
|
||||||
|
{t("changes.commentNoPane")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-ghost text-xs"
|
||||||
|
onClick={() => setCommentAt(null)}
|
||||||
|
>
|
||||||
|
{t("changes.commentCancel")}
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn-primary text-xs" disabled={!draft.trim()}>
|
||||||
|
{t("changes.commentSend")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
)}
|
)}
|
||||||
{file.truncated && (
|
</HunkActions.Provider>
|
||||||
<p className="px-2 py-1 text-[10px] text-status-warning">{t("changes.fileTruncated")}</p>
|
</CommentLine.Provider>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</HunkActions.Provider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { Checkbox } from "../Checkbox";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
@@ -516,14 +517,7 @@ export function LaneGitBar({
|
|||||||
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<label className="flex items-center gap-1.5 text-fg-secondary">
|
<Checkbox checked={remember} onChange={setRemember} label={t("gitBar.loginRemember")} />
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={remember}
|
|
||||||
onChange={(e) => setRemember(e.target.checked)}
|
|
||||||
/>
|
|
||||||
{t("gitBar.loginRemember")}
|
|
||||||
</label>
|
|
||||||
<p className="text-[10px] text-fg-muted">{t("gitBar.loginTokenHint")}</p>
|
<p className="text-[10px] text-fg-muted">{t("gitBar.loginTokenHint")}</p>
|
||||||
{/* The token crosses the network in the clear on a plain-http LAN address. */}
|
{/* The token crosses the network in the clear on a plain-http LAN address. */}
|
||||||
{location.protocol === "http:" && !LOCAL_HOST.test(location.hostname) && (
|
{location.protocol === "http:" && !LOCAL_HOST.test(location.hostname) && (
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* @file SessionResumeButton.tsx
|
* @file SessionResumeButton.tsx
|
||||||
* @description The transcript page's explicit Resume for a Claude session
|
* @description The transcript page's explicit Resume and Fork for a Claude
|
||||||
* inside a workspace. Opening a session never resumes it; this is one of the
|
* session inside a workspace. Opening a session never resumes it; this is the
|
||||||
* two places that do (the session view's Sessions tab is the other), behind a
|
* one place that does, behind a warning, because a resume re-reads the whole
|
||||||
* warning, because a resume re-reads the whole transcript into context. A
|
* transcript into context. Fork resumes it into a NEW session id
|
||||||
|
* (`forkSession: true` → `claude --resume <id> --fork-session`), leaving the
|
||||||
|
* original untouched, and is offered even while the original has a live pane. A
|
||||||
* session that still has a live pane offers that pane instead. A resume
|
* session that still has a live pane offers that pane instead. A resume
|
||||||
* starts in the workspace's first free pane slot (`POST /api/lanes/:id/start
|
* starts in the workspace's first free pane slot (`POST /api/lanes/:id/start
|
||||||
* {slot, resumeSessionId}`, panes re-read first) and opens it — offered only
|
* {slot, resumeSessionId}`, panes re-read first) and opens it — offered only
|
||||||
@@ -15,7 +17,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { History, SquareTerminal } from "lucide-react";
|
import { GitFork, History, SquareTerminal } from "lucide-react";
|
||||||
import { api, type RunHandle } from "../../lib/api";
|
import { api, type RunHandle } from "../../lib/api";
|
||||||
import type { Lane } from "../../lib/types";
|
import type { Lane } from "../../lib/types";
|
||||||
import { useLanes } from "../../hooks/useLanes";
|
import { useLanes } from "../../hooks/useLanes";
|
||||||
@@ -45,7 +47,7 @@ export function SessionResumeButton({
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { lanes } = useLanes();
|
const { lanes } = useLanes();
|
||||||
const [runs, setRuns] = useState<RunHandle[] | null>(null);
|
const [runs, setRuns] = useState<RunHandle[] | null>(null);
|
||||||
const [confirming, setConfirming] = useState(false);
|
const [confirming, setConfirming] = useState<"resume" | "fork" | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -60,7 +62,7 @@ export function SessionResumeButton({
|
|||||||
};
|
};
|
||||||
}, [session.id]);
|
}, [session.id]);
|
||||||
|
|
||||||
// Claude only, like the Sessions tab: a Codex session id is not what
|
// Claude only: a Codex session id is not what
|
||||||
// `codex resume` takes for every kind of thread.
|
// `codex resume` takes for every kind of thread.
|
||||||
const tool = session.tool || "claude";
|
const tool = session.tool || "claude";
|
||||||
const lane = tool === "claude" && session.cwd ? owningLane(session.cwd, tool, lanes) : null;
|
const lane = tool === "claude" && session.cwd ? owningLane(session.cwd, tool, lanes) : null;
|
||||||
@@ -72,7 +74,7 @@ export function SessionResumeButton({
|
|||||||
const open = (runId: string) =>
|
const open = (runId: string) =>
|
||||||
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`);
|
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`);
|
||||||
|
|
||||||
const resume = async () => {
|
const resume = async (fork: boolean) => {
|
||||||
// Busy first: the Confirm button is disabled only through it, and the
|
// Busy first: the Confirm button is disabled only through it, and the
|
||||||
// re-read below is a round trip a second click could land in.
|
// re-read below is a round trip a second click could land in.
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@@ -86,19 +88,23 @@ export function SessionResumeButton({
|
|||||||
.catch(() => runs);
|
.catch(() => runs);
|
||||||
setRuns(fresh);
|
setRuns(fresh);
|
||||||
const live = liveRunFor(lane, fresh, session.id);
|
const live = liveRunFor(lane, fresh, session.id);
|
||||||
if (live) return open(live.id);
|
if (live && !fork) return open(live.id);
|
||||||
const slot = freeSlot(lane.id, fresh);
|
const slot = freeSlot(lane.id, fresh);
|
||||||
if (slot === null) {
|
if (slot === null) {
|
||||||
setError(t("home.allBusy"));
|
setError(t("home.allBusy"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const r = await api.lanes.action(lane.id, "start", { slot, resumeSessionId: session.id });
|
const r = await api.lanes.action(lane.id, "start", {
|
||||||
|
slot,
|
||||||
|
resumeSessionId: session.id,
|
||||||
|
...(fork ? { forkSession: true } : {}),
|
||||||
|
});
|
||||||
open(r.run_id ?? paneRunId(lane.id, slot));
|
open(r.run_id ?? paneRunId(lane.id, slot));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
setConfirming(false);
|
setConfirming(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -112,7 +118,7 @@ export function SessionResumeButton({
|
|||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setConfirming(true)}
|
onClick={() => setConfirming("resume")}
|
||||||
title={t("resume.hint")}
|
title={t("resume.hint")}
|
||||||
className="btn-ghost text-xs"
|
className="btn-ghost text-xs"
|
||||||
>
|
>
|
||||||
@@ -120,21 +126,34 @@ export function SessionResumeButton({
|
|||||||
{t("sessions.resume")}
|
{t("sessions.resume")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{resumable && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirming("fork")}
|
||||||
|
title={t("resume.forkHint")}
|
||||||
|
className="btn-ghost text-xs"
|
||||||
|
>
|
||||||
|
<GitFork className="h-4 w-4" />
|
||||||
|
{t("resume.fork")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{error && (
|
{error && (
|
||||||
<p role="alert" className="max-w-xs text-right text-[11px] text-status-danger">
|
<p role="alert" className="max-w-xs text-right text-[11px] text-status-danger">
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
open={confirming}
|
open={confirming !== null}
|
||||||
title={t("resume.title")}
|
title={t(confirming === "fork" ? "resume.forkTitle" : "resume.title")}
|
||||||
message={t("resume.body", { workspace: lane.title || lane.cwd })}
|
message={t(confirming === "fork" ? "resume.forkBody" : "resume.body", {
|
||||||
confirmLabel={t("resume.confirm")}
|
workspace: lane.title || lane.cwd,
|
||||||
|
})}
|
||||||
|
confirmLabel={t(confirming === "fork" ? "resume.forkConfirm" : "resume.confirm")}
|
||||||
cancelLabel={t("home.cancel")}
|
cancelLabel={t("home.cancel")}
|
||||||
destructive={false}
|
destructive={false}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
onConfirm={() => void resume()}
|
onConfirm={() => void resume(confirming === "fork")}
|
||||||
onCancel={() => setConfirming(false)}
|
onCancel={() => setConfirming(null)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
* @file WorkspaceHome — what `/workspace?lane=<id>` opens on, shaped like a
|
* @file WorkspaceHome — what `/workspace?lane=<id>` opens on, shaped like a
|
||||||
* Claude Desktop project page: the name, a prompt box that starts a NEW
|
* Claude Desktop project page: the name, a prompt box that starts a NEW
|
||||||
* session in the first free pane slot (0–3, `POST /api/lanes/:id/start`) and
|
* session in the first free pane slot (0–3, `POST /api/lanes/:id/start`) and
|
||||||
* then opens it (`&run=<runId>`), the workspace's Recents (a live session
|
* then opens it (`&run=<runId>`), the workspace's Recents — the sessions it
|
||||||
* opens its pane, any other its transcript — nothing is resumed), and on the
|
* owns and has not archived (`?lane=&archived=0`, with each one's state dot),
|
||||||
* right its context: Instructions, Memory (read-only), Context files and
|
* then the archived ones folded with Unarchive; a live session opens its pane,
|
||||||
|
* any other its transcript, nothing is resumed — and on the right its context: Instructions, Memory (read-only), Context files and
|
||||||
* Folders (`GET/PUT/POST/DELETE /api/lanes/:id/sources…`, see
|
* Folders (`GET/PUT/POST/DELETE /api/lanes/:id/sources…`, see
|
||||||
* server/lib/lane-sources.js). Folder suggestions are the repo's untracked
|
* server/lib/lane-sources.js). Folder suggestions are the repo's untracked
|
||||||
* worktrees from `GET /api/projects`, filtered by Settings → Worktrees.
|
* worktrees from `GET /api/projects`, filtered by Settings → Worktrees.
|
||||||
@@ -14,7 +15,16 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { FileText, Folder, MoreHorizontal, Pencil, Play, Plus, X } from "lucide-react";
|
import {
|
||||||
|
ArchiveRestore,
|
||||||
|
FileText,
|
||||||
|
Folder,
|
||||||
|
MoreHorizontal,
|
||||||
|
Pencil,
|
||||||
|
Play,
|
||||||
|
Plus,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
type LaneMemoryFile,
|
type LaneMemoryFile,
|
||||||
@@ -31,7 +41,7 @@ import { Select } from "../Select";
|
|||||||
import { MarkdownPreview } from "../MarkdownPreview";
|
import { MarkdownPreview } from "../MarkdownPreview";
|
||||||
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./WorkspaceRowMenu";
|
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./WorkspaceRowMenu";
|
||||||
import { workspaceName } from "./WorkspaceRow";
|
import { workspaceName } from "./WorkspaceRow";
|
||||||
import { liveRunFor } from "./WorkspaceSessionList";
|
import { liveRunFor, SessionFold, sessionDot, useSessionPage } from "./WorkspaceSessionList";
|
||||||
|
|
||||||
/** The tmux session a pane slot of a lane owns (server/lib/pty-run.js). */
|
/** The tmux session a pane slot of a lane owns (server/lib/pty-run.js). */
|
||||||
export function paneRunId(laneId: number, slot: number): string {
|
export function paneRunId(laneId: number, slot: number): string {
|
||||||
@@ -103,6 +113,8 @@ function HomeBody({
|
|||||||
|
|
||||||
// A new pane, a pane gone, a session id learned — all move these lane fields.
|
// A new pane, a pane gone, a session id learned — all move these lane fields.
|
||||||
const runKey = `${lane.status}:${lane.session_id}:${lane.run_id}`;
|
const runKey = `${lane.status}:${lane.session_id}:${lane.run_id}`;
|
||||||
|
// Bumped after a session is unarchived, so the lists re-read.
|
||||||
|
const [version, setVersion] = useState(0);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let live = true;
|
let live = true;
|
||||||
api.run
|
api.run
|
||||||
@@ -110,13 +122,17 @@ function HomeBody({
|
|||||||
.then((r) => live && setLiveRuns(r.items))
|
.then((r) => live && setLiveRuns(r.items))
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
api.sessions
|
api.sessions
|
||||||
.list({ cwd: lane.cwd, tool: lane.tool, limit: 30 })
|
.list({ lane: lane.id, archived: false, limit: 30 })
|
||||||
.then((r) => live && setSessions(r.sessions))
|
.then((r) => live && setSessions(r.sessions))
|
||||||
.catch(() => live && setSessions([]));
|
.catch(() => live && setSessions([]));
|
||||||
return () => {
|
return () => {
|
||||||
live = false;
|
live = false;
|
||||||
};
|
};
|
||||||
}, [lane.id, lane.cwd, lane.tool, runKey]);
|
}, [lane.id, runKey, version]);
|
||||||
|
const archived = useSessionPage(
|
||||||
|
{ lane: lane.id, archived: true, limit: 30 },
|
||||||
|
`${runKey}:${version}`
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let live = true;
|
let live = true;
|
||||||
@@ -362,11 +378,7 @@ function HomeBody({
|
|||||||
onClick={() => openSession(s)}
|
onClick={() => openSession(s)}
|
||||||
className="flex w-full items-center gap-3 py-2.5 text-left hover:bg-surface-3"
|
className="flex w-full items-center gap-3 py-2.5 text-left hover:bg-surface-3"
|
||||||
>
|
>
|
||||||
<span
|
<span className={`h-2 w-2 shrink-0 rounded-full ${sessionDot(s, live)}`} />
|
||||||
className={`h-2 w-2 shrink-0 rounded-full ${
|
|
||||||
live ? "bg-status-success" : "border border-fg-muted"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
<span className="min-w-0 flex-1 truncate text-sm text-fg-primary">
|
<span className="min-w-0 flex-1 truncate text-sm text-fg-primary">
|
||||||
{s.name || s.id.slice(0, 8)}
|
{s.name || s.id.slice(0, 8)}
|
||||||
</span>
|
</span>
|
||||||
@@ -383,6 +395,17 @@ function HomeBody({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
|
<div className="mt-2">
|
||||||
|
<SessionFold
|
||||||
|
testId={`workspace-archived-${lane.id}`}
|
||||||
|
page={archived}
|
||||||
|
label={(count) => t("tree.archived", { count })}
|
||||||
|
actionLabel={t("tree.unarchive")}
|
||||||
|
actionIcon={<ArchiveRestore className="h-3.5 w-3.5" />}
|
||||||
|
onAction={(s) => api.sessions.patch(s.id, { archived: false })}
|
||||||
|
onDone={() => setVersion((v) => v + 1)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside data-testid="workspace-context" className="card w-80 shrink-0 self-start p-0">
|
<aside data-testid="workspace-context" className="card w-80 shrink-0 self-start p-0">
|
||||||
|
|||||||
@@ -1,19 +1,26 @@
|
|||||||
/**
|
/**
|
||||||
* @file WorkspaceSessionList — the sessions under an expanded workspace in the
|
* @file WorkspaceSessionList — the sessions under an expanded workspace in the
|
||||||
* sidebar (`GET /api/sessions?cwd=&tool=`, newest first). A session with a LIVE
|
* sidebar: the ones the workspace OWNS and has not archived
|
||||||
* pane (a `GET /api/run` handle carrying its session id) opens that tmux pane
|
* (`GET /api/sessions?lane=<id>&archived=0`, newest first) — never the other
|
||||||
* on the Workspace page (`?lane=<id>&run=<runId>`) — attaching, never starting
|
* sessions that ran in its folder. A row's dot
|
||||||
* or resuming anything; any other session opens its read-only transcript
|
* shows its state (`sessionDot`: live pane, needs you, error); double-click
|
||||||
* (`/sessions/:id`), where Resume is a separate, explicit action, because a
|
* renames it and its hover button archives it (`PATCH {name}` / `{archived}`,
|
||||||
* resume re-reads the whole transcript into context.
|
* which deletes nothing). A session with a LIVE pane (a `GET /api/run` handle
|
||||||
|
* carrying its session id) opens that tmux pane on the Workspace page
|
||||||
|
* (`?lane=<id>&run=<runId>`, Ctrl/Cmd+click `&pane=1` to open it in a second
|
||||||
|
* split pane) — attaching, never starting or resuming anything;
|
||||||
|
* any other session opens its read-only transcript (`/sessions/:id`), where
|
||||||
|
* Resume is a separate, explicit action, because a resume re-reads the whole
|
||||||
|
* transcript into context.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
|
import { Archive } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { api, type RunHandle } from "../../lib/api";
|
import { api, type RunHandle } from "../../lib/api";
|
||||||
import type { Lane, Session } from "../../lib/types";
|
import { isSessionAwaitingInput, type Lane, type Session } from "../../lib/types";
|
||||||
import { timeAgo } from "../../lib/format";
|
import { timeAgo } from "../../lib/format";
|
||||||
|
|
||||||
export const SESSION_LIMIT = 20;
|
export const SESSION_LIMIT = 20;
|
||||||
@@ -31,6 +38,118 @@ export function liveRunFor(lane: Lane, runs: RunHandle[], sessionId: string): Ru
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A session row's dot: waiting for the user, failed, held by a live pane, or
|
||||||
|
* at rest. */
|
||||||
|
export function sessionDot(session: Session, live: boolean): string {
|
||||||
|
if (isSessionAwaitingInput(session)) return "bg-status-warning";
|
||||||
|
if (session.status === "error") return "bg-status-danger";
|
||||||
|
if (live) return "bg-status-success";
|
||||||
|
return "border border-fg-muted";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionPage = { sessions: Session[]; total: number };
|
||||||
|
|
||||||
|
/** One page of `GET /api/sessions`, re-read whenever `refreshKey` changes. */
|
||||||
|
export function useSessionPage(
|
||||||
|
params: Parameters<typeof api.sessions.list>[0],
|
||||||
|
refreshKey: string
|
||||||
|
): SessionPage | null {
|
||||||
|
const [page, setPage] = useState<SessionPage | null>(null);
|
||||||
|
const key = JSON.stringify(params);
|
||||||
|
useEffect(() => {
|
||||||
|
let live = true;
|
||||||
|
api.sessions
|
||||||
|
.list(JSON.parse(key))
|
||||||
|
.then((r) => live && setPage({ sessions: r.sessions, total: r.total ?? r.sessions.length }))
|
||||||
|
.catch(() => live && setPage(null));
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
};
|
||||||
|
}, [key, refreshKey]);
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A folded list of sessions under a one-line toggle, each with one action
|
||||||
|
* (Unarchive); `onDone` re-reads the lists after it. */
|
||||||
|
export function SessionFold({
|
||||||
|
testId,
|
||||||
|
page,
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
actionLabel,
|
||||||
|
actionIcon,
|
||||||
|
onAction,
|
||||||
|
onDone,
|
||||||
|
indent = "",
|
||||||
|
}: {
|
||||||
|
testId: string;
|
||||||
|
page: SessionPage | null;
|
||||||
|
label: (count: number) => string;
|
||||||
|
hint?: string;
|
||||||
|
actionLabel: string;
|
||||||
|
actionIcon: ReactNode;
|
||||||
|
onAction: (s: Session) => Promise<unknown>;
|
||||||
|
onDone: () => void;
|
||||||
|
/** Left padding of the rows, to line up with the list above. */
|
||||||
|
indent?: string;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
if (!page || page.total === 0) return null;
|
||||||
|
const act = async (s: Session) => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await onAction(s);
|
||||||
|
onDone();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div data-testid={testId}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
title={hint}
|
||||||
|
className={`w-full py-1 pr-2 text-left text-[11px] text-fg-muted hover:text-fg-primary ${indent}`}
|
||||||
|
>
|
||||||
|
{open ? "▾" : "▸"} {label(page.total)}
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<ul className="space-y-px">
|
||||||
|
{page.sessions.map((s) => (
|
||||||
|
<li key={s.id} className={`flex items-center gap-1 pr-1 ${indent}`}>
|
||||||
|
<Link
|
||||||
|
to={`/sessions/${s.id}`}
|
||||||
|
title={`${s.name || s.id}\n${timeAgo(s.last_activity ?? s.started_at)}`}
|
||||||
|
className="min-w-0 flex-1 truncate rounded-md py-1 text-[12px] text-fg-muted hover:text-fg-primary"
|
||||||
|
>
|
||||||
|
{s.name || s.id.slice(0, 8)}
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid={`${testId}-action-${s.id}`}
|
||||||
|
onClick={() => void act(s)}
|
||||||
|
title={actionLabel}
|
||||||
|
aria-label={actionLabel}
|
||||||
|
className="shrink-0 rounded p-0.5 text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
|
||||||
|
>
|
||||||
|
{actionIcon}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className={`text-[11px] text-status-danger ${indent}`}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function WorkspaceSessionList({
|
export function WorkspaceSessionList({
|
||||||
lane,
|
lane,
|
||||||
liveRuns,
|
liveRuns,
|
||||||
@@ -54,20 +173,17 @@ export function WorkspaceSessionList({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("lanes");
|
const { t } = useTranslation("lanes");
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [sessions, setSessions] = useState<Session[] | null>(null);
|
// Bumped after a rename or an archive, so the list re-reads.
|
||||||
|
const [version, setVersion] = useState(0);
|
||||||
|
const [renaming, setRenaming] = useState<string | null>(null);
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// The lane's own session/status move on every hook, which is when a new
|
// The lane's own session/status/needs-you move on every hook, which is when
|
||||||
// session can appear — re-read then, not on a timer.
|
// a session can appear or change state — re-read then, not on a timer.
|
||||||
useEffect(() => {
|
const refreshKey = `${lane.session_id}:${lane.status}:${lane.needs_action}:${version}`;
|
||||||
let live = true;
|
const own = useSessionPage({ lane: lane.id, archived: false, limit: SESSION_LIMIT }, refreshKey);
|
||||||
api.sessions
|
const sessions = own?.sessions ?? (own === null ? null : []);
|
||||||
.list({ cwd: lane.cwd, tool: lane.tool, limit: SESSION_LIMIT })
|
|
||||||
.then((r) => live && setSessions(r.sessions))
|
|
||||||
.catch(() => live && setSessions([]));
|
|
||||||
return () => {
|
|
||||||
live = false;
|
|
||||||
};
|
|
||||||
}, [lane.cwd, lane.tool, lane.session_id, lane.status]);
|
|
||||||
|
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
const shown = (sessions ?? []).filter(
|
const shown = (sessions ?? []).filter(
|
||||||
@@ -79,47 +195,110 @@ export function WorkspaceSessionList({
|
|||||||
if (count !== null) onMatches?.(count);
|
if (count !== null) onMatches?.(count);
|
||||||
}, [count, onMatches]);
|
}, [count, onMatches]);
|
||||||
|
|
||||||
|
const patch = async (s: Session, body: { name?: string; archived?: boolean }) => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api.sessions.patch(s.id, body);
|
||||||
|
setVersion((v) => v + 1);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (hidden) return null;
|
if (hidden) return null;
|
||||||
|
const failure = error && (
|
||||||
|
<p role="alert" className="pl-7 text-[11px] text-status-danger">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
if (sessions !== null && shown.length === 0) {
|
if (sessions !== null && shown.length === 0) {
|
||||||
return <p className="py-1 pl-8 text-[11px] text-fg-muted">{t("tree.noSessions")}</p>;
|
return (
|
||||||
|
<>
|
||||||
|
<p className="py-1 pl-8 text-[11px] text-fg-muted">{t("tree.noSessions")}</p>
|
||||||
|
{failure}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<ul data-testid={`workspace-sessions-${lane.id}`} className="space-y-px">
|
<>
|
||||||
{shown.map((s) => {
|
<ul data-testid={`workspace-sessions-${lane.id}`} className="space-y-px">
|
||||||
const run = runFor(s);
|
{shown.map((s) => {
|
||||||
const selected = !!run && run.id === selectedRunId;
|
const run = runFor(s);
|
||||||
return (
|
const selected = !!run && run.id === selectedRunId;
|
||||||
<li key={s.id}>
|
if (renaming === s.id) {
|
||||||
<button
|
return (
|
||||||
type="button"
|
<li key={s.id} className="py-0.5 pl-7 pr-2">
|
||||||
data-testid={`workspace-session-${s.id}`}
|
<input
|
||||||
data-live={run ? "true" : undefined}
|
data-testid={`workspace-session-rename-${s.id}`}
|
||||||
onClick={() =>
|
aria-label={t("tree.renameSession")}
|
||||||
navigate(
|
autoFocus
|
||||||
run
|
value={draft}
|
||||||
? `/workspace?lane=${lane.id}&run=${encodeURIComponent(run.id)}`
|
maxLength={200}
|
||||||
: `/sessions/${s.id}`
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
)
|
// Blur CANCELS — Enter is the only save, like a workspace row.
|
||||||
}
|
onBlur={() => setRenaming(null)}
|
||||||
title={`${s.name || s.id}\n${timeAgo(s.last_activity ?? s.started_at)}${
|
onKeyDown={(e) => {
|
||||||
run ? "" : `\n${t("tree.transcriptHint")}`
|
if (e.key === "Escape") setRenaming(null);
|
||||||
}`}
|
if (e.key !== "Enter") return;
|
||||||
className={`flex w-full items-center gap-2 rounded-md py-1 pl-7 pr-2 text-left text-[12px] ${
|
setRenaming(null);
|
||||||
selected
|
const name = draft.trim();
|
||||||
? "bg-accent/10 text-fg-primary"
|
if (name && name !== s.name) void patch(s, { name });
|
||||||
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
}}
|
||||||
}`}
|
className="w-full rounded border border-accent bg-surface-1 px-1.5 py-0.5 text-[12px] text-fg-primary"
|
||||||
>
|
/>
|
||||||
<span
|
</li>
|
||||||
className={`h-1.5 w-1.5 shrink-0 rounded-full ${
|
);
|
||||||
run ? "bg-status-success" : "border border-fg-muted"
|
}
|
||||||
|
return (
|
||||||
|
<li key={s.id} className="group relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid={`workspace-session-${s.id}`}
|
||||||
|
data-live={run ? "true" : undefined}
|
||||||
|
onClick={(e) =>
|
||||||
|
navigate(
|
||||||
|
run
|
||||||
|
? // Ctrl/Cmd+click opens it beside the current pane.
|
||||||
|
`/workspace?lane=${lane.id}&run=${encodeURIComponent(run.id)}${
|
||||||
|
e.ctrlKey || e.metaKey ? "&pane=1" : ""
|
||||||
|
}`
|
||||||
|
: `/sessions/${s.id}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onDoubleClick={() => {
|
||||||
|
setDraft(s.name || "");
|
||||||
|
setRenaming(s.id);
|
||||||
|
}}
|
||||||
|
title={`${s.name || s.id}\n${timeAgo(s.last_activity ?? s.started_at)}\n${
|
||||||
|
run ? t("tree.splitHint") : t("tree.transcriptHint")
|
||||||
}`}
|
}`}
|
||||||
/>
|
className={`flex w-full items-center gap-2 rounded-md py-1 pl-7 pr-7 text-left text-[12px] ${
|
||||||
<span className="truncate">{s.name || s.id.slice(0, 8)}</span>
|
selected
|
||||||
</button>
|
? "bg-accent/10 text-fg-primary"
|
||||||
</li>
|
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||||
);
|
}`}
|
||||||
})}
|
>
|
||||||
</ul>
|
<span
|
||||||
|
data-testid={`workspace-session-dot-${s.id}`}
|
||||||
|
className={`h-1.5 w-1.5 shrink-0 rounded-full ${sessionDot(s, !!run)}`}
|
||||||
|
/>
|
||||||
|
<span className="truncate">{s.name || s.id.slice(0, 8)}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid={`workspace-session-archive-${s.id}`}
|
||||||
|
onClick={() => void patch(s, { archived: true })}
|
||||||
|
title={t("tree.archiveSession")}
|
||||||
|
aria-label={t("tree.archiveSession")}
|
||||||
|
className="absolute right-1 top-1/2 hidden -translate-y-1/2 rounded p-0.5 text-fg-muted hover:bg-surface-4 hover:text-fg-primary focus:block group-hover:block"
|
||||||
|
>
|
||||||
|
<Archive className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
{failure}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
/**
|
|
||||||
* @file WorkspaceSessionsTab — the right panel's Sessions tab: the selected
|
|
||||||
* workspace's sessions (`GET /api/sessions?cwd=<lane cwd>&tool=<lane tool>`,
|
|
||||||
* newest first, 50), each with its tool badge, title, model, relative time and
|
|
||||||
* cost, filtered client-side by a search box. **Resume** (claude only — attach
|
|
||||||
* and resume go through `claude`) hands the session to the console's slot-0
|
|
||||||
* pane; **Open** goes to its detail page. The cwd match is exact, so a
|
|
||||||
* session started in a subdirectory of the workspace is not listed here.
|
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { api } from "../../lib/api";
|
|
||||||
import type { Lane, Session } from "../../lib/types";
|
|
||||||
import { fmtCost, formatModelName, timeAgo } from "../../lib/format";
|
|
||||||
import { SessionToolBadge } from "../SessionToolBadge";
|
|
||||||
|
|
||||||
export function WorkspaceSessionsTab({
|
|
||||||
lane,
|
|
||||||
onResume,
|
|
||||||
}: {
|
|
||||||
lane: Lane;
|
|
||||||
onResume: (session: Session) => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation("lanes");
|
|
||||||
const [sessions, setSessions] = useState<Session[] | null>(null);
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let live = true;
|
|
||||||
setSessions(null);
|
|
||||||
api.sessions
|
|
||||||
.list({ cwd: lane.cwd, tool: lane.tool, limit: 50 })
|
|
||||||
.then((r) => live && setSessions(r.sessions))
|
|
||||||
.catch(() => live && setSessions([]));
|
|
||||||
return () => {
|
|
||||||
live = false;
|
|
||||||
};
|
|
||||||
}, [lane.cwd, lane.tool]);
|
|
||||||
|
|
||||||
const q = query.trim().toLowerCase();
|
|
||||||
const shown = (sessions ?? []).filter(
|
|
||||||
(s) => !q || (s.name ?? "").toLowerCase().includes(q) || s.id.includes(q)
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div data-testid="workspace-sessions" className="flex min-h-0 flex-1 flex-col gap-2">
|
|
||||||
<input
|
|
||||||
type="search"
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
|
||||||
placeholder={t("sessions.search")}
|
|
||||||
aria-label={t("sessions.search")}
|
|
||||||
className="rounded border border-border bg-surface-1 px-2 py-1 text-xs text-fg-primary"
|
|
||||||
/>
|
|
||||||
{sessions !== null && shown.length === 0 && (
|
|
||||||
<p className="py-6 text-center text-xs text-fg-muted">{t("sessions.empty")}</p>
|
|
||||||
)}
|
|
||||||
<ul className="min-h-0 flex-1 space-y-1 overflow-y-auto">
|
|
||||||
{shown.map((s) => (
|
|
||||||
<li
|
|
||||||
key={s.id}
|
|
||||||
data-testid={`workspace-session-${s.id}`}
|
|
||||||
className="rounded-md border border-border bg-surface-2 px-2 py-1.5"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<SessionToolBadge tool={s.tool} />
|
|
||||||
<span className="min-w-0 flex-1 truncate text-xs font-medium text-fg-primary">
|
|
||||||
{s.name || s.id.slice(0, 8)}
|
|
||||||
</span>
|
|
||||||
{lane.tool === "claude" && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onResume(s)}
|
|
||||||
className="shrink-0 rounded border border-border px-1.5 text-[11px] text-fg-secondary hover:bg-surface-4 hover:text-fg-primary"
|
|
||||||
>
|
|
||||||
{t("sessions.resume")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<Link
|
|
||||||
to={`/sessions/${s.id}`}
|
|
||||||
className="shrink-0 rounded border border-border px-1.5 text-[11px] text-fg-secondary hover:bg-surface-4 hover:text-fg-primary"
|
|
||||||
>
|
|
||||||
{t("sessions.open")}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<div className="mt-0.5 flex gap-2 text-[11px] text-fg-muted">
|
|
||||||
<span>{timeAgo(s.last_activity || s.started_at)}</span>
|
|
||||||
{s.model && <span className="truncate">{formatModelName(s.model)}</span>}
|
|
||||||
{s.cost !== undefined && (
|
|
||||||
<span className="ml-auto tabular-nums">{fmtCost(s.cost)}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -502,3 +502,34 @@ describe("LaneDiffView — hunk review bar navigation", () => {
|
|||||||
expect(screen.getByTestId("lane-diff-view").className).not.toContain("max-h-[60vh]");
|
expect(screen.getByTestId("lane-diff-view").className).not.toContain("max-h-[60vh]");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("LaneDiffView — line comments", () => {
|
||||||
|
it("writes a comment on a new line and hands over `<path>:<line> — <text>`", () => {
|
||||||
|
const sent: string[] = [];
|
||||||
|
let delivered = false;
|
||||||
|
render(
|
||||||
|
<LaneDiffView
|
||||||
|
file={FILE}
|
||||||
|
layout="unified"
|
||||||
|
onComment={(text) => {
|
||||||
|
sent.push(text);
|
||||||
|
return delivered;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByTestId("diff-comment-line-1"));
|
||||||
|
fireEvent.change(screen.getByRole("textbox"), { target: { value: "rename this" } });
|
||||||
|
fireEvent.submit(screen.getByTestId("diff-comment-box"));
|
||||||
|
expect(sent).toEqual(["server/lib/git-ops.js:1 — rename this"]);
|
||||||
|
// No pane took it: the box stays, with the reason.
|
||||||
|
expect(screen.getByRole("alert")).toBeInTheDocument();
|
||||||
|
delivered = true;
|
||||||
|
fireEvent.submit(screen.getByTestId("diff-comment-box"));
|
||||||
|
expect(screen.queryByTestId("diff-comment-box")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers no comment button without onComment", () => {
|
||||||
|
render(<LaneDiffView file={FILE} layout="unified" />);
|
||||||
|
expect(screen.queryByTestId("diff-comment-line-1")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -334,7 +334,7 @@ describe("git login (issue #45)", () => {
|
|||||||
const form = await screen.findByTestId("lane-git-login");
|
const form = await screen.findByTestId("lane-git-login");
|
||||||
expect(form).toHaveTextContent("git.smartgift.io.vn");
|
expect(form).toHaveTextContent("git.smartgift.io.vn");
|
||||||
expect(screen.getByLabelText("Username")).toHaveValue("vi");
|
expect(screen.getByLabelText("Username")).toHaveValue("vi");
|
||||||
expect(screen.getByLabelText(/Remember for everyone/)).toBeChecked();
|
expect(screen.getByRole("checkbox", { name: /Remember for everyone/ })).toBeChecked();
|
||||||
await user.type(screen.getByLabelText("Password or access token"), "s3cret");
|
await user.type(screen.getByLabelText("Password or access token"), "s3cret");
|
||||||
await user.click(screen.getByRole("button", { name: "Log in and retry" }));
|
await user.click(screen.getByRole("button", { name: "Log in and retry" }));
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* @file SessionResumeButton.test.tsx
|
* @file SessionResumeButton.test.tsx
|
||||||
* @description The transcript page's explicit Resume: it shows only for a
|
* @description The transcript page's explicit Resume and Fork: it shows only for a
|
||||||
* Claude session inside a workspace, a live session offers its pane instead,
|
* Claude session inside a workspace, a live session offers its pane instead,
|
||||||
* and a resume asks first (it re-reads the whole transcript), then starts in
|
* and a resume asks first (it re-reads the whole transcript), then starts in
|
||||||
* the first free pane slot and opens that pane — or refuses when all four are
|
* the first free pane slot and opens that pane — or refuses when all four are
|
||||||
* busy.
|
* busy; a Fork asks too and starts a new session id beside a live original.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -114,6 +114,28 @@ describe("SessionResumeButton", () => {
|
|||||||
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=3&run=agenthub-lane-3");
|
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=3&run=agenthub-lane-3");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("forks into a free pane after asking, even while the original has a live pane", async () => {
|
||||||
|
runs = [
|
||||||
|
{
|
||||||
|
id: "agenthub-lane-3",
|
||||||
|
laneId: 3,
|
||||||
|
slot: 0,
|
||||||
|
status: "running",
|
||||||
|
sessionId: "s-1",
|
||||||
|
} as RunHandle,
|
||||||
|
];
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderButton({ id: "s-1", cwd: "/r", tool: "claude" });
|
||||||
|
await user.click(await screen.findByRole("button", { name: "Fork" }));
|
||||||
|
expect(screen.getByRole("dialog")).toHaveTextContent("NEW session");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Fork session" }));
|
||||||
|
expect(api.lanes.action).toHaveBeenCalledWith(3, "start", {
|
||||||
|
slot: 1,
|
||||||
|
resumeSessionId: "s-1",
|
||||||
|
forkSession: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("offers no Resume for a session started in a subfolder, but opens its live pane", async () => {
|
it("offers no Resume for a session started in a subfolder, but opens its live pane", async () => {
|
||||||
const { unmount } = renderButton({ id: "s-9", cwd: "/r/lib", tool: "claude" });
|
const { unmount } = renderButton({ id: "s-9", cwd: "/r/lib", tool: "claude" });
|
||||||
await waitFor(() => expect(api.run.list).toHaveBeenCalled());
|
await waitFor(() => expect(api.run.list).toHaveBeenCalled());
|
||||||
|
|||||||
@@ -62,7 +62,10 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
|||||||
routerList: vi.fn(async () => ({ routers: [] })),
|
routerList: vi.fn(async () => ({ routers: [] })),
|
||||||
},
|
},
|
||||||
run: { list: vi.fn(async () => ({ items: runs })) },
|
run: { list: vi.fn(async () => ({ items: runs })) },
|
||||||
sessions: { list: vi.fn(async () => ({ sessions })) },
|
sessions: {
|
||||||
|
list: vi.fn(async () => ({ sessions })),
|
||||||
|
patch: vi.fn(async () => ({ session: {} })),
|
||||||
|
},
|
||||||
projects: {
|
projects: {
|
||||||
list: vi.fn(async () => ({
|
list: vi.fn(async () => ({
|
||||||
projects: [
|
projects: [
|
||||||
@@ -134,6 +137,12 @@ function renderHome(l = lane()) {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(api.sessions.list).mockImplementation(async () => ({
|
||||||
|
sessions,
|
||||||
|
total: sessions.length,
|
||||||
|
limit: 30,
|
||||||
|
offset: 0,
|
||||||
|
}));
|
||||||
view = {
|
view = {
|
||||||
instructions: "",
|
instructions: "",
|
||||||
supported: true,
|
supported: true,
|
||||||
@@ -196,11 +205,28 @@ describe("WorkspaceHome", () => {
|
|||||||
expect(api.lanes.action).not.toHaveBeenCalled();
|
expect(api.lanes.action).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("folds the archived sessions with Unarchive", async () => {
|
||||||
|
vi.mocked(api.sessions.list).mockImplementation(async (params) => ({
|
||||||
|
sessions: (params as { archived?: boolean }).archived ? [sessions[1]!] : [],
|
||||||
|
total: (params as { archived?: boolean }).archived ? 1 : 0,
|
||||||
|
limit: 30,
|
||||||
|
offset: 0,
|
||||||
|
}));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderHome();
|
||||||
|
const fold = await screen.findByTestId("workspace-archived-1");
|
||||||
|
await user.click(within(fold).getByRole("button", { name: /1 archived session/ }));
|
||||||
|
await user.click(within(fold).getByTestId("workspace-archived-1-action-s-old"));
|
||||||
|
expect(api.sessions.patch).toHaveBeenCalledWith("s-old", { archived: false });
|
||||||
|
});
|
||||||
|
|
||||||
it("opens a live recent's pane and an ended one's transcript, resuming nothing", async () => {
|
it("opens a live recent's pane and an ended one's transcript, resuming nothing", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderHome();
|
renderHome();
|
||||||
const live = await screen.findByTestId("workspace-recent-s-live");
|
const live = await screen.findByTestId("workspace-recent-s-live");
|
||||||
await waitFor(() => expect(live).toHaveAttribute("data-live", "true"));
|
await waitFor(() => expect(live).toHaveAttribute("data-live", "true"));
|
||||||
|
// Recents are the workspace's OWN sessions, not the folder's.
|
||||||
|
expect(api.sessions.list).toHaveBeenCalledWith({ lane: 1, archived: false, limit: 30 });
|
||||||
await user.click(live);
|
await user.click(live);
|
||||||
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1&run=agenthub-lane-1");
|
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1&run=agenthub-lane-1");
|
||||||
await user.click(screen.getByTestId("workspace-recent-s-old"));
|
await user.click(screen.getByTestId("workspace-recent-s-old"));
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
/**
|
|
||||||
* @file WorkspaceSessionsTab.test.tsx
|
|
||||||
* @description Tests for the Workspace right panel's Sessions tab: it lists
|
|
||||||
* the workspace's own sessions (cwd + tool), filters them client-side, hands a
|
|
||||||
* claude session to Resume, and offers no Resume on another tool's workspace.
|
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import { render, screen } from "@testing-library/react";
|
|
||||||
import userEvent from "@testing-library/user-event";
|
|
||||||
import { MemoryRouter } from "react-router-dom";
|
|
||||||
import type { Lane, Session } from "../../../lib/types";
|
|
||||||
|
|
||||||
vi.mock("../../../lib/api", () => ({
|
|
||||||
api: {
|
|
||||||
sessions: { list: vi.fn() },
|
|
||||||
aiTools: { list: vi.fn().mockResolvedValue({ tools: [], default: "claude" }) },
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { api } from "../../../lib/api";
|
|
||||||
import { WorkspaceSessionsTab } from "../WorkspaceSessionsTab";
|
|
||||||
|
|
||||||
const session = (id: string, name: string): Session =>
|
|
||||||
({
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
status: "completed",
|
|
||||||
cwd: "/w",
|
|
||||||
model: "claude-opus-5-5",
|
|
||||||
started_at: "2026-10-01T00:00:00Z",
|
|
||||||
ended_at: null,
|
|
||||||
metadata: null,
|
|
||||||
cost: 1.5,
|
|
||||||
tool: "claude",
|
|
||||||
}) as Session;
|
|
||||||
|
|
||||||
const lane = (tool: string) => ({ id: 1, cwd: "/w", tool }) as unknown as Lane;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.mocked(api.sessions.list).mockResolvedValue({
|
|
||||||
sessions: [session("s-1", "Fix login"), session("s-2", "Add tests")],
|
|
||||||
} as never);
|
|
||||||
});
|
|
||||||
|
|
||||||
function renderTab(tool: string, onResume = vi.fn()) {
|
|
||||||
render(
|
|
||||||
<MemoryRouter>
|
|
||||||
<WorkspaceSessionsTab lane={lane(tool)} onResume={onResume} />
|
|
||||||
</MemoryRouter>
|
|
||||||
);
|
|
||||||
return onResume;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("WorkspaceSessionsTab", () => {
|
|
||||||
it("lists the workspace's sessions by cwd and tool, and filters them", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
renderTab("claude");
|
|
||||||
expect(await screen.findByText("Fix login")).toBeInTheDocument();
|
|
||||||
expect(api.sessions.list).toHaveBeenCalledWith({ cwd: "/w", tool: "claude", limit: 50 });
|
|
||||||
await user.type(screen.getByRole("searchbox"), "tests");
|
|
||||||
expect(screen.queryByText("Fix login")).toBeNull();
|
|
||||||
expect(screen.getByText("Add tests")).toBeInTheDocument();
|
|
||||||
expect(screen.getAllByRole("link", { name: "Open" })[0]).toHaveAttribute(
|
|
||||||
"href",
|
|
||||||
"/sessions/s-2"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hands the session to Resume", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const onResume = renderTab("claude");
|
|
||||||
await screen.findByText("Fix login");
|
|
||||||
await user.click(screen.getAllByRole("button", { name: "Resume" })[0]!);
|
|
||||||
expect(onResume).toHaveBeenCalledWith(expect.objectContaining({ id: "s-1" }));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("offers no Resume on a non-claude workspace", async () => {
|
|
||||||
renderTab("codex");
|
|
||||||
await screen.findByText("Fix login");
|
|
||||||
expect(screen.queryByRole("button", { name: "Resume" })).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("says so when the workspace has no session yet", async () => {
|
|
||||||
vi.mocked(api.sessions.list).mockResolvedValue({ sessions: [] } as never);
|
|
||||||
renderTab("claude");
|
|
||||||
expect(await screen.findByText("No sessions in this workspace yet")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -86,10 +86,6 @@ export interface LaneConsolePaneProps {
|
|||||||
hiddenRunIds?: string[];
|
hiddenRunIds?: string[];
|
||||||
/** Reports the run this pane shows (null on the setup form). */
|
/** Reports the run this pane shows (null on the setup form). */
|
||||||
onHandleChange?: (runId: string | null) => void;
|
onHandleChange?: (runId: string | null) => void;
|
||||||
/** A session to resume in this pane, from the Workspace's Sessions tab. A new
|
|
||||||
* `nonce` is a new request; it goes through the same path as a resume from
|
|
||||||
* the run history, live-run fallback included. */
|
|
||||||
resumeRequest?: { sessionId: string; model: string | null; nonce: number } | null;
|
|
||||||
/** A live run to show in this pane (a session picked in the sidebar). It
|
/** A live run to show in this pane (a session picked in the sidebar). It
|
||||||
* attaches to that tmux session only — nothing is started or resumed. */
|
* attaches to that tmux session only — nothing is started or resumed. */
|
||||||
attachRequest?: { runId: string; nonce: string } | null;
|
attachRequest?: { runId: string; nonce: string } | null;
|
||||||
@@ -116,7 +112,6 @@ export function LaneConsolePane({
|
|||||||
slot = 0,
|
slot = 0,
|
||||||
hiddenRunIds,
|
hiddenRunIds,
|
||||||
onHandleChange,
|
onHandleChange,
|
||||||
resumeRequest,
|
|
||||||
attachRequest,
|
attachRequest,
|
||||||
}: LaneConsolePaneProps) {
|
}: LaneConsolePaneProps) {
|
||||||
const { t } = useTranslation("run");
|
const { t } = useTranslation("run");
|
||||||
@@ -492,23 +487,6 @@ export function LaneConsolePane({
|
|||||||
[busy, refreshList, t, lanes, slot, onLaneCreated, onLaneIdChange, attachToRun]
|
[busy, refreshList, t, lanes, slot, onLaneCreated, onLaneIdChange, attachToRun]
|
||||||
);
|
);
|
||||||
|
|
||||||
const resumeRef = useRef(onResumeFromHistory);
|
|
||||||
resumeRef.current = onResumeFromHistory;
|
|
||||||
useEffect(() => {
|
|
||||||
const lane = latest.current.lanes.find((l) => l.id === laneId);
|
|
||||||
if (!resumeRequest || !lane) return;
|
|
||||||
void resumeRef.current({
|
|
||||||
session_id: resumeRequest.sessionId,
|
|
||||||
cwd: lane.cwd,
|
|
||||||
tool: lane.tool,
|
|
||||||
model: resumeRequest.model,
|
|
||||||
permission_mode: null,
|
|
||||||
effort: null,
|
|
||||||
} as DashboardRunHistoryItem);
|
|
||||||
// Only a new request (nonce) resumes; a lane switch never replays one.
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [resumeRequest?.nonce]);
|
|
||||||
|
|
||||||
const onViewFromHistory = useCallback(
|
const onViewFromHistory = useCallback(
|
||||||
(item: DashboardRunHistoryItem) => {
|
(item: DashboardRunHistoryItem) => {
|
||||||
if (item.session_id) void onResumeFromHistory(item);
|
if (item.session_id) void onResumeFromHistory(item);
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { Checkbox } from "../Checkbox";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
@@ -577,15 +578,13 @@ export function RunSetup(props: RunSetupProps) {
|
|||||||
|
|
||||||
{canBypass && (
|
{canBypass && (
|
||||||
<div className="px-4 pb-3">
|
<div className="px-4 pb-3">
|
||||||
<label className="inline-flex items-center gap-2 text-[11.5px] text-status-danger cursor-pointer">
|
<Checkbox
|
||||||
<input
|
tone="danger"
|
||||||
type="checkbox"
|
checked={bypass}
|
||||||
checked={bypass}
|
onChange={setBypass}
|
||||||
onChange={(e) => setBypass(e.target.checked)}
|
label={t("fields.bypass")}
|
||||||
className="accent-[rgb(var(--status-danger))]"
|
labelClassName="text-[11.5px] text-status-danger"
|
||||||
/>
|
/>
|
||||||
{t("fields.bypass")}
|
|
||||||
</label>
|
|
||||||
{bypass && (
|
{bypass && (
|
||||||
<div className="mt-2 rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-[11px] text-status-danger flex items-start gap-2">
|
<div className="mt-2 rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-[11px] text-status-danger flex items-start gap-2">
|
||||||
<ShieldAlert className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" />
|
<ShieldAlert className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" />
|
||||||
|
|||||||
@@ -13,7 +13,9 @@
|
|||||||
* (`lib/terminalTheme.ts`), recoloured live without re-attaching.
|
* (`lib/terminalTheme.ts`), recoloured live without re-attaching.
|
||||||
* An image dropped on it is saved on the server (`POST /api/run/drop-image`)
|
* An image dropped on it is saved on the server (`POST /api/run/drop-image`)
|
||||||
* and its PATH pasted into the pane — what a local terminal does on a drop,
|
* and its PATH pasted into the pane — what a local terminal does on a drop,
|
||||||
* and how `claude` attaches an image.
|
* and how `claude` attaches an image. The pane also registers with
|
||||||
|
* `lib/panePaste.ts`, so a diff-line comment from the Changes panel can be
|
||||||
|
* pasted into whichever of the workspace's panes was focused last.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
import { useEffect, useMemo, useRef, useState, type DragEvent } from "react";
|
import { useEffect, useMemo, useRef, useState, type DragEvent } from "react";
|
||||||
@@ -25,6 +27,7 @@ import "@xterm/xterm/css/xterm.css";
|
|||||||
import { useTheme } from "../../hooks/useTheme";
|
import { useTheme } from "../../hooks/useTheme";
|
||||||
import { terminalThemeFor } from "../../lib/terminalTheme";
|
import { terminalThemeFor } from "../../lib/terminalTheme";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
|
import { notePaneFocus, registerPane } from "../../lib/panePaste";
|
||||||
|
|
||||||
type DropState = "hint" | "uploading" | "failed" | "imagesOnly";
|
type DropState = "hint" | "uploading" | "failed" | "imagesOnly";
|
||||||
const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer.types).includes("Files");
|
const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer.types).includes("Files");
|
||||||
@@ -291,6 +294,16 @@ export function TerminalView({ runId, wsBaseUrl, onExit }: TerminalViewProps) {
|
|||||||
if (termRef.current) termRef.current.options.theme = palette;
|
if (termRef.current) termRef.current.options.theme = palette;
|
||||||
}, [palette]);
|
}, [palette]);
|
||||||
|
|
||||||
|
// Other parts of the page (a diff-line comment) paste into this pane.
|
||||||
|
useEffect(
|
||||||
|
() =>
|
||||||
|
registerPane(runId, (text) => {
|
||||||
|
termRef.current?.paste(text);
|
||||||
|
termRef.current?.focus();
|
||||||
|
}),
|
||||||
|
[runId]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="relative h-full w-full"
|
className="relative h-full w-full"
|
||||||
@@ -311,6 +324,7 @@ export function TerminalView({ runId, wsBaseUrl, onExit }: TerminalViewProps) {
|
|||||||
if (dragDepth.current === 0) setDrop((d) => (d === "hint" ? null : d));
|
if (dragDepth.current === 0) setDrop((d) => (d === "hint" ? null : d));
|
||||||
}}
|
}}
|
||||||
onDrop={(e) => void onDrop(e)}
|
onDrop={(e) => void onDrop(e)}
|
||||||
|
onFocus={() => notePaneFocus(runId)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
|
|||||||
@@ -484,49 +484,6 @@ describe("LaneConsolePane", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resumes a session handed over by the Sessions tab, once per request", async () => {
|
|
||||||
(api.lanes.action as ReturnType<typeof vi.fn>).mockResolvedValue({ run_id: "agenthub-lane-1" });
|
|
||||||
(api.run.get as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
||||||
id: "agenthub-lane-1",
|
|
||||||
laneId: 1,
|
|
||||||
status: "running",
|
|
||||||
cwd: "/workspace/a",
|
|
||||||
model: null,
|
|
||||||
permissionMode: null,
|
|
||||||
effort: null,
|
|
||||||
resumeSessionId: "s-1",
|
|
||||||
sessionId: null,
|
|
||||||
startedAt: null,
|
|
||||||
promptPreview: null,
|
|
||||||
});
|
|
||||||
const request = { sessionId: "s-1", model: "claude-opus-5-5", nonce: 1 };
|
|
||||||
const { rerender } = render(
|
|
||||||
<MemoryRouter>
|
|
||||||
<LaneConsolePane {...baseProps()} resumeRequest={request} />
|
|
||||||
</MemoryRouter>
|
|
||||||
);
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(api.lanes.action).toHaveBeenCalledWith(
|
|
||||||
1,
|
|
||||||
"start",
|
|
||||||
expect.objectContaining({
|
|
||||||
slot: 0,
|
|
||||||
prompt: "",
|
|
||||||
resumeSessionId: "s-1",
|
|
||||||
model: "claude-opus-5-5",
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
// A re-render with the same request is not a second resume.
|
|
||||||
rerender(
|
|
||||||
<MemoryRouter>
|
|
||||||
<LaneConsolePane {...baseProps()} resumeRequest={{ ...request }} />
|
|
||||||
</MemoryRouter>
|
|
||||||
);
|
|
||||||
await screen.findByTestId("terminal-view");
|
|
||||||
expect(api.lanes.action).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("attaches to a run picked in the sidebar without starting or resuming anything", async () => {
|
it("attaches to a run picked in the sidebar without starting or resuming anything", async () => {
|
||||||
vi.mocked(api.run.get).mockResolvedValue({
|
vi.mocked(api.run.get).mockResolvedValue({
|
||||||
id: "agenthub-lane-1-2",
|
id: "agenthub-lane-1-2",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { Switch } from "../Switch";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ChevronDown, Download, ExternalLink, RefreshCw } from "lucide-react";
|
import { ChevronDown, Download, ExternalLink, RefreshCw } from "lucide-react";
|
||||||
@@ -44,7 +45,7 @@ function Homepage({ tool }: { tool: AiTool }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Switch({
|
function ToolSwitch({
|
||||||
tool,
|
tool,
|
||||||
disabled,
|
disabled,
|
||||||
onToggle,
|
onToggle,
|
||||||
@@ -55,26 +56,13 @@ function Switch({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("settings");
|
const { t } = useTranslation("settings");
|
||||||
return (
|
return (
|
||||||
<button
|
<Switch
|
||||||
type="button"
|
checked={tool.enabled}
|
||||||
role="switch"
|
onChange={onToggle}
|
||||||
aria-checked={tool.enabled}
|
label={t("aiTools.enabledOf", { tool: tool.label })}
|
||||||
aria-label={t("aiTools.enabledOf", { tool: tool.label })}
|
|
||||||
title={tool.enabled ? t("aiTools.enabled") : t("aiTools.disabled")}
|
title={tool.enabled ? t("aiTools.enabled") : t("aiTools.disabled")}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={onToggle}
|
/>
|
||||||
className={`relative h-5 w-9 shrink-0 rounded-full transition-colors disabled:opacity-50 ${
|
|
||||||
tool.enabled ? "bg-accent" : "bg-surface-4"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
aria-hidden
|
|
||||||
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white shadow transition-all ${
|
|
||||||
tool.enabled ? "left-[18px]" : "left-0.5"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
<span className="sr-only">{tool.enabled ? t("aiTools.enabled") : t("aiTools.disabled")}</span>
|
|
||||||
</button>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,7 +285,7 @@ export function AiToolsSettings() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<ToolSwitch
|
||||||
tool={tool}
|
tool={tool}
|
||||||
disabled={busy !== null}
|
disabled={busy !== null}
|
||||||
onToggle={() =>
|
onToggle={() =>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { Checkbox } from "../Checkbox";
|
||||||
import { useEffect, useState, useCallback, useRef, Fragment } from "react";
|
import { useEffect, useState, useCallback, useRef, Fragment } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { DollarSign, Plus, Pencil, Trash2, Check, X, RotateCcw, Info } from "lucide-react";
|
import { DollarSign, Plus, Pencil, Trash2, Check, X, RotateCcw, Info } from "lucide-react";
|
||||||
@@ -571,15 +572,11 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing" }:
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{hidden > 0 && (
|
{hidden > 0 && (
|
||||||
<label className="inline-flex items-center gap-1.5 text-xs text-fg-secondary cursor-pointer select-none">
|
<Checkbox
|
||||||
<input
|
checked={showAll}
|
||||||
type="checkbox"
|
onChange={setShowAll}
|
||||||
checked={showAll}
|
label={t("pricing.showAll", { count: hidden })}
|
||||||
onChange={(e) => setShowAll(e.target.checked)}
|
/>
|
||||||
className="accent-current"
|
|
||||||
/>
|
|
||||||
{t("pricing.showAll", { count: hidden })}
|
|
||||||
</label>
|
|
||||||
)}
|
)}
|
||||||
{allowReset && (
|
{allowReset && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { Switch } from "../Switch";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
export function SettingsSection({
|
export function SettingsSection({
|
||||||
@@ -70,25 +71,7 @@ export function SettingsSwitchRow({
|
|||||||
<SettingsRow
|
<SettingsRow
|
||||||
label={label}
|
label={label}
|
||||||
description={description}
|
description={description}
|
||||||
control={
|
control={<Switch checked={checked} onChange={onChange} label={label} disabled={disabled} />}
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="switch"
|
|
||||||
aria-checked={checked}
|
|
||||||
aria-label={label}
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={() => onChange(!checked)}
|
|
||||||
className={`relative inline-flex h-5 w-9 rounded-full border-2 border-transparent transition-colors disabled:opacity-50 ${
|
|
||||||
checked ? "bg-accent" : "bg-surface-4"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transition-transform ${
|
|
||||||
checked ? "translate-x-4" : "translate-x-0"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ describe("AiToolsSettings", () => {
|
|||||||
// A disabled tool cannot become the default.
|
// A disabled tool cannot become the default.
|
||||||
const grok = screen.getByTestId("ai-tool-grok");
|
const grok = screen.getByTestId("ai-tool-grok");
|
||||||
expect(within(grok).getByText("Set default")).toBeDisabled();
|
expect(within(grok).getByText("Set default")).toBeDisabled();
|
||||||
expect(within(grok).getByRole("switch")).toHaveTextContent("Disabled");
|
expect(within(grok).getByRole("switch")).toHaveAttribute("aria-checked", "false");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("refreshes detection", async () => {
|
it("refreshes detection", async () => {
|
||||||
|
|||||||
@@ -88,6 +88,11 @@
|
|||||||
"changes.expandFile": "Expand {{path}}",
|
"changes.expandFile": "Expand {{path}}",
|
||||||
"changes.fileGone": "That file no longer has changes in this range.",
|
"changes.fileGone": "That file no longer has changes in this range.",
|
||||||
"changes.fileTruncated": "Diff cut short — open the file in an editor for the rest.",
|
"changes.fileTruncated": "Diff cut short — open the file in an editor for the rest.",
|
||||||
|
"changes.commentLine": "Comment on line {{n}} — pasted into this workspace's pane",
|
||||||
|
"changes.commentOn": "Comment on {{at}} — pasted into the pane you used last; nothing is sent until you press Enter there",
|
||||||
|
"changes.commentSend": "Paste into pane",
|
||||||
|
"changes.commentCancel": "Cancel",
|
||||||
|
"changes.commentNoPane": "No pane of this workspace is open — open its session first",
|
||||||
"changes.layout.split": "Side-by-side",
|
"changes.layout.split": "Side-by-side",
|
||||||
"changes.layout.unified": "Unified",
|
"changes.layout.unified": "Unified",
|
||||||
"changes.loading": "Reading the working copy…",
|
"changes.loading": "Reading the working copy…",
|
||||||
@@ -325,16 +330,17 @@
|
|||||||
"pane.close": "Stop this pane's session",
|
"pane.close": "Stop this pane's session",
|
||||||
"panel.files": "Files",
|
"panel.files": "Files",
|
||||||
"panel.changes": "Changes",
|
"panel.changes": "Changes",
|
||||||
"panel.sessions": "Sessions",
|
|
||||||
"sessions.empty": "No sessions in this workspace yet",
|
|
||||||
"sessions.resume": "Resume",
|
"sessions.resume": "Resume",
|
||||||
"sessions.open": "Open",
|
|
||||||
"resume.openPane": "Open pane",
|
"resume.openPane": "Open pane",
|
||||||
"resume.hint": "Resume this session in a workspace pane — re-reads its whole transcript into context",
|
"resume.hint": "Resume this session in a workspace pane — re-reads its whole transcript into context",
|
||||||
"resume.title": "Resume this session?",
|
"resume.title": "Resume this session?",
|
||||||
"resume.body": "Resuming re-reads the session's whole transcript into the model's context, so its next turns pay for all of it again. It opens in the first free pane of {{workspace}}. To only read it, stay on this page.",
|
"resume.body": "Resuming re-reads the session's whole transcript into the model's context, so its next turns pay for all of it again. It opens in the first free pane of {{workspace}}. To only read it, stay on this page.",
|
||||||
"resume.confirm": "Resume session",
|
"resume.confirm": "Resume session",
|
||||||
"sessions.search": "Filter sessions…",
|
"resume.fork": "Fork",
|
||||||
|
"resume.forkHint": "Continue from here as a NEW session — the original stays as it is",
|
||||||
|
"resume.forkTitle": "Fork this session?",
|
||||||
|
"resume.forkBody": "A fork re-reads the session's whole transcript into a NEW session (its own id), so its turns pay for all of it again; the original is left untouched. It opens in the first free pane of {{workspace}}.",
|
||||||
|
"resume.forkConfirm": "Fork session",
|
||||||
"tree.title": "Workspaces",
|
"tree.title": "Workspaces",
|
||||||
"tree.search": "Search workspaces and sessions",
|
"tree.search": "Search workspaces and sessions",
|
||||||
"tree.toolFilter": "Filter by tool",
|
"tree.toolFilter": "Filter by tool",
|
||||||
@@ -342,6 +348,12 @@
|
|||||||
"tree.newSession": "New session",
|
"tree.newSession": "New session",
|
||||||
"tree.noSessions": "No sessions yet",
|
"tree.noSessions": "No sessions yet",
|
||||||
"tree.transcriptHint": "No live pane — opens the transcript",
|
"tree.transcriptHint": "No live pane — opens the transcript",
|
||||||
|
"tree.splitHint": "Live pane — Ctrl/Cmd+click opens it in a second pane",
|
||||||
|
"tree.renameSession": "Rename session",
|
||||||
|
"tree.archiveSession": "Archive — hide from this workspace (deletes nothing)",
|
||||||
|
"tree.archived_one": "{{count}} archived session",
|
||||||
|
"tree.archived_other": "{{count}} archived sessions",
|
||||||
|
"tree.unarchive": "Unarchive",
|
||||||
"home.breadcrumb": "Workspaces",
|
"home.breadcrumb": "Workspaces",
|
||||||
"home.repoRoot": "Repository root",
|
"home.repoRoot": "Repository root",
|
||||||
"home.mainBranch": "Main worktree's branch",
|
"home.mainBranch": "Main worktree's branch",
|
||||||
|
|||||||
@@ -11,7 +11,6 @@
|
|||||||
"checkingForUpdates": "Checking for updates...",
|
"checkingForUpdates": "Checking for updates...",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
"collapse": "Collapse sidebar",
|
"collapse": "Collapse sidebar",
|
||||||
"collapseShort": "Collapse",
|
|
||||||
"connection": "Connection",
|
"connection": "Connection",
|
||||||
"connectionDetails": "Connection details",
|
"connectionDetails": "Connection details",
|
||||||
"connectionUptime": "Connected",
|
"connectionUptime": "Connected",
|
||||||
@@ -49,14 +48,6 @@
|
|||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"skipToContent": "Skip to content",
|
"skipToContent": "Skip to content",
|
||||||
"statsPersisted": "Stats persist across reloads",
|
"statsPersisted": "Stats persist across reloads",
|
||||||
"switchLanguage": "Switch to {{language}}",
|
|
||||||
"switchTheme": "Switch to {{theme}}",
|
|
||||||
"theme": "Theme",
|
|
||||||
"themeNames": {
|
|
||||||
"dark": "Dark",
|
|
||||||
"light": "Light",
|
|
||||||
"system": "System"
|
|
||||||
},
|
|
||||||
"throughput60s": "Throughput · last 60s",
|
"throughput60s": "Throughput · last 60s",
|
||||||
"topEventTypes": "Top event types",
|
"topEventTypes": "Top event types",
|
||||||
"unitEvents": "events",
|
"unitEvents": "events",
|
||||||
@@ -74,5 +65,6 @@
|
|||||||
"running": "{{count}} running",
|
"running": "{{count}} running",
|
||||||
"needYou": "{{count}} need you",
|
"needYou": "{{count}} need you",
|
||||||
"today": "Today {{cost}}"
|
"today": "Today {{cost}}"
|
||||||
}
|
},
|
||||||
|
"version": "Version"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -710,5 +710,14 @@
|
|||||||
"claudeDesc": "Worktrees under <repo>/.claude/worktrees/, made by Claude Code's own agents.",
|
"claudeDesc": "Worktrees under <repo>/.claude/worktrees/, made by Claude Code's own agents.",
|
||||||
"agenthub": "AgentHub-managed worktrees",
|
"agenthub": "AgentHub-managed worktrees",
|
||||||
"agenthubDesc": "Worktrees AgentHub created under its workspaces root."
|
"agenthubDesc": "Worktrees AgentHub created under its workspaces root."
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"title": "Language",
|
||||||
|
"description": "The language of the whole dashboard, remembered in this browser.",
|
||||||
|
"label": "Interface language",
|
||||||
|
"names": {
|
||||||
|
"en": "English",
|
||||||
|
"vi": "Tiếng Việt"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,6 +88,11 @@
|
|||||||
"changes.expandFile": "Mở {{path}}",
|
"changes.expandFile": "Mở {{path}}",
|
||||||
"changes.fileGone": "File này không còn thay đổi nào trong phạm vi đang xem.",
|
"changes.fileGone": "File này không còn thay đổi nào trong phạm vi đang xem.",
|
||||||
"changes.fileTruncated": "Diff bị cắt bớt — mở file trong editor để xem phần còn lại.",
|
"changes.fileTruncated": "Diff bị cắt bớt — mở file trong editor để xem phần còn lại.",
|
||||||
|
"changes.commentLine": "Comment dòng {{n}} — dán vào pane của workspace này",
|
||||||
|
"changes.commentOn": "Comment {{at}} — dán vào pane bạn dùng gần nhất; chưa gửi gì cho tới khi bạn nhấn Enter ở đó",
|
||||||
|
"changes.commentSend": "Dán vào pane",
|
||||||
|
"changes.commentCancel": "Huỷ",
|
||||||
|
"changes.commentNoPane": "Workspace này chưa mở pane nào — hãy mở session trước",
|
||||||
"changes.layout.split": "Hai cột",
|
"changes.layout.split": "Hai cột",
|
||||||
"changes.layout.unified": "Một cột",
|
"changes.layout.unified": "Một cột",
|
||||||
"changes.loading": "Đang đọc working copy…",
|
"changes.loading": "Đang đọc working copy…",
|
||||||
@@ -325,16 +330,17 @@
|
|||||||
"pane.close": "Dừng phiên của ô này",
|
"pane.close": "Dừng phiên của ô này",
|
||||||
"panel.files": "Tệp",
|
"panel.files": "Tệp",
|
||||||
"panel.changes": "Thay đổi",
|
"panel.changes": "Thay đổi",
|
||||||
"panel.sessions": "Phiên",
|
|
||||||
"sessions.empty": "Chưa có phiên nào trong workspace này",
|
|
||||||
"sessions.resume": "Tiếp tục",
|
"sessions.resume": "Tiếp tục",
|
||||||
"sessions.open": "Mở",
|
|
||||||
"resume.openPane": "Mở pane",
|
"resume.openPane": "Mở pane",
|
||||||
"resume.hint": "Tiếp tục session này trong một pane của workspace — nạp lại toàn bộ transcript vào context",
|
"resume.hint": "Tiếp tục session này trong một pane của workspace — nạp lại toàn bộ transcript vào context",
|
||||||
"resume.title": "Tiếp tục session này?",
|
"resume.title": "Tiếp tục session này?",
|
||||||
"resume.body": "Tiếp tục sẽ nạp lại toàn bộ transcript của session vào context của model, nên các lượt sau phải trả lại chi phí cho tất cả phần đó. Session mở ở pane trống đầu tiên của {{workspace}}. Nếu chỉ cần đọc, hãy ở lại trang này.",
|
"resume.body": "Tiếp tục sẽ nạp lại toàn bộ transcript của session vào context của model, nên các lượt sau phải trả lại chi phí cho tất cả phần đó. Session mở ở pane trống đầu tiên của {{workspace}}. Nếu chỉ cần đọc, hãy ở lại trang này.",
|
||||||
"resume.confirm": "Tiếp tục session",
|
"resume.confirm": "Tiếp tục session",
|
||||||
"sessions.search": "Lọc phiên…",
|
"resume.fork": "Fork",
|
||||||
|
"resume.forkHint": "Tiếp tục từ đây thành một session MỚI — session gốc giữ nguyên",
|
||||||
|
"resume.forkTitle": "Fork session này?",
|
||||||
|
"resume.forkBody": "Fork nạp lại toàn bộ transcript vào một session MỚI (id riêng), nên các lượt sau phải trả lại chi phí cho tất cả phần đó; session gốc không bị đụng tới. Session mở ở pane trống đầu tiên của {{workspace}}.",
|
||||||
|
"resume.forkConfirm": "Fork session",
|
||||||
"tree.title": "Không gian làm việc",
|
"tree.title": "Không gian làm việc",
|
||||||
"tree.search": "Tìm workspace và session",
|
"tree.search": "Tìm workspace và session",
|
||||||
"tree.toolFilter": "Lọc theo tool",
|
"tree.toolFilter": "Lọc theo tool",
|
||||||
@@ -342,6 +348,12 @@
|
|||||||
"tree.newSession": "Session mới",
|
"tree.newSession": "Session mới",
|
||||||
"tree.noSessions": "Chưa có session",
|
"tree.noSessions": "Chưa có session",
|
||||||
"tree.transcriptHint": "Không còn pane — mở transcript",
|
"tree.transcriptHint": "Không còn pane — mở transcript",
|
||||||
|
"tree.splitHint": "Pane đang chạy — Ctrl/Cmd+click để mở ở pane thứ hai",
|
||||||
|
"tree.renameSession": "Đổi tên session",
|
||||||
|
"tree.archiveSession": "Lưu trữ — ẩn khỏi workspace này (không xoá gì)",
|
||||||
|
"tree.archived_one": "{{count}} session đã lưu trữ",
|
||||||
|
"tree.archived_other": "{{count}} session đã lưu trữ",
|
||||||
|
"tree.unarchive": "Bỏ lưu trữ",
|
||||||
"home.breadcrumb": "Không gian làm việc",
|
"home.breadcrumb": "Không gian làm việc",
|
||||||
"home.repoRoot": "Thư mục gốc của repo",
|
"home.repoRoot": "Thư mục gốc của repo",
|
||||||
"home.mainBranch": "Branch của worktree chính",
|
"home.mainBranch": "Branch của worktree chính",
|
||||||
|
|||||||
@@ -11,7 +11,6 @@
|
|||||||
"checkingForUpdates": "Đang kiểm tra cập nhật...",
|
"checkingForUpdates": "Đang kiểm tra cập nhật...",
|
||||||
"close": "Đóng",
|
"close": "Đóng",
|
||||||
"collapse": "Thu gọn thanh bên",
|
"collapse": "Thu gọn thanh bên",
|
||||||
"collapseShort": "Thu gọn",
|
|
||||||
"connection": "Kết nối",
|
"connection": "Kết nối",
|
||||||
"connectionDetails": "Chi tiết kết nối",
|
"connectionDetails": "Chi tiết kết nối",
|
||||||
"connectionUptime": "Đã kết nối",
|
"connectionUptime": "Đã kết nối",
|
||||||
@@ -49,14 +48,6 @@
|
|||||||
"settings": "Cài đặt",
|
"settings": "Cài đặt",
|
||||||
"skipToContent": "Bỏ qua đến nội dung",
|
"skipToContent": "Bỏ qua đến nội dung",
|
||||||
"statsPersisted": "Số liệu được lưu sau khi tải lại",
|
"statsPersisted": "Số liệu được lưu sau khi tải lại",
|
||||||
"switchLanguage": "Chuyển sang {{language}}",
|
|
||||||
"switchTheme": "Chuyển sang {{theme}}",
|
|
||||||
"theme": "Giao diện",
|
|
||||||
"themeNames": {
|
|
||||||
"dark": "Tối",
|
|
||||||
"light": "Sáng",
|
|
||||||
"system": "Theo hệ thống"
|
|
||||||
},
|
|
||||||
"throughput60s": "Lưu lượng · 60s qua",
|
"throughput60s": "Lưu lượng · 60s qua",
|
||||||
"topEventTypes": "Loại sự kiện phổ biến",
|
"topEventTypes": "Loại sự kiện phổ biến",
|
||||||
"unitEvents": "sự kiện",
|
"unitEvents": "sự kiện",
|
||||||
@@ -74,5 +65,6 @@
|
|||||||
"running": "{{count}} đang chạy",
|
"running": "{{count}} đang chạy",
|
||||||
"needYou": "{{count}} cần bạn",
|
"needYou": "{{count}} cần bạn",
|
||||||
"today": "Hôm nay {{cost}}"
|
"today": "Hôm nay {{cost}}"
|
||||||
}
|
},
|
||||||
|
"version": "Phiên bản"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -710,5 +710,14 @@
|
|||||||
"claudeDesc": "Worktree dưới <repo>/.claude/worktrees/, do agent của Claude Code tạo.",
|
"claudeDesc": "Worktree dưới <repo>/.claude/worktrees/, do agent của Claude Code tạo.",
|
||||||
"agenthub": "Worktree do AgentHub quản lý",
|
"agenthub": "Worktree do AgentHub quản lý",
|
||||||
"agenthubDesc": "Worktree AgentHub tạo dưới thư mục gốc workspace của nó."
|
"agenthubDesc": "Worktree AgentHub tạo dưới thư mục gốc workspace của nó."
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"title": "Ngôn ngữ",
|
||||||
|
"description": "Ngôn ngữ của toàn bộ dashboard, được ghi nhớ trong trình duyệt này.",
|
||||||
|
"label": "Ngôn ngữ giao diện",
|
||||||
|
"names": {
|
||||||
|
"en": "English",
|
||||||
|
"vi": "Tiếng Việt"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* @file panePaste.test.ts
|
||||||
|
* @description Pins where a pasted diff comment lands: the workspace's pane
|
||||||
|
* focused last, else any of its panes, never another workspace's (lane 1 is
|
||||||
|
* not lane 12), and nowhere once the pane unmounted.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { notePaneFocus, pasteToLane, registerPane } from "../panePaste";
|
||||||
|
|
||||||
|
describe("panePaste", () => {
|
||||||
|
it("pastes into the workspace's last-focused pane, and only its own", () => {
|
||||||
|
const got: Record<string, string[]> = {};
|
||||||
|
const reg = (id: string) => registerPane(id, (t) => (got[id] ??= []).push(t));
|
||||||
|
const off12 = reg("agenthub-lane-12");
|
||||||
|
const off0 = reg("agenthub-lane-1");
|
||||||
|
const off2 = reg("agenthub-lane-1-2");
|
||||||
|
expect(pasteToLane(1, "a")).toBe(true);
|
||||||
|
expect(got["agenthub-lane-1"]).toEqual(["a"]);
|
||||||
|
notePaneFocus("agenthub-lane-1-2");
|
||||||
|
pasteToLane(1, "b");
|
||||||
|
expect(got["agenthub-lane-1-2"]).toEqual(["b"]);
|
||||||
|
// Focus on another workspace's pane does not pull lane 1's text there.
|
||||||
|
notePaneFocus("agenthub-lane-12");
|
||||||
|
pasteToLane(1, "c");
|
||||||
|
expect(got["agenthub-lane-12"]).toBeUndefined();
|
||||||
|
off0();
|
||||||
|
off2();
|
||||||
|
expect(pasteToLane(1, "d")).toBe(false);
|
||||||
|
off12();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -681,6 +681,10 @@ export const api = {
|
|||||||
cwd?: string;
|
cwd?: string;
|
||||||
/** One CLI's sessions only (`claude`, `codex`, `antigravity`). */
|
/** One CLI's sessions only (`claude`, `codex`, `antigravity`). */
|
||||||
tool?: string;
|
tool?: string;
|
||||||
|
/** The sessions this workspace owns (`sessions.lane_id`). */
|
||||||
|
lane?: number;
|
||||||
|
/** `false` = not archived, `true` = archived only; omitted = both. */
|
||||||
|
archived?: boolean;
|
||||||
sort_by?: string;
|
sort_by?: string;
|
||||||
sort_desc?: boolean;
|
sort_desc?: boolean;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
@@ -692,6 +696,8 @@ export const api = {
|
|||||||
if (params?.q) qs.set("q", params.q);
|
if (params?.q) qs.set("q", params.q);
|
||||||
if (params?.cwd) qs.set("cwd", params.cwd);
|
if (params?.cwd) qs.set("cwd", params.cwd);
|
||||||
if (params?.tool) qs.set("tool", params.tool);
|
if (params?.tool) qs.set("tool", params.tool);
|
||||||
|
if (params?.lane !== undefined) qs.set("lane", String(params.lane));
|
||||||
|
if (params?.archived !== undefined) qs.set("archived", params.archived ? "1" : "0");
|
||||||
if (params?.sort_by) qs.set("sort_by", params.sort_by);
|
if (params?.sort_by) qs.set("sort_by", params.sort_by);
|
||||||
// `!== undefined` (not truthiness) so an explicit `sort_desc: false` is preserved.
|
// `!== undefined` (not truthiness) so an explicit `sort_desc: false` is preserved.
|
||||||
if (params?.sort_desc !== undefined) qs.set("sort_desc", String(params.sort_desc));
|
if (params?.sort_desc !== undefined) qs.set("sort_desc", String(params.sort_desc));
|
||||||
@@ -733,6 +739,13 @@ export const api = {
|
|||||||
* @returns {@link SessionStats} for that one session.
|
* @returns {@link SessionStats} for that one session.
|
||||||
*/
|
*/
|
||||||
stats: (id: string) => request<SessionStats>(`/sessions/${encodeURIComponent(id)}/stats`),
|
stats: (id: string) => request<SessionStats>(`/sessions/${encodeURIComponent(id)}/stats`),
|
||||||
|
/** PATCH /api/sessions/:id — rename, or archive (hides it from its
|
||||||
|
* workspace, deletes nothing). */
|
||||||
|
patch: (id: string, body: { name?: string; archived?: boolean }) =>
|
||||||
|
request<{ session: Session }>(`/sessions/${encodeURIComponent(id)}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
/**
|
/**
|
||||||
* GET /api/sessions/:id/transcripts - the picker list of available
|
* GET /api/sessions/:id/transcripts - the picker list of available
|
||||||
* transcripts (main agent, subagents, compaction markers) for this session.
|
* transcripts (main agent, subagents, compaction markers) for this session.
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* @file panePaste.ts — lets another part of the Workspace page paste text into
|
||||||
|
* a workspace's terminal (a diff-line comment from the Changes panel). Every
|
||||||
|
* mounted TerminalView registers its run id; the text goes to the pane of that
|
||||||
|
* workspace the user focused last, else any of its panes. It is a paste —
|
||||||
|
* bracketed, so `claude` takes it as one block in its composer — and never an
|
||||||
|
* Enter: the user reads it and sends it.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const panes = new Map<string, (text: string) => void>();
|
||||||
|
let lastFocused: string | null = null;
|
||||||
|
|
||||||
|
/** Registers a mounted terminal; returns its unregister. */
|
||||||
|
export function registerPane(runId: string, paste: (text: string) => void): () => void {
|
||||||
|
panes.set(runId, paste);
|
||||||
|
return () => {
|
||||||
|
if (panes.get(runId) === paste) panes.delete(runId);
|
||||||
|
if (lastFocused === runId) lastFocused = null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notePaneFocus(runId: string): void {
|
||||||
|
lastFocused = runId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tmux names of workspace `laneId`'s panes: `agenthub-lane-<id>[-slot]`. */
|
||||||
|
function ofLane(runId: string, laneId: number): boolean {
|
||||||
|
const base = `agenthub-lane-${laneId}`;
|
||||||
|
return runId === base || runId.startsWith(`${base}-`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pastes `text` into one of the workspace's mounted panes; false when none is. */
|
||||||
|
export function pasteToLane(laneId: number, text: string): boolean {
|
||||||
|
const id =
|
||||||
|
lastFocused && panes.has(lastFocused) && ofLane(lastFocused, laneId)
|
||||||
|
? lastFocused
|
||||||
|
: [...panes.keys()].find((k) => ofLane(k, laneId));
|
||||||
|
if (!id) return false;
|
||||||
|
panes.get(id)!(text);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -25,6 +25,11 @@ export const SETTINGS_INDEX: {
|
|||||||
descKey?: string;
|
descKey?: string;
|
||||||
keywords?: string[];
|
keywords?: string[];
|
||||||
}[] = [
|
}[] = [
|
||||||
|
{
|
||||||
|
section: "general",
|
||||||
|
titleKey: "language.title",
|
||||||
|
keywords: ["english", "tiếng việt", "vietnamese", "ngôn ngữ"],
|
||||||
|
},
|
||||||
{ section: "general", titleKey: "lanesSection.title", descKey: "lanesSection.description" },
|
{ section: "general", titleKey: "lanesSection.title", descKey: "lanesSection.description" },
|
||||||
{ section: "general", titleKey: "lanesSection.runHousekeeping" },
|
{ section: "general", titleKey: "lanesSection.runHousekeeping" },
|
||||||
{ section: "general", titleKey: "import.title", keywords: ["history", "transcripts"] },
|
{ section: "general", titleKey: "import.title", keywords: ["history", "transcripts"] },
|
||||||
|
|||||||
@@ -734,6 +734,13 @@ export interface Session {
|
|||||||
* journals and hooks) or `"antigravity"` (agy's conversation files and
|
* journals and hooks) or `"antigravity"` (agy's conversation files and
|
||||||
* hooks). Attach/resume offer `claude` rows only. Maps to `sessions.tool`. */
|
* hooks). Attach/resume offer `claude` rows only. Maps to `sessions.tool`. */
|
||||||
tool?: string;
|
tool?: string;
|
||||||
|
/** The workspace (lane id) that owns the session — the one whose pane held
|
||||||
|
* it; null for a session no workspace started.
|
||||||
|
* Maps to `sessions.lane_id`. */
|
||||||
|
lane_id?: number | null;
|
||||||
|
/** ISO time the user archived the session out of its workspace's lists;
|
||||||
|
* null when not archived. Hides only. Maps to `sessions.archived_at`. */
|
||||||
|
archived_at?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -55,6 +55,7 @@
|
|||||||
*
|
*
|
||||||
* ----------------------------------------------------------------------------- */
|
* ----------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
import { Checkbox } from "../components/Checkbox";
|
||||||
import { useEffect, useState, useCallback } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
@@ -108,6 +109,7 @@ import { Skeleton } from "../components/Skeleton";
|
|||||||
import { AlertsNotifications } from "../components/AlertsNotifications";
|
import { AlertsNotifications } from "../components/AlertsNotifications";
|
||||||
import {
|
import {
|
||||||
SettingsRow,
|
SettingsRow,
|
||||||
|
SettingsSection,
|
||||||
SettingsSegmented,
|
SettingsSegmented,
|
||||||
SettingsSwitchRow,
|
SettingsSwitchRow,
|
||||||
} from "../components/settings/SettingsRow";
|
} from "../components/settings/SettingsRow";
|
||||||
@@ -194,7 +196,7 @@ export function Settings({
|
|||||||
/** Which of the two Settings pages this renders (`/settings/general` or `/settings/notifications`). */
|
/** Which of the two Settings pages this renders (`/settings/general` or `/settings/notifications`). */
|
||||||
section?: "general" | "notifications";
|
section?: "general" | "notifications";
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const { t } = useTranslation("settings");
|
const { t, i18n } = useTranslation("settings");
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [totalCost, setTotalCost] = useState<number | null>(null);
|
const [totalCost, setTotalCost] = useState<number | null>(null);
|
||||||
@@ -414,6 +416,23 @@ export function Settings({
|
|||||||
|
|
||||||
{section === "general" && (
|
{section === "general" && (
|
||||||
<>
|
<>
|
||||||
|
<SettingsSection title={t("language.title")} description={t("language.description")}>
|
||||||
|
<SettingsRow
|
||||||
|
label={t("language.label")}
|
||||||
|
control={
|
||||||
|
<SettingsSegmented
|
||||||
|
ariaLabel={t("language.label")}
|
||||||
|
value={(i18n.resolvedLanguage ?? i18n.language).startsWith("vi") ? "vi" : "en"}
|
||||||
|
onChange={(v) => void i18n.changeLanguage(v)}
|
||||||
|
options={[
|
||||||
|
{ value: "en" as const, label: t("language.names.en") },
|
||||||
|
{ value: "vi" as const, label: t("language.names.vi") },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
{/* Cost summary card */}
|
{/* Cost summary card */}
|
||||||
<div className="card p-6">
|
<div className="card p-6">
|
||||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||||
@@ -453,14 +472,11 @@ export function Settings({
|
|||||||
|
|
||||||
<div className="card p-5 space-y-4">
|
<div className="card p-5 space-y-4">
|
||||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||||
<label className="flex items-center gap-2 text-xs text-fg-secondary">
|
<Checkbox
|
||||||
<input
|
checked={gcDryRun}
|
||||||
type="checkbox"
|
onChange={setGcDryRun}
|
||||||
checked={gcDryRun}
|
label={t("lanesSection.dryRun")}
|
||||||
onChange={(e) => setGcDryRun(e.target.checked)}
|
/>
|
||||||
/>
|
|
||||||
{t("lanesSection.dryRun")}
|
|
||||||
</label>
|
|
||||||
<button
|
<button
|
||||||
onClick={handleGc}
|
onClick={handleGc}
|
||||||
disabled={actionLoading !== null}
|
disabled={actionLoading !== null}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
*
|
*
|
||||||
* ----------------------------------------------------------------------------- */
|
* ----------------------------------------------------------------------------- */
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||||
import { useLocation, useSearchParams } from "react-router-dom";
|
import { useLocation, useSearchParams } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
@@ -58,7 +58,6 @@ import {
|
|||||||
Maximize2,
|
Maximize2,
|
||||||
FolderTree,
|
FolderTree,
|
||||||
GitCompare,
|
GitCompare,
|
||||||
History,
|
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
@@ -79,7 +78,6 @@ import LaneCard, {
|
|||||||
} from "../components/lanes/LaneCard";
|
} from "../components/lanes/LaneCard";
|
||||||
import { AddLaneModal } from "../components/lanes/AddLaneModal";
|
import { AddLaneModal } from "../components/lanes/AddLaneModal";
|
||||||
import { LaneChangesPanel } from "../components/lanes/LaneChangesPanel";
|
import { LaneChangesPanel } from "../components/lanes/LaneChangesPanel";
|
||||||
import { WorkspaceSessionsTab } from "../components/lanes/WorkspaceSessionsTab";
|
|
||||||
import { WorkspaceHome } from "../components/lanes/WorkspaceHome";
|
import { WorkspaceHome } from "../components/lanes/WorkspaceHome";
|
||||||
import { DestructiveLaneModal } from "../components/lanes/DestructiveLaneModal";
|
import { DestructiveLaneModal } from "../components/lanes/DestructiveLaneModal";
|
||||||
import { readSplitViewState, writeSplitViewState } from "../lib/splitViewStorage";
|
import { readSplitViewState, writeSplitViewState } from "../lib/splitViewStorage";
|
||||||
@@ -97,7 +95,6 @@ const SELECTED_KEY = "agenthub.workspace.selected";
|
|||||||
const PANEL_TABS = [
|
const PANEL_TABS = [
|
||||||
{ id: "files", Icon: FolderTree },
|
{ id: "files", Icon: FolderTree },
|
||||||
{ id: "changes", Icon: GitCompare },
|
{ id: "changes", Icon: GitCompare },
|
||||||
{ id: "sessions", Icon: History },
|
|
||||||
] as const;
|
] as const;
|
||||||
type PanelTab = (typeof PANEL_TABS)[number]["id"];
|
type PanelTab = (typeof PANEL_TABS)[number]["id"];
|
||||||
|
|
||||||
@@ -233,7 +230,6 @@ function ConsoleArea({
|
|||||||
onLaneCreated,
|
onLaneCreated,
|
||||||
onLaneIdChange,
|
onLaneIdChange,
|
||||||
showToggle = true,
|
showToggle = true,
|
||||||
resumeRequest,
|
|
||||||
attachRequest,
|
attachRequest,
|
||||||
}: {
|
}: {
|
||||||
lanes: Lane[];
|
lanes: Lane[];
|
||||||
@@ -250,10 +246,8 @@ function ConsoleArea({
|
|||||||
onLaneCreated: (lane: Lane) => void;
|
onLaneCreated: (lane: Lane) => void;
|
||||||
onLaneIdChange: (id: number | null) => void;
|
onLaneIdChange: (id: number | null) => void;
|
||||||
showToggle?: boolean;
|
showToggle?: boolean;
|
||||||
/** A session to resume in slot 0, from the Sessions tab. */
|
/** A live run to attach a pane to, from the sidebar (`?run=`, `&pane=`). */
|
||||||
resumeRequest: { sessionId: string; model: string | null; nonce: number } | null;
|
attachRequest: { runId: string; nonce: string; pane: number } | null;
|
||||||
/** A live run to attach slot 0 to, from the sidebar (`?run=`). */
|
|
||||||
attachRequest: { runId: string; nonce: string } | null;
|
|
||||||
}) {
|
}) {
|
||||||
// Every split pane shows the SELECTED lane, each in its own tmux session
|
// Every split pane shows the SELECTED lane, each in its own tmux session
|
||||||
// (slot = pane index). Tracking what each pane shows lets the others leave
|
// (slot = pane index). Tracking what each pane shows lets the others leave
|
||||||
@@ -297,8 +291,7 @@ function ConsoleArea({
|
|||||||
wsConnected={wsConnected}
|
wsConnected={wsConnected}
|
||||||
defaultCwd={defaultCwd}
|
defaultCwd={defaultCwd}
|
||||||
onHasActiveRunChange={onHasActiveRunChange}
|
onHasActiveRunChange={onHasActiveRunChange}
|
||||||
resumeRequest={resumeRequest}
|
attachRequest={attachRequest?.pane === 0 ? attachRequest : null}
|
||||||
attachRequest={attachRequest}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
@@ -329,8 +322,7 @@ function ConsoleArea({
|
|||||||
externalSessions={externalSessions}
|
externalSessions={externalSessions}
|
||||||
wsConnected={wsConnected}
|
wsConnected={wsConnected}
|
||||||
defaultCwd={defaultCwd}
|
defaultCwd={defaultCwd}
|
||||||
resumeRequest={i === 0 ? resumeRequest : null}
|
attachRequest={attachRequest?.pane === i ? attachRequest : null}
|
||||||
attachRequest={i === 0 ? attachRequest : null}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -354,9 +346,12 @@ export function Workspace() {
|
|||||||
// second pick of the same session a new request.
|
// second pick of the same session a new request.
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const runParam = searchParams.get("run");
|
const runParam = searchParams.get("run");
|
||||||
|
// `&pane=<n>` — which split pane attaches it (Ctrl/Cmd+click in the sidebar
|
||||||
|
// asks for pane 1); pane 0 otherwise.
|
||||||
|
const paneParam = Math.min(Math.max(Number(searchParams.get("pane")) || 0, 0), 3);
|
||||||
const attachRequest = useMemo(
|
const attachRequest = useMemo(
|
||||||
() => (runParam ? { runId: runParam, nonce: location.key } : null),
|
() => (runParam ? { runId: runParam, nonce: location.key, pane: paneParam } : null),
|
||||||
[runParam, location.key]
|
[runParam, location.key, paneParam]
|
||||||
);
|
);
|
||||||
const paramId = laneParam === null ? null : Number(laneParam);
|
const paramId = laneParam === null ? null : Number(laneParam);
|
||||||
const fallbackId = (() => {
|
const fallbackId = (() => {
|
||||||
@@ -417,11 +412,6 @@ export function Workspace() {
|
|||||||
>([]);
|
>([]);
|
||||||
const [viewedFeature, setViewedFeature] = useState<LaneFeature | null>(null);
|
const [viewedFeature, setViewedFeature] = useState<LaneFeature | null>(null);
|
||||||
const [proofFeatures, setProofFeatures] = useState<ProofFeature[]>([]);
|
const [proofFeatures, setProofFeatures] = useState<ProofFeature[]>([]);
|
||||||
const [resumeRequest, setResumeRequest] = useState<{
|
|
||||||
sessionId: string;
|
|
||||||
model: string | null;
|
|
||||||
nonce: number;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
// Run state kept at page level: shared across every pane, or drives the
|
// Run state kept at page level: shared across every pane, or drives the
|
||||||
// lane strip itself rather than any one pane's form.
|
// lane strip itself rather than any one pane's form.
|
||||||
@@ -503,6 +493,16 @@ export function Workspace() {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// A pane the layout does not show yet (`&pane=1` on one pane) widens the
|
||||||
|
// split so the run lands where it was asked for — once per request, so the
|
||||||
|
// user can still go back to one pane afterwards.
|
||||||
|
const layoutRef = useRef(splitView.layout);
|
||||||
|
layoutRef.current = splitView.layout;
|
||||||
|
useEffect(() => {
|
||||||
|
const pane = attachRequest?.pane ?? 0;
|
||||||
|
if (pane >= layoutRef.current) setLayout(pane < 2 ? 2 : 4);
|
||||||
|
}, [attachRequest, setLayout]);
|
||||||
|
|
||||||
// Probe the CLI of every tool a lane uses — fresh on the server, so a CLI
|
// Probe the CLI of every tool a lane uses — fresh on the server, so a CLI
|
||||||
// installed since the page loaded clears its missing-binary banner. Claude is
|
// installed since the page loaded clears its missing-binary banner. Claude is
|
||||||
// always probed: the console's no-lane state runs it.
|
// always probed: the console's no-lane state runs it.
|
||||||
@@ -1002,7 +1002,6 @@ export function Workspace() {
|
|||||||
onLaneCreated={() => void refreshLanes()}
|
onLaneCreated={() => void refreshLanes()}
|
||||||
onLaneIdChange={setSelectedLaneId}
|
onLaneIdChange={setSelectedLaneId}
|
||||||
showToggle={!currentLane}
|
showToggle={!currentLane}
|
||||||
resumeRequest={resumeRequest}
|
|
||||||
attachRequest={attachRequest}
|
attachRequest={attachRequest}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1065,34 +1064,21 @@ export function Workspace() {
|
|||||||
})()}
|
})()}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{panelTab === "sessions" ? (
|
<LaneChangesPanel
|
||||||
<WorkspaceSessionsTab
|
laneId={currentLane.id}
|
||||||
lane={currentLane}
|
tab={panelTab}
|
||||||
onResume={(session) =>
|
full={changesSize === "full"}
|
||||||
setResumeRequest({
|
// A sibling worktree forks from the lane's SOURCE repo when
|
||||||
sessionId: session.id,
|
// it has one (this lane is itself a worktree of it); an
|
||||||
model: session.model,
|
// adopted lane is its own repo.
|
||||||
nonce: Date.now(),
|
onNewLane={(branch) => {
|
||||||
})
|
setAddLanePrefill({
|
||||||
}
|
sourceRepo: currentLane.source_repo || currentLane.cwd,
|
||||||
/>
|
branch,
|
||||||
) : (
|
});
|
||||||
<LaneChangesPanel
|
setAddLaneOpen(true);
|
||||||
laneId={currentLane.id}
|
}}
|
||||||
tab={panelTab}
|
/>
|
||||||
full={changesSize === "full"}
|
|
||||||
// A sibling worktree forks from the lane's SOURCE repo when
|
|
||||||
// it has one (this lane is itself a worktree of it); an
|
|
||||||
// adopted lane is its own repo.
|
|
||||||
onNewLane={(branch) => {
|
|
||||||
setAddLanePrefill({
|
|
||||||
sourceRepo: currentLane.source_repo || currentLane.cwd,
|
|
||||||
branch,
|
|
||||||
});
|
|
||||||
setAddLaneOpen(true);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</aside>
|
</aside>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -722,4 +722,16 @@ describe("Workspace — home and session view", () => {
|
|||||||
await userEvent.setup().click(screen.getByTestId("workspace-back-home"));
|
await userEvent.setup().click(screen.getByTestId("workspace-back-home"));
|
||||||
expect(await screen.findByTestId("workspace-home")).toBeInTheDocument();
|
expect(await screen.findByTestId("workspace-home")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
it("widens one pane to two for &pane=1, so the run opens beside the first", async () => {
|
||||||
|
localStorage.removeItem("agenthub.workspace.splitView");
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={["/workspace?lane=1&run=agenthub-lane-1&pane=1"]}>
|
||||||
|
<Workspace />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
await settle();
|
||||||
|
await settle();
|
||||||
|
expect(screen.getAllByTestId("console-body")).toHaveLength(2);
|
||||||
|
expect(JSON.parse(localStorage.getItem("agenthub.workspace.splitView")!).layout).toBe(2);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7174,6 +7174,63 @@ exports[`screen snapshots > Settings 1`] = `
|
|||||||
Refresh
|
Refresh
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<section
|
||||||
|
class="card p-5"
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
class="text-sm font-semibold text-fg-primary"
|
||||||
|
>
|
||||||
|
Language
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
class="mt-0.5 text-xs text-fg-muted"
|
||||||
|
>
|
||||||
|
The language of the whole dashboard, remembered in this browser.
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
class="mt-2"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="min-w-0"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
class="text-sm text-fg-secondary"
|
||||||
|
>
|
||||||
|
Interface language
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="shrink-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
aria-label="Interface language"
|
||||||
|
class="inline-flex rounded-lg border border-border bg-surface-2 p-0.5"
|
||||||
|
role="radiogroup"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
aria-checked="true"
|
||||||
|
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors bg-accent/20 text-accent"
|
||||||
|
role="radio"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
English
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-checked="false"
|
||||||
|
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||||
|
role="radio"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Tiếng Việt
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<div
|
<div
|
||||||
class="card p-6"
|
class="card p-6"
|
||||||
>
|
>
|
||||||
@@ -7290,14 +7347,21 @@ exports[`screen snapshots > Settings 1`] = `
|
|||||||
<div
|
<div
|
||||||
class="flex items-center justify-between flex-wrap gap-3"
|
class="flex items-center justify-between flex-wrap gap-3"
|
||||||
>
|
>
|
||||||
<label
|
<button
|
||||||
class="flex items-center gap-2 text-xs text-fg-secondary"
|
aria-checked="false"
|
||||||
|
class="group inline-flex items-center gap-2 text-left disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none "
|
||||||
|
role="checkbox"
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
<input
|
<span
|
||||||
type="checkbox"
|
class="flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-[5px] border transition-colors group-focus-visible:ring-2 group-focus-visible:ring-accent/40 bg-surface-2 border-border group-hover:border-border-light"
|
||||||
/>
|
/>
|
||||||
Dry run
|
<span
|
||||||
</label>
|
class="text-xs text-fg-secondary group-hover:text-fg-secondary"
|
||||||
|
>
|
||||||
|
Dry run
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
class="btn-ghost text-xs disabled:opacity-50"
|
class="btn-ghost text-xs disabled:opacity-50"
|
||||||
>
|
>
|
||||||
@@ -8274,12 +8338,13 @@ exports[`screen snapshots > Settings 1`] = `
|
|||||||
<button
|
<button
|
||||||
aria-checked="true"
|
aria-checked="true"
|
||||||
aria-label="Show Sagi"
|
aria-label="Show Sagi"
|
||||||
class="relative inline-flex h-5 w-9 rounded-full border-2 border-transparent transition-colors disabled:opacity-50 bg-accent"
|
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-50 bg-accent"
|
||||||
role="switch"
|
role="switch"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transition-transform translate-x-4"
|
aria-hidden="true"
|
||||||
|
class="inline-block h-4 w-4 rounded-full bg-white shadow transition-transform translate-x-[18px]"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -9064,12 +9129,13 @@ exports[`screen snapshots > Settings — notifications 1`] = `
|
|||||||
<button
|
<button
|
||||||
aria-checked="false"
|
aria-checked="false"
|
||||||
aria-label="Enable Browser Notifications"
|
aria-label="Enable Browser Notifications"
|
||||||
class="relative inline-flex h-5 w-9 rounded-full border-2 border-transparent transition-colors disabled:opacity-50 bg-surface-4"
|
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-50 bg-surface-4"
|
||||||
role="switch"
|
role="switch"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transition-transform translate-x-0"
|
aria-hidden="true"
|
||||||
|
class="inline-block h-4 w-4 rounded-full bg-white shadow transition-transform translate-x-0.5"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ if (nodeProcess) nodeProcess.env.TZ = "UTC";
|
|||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from "vitest";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { render, act, screen, fireEvent } from "@testing-library/react";
|
import { render, act, screen, fireEvent, within } from "@testing-library/react";
|
||||||
import { MemoryRouter, Routes, Route } from "react-router-dom";
|
import { MemoryRouter, Routes, Route } from "react-router-dom";
|
||||||
import i18n from "i18next";
|
import i18n from "i18next";
|
||||||
|
|
||||||
@@ -604,6 +604,29 @@ describe("screen snapshots", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The language picker moved off the sidebar into Settings → General.
|
||||||
|
describe("Settings — language", () => {
|
||||||
|
it("switches the interface language from General", async () => {
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={["/settings/general"]}>
|
||||||
|
<Settings section="general" />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
await settle();
|
||||||
|
const group = screen.getByRole("radiogroup", { name: "Interface language" });
|
||||||
|
expect(within(group).getByRole("radio", { name: "English" })).toHaveAttribute(
|
||||||
|
"aria-checked",
|
||||||
|
"true"
|
||||||
|
);
|
||||||
|
fireEvent.click(within(group).getByRole("radio", { name: "Tiếng Việt" }));
|
||||||
|
await settle();
|
||||||
|
expect(i18n.language).toBe("vi");
|
||||||
|
await act(async () => {
|
||||||
|
await i18n.changeLanguage("en");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Pricing, hook status and CLAUDE_HOME belong to Claude Code (#40 part B):
|
// Pricing, hook status and CLAUDE_HOME belong to Claude Code (#40 part B):
|
||||||
// they sit under the Claude Code tab's Overview, not on Dashboard or on the
|
// they sit under the Claude Code tab's Overview, not on Dashboard or on the
|
||||||
// other sub-tabs.
|
// other sub-tabs.
|
||||||
|
|||||||
@@ -271,6 +271,7 @@ All fields are optional. The tool (CLI invoked) comes from the workspace itself,
|
|||||||
- **`sandbox`** — one of `read-only`, `workspace-write`, or `danger-full-access`, or omitted/empty for no sandbox. Passed to `codex -s <value>`.
|
- **`sandbox`** — one of `read-only`, `workspace-write`, or `danger-full-access`, or omitted/empty for no sandbox. Passed to `codex -s <value>`.
|
||||||
- **`approval`** — one of `on-request` or `never`, or omitted/empty for no approval prompt. Passed to `codex -a <value>`.
|
- **`approval`** — one of `on-request` or `never`, or omitted/empty for no approval prompt. Passed to `codex -a <value>`.
|
||||||
- **`resumeSessionId`** or **`resumeLast: true`** — resume an earlier run instead of starting new. For Codex workspaces specifically, `resumeLast: true` uses `codex resume --last`; `resumeSessionId` uses `codex resume <sessionId>`. For Claude workspaces, `resumeSessionId` re-attaches a Claude session by id.
|
- **`resumeSessionId`** or **`resumeLast: true`** — resume an earlier run instead of starting new. For Codex workspaces specifically, `resumeLast: true` uses `codex resume --last`; `resumeSessionId` uses `codex resume <sessionId>`. For Claude workspaces, `resumeSessionId` re-attaches a Claude session by id.
|
||||||
|
- **`forkSession: true`** (with `resumeSessionId`, Claude workspaces only) — resume into a NEW session id (`claude --resume <id> --fork-session`), leaving the original untouched; the run records no session until the fork's first hook. Without `resumeSessionId` it is `400 EBADSESSION`, on any other tool `400 EUNSUPPORTED`.
|
||||||
|
|
||||||
- **`slot`** — which Workspace split pane's session to start, `0`–`3` (default `0`, else `400 EBADSLOT`). Slot 0 is the workspace's own `agenthub-lane-<id>` and is what `run_id` records; slots 1–3 are extra sessions on the same workspace, `agenthub-lane-<id>-<slot>`, and never touch `run_id`/`status`. The response adds `run_id`: the session this start opened. `stop`, `reset` and `remove` kill every slot's session, not only `run_id`.
|
- **`slot`** — which Workspace split pane's session to start, `0`–`3` (default `0`, else `400 EBADSLOT`). Slot 0 is the workspace's own `agenthub-lane-<id>` and is what `run_id` records; slots 1–3 are extra sessions on the same workspace, `agenthub-lane-<id>-<slot>`, and never touch `run_id`/`status`. The response adds `run_id`: the session this start opened. `stop`, `reset` and `remove` kill every slot's session, not only `run_id`.
|
||||||
|
|
||||||
@@ -1199,6 +1200,10 @@ Returns all sessions, ordered by most recent activity.
|
|||||||
| `limit` | integer | 50 | Maximum sessions to return (1-1000) |
|
| `limit` | integer | 50 | Maximum sessions to return (1-1000) |
|
||||||
| `offset` | integer | 0 | Pagination offset |
|
| `offset` | integer | 0 | Pagination offset |
|
||||||
| `status` | string | - | Filter by persisted status: `active`, `completed`, `error`, `abandoned`. The UI **Waiting** state is derived from the `awaiting_input_since` column and is not a queryable enum — filter `status=active` and inspect `awaiting_input_since` (non-null = Waiting) |
|
| `status` | string | - | Filter by persisted status: `active`, `completed`, `error`, `abandoned`. The UI **Waiting** state is derived from the `awaiting_input_since` column and is not a queryable enum — filter `status=active` and inspect `awaiting_input_since` (non-null = Waiting) |
|
||||||
|
| `cwd` | string | - | Exact working directory |
|
||||||
|
| `tool` | string | - | One CLI's sessions: `claude`, `codex`, `antigravity` |
|
||||||
|
| `lane` | integer | - | The sessions a workspace OWNS (`sessions.lane_id`): the ones its panes held. `400 INVALID_INPUT` when not an id |
|
||||||
|
| `archived` | `0` \| `1` | - | `0` = not archived, `1` = archived only; omitted = both |
|
||||||
| `sources` | string | - | Comma-separated data-source ids to include (the built-in local history is `local`; remote SSH machines use their `remote_sources.id`). Omit for all sources. Also accepted on `/api/events`, `/api/agents`, `/api/stats`, `/api/analytics`, and `/api/pricing/cost`. See [Remote Data Sources](#remote-data-sources) |
|
| `sources` | string | - | Comma-separated data-source ids to include (the built-in local history is `local`; remote SSH machines use their `remote_sources.id`). Omit for all sources. Also accepted on `/api/events`, `/api/agents`, `/api/stats`, `/api/analytics`, and `/api/pricing/cost`. See [Remote Data Sources](#remote-data-sources) |
|
||||||
|
|
||||||
**Example Request:**
|
**Example Request:**
|
||||||
@@ -1252,6 +1257,8 @@ classDiagram
|
|||||||
+string updated_at
|
+string updated_at
|
||||||
+string awaiting_input_since "null unless Waiting"
|
+string awaiting_input_since "null unless Waiting"
|
||||||
+string awaiting_reason "notification|stop|session_start|interrupted; null unless Waiting"
|
+string awaiting_reason "notification|stop|session_start|interrupted; null unless Waiting"
|
||||||
|
+number lane_id "owning workspace; null if none"
|
||||||
|
+string archived_at "null unless archived"
|
||||||
+number cost
|
+number cost
|
||||||
+number agent_count
|
+number agent_count
|
||||||
}
|
}
|
||||||
@@ -1261,6 +1268,22 @@ classDiagram
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
#### Update Session
|
||||||
|
|
||||||
|
```http
|
||||||
|
PATCH /api/sessions/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
Body: any of `name`, `status`, `ended_at`, `metadata` and `archived`.
|
||||||
|
`archived` (boolean, else `400 INVALID_INPUT`) sets or clears `archived_at`:
|
||||||
|
the session leaves its workspace's lists and nothing is deleted. Ownership
|
||||||
|
(`lane_id`) is not patchable: `bindRunSession` (`server/routes/hooks.js`)
|
||||||
|
stamps the session a dashboard pane holds with that pane's lane, first writer
|
||||||
|
wins.
|
||||||
|
Returns `{ session }`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
#### Get Session
|
#### Get Session
|
||||||
|
|
||||||
```http
|
```http
|
||||||
@@ -2154,7 +2177,7 @@ DELETE /api/run/:id Kill (SIGTERM → SIGKILL after 5 s)
|
|||||||
|
|
||||||
**PTY streaming:** Frames from the tmux pane are streamed to the client over `/ws-pty/:runId` as binary WebSocket frames (not JSON). The Workspace page's TerminalView component feeds these frames to xterm.js for live rendering. Simultaneously, `agenthub lanes shell` can attach the same session via a real local terminal, staying in sync with the browser view.
|
**PTY streaming:** Frames from the tmux pane are streamed to the client over `/ws-pty/:runId` as binary WebSocket frames (not JSON). The Workspace page's TerminalView component feeds these frames to xterm.js for live rendering. Simultaneously, `agenthub lanes shell` can attach the same session via a real local terminal, staying in sync with the browser view.
|
||||||
|
|
||||||
Every history row carries `lane_id` (the workspace whose run it belongs to) and `tool` (the CLI that was invoked: `"claude"` or `"codex"`). `GET /api/run/history?laneId=<n>` returns only that workspace's runs. Spawned Claude processes fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, and the Dashboard's Analytics tab (Workflow sub-tab included) automatically. Codex runs do not emit these hooks; they are recorded only in run history and `dashboard_runs`.
|
Every history row carries `lane_id` (the workspace whose run it belongs to) and `tool` (the CLI that was invoked: `"claude"` or `"codex"`). A run's `id` is its tmux pane name, which each Start in that pane reuses, so the run it replaces is kept as its own row with `id` = `<pane name>@<its started_at>` (status `abandoned` if it never recorded an end). A row still marked `running` is flipped to `abandoned` once its tmux session is gone — checked at server start and on the 10 s run-heal beat (`DASHBOARD_RUN_HEAL_MS`); a pane that survived a server restart stays `running`. `GET /api/run/history?laneId=<n>` returns only that workspace's runs. Spawned Claude processes fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, and the Dashboard's Analytics tab (Workflow sub-tab included) automatically. Codex runs do not emit these hooks; they are recorded only in run history and `dashboard_runs`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -212,6 +212,8 @@ CREATE TABLE sessions (
|
|||||||
| `transcript_path` | TEXT | YES | Absolute path to the session's JSONL transcript. Written by `routes/hooks.js` on the first event that carries it (subsequent events no-op via a SQL guard) and read by the periodic compaction sweep — so the sweep touches only active session rows instead of scanning the entire `events` table for `json_extract(data,'$.transcript_path')`. Backfilled once from `events` by the `db.js` migration |
|
| `transcript_path` | TEXT | YES | Absolute path to the session's JSONL transcript. Written by `routes/hooks.js` on the first event that carries it (subsequent events no-op via a SQL guard) and read by the periodic compaction sweep — so the sweep touches only active session rows instead of scanning the entire `events` table for `json_extract(data,'$.transcript_path')`. Backfilled once from `events` by the `db.js` migration |
|
||||||
| `source` | TEXT | NO | Data source this session was captured from. `'local'` for this machine's own Claude Code history (the default); otherwise the `remote_sources.id` of the remote SSH machine it was pulled from. Powers the `sources` query filter on `/api/sessions`, `/api/events`, `/api/agents`, `/api/stats`, and `/api/analytics`, and the `sources` facet on `/api/sessions/facets`. Indexed by `idx_sessions_source` |
|
| `source` | TEXT | NO | Data source this session was captured from. `'local'` for this machine's own Claude Code history (the default); otherwise the `remote_sources.id` of the remote SSH machine it was pulled from. Powers the `sources` query filter on `/api/sessions`, `/api/events`, `/api/agents`, `/api/stats`, and `/api/analytics`, and the `sources` facet on `/api/sessions/facets`. Indexed by `idx_sessions_source` |
|
||||||
| `tool` | TEXT | NO | The AI coding CLI that produced the session. `'claude'` for every hook-created and imported row (the default); `'codex'` for a session ingested from Codex's rollout journals by `server/lib/codex-usage.js` (see `codex_rollouts`) or created by a Codex hook (`tool: "codex"`, `server/lib/codex-hooks.js`) — a Codex session keeps the status its hooks give it while they are current (its newest event at or after its newest journal write minus two minutes); once they stop (removed, untrusted) the journal decides again. A codex row has no `transcript_path`, and, going by the journal, its `status` is `active` only while one of its journals was written in the last two minutes. `'antigravity'` for an agy CLI conversation, ingested by `server/lib/antigravity-usage.js` (see `antigravity_conversations`) or created by an Antigravity hook (`server/lib/antigravity-hooks.js`); no `transcript_path`, and `active` until it has been quiet — no write to its conversation file (or a non-empty `-wal`) or transcript, no hook event — for ten minutes when it has hook events, two minutes when it has none; it then settles to `completed`, its agents too. The startup cleanup of stale `active` sessions and a workspace purge touch only the matching tool's rows (a purge takes the sessions of the workspace's own `tool`). The Workspace's attach/resume list offers `'claude'` rows only |
|
| `tool` | TEXT | NO | The AI coding CLI that produced the session. `'claude'` for every hook-created and imported row (the default); `'codex'` for a session ingested from Codex's rollout journals by `server/lib/codex-usage.js` (see `codex_rollouts`) or created by a Codex hook (`tool: "codex"`, `server/lib/codex-hooks.js`) — a Codex session keeps the status its hooks give it while they are current (its newest event at or after its newest journal write minus two minutes); once they stop (removed, untrusted) the journal decides again. A codex row has no `transcript_path`, and, going by the journal, its `status` is `active` only while one of its journals was written in the last two minutes. `'antigravity'` for an agy CLI conversation, ingested by `server/lib/antigravity-usage.js` (see `antigravity_conversations`) or created by an Antigravity hook (`server/lib/antigravity-hooks.js`); no `transcript_path`, and `active` until it has been quiet — no write to its conversation file (or a non-empty `-wal`) or transcript, no hook event — for ten minutes when it has hook events, two minutes when it has none; it then settles to `completed`, its agents too. The startup cleanup of stale `active` sessions and a workspace purge touch only the matching tool's rows (a purge takes the sessions of the workspace's own `tool`). The Workspace's attach/resume list offers `'claude'` rows only |
|
||||||
|
| `lane_id` | INTEGER | YES | The workspace that OWNS the session — the only sessions a workspace lists: the lane whose dashboard pane held it, stamped by `bindRunSession` (`server/routes/hooks.js`) from the hook's `run_id` — first writer wins, a nested `claude -p` excluded. NULL for a session no workspace started (a terminal, an import, a tool with no hooks). No FK: removing a workspace leaves its sessions. Backfilled once from `dashboard_runs`. Indexed by `idx_sessions_lane` |
|
||||||
|
| `archived_at` | TEXT | YES | When the user archived the session out of its workspace's lists (`PATCH /api/sessions/:id {archived}`). Hides only — nothing is deleted |
|
||||||
|
|
||||||
**Constraints:**
|
**Constraints:**
|
||||||
- `status` must be one of the four enum values
|
- `status` must be one of the four enum values
|
||||||
|
|||||||
@@ -132,9 +132,7 @@ classDiagram
|
|||||||
+t(key, params)
|
+t(key, params)
|
||||||
}
|
}
|
||||||
|
|
||||||
class SidebarLanguageSwitch {
|
class SettingsLanguagePicker {
|
||||||
+SUPPORTED_LANGUAGES
|
|
||||||
+normalizeLanguage()
|
|
||||||
+changeLanguage()
|
+changeLanguage()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,7 +147,7 @@ classDiagram
|
|||||||
I18nConfig --> NamespaceResource
|
I18nConfig --> NamespaceResource
|
||||||
ReactComponent --> I18nConfig
|
ReactComponent --> I18nConfig
|
||||||
ReactComponent --> NamespaceResource
|
ReactComponent --> NamespaceResource
|
||||||
SidebarLanguageSwitch --> I18nConfig
|
SettingsLanguagePicker --> I18nConfig
|
||||||
FormatUtils --> I18nConfig
|
FormatUtils --> I18nConfig
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -157,7 +155,7 @@ classDiagram
|
|||||||
|
|
||||||
## 4) Language Detection and Switching Flow
|
## 4) Language Detection and Switching Flow
|
||||||
|
|
||||||
The sidebar language controls call `i18n.changeLanguage()` and UI updates reactively through `useTranslation`.
|
The language picker in Settings → General (`client/src/pages/Settings.tsx`, a `SettingsSegmented` of English / Tiếng Việt, found by the settings search too) calls `i18n.changeLanguage()` and UI updates reactively through `useTranslation`. The sidebar has no language control.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
@@ -239,7 +237,7 @@ npm run test:client
|
|||||||
| Locale fallback | Unknown locales fall back to `en` | `vi-VN` resolves to `vi` |
|
| Locale fallback | Unknown locales fall back to `en` | `vi-VN` resolves to `vi` |
|
||||||
| Terminology consistency | Canonical terms stay stable | `Agent`/`Subagent` expectations |
|
| Terminology consistency | Canonical terms stay stable | `Agent`/`Subagent` expectations |
|
||||||
| Date/number formatting | Locale-specific output shape | `en-US`, `vi-VN` formatting |
|
| Date/number formatting | Locale-specific output shape | `en-US`, `vi-VN` formatting |
|
||||||
| Runtime switching | UI rerenders without reload | Sidebar language toggle |
|
| Runtime switching | UI rerenders without reload | Settings → General language picker |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -284,7 +282,7 @@ gantt
|
|||||||
### Operational checklist
|
### Operational checklist
|
||||||
- [ ] Confirm all namespaces exist for `en` and `vi`
|
- [ ] Confirm all namespaces exist for `en` and `vi`
|
||||||
- [ ] Confirm key parity across all locale JSON files
|
- [ ] Confirm key parity across all locale JSON files
|
||||||
- [ ] Confirm language switching works in collapsed and expanded sidebar modes
|
- [ ] Confirm language switching from Settings → General re-renders the whole app
|
||||||
- [ ] Confirm fallback behavior for region tags (e.g., `vi-VN`, `en-US`)
|
- [ ] Confirm fallback behavior for region tags (e.g., `vi-VN`, `en-US`)
|
||||||
- [ ] Confirm date/time/currency formatting for all supported languages
|
- [ ] Confirm date/time/currency formatting for all supported languages
|
||||||
- [ ] Confirm client tests pass before release
|
- [ ] Confirm client tests pass before release
|
||||||
|
|||||||
@@ -390,11 +390,11 @@ A missing source `.env` falls back to `.env.example` with a loud warning, never
|
|||||||
The dashboard web UI merges workspaces and runs into a single **Workspace** page accessible at `/run` (the old `/lanes` route now redirects here). Top to bottom:
|
The dashboard web UI merges workspaces and runs into a single **Workspace** page accessible at `/run` (the old `/lanes` route now redirects here). Top to bottom:
|
||||||
|
|
||||||
- **No page header.** The page title and the four counters (`lanes`, `running`, `needs you`, `dead`) are gone: the sidebar's workspace list shows every workspace with its own status dot, and the status bar at the bottom of every page counts running and need-you workspaces. `GET /api/lanes` still returns `counts` for other callers.
|
- **No page header.** The page title and the four counters (`lanes`, `running`, `needs you`, `dead`) are gone: the sidebar's workspace list shows every workspace with its own status dot, and the status bar at the bottom of every page counts running and need-you workspaces. `GET /api/lanes` still returns `counts` for other callers.
|
||||||
- **The sidebar's workspace list picks the workspace** — on every page, between the nav and the footer (`client/src/components/WorkspaceTree.tsx`), shaped like Claude Desktop's project list: every workspace on ONE level (no repo grouping, no worktree rows), in your stored order. A row (`WorkspaceRow.tsx`) is a chevron, a status dot (green = active, pulsing while running; amber row = needs you; red = dead or error; grey = idle), the title or folder name and the tool's icon, with `+` (open it for a new session) and `⋯` on hover. Clicking the name opens `/workspace?lane=<id>` and expands the row: the URL is the selection, so a notification link, F5 and the back button all land on the same workspace, and a bare `/workspace` reopens the last one shown (`agenthub.workspace.selected`) or the first. The header has a **tool filter** (shown once workspaces of two tools exist; kept in `agenthub.sidebar.projects` → `tool`, a stored tool no workspace has filters nothing), search and `+` (Add workspace). Search matches workspace names and paths — such a workspace keeps all its sessions — and session names: a workspace whose name does not match still shows when one of its sessions does, with only the matching sessions under it (every list stays mounted, hidden, to load and count). In the collapsed rail the list is a single icon that opens the sidebar.
|
- **The sidebar's workspace list picks the workspace** — on every page, below the nav (`client/src/components/WorkspaceTree.tsx`), shaped like Claude Desktop's project list: every workspace on ONE level (no repo grouping, no worktree rows), in your stored order. A row (`WorkspaceRow.tsx`) is a chevron, a status dot (green = active, pulsing while running; amber row = needs you; red = dead or error; grey = idle), the title or folder name and the tool's icon, with `+` (open it for a new session) and `⋯` on hover. Clicking the name opens `/workspace?lane=<id>` and expands the row: the URL is the selection, so a notification link, F5 and the back button all land on the same workspace, and a bare `/workspace` reopens the last one shown (`agenthub.workspace.selected`) or the first. The header has a **tool filter** (shown once workspaces of two tools exist; kept in `agenthub.sidebar.projects` → `tool`, a stored tool no workspace has filters nothing), search and `+` (Add workspace). Search matches workspace names and paths — such a workspace keeps all its sessions — and session names: a workspace whose name does not match still shows when one of its sessions does, with only the matching sessions under it (every list stays mounted, hidden, to load and count). In the collapsed rail the list is a single icon that opens the sidebar.
|
||||||
- **A workspace's sessions** — the chevron expands the row to its sessions (`WorkspaceSessionList.tsx`: `GET /api/sessions?cwd=&tool=`, the newest 20, re-read when the workspace's status or session moves; which rows are expanded is kept in `agenthub.sidebar.projects` → `expanded`). A filled dot marks a session that still has a live pane (a `GET /api/run` handle carrying its session id — every pane the dashboard starts carries `AGENTHUB_RUN_ID=<its tmux name>`, the hook handler forwards it as `run_id`, and `bindRunSession` in `server/routes/hooks.js` records the hook's session on that run: the first session always, afterwards — for a Claude pane — only a `SessionStart` that is not a fresh `startup` (`/clear`, `/resume`), so a `claude -p` the agent runs inside the pane never takes it over; the rule is Claude-only, and a Codex or Antigravity pane follows every new session): clicking it opens `/workspace?lane=<id>&run=<runId>`, which ATTACHES pane 0 to that tmux session (`attachRequest` on `LaneConsolePane`) — it starts nothing and resumes nothing. Any other session opens its read-only transcript at `/sessions/<id>`; resuming it is a separate, explicit action there, because a resume re-reads the whole transcript into context.
|
- **A workspace's sessions** — the chevron expands the row to the sessions the workspace OWNS and has not archived (`WorkspaceSessionList.tsx`: `GET /api/sessions?lane=<id>&archived=0`, the newest 20, re-read when the workspace's status or session moves; which rows are expanded is kept in `agenthub.sidebar.projects` → `expanded`). A workspace owns a session its pane held — `bindRunSession` stamps `sessions.lane_id` from the pane's run, first writer wins, a nested `claude -p` excluded — and lists nothing else: a `claude` typed into a terminal in the same folder, or history from before the workspace existed, belongs to no workspace and is found on the Sessions page. Sessions older than this ownership column are not guessed: only those `dashboard_runs` still named were backfilled. A session started by a tool with no hooks (Codex without its hooks, any catalog tool) is never stamped. A row's dot is the session's state (`sessionDot`: amber waiting for you, red failed, green a live pane, hollow at rest); double-click renames it (`PATCH {name}`), and its hover button **archives** it (`PATCH {archived: true}` — hidden from the workspace, nothing deleted; the home lists the archived ones folded with **Unarchive**). A filled dot marks a session that still has a live pane (a `GET /api/run` handle carrying its session id — every pane the dashboard starts carries `AGENTHUB_RUN_ID=<its tmux name>`, the hook handler forwards it as `run_id`, and `bindRunSession` in `server/routes/hooks.js` records the hook's session on that run: the first session always, afterwards — for a Claude pane — only a `SessionStart` that is not a fresh `startup` (`/clear`, `/resume`), so a `claude -p` the agent runs inside the pane never takes it over; the rule is Claude-only, and a Codex or Antigravity pane follows every new session): clicking it opens `/workspace?lane=<id>&run=<runId>`, which ATTACHES pane 0 to that tmux session (`attachRequest` on `LaneConsolePane`) — it starts nothing and resumes nothing. Ctrl/Cmd+click adds `&pane=1`: the session view attaches pane 1 instead, widening a one-pane layout to two once for that request. Any other session opens its read-only transcript at `/sessions/<id>`; resuming it is a separate, explicit action there, because a resume re-reads the whole transcript into context.
|
||||||
- **Row menu** — right-click a row, or its `⋯` button: Start/Stop (slot 0), Rename, the Model (AI Router) select, Create/update readme file (Claude only), New workspace here…, Copy path, Reset worktree… (managed only) and Delete workspace…, each the call the rest of the UI already makes. Delete is record-only exactly as before (see "Removing a workspace").
|
- **Row menu** — right-click a row, or its `⋯` button: Start/Stop (slot 0), Rename, the Model (AI Router) select, Create/update readme file (Claude only), New workspace here…, Copy path, Reset worktree… (managed only) and Delete workspace…, each the call the rest of the UI already makes. Delete is record-only exactly as before (see "Removing a workspace").
|
||||||
- **The workspace home** — `/workspace?lane=<id>` opens on the workspace's home (`client/src/components/lanes/WorkspaceHome.tsx`), shaped like a Claude Desktop project page: the name (rename from its `⋯` menu, which is the sidebar row menu) with the repository it belongs to under it (root, the main worktree's branch, the worktree count, from `GET /api/projects` — the facts Settings → Projects used to show), a prompt box with the tool's model picker (and Claude's permission mode) whose **Start** opens a NEW session in the first pane slot with no live run (0–3, `POST /api/lanes/:id/start {slot}`; all four busy is refused) and then shows it, and **Recents** — the workspace's sessions with their cost and time, a live one opening its pane and any other its read-only transcript, exactly like the sidebar. The transcript page (`/sessions/<id>`) of a Claude session started in a workspace's own folder carries an explicit **Resume** (`client/src/components/lanes/SessionResumeButton.tsx`; the session view's Sessions tab is the other place that resumes): it warns that a resume re-reads the whole transcript into context, re-reads the live panes, then starts it in the workspace's first free pane (`POST /api/lanes/:id/start {slot, resumeSessionId}`) and opens it. A session from a SUBFOLDER gets no Resume — the pane starts in the workspace's folder and Claude finds a session by the folder it ran in — and a session that still has a live pane shows **Open pane** instead. On the right sit the workspace's own context: **Instructions** (appended to every new session's system prompt), **Memory** (read-only: the folder's CLAUDE.md / AGENTS.md family and Claude's auto memory), **Context** (uploaded files, ≤ 10 MB each, stored by AgentHub) and **Folder** (the workspace's own folder plus extra ones, with the repo's untracked worktrees suggested). Only Claude and Codex panes can take them (`--add-dir`, `--append-system-prompt-file=` / `-c developer_instructions=`); for any other tool the page says so and its panes start without them. See `docs/API.md` → Workspace context. The home HIDES the session view with CSS — the console never unmounts behind it.
|
- **The workspace home** — `/workspace?lane=<id>` opens on the workspace's home (`client/src/components/lanes/WorkspaceHome.tsx`), shaped like a Claude Desktop project page: the name (rename from its `⋯` menu, which is the sidebar row menu) with the repository it belongs to under it (root, the main worktree's branch, the worktree count, from `GET /api/projects` — the facts Settings → Projects used to show), a prompt box with the tool's model picker (and Claude's permission mode) whose **Start** opens a NEW session in the first pane slot with no live run (0–3, `POST /api/lanes/:id/start {slot}`; all four busy is refused) and then shows it, and **Recents** — the workspace's own sessions (`?lane=<id>`) with their cost and time, a live one opening its pane and any other its read-only transcript, exactly like the sidebar. The transcript page (`/sessions/<id>`) of a Claude session started in a workspace's own folder carries an explicit **Resume** (`client/src/components/lanes/SessionResumeButton.tsx`, the one place that resumes) and beside it **Fork**, which resumes the history into a NEW session id (`{forkSession: true}` → `--fork-session`, the original untouched, offered even while the original has a live pane): it warns that a resume re-reads the whole transcript into context, re-reads the live panes, then starts it in the workspace's first free pane (`POST /api/lanes/:id/start {slot, resumeSessionId}`) and opens it. A session from a SUBFOLDER gets no Resume — the pane starts in the workspace's folder and Claude finds a session by the folder it ran in — and a session that still has a live pane shows **Open pane** instead. On the right sit the workspace's own context: **Instructions** (appended to every new session's system prompt), **Memory** (read-only: the folder's CLAUDE.md / AGENTS.md family and Claude's auto memory), **Context** (uploaded files, ≤ 10 MB each, stored by AgentHub) and **Folder** (the workspace's own folder plus extra ones, with the repo's untracked worktrees suggested). Only Claude and Codex panes can take them (`--add-dir`, `--append-system-prompt-file=` / `-c developer_instructions=`); for any other tool the page says so and its panes start without them. See `docs/API.md` → Workspace context. The home HIDES the session view with CSS — the console never unmounts behind it.
|
||||||
- **The session view** — `/workspace?lane=<id>&run=<runId>` (a live session picked in the sidebar or Recents, or one just started) shows today's console, split panes and the Files / Changes / Sessions panel, with pane 0 attached to that run; the header's back arrow returns to the home. A browser notification for a workspace with a live pane opens it here directly.
|
- **The session view** — `/workspace?lane=<id>&run=<runId>` (a live session picked in the sidebar or Recents, or one just started) shows today's console, split panes and the Files / Changes panel, with pane 0 attached to that run; the header's back arrow returns to the home. A browser notification for a workspace with a live pane opens it here directly.
|
||||||
- **Untracked worktrees** are no longer listed in the sidebar; Settings → Git → **Worktrees** (`showUntracked`, hidden sources `claude` = `<repo>/.claude/worktrees/*` and `agenthub` = `LANES_ROOT`) now decides which of them the home's Add folder suggests.
|
- **Untracked worktrees** are no longer listed in the sidebar; Settings → Git → **Worktrees** (`showUntracked`, hidden sources `claude` = `<repo>/.claude/worktrees/*` and `agenthub` = `LANES_ROOT`) now decides which of them the home's Add folder suggests.
|
||||||
- **No workspaces yet** — the page shows a single banner (`lane-empty`): icon, one line of what a workspace is, the CLI equivalent (`agenthub lanes add --cwd $(pwd)`) and an **Add workspace** affordance. The whole banner IS the button — clicking anywhere on it opens the Add-workspace modal. The banner stays compact on purpose: the run setup form below it is what needs the height.
|
- **No workspaces yet** — the page shows a single banner (`lane-empty`): icon, one line of what a workspace is, the CLI equivalent (`agenthub lanes add --cwd $(pwd)`) and an **Add workspace** affordance. The whole banner IS the button — clicking anywhere on it opens the Add-workspace modal. The banner stays compact on purpose: the run setup form below it is what needs the height.
|
||||||
- **Reordering** — drag a row onto another, or press **Alt+↑/↓** with a row focused. The order is a **per-viewer preference**, not a workspace fact: it persists to `localStorage` (`agenthub.workspace.laneOrder`) and writes nothing server-side — `GET /api/lanes` and `agenthub lanes` keep answering in id order, and the order does not follow you to another browser. A workspace the stored order has never seen lands at the END; a stored id whose workspace is gone is skipped.
|
- **Reordering** — drag a row onto another, or press **Alt+↑/↓** with a row focused. The order is a **per-viewer preference**, not a workspace fact: it persists to `localStorage` (`agenthub.workspace.laneOrder`) and writes nothing server-side — `GET /api/lanes` and `agenthub lanes` keep answering in id order, and the order does not follow you to another browser. A workspace the stored order has never seen lands at the END; a stored id whose workspace is gone is skipped.
|
||||||
@@ -621,15 +621,11 @@ a setting — and one button beats a width button wedged beside a collapse butto
|
|||||||
in the panel's tightest corner. It is icon-only, and the icon it draws is the
|
in the panel's tightest corner. It is icon-only, and the icon it draws is the
|
||||||
width the next click LANDS on (even split → wider → closed), with the wording
|
width the next click LANDS on (even split → wider → closed), with the wording
|
||||||
left to its tooltip. Beside it, along the panel's top edge, an **activity bar**
|
left to its tooltip. Beside it, along the panel's top edge, an **activity bar**
|
||||||
of three icon buttons picks what the panel shows (`agenthub.workspace.panelTab`):
|
of two icon buttons picks what the panel shows (`agenthub.workspace.panelTab`):
|
||||||
**Files** (the browse mode below, on its own — no git header, no mode
|
**Files** (the browse mode below, on its own — no git header, no mode
|
||||||
switcher), **Changes** (the git header and the other four modes) and
|
switcher) and **Changes** (the git header and the other four modes). The
|
||||||
**Sessions** — the workspace's sessions (`GET /api/sessions?cwd=<cwd>&tool=<tool>`,
|
workspace's sessions live in the sidebar and on its home only — the panel's
|
||||||
newest 50; an exact cwd match, so a session started in a subdirectory is not
|
former Sessions tab was a third copy of the same list. At Full screen the Files and Changes tabs show the same thing: the
|
||||||
listed) with tool badge, title, model, relative time and cost, a filter box,
|
|
||||||
**Open** (its detail page) and, on a Claude workspace, **Resume**, which resumes
|
|
||||||
it in the console's slot-0 pane through the same path as a resume from the run
|
|
||||||
history. At Full screen the Files and Changes tabs show the same thing: the
|
|
||||||
explorer sidebar beside the changes. It
|
explorer sidebar beside the changes. It
|
||||||
reads the workspace's git surface in five modes:
|
reads the workspace's git surface in five modes:
|
||||||
|
|
||||||
@@ -678,6 +674,12 @@ reads the workspace's git surface in five modes:
|
|||||||
(`discard-hunk` — asks twice, like discard) and **Keep** (`stage-hunk`),
|
(`discard-hunk` — asks twice, like discard) and **Keep** (`stage-hunk`),
|
||||||
both sent with the hunk's `@@` line plus the block's index, so they touch
|
both sent with the hunk's `@@` line plus the block's index, so they touch
|
||||||
that block alone. The single-file view offers the same bar.
|
that block alone. The single-file view offers the same bar.
|
||||||
|
A new line's NUMBER is a button too: it opens a comment box under the diff,
|
||||||
|
and **Paste into pane** pastes `<path>:<line> — <text>` into this
|
||||||
|
workspace's pane focused last (`client/src/lib/panePaste.ts`, the same
|
||||||
|
bracketed `term.paste()` an image drop uses) — nothing is sent until the
|
||||||
|
user presses Enter in the pane; with no pane of the workspace open the box
|
||||||
|
says so and keeps the text.
|
||||||
- **One file** — clicking a file's path in any list opens just that file.
|
- **One file** — clicking a file's path in any list opens just that file.
|
||||||
**All files** returns to the list it was opened from — the same mode, at the
|
**All files** returns to the list it was opened from — the same mode, at the
|
||||||
same scroll offset — because reading a change-set is a walk down a long list
|
same scroll offset — because reading a change-set is a walk down a long list
|
||||||
@@ -1717,7 +1719,7 @@ agenthub stage <stage> [--evidence "..."] # Report the current stage
|
|||||||
|
|
||||||
## Workspaces on any AI tool
|
## Workspaces on any AI tool
|
||||||
|
|
||||||
A workspace can run any AI coding CLI in the AI Tools catalog (`server/data/ai-tools/*.json`, plus any `*.json` in `DASHBOARD_AI_TOOLS_DIR`, which replaces a built-in file with the same `id` but may not set `launcher`). Settings → Agents → **AI tools** (`/settings/agents`; the old `/settings#ai-tools` redirects there) lists what is installed (found on PATH; **Refresh** re-reads the login shell's PATH first) with an enable switch, **Set default** and **Update** (for every tool whose catalog file names an updater — no version lookup, the updater itself reports "up to date"), and, folded behind a disclosure, what is available to install with a homepage link. The Add-workspace modal shows the chosen tool as one button (the default until changed); clicking it lists every installed, enabled tool as buttons to pick from; `agenthub lanes add --tool <id>` does the same from a terminal. The run form's **Model** picker lists the workspace tool's own models (`GET /api/ai-tools/:id/models`; Claude, Codex, and any catalog tool whose file declares `model: { flag }` — the id is validated and passed as `<flag> <id>`), plus **Custom…**, and starts on the tool's default model when one is set.
|
A workspace can run any AI coding CLI in the AI Tools catalog (`server/data/ai-tools/*.json` — ten built in: Claude Code, Codex, Gemini CLI, Antigravity, GitHub Copilot CLI, OpenCode, Cursor CLI (`cursor-agent`), Oh My Pi, Hermes and OpenClaw —, plus any `*.json` in `DASHBOARD_AI_TOOLS_DIR`, which replaces a built-in file with the same `id` but may not set `launcher`). Settings → Agents → **AI tools** (`/settings/agents`; the old `/settings#ai-tools` redirects there) lists what is installed (found on PATH; **Refresh** re-reads the login shell's PATH first) with an enable switch, **Set default** and **Update** (for every tool whose catalog file names an updater — no version lookup, the updater itself reports "up to date"), and, folded behind a disclosure, what is available to install with a homepage link. The Add-workspace modal shows the chosen tool as one button (the default until changed); clicking it lists every installed, enabled tool as buttons to pick from; `agenthub lanes add --tool <id>` does the same from a terminal. The run form's **Model** picker lists the workspace tool's own models (`GET /api/ai-tools/:id/models`; Claude, Codex, and any catalog tool whose file declares `model: { flag }` — the id is validated and passed as `<flag> <id>`), plus **Custom…**, and starts on the tool's default model when one is set.
|
||||||
|
|
||||||
A catalog file:
|
A catalog file:
|
||||||
|
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ recorded install first (`supersededBy(oldRoot)`), falling back to the newest
|
|||||||
sibling directory only when there is no record.
|
sibling directory only when there is no record.
|
||||||
|
|
||||||
Separately, a plugin install whose release lookup failed (`release_check` not
|
Separately, a plugin install whose release lookup failed (`release_check` not
|
||||||
`ok`) now reads **Could not check for updates** in the sidebar instead of
|
`ok`) now reads **Could not check for updates** in the connection details modal (opened from the status bar) instead of
|
||||||
**Up to date** — a plugin install has no commit count to fall back on.
|
**Up to date** — a plugin install has no commit count to fall back on.
|
||||||
|
|
||||||
To hand over by hand without the dashboard:
|
To hand over by hand without the dashboard:
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
# Desktop parity — what the workspace still lacks next to Claude Desktop / Codex app
|
||||||
|
|
||||||
|
_Status: written 2026-10-02. Phases 1–5 and 7 implemented 2026-10-02
|
||||||
|
(uncommitted); phase 6 waits on decision D4. Phases run in order, each one change-set on `main`.
|
||||||
|
Comes BEFORE `docs/plans/multi-user-server/` — that plan's owner column hangs
|
||||||
|
off the session ownership built in phase 1 here._
|
||||||
|
|
||||||
|
## Where we stand
|
||||||
|
|
||||||
|
The workspace-sessions plan gave AgentHub Claude Desktop's *surface*: a sidebar
|
||||||
|
of workspaces that expand to sessions, a project home with a prompt box,
|
||||||
|
Recents, Instructions / Memory / Context / Folder. Its *data model* is still
|
||||||
|
"a workspace is a folder":
|
||||||
|
|
||||||
|
- Every session list (sidebar `WorkspaceSessionList.tsx`, home Recents in
|
||||||
|
`WorkspaceHome.tsx`, the activity bar's `WorkspaceSessionsTab.tsx`) asks
|
||||||
|
`GET /api/sessions?cwd=<lane.cwd>&tool=`, which filters `s.cwd = ?` exactly.
|
||||||
|
A session typed into a terminal in that folder, or one from months before the
|
||||||
|
workspace existed, is listed as the workspace's own; a session that `cd`'d
|
||||||
|
into a subfolder is not.
|
||||||
|
- Nothing durable records which sessions a workspace started.
|
||||||
|
`dashboard_runs` has `lane_id`, but its key is the pane name
|
||||||
|
(`agenthub-lane-<id>[-slot]`) and `server/lib/dashboard-runs.js` writes it
|
||||||
|
with `INSERT OR REPLACE`, so each Start overwrites the last; `bindRunSession`
|
||||||
|
(`server/routes/hooks.js`) keeps only the pane's CURRENT session, so a
|
||||||
|
`/clear` forgets the previous one.
|
||||||
|
|
||||||
|
Claude Desktop (Code tab) and the Codex app both treat a project as the owner
|
||||||
|
of the sessions/threads started in it, show each one's state, let one be
|
||||||
|
archived, and give a session — not a project — its own worktree.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
| # | Decision | Value |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| D1 | Sessions in the folder that the workspace did not start | **Not listed** (user's call, 2026-10-02): a workspace shows only its own sessions; the rest stay on the Sessions page. The fold and `?unowned=` / `PATCH {lane_id}` built first were removed again. |
|
||||||
|
| D2 | What "belongs to a workspace" means | `sessions.lane_id` = the lane whose pane held the session (stamped from the hook's `run_id`), or one the user added by hand. First writer wins; only the user moves it. |
|
||||||
|
| D3 | Old history | Not guessed. Only the sessions `dashboard_runs` still names are backfilled; everything older is not listed in any workspace. |
|
||||||
|
| D4 | Per-session worktree (phase 6) | A **child workspace**: a worktree lane with `parent_lane_id`, listed under its parent in the sidebar. Reuses every lane rule (git surface, destroy guard) instead of giving a session its own cwd. Confirm with the user before phase 6. |
|
||||||
|
| D5 | Tool chosen per session instead of per workspace | **Out of scope here.** `lanes.tool` stays immutable. Revisit with the user after phase 6. |
|
||||||
|
| D6 | Environments (SSH/cloud), Routines/Automations, cross-session messaging | **Not built.** AgentHub runs locally and does not orchestrate (`CLAUDE.md`). |
|
||||||
|
|
||||||
|
## Phases
|
||||||
|
|
||||||
|
| # | Scope | Size |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | Session ownership: `sessions.lane_id`, `GET /api/sessions?lane=`, the "other sessions" fold, **Add to workspace** | medium |
|
||||||
|
| 2 | A session row's state (running / needs you / error), rename, archive | medium |
|
||||||
|
| 3 | Drop the activity bar's **Sessions** tab (third copy of the same list) | small |
|
||||||
|
| 4 | **Fork** a Claude session into a free pane (`--resume <id> --fork-session`) | small |
|
||||||
|
| 5 | Ctrl/Cmd+click a live session in the sidebar opens it in a second pane | small |
|
||||||
|
| 6 | Worktree per session = child workspace (D4) — **ask first** | large |
|
||||||
|
| 7 | Comment on a diff line → text pasted into the session's prompt | medium, optional |
|
||||||
|
|
||||||
|
### Phase 1 — session ownership
|
||||||
|
|
||||||
|
Schema (`server/db.js`, probe-then-ALTER like every other column, inside the
|
||||||
|
migration lock):
|
||||||
|
- `sessions.lane_id INTEGER` (nullable, no FK — a removed lane leaves its
|
||||||
|
sessions, which are the user's history) + `idx_sessions_lane`.
|
||||||
|
- Backfill once: `UPDATE sessions SET lane_id = r.lane_id FROM dashboard_runs r
|
||||||
|
WHERE r.session_id = sessions.id AND sessions.lane_id IS NULL AND r.lane_id IS NOT NULL`.
|
||||||
|
|
||||||
|
Stamping (`server/routes/hooks.js`):
|
||||||
|
- `bindRunSession` already decides when a pane HOLDS a session (a nested
|
||||||
|
`claude -p` the agent runs through Bash inherits `AGENTHUB_RUN_ID` but does
|
||||||
|
not take the pane over). Whenever the run's session is — or becomes — this
|
||||||
|
session, stamp `sessions.lane_id = dashboard_runs.lane_id` where it is null.
|
||||||
|
- A session started from the dashboard with no hooks (Codex without hooks, any
|
||||||
|
catalog tool) is not stamped, so no workspace lists it. Known limit.
|
||||||
|
|
||||||
|
API (`server/routes/sessions.js`):
|
||||||
|
- `GET /api/sessions?lane=<id>` → `s.lane_id = ?`;
|
||||||
|
`?unowned=1` → `s.lane_id IS NULL` (combined with `cwd`+`tool` for the fold).
|
||||||
|
- `PATCH /api/sessions/:id {lane_id}` (number or `null`), `sameOriginGuard`:
|
||||||
|
the lane must exist and have the session's `tool` (`EBADLANE`).
|
||||||
|
Existing `name`/`status` patching is unchanged.
|
||||||
|
|
||||||
|
Client:
|
||||||
|
- `WorkspaceSessionList` and home Recents list `?lane=<id>`; below them a folded
|
||||||
|
"Other sessions in this folder (N)" (`?cwd=&tool=&unowned=1`), each row with
|
||||||
|
**Add to workspace**.
|
||||||
|
- `SessionResumeButton`/`owningLane` unchanged (resume still needs the folder).
|
||||||
|
|
||||||
|
Tests first: server — a hook carrying `run_id` stamps the lane, a nested
|
||||||
|
`claude -p` session does not, the backfill, `?lane=`, `?unowned=1`, PATCH
|
||||||
|
validation. Client — the list asks `lane=`, the fold, Add to workspace.
|
||||||
|
|
||||||
|
### Phase 2 — row state, rename, archive
|
||||||
|
|
||||||
|
- Row dot: live pane (green), the session `awaiting_input_since` set (amber,
|
||||||
|
"needs you"), `status = 'error'` (red), else hollow. Re-read when the lane's
|
||||||
|
`needs_action` moves too.
|
||||||
|
- Rename: double-click a session row → `PATCH /api/sessions/:id {name}`.
|
||||||
|
`syncSessionName` already lets only a Claude `/rename` (custom title)
|
||||||
|
override a non-placeholder name.
|
||||||
|
- Archive: `sessions.archived_at TEXT`; `PATCH {archived: true|false}`; lists
|
||||||
|
for a workspace skip archived rows unless `?archived=1`; the row's hover
|
||||||
|
shows an archive button, the home Recents a "Show archived" toggle.
|
||||||
|
Archiving deletes nothing.
|
||||||
|
|
||||||
|
### Phase 3 — one session list fewer
|
||||||
|
|
||||||
|
`WorkspaceSessionsTab.tsx` and the `sessions` activity-bar tab go; a stored
|
||||||
|
`agenthub.workspace.panelTab = "sessions"` falls back to `changes`. Resume stays
|
||||||
|
on the transcript page (`SessionResumeButton`). `CLAUDE.md`'s "Opening a
|
||||||
|
session never resumes it" rule loses the Sessions-tab mention.
|
||||||
|
|
||||||
|
### Phase 4 — fork
|
||||||
|
|
||||||
|
- `buildClaudeArgv` gains `forkSession` → `--fork-session` after `--resume <id>`
|
||||||
|
(Claude only; refused elsewhere, `EUNSUPPORTED`). `POST /api/lanes/:id/start`
|
||||||
|
passes `forkSession: true` through like `resumeSessionId`.
|
||||||
|
- Transcript page: **Fork** beside **Resume**, same warning, same first-free-slot
|
||||||
|
logic (`freeSlot`). The new session id arrives by hook and is stamped to the
|
||||||
|
workspace by phase 1.
|
||||||
|
|
||||||
|
### Phase 5 — open in a second pane
|
||||||
|
|
||||||
|
Ctrl/Cmd+click on a live session row → `/workspace?lane=<id>&run=<runId>&pane=1`;
|
||||||
|
`Workspace.tsx` switches a 1-pane layout to 2 and hands `attachRequest` to that
|
||||||
|
pane instead of pane 0. A plain click is unchanged.
|
||||||
|
|
||||||
|
### Phase 6 — worktree per session (needs D4 confirmed)
|
||||||
|
|
||||||
|
- `lanes.parent_lane_id INTEGER` (nullable).
|
||||||
|
- Home prompt box: a **Worktree** toggle (git repos only). Run → `POST
|
||||||
|
/api/lanes/worktree` with a generated branch + `parent_lane_id`, then start the
|
||||||
|
prompt in the child's slot 0.
|
||||||
|
- Sidebar: a child lane is listed under its parent's sessions, not as a
|
||||||
|
top-level row. Archive on it = the existing Remove (record only — the
|
||||||
|
worktree stays the user's, per the remove rule) plus a hint to delete it.
|
||||||
|
|
||||||
|
### Phase 7 — diff comment to prompt (optional)
|
||||||
|
|
||||||
|
A **Comment** action on a diff line in `LaneDiffView.tsx` builds
|
||||||
|
`<path>:<line> — <text>` and pastes it into the pane the Changes panel belongs
|
||||||
|
to, through the same client-side `term.paste()` path the image drop uses. No
|
||||||
|
server route.
|
||||||
|
|
||||||
|
## Ground rules
|
||||||
|
|
||||||
|
`CLAUDE.md` binds every phase: one console render site, opening a session never
|
||||||
|
resumes it, migrations probe-then-ALTER under the lock, headers on every
|
||||||
|
touched file, `npm run test:server` + `npm run test:client` (snapshots
|
||||||
|
regenerated only after reviewing the diff), docs + the `CLAUDE.md` marker in the
|
||||||
|
same change-set.
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Phase 0 — Call lanes "workspaces" in the UI and docs
|
||||||
|
|
||||||
|
_One PR. No behaviour change, no identifier change. Every test that passed
|
||||||
|
before passes after; only copy and snapshots differ._
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
The **word** a user reads changes: "Lane" → "Workspace", "Lanes" →
|
||||||
|
"Workspaces", "Add lane" → "Add workspace", "lane strip" → "workspace list",
|
||||||
|
and so on — in the dashboard UI (both languages), in the docs, in CLI *help
|
||||||
|
and output text*, and in the plugin's skill/command prose.
|
||||||
|
|
||||||
|
## What does NOT change
|
||||||
|
|
||||||
|
Everything that is an identifier stays exactly as it is:
|
||||||
|
|
||||||
|
- API paths and payload keys: `/api/lanes`, `{ lanes, counts }`, `lane_id`, …
|
||||||
|
- WebSocket message types `lane` / `lanes`.
|
||||||
|
- CLI commands and flags: `agenthub lanes add|up|down|…`, `agenthub stage --lane 3`.
|
||||||
|
- Env vars: `AGENTHUB_LANE_ID`, `LANES_ROOT`, `LANE_DEAD_SEC`, …
|
||||||
|
- Error codes (`EBADLANE`, …), tmux session names `agenthub-lane-<id>`.
|
||||||
|
- SQLite tables/columns (`lanes`, `lane_features`, `lane_stage_events`).
|
||||||
|
- Source file, component, hook, type and function names (`LaneCard.tsx`,
|
||||||
|
`LaneChangesPanel`, `useLaneGitFacts`, `listLanes`, `Lane` type, …),
|
||||||
|
`data-testid` values, localStorage keys, i18n **namespace file name**
|
||||||
|
`lanes.json` and its **keys**.
|
||||||
|
|
||||||
|
Rationale: a rename of identifiers touches ~200 files and every running
|
||||||
|
install's tmux panes, hooks and bookmarks, for no user-visible gain. The
|
||||||
|
user-visible gain is the word, so only the word changes.
|
||||||
|
|
||||||
|
## Where the word changes
|
||||||
|
|
||||||
|
| Surface | Action |
|
||||||
|
| --- | --- |
|
||||||
|
| `client/src/i18n/locales/en/*.json`, `…/vi/*.json` | Replace the English word in **values** (not keys): `"Lane"→"Workspace"`, `"lane"→"workspace"`, `"Lanes"→"Workspaces"`, plural/possessive forms. Vietnamese values: "lane"/"làn" → "workspace" (the Vietnamese UI already uses the English loanword "Workspace" in `nav.json`; keep that). Check `lanes.json`, `nav.json`, `settings.json` (`lanesSection.title` = "Workspaces"), `run.json`, `dashboard.json`, `kanban.json`, `errors.json`, `splash.json`. |
|
||||||
|
| Hardcoded UI strings | `grep -rn -i '\blanes\?\b' client/src --include=*.tsx` — any string literal rendered to the user that escaped i18n is moved into i18n with the new word (do not leave it hardcoded). Code comments and identifiers are left alone. |
|
||||||
|
| CLI output and help (`bin/agenthub.js`) | Prose in `console.log`/`console.error`/usage strings: "lane" → "workspace" in the sentence, while the command names in those same sentences stay literal: e.g. `no workspace owns <cwd> — create one with: agenthub lanes add --cwd <cwd>`. |
|
||||||
|
| Docs | `docs/LANES.md`: keep the file name (it is linked from many places and from `CLAUDE.md`), retitle to "Workspaces (lanes)" and open with one paragraph: *"A workspace — called a lane in the API, CLI, database and code — is …"*. Then use "workspace" in prose throughout, keeping every literal command/route/field as is. Same treatment in `README.md`, `docs/API.md`, `docs/CLI.md`, `docs/DATABASE.md`, `docs/HOOKS.md`, `client/README.md`, `server/README.md`, `docs/PLUGINS.md`. |
|
||||||
|
| `CLAUDE.md` | The "## Lanes" heading becomes "## Workspaces (lanes)"; the first bullet gains the one-sentence glossary above; every rule keeps its literal identifiers. Bump the sync marker. |
|
||||||
|
| Plugin skills/commands (`skills/*`, `plugins/agenthub/commands/*`) | Prose only. |
|
||||||
|
| Server-side user-facing text | Error `message` strings that reach the UI (e.g. `"lane not found"`): change the word, keep the `code`. Log lines may stay. |
|
||||||
|
|
||||||
|
## Order of work
|
||||||
|
|
||||||
|
1. i18n values (en, vi) + any escaped literals → `npm run test:client`, review
|
||||||
|
the snapshot diff (copy only), regenerate.
|
||||||
|
2. CLI strings → `npm run test:server` (`lanes-cli.test.js` asserts some
|
||||||
|
output text; update those assertions).
|
||||||
|
3. Server error messages → server suite.
|
||||||
|
4. Docs + `CLAUDE.md` + skills prose.
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
- Opening every page of the dashboard in EN and VI shows no "lane" (walk:
|
||||||
|
Dashboard, Sessions, Workflows, Workspace incl. Add modal / destructive
|
||||||
|
modals / Changes panel / run form, Settings incl. every tool tab).
|
||||||
|
- `agenthub lanes` (no args) and `agenthub help` print "workspace" in prose
|
||||||
|
and `lanes` as the command.
|
||||||
|
- `git diff --stat` shows no renamed files, no changes to `server/routes/`
|
||||||
|
paths, `client/src/lib/api.ts` or `client/src/lib/types.ts`.
|
||||||
|
- Headers check, both suites, snapshots regenerated once.
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
# Phase 1 — Project tree in the sidebar, workbench, right panel, status bar
|
||||||
|
|
||||||
|
_One PR, after phase 0. New UI text says "workspace"; identifiers stay `lane`._
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
The `/workspace` page stops being "a strip of tiles above a console" and becomes
|
||||||
|
a three-column workbench: **sidebar** (nav + project → workspace tree),
|
||||||
|
**center** (the selected workspace's panes), **right panel** (Files · Changes ·
|
||||||
|
Sessions). Picking a workspace is a sidebar click; creating one is a `+` on its
|
||||||
|
project row. Reference: `reference/orca-project.png`, `reference/orca-ws2.png`,
|
||||||
|
`reference/orca-git.png`.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- No theme change. Use existing tokens (`surface-*`, `border`, `accent`,
|
||||||
|
`status-*`, `fg-*`) and existing primitives (`Tooltip` via `title=`,
|
||||||
|
`ConfirmModal`, `Select`, `AiToolIcon`, `StatusBadge`).
|
||||||
|
- No pipeline changes. Stage/feature/PipelineMap UI stays exactly where it is
|
||||||
|
today for a workspace whose pipeline has nodes; it is hidden otherwise (as
|
||||||
|
now).
|
||||||
|
- No orchestration, no kanban, no parent/child worktree lineage.
|
||||||
|
- No provider quota in the status bar.
|
||||||
|
|
||||||
|
## 1. Data: projects
|
||||||
|
|
||||||
|
### Server
|
||||||
|
|
||||||
|
`server/lib/projects.js` (new):
|
||||||
|
|
||||||
|
```js
|
||||||
|
/** Root of the repository a directory belongs to: the MAIN worktree's
|
||||||
|
* top level (realpath), or null when the directory is not inside a git repo. */
|
||||||
|
projectRootFor(cwd) -> Promise<string|null>
|
||||||
|
```
|
||||||
|
|
||||||
|
Implementation: `git -C <cwd> rev-parse --path-format=absolute --git-common-dir`
|
||||||
|
(`execFile`, never a shell string), then `dirname` of that, `fs.realpathSync`.
|
||||||
|
For a bare/odd layout where the common dir does not end in `/.git`, fall back to
|
||||||
|
`git rev-parse --show-toplevel` of the common dir's parent. Any git error ⇒
|
||||||
|
`null`.
|
||||||
|
|
||||||
|
New nullable column `lanes.project_root TEXT` (add in `server/db.js` with the
|
||||||
|
existing probe-then-ALTER pattern under the migrate lock; index
|
||||||
|
`idx_lanes_project_root`). Written by:
|
||||||
|
|
||||||
|
- `createLane` (both branches of `POST /api/lanes/ensure`, `POST /…/worktree`, CLI `lanes add`) — computed once at creation.
|
||||||
|
- A one-time backfill at server start for rows with `project_root IS NULL`
|
||||||
|
(same place as the other `user_version` repairs; bump `user_version`).
|
||||||
|
- `POST /api/lanes/:id/refresh-project` — recompute on demand (the
|
||||||
|
sidebar's row menu "Re-detect project"). Never on the polled list.
|
||||||
|
|
||||||
|
Why stored, not computed: `GET /api/lanes` is polled and re-broadcast on
|
||||||
|
every hook; `CLAUDE.md` forbids shelling out there.
|
||||||
|
|
||||||
|
`GET /api/projects` (new route file `server/routes/projects.js`):
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"projects": [
|
||||||
|
{
|
||||||
|
"root": "/home/u/agenthub", // null for the "Folders" pseudo-project
|
||||||
|
"name": "agenthub", // basename(root); overridable later
|
||||||
|
"workspaces": [12, 15], // lane ids, in id order
|
||||||
|
"worktrees": [ // from `git worktree list --porcelain` on root
|
||||||
|
{ "path": "/home/u/agenthub", "branch": "main", "head": "e01cb26", "main": true, "workspace_id": 12 },
|
||||||
|
{ "path": "/tmp/ah-release", "branch": "ah-release-1823", "head": "351afb2", "main": false, "workspace_id": null },
|
||||||
|
{ "path": "/home/u/agenthub/.claude/worktrees/wf_…-1", "branch": "wf-22-macos", "head": "…", "main": false, "workspace_id": null, "source": "claude" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ "root": null, "name": "Folders", "workspaces": [3], "worktrees": [] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `workspace_id` is the lane whose `cwd` equals the worktree path (realpath
|
||||||
|
compare), else null. A lane on a *subdirectory* of a worktree appears in
|
||||||
|
`workspaces` but marks no worktree.
|
||||||
|
- `source` is `"claude"` when the path is under `<root>/.claude/worktrees/`,
|
||||||
|
`"agenthub"` when under `LANES_ROOT`, else absent. The client uses it for the
|
||||||
|
visibility toggles.
|
||||||
|
- Worktree lists are read through the existing `listWorktrees` in
|
||||||
|
`server/lib/worktree.js`, cached per root for 30 s (in-memory `Map`,
|
||||||
|
invalidated by any lane create/remove and by `POST /api/projects/refresh`).
|
||||||
|
Shelling out happens here, on an endpoint the client fetches on demand, not on
|
||||||
|
the lane list.
|
||||||
|
- Order: projects by name, the `Folders` pseudo-project last.
|
||||||
|
|
||||||
|
### Client
|
||||||
|
|
||||||
|
`client/src/hooks/useProjects.ts`: fetches `/api/projects` on mount, on every
|
||||||
|
`lane`/`lanes` websocket message (debounced 500 ms), and on a manual refresh.
|
||||||
|
Joins with the already-polled workspace list (`useWorkspaces` or whatever the
|
||||||
|
page uses today) so each sidebar row has the live `status`, `liveness`,
|
||||||
|
`needs_action`, `tool`, `branch`, `title`.
|
||||||
|
|
||||||
|
## 2. Sidebar
|
||||||
|
|
||||||
|
`client/src/components/Sidebar.tsx` today: brand, 5 `NavLink`s, language,
|
||||||
|
theme, connection dot, collapse toggle. Keep all of it; insert the tree between
|
||||||
|
the nav and the footer.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────┐
|
||||||
|
│ [SmartGift] AgentHub │ brand (unchanged)
|
||||||
|
│ ▣ Dashboard │
|
||||||
|
│ ▤ Sessions │ NAV_KEYS unchanged (Dashboard, Sessions,
|
||||||
|
│ ⟳ Workflows │ Workflows, Workspace, Settings)
|
||||||
|
│ ▶ Workspace │
|
||||||
|
│ ──────────────────────────── │
|
||||||
|
│ PROJECTS ⟳ + │ 11px uppercase, tracking-wide, fg-muted
|
||||||
|
│ ▾ ◈ agenthub + ⋯ │ project row: chevron, folder icon, name, "+ workspace", menu
|
||||||
|
│ ● main primary ✳ │ workspace row (see below)
|
||||||
|
│ ◐ ah-release-1823 ⌘ │
|
||||||
|
│ ○ wf-22-macos adopt │ untracked worktree row (dimmed)
|
||||||
|
│ ▸ ◈ other-repo │
|
||||||
|
│ ▸ ▭ Folders │
|
||||||
|
│ ──────────────────────────── │
|
||||||
|
│ EN · ◐ · ● ‹ │ footer (unchanged)
|
||||||
|
└──────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- The tree renders only when the current route is `/workspace`? **No** — it
|
||||||
|
renders on every page; clicking a workspace row navigates to
|
||||||
|
`/workspace?ws=<id>`. This is what makes the sidebar the one place to pick a
|
||||||
|
workspace.
|
||||||
|
- Collapsed sidebar (rail, 4.25rem): the tree collapses to one icon
|
||||||
|
(`FolderTree` from lucide) that expands the sidebar on click. No tree in the
|
||||||
|
rail.
|
||||||
|
- Width: keep `15rem` fixed in this phase. (Resizable sidebar is a follow-up;
|
||||||
|
Orca does 220–500 px.)
|
||||||
|
- Scroll: the tree is the only scrolling region (`overflow-y-auto min-h-0`);
|
||||||
|
nav and footer stay pinned.
|
||||||
|
- Persisted UI state (localStorage, one key `agenthub.sidebar.projects`):
|
||||||
|
`{ collapsed: { [root]: boolean }, showUntracked: boolean, hideSources: string[] }`.
|
||||||
|
Defaults: nothing collapsed, `showUntracked: true`, `hideSources: []`.
|
||||||
|
(Phase 2 moves the toggles into Settings; the key stays.)
|
||||||
|
|
||||||
|
### Workspace row
|
||||||
|
|
||||||
|
`client/src/components/lanes/WorkspaceRow.tsx` (replaces
|
||||||
|
`LaneStripCard.tsx`, which is deleted with its test):
|
||||||
|
|
||||||
|
- Left: status dot, 8 px. Map: `status === "running"` ⇒ `bg-status-success`
|
||||||
|
pulsing (`animate-pulse-slow`); `needs_action` ⇒ `bg-status-warning` and the
|
||||||
|
row's background `bg-status-warning/10`; `liveness === "dead"` ⇒
|
||||||
|
`bg-status-danger`; `error` ⇒ `bg-status-danger`; else `bg-surface-4`.
|
||||||
|
Exactly the existing `LIVENESS_DOT` semantics plus needs-action — do not
|
||||||
|
invent new states.
|
||||||
|
- Title line (13 px, `font-medium`): `title || basename(cwd)`. Badge
|
||||||
|
`primary` (existing badge style, `text-[10px]`) when `cwd === project.root`.
|
||||||
|
- Second line (11 px, `fg-muted`): `branch` when present, else the cwd
|
||||||
|
relative to the project root; trailing `AiToolIcon` for the tool, and the id
|
||||||
|
as `#12` (the CLI still addresses by id).
|
||||||
|
- `aria-current="true"` + `bg-accent/10 border-l-2 border-accent` when selected.
|
||||||
|
- Hover/focus reveals a `⋯` button (ghost, `title=` "Workspace actions") that
|
||||||
|
opens the same menu as right-click.
|
||||||
|
- Reorder: drag within a project (reuse `laneOrderStorage.ts`),
|
||||||
|
Alt+↑/↓ as the keyboard path. Order is per project.
|
||||||
|
- Double-click the title ⇒ inline rename (the existing rename field from the
|
||||||
|
detail header, moved here; `PATCH /api/lanes/:id {title}`).
|
||||||
|
|
||||||
|
### Context menu (`WorkspaceRowMenu.tsx`)
|
||||||
|
|
||||||
|
Native `<div role="menu">` positioned at the pointer (there is no menu
|
||||||
|
primitive in the repo; copy the small pattern from `LaneToolsMenu` in
|
||||||
|
`LaneCard.tsx`). Items, in order, each wired to an **existing** call:
|
||||||
|
|
||||||
|
1. Start / Stop — `POST /api/lanes/:id/start` (slot 0) / `…/stop`.
|
||||||
|
2. Rename — enters inline rename.
|
||||||
|
3. Model… — the `LaneRouterRow` select, rendered inline in the menu's
|
||||||
|
submenu area (claude/codex only; hidden for other tools).
|
||||||
|
4. Create/update readme — the existing Tools-menu action (claude only).
|
||||||
|
5. New workspace here… — opens the Add modal prefilled with this repo
|
||||||
|
(`prefill` prop already exists).
|
||||||
|
6. Copy path.
|
||||||
|
7. Re-detect project — `POST /api/lanes/:id/refresh-project`.
|
||||||
|
8. ─
|
||||||
|
9. Reset… — `DestructiveLaneModal` (managed only, as today).
|
||||||
|
10. Remove… — `DestructiveLaneModal` (record-only, as today).
|
||||||
|
|
||||||
|
The server's guards are the real ones; the menu only disables what the
|
||||||
|
row's `kind`/`tool` makes meaningless.
|
||||||
|
|
||||||
|
### Untracked worktree row
|
||||||
|
|
||||||
|
Dimmed (`text-fg-muted`), hollow dot, branch name, path on hover (`title=`),
|
||||||
|
and one button **Adopt** → `POST /api/lanes/ensure {cwd, tool: <default tool>, exact: true}`
|
||||||
|
then select it. Hidden when `showUntracked` is false or its `source` is in
|
||||||
|
`hideSources`. The project row shows "N hidden" in `fg-muted` when any are
|
||||||
|
hidden, clicking it toggles `showUntracked` for the session.
|
||||||
|
|
||||||
|
### Project row
|
||||||
|
|
||||||
|
Chevron (collapse state persisted), folder icon, name, right-aligned on hover:
|
||||||
|
`+` ("New workspace in <name>" → Add modal prefilled with `source_repo: root`,
|
||||||
|
Worktree mode) and `⋯` (Refresh worktrees → `POST /api/projects/refresh`;
|
||||||
|
Copy path; Hide `.claude/worktrees` here — adds to `hideSources`).
|
||||||
|
|
||||||
|
### Add modal
|
||||||
|
|
||||||
|
`AddLaneModal` is unchanged in logic. Two small edits:
|
||||||
|
accept `prefill.sourceRepo` (already there as `prefill`), and when opened from
|
||||||
|
the PROJECTS header `+` with no project, open in the current Repo/Worktree mode
|
||||||
|
as today.
|
||||||
|
|
||||||
|
## 3. Center: workbench
|
||||||
|
|
||||||
|
`client/src/pages/Workspace.tsx`:
|
||||||
|
|
||||||
|
- Delete the strip (`data-testid="lane-strip"`, the add tile, `LaneStripCard`
|
||||||
|
usage). Keep `lane-empty` as the only thing rendered when there are
|
||||||
|
**no workspaces at all**; when workspaces exist but none is selected, render
|
||||||
|
a quiet centered hint "Pick a workspace in the sidebar" **above** the
|
||||||
|
always-mounted `ConsoleArea` (CSS-hidden console, per the one-render-site
|
||||||
|
rule; see the comment block around `lane-detail` — keep it).
|
||||||
|
- Selection source of truth: `?ws=<id>` in the URL (`useSearchParams`),
|
||||||
|
mirrored to `localStorage agenthub.workspace.selected` so a bare `/workspace`
|
||||||
|
restores the last one. The sidebar sets the param; `Workspace.tsx` reads it.
|
||||||
|
Drop `selectedLaneId` state in favour of this.
|
||||||
|
- Detail header: keep it, compacted to one row: status dot + status word,
|
||||||
|
title (read-only here — rename lives in the sidebar), `LaneRouterRow` select,
|
||||||
|
pipeline picker + stage chips **only when `pipeline_nodes.length > 0`**
|
||||||
|
(as today), the Changes-size control at the right edge. Remove the
|
||||||
|
`infoExpanded` toggle and its button (`lane-detail-toggle`): the header is
|
||||||
|
one row now and never needs collapsing.
|
||||||
|
- Panes: keep `ConsoleArea`, `SplitLayoutToggle` (1/2/4) and
|
||||||
|
`splitViewStorage.ts`. Add a 24 px **pane header** to each pane
|
||||||
|
(`PaneHeader.tsx`): tool icon + `title || basename(cwd)` + `slot N` + the
|
||||||
|
run's model when known + a close/stop button for slots 1–3
|
||||||
|
(`DELETE /api/run/<runId>`). Slot 0's header has no close. This is the
|
||||||
|
"tab-looking" header, not a tab bar: there is still one terminal per pane.
|
||||||
|
- Everything else in the page (Add modal, destructive modals, feature picker,
|
||||||
|
PipelineMap, proof, run form) stays.
|
||||||
|
|
||||||
|
## 4. Right panel
|
||||||
|
|
||||||
|
`LaneChangesPanel` gains an **activity bar**
|
||||||
|
along its top edge (icon buttons, 28 px, `title=` names, `aria-pressed`):
|
||||||
|
|
||||||
|
| Tab | Content | Status |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Files | the existing Files mode (finder + tree) | exists |
|
||||||
|
| Changes | the existing overview/review/diff/log modes + `LaneGitHeader` | exists |
|
||||||
|
| Sessions | **new** `WorkspaceSessionsTab.tsx` | new |
|
||||||
|
|
||||||
|
- The current five-mode switcher stays *inside* the Changes tab; Files leaves it
|
||||||
|
and becomes its own tab. Back-navigation and scroll restoration are
|
||||||
|
unchanged.
|
||||||
|
- Sizes Narrow / Medium / Full stay. Default when first opened: Medium.
|
||||||
|
- The panel's open/size/tab is persisted (`agenthub.workspace.changesOpen`,
|
||||||
|
`…changesSize` exist; add `…panelTab`).
|
||||||
|
|
||||||
|
### Sessions tab
|
||||||
|
|
||||||
|
Lists `GET /api/sessions?cwd=<workspace cwd>&tool=<workspace tool>&limit=50`
|
||||||
|
(both query params exist) newest first: `SessionToolBadge`, first prompt
|
||||||
|
excerpt or title, message count, relative time, model, cost. Actions per row:
|
||||||
|
**Resume** (claude only: starts a run with `resumeSessionId`, the same call the
|
||||||
|
run form's resume picker makes; other tools: hidden) and **Open** (`/sessions/:id`).
|
||||||
|
A search box filters client-side by title/excerpt. Empty state: "No sessions
|
||||||
|
in this workspace yet". Reference: `reference/orca-grid.png`.
|
||||||
|
|
||||||
|
## 5. Status bar
|
||||||
|
|
||||||
|
`client/src/components/StatusBar.tsx`, mounted once in `Layout.tsx` under
|
||||||
|
`<main>`, 24 px, `border-t`, `text-xs`, full width (the main area gets
|
||||||
|
`pb-6`). Segments left → right:
|
||||||
|
|
||||||
|
1. Connection dot + "Live"/"Disconnected" (moves the current sidebar dot's
|
||||||
|
semantics here; the sidebar keeps its dot for the collapsed rail only).
|
||||||
|
Click opens the existing `ConnectionStatusModal`.
|
||||||
|
2. `N running · M need you` from the workspace list's `counts`.
|
||||||
|
3. Today's cost + tokens — reuse `GET /api/stats` (`server/routes/stats.js`,
|
||||||
|
the Dashboard's summary call); do not add an endpoint. Poll at the
|
||||||
|
Dashboard's own interval, only while the tab is visible.
|
||||||
|
4. Spacer.
|
||||||
|
5. Update chip — the `UpdateNotifier`'s state, one-line form ("v1.9.0
|
||||||
|
available"), click opens its modal.
|
||||||
|
|
||||||
|
Right-click: nothing in this phase (Orca's show/hide menu is phase 2).
|
||||||
|
|
||||||
|
## 6. i18n
|
||||||
|
|
||||||
|
New namespace keys in `workspaces.json` (en + vi): `projects.title`,
|
||||||
|
`projects.folders`, `projects.newWorkspace`, `projects.refresh`,
|
||||||
|
`projects.hidden_one/_other`, `row.primary`, `row.adopt`, `row.actions`,
|
||||||
|
`menu.*` (ten items), `pane.slot`, `panel.files/changes/sessions`,
|
||||||
|
`sessions.empty/resume/open/search`, `statusbar.running/needYou/today`.
|
||||||
|
|
||||||
|
## 7. Tests
|
||||||
|
|
||||||
|
- Delete `LaneStripCard.test.tsx`; add `WorkspaceRow.test.tsx` (dot states,
|
||||||
|
primary badge, adopt button, menu items present/disabled by kind/tool) and
|
||||||
|
`Sidebar.projects.test.tsx` (grouping, collapse persistence, hidden count).
|
||||||
|
- `Workspace.test.tsx`, `Workspace.laneReorderRename.test.tsx`: rewrite the
|
||||||
|
strip/rename cases against the sidebar; keep every console/one-render-site
|
||||||
|
assertion.
|
||||||
|
- `WorkspaceSessionsTab.test.tsx`: list, filter, resume call shape.
|
||||||
|
- Server: `projects.test.js` — `projectRootFor` on a main worktree, a linked
|
||||||
|
worktree, a subdir, a non-repo; `GET /api/projects` grouping and
|
||||||
|
`workspace_id` matching; cache invalidation on lane create/remove.
|
||||||
|
- Snapshots: `screens.snapshot.test.tsx` changes for Workspace and every page
|
||||||
|
(status bar). Regenerate once, after review.
|
||||||
|
|
||||||
|
## 8. Docs
|
||||||
|
|
||||||
|
`docs/LANES.md` (sidebar, projects, untracked worktrees, Sessions tab,
|
||||||
|
status bar), `docs/API.md` (`/api/projects`, `refresh-project`, the new
|
||||||
|
column), `docs/DATABASE.md` (column), `client/README.md`, `CLAUDE.md` marker
|
||||||
|
and the "Working-copy facts live at …" rule gains: "project roots are stored at
|
||||||
|
creation (`lanes.project_root`) and worktree lists are read only by
|
||||||
|
`GET /api/projects`".
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
- Two repos with three workspaces and one untracked worktree render as two
|
||||||
|
project groups + Folders; adopting the worktree adds a row without reload.
|
||||||
|
- Selecting a row changes the URL, the console keeps its tmux attachment
|
||||||
|
(no re-attach on selection change of a *different* pane), and F5 restores
|
||||||
|
the selection.
|
||||||
|
- The 4-pane layout still works; closing slot 2's pane kills only
|
||||||
|
`agenthub-lane-<id>-2`.
|
||||||
|
- Sessions tab: Resume on a claude session starts a run in slot 0 with that
|
||||||
|
session id; non-claude workspaces show no Resume.
|
||||||
|
- `npm run test:server`, `npm run test:client`, `tsc --noEmit`, headers check
|
||||||
|
all green; before/after screenshots in the PR.
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# Phase 2 — Settings as a full-page grouped navigation
|
||||||
|
|
||||||
|
_One PR, after phase 1._
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Replace the horizontal `PageTabs` bar on `/settings` with a left navigation
|
||||||
|
grouped under small uppercase headings, one section per route, a search box
|
||||||
|
that filters sections, and consistent row primitives — the structure in
|
||||||
|
`reference/orca-settings1.png` / `orca-settings-agents.png` /
|
||||||
|
`orca-settings-scrolled.png`, in AgentHub's own tokens.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- No new settings. Every control that exists today is kept, moved, and
|
||||||
|
reachable from search. (One exception: the worktree-visibility toggles from
|
||||||
|
phase 1 get their first proper home here.)
|
||||||
|
- No theme change. No new dependency.
|
||||||
|
- No change to any server setting route.
|
||||||
|
|
||||||
|
## 1. Routes
|
||||||
|
|
||||||
|
`App.tsx`: `settings` → `settings/:section?`. `SettingsHub.tsx` reads
|
||||||
|
`:section`; unknown or missing ⇒ redirect to `settings/general`. The old
|
||||||
|
`?tool=<id>` query is honoured for one release: `?tool=claude-code` ⇒
|
||||||
|
`/settings/agents/claude-code`, `?tool=ai-router` ⇒ `/settings/ai-router`, and
|
||||||
|
`#ai-tools` ⇒ `/settings/agents` with the catalog modal open. Every in-app link
|
||||||
|
that builds a settings URL (`AddLaneModal`'s AI Tools link, the Claude
|
||||||
|
tab's cross-links, `Sidebar` nav) is updated to the path form.
|
||||||
|
|
||||||
|
Section ids are stable strings (they are URLs and i18n keys):
|
||||||
|
|
||||||
|
| Group (i18n `settingsNav.group.*`) | Section id | Content = today's component |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **General** | `general` | `Settings.tsx` sections: `lanesSection` (renamed "Workspaces": housekeeping) + `data` (import, remote sources, clear data) + `about` |
|
||||||
|
| | `appearance` | `AppearanceSettings` |
|
||||||
|
| | `notifications` | `Settings.tsx` `notifications` + `alertsHub` (`AlertsNotifications`, `WebhookSettings`) |
|
||||||
|
| **Agents** | `agents` | `AiToolsSettings` inline (the modal's content as a page; `AiToolsModal` stays for the Add-workspace link) |
|
||||||
|
| | `agents/claude-code` | `CcConfig` (its own sub-tabs stay inside the page) |
|
||||||
|
| | `agents/codex` | `CodexConfig` |
|
||||||
|
| | `agents/<catalog id>` | `AiToolConfig toolId` — one entry per installed, enabled tool without a `launcher` (same rule as today's `catalogTabs`) |
|
||||||
|
| **Routing** | `ai-router` | `AiRouterConfig` |
|
||||||
|
| | `pricing` | `PricingSettings` with a vendor filter for *all* vendors (today it is embedded per tool; keep those embeds too — this page is the all-vendors view) |
|
||||||
|
| **Git** | `git-credentials` | `GitCredentialsConfig` |
|
||||||
|
| | `worktrees` | **new** `WorktreeVisibilitySettings`: "Show untracked worktrees" switch; per-source rows `.claude/worktrees/*` (Show/Hide), AgentHub-managed (`LANES_ROOT`) (Show/Hide); writes the phase-1 localStorage key |
|
||||||
|
| **Projects** | `projects/<encoded root>` | **new** `ProjectSettings`: read-only project card (root, main branch, worktree count), its workspaces (link to each), the per-project override of the worktree sources above (`hideSources` keyed by root), and "Forget all workspaces of this project" (loops `POST …/remove`, confirm modal listing them) |
|
||||||
|
|
||||||
|
A tool section is hidden while that tool is disabled (same `aiTools.byId(...).enabled` check as today).
|
||||||
|
|
||||||
|
## 2. Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
┌ sidebar (app) ┬──────────────────────────────────────────────────────────┐
|
||||||
|
│ │ ← Back to Workspace │
|
||||||
|
│ │ [🔍 Search settings Ctrl+F] │
|
||||||
|
│ │ │
|
||||||
|
│ │ GENERAL │ General │
|
||||||
|
│ │ ⚙ General │ Workspace housekeeping, data, about. │
|
||||||
|
│ │ ◐ Appearance │ ───────────────────────────────────── │
|
||||||
|
│ │ 🔔 Notifications│ ┌ card ─────────────────────────────┐ │
|
||||||
|
│ │ AGENTS │ │ Housekeeping │ │
|
||||||
|
│ │ ▣ AI tools │ │ Run housekeeping [Dry run][Run]│ │
|
||||||
|
│ │ ✳ Claude Code │ ├───────────────────────────────────┤ │
|
||||||
|
│ │ ◎ Codex │ │ Data │ │
|
||||||
|
│ │ ▲ Antigravity │ │ Import history … │ │
|
||||||
|
│ │ ROUTING … │ └───────────────────────────────────┘ │
|
||||||
|
│ │ PROJECTS │ │
|
||||||
|
│ │ ◈ agenthub │ │
|
||||||
|
└───────────────┴──────────────────┴─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- The settings nav is a second column **inside** the page (`w-56`, sticky,
|
||||||
|
own scroll), not the app sidebar. The app sidebar stays as it is.
|
||||||
|
- "← Back to Workspace" is a plain link to `/workspace` (keeps `?ws` from
|
||||||
|
localStorage).
|
||||||
|
- Content column: `max-w-3xl`, section title `text-2xl font-semibold`, one-line
|
||||||
|
description `text-sm text-fg-muted`, hairline, then **one card per subsection**
|
||||||
|
(`.card` as today). Do not nest cards.
|
||||||
|
- Mobile (`< md`): the nav becomes a `<select>` above the content.
|
||||||
|
|
||||||
|
## 3. Primitives (`client/src/components/settings/SettingsRow.tsx`)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<SettingsSection title description> // h3 + muted description inside a card
|
||||||
|
<SettingsRow label description control alignTop?> // 2 columns: label+desc | control (shrink-0)
|
||||||
|
<SettingsSwitchRow label description checked onChange disabled? />
|
||||||
|
<SettingsSegmented options value onChange /> // role="radiogroup", existing button styles
|
||||||
|
```
|
||||||
|
|
||||||
|
Every control already in `Settings.tsx`, `AppearanceSettings.tsx`,
|
||||||
|
`ClaudeAgentHubSettings.tsx`, `CodexHooksSettings.tsx`,
|
||||||
|
`AntigravityHooksSettings.tsx`, `AiToolConfig.tsx` is re-wrapped in these
|
||||||
|
rows. `CcConfig.tsx` (172 KB) and `CodexConfig.tsx` keep their internal layout
|
||||||
|
in this phase — only their outer frame changes. Rows: `py-3` with a
|
||||||
|
description, `py-2` without; divider `border-b border-border/60` between
|
||||||
|
rows, none after the last.
|
||||||
|
|
||||||
|
## 4. Search
|
||||||
|
|
||||||
|
`client/src/lib/settingsSearch.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface SettingsEntry { section: string; title: string; description?: string; keywords?: string[] }
|
||||||
|
rankSettings(query, entries): { section: string; score: number }[]
|
||||||
|
```
|
||||||
|
|
||||||
|
Scoring (lowercase; exact > prefix > substring): section title 900/850/800,
|
||||||
|
entry title 700/650/600, description 500/450/400, keywords 300/250/200. Each
|
||||||
|
section registers its entries in a static `SETTINGS_INDEX` array (one per
|
||||||
|
`SettingsRow` label at minimum; i18n-resolved at render). While the query is
|
||||||
|
non-empty the nav shows only matching sections (groups with no match hidden),
|
||||||
|
the first match is focused, Enter opens it; Esc clears. `Ctrl+F` / `Cmd+F`
|
||||||
|
focuses the box when the settings page has focus (do not steal it from a
|
||||||
|
terminal — `/settings` has none, so a plain keydown listener on the page root
|
||||||
|
is enough).
|
||||||
|
|
||||||
|
## 5. i18n
|
||||||
|
|
||||||
|
`settings.json`: `settingsNav.group.{general,agents,routing,git,projects}`,
|
||||||
|
`settingsNav.section.*` (title + description per id), `search.placeholder`,
|
||||||
|
`search.noMatch`, `back`, `worktrees.*`, `project.*`. Remove `tools.*` keys
|
||||||
|
that no longer render (keep `tools.label` if `AiToolsModal` still uses it).
|
||||||
|
|
||||||
|
## 6. Tests
|
||||||
|
|
||||||
|
- `SettingsHub.test.tsx`: route → section, legacy `?tool=` redirect, hidden
|
||||||
|
tool section when disabled, mobile select.
|
||||||
|
- `settingsSearch.test.ts`: ranking order, empty query, no match.
|
||||||
|
- `WorktreeVisibilitySettings.test.tsx`: writes the phase-1 key, per-project
|
||||||
|
override.
|
||||||
|
- Existing panel tests unchanged except their mount path.
|
||||||
|
- Snapshots: Settings screens regenerate once.
|
||||||
|
|
||||||
|
## 7. Docs
|
||||||
|
|
||||||
|
`docs/LANES.md` (worktree visibility, per-project settings), `README.md`
|
||||||
|
(settings tour), `client/README.md` (route list, primitives), `CLAUDE.md`
|
||||||
|
marker: "Settings is `/settings/:section` with a grouped nav; a tool's own
|
||||||
|
settings live under `agents/<id>`; the whole-page search index is
|
||||||
|
`SETTINGS_INDEX`".
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
- Every control reachable before is reachable after (walk `Settings.tsx`'s
|
||||||
|
section list and each tool tab, tick them off in the PR body).
|
||||||
|
- `/settings?tool=codex` lands on `/settings/agents/codex`.
|
||||||
|
- Typing "pricing" in search shows Routing → Pricing and the Claude/Codex
|
||||||
|
pages (both embed the table); Enter opens the first.
|
||||||
|
- Keyboard: Tab order follows the visual order; `Ctrl+F` focuses search.
|
||||||
|
- Suites + headers + snapshots green; before/after screenshots.
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
# Phase 3 — Two Orca-derived named themes and notification sounds
|
||||||
|
|
||||||
|
_One PR, after phase 2. Small._
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
1. Offer Orca's neutral palette as two **optional** named themes — "Orca Dark"
|
||||||
|
and "Orca Light" — in the existing Appearance picker. Defaults do not
|
||||||
|
change; nothing else about the theme system changes.
|
||||||
|
2. Play a short sound with a browser notification, per category, user-chosen.
|
||||||
|
|
||||||
|
## 1. Themes
|
||||||
|
|
||||||
|
`client/src/hooks/useTheme.ts` — extend the registry only:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const THEMES = {
|
||||||
|
dark: ["default", "darcula", "apple-dark", "orca-dark"],
|
||||||
|
light: ["default", "apple-light", "intellij-light", "canvas", "orca-light"],
|
||||||
|
} as const …
|
||||||
|
```
|
||||||
|
|
||||||
|
`client/src/index.css` — two override blocks shaped exactly like
|
||||||
|
`.dark[data-theme="darcula"]` (every token the other named themes set, no
|
||||||
|
more, no fewer; comment each with the hex). Values mapped from Orca's
|
||||||
|
`src/renderer/src/assets/main.css` (`.dark` / `:root`) onto AgentHub's roles:
|
||||||
|
|
||||||
|
| AgentHub token | Orca Dark | Orca Light |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `--surface-0` (page) | `#0a0a0a` | `#ffffff` |
|
||||||
|
| `--surface-1` | `#111111` | `#fafafa` |
|
||||||
|
| `--surface-2` | `#171717` (card) | `#f5f5f5` (muted) |
|
||||||
|
| `--surface-3` (`.card`) | `#1c1c1c` | `#ffffff` |
|
||||||
|
| `--surface-4` | `#262626` (secondary/muted) | `#ececec` |
|
||||||
|
| `--surface-5` | `#404040` (accent/hover) | `#e5e5e5` |
|
||||||
|
| `--border` | `#1f1f1f` (≈ white 7 % on #0a0a0a) | `#e5e5e5` |
|
||||||
|
| `--border-light` | `#2e2e2e` (≈ white 15 %) | `#d4d4d4` |
|
||||||
|
| `--accent` | `#e5e5e5` (Orca `primary`) — **check contrast of accent-on-surface text; if the UI uses `text-accent` on `surface-3`, use `#3b82f6` instead and note it** | `#171717` (same caveat) |
|
||||||
|
| `--accent-hover` | `#fafafa` | `#262626` |
|
||||||
|
| `--fg-primary` | `#fafafa` | `#0a0a0a` |
|
||||||
|
| `--fg-secondary` | `#d4d4d4` | `#404040` |
|
||||||
|
| `--fg-muted` | `#a1a1a1` | `#737373` |
|
||||||
|
| `--status-success` | `#86efac` | `#15803d` |
|
||||||
|
| `--status-warning` | `#eab308` | `#ca8a04` |
|
||||||
|
| `--status-danger` | `#ff6568` | `#e40014` |
|
||||||
|
| `--syn-*` | copy from `apple-dark` | copy from `apple-light` |
|
||||||
|
|
||||||
|
The light theme's surfaces must still *rise in lightness with their index* as
|
||||||
|
`index.css` documents; the table above honours that except `surface-3` =
|
||||||
|
`#ffffff` on `surface-0` = `#ffffff` — set `--surface-0` to `#f5f5f5` and
|
||||||
|
`--surface-1` to `#fafafa` so the card lifts. State the final values in the
|
||||||
|
PR.
|
||||||
|
|
||||||
|
Verify with the existing contrast rule (`fg-muted` ≥ 4.5:1 on `surface-3`,
|
||||||
|
status labels ≥ 4.5:1 on their densest chip) — the Appearance swatch test
|
||||||
|
(`Sidebar.theme.test.tsx` / `AppearanceSettings` tests) covers the picker;
|
||||||
|
add the two names to whatever list those tests assert.
|
||||||
|
|
||||||
|
`terminalTheme.ts` already derives the xterm palette from the tokens; confirm
|
||||||
|
it reads the new blocks live when switching.
|
||||||
|
|
||||||
|
i18n: `settings.json` `appearance.themes.orca-dark` / `orca-light` (en: "Orca
|
||||||
|
Dark"/"Orca Light", vi same).
|
||||||
|
|
||||||
|
## 2. Notification sounds
|
||||||
|
|
||||||
|
### Prefs
|
||||||
|
|
||||||
|
`client/src/lib/notifPrefs.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export type NotifSound = "none" | "chime" | "pop" | "alert";
|
||||||
|
export interface NotifPrefs {
|
||||||
|
…existing…
|
||||||
|
sound: NotifSound; // global default
|
||||||
|
soundByType?: Partial<Record<keyof Omit<NotifPrefs, "enabled" | "sound" | "soundByType">, NotifSound>>;
|
||||||
|
}
|
||||||
|
defaultNotifPrefs.sound = "none";
|
||||||
|
```
|
||||||
|
|
||||||
|
`loadNotifPrefs` already spreads defaults over stored JSON, so existing users
|
||||||
|
get `"none"`.
|
||||||
|
|
||||||
|
### Assets
|
||||||
|
|
||||||
|
`client/public/sounds/{chime,pop,alert}.wav`, each ≤ 20 KB, generated — not
|
||||||
|
downloaded — by `scripts/gen-notification-sounds.js` (Node, PCM 16-bit 22.05 kHz
|
||||||
|
mono, two or three sine partials with a short decay; commit the WAVs *and* the
|
||||||
|
script; the script carries the file header). No licence question, no network.
|
||||||
|
|
||||||
|
### Playback
|
||||||
|
|
||||||
|
`client/src/lib/notify.ts`: `playSound(kind)` — a module-level
|
||||||
|
`Map<NotifSound, HTMLAudioElement>` created lazily, `audio.currentTime = 0;
|
||||||
|
audio.play().catch(() => {})` (autoplay may be refused before a user gesture;
|
||||||
|
that is fine). `useNotifications.ts`: in `notify()`, after deciding to show
|
||||||
|
(native, push or local fallback), resolve `soundByType[type] ?? sound` and
|
||||||
|
play it when not `"none"`. Play whenever a notification is shown, visible tab
|
||||||
|
or not; the per-type setting is the user's volume control. No focus
|
||||||
|
heuristics.
|
||||||
|
|
||||||
|
### Settings UI
|
||||||
|
|
||||||
|
Phase-2 `notifications` section: a `SettingsRow` "Sound" with a `<select>`
|
||||||
|
(None / Chime / Pop / Alert) and a ▶ preview button; under each existing
|
||||||
|
per-type switch, a small "Use default · Chime · Pop · Alert · None" segmented
|
||||||
|
control (`SettingsSegmented`) that writes `soundByType[type]`. i18n:
|
||||||
|
`notifications.sound.*`.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- `notifPrefs.test.ts`: defaults, legacy JSON without `sound` loads as
|
||||||
|
`"none"`, `soundByType` round-trip.
|
||||||
|
- `useNotifications` test: `playSound` called with the per-type value, not
|
||||||
|
called for `"none"`; stub `HTMLMediaElement.prototype.play`.
|
||||||
|
- Theme: picker lists the two new names; `data-theme` attribute set; snapshot
|
||||||
|
of Appearance regenerated once.
|
||||||
|
|
||||||
|
## Docs
|
||||||
|
|
||||||
|
`client/README.md` (themes list, sounds), `README.md` feature list,
|
||||||
|
`CLAUDE.md` marker ("named themes gained `orca-dark`/`orca-light`; a sound per
|
||||||
|
notification category, assets generated by `scripts/gen-notification-sounds.js`").
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
- Switching to Orca Dark/Light re-colours every surface, the xterm palette and
|
||||||
|
the diff view with no hardcoded colour leaking (walk Dashboard, Sessions,
|
||||||
|
Workspace with the Changes panel open, Settings).
|
||||||
|
- A turn-complete notification with sound "Pop" plays once; with "None" plays
|
||||||
|
nothing; preview button plays immediately.
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# Workspace redesign — overview and phase plan
|
||||||
|
|
||||||
|
_Status: specs written 2026-10-01, nothing implemented yet. Each phase is one PR, executed in order; a later phase assumes the earlier ones are merged._
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
AgentHub's lane surface grew one card at a time: a horizontal lane strip on
|
||||||
|
`/workspace`, a detail header below it, a Changes panel beside that, and a
|
||||||
|
Settings page made of horizontal tabs. Orca (https://github.com/stablyai/orca,
|
||||||
|
MIT, source cloned at `/tmp/orca-repo` while these specs were written) solved
|
||||||
|
the same problem — many agent worktrees on one screen — with a left-hand
|
||||||
|
**project → workspace** tree, a terminal workbench in the middle, an
|
||||||
|
activity-bar panel on the right and a full-page grouped Settings screen.
|
||||||
|
|
||||||
|
We are **borrowing Orca's information architecture, not its look**. The
|
||||||
|
decisions below are fixed for every phase:
|
||||||
|
|
||||||
|
| Decision | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| Visual theme | **Unchanged.** Keep AgentHub's tokens, dark Radix slate + blue accent, warm light theme, the named themes, Inter + JetBrains Mono. Orca's neutral palette is added only as two more *named* themes in phase 3. |
|
||||||
|
| Terminology | **Only the word changes**: "lane" → "workspace" in UI copy (en + vi), docs prose, CLI output prose (phase 0). Every identifier stays — `/api/lanes`, `agenthub lanes`, `--lane`, `AGENTHUB_LANE_ID`, DB tables, tmux names, component/file/type names, i18n keys, test ids. |
|
||||||
|
| Grouping | Sidebar lists **Projects** (one per git repo = the main worktree's root), each expanded into its **workspaces** (one per lane) and the repo's **untracked worktrees** (from `git worktree list`), the latter shown by default with a user setting to hide them. |
|
||||||
|
| Existing pages | Dashboard, Sessions, Workflows all stay as top-level nav items. Nothing is removed. |
|
||||||
|
| Split panes | The existing 1 / 2 / 4 pane layout (`splitViewStorage.ts`) stays and gains a tab-style header per pane. |
|
||||||
|
| Changes panel | Keeps its Narrow / Medium / Full-screen sizes; gains an activity bar (Files · Changes · Sessions). |
|
||||||
|
| Status bar | A new one-line bar at the bottom of the shell: connection, running / needs-you counts, today's cost, update chip. **No provider quota** — reading Claude quota rotates the stored OAuth token (`cc-usage.js`), and the CLI shows it inside the pane anyway. |
|
||||||
|
| Per-project settings | Phase 2. |
|
||||||
|
| Notification sounds | Phase 3. |
|
||||||
|
| Fonts | Unchanged: UI Inter (`client/tailwind.config.js`), terminal JetBrains Mono 13px (`TerminalView.tsx`). The `claude` CLI has no font of its own — it renders in whatever font the hosting terminal uses. |
|
||||||
|
|
||||||
|
## Phases
|
||||||
|
|
||||||
|
| # | Spec | Scope | Size |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 0 | [00-rename-lane-to-workspace.md](00-rename-lane-to-workspace.md) | Copy-only rename: i18n values, escaped UI literals, CLI prose, docs. No identifier changes. | small, mechanical |
|
||||||
|
| 1 | [01-sidebar-and-workbench.md](01-sidebar-and-workbench.md) | Project tree in the sidebar, lane strip removed, workspace cards with status dot + context menu, untracked worktrees, pane tab headers, right-panel activity bar with a Sessions tab, status bar. | large |
|
||||||
|
| 2 | [02-settings.md](02-settings.md) | Settings as a full-page grouped nav with search, `/settings/:section` routes, row primitives, per-project pane, worktree-visibility setting moved here. | medium |
|
||||||
|
| 3 | [03-appearance-and-sounds.md](03-appearance-and-sounds.md) | Two Orca-derived named themes, notification sounds per category. | small |
|
||||||
|
|
||||||
|
Phase 1 may start before phase 0 is merged; its new UI strings must already
|
||||||
|
say "workspace". New code keeps using the existing `Lane*` identifiers,
|
||||||
|
`api.lanes` and `/api/lanes` — only new *user-facing* text uses the new word.
|
||||||
|
|
||||||
|
## Ground rules for every phase
|
||||||
|
|
||||||
|
- Follow the repo's `CLAUDE.md` rules verbatim. Three bind these specs hardest:
|
||||||
|
the console has **exactly one render site** and collapses with CSS; the
|
||||||
|
console never writes a lane's stage; lane git facts come from
|
||||||
|
`GET …/:id/git`, never from the list endpoint.
|
||||||
|
- Every new or edited `.ts/.tsx/.js/.css` file starts with the header from
|
||||||
|
`skills/file-headers/` (`bash skills/file-headers/scripts/check-headers.sh`
|
||||||
|
must exit 0).
|
||||||
|
- Backend changes: `npm run test:server` (private tmux server — never run the
|
||||||
|
suites bare). Frontend: `npm run test:client`; snapshot baselines in
|
||||||
|
`client/src/pages/__tests__/screens.snapshot.test.tsx` are regenerated with
|
||||||
|
`cd client && npx vitest run -u` **only after** the diff was reviewed as the
|
||||||
|
intended change, and the PR says which screens changed.
|
||||||
|
- Docs are part of the change-set: `CLAUDE.md` (its sync marker and any
|
||||||
|
statement made wrong), `docs/API.md`, `docs/CLI.md`, `docs/LANES.md`
|
||||||
|
(file name kept; prose says "workspace"), `client/README.md`, `README.md`. Apply the
|
||||||
|
`update-project-docs` skill before finishing.
|
||||||
|
- i18n: every new string goes into **both** `client/src/i18n/locales/en/*.json`
|
||||||
|
and `…/vi/*.json` (see `docs/I18N.md`); no hardcoded UI text.
|
||||||
|
- No new runtime dependency unless the spec names it. The specs name none.
|
||||||
|
- Keep response shapes backward compatible; add fields, never rename or drop
|
||||||
|
one in the same release as its consumer.
|
||||||
|
- Each PR ends with a before/after screenshot of every changed screen in the
|
||||||
|
PR body (the Orca reference screenshots used for these specs live in
|
||||||
|
`docs/plans/workspace-redesign/reference/`).
|
||||||
|
|
||||||
|
## Verification matrix (all phases)
|
||||||
|
|
||||||
|
| Check | Command |
|
||||||
|
| --- | --- |
|
||||||
|
| Server suite | `npm run test:server` |
|
||||||
|
| Client suite | `npm run test:client` |
|
||||||
|
| Type check | `cd client && npx tsc --noEmit` |
|
||||||
|
| Headers | `bash skills/file-headers/scripts/check-headers.sh` |
|
||||||
|
| Build | `npm run build` |
|
||||||
|
| Manual | `npm run dev`, open `/workspace`, `/settings`, create a workspace from the sidebar, start a run, resume a session from the Sessions tab, switch theme Dark/Light/System on every changed screen |
|
||||||
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 227 KiB |
|
After Width: | Height: | Size: 228 KiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 139 KiB |
@@ -0,0 +1,128 @@
|
|||||||
|
# Workspace = Claude-Desktop-style project (v2 of the workspace redesign)
|
||||||
|
|
||||||
|
_Replaces the Orca-shaped project → workspace → worktree tree from phase 1.
|
||||||
|
Three phases, each one commit on `main`, in order._
|
||||||
|
|
||||||
|
## What the user asked for
|
||||||
|
|
||||||
|
1. Merge the **Workflows** page into the Dashboard → **Analytics** tab.
|
||||||
|
2. The sidebar lists **workspaces**, each expanding to **its sessions only** —
|
||||||
|
like Claude Desktop's project list (name ›, `+` on the row, sessions under it).
|
||||||
|
3. A workspace opens on a **project home** like Claude Desktop's: title,
|
||||||
|
prompt box, Recents; on the right, **Instructions**, **Memory**,
|
||||||
|
**Context** (files/documents) and **Folder** (folders) — no Scheduled.
|
||||||
|
4. Clicking a session **opens its tmux pane** and never resumes it (a resume
|
||||||
|
re-reads the whole transcript and burns tokens).
|
||||||
|
5. The Orca parts are regrouped by workspace and kept where they still make sense.
|
||||||
|
|
||||||
|
## Phase A — Workflows inside Analytics
|
||||||
|
|
||||||
|
- `Dashboard.tsx` Analytics → the existing `workflow` sub-tab renders the whole
|
||||||
|
`Workflows` page body (pipelines, skill usage, Workflow-tool runs, DAG, error
|
||||||
|
map, complexity, compaction, drill-in) under its current small cards.
|
||||||
|
- `/workflows` → `<Navigate to="/?tab=analytics&sub=workflow">`; the sidebar
|
||||||
|
loses its **Workflows** item; `DocumentTitle`, i18n `nav.workflows` removed.
|
||||||
|
- No server change.
|
||||||
|
|
||||||
|
## Phase B — Sidebar: workspaces → sessions
|
||||||
|
|
||||||
|
```
|
||||||
|
WORKSPACES + 🔍 ⚙
|
||||||
|
agenthub › +
|
||||||
|
SG_MiniApp ⌄ +
|
||||||
|
● Tách thông tin official account (● = a live tmux pane)
|
||||||
|
○ Kiến trúc tenant đơn mở rộng
|
||||||
|
scratch › +
|
||||||
|
```
|
||||||
|
|
||||||
|
- A flat list in the stored drag order (`agenthub.workspace.laneOrder`, kept);
|
||||||
|
the repo grouping, the "Folders" pseudo-project, untracked worktrees and
|
||||||
|
Adopt leave the sidebar.
|
||||||
|
- A row's chevron expands its sessions (`GET /api/sessions?cwd=&tool=`, the
|
||||||
|
newest 20, the session's name or first prompt). A dot shows a live pane.
|
||||||
|
- Click the **name** → the workspace home (Phase C). Click `+` → the home with
|
||||||
|
the prompt focused. Click a **session**:
|
||||||
|
- its pane is alive (a `dashboard_runs` row with that `session_id`, the
|
||||||
|
tmux session `agenthub-lane-<id>[-slot]` still there) → the session view
|
||||||
|
attached to that pane — no new process, no resume;
|
||||||
|
- no live pane → the read-only transcript (`/sessions/:id`, Conversation tab),
|
||||||
|
with **Resume** as a separate, explicit button that warns about context cost.
|
||||||
|
- Kept: rename (double-click), drag / Alt+↑↓ reorder, right-click menu
|
||||||
|
(Start/Stop, Rename, router, readme, Copy path, Reset, Remove).
|
||||||
|
- Header: `+` new workspace (the Add modal), search (filters workspaces and
|
||||||
|
loaded session titles), a tool filter.
|
||||||
|
|
||||||
|
## Phase C — Workspace home + sources
|
||||||
|
|
||||||
|
Route: `/workspace?lane=<id>` = home; `/workspace?lane=<id>&run=<runId>` = the
|
||||||
|
session view (today's terminal, split panes and the Files/Changes panel,
|
||||||
|
unchanged — the one-render-site console rule still holds).
|
||||||
|
|
||||||
|
```
|
||||||
|
Workspaces / SG_MiniApp 📌 ⋯
|
||||||
|
┌────────────────────────────────────────────┐ ┌ Instructions ✎ ┐
|
||||||
|
│ How can I help you today? │ │ text… │
|
||||||
|
│ + [Claude ▾] [model ▾] [effort ▾] [Run]│ ├ Memory View ────┤
|
||||||
|
└────────────────────────────────────────────┘ │ CLAUDE.md, auto │
|
||||||
|
Recents ├ Context + ─┤
|
||||||
|
Tách thông tin official account 2h ago ● │ spec.pdf notes… │
|
||||||
|
Kiến trúc tenant đơn mở rộng Sep 29 ├ Folder + ─┤
|
||||||
|
│ 📁 SG_MiniApp │
|
||||||
|
│ 📁 sg-shared │
|
||||||
|
└──────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Prompt box** = today's run form (tool's model picker, permission, effort,
|
||||||
|
bypass), compact. Run → a new pane, then the session view.
|
||||||
|
- **Recents** = the same session list as the sidebar, with time ago, cost and
|
||||||
|
the live dot.
|
||||||
|
- **Instructions** — one text per workspace, `lanes.instructions`. Reaches the
|
||||||
|
agent at spawn: Claude `--append-system-prompt <text>`; Codex
|
||||||
|
`-c developer_instructions=<text>` if 0.155 accepts it (verified while
|
||||||
|
building — otherwise Claude-only, shown as "not supported by <tool>").
|
||||||
|
Never written into the user's repo.
|
||||||
|
- **Memory** — read-only: the folder's `CLAUDE.md` / `AGENTS.md` and Claude's
|
||||||
|
auto-memory dir for that cwd (`<CLAUDE_HOME>/projects/<slug>/memory/`).
|
||||||
|
**View memory** opens them in the existing markdown viewer.
|
||||||
|
- **Context** — files the user uploads (pdf, md, txt, images…, ≤ 10 MB each),
|
||||||
|
stored by AgentHub under `<data dir>/workspace-context/<laneId>/` with a
|
||||||
|
server-chosen name. The directory goes to the agent as `--add-dir`, and the
|
||||||
|
appended instructions list the files, so the agent knows to read them.
|
||||||
|
- **Folder** — the workspace's own cwd (fixed) plus extra folders, each
|
||||||
|
`--add-dir` for Claude and Codex (both verified in `--help`); other tools show
|
||||||
|
them as unsupported. **+** opens a folder picker that also suggests the repo's
|
||||||
|
untracked worktrees (what is left of Adopt). A folder must exist, be a
|
||||||
|
directory and not be or contain the OS home (`validateHomeDir`).
|
||||||
|
- **⋯ menu** = the row menu plus what Settings → Projects held (facts,
|
||||||
|
worktree count, Forget).
|
||||||
|
|
||||||
|
### Schema (migration-safe)
|
||||||
|
|
||||||
|
- `lanes.instructions TEXT` (ADD COLUMN, null).
|
||||||
|
- `CREATE TABLE IF NOT EXISTS lane_sources (id INTEGER PRIMARY KEY, lane_id
|
||||||
|
INTEGER NOT NULL REFERENCES lanes(id) ON DELETE CASCADE, kind TEXT NOT NULL
|
||||||
|
CHECK (kind IN ('folder','file')), path TEXT NOT NULL, name TEXT NOT NULL,
|
||||||
|
size INTEGER, added_at TEXT NOT NULL)`.
|
||||||
|
- Removing a workspace deletes its rows and AgentHub's own context copies —
|
||||||
|
never a user folder.
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
- `GET/PUT /api/lanes/:id/instructions`
|
||||||
|
- `GET /api/lanes/:id/sources`, `POST …/sources/folder {path}`,
|
||||||
|
`POST …/sources/file` (raw body, name header, size cap),
|
||||||
|
`DELETE …/sources/:sourceId` — all `sameOriginGuard`.
|
||||||
|
- `GET /api/lanes/:id/memory` → the memory files above, read-only.
|
||||||
|
- Spawn (`spawnRun`) reads instructions + sources from the lane row, never
|
||||||
|
from the request — the same rule as `tool`.
|
||||||
|
|
||||||
|
### Settings
|
||||||
|
|
||||||
|
- **Projects** group leaves Settings (now the workspace's ⋯ menu).
|
||||||
|
- **Worktrees** stays, renamed "Worktree suggestions": whether Add folder
|
||||||
|
suggests untracked worktrees and from which sources.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Each phase: tests first for new logic, `npm run test:server`,
|
||||||
|
`npm run test:client`, headers, build, screenshots; docs + CLAUDE.md marker.
|
||||||
@@ -135,21 +135,21 @@ describe("lanes on catalog tools", () => {
|
|||||||
let r = await request("POST", "/api/lanes", { cwd: dir("a"), title: "a", tool: "gemini" });
|
let r = await request("POST", "/api/lanes", { cwd: dir("a"), title: "a", tool: "gemini" });
|
||||||
assert.equal(r.status, 201);
|
assert.equal(r.status, 201);
|
||||||
assert.equal(r.body.lane.tool, "gemini");
|
assert.equal(r.body.lane.tool, "gemini");
|
||||||
r = await request("POST", "/api/lanes", { cwd: dir("b"), title: "b", tool: "aider" });
|
r = await request("POST", "/api/lanes", { cwd: dir("b"), title: "b", tool: "omp" });
|
||||||
assert.equal(r.status, 201, "a tool that is not installed may still get a lane");
|
assert.equal(r.status, 201, "a tool that is not installed may still get a lane");
|
||||||
r = await request("POST", "/api/lanes", { cwd: dir("c"), title: "c", tool: "nope" });
|
r = await request("POST", "/api/lanes", { cwd: dir("c"), title: "c", tool: "nope" });
|
||||||
assert.equal(r.body.error.code, "EBADTOOL");
|
assert.equal(r.body.error.code, "EBADTOOL");
|
||||||
await request("PUT", "/api/ai-tools/aider", { enabled: false });
|
await request("PUT", "/api/ai-tools/omp", { enabled: false });
|
||||||
r = await request("POST", "/api/lanes", { cwd: dir("d"), title: "d", tool: "aider" });
|
r = await request("POST", "/api/lanes", { cwd: dir("d"), title: "d", tool: "omp" });
|
||||||
assert.equal(r.status, 400);
|
assert.equal(r.status, 400);
|
||||||
assert.equal(r.body.error.code, "EDISABLED");
|
assert.equal(r.body.error.code, "EDISABLED");
|
||||||
r = await request("POST", "/api/lanes/ensure", { cwd: dir("e"), tool: "aider" });
|
r = await request("POST", "/api/lanes/ensure", { cwd: dir("e"), tool: "omp" });
|
||||||
assert.equal(r.body.error.code, "EDISABLED");
|
assert.equal(r.body.error.code, "EDISABLED");
|
||||||
r = await request("POST", "/api/lanes/ensure", { cwd: dir("b"), tool: "aider" });
|
r = await request("POST", "/api/lanes/ensure", { cwd: dir("b"), tool: "omp" });
|
||||||
assert.equal(r.status, 200, "an existing lane on a disabled tool is still found");
|
assert.equal(r.status, 200, "an existing lane on a disabled tool is still found");
|
||||||
r = await request("POST", "/api/lanes/worktree", { sourceRepo: dir("f"), tool: "aider" });
|
r = await request("POST", "/api/lanes/worktree", { sourceRepo: dir("f"), tool: "omp" });
|
||||||
assert.equal(r.body.error.code, "EDISABLED");
|
assert.equal(r.body.error.code, "EDISABLED");
|
||||||
await request("PUT", "/api/ai-tools/aider", { enabled: true });
|
await request("PUT", "/api/ai-tools/omp", { enabled: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -190,23 +190,23 @@ describe("install / update / icons", () => {
|
|||||||
it("starts an install job and reports it", async () => {
|
it("starts an install job and reports it", async () => {
|
||||||
jobs.__reset();
|
jobs.__reset();
|
||||||
assert.equal((await request("GET", "/api/ai-tools/job")).body, null);
|
assert.equal((await request("GET", "/api/ai-tools/job")).body, null);
|
||||||
const r = await request("POST", "/api/ai-tools/qwen-code/install");
|
const r = await request("POST", "/api/ai-tools/copilot/install");
|
||||||
assert.equal(r.status, 202);
|
assert.equal(r.status, 202);
|
||||||
assert.equal(r.body.job.tool, "qwen-code");
|
assert.equal(r.body.job.tool, "copilot");
|
||||||
const done = await settle();
|
const done = await settle();
|
||||||
assert.equal(done.status, "ok");
|
assert.equal(done.status, "ok");
|
||||||
assert.deepEqual(spawned.at(-1), [
|
assert.deepEqual(spawned.at(-1), [
|
||||||
path.join(BIN, "npm"),
|
path.join(BIN, "npm"),
|
||||||
"install",
|
"install",
|
||||||
"-g",
|
"-g",
|
||||||
"@qwen-code/qwen-code@latest",
|
"@github/copilot@latest",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps refusals to statuses", async () => {
|
it("maps refusals to statuses", async () => {
|
||||||
jobs.__reset();
|
jobs.__reset();
|
||||||
assert.equal((await request("POST", "/api/ai-tools/nope/install")).status, 404);
|
assert.equal((await request("POST", "/api/ai-tools/nope/install")).status, 404);
|
||||||
const noInstall = await request("POST", "/api/ai-tools/aider/install");
|
const noInstall = await request("POST", "/api/ai-tools/antigravity/install");
|
||||||
assert.equal(noInstall.status, 400);
|
assert.equal(noInstall.status, 400);
|
||||||
assert.equal(noInstall.body.error.code, "ENOINSTALL");
|
assert.equal(noInstall.body.error.code, "ENOINSTALL");
|
||||||
const notInstalled = await request("POST", "/api/ai-tools/codex/update");
|
const notInstalled = await request("POST", "/api/ai-tools/codex/update");
|
||||||
@@ -216,7 +216,7 @@ describe("install / update / icons", () => {
|
|||||||
const notNpm = await request("POST", "/api/ai-tools/gemini/update");
|
const notNpm = await request("POST", "/api/ai-tools/gemini/update");
|
||||||
assert.equal(notNpm.status, 409);
|
assert.equal(notNpm.status, 409);
|
||||||
assert.equal(notNpm.body.error.code, "ENOTNPM");
|
assert.equal(notNpm.body.error.code, "ENOTNPM");
|
||||||
const cross = await request("POST", "/api/ai-tools/qwen-code/install", undefined, {
|
const cross = await request("POST", "/api/ai-tools/copilot/install", undefined, {
|
||||||
Origin: "https://evil.test",
|
Origin: "https://evil.test",
|
||||||
});
|
});
|
||||||
assert.equal(cross.status, 403);
|
assert.equal(cross.status, 403);
|
||||||
|
|||||||
@@ -292,6 +292,47 @@ describe("Sessions API", () => {
|
|||||||
assert.ok(claude.body.sessions.every((s) => s.tool === "claude"));
|
assert.ok(claude.body.sessions.every((s) => s.tool === "claude"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("lists only the sessions a workspace owns, not the rest of its folder", async () => {
|
||||||
|
const laneId = db
|
||||||
|
.prepare("INSERT INTO lanes (cwd, tool) VALUES ('/tmp/own-ws', 'claude')")
|
||||||
|
.run().lastInsertRowid;
|
||||||
|
const add = (id, cwd, lane) =>
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
"INSERT INTO sessions (id, name, status, cwd, lane_id, started_at, updated_at) VALUES (?, ?, 'active', ?, ?, '2026-09-29T00:00:00Z', '2026-09-29T00:00:00Z')"
|
||||||
|
)
|
||||||
|
.run(id, id, cwd, lane);
|
||||||
|
add("own-1", "/tmp/own-ws", laneId);
|
||||||
|
add("own-sub", "/tmp/own-ws/pkg", laneId); // cd'd into a subfolder — still its own
|
||||||
|
add("stray-1", "/tmp/own-ws", null); // a terminal session in the same folder
|
||||||
|
const own = await fetch(`/api/sessions?lane=${laneId}`);
|
||||||
|
assert.deepEqual(own.body.sessions.map((s) => s.id).sort(), ["own-1", "own-sub"]);
|
||||||
|
assert.equal((await fetch("/api/sessions?lane=abc")).status, 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("archives a session out of a workspace's list and back, deleting nothing", async () => {
|
||||||
|
const laneId = stmts.getSession.get("own-1").lane_id;
|
||||||
|
let res = await patch("/api/sessions/own-1", { archived: true });
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.ok(res.body.session.archived_at);
|
||||||
|
const live = await fetch(`/api/sessions?lane=${laneId}&archived=0`);
|
||||||
|
assert.deepEqual(
|
||||||
|
live.body.sessions.map((s) => s.id),
|
||||||
|
["own-sub"]
|
||||||
|
);
|
||||||
|
const gone = await fetch(`/api/sessions?lane=${laneId}&archived=1`);
|
||||||
|
assert.deepEqual(
|
||||||
|
gone.body.sessions.map((s) => s.id),
|
||||||
|
["own-1"]
|
||||||
|
);
|
||||||
|
// Without the parameter every session is listed, as before.
|
||||||
|
assert.equal((await fetch(`/api/sessions?lane=${laneId}`)).body.sessions.length, 2);
|
||||||
|
res = await patch("/api/sessions/own-1", { archived: "yes" });
|
||||||
|
assert.equal(res.status, 400);
|
||||||
|
res = await patch("/api/sessions/own-1", { archived: false });
|
||||||
|
assert.equal(res.body.session.archived_at, null);
|
||||||
|
});
|
||||||
|
|
||||||
it("should paginate sessions", async () => {
|
it("should paginate sessions", async () => {
|
||||||
const res = await fetch("/api/sessions?limit=1&offset=0");
|
const res = await fetch("/api/sessions?limit=1&offset=0");
|
||||||
assert.equal(res.body.sessions.length, 1);
|
assert.equal(res.body.sessions.length, 1);
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* @file Tests for the persisted run history (`server/lib/dashboard-runs.js`).
|
||||||
|
* A run's id is its tmux pane name, which every Start in that pane reuses, so
|
||||||
|
* a new run must ARCHIVE the previous row under its own id instead of
|
||||||
|
* overwriting it; and a row is flagged dead only when its tmux session is
|
||||||
|
* really gone — panes outlive a server restart.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { describe, it, before, after } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "dashboard-runs-"));
|
||||||
|
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db");
|
||||||
|
|
||||||
|
let runs;
|
||||||
|
before(() => {
|
||||||
|
runs = require("../lib/dashboard-runs");
|
||||||
|
});
|
||||||
|
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
const start = (id, extra = {}) =>
|
||||||
|
runs.recordRun({ id, mode: "interactive", cwd: "/r", status: "running", laneId: 4, ...extra });
|
||||||
|
|
||||||
|
describe("dashboard-runs", () => {
|
||||||
|
it("keeps the previous run of a pane as history when the pane starts again", () => {
|
||||||
|
start("agenthub-lane-4", { startedAt: Date.parse("2026-10-01T10:00:00Z"), sessionId: "s-old" });
|
||||||
|
start("agenthub-lane-4", { startedAt: Date.parse("2026-10-02T10:00:00Z"), sessionId: "s-new" });
|
||||||
|
const current = runs.getRun("agenthub-lane-4");
|
||||||
|
assert.equal(current.session_id, "s-new");
|
||||||
|
const history = runs.listRuns({ laneId: 4 });
|
||||||
|
assert.equal(history.length, 2);
|
||||||
|
const old = history.find((r) => r.session_id === "s-old");
|
||||||
|
assert.notEqual(old.id, "agenthub-lane-4");
|
||||||
|
assert.ok(old.id.startsWith("agenthub-lane-4@"), old.id);
|
||||||
|
// A pane that is being replaced is not running any more.
|
||||||
|
assert.equal(old.status, "abandoned");
|
||||||
|
assert.ok(old.ended_at);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags as abandoned only the running rows whose pane is gone", () => {
|
||||||
|
start("agenthub-lane-5", { laneId: 5 });
|
||||||
|
start("agenthub-lane-6", { laneId: 6 });
|
||||||
|
const n = runs.reconcileOrphans((id) => id !== "agenthub-lane-6");
|
||||||
|
assert.equal(n, 1);
|
||||||
|
assert.equal(runs.getRun("agenthub-lane-5").status, "running");
|
||||||
|
assert.equal(runs.getRun("agenthub-lane-6").status, "abandoned");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"id": "command-code",
|
||||||
|
"label": "Command Code",
|
||||||
|
"detect": ["command-code"],
|
||||||
|
"args": ["--trust"],
|
||||||
|
"prompt": {
|
||||||
|
"mode": "argv"
|
||||||
|
},
|
||||||
|
"bypass": ["--yolo"]
|
||||||
|
}
|
||||||
@@ -1,16 +1,12 @@
|
|||||||
{
|
{
|
||||||
"id": "goose",
|
"id": "goose",
|
||||||
"label": "Goose",
|
"label": "Goose",
|
||||||
"detect": [
|
"detect": ["goose"],
|
||||||
"goose"
|
|
||||||
],
|
|
||||||
"prompt": {
|
"prompt": {
|
||||||
"mode": "after-start",
|
"mode": "after-start",
|
||||||
"delayMs": 3000
|
"delayMs": 3000
|
||||||
},
|
},
|
||||||
"bypassEnv": {
|
"bypassEnv": {
|
||||||
"GOOSE_MODE": "auto"
|
"GOOSE_MODE": "auto"
|
||||||
},
|
}
|
||||||
"homepageUrl": "https://block.github.io/goose/docs/quickstart/",
|
|
||||||
"faviconDomain": "goose-docs.ai"
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"id": "grok",
|
||||||
|
"label": "Grok",
|
||||||
|
"detect": ["grok"],
|
||||||
|
"prompt": {
|
||||||
|
"mode": "argv",
|
||||||
|
"separator": "--"
|
||||||
|
},
|
||||||
|
"bypass": ["--permission-mode", "bypassPermissions"]
|
||||||
|
}
|
||||||
@@ -426,6 +426,25 @@ describe("lane start input", () => {
|
|||||||
fs.rmSync(cwd, { recursive: true, force: true });
|
fs.rmSync(cwd, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("answers 400 for a fork with nothing to fork, or on a tool that cannot fork", async () => {
|
||||||
|
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-bad-fork-"));
|
||||||
|
const id = (await request("POST", "/api/lanes", { cwd })).body.lane.id;
|
||||||
|
const codexId = (await request("POST", "/api/lanes", { cwd, tool: "codex" })).body.lane.id;
|
||||||
|
try {
|
||||||
|
let r = await request("POST", `/api/lanes/${id}/start`, { forkSession: true });
|
||||||
|
assert.equal(r.status, 400);
|
||||||
|
assert.equal(r.body.error.code, "EBADSESSION");
|
||||||
|
r = await request("POST", `/api/lanes/${codexId}/start`, {
|
||||||
|
forkSession: true,
|
||||||
|
resumeSessionId: "abc12345",
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 400);
|
||||||
|
assert.equal(r.body.error.code, "EUNSUPPORTED");
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(cwd, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("hook → lane binding", () => {
|
describe("hook → lane binding", () => {
|
||||||
|
|||||||
@@ -196,6 +196,40 @@ describe("pty-run", () => {
|
|||||||
assert.ok(newSessionArgv.includes("abc12345"));
|
assert.ok(newSessionArgv.includes("abc12345"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("spawnRun forks a resumed Claude session into a new id, and refuses a fork elsewhere", () => {
|
||||||
|
let newSessionArgv = null;
|
||||||
|
tmux.__setExecImpl((args) => {
|
||||||
|
if (args[0] === "has-session") {
|
||||||
|
const e = new Error("gone");
|
||||||
|
e.status = 1;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
if (args[0] === "new-session") newSessionArgv = args;
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
pty.spawnRun({ laneId: 31, cwd: "/tmp/repo", resumeSessionId: "abc12345", forkSession: true });
|
||||||
|
const at = newSessionArgv.indexOf("--resume");
|
||||||
|
assert.deepEqual(newSessionArgv.slice(at, at + 3), ["--resume", "abc12345", "--fork-session"]);
|
||||||
|
// The pane holds the FORK, whose id only its first hook reports.
|
||||||
|
const dashboardRuns = require("../lib/dashboard-runs");
|
||||||
|
assert.equal(dashboardRuns.getRun("agenthub-lane-31").session_id, null);
|
||||||
|
assert.throws(
|
||||||
|
() => pty.spawnRun({ laneId: 32, cwd: "/tmp/repo", forkSession: true }),
|
||||||
|
(e) => e.code === "EBADSESSION"
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
pty.spawnRun({
|
||||||
|
laneId: 33,
|
||||||
|
cwd: "/tmp/repo",
|
||||||
|
tool: "codex",
|
||||||
|
resumeSessionId: "abc12345",
|
||||||
|
forkSession: true,
|
||||||
|
}),
|
||||||
|
(e) => e.code === "EUNSUPPORTED"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("spawnRun offers bypass in the pane's shift+tab cycle without starting in it", () => {
|
it("spawnRun offers bypass in the pane's shift+tab cycle without starting in it", () => {
|
||||||
let newSessionArgv = null;
|
let newSessionArgv = null;
|
||||||
tmux.__setExecImpl((args) => {
|
tmux.__setExecImpl((args) => {
|
||||||
@@ -427,11 +461,16 @@ describe("pty-run", () => {
|
|||||||
}
|
}
|
||||||
savedPath = process.env.PATH;
|
savedPath = process.env.PATH;
|
||||||
process.env.PATH = `${BIN}:/usr/bin:/bin`;
|
process.env.PATH = `${BIN}:/usr/bin:/bin`;
|
||||||
aiTools.refresh();
|
// The built-in catalog plus three test-only tools for the prompt modes
|
||||||
|
// and bypassEnv no built-in tool uses any more (custom files may).
|
||||||
|
aiTools.__setCatalogDirs([
|
||||||
|
path.join(__dirname, "..", "data", "ai-tools"),
|
||||||
|
path.join(__dirname, "fixtures", "ai-tools"),
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
after(() => {
|
after(() => {
|
||||||
process.env.PATH = savedPath;
|
process.env.PATH = savedPath;
|
||||||
aiTools.refresh();
|
aiTools.__setCatalogDirs(null);
|
||||||
});
|
});
|
||||||
const at = (name) => path.join(BIN, name);
|
const at = (name) => path.join(BIN, name);
|
||||||
const envOf = (call, key) => {
|
const envOf = (call, key) => {
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
* is started with `AGENTHUB_RUN_ID` in its tmux env, and a hook carrying that
|
* is started with `AGENTHUB_RUN_ID` in its tmux env, and a hook carrying that
|
||||||
* `run_id` binds the hook's session to the run (`dashboard_runs.session_id`),
|
* `run_id` binds the hook's session to the run (`dashboard_runs.session_id`),
|
||||||
* following `/clear` to a new session; a `run_id` that is not a dashboard pane
|
* following `/clear` to a new session; a `run_id` that is not a dashboard pane
|
||||||
* name, or names no run, changes nothing.
|
* name, or names no run, changes nothing. A session a lane's pane holds is
|
||||||
|
* stamped with that lane (`sessions.lane_id`) — the workspace owns it — and a
|
||||||
|
* nested CLI's session, or one another lane already owns, is not.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -22,6 +24,7 @@ process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
|
|||||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||||
|
|
||||||
const { createApp } = require("../index");
|
const { createApp } = require("../index");
|
||||||
|
const { db } = require("../db");
|
||||||
const dashboardRuns = require("../lib/dashboard-runs");
|
const dashboardRuns = require("../lib/dashboard-runs");
|
||||||
const tmux = require("../lib/tmux");
|
const tmux = require("../lib/tmux");
|
||||||
const pty = require("../lib/pty-run");
|
const pty = require("../lib/pty-run");
|
||||||
@@ -140,4 +143,59 @@ describe("run ↔ session binding", () => {
|
|||||||
assert.equal(dashboardRuns.getRun("agenthub-lane-999"), null);
|
assert.equal(dashboardRuns.getRun("agenthub-lane-999"), null);
|
||||||
assert.equal(dashboardRuns.getRun("agenthub-lane-5-1").session_id, "sess-bbbb-2222");
|
assert.equal(dashboardRuns.getRun("agenthub-lane-5-1").session_id, "sess-bbbb-2222");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("stamps the session a lane's pane holds with that lane, once", async () => {
|
||||||
|
dashboardRuns.recordRun({
|
||||||
|
id: "agenthub-lane-8",
|
||||||
|
laneId: 8,
|
||||||
|
mode: "interactive",
|
||||||
|
cwd: ROOT,
|
||||||
|
status: "running",
|
||||||
|
startedAt: Date.now(),
|
||||||
|
});
|
||||||
|
const laneOf = (id) => db.prepare("SELECT lane_id FROM sessions WHERE id = ?").get(id)?.lane_id;
|
||||||
|
await postHook({
|
||||||
|
hook_type: "SessionStart",
|
||||||
|
data: { session_id: "own-aaaa-0001", cwd: ROOT, source: "startup" },
|
||||||
|
run_id: "agenthub-lane-8",
|
||||||
|
});
|
||||||
|
assert.equal(laneOf("own-aaaa-0001"), 8);
|
||||||
|
// A nested `claude -p` in that pane is not the pane's session.
|
||||||
|
await postHook({
|
||||||
|
hook_type: "SessionStart",
|
||||||
|
data: { session_id: "own-nest-0002", cwd: ROOT, source: "startup" },
|
||||||
|
run_id: "agenthub-lane-8",
|
||||||
|
});
|
||||||
|
assert.equal(laneOf("own-nest-0002"), null);
|
||||||
|
// A session another lane owns keeps its owner when resumed elsewhere.
|
||||||
|
db.prepare("UPDATE sessions SET lane_id = 3 WHERE id = ?").run("own-nest-0002");
|
||||||
|
await postHook({
|
||||||
|
hook_type: "SessionStart",
|
||||||
|
data: { session_id: "own-nest-0002", cwd: ROOT, source: "resume" },
|
||||||
|
run_id: "agenthub-lane-8",
|
||||||
|
});
|
||||||
|
assert.equal(dashboardRuns.getRun("agenthub-lane-8").session_id, "own-nest-0002");
|
||||||
|
assert.equal(laneOf("own-nest-0002"), 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stamps a resumed session the run was started with", async () => {
|
||||||
|
dashboardRuns.recordRun({
|
||||||
|
id: "agenthub-lane-9",
|
||||||
|
laneId: 9,
|
||||||
|
sessionId: "own-resu-0003",
|
||||||
|
mode: "interactive",
|
||||||
|
cwd: ROOT,
|
||||||
|
status: "running",
|
||||||
|
startedAt: Date.now(),
|
||||||
|
});
|
||||||
|
await postHook({
|
||||||
|
hook_type: "SessionStart",
|
||||||
|
data: { session_id: "own-resu-0003", cwd: ROOT, source: "resume" },
|
||||||
|
run_id: "agenthub-lane-9",
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
db.prepare("SELECT lane_id FROM sessions WHERE id = ?").get("own-resu-0003").lane_id,
|
||||||
|
9
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "aider",
|
|
||||||
"label": "Aider",
|
|
||||||
"detect": [
|
|
||||||
"aider"
|
|
||||||
],
|
|
||||||
"prompt": {
|
|
||||||
"mode": "after-start",
|
|
||||||
"delayMs": 3000
|
|
||||||
},
|
|
||||||
"bypass": [
|
|
||||||
"--yes-always"
|
|
||||||
],
|
|
||||||
"homepageUrl": "https://aider.chat/docs/"
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "amp",
|
|
||||||
"label": "Amp",
|
|
||||||
"detect": [
|
|
||||||
"amp"
|
|
||||||
],
|
|
||||||
"prompt": {
|
|
||||||
"mode": "after-start",
|
|
||||||
"delayMs": 3000
|
|
||||||
},
|
|
||||||
"bypass": [
|
|
||||||
"--dangerously-allow-all"
|
|
||||||
],
|
|
||||||
"homepageUrl": "https://ampcode.com/manual#install",
|
|
||||||
"faviconDomain": "ampcode.com",
|
|
||||||
"install": {
|
|
||||||
"kind": "npm",
|
|
||||||
"package": "@sourcegraph/amp"
|
|
||||||
},
|
|
||||||
"update": {
|
|
||||||
"kind": "npm"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "ante",
|
|
||||||
"label": "Ante",
|
|
||||||
"detect": [
|
|
||||||
"ante"
|
|
||||||
],
|
|
||||||
"prompt": {
|
|
||||||
"mode": "after-start",
|
|
||||||
"delayMs": 3000
|
|
||||||
},
|
|
||||||
"bypass": [
|
|
||||||
"--yolo"
|
|
||||||
],
|
|
||||||
"homepageUrl": "https://github.com/AntigmaLabs/ante-preview",
|
|
||||||
"faviconDomain": "antigma.ai"
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "aug",
|
|
||||||
"label": "Auggie",
|
|
||||||
"detect": [
|
|
||||||
"auggie"
|
|
||||||
],
|
|
||||||
"prompt": {
|
|
||||||
"mode": "after-start",
|
|
||||||
"delayMs": 3000
|
|
||||||
},
|
|
||||||
"homepageUrl": "https://docs.augmentcode.com/cli/overview",
|
|
||||||
"faviconDomain": "augmentcode.com"
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "autohand",
|
|
||||||
"label": "Autohand Code",
|
|
||||||
"detect": [
|
|
||||||
"autohand"
|
|
||||||
],
|
|
||||||
"prompt": {
|
|
||||||
"mode": "after-start",
|
|
||||||
"delayMs": 3000
|
|
||||||
},
|
|
||||||
"bypass": [
|
|
||||||
"--unrestricted"
|
|
||||||
],
|
|
||||||
"homepageUrl": "https://github.com/autohandai/code-cli",
|
|
||||||
"faviconDomain": "autohand.ai"
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "cline",
|
|
||||||
"label": "Cline",
|
|
||||||
"detect": [
|
|
||||||
"cline"
|
|
||||||
],
|
|
||||||
"prompt": {
|
|
||||||
"mode": "after-start",
|
|
||||||
"delayMs": 3000
|
|
||||||
},
|
|
||||||
"bypass": [
|
|
||||||
"--auto-approve",
|
|
||||||
"true"
|
|
||||||
],
|
|
||||||
"homepageUrl": "https://docs.cline.bot/cline-cli/overview",
|
|
||||||
"faviconDomain": "cline.bot"
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "codebuff",
|
|
||||||
"label": "Codebuff",
|
|
||||||
"detect": [
|
|
||||||
"codebuff"
|
|
||||||
],
|
|
||||||
"prompt": {
|
|
||||||
"mode": "after-start",
|
|
||||||
"delayMs": 3000
|
|
||||||
},
|
|
||||||
"homepageUrl": "https://www.codebuff.com/docs/help/quick-start",
|
|
||||||
"faviconDomain": "codebuff.com"
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "command-code",
|
|
||||||
"label": "Command Code",
|
|
||||||
"detect": [
|
|
||||||
"command-code"
|
|
||||||
],
|
|
||||||
"args": [
|
|
||||||
"--trust"
|
|
||||||
],
|
|
||||||
"prompt": {
|
|
||||||
"mode": "argv"
|
|
||||||
},
|
|
||||||
"bypass": [
|
|
||||||
"--yolo"
|
|
||||||
],
|
|
||||||
"homepageUrl": "https://commandcode.ai/docs/quickstart"
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "continue",
|
|
||||||
"label": "Continue",
|
|
||||||
"detect": [
|
|
||||||
"cn"
|
|
||||||
],
|
|
||||||
"prompt": {
|
|
||||||
"mode": "after-start",
|
|
||||||
"delayMs": 3000
|
|
||||||
},
|
|
||||||
"bypass": [
|
|
||||||
"--allow",
|
|
||||||
"*"
|
|
||||||
],
|
|
||||||
"homepageUrl": "https://docs.continue.dev/guides/cli",
|
|
||||||
"faviconDomain": "continue.dev"
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "crush",
|
|
||||||
"label": "Charm Crush",
|
|
||||||
"detect": [
|
|
||||||
"crush"
|
|
||||||
],
|
|
||||||
"prompt": {
|
|
||||||
"mode": "after-start",
|
|
||||||
"delayMs": 3000
|
|
||||||
},
|
|
||||||
"bypass": [
|
|
||||||
"--yolo"
|
|
||||||
],
|
|
||||||
"homepageUrl": "https://github.com/charmbracelet/crush",
|
|
||||||
"faviconDomain": "charm.sh"
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"id": "cursor",
|
"id": "cursor",
|
||||||
"label": "Cursor",
|
"label": "Cursor CLI",
|
||||||
"detect": [
|
"detect": [
|
||||||
"cursor-agent"
|
"cursor-agent"
|
||||||
],
|
],
|
||||||
@@ -10,6 +10,9 @@
|
|||||||
"model": {
|
"model": {
|
||||||
"flag": "--model"
|
"flag": "--model"
|
||||||
},
|
},
|
||||||
|
"resume": [
|
||||||
|
"--continue"
|
||||||
|
],
|
||||||
"bypass": [
|
"bypass": [
|
||||||
"--yolo"
|
"--yolo"
|
||||||
],
|
],
|
||||||
@@ -20,7 +23,10 @@
|
|||||||
"url": "https://cursor.com/install"
|
"url": "https://cursor.com/install"
|
||||||
},
|
},
|
||||||
"update": {
|
"update": {
|
||||||
"kind": "script"
|
"kind": "self",
|
||||||
|
"args": [
|
||||||
|
"update"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"config": [
|
"config": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "devin",
|
|
||||||
"label": "Devin",
|
|
||||||
"detect": [
|
|
||||||
"devin"
|
|
||||||
],
|
|
||||||
"prompt": {
|
|
||||||
"mode": "after-start",
|
|
||||||
"delayMs": 3000
|
|
||||||
},
|
|
||||||
"bypass": [
|
|
||||||
"--permission-mode",
|
|
||||||
"bypass",
|
|
||||||
"--respect-workspace-trust",
|
|
||||||
"false"
|
|
||||||
],
|
|
||||||
"homepageUrl": "https://devin.ai/cli",
|
|
||||||
"faviconDomain": "devin.ai"
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "droid",
|
|
||||||
"label": "Droid",
|
|
||||||
"detect": [
|
|
||||||
"droid"
|
|
||||||
],
|
|
||||||
"prompt": {
|
|
||||||
"mode": "argv"
|
|
||||||
},
|
|
||||||
"bypass": [
|
|
||||||
"--auto",
|
|
||||||
"high"
|
|
||||||
],
|
|
||||||
"homepageUrl": "https://docs.factory.ai/cli/getting-started/quickstart"
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "grok",
|
|
||||||
"label": "Grok",
|
|
||||||
"detect": [
|
|
||||||
"grok"
|
|
||||||
],
|
|
||||||
"prompt": {
|
|
||||||
"mode": "argv",
|
|
||||||
"separator": "--"
|
|
||||||
},
|
|
||||||
"bypass": [
|
|
||||||
"--permission-mode",
|
|
||||||
"bypassPermissions"
|
|
||||||
],
|
|
||||||
"homepageUrl": "https://x.ai/cli",
|
|
||||||
"faviconDomain": "x.ai"
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "kilo",
|
|
||||||
"label": "Kilocode",
|
|
||||||
"detect": [
|
|
||||||
"kilo"
|
|
||||||
],
|
|
||||||
"prompt": {
|
|
||||||
"mode": "after-start",
|
|
||||||
"delayMs": 3000
|
|
||||||
},
|
|
||||||
"homepageUrl": "https://kilo.ai/docs/cli"
|
|
||||||
}
|
|
||||||