feat: v2.6.0 — Chat UI composer (+ menu, project files, activity panel, permission mode, Stop), git on a workspace's extra folders (#70), long paste as a file (#68), S3 server mode (experimental)

Chat UI:
- Each Claude/Codex session keeps its own chat/terminal view; Settings only
  sets how new sessions open.
- Composer + menu: upload, project files and folders (#54, also dragged from
  the Files/Changes tree), context files, recent uploads, skills, plugins.
- Activity side panel (progress, outputs, files read, uploads, memory,
  skills, connectors) with a file preview; edited-file counts per turn.
- Permission-mode chip (Shift+Tab steps, read back from the pane:
  server/lib/pane-mode.js, GET|POST /api/run/:id/permission-mode).
- Stop (Esc) while the main agent works; the status is checked per agent
  (with session_id /api/agents ignores its status filter) and re-read every
  5 s while Stop shows.
- Dropped images shown in the conversation (GET /api/run/drop/:name, the
  requester's own drop dir), full-size viewer.
- A plain-text paste over 5,000 characters is attached as pasted-text.txt,
  so no prompt reaches tmux/argv limits (#68).

Changes panel: every /api/lanes/:id/git* route takes ?source=<lane_sources
id> (sourceFolder; ENOSOURCE otherwise) and a folder picker switches the
whole panel to a workspace's extra folder (#70).

Server mode S3 (one Linux user per account) — experimental, verified with
fake sudo plus a P6 managed-settings spike on a test account:
- member tmux listing runs in the background (cached, 15 s => unknown);
- transcript snapshot copy is async, chunked, temp file + rename;
- Express Layer patch only on Express 4;
- tmux capturePane/sendKey go to the member's own server.

Docs: the multi-user design moved in as docs/plans/server-mode/README.md
(the AgentHub-Server repo is gone); S3-PLAN P6 result; LANES/API/openapi.
This commit is contained in:
2026-10-09 15:51:19 +07:00
parent ce773a3f5a
commit fa156d6a59
114 changed files with 5831 additions and 885 deletions
+2 -2
View File
File diff suppressed because one or more lines are too long
+9 -9
View File
@@ -30,7 +30,7 @@
<title>AgentHub - SmartGift Solution</title> <title>AgentHub - SmartGift Solution</title>
<meta <meta
name="description" name="description"
content="Real-time monitoring platform for Claude Code agent activity. Track sessions, monitor agents, visualize analytics, manage cost tracking, and receive browser notifications - all with live WebSocket updates." content="Run, steer and track your AI coding agents in one browser tab: workspaces with live terminals for Claude Code, Codex and more, built-in Git, chat replay, usage and cost tracking, and alerts - local-first and real-time."
/> />
<meta <meta
name="keywords" name="keywords"
@@ -45,23 +45,23 @@
<meta property="og:title" content="AgentHub - SmartGift Solution" /> <meta property="og:title" content="AgentHub - SmartGift Solution" />
<meta <meta
property="og:description" property="og:description"
content="Real-time monitoring platform for Claude Code agent activity. Track sessions, monitor agents, visualize analytics, manage cost tracking, and receive browser notifications - all with live WebSocket updates." content="Run, steer and track your AI coding agents in one browser tab: workspaces with live terminals for Claude Code, Codex and more, built-in Git, chat replay, usage and cost tracking, and alerts - local-first and real-time."
/> />
<meta property="og:image" content="/og-image.svg" /> <meta property="og:image" content="/og-image.png" />
<meta property="og:image:width" content="1200" /> <meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" /> <meta property="og:image:height" content="630" />
<meta property="og:image:type" content="image/svg+xml" /> <meta property="og:image:type" content="image/png" />
<meta property="og:image:alt" content="AgentHub - real-time Claude Code monitoring" /> <meta property="og:image:alt" content="AgentHub - run, steer and track your AI coding agents" />
<!-- Twitter / X Card --> <!-- Twitter / X Card -->
<meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="AgentHub - SmartGift Solution" /> <meta name="twitter:title" content="AgentHub - SmartGift Solution" />
<meta <meta
name="twitter:description" name="twitter:description"
content="Real-time monitoring platform for Claude Code agent activity. Sessions, Kanban board, analytics, cost tracking, browser notifications, and WebSocket push." content="Run, steer and track your AI coding agents in one browser tab: workspaces with live terminals for Claude Code, Codex and more, built-in Git, chat replay, usage and cost tracking, and alerts - local-first and real-time."
/> />
<meta name="twitter:image" content="/og-image.svg" /> <meta name="twitter:image" content="/og-image.png" />
<meta name="twitter:image:alt" content="AgentHub - real-time Claude Code monitoring" /> <meta name="twitter:image:alt" content="AgentHub - run, steer and track your AI coding agents" />
<meta name="twitter:creator" content="@smartgift" /> <meta name="twitter:creator" content="@smartgift" />
<!-- JSON-LD structured data --> <!-- JSON-LD structured data -->
@@ -70,7 +70,7 @@
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "WebApplication", "@type": "WebApplication",
"name": "AgentHub", "name": "AgentHub",
"description": "Real-time monitoring platform for Claude Code agent activity. Tracks sessions, agents, tool events, token usage analytics, cost tracking, and browser notifications via native hooks with live WebSocket push.", "description": "Run, steer and track your AI coding agents in one browser tab: workspaces with live terminals for Claude Code, Codex and more, built-in Git, chat replay, usage and cost tracking, and alerts - local-first and real-time.",
"applicationCategory": "DeveloperApplication", "applicationCategory": "DeveloperApplication",
"operatingSystem": "Windows, macOS, Linux", "operatingSystem": "Windows, macOS, Linux",
"softwareVersion": "1.0.0", "softwareVersion": "1.0.0",
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 30 KiB

+91 -32
View File
@@ -10,6 +10,7 @@
* `usePermissions()` says what that user's role may do, so pages hide the rest. * `usePermissions()` says what that user's role may do, so pages hide the rest.
* Once an account is known (local mode is one), its own UI preferences are * Once an account is known (local mode is one), its own UI preferences are
* loaded into this browser (`lib/userPrefs.ts`). * loaded into this browser (`lib/userPrefs.ts`).
* The login page follows the OS's light/dark (`applySystemTheme`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -17,11 +18,15 @@ import {
createContext, createContext,
useContext, useContext,
useEffect, useEffect,
useLayoutEffect,
useState, useState,
type FormEvent, type FormEvent,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Eye, EyeOff } from "lucide-react";
import { BRAND_LOGO } from "../lib/brand";
import { applySystemTheme } from "../hooks/useTheme";
import { api, AUTH_REQUIRED_EVENT, type AuthState, type AuthUser } from "../lib/api"; import { api, AUTH_REQUIRED_EVENT, type AuthState, type AuthUser } from "../lib/api";
import { hydrate } from "../lib/userPrefs"; import { hydrate } from "../lib/userPrefs";
@@ -144,9 +149,18 @@ function LoginPage({ onLoggedIn }: { onLoggedIn: (user: AuthUser) => void }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Nobody is signed in, so no account's theme applies: follow the OS, live.
useLayoutEffect(() => {
applySystemTheme();
const mq = window.matchMedia?.("(prefers-color-scheme: dark)");
mq?.addEventListener?.("change", applySystemTheme);
return () => mq?.removeEventListener?.("change", applySystemTheme);
}, []);
const submit = async (e: FormEvent) => { const submit = async (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
setBusy(true); setBusy(true);
@@ -164,38 +178,83 @@ function LoginPage({ onLoggedIn }: { onLoggedIn: (user: AuthUser) => void }) {
return ( return (
<main className="flex min-h-screen items-center justify-center bg-surface-0 p-4"> <main className="flex min-h-screen items-center justify-center bg-surface-0 p-4">
<form onSubmit={submit} className="card w-full max-w-sm space-y-4 p-6"> <form
<h1 className="text-lg font-semibold text-fg-primary">{t("auth.title")}</h1> onSubmit={submit}
<label className="block space-y-1 text-sm text-fg-secondary"> className="card relative w-full max-w-md overflow-hidden px-6 pb-8 pt-12 sm:px-10"
<span>{t("auth.username")}</span> >
<input {/* Accent glow and a grid fading out from the top, drawn from the
className="input w-full" theme's own accent so it holds in every theme. */}
autoComplete="username" <div
autoFocus aria-hidden
required className="pointer-events-none absolute inset-x-0 top-0 h-56"
value={username} style={{
onChange={(e) => setUsername(e.target.value)} backgroundImage:
/> "linear-gradient(to right, rgb(var(--accent) / 0.12) 1px, transparent 1px), linear-gradient(to bottom, rgb(var(--accent) / 0.12) 1px, transparent 1px), radial-gradient(ellipse at top, rgb(var(--accent) / 0.16), transparent 70%)",
</label> backgroundSize: "48px 48px, 48px 48px, 100% 100%",
<label className="block space-y-1 text-sm text-fg-secondary"> backgroundPosition: "center top",
<span>{t("auth.password")}</span> maskImage: "radial-gradient(ellipse 70% 100% at top, black 30%, transparent 100%)",
<input }}
className="input w-full" />
type="password" <div className="relative space-y-5">
autoComplete="current-password" <div className="text-center">
required <img src={BRAND_LOGO} alt="SmartGift" className="block h-auto w-full" />
value={password} <h1 className="mt-5 text-3xl font-semibold tracking-tight text-fg-primary">
onChange={(e) => setPassword(e.target.value)} {t("auth.title")}
/> </h1>
</label> <p className="mt-2 text-sm text-fg-secondary">{t("auth.subtitle")}</p>
{error && ( </div>
<p role="alert" className="text-sm text-status-danger"> <label className="block space-y-1.5 text-sm font-medium text-fg-primary">
{error} <span>{t("auth.username")}</span>
</p> <input
)} className="input h-11 w-full px-4"
<button type="submit" className="btn-primary w-full" disabled={busy}> autoComplete="username"
{busy ? t("auth.submitting") : t("auth.submit")} autoFocus
</button> required
placeholder={t("auth.username")}
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</label>
<div className="space-y-1.5 text-sm font-medium text-fg-primary">
<label htmlFor="login-password">{t("auth.password")}</label>
<div className="relative">
<input
id="login-password"
className="input h-11 w-full pl-4 pr-11"
type={showPassword ? "text" : "password"}
autoComplete="current-password"
required
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
aria-label={showPassword ? t("auth.hidePassword") : t("auth.showPassword")}
aria-pressed={showPassword}
className="absolute inset-y-0 right-0 flex w-11 items-center justify-center rounded-r-lg text-fg-muted hover:text-fg-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
{error && (
<p role="alert" className="text-sm text-status-danger">
{error}
</p>
)}
{/* Inverted fill (text colour behind the surface colour): black in a
light theme, light in a dark one, like the reference design. */}
<button
type="submit"
className="h-11 w-full rounded-xl bg-fg-primary text-sm font-medium text-surface-0 shadow-sm transition-opacity hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50"
disabled={busy}
>
{busy ? t("auth.submitting") : t("auth.submit")}
</button>
<p className="text-center text-sm text-fg-secondary">{t("auth.noAccount")}</p>
</div>
</form> </form>
</main> </main>
); );
+44
View File
@@ -0,0 +1,44 @@
/**
* @file ImageLightbox.tsx
* @description One image shown full size over the whole page; a click or
* Escape closes it. Rendered into `document.body`, so no pane, its stacking
* layer or a header drawn over a pane can cover it — opened from inside a
* pane's Chat view, a `fixed` overlay would otherwise stay in that pane's
* layer, under anything stacked above it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect } from "react";
import { createPortal } from "react-dom";
export function ImageLightbox({
src,
alt,
onClose,
}: {
src: string;
alt: string;
onClose: () => void;
}) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onClose]);
return createPortal(
<div
role="dialog"
aria-label={alt}
data-testid="image-lightbox"
onClick={onClose}
className="fixed inset-0 z-[200] flex cursor-zoom-out items-center justify-center bg-black/60 p-8"
>
<img
src={src}
alt={alt}
className="max-h-full max-w-full rounded-lg object-contain shadow-2xl"
/>
</div>,
document.body
);
}
+5 -1
View File
@@ -12,7 +12,7 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { useState } from "react"; import { useState, type Ref } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FolderOpen } from "lucide-react"; import { FolderOpen } from "lucide-react";
import { api } from "../lib/api"; import { api } from "../lib/api";
@@ -27,12 +27,15 @@ export function PathPickButton({
start, start,
onPick, onPick,
showLabel = false, showLabel = false,
buttonRef,
}: { }: {
kind?: "dir" | "file"; kind?: "dir" | "file";
/** The field's current value — the picker opens there. */ /** The field's current value — the picker opens there. */
start?: string; start?: string;
onPick: (path: string) => void; onPick: (path: string) => void;
showLabel?: boolean; showLabel?: boolean;
/** Lets a field beside it open the same picker (a click on an empty path). */
buttonRef?: Ref<HTMLButtonElement>;
}) { }) {
const { t } = useTranslation(["lanes"]); const { t } = useTranslation(["lanes"]);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -61,6 +64,7 @@ export function PathPickButton({
return ( return (
<> <>
<button <button
ref={buttonRef}
type="button" type="button"
onClick={pick} onClick={pick}
disabled={busy} disabled={busy}
+15 -4
View File
@@ -3,7 +3,8 @@
* @description Generic styled dropdown that replaces native `<select>` elements * @description Generic styled dropdown that replaces native `<select>` elements
* for consistent cross-platform rendering. Native macOS/Chromium selects reserve * for consistent cross-platform rendering. Native macOS/Chromium selects reserve
* checkmark space inconsistently; this control aligns labels, flips above the * checkmark space inconsistently; this control aligns labels, flips above the
* trigger when viewport space is tight, and marks the chosen option with accent * trigger when space is tight — in its split pane (`data-menu-bounds`, see
* `menuRoom`) or else the viewport — and marks the chosen option with accent
* color plus a Lucide check. * color plus a Lucide check.
* *
* ## Accessibility — WAI-ARIA "listbox button" * ## Accessibility — WAI-ARIA "listbox button"
@@ -74,6 +75,14 @@
import { useEffect, useId, useRef, useState, type ReactNode } from "react"; import { useEffect, useId, useRef, useState, type ReactNode } from "react";
import { ChevronDown, Check } from "lucide-react"; import { ChevronDown, Check } from "lucide-react";
/** Room above and below `r` (the trigger's rect) for a menu it opens: inside
* the nearest `data-menu-bounds` box — a split pane, whose neighbour paints
* over anything crossing its edge — else the viewport. */
export function menuRoom(el: Element, r: DOMRect): { above: number; below: number } {
const box = el.closest("[data-menu-bounds]")?.getBoundingClientRect();
return { above: r.top - (box?.top ?? 0), below: (box?.bottom ?? window.innerHeight) - r.bottom };
}
/** Single option in a {@link Select} list. */ /** Single option in a {@link Select} list. */
export interface SelectOption<T extends string> { export interface SelectOption<T extends string> {
/** Stored value emitted via `onChange`. */ /** Stored value emitted via `onChange`. */
@@ -144,9 +153,11 @@ export function Select<T extends string>({
const idx = options.findIndex((o) => o.value === value); const idx = options.findIndex((o) => o.value === value);
setActive(idx >= 0 ? idx : 0); setActive(idx >= 0 ? idx : 0);
if (buttonRef.current) { if (buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect(); const { above, below } = menuRoom(
const below = window.innerHeight - rect.bottom; buttonRef.current,
setOpenUp(below < 288 && rect.top > below); buttonRef.current.getBoundingClientRect()
);
setOpenUp(below < 288 && above > below);
} }
listRef.current?.focus({ preventScroll: true }); listRef.current?.focus({ preventScroll: true });
// eslint-disable-next-line react-hooks/exhaustive-deps -- only on open // eslint-disable-next-line react-hooks/exhaustive-deps -- only on open
+2 -7
View File
@@ -74,6 +74,7 @@ import {
} 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 { BRAND_LOGO, BRAND_MARK } from "../lib/brand";
import { useAuth, usePermissions } from "./AuthGate"; import { useAuth, usePermissions } from "./AuthGate";
import { eventBus } from "../lib/eventBus"; import { eventBus } from "../lib/eventBus";
import { WorkspaceTree } from "./WorkspaceTree"; import { WorkspaceTree } from "./WorkspaceTree";
@@ -88,13 +89,6 @@ const NAV_KEYS = [
{ to: "/sessions", icon: FolderOpen, key: "nav:sessions" }, { to: "/sessions", icon: FolderOpen, key: "nav:sessions" },
] as const; ] as const;
// SmartGift wordmark, replacing the old text subtitle. Remote URLs: the logo is
// owned by the brand, not vendored here — a machine with no network shows the
// alt text instead.
const BRAND_LOGO =
"https://smg-services.s3.ap-southeast-1.amazonaws.com/smartgift/assets/logo/logo-main.png";
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;
@@ -496,6 +490,7 @@ export function Sidebar({
<button <button
type="button" type="button"
onClick={onToggle} onClick={onToggle}
data-testid="sidebar-tree-rail"
title={t("lanes:tree.title")} title={t("lanes:tree.title")}
aria-label={t("lanes:tree.title")} aria-label={t("lanes:tree.title")}
className="flex w-full items-center justify-center rounded-md px-2 py-2 text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary" className="flex w-full items-center justify-center rounded-md px-2 py-2 text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary"
+113 -45
View File
@@ -5,11 +5,15 @@
* (`WorkspaceSessionList.tsx`) — no repo grouping, no worktree rows. Picking a * (`WorkspaceSessionList.tsx`) — no repo grouping, no worktree rows. Picking a
* workspace — or `+` on its row — opens its home (`/workspace?lane=<id>`); a * workspace — or `+` on its row — opens its home (`/workspace?lane=<id>`); a
* live tab is opened from its session row. Live status comes from the shared * live tab is opened from its session row. Live status comes from the shared
* lane list (`useWorkLanes`), live panes from `GET /api/run`, re-read whenever a * lane list (`useLanes`), live panes from `GET /api/run`, re-read whenever a
* lane's status or session moves (at most every 3 s) and handed over by the * lane's status or session moves (at most every 3 s) and handed over by the
* Workspace page's own poll (`LIVE_RUNS_EVENT`). Owns its own Add-workspace and * Workspace page's own poll (`LIVE_RUNS_EVENT`). Owns its own Add-workspace and
* destructive modals (lazy-loaded on first open) so the row menu works from any page. Which rows are * destructive modals (lazy-loaded on first open) so the row menu works from any page. Which rows are
* expanded lives in `projectTreePrefs.ts` (`expanded`). * expanded lives in `projectTreePrefs.ts` (`expanded`). Server mode, admin or
* analyst: EVERY workspace, grouped under its owner (`owner_name`; yours
* first, no owner last, groups foldable — `collapsedOwners`); one this user
* may not change (`canEditLane`) is read only — no menu, no new session, and a
* session opens its analysis page (`/sessions/<id>`), never a pane.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -25,10 +29,11 @@ import {
} from "react"; } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import { Plus, Search } from "lucide-react"; import { ChevronDown, ChevronRight, Plus, Search } from "lucide-react";
import { api, type CwdSuggestion, type RunHandle } from "../lib/api"; import { api, type CwdSuggestion, type RunHandle } from "../lib/api";
import type { Lane } from "../lib/types"; import type { Lane } from "../lib/types";
import { useWorkLanes } from "../hooks/useLanes"; import { useLanes } from "../hooks/useLanes";
import { useAuth, usePermissions } from "./AuthGate";
import { useAiTools } from "../hooks/useAiTools"; import { useAiTools } from "../hooks/useAiTools";
import { useThrottledValue } from "../hooks/useThrottledValue"; import { useThrottledValue } from "../hooks/useThrottledValue";
import { import {
@@ -70,7 +75,12 @@ export function WorkspaceTree() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const selected = selectedFrom(location.pathname, location.search); const selected = selectedFrom(location.pathname, location.search);
const { lanes, refresh: refreshLanes } = useWorkLanes(); const { lanes, refresh: refreshLanes } = useLanes();
const { state: auth } = useAuth();
const { seesAll, canEditLane } = usePermissions();
const meId = auth.mode === "server" ? (auth.user?.id ?? null) : null;
// Server mode staff see every user's workspaces, so the list says whose.
const grouped = auth.mode === "server" && seesAll;
const prefs = useProjectTreePrefs(); const prefs = useProjectTreePrefs();
const [liveRuns, setLiveRuns] = useState<RunHandle[]>([]); const [liveRuns, setLiveRuns] = useState<RunHandle[]>([]);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
@@ -132,10 +142,17 @@ export function WorkspaceTree() {
const tools = useMemo(() => [...new Set(lanes.map((l) => l.tool))], [lanes]); const tools = useMemo(() => [...new Set(lanes.map((l) => l.tool))], [lanes]);
const toolFilter = const toolFilter =
tools.length > 1 && prefs.tool && tools.includes(prefs.tool) ? prefs.tool : null; tools.length > 1 && prefs.tool && tools.includes(prefs.tool) ? prefs.tool : null;
const sorted = useMemo( const sorted = useMemo(() => {
() => sortByStoredOrder(lanes, order).filter((l) => !toolFilter || l.tool === toolFilter), const list = sortByStoredOrder(lanes, order).filter(
[lanes, order, toolFilter] (l) => !toolFilter || l.tool === toolFilter
); );
if (!grouped) return list;
// Stable: each owner's workspaces keep the stored order inside the group.
const rank = (l: Lane) => (l.owner_id == null ? 2 : l.owner_id === meId ? 0 : 1);
return [...list].sort(
(a, b) => rank(a) - rank(b) || (a.owner_name ?? "").localeCompare(b.owner_name ?? "")
);
}, [lanes, order, toolFilter, grouped, meId]);
const q = query.trim().toLowerCase(); const q = query.trim().toLowerCase();
const nameHit = (l: Lane) => const nameHit = (l: Lane) =>
workspaceName(l).toLowerCase().includes(q) || l.cwd.toLowerCase().includes(q); workspaceName(l).toLowerCase().includes(q) || l.cwd.toLowerCase().includes(q);
@@ -312,42 +329,90 @@ export function WorkspaceTree() {
)} )}
<div ref={listRef} className="space-y-px px-1"> <div ref={listRef} className="space-y-px px-1">
{sorted.map((lane, i) => ( {sorted.map((lane, i) => {
<div key={lane.id} className="relative"> const owner = ownerKey(lane);
{dropAt === i && <DropLine />} // A search opens every group, like it opens every workspace.
{visible(lane) && ( const folded = grouped && !q && !!prefs.collapsedOwners[owner];
<WorkspaceRow const readOnly = !canEditLane(lane);
lane={lane} const ownerLabel =
index={i} lane.owner_id == null
selected={selected.lane === lane.id && !selected.run && !selected.session} ? t("tree.noOwner")
expanded={expanded(lane.id)} : lane.owner_id === meId
renaming={renamingId === lane.id} ? t("tree.ownerYou", { name: lane.owner_name ?? `#${lane.owner_id}` })
onSelect={() => { : (lane.owner_name ?? `#${lane.owner_id}`);
select(lane.id); const groupStart = grouped && (i === 0 || ownerKey(sorted[i - 1] ?? lane) !== owner);
if (!expanded(lane.id)) setExpanded(lane.id, true); const groupShown = sorted.some((l) => ownerKey(l) === owner && visible(l));
}} return (
onToggle={() => setExpanded(lane.id, !expanded(lane.id))} <div key={lane.id} className="relative">
onNewSession={() => select(lane.id)} {groupStart && groupShown && (
onReorder={(from, to) => reorder((ids) => moveLane(ids, from, to))} <button
onMenu={(x, y) => setMenu({ lane, x, y })} type="button"
onRenameStart={() => setRenamingId(lane.id)} data-testid={`workspace-owner-${owner}`}
onRenameEnd={(title) => void rename(lane, title)} aria-expanded={!folded}
/> title={t("tree.ownerToggle", { name: ownerLabel })}
)} onClick={() =>
{expanded(lane.id) && ( writeProjectTreePrefs({
<WorkspaceSessionList collapsedOwners: { ...prefs.collapsedOwners, [owner]: !folded },
lane={lane} })
liveRuns={liveRuns} }
// A workspace found by its name shows all of its sessions. className={`flex w-full items-center gap-1 rounded px-1 py-1 text-left text-[11px] font-medium text-fg-muted hover:text-fg-primary ${i === 0 ? "" : "mt-2"}`}
query={q && nameHit(lane) ? "" : query} >
selectedRunId={selected.lane === lane.id ? selected.run : null} {folded ? (
selectedSessionId={selected.lane === lane.id ? selected.session : null} <ChevronRight className="h-3 w-3 shrink-0" />
onMatches={q ? (n) => onMatches(lane.id, n) : undefined} ) : (
hidden={!visible(lane)} <ChevronDown className="h-3 w-3 shrink-0" />
/> )}
)} <span
</div> aria-hidden
))} className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-surface-4 text-[9px] font-semibold uppercase text-fg-secondary"
>
{lane.owner_name?.[0] ?? "?"}
</span>
<span className="truncate">{ownerLabel}</span>
<span className="ml-auto shrink-0 tabular-nums">
{sorted.filter((l) => ownerKey(l) === owner).length}
</span>
</button>
)}
{dropAt === i && <DropLine />}
{visible(lane) && !folded && (
<WorkspaceRow
lane={lane}
index={i}
readOnly={readOnly ? t("tree.readOnly", { name: ownerLabel }) : undefined}
selected={selected.lane === lane.id && !selected.run && !selected.session}
expanded={expanded(lane.id)}
renaming={renamingId === lane.id}
onSelect={() => {
// A read-only workspace has no home to open: it only unfolds.
if (readOnly) return setExpanded(lane.id, !expanded(lane.id));
select(lane.id);
if (!expanded(lane.id)) setExpanded(lane.id, true);
}}
onToggle={() => setExpanded(lane.id, !expanded(lane.id))}
onNewSession={() => select(lane.id)}
onReorder={(from, to) => reorder((ids) => moveLane(ids, from, to))}
onMenu={(x, y) => setMenu({ lane, x, y })}
onRenameStart={() => setRenamingId(lane.id)}
onRenameEnd={(title) => void rename(lane, title)}
/>
)}
{expanded(lane.id) && !folded && (
<WorkspaceSessionList
lane={lane}
liveRuns={liveRuns}
readOnly={readOnly}
// A workspace found by its name shows all of its sessions.
query={q && nameHit(lane) ? "" : query}
selectedRunId={selected.lane === lane.id ? selected.run : null}
selectedSessionId={selected.lane === lane.id ? selected.session : null}
onMatches={q ? (n) => onMatches(lane.id, n) : undefined}
hidden={!visible(lane)}
/>
)}
</div>
);
})}
{dropAt === sorted.length && ( {dropAt === sorted.length && (
<div className="relative"> <div className="relative">
<DropLine /> <DropLine />
@@ -400,6 +465,9 @@ export function WorkspaceTree() {
); );
} }
/** A workspace's owner group: its user id, or "none". */
const ownerKey = (l: Lane) => (l.owner_id == null ? "none" : String(l.owner_id));
/** Where a dragged workspace would land: a line on its parent's top edge. */ /** Where a dragged workspace would land: a line on its parent's top edge. */
function DropLine() { function DropLine() {
return ( return (
@@ -5,17 +5,19 @@
* and a later 401 `EAUTH` brings the login page back. `usePermissions` and * and a later 401 `EAUTH` brings the login page back. `usePermissions` and
* `useWorkLanes` mirror the server's roles: an admin changes everything, an * `useWorkLanes` mirror the server's roles: an admin changes everything, an
* analyst reads everything but works only in their own workspaces, a * analyst reads everything but works only in their own workspaces, a
* developer only ever sees (and so changes) their own. * developer only ever sees (and so changes) their own. The login page wears the
* OS's light/dark; a sign-in swaps in the account's saved appearance.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { act, cleanup, render, screen } from "@testing-library/react"; import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import "../../i18n"; import "../../i18n";
const me = vi.fn(); const me = vi.fn();
const login = vi.fn(); const login = vi.fn();
const prefs = vi.fn();
const LANES = [ const LANES = [
{ id: 1, owner_id: 5 }, { id: 1, owner_id: 5 },
{ id: 2, owner_id: 6 }, { id: 2, owner_id: 6 },
@@ -25,6 +27,7 @@ vi.mock("../../lib/api", () => ({
api: { api: {
auth: { me: () => me(), login: (u: string, p: string) => login(u, p), logout: vi.fn() }, auth: { me: () => me(), login: (u: string, p: string) => login(u, p), logout: vi.fn() },
lanes: { list: () => Promise.resolve({ lanes: LANES }) }, lanes: { list: () => Promise.resolve({ lanes: LANES }) },
me: { prefs: () => prefs(), savePrefs: () => Promise.resolve({ prefs: {} }) },
}, },
})); }));
@@ -52,6 +55,7 @@ const user = { id: 1, username: "mia", role: "developer" };
beforeEach(() => { beforeEach(() => {
me.mockReset(); me.mockReset();
login.mockReset(); login.mockReset();
prefs.mockReset().mockResolvedValue({ prefs: {} });
localStorage.clear(); localStorage.clear();
}); });
@@ -107,15 +111,15 @@ describe("AuthGate", () => {
render(<AuthGate>app</AuthGate>); render(<AuthGate>app</AuthGate>);
const name = await screen.findByLabelText(/username/i); const name = await screen.findByLabelText(/username/i);
const pass = screen.getByLabelText(/password/i); const pass = screen.getByLabelText(/^password$/i);
await userEvent.type(name, "mia"); await userEvent.type(name, "mia");
await userEvent.type(pass, "nope"); await userEvent.type(pass, "nope");
await userEvent.click(screen.getByRole("button")); await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
expect(await screen.findByRole("alert")).toBeInTheDocument(); expect(await screen.findByRole("alert")).toBeInTheDocument();
expect(screen.queryByText("app")).toBeNull(); expect(screen.queryByText("app")).toBeNull();
await userEvent.type(pass, "member-pass-1"); await userEvent.type(pass, "member-pass-1");
await userEvent.click(screen.getByRole("button")); await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
expect(await screen.findByText("app")).toBeInTheDocument(); expect(await screen.findByText("app")).toBeInTheDocument();
expect(login).toHaveBeenLastCalledWith("mia", "member-pass-1"); expect(login).toHaveBeenLastCalledWith("mia", "member-pass-1");
@@ -126,6 +130,32 @@ describe("AuthGate", () => {
}); });
}); });
describe("AuthGate theme", () => {
it("wears the OS theme while signed out and the account's own once signed in", async () => {
const html = document.documentElement;
// The previous user's choice, left in this browser.
localStorage.setItem("theme", "dark");
localStorage.setItem("agenthub.theme.dark", "darcula");
const mq = { matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() };
vi.stubGlobal("matchMedia", () => mq); // the OS is light
me.mockResolvedValue({ mode: "server", user: null });
prefs.mockResolvedValue({ prefs: { theme: "dark", "agenthub.theme.dark": "orca-dark" } });
login.mockResolvedValue({ user });
render(<AuthGate>app</AuthGate>);
await userEvent.type(await screen.findByLabelText(/username/i), "mia");
expect(html.dataset.mode).toBe("light");
expect(html.hasAttribute("data-theme")).toBe(false);
await userEvent.type(screen.getByLabelText(/^password$/i), "member-pass-1");
await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
await waitFor(() => expect(html.dataset.theme).toBe("orca-dark"));
expect(html.dataset.mode).toBe("dark");
expect(localStorage.getItem("agenthub.theme.dark")).toBe("orca-dark");
vi.unstubAllGlobals();
});
});
describe("usePermissions / useWorkLanes", () => { describe("usePermissions / useWorkLanes", () => {
const as = async (state: unknown) => { const as = async (state: unknown) => {
cleanup(); cleanup();
@@ -2,7 +2,8 @@
* @file Select.test.tsx * @file Select.test.tsx
* @description The shared Select as a WAI-ARIA listbox button: expanded state, * @description The shared Select as a WAI-ARIA listbox button: expanded state,
* listbox/option roles with aria-selected, aria-activedescendant tracking, * listbox/option roles with aria-selected, aria-activedescendant tracking,
* Arrow/Home/End/Enter/Escape/type-ahead, and focus back on the trigger. * Arrow/Home/End/Enter/Escape/type-ahead, focus back on the trigger, and a
* list that flips up by the room in its split pane, not the window's.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -90,4 +91,21 @@ describe("Select (listbox button)", () => {
fireEvent.click(screen.getByText("Apple")); fireEvent.click(screen.getByText("Apple"));
expect(onChange).toHaveBeenCalledWith("a"); expect(onChange).toHaveBeenCalledWith("a");
}); });
it("opens upward when its pane (data-menu-bounds) has no room below, whatever the window has", () => {
const rect = (top: number, bottom: number) => ({ top, bottom }) as DOMRect;
render(
<div data-menu-bounds data-testid="pane">
<Select value="b" onChange={() => {}} options={OPTIONS} label="Fruit" />
</div>
);
// A top pane 0–400 in a 2000px window; the trigger near its bottom.
vi.spyOn(screen.getByTestId("pane"), "getBoundingClientRect").mockReturnValue(rect(0, 400));
const button = screen.getByRole("button", { name: "Fruit" });
vi.spyOn(button, "getBoundingClientRect").mockReturnValue(rect(360, 380));
vi.stubGlobal("innerHeight", 2000);
fireEvent.click(button);
expect(screen.getByRole("listbox").className).toContain("bottom-full");
vi.unstubAllGlobals();
});
}); });
@@ -0,0 +1,143 @@
/**
* @file WorkspaceTree.owners.test.tsx
* @description Server mode, the sidebar workspace list for an analyst: every
* user's workspaces, grouped under their owner (yours first, no owner last),
* groups folding and remembered; another user's workspace is read only — no
* menu, no new session, and a session opens its analysis page, never a pane.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import type { Lane, Session } from "../../lib/types";
const ME = { id: 2, username: "ana", role: "analyst" as const };
const lane = (id: number, owner: number | null, name: string | null, title: string) =>
({
id,
title,
cwd: `/w/${id}`,
kind: "adopted",
tool: "claude",
status: "idle",
liveness: "idle",
pipeline_nodes: [],
owner_id: owner,
owner_name: name,
}) as unknown as Lane;
const lanes = [
lane(1, 3, "bob", "Bob app"),
lane(2, null, null, "Old"),
lane(3, ME.id, "ana", "Mine"),
lane(4, 1, "admin", "Admin site"),
];
vi.mock("../AuthGate", () => ({
useAuth: () => ({ state: { mode: "server", user: ME } }),
usePermissions: () => ({
isAdmin: false,
seesAll: true,
canEditLane: (l?: { owner_id?: number | null } | null) => l?.owner_id === ME.id,
}),
}));
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
lanes: { list: vi.fn(async () => ({ lanes, counts: {} })) },
sessions: {
list: vi.fn(async () => ({
sessions: [
{ id: "s-1", name: "Bob's run", status: "completed", started_at: "2026-10-01" },
] as Session[],
total: 1,
limit: 6,
offset: 0,
})),
},
run: { list: vi.fn(async () => ({ items: [] })) },
aiTools: { list: vi.fn(async () => ({ tools: [], default: "claude" })), iconUrl: () => "" },
},
};
});
import { WorkspaceTree } from "../WorkspaceTree";
function Where() {
const loc = useLocation();
return <span data-testid="where">{loc.pathname + loc.search}</span>;
}
function renderTree() {
return render(
<MemoryRouter>
<Routes>
<Route
path="*"
element={
<>
<WorkspaceTree />
<Where />
</>
}
/>
</Routes>
</MemoryRouter>
);
}
beforeEach(() => localStorage.clear());
describe("WorkspaceTree for an analyst", () => {
it("groups every workspace under its owner: yours first, no owner last", async () => {
renderTree();
await screen.findByTestId("workspace-row-1");
const order = screen
.getAllByTestId(/^workspace-(owner-\w+|row-\d+)$/)
.map((el) => el.dataset.testid);
expect(order).toEqual([
"workspace-owner-2",
"workspace-row-3",
"workspace-owner-1",
"workspace-row-4",
"workspace-owner-3",
"workspace-row-1",
"workspace-owner-none",
"workspace-row-2",
]);
expect(screen.getByTestId("workspace-owner-2")).toHaveTextContent("ana (you)");
});
it("folds a group and remembers it", async () => {
const user = userEvent.setup();
renderTree();
await user.click(await screen.findByTestId("workspace-owner-3"));
expect(screen.queryByTestId("workspace-row-1")).toBeNull();
expect(screen.getByTestId("workspace-owner-3")).toHaveAttribute("aria-expanded", "false");
expect(JSON.parse(localStorage.getItem("agenthub.sidebar.projects")!).collapsedOwners).toEqual({
"3": true,
});
});
it("keeps another user's workspace read only", async () => {
const user = userEvent.setup();
renderTree();
await screen.findByTestId("workspace-row-1");
// Yours keeps its controls; theirs has none.
expect(screen.getByTestId("workspace-menu-3")).toBeInTheDocument();
expect(screen.queryByTestId("workspace-menu-1")).toBeNull();
expect(screen.queryByTestId("workspace-new-1")).toBeNull();
// Picking it unfolds it instead of opening a workspace home.
await user.click(screen.getByTestId("workspace-open-1"));
expect(screen.getByTestId("where")).toHaveTextContent(/^\/$/);
const list = await screen.findByTestId("workspace-sessions-1");
expect(within(list).queryByTestId("workspace-session-archive-s-1")).toBeNull();
await user.click(within(list).getByTestId("workspace-session-s-1"));
expect(screen.getByTestId("where")).toHaveTextContent("/sessions/s-1");
});
});
@@ -5,7 +5,11 @@
* the message stream via MessageList. Combines a WebSocket subscription, a * the message stream via MessageList. Combines a WebSocket subscription, a
* visibility-gated polling fallback, and a manual refresh button so the view * visibility-gated polling fallback, and a manual refresh button so the view
* stays caught up even when hooks miss frames or the user is mid-text-only * stays caught up even when hooks miss frames or the user is mid-text-only
* turn (no PreToolUse fires until Stop). * turn (no PreToolUse fires until Stop). The view sticks to its end while
* the reader is there: anything that makes it taller — a new line, the live
* step, an image loading, the pane shown again after being hidden by CSS
* (a `display:none` pane cannot scroll) — keeps the newest in sight, until the
* reader scrolls up; `follow` (a chat's send) sends it back to the end.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
/* ============================================================================= /* =============================================================================
@@ -79,12 +83,21 @@ interface ConversationViewProps {
/** Fill the parent as a chat (a workspace's session view): no toolbar, no /** Fill the parent as a chat (a workspace's session view): no toolbar, no
* page-height cap — the parent sizes it and puts a composer under it. */ * page-height cap — the parent sizes it and puts a composer under it. */
fill?: boolean; fill?: boolean;
/** Opens a file named in a turn's "Edited N files" card. */
onOpenFile?: (path: string) => void;
/** The session runs in a pane: show the step in progress. */
live?: boolean;
/** Changed (a chat's send): back to the end, and stick to it. */
follow?: number;
} }
export function ConversationView({ export function ConversationView({
sessionId, sessionId,
initialTranscriptId, initialTranscriptId,
fill = false, fill = false,
onOpenFile,
live = false,
follow = 0,
}: ConversationViewProps) { }: ConversationViewProps) {
const { t } = useTranslation("sessions"); const { t } = useTranslation("sessions");
const [messages, setMessages] = useState<TranscriptMessage[]>([]); const [messages, setMessages] = useState<TranscriptMessage[]>([]);
@@ -105,6 +118,7 @@ export function ConversationView({
const lastLineRef = useRef(0); const lastLineRef = useRef(0);
const firstLineRef = useRef(0); const firstLineRef = useRef(0);
const scrollContainerRef = useRef<HTMLDivElement>(null); const scrollContainerRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const isAtBottomRef = useRef(true); const isAtBottomRef = useRef(true);
const fetchingRef = useRef(false); const fetchingRef = useRef(false);
// When a fetch is in flight and a new trigger arrives (WS event, poll, // When a fetch is in flight and a new trigger arrives (WS event, poll,
@@ -392,6 +406,30 @@ export function ConversationView({
} }
}, [hasMore, loadingHistory, loadHistory]); }, [hasMore, loadingHistory, loadHistory]);
// Stick to the end while the reader is there. Scroll events say whether
// they are; a size change — of the content, or of the box itself when a
// hidden pane is shown — never fires one, so it is watched here.
useEffect(() => {
const container = scrollContainerRef.current;
const content = contentRef.current;
if (!container || !content || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(() => {
if (isAtBottomRef.current) container.scrollTop = container.scrollHeight;
});
observer.observe(container);
observer.observe(content);
return () => observer.disconnect();
}, []);
// The reader sent a message: show it, and what follows, as it lands.
useEffect(() => {
if (!follow) return;
isAtBottomRef.current = true;
setAwayFromEnd(false);
setShowNewMsg(false);
scrollToBottom();
}, [follow, scrollToBottom]);
// Auto-scroll to bottom after initial load // Auto-scroll to bottom after initial load
useEffect(() => { useEffect(() => {
if (!loading && messages.length > 0) { if (!loading && messages.length > 0) {
@@ -450,38 +488,42 @@ export function ConversationView({
<div <div
ref={scrollContainerRef} ref={scrollContainerRef}
onScroll={handleScroll} onScroll={handleScroll}
className="flex-1 overflow-y-auto" className="flex-1 overflow-y-auto [scrollbar-gutter:stable]"
style={fill ? { minHeight: 0 } : { maxHeight: "calc(100vh - 320px)", minHeight: 200 }} style={fill ? { minHeight: 0 } : { maxHeight: "calc(100vh - 320px)", minHeight: 200 }}
> >
{/* History loading indicator */} <div ref={contentRef}>
{loadingHistory && ( {/* History loading indicator */}
<div className="flex justify-center py-3"> {loadingHistory && (
<Loader2 className="w-4 h-4 text-fg-muted animate-spin" /> <div className="flex justify-center py-3">
<span className="text-xs text-fg-muted ml-2">{t("conversation.loadingHistory")}</span> <Loader2 className="w-4 h-4 text-fg-muted animate-spin" />
</div> <span className="text-xs text-fg-muted ml-2">{t("conversation.loadingHistory")}</span>
)} </div>
)}
{/* Scroll-up for history hint */} {/* Scroll-up for history hint */}
{hasMore && !loadingHistory && !loading && ( {hasMore && !loadingHistory && !loading && (
<div className="flex justify-center py-2"> <div className="flex justify-center py-2">
<span className="text-[11px] text-fg-muted">↑ {t("conversation.scrollForOlder")}</span> <span className="text-[11px] text-fg-muted">
</div> ↑ {t("conversation.scrollForOlder")}
)} </span>
</div>
)}
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-12 text-fg-muted text-sm"> <div className="flex items-center justify-center py-12 text-fg-muted text-sm">
{t("conversation.loading")} {t("conversation.loading")}
</div> </div>
) : messages.length === 0 ? ( ) : messages.length === 0 ? (
<div className="mx-auto max-w-md py-12 text-center"> <div className="mx-auto max-w-md py-12 text-center">
<p className="text-sm text-fg-secondary">{t("conversation.empty")}</p> <p className="text-sm text-fg-secondary">{t("conversation.empty")}</p>
<p className="mt-2 text-xs leading-relaxed text-fg-muted"> <p className="mt-2 text-xs leading-relaxed text-fg-muted">
{t("conversation.emptyBody")} {t("conversation.emptyBody")}
</p> </p>
</div> </div>
) : ( ) : (
<MessageList messages={messages} loading={false} /> <MessageList messages={messages} loading={false} onOpenFile={onOpenFile} live={live} />
)} )}
</div>
</div> </div>
{/* Claude Desktop's round "back to the end" button */} {/* Claude Desktop's round "back to the end" button */}
@@ -4,7 +4,16 @@
* transcript: alternating user / assistant rows with collapsible thinking * transcript: alternating user / assistant rows with collapsible thinking
* blocks, inline ToolCallBlocks for tool_use / tool_result pairs, and * blocks, inline ToolCallBlocks for tool_use / tool_result pairs, and
* MarkdownContent for prose. Used by ConversationView as the main body of * MarkdownContent for prose. Used by ConversationView as the main body of
* the Conversation tab on the Session detail page. * the Conversation tab on the Session detail page. An image the prompt box
* attached reaches the transcript as its saved path; a user turn shows the
* image instead (`GET /api/run/drop/:name`). Each assistant turn that changed
* files ends with Claude Desktop's "Edited N files" card: every file with the
* lines added and removed (the tool results' `diff`); a click opens the file
* when the caller can (`onOpenFile`). A thinking block that carries text is
* the model's narration to the user (Claude Code ≥ 2.1.280 stores real
* thinking empty) and reads as prose, like Claude Desktop shows it; a live
* chat (`live`) ends, until its turn is over, with what the model is doing
* now — the call still running, else "Thinking…" — and for how long.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
/* ============================================================================= /* =============================================================================
@@ -53,7 +62,8 @@
* *
* ----------------------------------------------------------------------------- */ * ----------------------------------------------------------------------------- */
import { memo, useState, useMemo } from "react"; import { memo, useEffect, useState, useMemo } from "react";
import { ImageLightbox } from "../ImageLightbox";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import i18n from "../../i18n"; import i18n from "../../i18n";
import { import {
@@ -69,6 +79,8 @@ import {
Pencil, Pencil,
Workflow, Workflow,
Cog, Cog,
FileText,
Loader2,
} from "lucide-react"; } from "lucide-react";
import type { TranscriptMessage, TranscriptContent, TranscriptSender } from "../../lib/types"; import type { TranscriptMessage, TranscriptContent, TranscriptSender } from "../../lib/types";
@@ -113,12 +125,16 @@ const SENDER_STYLES: Record<
}; };
import { ToolCallBlock, buildSummary, renderInput, renderResult } from "./ToolCallBlock"; import { ToolCallBlock, buildSummary, renderInput, renderResult } from "./ToolCallBlock";
import { MarkdownContent } from "./MarkdownContent"; import { MarkdownContent } from "./MarkdownContent";
import { fmt, formatModelName } from "../../lib/format"; import { fmt, formatModelName, formatMs, parseDate } from "../../lib/format";
import { parseTuiSegments, stripAnsi, hasTuiTags, type TuiSegment } from "./tuiSegments"; import { parseTuiSegments, stripAnsi, hasTuiTags, type TuiSegment } from "./tuiSegments";
interface MessageListProps { interface MessageListProps {
messages: TranscriptMessage[]; messages: TranscriptMessage[];
loading: boolean; loading: boolean;
/** Opens a file named in the "Edited N files" card. */
onOpenFile?: (path: string) => void;
/** The session is running in a pane: show the step in progress. */
live?: boolean;
} }
/** Build a map from tool_use id → tool_result for matching */ /** Build a map from tool_use id → tool_result for matching */
@@ -135,6 +151,16 @@ function buildToolResultMap(messages: TranscriptMessage[]): Map<string, Transcri
return map; return map;
} }
/** The whole prompt a message was answered from: `input_tokens` is only its
* uncached tail (a few tokens once the cache is warm), the rest is cache. */
function promptTokens(u: NonNullable<TranscriptMessage["usage"]>): number {
return (
(u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0)
);
}
const usageTitle = (u: NonNullable<TranscriptMessage["usage"]>) =>
`input ${fmt(u.input_tokens || 0)} · cache read ${fmt(u.cache_read_input_tokens || 0)} · cache write ${fmt(u.cache_creation_input_tokens || 0)} · output ${fmt(u.output_tokens || 0)}`;
/** Detect if text is skill loading content (starts with "Base directory for this skill:") */ /** Detect if text is skill loading content (starts with "Base directory for this skill:") */
function isSkillContent(text: string): boolean { function isSkillContent(text: string): boolean {
return text.startsWith("Base directory for this skill:"); return text.startsWith("Base directory for this skill:");
@@ -390,34 +416,120 @@ type Item =
| { kind: "row"; key: string; msg: TranscriptMessage } | { kind: "row"; key: string; msg: TranscriptMessage }
| { kind: "user"; key: string; msg: TranscriptMessage } | { kind: "user"; key: string; msg: TranscriptMessage }
| { kind: "text"; key: string; msg: TranscriptMessage; text: string } | { kind: "text"; key: string; msg: TranscriptMessage; text: string }
| { kind: "work"; key: string; blocks: TranscriptContent[] }; | { kind: "work"; key: string; blocks: TranscriptContent[] }
| { kind: "edits"; key: string; blocks: TranscriptContent[] };
const EDIT_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[] { export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[] {
const items: Item[] = []; const items: Item[] = [];
// The file-changing calls of the turn so far, closed into one card when the
// human speaks again (or the list ends).
let edits: TranscriptContent[] = [];
const endTurn = (key: string) => {
if (edits.length) items.push({ kind: "edits", key: `edits:${key}`, blocks: edits });
edits = [];
};
messages.forEach((msg, idx) => { messages.forEach((msg, idx) => {
const key = keys[idx]!; const key = keys[idx]!;
if (msg.type === "session_event") return items.push({ kind: "event", key, msg }); if (msg.type === "session_event") return items.push({ kind: "event", key, msg });
// Purely tool_result: shown inside its call's block. // Purely tool_result: shown inside its call's block.
if (msg.type === "user" && !msg.content.some((c) => c.type === "text")) return; if (msg.type === "user" && !msg.content.some((c) => c.type === "text")) return;
const sender = msg.sender ?? (msg.type === "assistant" ? "assistant" : "user"); const sender = msg.sender ?? (msg.type === "assistant" ? "assistant" : "user");
if (sender === "user") return items.push({ kind: "user", key, msg }); if (sender === "user") {
endTurn(key);
return items.push({ kind: "user", key, msg });
}
if (sender !== "assistant") return items.push({ kind: "row", key, msg }); if (sender !== "assistant") return items.push({ kind: "row", key, msg });
msg.content.forEach((block, b) => { msg.content.forEach((block, b) => {
const last = items[items.length - 1]; const last = items[items.length - 1];
if (block.type === "tool_use" || (block.type === "thinking" && block.text)) { if (block.type === "tool_use" && EDIT_TOOLS.has(block.name ?? "")) edits.push(block);
if (block.type === "tool_use") {
if (last?.kind === "work") last.blocks.push(block); if (last?.kind === "work") last.blocks.push(block);
else items.push({ kind: "work", key: `${key}:${b}`, blocks: [block] }); else items.push({ kind: "work", key: `${key}:${b}`, blocks: [block] });
} else if (block.type === "text" && block.text?.trim()) { } else if ((block.type === "text" || block.type === "thinking") && block.text?.trim()) {
items.push({ kind: "text", key: `${key}:${b}`, msg, text: block.text }); items.push({ kind: "text", key: `${key}:${b}`, msg, text: block.text });
} }
}); });
}); });
endTurn(keys[keys.length - 1] ?? "end");
return items; return items;
} }
export function MessageList({ messages, loading }: MessageListProps) { /** Claude Desktop's end-of-turn card: the files the turn changed, each with
* the lines added and removed. A call still running or failed is left out. */
function EditedFiles({
blocks,
toolResultMap,
onOpenFile,
}: {
blocks: TranscriptContent[];
toolResultMap: Map<string, TranscriptContent>;
onOpenFile?: (path: string) => void;
}) {
const { t } = useTranslation("sessions");
// `counted`: a result carried its +/- lines (an older server sends none).
const files = new Map<string, { added: number; removed: number; counted: boolean }>();
for (const b of blocks) {
const input = (b.input ?? {}) as Record<string, unknown>;
const p = input.file_path ?? input.notebook_path;
const r = b.id ? toolResultMap.get(b.id) : undefined;
if (typeof p !== "string" || !r || r.is_error) continue;
const f = files.get(p) ?? { added: 0, removed: 0, counted: false };
f.added += r.diff?.added ?? 0;
f.removed += r.diff?.removed ?? 0;
f.counted ||= !!r.diff;
files.set(p, f);
}
if (files.size === 0) return null;
let added = 0;
let removed = 0;
let counted = false;
files.forEach((f) => {
added += f.added;
removed += f.removed;
counted ||= f.counted;
});
const counts = (a: number, r: number, show = counted) =>
show && (
<span className="flex-shrink-0 font-mono text-xs">
<span className="text-status-success">+{a}</span>{" "}
<span className="text-status-danger">−{r}</span>
</span>
);
return (
<div data-testid="edited-files" className="overflow-hidden rounded-xl border border-border">
<div className="flex items-center gap-2 border-b border-border bg-surface-1 px-3 py-2 text-sm text-fg-primary">
<Pencil className="h-3.5 w-3.5 text-fg-muted" />
<span className="flex-1">{t("conversation.edited", { count: files.size })}</span>
{counts(added, removed)}
</div>
{[...files].map(([p, f]) => (
<button
key={p}
type="button"
title={p}
disabled={!onOpenFile}
onClick={() => onOpenFile?.(p)}
className="flex w-full items-center gap-2 border-b border-border px-3 py-2 text-left text-sm text-fg-secondary last:border-b-0 enabled:hover:bg-surface-2"
>
<FileText className="h-3.5 w-3.5 flex-shrink-0 text-fg-muted" />
<span className="min-w-0 flex-1 truncate">{p.split("/").pop()}</span>
{counts(f.added, f.removed, f.counted)}
{onOpenFile && <ChevronRight className="h-3.5 w-3.5 flex-shrink-0 text-fg-muted" />}
</button>
))}
</div>
);
}
export function MessageList({ messages, loading, onOpenFile, live = false }: MessageListProps) {
const { t } = useTranslation("sessions"); const { t } = useTranslation("sessions");
const toolResultMap = useMemo(() => buildToolResultMap(messages), [messages]); const toolResultMap = useMemo(() => buildToolResultMap(messages), [messages]);
const pending = useMemo(
() => (live ? workingOn(messages, toolResultMap) : null),
[live, messages, toolResultMap]
);
const keys = useMemo(() => messageKeys(messages), [messages]); const keys = useMemo(() => messageKeys(messages), [messages]);
const items = useMemo(() => chatItems(messages, keys), [messages, keys]); const items = useMemo(() => chatItems(messages, keys), [messages, keys]);
@@ -434,7 +546,7 @@ export function MessageList({ messages, loading }: MessageListProps) {
} }
return ( return (
<div className="mx-auto w-full max-w-3xl space-y-4 px-4 py-4"> <div className="mx-auto w-full max-w-[max(48rem,70%)] space-y-4 px-4 py-4">
{items.map((item) => { {items.map((item) => {
switch (item.kind) { switch (item.kind) {
case "event": case "event":
@@ -453,14 +565,133 @@ export function MessageList({ messages, loading }: MessageListProps) {
return <AssistantText key={item.key} msg={item.msg} text={item.text} />; return <AssistantText key={item.key} msg={item.msg} text={item.text} />;
case "work": case "work":
return <WorkGroup key={item.key} blocks={item.blocks} toolResultMap={toolResultMap} />; return <WorkGroup key={item.key} blocks={item.blocks} toolResultMap={toolResultMap} />;
case "edits":
// The card closes a finished turn; one still working has none yet.
if (pending && item === items[items.length - 1]) return null;
return (
<EditedFiles
key={item.key}
blocks={item.blocks}
toolResultMap={toolResultMap}
onOpenFile={onOpenFile}
/>
);
} }
})} })}
{pending && <RunningStep block={pending.block} since={pending.since} />}
</div>
);
}
// No line for this long and no call running: the turn died with no end
// written (a crash, a kill) — stop saying it works.
const IDLE_AFTER_MS = 15 * 60_000;
/** What the model is doing while its turn is not over: the call still
* waiting for its result (`block`), else thinking (`block: null`); `since`
* is that line's time. Null once the turn ended. */
export function workingOn(
messages: TranscriptMessage[],
results: Map<string, TranscriptContent>,
now = Date.now()
): { block: TranscriptContent | null; since: string | null } | null {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]!;
const sender = msg.sender ?? (msg.type === "assistant" ? "assistant" : "user");
// A rename marker or a harness line says nothing about the turn.
if (msg.type === "session_event" || sender === "system" || sender === "orchestrator") continue;
const since = msg.timestamp ?? null;
const fresh = !since || now - parseDate(since).getTime() < IDLE_AFTER_MS;
if (msg.type === "assistant") {
const call = [...msg.content].reverse().find((b) => b.type === "tool_use");
if (call?.id && !results.has(call.id)) return { block: call, since };
// "tool_use": a call follows. "end_turn" (or none, from an older server): over.
return msg.stop_reason === "tool_use" && fresh ? { block: null, since } : null;
}
// A prompt or a tool result: the model answers it next — unless the user stopped it.
const text = msg.content.find((c) => c.type === "text")?.text ?? "";
if (text.startsWith("[Request interrupted")) return null;
return fresh ? { block: null, since } : null;
}
return null;
}
/** Claude Desktop's live line: the step running now and for how long. */
function RunningStep({ block, since }: { block: TranscriptContent | null; since: string | null }) {
const { t } = useTranslation("sessions");
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(timer);
}, []);
const input = block?.input as Record<string, unknown> | undefined;
const title = !block
? t("conversation.working")
: typeof input?.description === "string" && input.description
? input.description
: [block.name, buildSummary(block)].filter(Boolean).join(" ");
return (
<div data-testid="chat-running-step" className="flex items-center gap-2 text-sm text-fg-muted">
<Loader2 className="h-3.5 w-3.5 flex-shrink-0 animate-spin text-accent" />
<span className="min-w-0 truncate">{title}</span>
{since && (
<span className="flex-shrink-0 font-mono text-xs">
{formatMs(now - parseDate(since).getTime())}
</span>
)}
</div> </div>
); );
} }
const meta = "text-[10px] font-mono text-fg-muted"; const meta = "text-[10px] font-mono text-fg-muted";
// What the prompt box sends for an attached image: its saved path under the
// drop dir (`<tmp>/agenthub-drops-<uid>/<16 hex>[-name].png`, server/routes/run.js).
const DROP_IMAGE =
/(?<=^|\s)\S*[\\/]agenthub-drops--?\d+[\\/]([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.(?:png|jpe?g|gif|webp))(?=\s|$)/gi;
/** A user turn's text without its dropped-image paths, and those images' names. */
export function splitDropImages(text: string): { text: string; images: string[] } {
const images: string[] = [];
const rest = text.replace(DROP_IMAGE, (_all, name: string) => {
images.push(name);
return "";
});
return { text: images.length ? rest.replace(/[ \t]+$/gm, "").trim() : text, images };
}
function DropImage({ name }: { name: string }) {
const [open, setOpen] = useState(false);
const [gone, setGone] = useState(false);
const src = `/api/run/drop/${encodeURIComponent(name)}`;
// Drops are pruned after a day: the name is all that is left to show.
if (gone)
return (
<span className="flex items-center gap-1.5 rounded-lg border border-border bg-surface-2 px-2 py-1 text-xs text-fg-muted">
<FileText className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{name.replace(/^[0-9a-f]{16}-?/, "") || name}</span>
</span>
);
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="block overflow-hidden rounded-xl border border-border bg-surface-2 hover:border-accent/50"
>
<img
src={src}
alt={name}
loading="lazy"
onError={() => setGone(true)}
className="max-h-48 max-w-64 object-contain"
/>
</button>
{open && <ImageLightbox src={src} alt={name} onClose={() => setOpen(false)} />}
</>
);
}
/** The human's turn: a bubble on the right, its time on hover. A long one (a /** The human's turn: a bubble on the right, its time on hover. A long one (a
* paste, a compaction summary) shows its first lines until opened. */ * paste, a compaction summary) shows its first lines until opened. */
const LONG_BUBBLE = 800; const LONG_BUBBLE = 800;
@@ -468,35 +699,54 @@ const UserBubble = memo(function UserBubble({ msg }: { msg: TranscriptMessage })
const { t } = useTranslation("sessions"); const { t } = useTranslation("sessions");
const long = msg.content.reduce((n, c) => n + (c.text?.length ?? 0), 0) > LONG_BUBBLE; const long = msg.content.reduce((n, c) => n + (c.text?.length ?? 0), 0) > LONG_BUBBLE;
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const blocks = useMemo(
() =>
msg.content.map((block) =>
block.type === "text" && block.text ? splitDropImages(block.text) : null
),
[msg.content]
);
const images = blocks.flatMap((b) => b?.images ?? []);
const hasText = blocks.some((b) => b?.text);
return ( return (
<div <div
data-testid="chat-user" data-testid="chat-user"
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 60px" }} style={{ contentVisibility: "auto", containIntrinsicSize: "auto 60px" }}
className="group flex flex-col items-end gap-1" className="group flex flex-col items-end gap-1"
> >
<div {images.length > 0 && (
className={`max-w-[85%] min-w-0 space-y-2 rounded-2xl bg-surface-2 px-4 py-2.5 text-sm text-fg-primary ${ <div
long && !open data-testid="chat-user-images"
? "max-h-40 overflow-hidden [mask-image:linear-gradient(to_bottom,black_60%,transparent)]" className="flex max-w-[85%] flex-wrap justify-end gap-2"
: "" >
}`} {images.map((name) => (
> <DropImage key={name} name={name} />
{msg.content.map((block, b) => { ))}
if (block.type !== "text" || !block.text) return null; </div>
// Plain typing reads as typed; anything structured keeps its rendering. )}
const plain = {hasText && (
!hasTuiTags(block.text) && <div
!isSkillContent(block.text) && className={`max-w-[85%] min-w-0 space-y-2 rounded-2xl bg-surface-2 px-4 py-2.5 text-sm text-fg-primary ${
!isTaskNotification(block.text); long && !open
return plain ? ( ? "max-h-40 overflow-hidden [mask-image:linear-gradient(to_bottom,black_60%,transparent)]"
<p key={b} className="whitespace-pre-wrap break-words leading-relaxed"> : ""
{stripAnsi(block.text).trim()} }`}
</p> >
) : ( {blocks.map((block, b) => {
renderTextBlock(block.text, b) const text = block?.text;
); if (!text) return null;
})} // Plain typing reads as typed; anything structured keeps its rendering.
</div> const plain = !hasTuiTags(text) && !isSkillContent(text) && !isTaskNotification(text);
return plain ? (
<p key={b} className="whitespace-pre-wrap break-words leading-relaxed">
{stripAnsi(text).trim()}
</p>
) : (
renderTextBlock(text, b)
);
})}
</div>
)}
{long && ( {long && (
<button <button
type="button" type="button"
@@ -540,8 +790,8 @@ const AssistantText = memo(function AssistantText({
> >
{msg.model && <span>{formatModelName(msg.model)}</span>} {msg.model && <span>{formatModelName(msg.model)}</span>}
{msg.usage && ( {msg.usage && (
<span> <span title={usageTitle(msg.usage)}>
↓ {fmt(msg.usage.input_tokens)} · ↑ {fmt(msg.usage.output_tokens)} ↓ {fmt(promptTokens(msg.usage))} · ↑ {fmt(msg.usage.output_tokens)}
</span> </span>
)} )}
{msg.timestamp && <span>{formatLocalTime(msg.timestamp)}</span>} {msg.timestamp && <span>{formatLocalTime(msg.timestamp)}</span>}
@@ -592,6 +842,8 @@ const WORK_KIND: Record<string, string> = {
}; };
/** "Ran 3 commands, edited 2 files" — or "Thinking" when it only thought. */ /** "Ran 3 commands, edited 2 files" — or "Thinking" when it only thought. */
const KIND_ORDER = ["commands", "edits", "reads", "searches", "web", "agents", "tools"];
export function workLabel(blocks: TranscriptContent[]): string { export function workLabel(blocks: TranscriptContent[]): string {
const counts = new Map<string, number>(); const counts = new Map<string, number>();
for (const b of blocks) { for (const b of blocks) {
@@ -600,7 +852,9 @@ export function workLabel(blocks: TranscriptContent[]): string {
counts.set(kind, (counts.get(kind) ?? 0) + 1); counts.set(kind, (counts.get(kind) ?? 0) + 1);
} }
if (!counts.size) return i18n.t("sessions:conversation.thinking"); if (!counts.size) return i18n.t("sessions:conversation.thinking");
// Claude Desktop's order: commands, edits, reads, … and other tools last.
const text = [...counts] const text = [...counts]
.sort(([a], [b]) => KIND_ORDER.indexOf(a) - KIND_ORDER.indexOf(b))
.map(([kind, count]) => i18n.t(`sessions:conversation.work.${kind}`, { count })) .map(([kind, count]) => i18n.t(`sessions:conversation.work.${kind}`, { count }))
.join(", "); .join(", ");
return text.charAt(0).toUpperCase() + text.slice(1); return text.charAt(0).toUpperCase() + text.slice(1);
@@ -751,8 +1005,11 @@ const MessageRow = memo(function MessageRow({
</span> </span>
)} )}
{msg.usage && ( {msg.usage && (
<span className="text-[10px] text-fg-muted font-mono inline-flex items-center gap-1"> <span
<span className="text-status-success/70">↓ {fmt(msg.usage.input_tokens)}</span> title={usageTitle(msg.usage)}
className="text-[10px] text-fg-muted font-mono inline-flex items-center gap-1"
>
<span className="text-status-success/70">↓ {fmt(promptTokens(msg.usage))}</span>
<span className="text-fg-muted">·</span> <span className="text-fg-muted">·</span>
<span className="text-syntax-number">↑ {fmt(msg.usage.output_tokens)}</span> <span className="text-syntax-number">↑ {fmt(msg.usage.output_tokens)}</span>
</span> </span>
@@ -0,0 +1,93 @@
/**
* @file ConversationView.follow.test.tsx
* @description A chat sticks to its end: when it grows or is shown again
* after being hidden (a size change, which fires no scroll event) it scrolls
* to the newest while the reader is at the end, leaves a reader who scrolled
* up alone, and a send (`follow`) brings it back to the end.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { act, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ConversationView } from "../ConversationView";
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
sessions: {
transcripts: vi.fn(async () => ({ transcripts: [] })),
transcript: vi.fn(async () => ({
messages: [
{
type: "user",
content: [{ type: "text", text: "hello" }],
timestamp: "2026-10-09T00:00:00Z",
},
],
total: 1,
has_more: false,
first_line: 1,
last_line: 1,
})),
},
},
};
});
let resize: (() => void) | null = null;
const original = globalThis.ResizeObserver;
beforeEach(() => {
resize = null;
// @ts-expect-error test stub
globalThis.ResizeObserver = class {
constructor(cb: () => void) {
resize = cb;
}
observe() {}
disconnect() {}
};
vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => {
cb(0);
return 0;
});
});
afterEach(() => {
globalThis.ResizeObserver = original;
vi.restoreAllMocks();
});
/** The scroll box, with the layout jsdom does not compute. */
function box(height: number) {
const el = screen.getByText("hello").closest(".overflow-y-auto") as HTMLElement;
Object.defineProperty(el, "scrollHeight", { configurable: true, value: height });
Object.defineProperty(el, "clientHeight", { configurable: true, value: 200 });
Object.defineProperty(el, "scrollTop", { configurable: true, writable: true, value: 0 });
return el;
}
describe("ConversationView sticks to its end", () => {
it("scrolls to the newest when it grows or is shown again, unless the reader scrolled up", async () => {
const view = render(<ConversationView sessionId="s1" fill live />);
await screen.findByText("hello");
const el = box(1000);
// Shown again after display:none, or taller: only a resize says so.
act(() => resize?.());
expect(el.scrollTop).toBe(1000);
// The reader scrolls up: growing no longer pulls them down.
el.scrollTop = 100;
fireEvent.scroll(el);
Object.defineProperty(el, "scrollHeight", { configurable: true, value: 1400 });
act(() => resize?.());
expect(el.scrollTop).toBe(100);
// They send a message: back to the end, and it sticks again.
view.rerender(<ConversationView sessionId="s1" fill live follow={1} />);
expect(el.scrollTop).toBe(1400);
Object.defineProperty(el, "scrollHeight", { configurable: true, value: 1600 });
act(() => resize?.());
expect(el.scrollTop).toBe(1600);
});
});
@@ -0,0 +1,70 @@
/**
* @file MessageList.edits.test.tsx
* @description Each assistant turn that changed files ends with Claude
* Desktop's "Edited N files" card: one row per file with the lines added and
* removed summed over the turn, a failed call left out, and a row click handed
* to the caller.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi } from "vitest";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { MessageList } from "../MessageList";
import type { TranscriptMessage } from "../../../lib/types";
const at = "2026-10-09T08:00:00.000Z";
const user = (text: string): TranscriptMessage =>
({
type: "user",
sender: "user",
timestamp: at,
content: [{ type: "text", text }],
}) as TranscriptMessage;
const call = (id: string, name: string, file_path: string): TranscriptMessage =>
({
type: "assistant",
sender: "assistant",
timestamp: at,
content: [{ type: "tool_use", id, name, input: { file_path } }],
}) as TranscriptMessage;
const result = (id: string, diff?: { added: number; removed: number }, is_error = false) =>
({
type: "user",
sender: "tool",
timestamp: at,
content: [{ type: "tool_result", id, output: "ok", is_error, ...(diff ? { diff } : {}) }],
}) as TranscriptMessage;
describe("the Edited N files card", () => {
it("sums each file's lines over the turn and opens a file on click", () => {
const onOpenFile = vi.fn();
render(
<MessageList
loading={false}
onOpenFile={onOpenFile}
messages={[
user("fix it"),
call("1", "Edit", "/w/a.ts"),
result("1", { added: 3, removed: 1 }),
call("2", "Write", "/w/b.md"),
result("2", { added: 10, removed: 0 }),
call("3", "Edit", "/w/a.ts"),
result("3", { added: 2, removed: 2 }),
call("4", "Edit", "/w/c.ts"),
result("4", undefined, true),
user("thanks"),
call("5", "Read", "/w/a.ts"),
result("5"),
]}
/>
);
const cards = screen.getAllByTestId("edited-files");
expect(cards).toHaveLength(1); // the second turn only read
const card = cards[0]!;
expect(within(card).getByText("Edited 2 files")).toBeTruthy();
expect(within(card).getByText("+15")).toBeTruthy();
expect(within(card).getByText("+5")).toBeTruthy(); // a.ts: 3+2 added
expect(within(card).queryByText("c.ts")).toBeNull(); // failed
fireEvent.click(within(card).getByTitle("/w/b.md"));
expect(onOpenFile).toHaveBeenCalledWith("/w/b.md");
});
});
@@ -0,0 +1,54 @@
/**
* @file MessageList.images.test.tsx
* @description An image the prompt box attached reaches the transcript as its
* saved drop path; the user's turn shows the image (served by
* `GET /api/run/drop/:name`), opens it full size on the page, and keeps any
* other path as typed.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { MessageList, splitDropImages } from "../MessageList";
import type { TranscriptMessage } from "../../../lib/types";
const SHOT = "366d0a87d12176a2-image.png";
describe("dropped images in a user turn", () => {
it("splitDropImages takes drop-dir image paths out of the text", () => {
expect(
splitDropImages(`Đây là cli và version đã có. /tmp/agenthub-drops-1001/${SHOT}`)
).toEqual({ text: "Đây là cli và version đã có.", images: [SHOT] });
// Windows' temp dir, no prefix name.
expect(splitDropImages("C:\\Temp\\agenthub-drops--1\\0123456789abcdef.webp").images).toEqual([
"0123456789abcdef.webp",
]);
// Not a drop, or not an image: left as typed.
for (const text of [
"/home/me/shot.png",
"/tmp/agenthub-drops-1001/0123456789abcdef-spec.pdf",
]) {
expect(splitDropImages(text)).toEqual({ text, images: [] });
}
});
it("shows the image instead of its path, full size on click", () => {
const messages = [
{
type: "user",
sender: "user",
timestamp: "2026-10-09T08:00:00.000Z",
content: [{ type: "text", text: `look at this /tmp/agenthub-drops-1001/${SHOT}` }],
},
] as TranscriptMessage[];
render(<MessageList messages={messages} loading={false} />);
const bubble = screen.getByTestId("chat-user");
expect(bubble).toHaveTextContent("look at this");
expect(bubble).not.toHaveTextContent("agenthub-drops");
const img = screen.getByRole("img", { name: SHOT });
expect(img.getAttribute("src")).toBe(`/api/run/drop/${SHOT}`);
fireEvent.click(img);
expect(screen.getByTestId("image-lightbox").parentElement).toBe(document.body);
});
});
@@ -9,7 +9,7 @@
*/ */
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react"; import { fireEvent, render, screen } from "@testing-library/react";
import { MessageList, workLabel } from "../MessageList"; import { MessageList, workLabel, workingOn } from "../MessageList";
import type { TranscriptMessage } from "../../../lib/types"; import type { TranscriptMessage } from "../../../lib/types";
function msg(partial: Partial<TranscriptMessage>): TranscriptMessage { function msg(partial: Partial<TranscriptMessage>): TranscriptMessage {
@@ -76,5 +76,132 @@ describe("MessageList — sender attribution", () => {
"Ran 2 commands, edited a file, read a file" "Ran 2 commands, edited a file, read a file"
); );
expect(workLabel([{ type: "thinking", text: "hm" }])).toBe("Thinking"); expect(workLabel([{ type: "thinking", text: "hm" }])).toBe("Thinking");
// Desktop's fixed order, whatever ran first: other tools always last.
expect(workLabel([use("mcp__x__y"), use("Read"), use("Bash")])).toBe(
"Ran 1 command, read a file, used a tool"
);
});
it("reads a thinking block's text as narration, between two groups", () => {
const call = (id: string) =>
msg({
type: "assistant",
sender: "assistant",
content: [{ type: "tool_use", id, name: "Bash", input: { command: `echo ${id}` } }],
});
const result = (id: string) => msg({ content: [{ type: "tool_result", id, output: "ok" }] });
const say = (text: string) =>
msg({ type: "assistant", sender: "assistant", content: [{ type: "thinking", text }] });
render(
<MessageList
loading={false}
messages={[call("a"), result("a"), say("Found an issue."), say(""), call("b"), result("b")]}
/>
);
expect(screen.getAllByTestId("chat-tools")).toHaveLength(2);
expect(screen.getByText("Found an issue.")).toBeTruthy();
});
it("shows the whole prompt as ↓, not just its uncached tail", () => {
render(
<MessageList
loading={false}
messages={[
msg({
type: "assistant",
sender: "assistant",
content: [{ type: "text", text: "done" }],
usage: {
input_tokens: 2,
output_tokens: 40,
cache_read_input_tokens: 120000,
cache_creation_input_tokens: 3000,
},
}),
]}
/>
);
expect(screen.getByText(/↓ 123/)).toBeTruthy();
});
it("ends a live chat with the step still running", () => {
const messages = [
msg({ type: "user", sender: "user", content: [{ type: "text", text: "test it" }] }),
msg({
type: "assistant",
sender: "assistant",
timestamp: new Date(Date.now() - 65_000).toISOString(),
content: [
{
type: "tool_use",
id: "t1",
name: "Bash",
input: { command: "npm test", description: "Run the full client suite" },
},
],
}),
];
const { rerender } = render(<MessageList loading={false} messages={messages} live />);
const row = screen.getByTestId("chat-running-step");
expect(row).toHaveTextContent("Run the full client suite");
expect(row).toHaveTextContent(/1m \ds/);
// Its result arrived: nothing is running any more.
rerender(
<MessageList
loading={false}
live
messages={[
...messages,
msg({ content: [{ type: "tool_result", id: "t1", output: "ok" }] }),
]}
/>
);
expect(screen.queryByTestId("chat-running-step")).toBeNull();
});
it("knows what a live turn is doing until it ends", () => {
const now = Date.parse("2026-06-26T08:20:00.000Z");
const at = "2026-06-26T08:19:00.000Z";
const prompt = msg({ type: "user", sender: "user", timestamp: at });
const call = msg({
type: "assistant",
sender: "assistant",
timestamp: at,
stop_reason: "tool_use",
content: [{ type: "tool_use", id: "t1", name: "Bash", input: { command: "ls" } }],
});
const result = msg({
sender: "tool",
timestamp: at,
content: [{ type: "tool_result", id: "t1", output: "ok" }],
});
const reply = (stop_reason?: string) =>
msg({
type: "assistant",
sender: "assistant",
timestamp: at,
stop_reason,
content: [{ type: "text", text: "ok" }],
});
const results = (m: ReturnType<typeof msg>[]) =>
new Map(
m.flatMap((x) => x.content.filter((c) => c.type === "tool_result").map((c) => [c.id!, c]))
);
const state = (m: ReturnType<typeof msg>[]) => workingOn(m, results(m), now);
expect(state([prompt])).toEqual({ block: null, since: at }); // thinking on the prompt
expect(state([prompt, call])?.block?.id).toBe("t1"); // the call is running
expect(state([prompt, call, result])).toEqual({ block: null, since: at }); // thinking again
expect(state([prompt, call, result, reply("tool_use")])?.block).toBeNull(); // narration, a call follows
expect(state([prompt, call, result, reply("end_turn")])).toBeNull(); // turn over
expect(state([prompt, call, result, reply()])).toBeNull(); // an older server: no stop_reason
const stopped = msg({
type: "user",
sender: "user",
content: [{ type: "text", text: "[Request interrupted by user]" }],
});
expect(state([prompt, call, result, stopped])).toBeNull();
// Silent for 15 minutes with nothing running: the turn died.
expect(workingOn([prompt], new Map(), now + 20 * 60_000)).toBeNull();
}); });
}); });
+17 -3
View File
@@ -10,7 +10,8 @@
* (Claude Code or Codex) — this choice is immutable after creation. Either * (Claude Code or Codex) — this choice is immutable after creation. Either
* mode's path field can be filled by typing, by the CwdAutocomplete * mode's path field can be filled by typing, by the CwdAutocomplete
* suggestions, or by Browse (`PathPickButton`: the OS's own folder picker * suggestions, or by Browse (`PathPickButton`: the OS's own folder picker
* opened by the server, the in-app `FolderBrowseModal` where it cannot be). * opened by the server, the in-app `FolderBrowseModal` where it cannot be) —
* which a click into the field opens too.
* The worktree lane returned is `status: "provisioning"`: * The worktree lane returned is `status: "provisioning"`:
* the route answers before the actual `git worktree add` runs, so this * the route answers before the actual `git worktree add` runs, so this
* component polls `GET /api/lanes/:id` until that finishes before running the * component polls `GET /api/lanes/:id` until that finishes before running the
@@ -327,6 +328,7 @@ export function AddLaneModal({
const titleId = useId(); const titleId = useId();
const panelRef = useRef<HTMLDivElement>(null); const panelRef = useRef<HTMLDivElement>(null);
const cancelRef = useRef<HTMLButtonElement>(null); const cancelRef = useRef<HTMLButtonElement>(null);
const browseRef = useRef<HTMLButtonElement>(null);
useDialog(open, panelRef, handleCancel, cancelRef); useDialog(open, panelRef, handleCancel, cancelRef);
// A summary on screen means the lane already exists and a step failed, so // A summary on screen means the lane already exists and a step failed, so
@@ -459,7 +461,14 @@ export function AddLaneModal({
{mode === "repo" ? t("addLaneRepoLabelAdopt") : t("addLaneRepoLabel")} {mode === "repo" ? t("addLaneRepoLabelAdopt") : t("addLaneRepoLabel")}
</label> </label>
<div className="flex gap-1.5" data-tour="add-lane-folder"> <div className="flex gap-1.5" data-tour="add-lane-folder">
<div className="min-w-0 flex-1"> {/* A click into the path opens the picker, as Browse does (the
OS's own where it can be); typing still works from the keyboard. */}
<div
className="min-w-0 flex-1"
onClick={(e) => {
if ((e.target as HTMLElement).tagName === "INPUT") browseRef.current?.click();
}}
>
<CwdAutocomplete <CwdAutocomplete
inputId="add-lane-repo" inputId="add-lane-repo"
value={sourceRepo} value={sourceRepo}
@@ -467,7 +476,12 @@ export function AddLaneModal({
suggestions={cwdSuggestions} suggestions={cwdSuggestions}
/> />
</div> </div>
<PathPickButton start={sourceRepo} onPick={setSourceRepo} showLabel /> <PathPickButton
start={sourceRepo}
onPick={setSourceRepo}
showLabel
buttonRef={browseRef}
/>
</div> </div>
<p className="mt-1 text-[10px] text-fg-muted"> <p className="mt-1 text-[10px] text-fg-muted">
{mode === "repo" ? t("addLaneRepoHintAdopt") : t("addLaneRepoHint")} {mode === "repo" ? t("addLaneRepoHintAdopt") : t("addLaneRepoHint")}
@@ -66,6 +66,7 @@ export function FolderBrowseModal({
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={t("browse.title")} aria-label={t("browse.title")}
data-tour="folder-browse"
> >
<div className="border-b border-border p-3"> <div className="border-b border-border p-3">
<div className="truncate font-mono text-xs text-fg-secondary" title={listing?.path}> <div className="truncate font-mono text-xs text-fg-secondary" title={listing?.path}>
@@ -17,12 +17,16 @@
* image file is shown as the picture (`GET …/git/raw`) * image file is shown as the picture (`GET …/git/raw`)
* history — recent commits; opening one shows that commit's diff * history — recent commits; opening one shows that commit's diff
* browse — quick-open by path, or the repo's tracked file tree (the * browse — quick-open by path, or the repo's tracked file tree (the
* mode switcher's Files button; at full width the sidebar) * mode switcher's Files button; at full width the sidebar);
* a row dragged onto a prompt box attaches it as `@path`
* *
* and three ranges: `work` (uncommitted, what the agent has touched right * and three ranges: `work` (uncommitted, what the agent has touched right
* now), `branch` (`base...HEAD`, what this lane adds on top of its base) and * now), `branch` (`base...HEAD`, what this lane adds on top of its base) and
* `commit` (one commit, reached from history). * `commit` (one commit, reached from history).
* *
* A file can also be opened from outside the panel (`openRequest`: a chat's
* "Edited N files" card), the same way a click in the tree opens it.
*
* Only the overview polls. Review, file, history and browse are a deliberate * Only the overview polls. Review, file, history and browse are a deliberate
* read of a frozen moment — re-fetching under the reader's scroll position is * read of a frozen moment — re-fetching under the reader's scroll position is
* worse than a slightly stale view, and those are the expensive shapes. * worse than a slightly stale view, and those are the expensive shapes.
@@ -121,6 +125,7 @@ import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../Context
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview"; import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader"; import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader";
import { changeTree, treeFiles, type ChangeTreeDir } from "../../lib/changeTree"; import { changeTree, treeFiles, type ChangeTreeDir } from "../../lib/changeTree";
import { dragProjectPath } from "../../lib/promptAttach";
import { commitGraph, type GraphRow } from "../../lib/commitGraph"; import { commitGraph, type GraphRow } from "../../lib/commitGraph";
import type { import type {
LaneChangeFile, LaneChangeFile,
@@ -277,6 +282,8 @@ function TreeNodes({
data-tree-ignored={e.ignored || undefined} data-tree-ignored={e.ignored || undefined}
onClick={() => (e.dir ? onToggleDir(e.path) : onOpenFile(e.path))} onClick={() => (e.dir ? onToggleDir(e.path) : onOpenFile(e.path))}
onContextMenu={onMenu && ((ev) => onMenu(ev, e))} onContextMenu={onMenu && ((ev) => onMenu(ev, e))}
draggable
onDragStart={(ev) => dragProjectPath(ev, e.dir ? `${e.path}/` : e.path)}
style={{ paddingLeft: `${depth * 0.9 + 0.25}rem` }} style={{ paddingLeft: `${depth * 0.9 + 0.25}rem` }}
aria-current={active === e.path ? "true" : undefined} aria-current={active === e.path ? "true" : undefined}
className={`flex w-full items-center gap-1 rounded px-1 py-0.5 text-left font-mono text-[11px] hover:bg-surface-2 ${ className={`flex w-full items-center gap-1 rounded px-1 py-0.5 text-left font-mono text-[11px] hover:bg-surface-2 ${
@@ -792,18 +799,25 @@ function GraphCell({ row, width }: { row: GraphRow; width: number }) {
export function LaneChangesPanel({ export function LaneChangesPanel({
laneId, laneId,
session, session,
source,
onNewLane, onNewLane,
full = false, full = false,
wide = full, wide = full,
onDirtyChange, onDirtyChange,
openRequest = null,
}: { }: {
laneId: number; laneId: number;
/** Open this file (relative to the panel's folder); a new `nonce` asks again. */
openRequest?: { path: string; nonce: number } | null;
/** The whole-file editor holds unsaved text (true) or not (false). */ /** The whole-file editor holds unsaved text (true) or not (false). */
onDirtyChange?: (dirty: boolean) => void; onDirtyChange?: (dirty: boolean) => void;
/** Show and act on that session's own worktree (`claude --worktree`) /** Show and act on that session's own worktree (`claude --worktree`)
* instead of the workspace's folder. The Workspace keys the panel on it, * instead of the workspace's folder. The Workspace keys the panel on it,
* so a different session is a fresh panel. */ * so a different session is a fresh panel. */
session?: string; session?: string;
/** Show and act on that extra folder's repo instead (`lane_sources` id);
* wins over `session`, and is keyed on like it. */
source?: number;
/** Open the Add-lane flow for a sibling worktree; see `LaneGitBar`. */ /** Open the Add-lane flow for a sibling worktree; see `LaneGitBar`. */
onNewLane?: (branch?: string) => void; onNewLane?: (branch?: string) => void;
/** The panel has the whole width: the file tree becomes a sidebar and a /** The panel has the whole width: the file tree becomes a sidebar and a
@@ -815,11 +829,11 @@ export function LaneChangesPanel({
const { t } = useTranslation(["lanes"]); const { t } = useTranslation(["lanes"]);
// 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, session); const [git, refreshGit] = useLaneGitFacts(laneId, session, source);
// A merge, cherry-pick or revert is open: no new one starts from history. // A merge, cherry-pick or revert is open: no new one starts from history.
const opOpen = !!(git?.available && git.merging); const opOpen = !!(git?.available && git.merging);
// Every git read and write below goes to the same tree as those facts. // Every git read and write below goes to the same tree as those facts.
const target = gitTarget(laneId, session); const target = gitTarget(laneId, session, source);
// A diff line's comment goes into this workspace's pane, as a paste. // A diff line's comment goes into this workspace's pane, as a paste.
const comment = useCallback( const comment = useCallback(
(text: string) => { (text: string) => {
@@ -1232,6 +1246,11 @@ export function LaneChangesPanel({
const show = (path: string) => { const show = (path: string) => {
void confirmDrop().then((ok) => ok && showNow(path)); void confirmDrop().then((ok) => ok && showNow(path));
}; };
// A file named from outside the panel (a chat's "Edited N files" card).
useEffect(() => {
if (openRequest) show(openRequest.path);
// eslint-disable-next-line react-hooks/exhaustive-deps -- once per request
}, [openRequest?.nonce]);
// A file's history (Show History): the commits that touched it. // A file's history (Show History): the commits that touched it.
const showHistory = (path: string) => { const showHistory = (path: string) => {
void confirmDrop().then((ok) => { void confirmDrop().then((ok) => {
@@ -2053,6 +2072,8 @@ export function LaneChangesPanel({
<button <button
type="button" type="button"
onClick={() => void openWhole(p)} onClick={() => void openWhole(p)}
draggable
onDragStart={(ev) => dragProjectPath(ev, p)}
className="w-full truncate rounded px-1 py-0.5 text-left font-mono text-[11px] text-fg-primary hover:bg-surface-2" className="w-full truncate rounded px-1 py-0.5 text-left font-mono text-[11px] text-fg-primary hover:bg-surface-2"
> >
{p} {p}
@@ -2105,6 +2126,7 @@ export function LaneChangesPanel({
<LaneGitHeader <LaneGitHeader
laneId={laneId} laneId={laneId}
session={session} session={session}
source={source}
git={git} git={git}
onChanged={rereadAll} onChanged={rereadAll}
onNewLane={onNewLane} onNewLane={onNewLane}
@@ -2487,7 +2509,9 @@ export function LaneChangesPanel({
) : !data ? ( ) : !data ? (
<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p> <p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>
) : !data.available ? ( ) : !data.available ? (
<p className="text-[11px] text-fg-muted">{t("changes.unavailable")}</p> <p className="text-[11px] text-fg-muted">
{t(source ? "changes.unavailableFolder" : "changes.unavailable")}
</p>
) : files.length === 0 && mode !== "file" ? ( ) : files.length === 0 && mode !== "file" ? (
<p className="text-[11px] text-fg-muted">{t(`changes.empty.${range}`)}</p> <p className="text-[11px] text-fg-muted">{t(`changes.empty.${range}`)}</p>
) : mode === "overview" ? ( ) : mode === "overview" ? (
@@ -2678,6 +2702,7 @@ export function LaneChangesPanel({
<LaneConflictView <LaneConflictView
laneId={laneId} laneId={laneId}
session={session} session={session}
source={source}
path={openFile} path={openFile}
onResolved={() => { onResolved={() => {
refreshGit(); refreshGit();
@@ -38,6 +38,7 @@ function Lines({ lines, className = "" }: { lines: string[]; className?: string
export function LaneConflictView({ export function LaneConflictView({
laneId, laneId,
session, session,
source,
path, path,
onResolved, onResolved,
onEdit, onEdit,
@@ -45,6 +46,8 @@ export function LaneConflictView({
laneId: number; laneId: number;
/** Work in that session's own worktree (see `gitTarget`). */ /** Work in that session's own worktree (see `gitTarget`). */
session?: string; session?: string;
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
source?: number;
path: string; path: string;
/** The file left the conflict (resolved, or a whole side taken). */ /** The file left the conflict (resolved, or a whole side taken). */
onResolved: () => void; onResolved: () => void;
@@ -65,7 +68,7 @@ export function LaneConflictView({
setChoices([]); setChoices([]);
setError(null); setError(null);
api.lanes api.lanes
.gitFile(gitTarget(laneId, session), path, "work") .gitFile(gitTarget(laneId, session, source), path, "work")
.then((r) => { .then((r) => {
if (!live) return; if (!live) return;
setFile( setFile(
@@ -78,7 +81,7 @@ export function LaneConflictView({
return () => { return () => {
live = false; live = false;
}; };
}, [laneId, session, path]); }, [laneId, session, source, path]);
const parts: ConflictPart[] | null = file?.usable ? parseConflicts(file.lines) : null; const parts: ConflictPart[] | null = file?.usable ? parseConflicts(file.lines) : null;
const total = parts ? parts.filter((p) => p.kind === "conflict").length : 0; const total = parts ? parts.filter((p) => p.kind === "conflict").length : 0;
@@ -102,15 +105,18 @@ export function LaneConflictView({
run(async () => { run(async () => {
if (!parts || !file?.sha) return; if (!parts || !file?.sha) return;
await api.lanes.gitSaveFile( await api.lanes.gitSaveFile(
gitTarget(laneId, session), gitTarget(laneId, session, source),
path, path,
applyChoices(parts, choices).join("\n"), applyChoices(parts, choices).join("\n"),
file.sha file.sha
); );
await api.lanes.gitAction(gitTarget(laneId, session), "resolve", { path, side: "done" }); await api.lanes.gitAction(gitTarget(laneId, session, source), "resolve", {
path,
side: "done",
});
}); });
const takeAll = (side: "ours" | "theirs") => const takeAll = (side: "ours" | "theirs") =>
run(() => api.lanes.gitAction(gitTarget(laneId, session), "resolve", { path, side })); run(() => api.lanes.gitAction(gitTarget(laneId, session, source), "resolve", { path, side }));
const choose = (i: number, c: ConflictChoice | undefined) => const choose = (i: number, c: ConflictChoice | undefined) =>
setChoices((prev) => { setChoices((prev) => {
+6 -10
View File
@@ -45,14 +45,7 @@ import { isSubmitEnter } from "../../lib/keys";
import type { LaneGitFacts, LaneWorktree } from "../../lib/types"; import type { LaneGitFacts, LaneWorktree } from "../../lib/types";
type Action = type Action =
| "fetch" "fetch" | "pull" | "push" | "switch" | "branch-create" | "branch-delete" | "merge" | "publish";
| "pull"
| "push"
| "switch"
| "branch-create"
| "branch-delete"
| "merge"
| "publish";
type RunBody = Parameters<typeof api.lanes.gitAction>[2]; type RunBody = Parameters<typeof api.lanes.gitAction>[2];
const LOCAL_HOST = /^(localhost|127\.|\[::1\])/; const LOCAL_HOST = /^(localhost|127\.|\[::1\])/;
@@ -95,6 +88,7 @@ function Btn({
export function LaneGitBar({ export function LaneGitBar({
laneId, laneId,
session, session,
source,
git, git,
onChanged, onChanged,
onNewLane, onNewLane,
@@ -103,6 +97,8 @@ export function LaneGitBar({
laneId: number; laneId: number;
/** Work in that session's own worktree (see `gitTarget`). */ /** Work in that session's own worktree (see `gitTarget`). */
session?: string; session?: string;
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
source?: number;
git: LaneGitFacts; git: LaneGitFacts;
/** Rendered at the start of the action row: the header's branch line, so /** Rendered at the start of the action row: the header's branch line, so
* the facts and the buttons that act on them read as one row. */ * the facts and the buttons that act on them read as one row. */
@@ -165,7 +161,7 @@ export function LaneGitBar({
setBusy(action); setBusy(action);
setError(null); setError(null);
try { try {
const res = await api.lanes.gitAction(gitTarget(lane, session), action, body || {}); const res = await api.lanes.gitAction(gitTarget(lane, session, source), action, body || {});
// Landed after a lane switch: every write below belongs to the lane // Landed after a lane switch: every write below belongs to the lane
// no longer shown. // no longer shown.
if (laneNow.current !== lane) return; if (laneNow.current !== lane) return;
@@ -218,7 +214,7 @@ export function LaneGitBar({
const refreshBranches = async () => { const refreshBranches = async () => {
try { try {
const r = await api.lanes.gitBranches(gitTarget(laneId, session)); const r = await api.lanes.gitBranches(gitTarget(laneId, session, source));
if (laneNow.current !== laneId) return; if (laneNow.current !== laneId) return;
setBranches(r.branches); setBranches(r.branches);
setWorktrees(r.worktrees || []); setWorktrees(r.worktrees || []);
+12 -4
View File
@@ -36,7 +36,9 @@ const GIT_REFRESH_MS = 30_000;
export function useLaneGitFacts( export function useLaneGitFacts(
laneId: number, laneId: number,
/** Read that session's own worktree instead (`?session=`). */ /** Read that session's own worktree instead (`?session=`). */
session?: string session?: string,
/** Read that extra folder's repo instead (`?source=`). */
source?: number
): [LaneGitFacts | null, () => void] { ): [LaneGitFacts | null, () => void] {
const [facts, setFacts] = useState<LaneGitFacts | null>(null); const [facts, setFacts] = useState<LaneGitFacts | null>(null);
// Bumped by the git action bar: a commit or a pull changes every fact on // Bumped by the git action bar: a commit or a pull changes every fact on
@@ -54,7 +56,7 @@ export function useLaneGitFacts(
let alive = true; let alive = true;
const read = () => { const read = () => {
api.lanes api.lanes
.git(gitTarget(laneId, session)) .git(gitTarget(laneId, session, source))
.then((f) => { .then((f) => {
if (alive) setFacts(keepIfSame(f)); if (alive) setFacts(keepIfSame(f));
}) })
@@ -73,7 +75,7 @@ export function useLaneGitFacts(
alive = false; alive = false;
clearInterval(timer); clearInterval(timer);
}; };
}, [laneId, session, nonce]); }, [laneId, session, source, nonce]);
return [facts, () => setNonce((n) => n + 1)]; return [facts, () => setNonce((n) => n + 1)];
} }
@@ -322,20 +324,23 @@ export function LaneGitCounts({
export function LaneGitRailCounts({ export function LaneGitRailCounts({
laneId, laneId,
session, session,
source,
onShowAll, onShowAll,
}: { }: {
laneId: number; laneId: number;
session?: string; session?: string;
source?: number;
/** Opens the Changes panel from the file list's "… more files". */ /** Opens the Changes panel from the file list's "… more files". */
onShowAll?: () => void; onShowAll?: () => void;
}) { }) {
const [git] = useLaneGitFacts(laneId, session); const [git] = useLaneGitFacts(laneId, session, source);
return git?.available ? <LaneGitCounts git={git} vertical onShowAll={onShowAll} /> : null; return git?.available ? <LaneGitCounts git={git} vertical onShowAll={onShowAll} /> : null;
} }
export function LaneGitHeader({ export function LaneGitHeader({
laneId, laneId,
session, session,
source,
git, git,
onChanged, onChanged,
onNewLane, onNewLane,
@@ -343,6 +348,8 @@ export function LaneGitHeader({
laneId: number; laneId: number;
/** Work in that session's own worktree (see `gitTarget`). */ /** Work in that session's own worktree (see `gitTarget`). */
session?: string; session?: string;
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
source?: number;
git: LaneGitFacts | null; git: LaneGitFacts | null;
/** Called after a git bar action: the facts AND the panel's diff moved. */ /** Called after a git bar action: the facts AND the panel's diff moved. */
onChanged: () => void; onChanged: () => void;
@@ -374,6 +381,7 @@ export function LaneGitHeader({
<LaneGitBar <LaneGitBar
laneId={laneId} laneId={laneId}
session={session} session={session}
source={source}
git={git} git={git}
onChanged={onChanged} onChanged={onChanged}
onNewLane={onNewLane} onNewLane={onNewLane}
+20 -2
View File
@@ -5,7 +5,8 @@
* then opens it (`&run=<runId>`) — the one place a session starts WITH a * then opens it (`&run=<runId>`) — the one place a session starts WITH a
* prompt (the session view's tab bar `+` only starts a bare CLI or a shell, * prompt (the session view's tab bar `+` only starts a bare CLI or a shell,
* `ConsoleTabs.tsx`). Inside * `ConsoleTabs.tsx`). Inside
* the box, like Claude Desktop's: AI (the session's own tool, the workspace's * the box, like Claude Desktop's: the `+` menu (`ChatAddMenu`: upload, or
* attach a project file, a context file or a recent upload), AI (the session's own tool, the workspace's
* by default; its last row, **Open terminal**, is no AI — it starts a plain * by default; its last row, **Open terminal**, is no AI — it starts a plain
* shell tab at once, like the tab bar's New terminal) and one Model chip (`ModelMenu`: the tool's own list or the * shell tab at once, like the tab bar's New terminal) and one Model chip (`ModelMenu`: the tool's own list or the
* proxy's models, plus Effort); under it the quieter choices — AI Proxy * proxy's models, plus Effort); under it the quieter choices — AI Proxy
@@ -59,6 +60,8 @@ import { fmtCost, timeAgo } from "../../lib/format";
import { useAiTools } from "../../hooks/useAiTools"; import { useAiTools } from "../../hooks/useAiTools";
import { useProjectTreePrefs } from "../../lib/projectTreePrefs"; import { useProjectTreePrefs } from "../../lib/projectTreePrefs";
import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "../run/RunSetup"; import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "../run/RunSetup";
import { ChatAddMenu } from "../run/ChatAddMenu";
import { addAttachments } from "../../lib/promptAttach";
import { AiToolIcon } from "../AiToolIcon"; import { AiToolIcon } from "../AiToolIcon";
import { ProxyPicker, proxyArg, useAiProxies, useSessionProxy } from "../run/ProxyPicker"; import { ProxyPicker, proxyArg, useAiProxies, useSessionProxy } from "../run/ProxyPicker";
import { Select } from "../Select"; import { Select } from "../Select";
@@ -115,12 +118,15 @@ function HomeBody({
lane, lane,
onMenuAction, onMenuAction,
onRenamed, onRenamed,
onSourcesChange,
}: { }: {
lane: Lane; lane: Lane;
/** Row-menu items the page carries out (its modals); rename is done here. */ /** Row-menu items the page carries out (its modals); rename is done here. */
onMenuAction: (action: WorkspaceMenuAction) => void; onMenuAction: (action: WorkspaceMenuAction) => void;
/** Called after a rename, so the page re-reads the lane list. */ /** Called after a rename, so the page re-reads the lane list. */
onRenamed?: () => void; onRenamed?: () => void;
/** Called with the sources after a folder or file is added or removed. */
onSourcesChange?: (view: LaneSourcesView) => void;
}) { }) {
const { t } = useTranslation("lanes"); const { t } = useTranslation("lanes");
const navigate = useNavigate(); const navigate = useNavigate();
@@ -149,6 +155,7 @@ function HomeBody({
} }
}, [draftKey, prompt]); }, [draftKey, prompt]);
const [images, setImages] = useState<PromptImage[]>([]); const [images, setImages] = useState<PromptImage[]>([]);
const pickFiles = useRef<(() => void) | null>(null);
const [proxy, setProxy] = useSessionProxy(); const [proxy, setProxy] = useSessionProxy();
const proxies = useAiProxies(); const proxies = useAiProxies();
// Claude and Codex, also before the catalog has loaded (as ProxyPicker). // Claude and Codex, also before the catalog has loaded (as ProxyPicker).
@@ -310,7 +317,9 @@ function HomeBody({
const run = async (fn: () => Promise<LaneSourcesView>) => { const run = async (fn: () => Promise<LaneSourcesView>) => {
setError(null); setError(null);
try { try {
setView(await fn()); const next = await fn();
setView(next);
onSourcesChange?.(next);
return true; return true;
} catch (err) { } catch (err) {
setError(message(err)); setError(message(err));
@@ -535,9 +544,18 @@ function HomeBody({
images={images} images={images}
onImagesChange={setImages} onImagesChange={setImages}
onBusyChange={setUploading} onBusyChange={setUploading}
pickFiles={pickFiles}
bare bare
/> />
<div className="mt-1 flex items-center gap-1"> <div className="mt-1 flex items-center gap-1">
<ChatAddMenu
cwd={lane.cwd}
laneId={lane.id}
skills={claude}
onUpload={() => pickFiles.current?.()}
onInsert={(cmd) => setPrompt((p) => `${cmd} ${p.replace(/^\s+/, "")}`)}
onAttach={(items) => setImages((prev) => addAttachments(prev, items))}
/>
<div data-testid="workspace-home-tool"> <div data-testid="workspace-home-tool">
<Select<string> <Select<string>
variant="ghost" variant="ghost"
+18 -11
View File
@@ -8,13 +8,14 @@
* preference kept in localStorage (`laneOrderStorage.ts`), never written to * preference kept in localStorage (`laneOrderStorage.ts`), never written to
* the lane row. The row only STARTS a drag (`LANE_DRAG_TYPE`); the list * the lane row. The row only STARTS a drag (`LANE_DRAG_TYPE`); the list
* (`WorkspaceTree.tsx`) takes the drop anywhere, gaps included. F2 renames too; the * (`WorkspaceTree.tsx`) takes the drop anywhere, gaps included. F2 renames too; the
* dot's meaning is screen-reader text, and an idle dot is a ring. * dot's meaning is screen-reader text, and an idle dot is a ring. A `readOnly`
* row (another user's workspace, for an analyst) has no menu, rename or `+`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { useState, type MouseEvent } from "react"; import { useState, type MouseEvent } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronRight, MoreHorizontal, Plus } from "lucide-react"; import { ChevronDown, ChevronRight, Eye, MoreHorizontal, Plus } from "lucide-react";
import type { Lane } from "../../lib/types"; import type { Lane } from "../../lib/types";
import { isSubmitEnter } from "../../lib/keys"; import { isSubmitEnter } from "../../lib/keys";
@@ -49,6 +50,7 @@ export function workspaceName(lane: Lane): string {
export function WorkspaceRow({ export function WorkspaceRow({
lane, lane,
index, index,
readOnly,
selected, selected,
expanded, expanded,
renaming, renaming,
@@ -63,6 +65,9 @@ export function WorkspaceRow({
lane: Lane; lane: Lane;
/** Position in the list — the unit both reorder paths speak in. */ /** Position in the list — the unit both reorder paths speak in. */
index: number; index: number;
/** Set (to its tooltip) for a workspace this user may only read: no menu,
* no rename, no new session. */
readOnly?: string;
selected: boolean; selected: boolean;
expanded: boolean; expanded: boolean;
renaming: boolean; renaming: boolean;
@@ -82,7 +87,12 @@ export function WorkspaceRow({
const openMenu = (e: MouseEvent) => { const openMenu = (e: MouseEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
onMenu(e.clientX, e.clientY); if (!readOnly) onMenu(e.clientX, e.clientY);
};
const startRename = () => {
if (readOnly) return;
setDraft(lane.title || "");
onRenameStart();
}; };
const Chevron = expanded ? ChevronDown : ChevronRight; const Chevron = expanded ? ChevronDown : ChevronRight;
const hoverBtn = const hoverBtn =
@@ -140,22 +150,18 @@ export function WorkspaceRow({
aria-current={selected ? "true" : undefined} aria-current={selected ? "true" : undefined}
// A double-click's second click renames instead of opening again. // A double-click's second click renames instead of opening again.
onClick={(e) => e.detail < 2 && onSelect()} onClick={(e) => e.detail < 2 && onSelect()}
onDoubleClick={() => { onDoubleClick={startRename}
setDraft(lane.title || "");
onRenameStart();
}}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "F2") { if (e.key === "F2") {
e.preventDefault(); e.preventDefault();
setDraft(lane.title || ""); startRename();
onRenameStart();
return; return;
} }
if (!e.altKey || (e.key !== "ArrowUp" && e.key !== "ArrowDown")) return; if (!e.altKey || (e.key !== "ArrowUp" && e.key !== "ArrowDown")) return;
e.preventDefault(); e.preventDefault();
onReorder(index, index + (e.key === "ArrowUp" ? -1 : 1)); onReorder(index, index + (e.key === "ArrowUp" ? -1 : 1));
}} }}
title={`${lane.cwd}\n${t("row.hint")}`} title={`${lane.cwd}\n${readOnly ?? t("row.hint")}`}
className="flex min-w-0 flex-1 items-center gap-2 py-1.5 text-left" className="flex min-w-0 flex-1 items-center gap-2 py-1.5 text-left"
> >
<span aria-hidden className={`h-2 w-2 shrink-0 rounded-full ${workspaceDot(lane)}`} /> <span aria-hidden className={`h-2 w-2 shrink-0 rounded-full ${workspaceDot(lane)}`} />
@@ -163,7 +169,8 @@ export function WorkspaceRow({
<span className="truncate text-[13px] font-medium text-fg-primary">{name}</span> <span className="truncate text-[13px] font-medium text-fg-primary">{name}</span>
</button> </button>
)} )}
{!renaming && ( {readOnly && <Eye aria-label={readOnly} className="mr-1 h-3 w-3 shrink-0 text-fg-muted" />}
{!renaming && !readOnly && (
<> <>
<button <button
type="button" type="button"
@@ -24,7 +24,9 @@
* loads every one, then a **Show less** that goes back to `SESSION_LIMIT`. A * loads every one, then a **Show less** that goes back to `SESSION_LIMIT`. A
* live tab whose session was archived is not brought back as a New session, * live tab whose session was archived is not brought back as a New session,
* and archiving a live one asks first. F2 renames the focused row too; each * and archiving a live one asks first. F2 renames the focused row too; each
* dot carries its meaning as screen-reader text. * dot carries its meaning as screen-reader text. `readOnly` (another user's
* workspace, for an analyst): no live tabs, no rename/archive/menu, and a row
* opens the session's analysis page (`/sessions/<id>`) — never a pane or a resume.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -220,6 +222,7 @@ export function WorkspaceSessionList({
selectedSessionId = null, selectedSessionId = null,
onMatches, onMatches,
hidden = false, hidden = false,
readOnly = false,
}: { }: {
lane: Lane; lane: Lane;
/** Every live dashboard pane, from `GET /api/run`. */ /** Every live dashboard pane, from `GET /api/run`. */
@@ -235,6 +238,8 @@ export function WorkspaceSessionList({
onMatches?: (count: number) => void; onMatches?: (count: number) => void;
/** Mounted only to load and count, during a search that hides the row. */ /** Mounted only to load and count, during a search that hides the row. */
hidden?: boolean; hidden?: boolean;
/** A workspace this user may only read. */
readOnly?: boolean;
}) { }) {
const { t } = useTranslation("lanes"); const { t } = useTranslation("lanes");
const navigate = useNavigate(); const navigate = useNavigate();
@@ -315,7 +320,7 @@ export function WorkspaceSessionList({
}; };
}, [orphanIds]); }, [orphanIds]);
const fresh = const fresh =
sessions === null || q sessions === null || q || readOnly
? [] ? []
: [ : [
...laneRuns.filter( ...laneRuns.filter(
@@ -492,6 +497,7 @@ export function WorkspaceSessionList({
// A double-click's second click renames; it must not // A double-click's second click renames; it must not
// navigate (a second history entry) on top of the first. // navigate (a second history entry) on top of the first.
if (e.detail > 1) return; if (e.detail > 1) return;
if (readOnly) return navigate(`/sessions/${encodeURIComponent(s.id)}`);
navigate( navigate(
run run
? `/workspace?lane=${lane.id}&run=${encodeURIComponent(run.id)}` ? `/workspace?lane=${lane.id}&run=${encodeURIComponent(run.id)}`
@@ -501,19 +507,19 @@ export function WorkspaceSessionList({
run && (e.ctrlKey || e.metaKey) ? { state: { split: true } } : undefined run && (e.ctrlKey || e.metaKey) ? { state: { split: true } } : undefined
); );
}} }}
onDoubleClick={() => startRename(s)} onDoubleClick={() => !readOnly && startRename(s)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "F2") { if (e.key === "F2" && !readOnly) {
e.preventDefault(); e.preventDefault();
startRename(s); startRename(s);
} }
}} }}
onContextMenu={(e) => sessionCtx.open(e, s)} onContextMenu={readOnly ? undefined : (e) => sessionCtx.open(e, s)}
title={`${s.name || s.id}\n${timeAgo(s.last_activity ?? s.started_at)}${ title={`${s.name || s.id}\n${timeAgo(s.last_activity ?? s.started_at)}${
sessionWorktreeName(lane, s) sessionWorktreeName(lane, s)
? `\n${t("tree.worktreeSession", { name: sessionWorktreeName(lane, s) })}` ? `\n${t("tree.worktreeSession", { name: sessionWorktreeName(lane, s) })}`
: "" : ""
}\n${run ? t("tree.splitHint") : t("tree.transcriptHint")}`} }${readOnly ? "" : `\n${run ? t("tree.splitHint") : t("tree.transcriptHint")}`}`}
className={`flex w-full items-center gap-2 rounded-md py-1 pl-2 pr-2 text-left text-[12px] ${ className={`flex w-full items-center gap-2 rounded-md py-1 pl-2 pr-2 text-left text-[12px] ${
selected selected
? "bg-accent/10 text-fg-primary" ? "bg-accent/10 text-fg-primary"
@@ -539,21 +545,23 @@ export function WorkspaceSessionList({
</span> </span>
<AiToolIcon <AiToolIcon
tool={{ id: s.tool || "claude", label: tools.label(s.tool || "claude") }} tool={{ id: s.tool || "claude", label: tools.label(s.tool || "claude") }}
className="h-3.5 w-3.5 shrink-0 !p-0 opacity-60 group-hover:invisible" className={`h-3.5 w-3.5 shrink-0 !p-0 opacity-60 ${readOnly ? "" : "group-hover:invisible"}`}
/> />
</button> </button>
<button {!readOnly && (
type="button" <button
data-testid={`workspace-session-archive-${s.id}`} type="button"
onClick={() => void archive(s)} data-testid={`workspace-session-archive-${s.id}`}
title={t("tree.archiveSession")} onClick={() => void archive(s)}
aria-label={t("tree.archiveSession")} title={t("tree.archiveSession")}
// Invisible, not `hidden`: a keyboard user must be able to aria-label={t("tree.archiveSession")}
// Tab onto it, and it shows when focused. // Invisible, not `hidden`: a keyboard user must be able to
className="absolute right-1 top-1/2 -translate-y-1/2 rounded p-0.5 text-fg-muted opacity-0 hover:bg-surface-4 hover:text-fg-primary focus:opacity-100 group-hover:opacity-100" // Tab onto it, and it shows when focused.
> className="absolute right-1 top-1/2 -translate-y-1/2 rounded p-0.5 text-fg-muted opacity-0 hover:bg-surface-4 hover:text-fg-primary focus:opacity-100 group-hover:opacity-100"
<Archive className="h-3.5 w-3.5" /> >
</button> <Archive className="h-3.5 w-3.5" />
</button>
)}
</li> </li>
); );
})} })}
@@ -582,6 +582,19 @@ describe("AddLaneModal — folder browse", () => {
expect(api.lanes.browse).not.toHaveBeenCalled(); expect(api.lanes.browse).not.toHaveBeenCalled();
}); });
it("opens the picker on every click into the path field", async () => {
vi.mocked(api.lanes.pickPath).mockResolvedValue({ path: "/Users/tester/app" });
renderModal();
const user = userEvent.setup();
const field = document.getElementById("add-lane-repo")!;
await user.click(field);
await waitFor(() => expect(field).toHaveValue("/Users/tester/app"));
await user.click(field);
await waitFor(() => expect(api.lanes.pickPath).toHaveBeenCalledTimes(2));
expect(api.lanes.pickPath).toHaveBeenLastCalledWith("dir", "/Users/tester/app");
});
it("falls back to the in-app browser when no native picker is available", async () => { it("falls back to the in-app browser when no native picker is available", async () => {
noNativePicker(); noNativePicker();
vi.mocked(api.lanes.browse).mockResolvedValue({ vi.mocked(api.lanes.browse).mockResolvedValue({
@@ -255,6 +255,36 @@ describe("LaneChangesPanel", () => {
expect(view).toHaveTextContent("keep"); expect(view).toHaveTextContent("keep");
}); });
it("opens a file asked for from outside (a chat's Edited files card), once per request", async () => {
const view = render(
<LaneChangesPanel laneId={7} openRequest={{ path: "server/lib/git-ops.js", nonce: 1 }} />
);
await waitFor(() =>
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
range: "work",
path: "server/lib/git-ops.js",
context: 3,
})
);
vi.mocked(api.lanes.changes).mockClear();
// The same request again changes nothing; a new nonce opens the next one.
view.rerender(
<LaneChangesPanel laneId={7} openRequest={{ path: "server/lib/git-ops.js", nonce: 1 }} />
);
view.rerender(<LaneChangesPanel laneId={7} openRequest={{ path: "src/main.ts", nonce: 2 }} />);
await waitFor(() =>
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
range: "work",
path: "src/main.ts",
context: 3,
})
);
expect(api.lanes.changes).not.toHaveBeenCalledWith(
7,
expect.objectContaining({ path: "server/lib/git-ops.js" })
);
});
it("asks for every file's hunks in review mode", async () => { it("asks for every file's hunks in review mode", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />); render(<LaneChangesPanel laneId={7} />);
@@ -7,7 +7,8 @@
* the real UI); a step moves on when what it asked for has happened (the * the real UI); a step moves on when what it asked for has happened (the
* dialog opened, the workspace exists, the session started), and steps back * dialog opened, the workspace exists, the session started), and steps back
* when the user backed out (the dialog closed with nothing made). Targets are * when the user backed out (the dialog closed with nothing made). Targets are
* existing `data-testid`s plus three `data-tour` marks in AddLaneModal. The * existing `data-testid`s plus `data-tour` marks in AddLaneModal and
* FolderBrowseModal. The
* step reached is `localStorage["agenthub.tour"]`, so it is synced to the * step reached is `localStorage["agenthub.tour"]`, so it is synced to the
* account and survives a reload; `done` ends it. * account and survives a reload; `done` ends it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -45,12 +46,14 @@ const STEPS: Record<
{ target: () => Element | null; next?: () => StepId | null; info?: boolean } { target: () => Element | null; next?: () => StepId | null; info?: boolean }
> = { > = {
add: { add: {
// The sidebar's +; folded to a rail, its tree icon (which unfolds it).
target: () => target: () =>
shown('[data-testid="lane-empty"]') || shown('[data-testid="workspace-tree-add"]'), shown('[data-testid="workspace-tree-add"]') || shown('[data-testid="sidebar-tree-rail"]'),
next: () => (modalOpen() ? "folder" : null), next: () => (modalOpen() ? "folder" : null),
}, },
folder: { folder: {
target: () => $('[data-tour="add-lane-folder"]'), // The folder browser while it is open: it sits over the field.
target: () => $('[data-tour="folder-browse"]') || $('[data-tour="add-lane-folder"]'),
next: () => (modalOpen() ? null : composer() ? "prompt" : "add"), next: () => (modalOpen() ? null : composer() ? "prompt" : "add"),
info: true, info: true,
}, },
@@ -113,7 +116,19 @@ export function OnboardingTour() {
const moved = def.next?.(); const moved = def.next?.();
if (moved && moved !== step) return write(moved); if (moved && moved !== step) return write(moved);
const el = def.target(); const el = def.target();
const r = el ? el.getBoundingClientRect() : null; let r = el ? el.getBoundingClientRect() : null;
// The folder field's suggestions hang below it: light them too.
const list = el?.querySelector("[data-cwd-suggestions]")?.getBoundingClientRect();
if (r && list) {
const top = Math.min(r.top, list.top);
const left = Math.min(r.left, list.left);
r = new DOMRect(
left,
top,
Math.max(r.right, list.right) - left,
Math.max(r.bottom, list.bottom) - top
);
}
const d = el?.closest("[role=dialog]")?.getBoundingClientRect() ?? null; const d = el?.closest("[role=dialog]")?.getBoundingClientRect() ?? null;
setBox((prev) => (prev && d && prev.right === d.right ? prev : d)); setBox((prev) => (prev && d && prev.right === d.right ? prev : d));
// Only when it moved: a new DOMRect every tick would re-render forever. // Only when it moved: a new DOMRect every tick would re-render forever.
@@ -96,7 +96,7 @@ function Wizard() {
if (!last) return setIndex(index + 1); if (!last) return setIndex(index + 1);
void markOnboarded(); void markOnboarded();
startTour(); startTour();
navigate("/workspace"); navigate("/");
}; };
// Ctrl/⌘+Enter continues, like Orca. // Ctrl/⌘+Enter continues, like Orca.
@@ -207,7 +207,7 @@ describe("the tour", () => {
it("follows the real UI: add → folder → create → prompt", async () => { it("follows the real UI: add → folder → create → prompt", async () => {
const spy = boxed(); const spy = boxed();
const user = userEvent.setup(); const user = userEvent.setup();
const page = mount('<button data-testid="lane-empty">Add workspace</button>'); const page = mount('<button data-testid="workspace-tree-add">+</button>');
try { try {
startTour(); startTour();
render( render(
@@ -239,7 +239,7 @@ describe("the tour", () => {
it("goes back to Add workspace when the dialog closes with nothing made", async () => { it("goes back to Add workspace when the dialog closes with nothing made", async () => {
const spy = boxed(); const spy = boxed();
const page = mount('<button data-testid="lane-empty">Add workspace</button>'); const page = mount('<button data-testid="workspace-tree-add">+</button>');
try { try {
localStorage.setItem("agenthub.tour", "create"); localStorage.setItem("agenthub.tour", "create");
render( render(
+430
View File
@@ -0,0 +1,430 @@
/**
* @file ChatAddMenu.tsx
* @description The Chat view's `+` menu, Claude Desktop's: Upload or add
* files (the prompt box's own picker), what is already there to attach —
* Project files (a search of the box's folder, folders too:
* `GET /api/run/files?dirs=1`, sent as `@path`), the workspace's Context
* files (`GET /api/lanes/:id/sources`) and Recent uploads (this browser's,
* `lib/promptAttach.ts`) —, Skills (the user's and the project's)
* and Plugins → one plugin → the skills and commands it ships. Picking one
* puts `/<name>` (`/<plugin>:<name>`) at the front of the message — the same
* text the `/` autocomplete inserts, so the CLI runs it as typed. Both lists
* are read when the menu first opens (`GET /api/cc-config/skills|plugins`),
* and Manage opens the Claude config page on that tab.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import {
ChevronRight,
FileText,
FolderOpen,
History,
Paperclip,
Plug,
Plus,
ScrollText,
Settings2,
} from "lucide-react";
import { api } from "../../lib/api";
import { projectAttachment, recentUploads, type Attachment } from "../../lib/promptAttach";
interface Entry {
name: string;
description?: string;
}
interface Lists {
skills: Entry[];
plugins: { name: string; items: Entry[] }[];
}
const byName = (a: Entry, b: Entry) => a.name.localeCompare(b.name);
function loadLists(cwd: string): Promise<Lists> {
return Promise.all([
api.ccConfig.skills(undefined, cwd).catch(() => ({ items: [] })),
api.ccConfig.plugins().catch(() => ({ plugins: [] })),
]).then(([sk, pl]) => ({
skills: sk.items
.map((s) => ({ name: s.name, description: s.frontmatter?.description }))
.filter((s, i, all) => all.findIndex((o) => o.name === s.name) === i)
.sort(byName),
plugins: pl.plugins
.filter((p) => p.enabled !== false && p.contributes)
.map((p) => ({
name: p.name,
items: [...p.contributes!.items.skills, ...p.contributes!.items.commands]
.map((it) => ({ name: it.name, description: it.description ?? undefined }))
.filter((it, i, all) => all.findIndex((o) => o.name === it.name) === i)
.sort(byName),
}))
.filter((p, i, all) => p.items.length > 0 && all.findIndex((o) => o.name === p.name) === i)
.sort(byName),
}));
}
const PANEL =
"absolute w-60 rounded-xl border border-border bg-surface-1 py-1 shadow-lg animate-fade-in";
const ROW =
"flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-3 hover:text-fg-primary";
function Row({
icon,
label,
more,
active,
onClick,
onHover,
title,
testId,
}: {
icon: ReactNode;
label: string;
more?: boolean;
active?: boolean;
onClick: () => void;
onHover?: () => void;
title?: string;
testId?: string;
}) {
return (
<button
type="button"
role="menuitem"
title={title}
data-testid={testId}
onClick={onClick}
onMouseEnter={onHover}
className={`${ROW} ${active ? "bg-surface-3 text-fg-primary" : ""}`}
>
<span className="shrink-0 text-fg-muted">{icon}</span>
<span className="min-w-0 flex-1 truncate">{label}</span>
{more && <ChevronRight className="h-3.5 w-3.5 shrink-0 text-fg-muted" />}
</button>
);
}
/** Project files: a search of the box's folder, shortest paths first. */
function ProjectFiles({ cwd, onPick }: { cwd: string; onPick: (rel: string) => void }) {
const { t } = useTranslation("run");
const [q, setQ] = useState("");
const [items, setItems] = useState<string[] | null>(null);
useEffect(() => {
let live = true;
const tid = window.setTimeout(() => {
api.run
.files(cwd, q, true)
.then((r) => live && setItems(r.items))
.catch(() => live && setItems([]));
}, 120);
return () => {
live = false;
window.clearTimeout(tid);
};
}, [cwd, q]);
return (
<>
<input
autoFocus
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder={t("chat.searchFiles")}
data-testid="chat-add-project-search"
className="mx-2 mb-1 w-[calc(100%-1rem)] rounded-md border border-border bg-surface-2 px-2 py-1 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent/50 focus:outline-none"
/>
<div className="max-h-72 overflow-y-auto">
{!items || items.length === 0 ? (
<p className="px-3 py-1.5 text-xs text-fg-muted">{items ? t("chat.none") : "…"}</p>
) : (
items.map((p) => (
<Row
key={p}
icon={
p.endsWith("/") ? (
<FolderOpen className="h-4 w-4" />
) : (
<FileText className="h-4 w-4" />
)
}
label={p}
title={p}
testId={`chat-add-project-${p}`}
onClick={() => onPick(p)}
/>
))
)}
</div>
</>
);
}
type Sub = "project" | "context" | "recent" | "skills" | "plugins";
export function ChatAddMenu({
cwd,
laneId = null,
skills,
onUpload,
onInsert,
onAttach,
}: {
cwd: string;
/** The workspace, for its Context files; none hides them. */
laneId?: number | null;
/** Offer Skills and Plugins: Claude only, they are Claude Code's. */
skills: boolean;
onUpload: () => void;
/** `/<name>` picked. */
onInsert: (command: string) => void;
/** An existing file picked: a project path, a context file, an upload. */
onAttach: (items: Attachment[]) => void;
}) {
const { t } = useTranslation("run");
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const [sub, setSub] = useState<Sub | null>(null);
const [context, setContext] = useState<Attachment[] | null>(null);
const [recent, setRecent] = useState<Attachment[]>([]);
const [plugin, setPlugin] = useState<string | null>(null);
const [lists, setLists] = useState<Lists | null>(null);
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!open) return;
if (skills && !lists) void loadLists(cwd).then(setLists);
const away = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node)) setOpen(false);
};
const esc = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
document.addEventListener("mousedown", away);
document.addEventListener("keydown", esc);
return () => {
document.removeEventListener("mousedown", away);
document.removeEventListener("keydown", esc);
};
}, [open, skills, lists, cwd]);
// Read again at each opening: either list may have grown since.
useEffect(() => {
if (!open) return;
setRecent(recentUploads());
setContext(null);
if (laneId === null) return;
let live = true;
api.lanes
.sources(laneId)
.then(
(v) =>
live &&
setContext(
v.sources
.filter((s) => s.kind === "file" && !s.missing)
.map((s) => ({ path: `@${s.path}`, name: s.name }))
)
)
.catch(() => live && setContext([]));
return () => {
live = false;
};
}, [open, laneId]);
const close = () => {
setOpen(false);
setSub(null);
setPlugin(null);
};
const pick = (command: string) => {
close();
onInsert(command);
};
const attach = (item: Attachment) => {
close();
onAttach([item]);
};
const manage = (tab: "skills" | "plugins") => {
close();
navigate(`/cc-config?tab=${tab}`);
};
const entries = (items: Entry[], prefix: string, from?: string) =>
items.length === 0 ? (
<p className="px-3 py-1.5 text-xs text-fg-muted">{lists ? t("chat.none") : "…"}</p>
) : (
items.map((it) => (
<Row
key={it.name}
icon={<ScrollText className="h-4 w-4" />}
label={it.name}
title={[it.description, from].filter(Boolean).join("\n") || undefined}
testId={`chat-add-skill-${prefix}${it.name}`}
onClick={() => pick(`/${prefix}${it.name}`)}
/>
))
);
const shownPlugin = lists?.plugins.find((p) => p.name === plugin);
return (
<div ref={ref} className="relative">
<button
type="button"
data-testid="chat-add"
aria-label={t("chat.add")}
title={t("chat.add")}
aria-expanded={open}
onClick={() => (open ? close() : setOpen(true))}
className="flex h-8 w-8 items-center justify-center rounded-full text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary"
>
<Plus className="h-4 w-4" />
</button>
{open && (
<div role="menu" data-testid="chat-add-menu" className={`${PANEL} bottom-full left-0 mb-2`}>
<Row
icon={<Paperclip className="h-4 w-4" />}
label={t("chat.upload")}
testId="chat-add-upload"
onHover={() => setSub(null)}
onClick={() => {
close();
onUpload();
}}
/>
<Row
icon={<FolderOpen className="h-4 w-4" />}
label={t("chat.projectFiles")}
more
active={sub === "project"}
testId="chat-add-project"
onHover={() => setSub("project")}
onClick={() => setSub(sub === "project" ? null : "project")}
/>
{laneId !== null && (
<Row
icon={<FileText className="h-4 w-4" />}
label={t("chat.contextFiles")}
more
active={sub === "context"}
testId="chat-add-context"
onHover={() => setSub("context")}
onClick={() => setSub(sub === "context" ? null : "context")}
/>
)}
<Row
icon={<History className="h-4 w-4" />}
label={t("chat.recentUploads")}
more
active={sub === "recent"}
testId="chat-add-recent"
onHover={() => setSub("recent")}
onClick={() => setSub(sub === "recent" ? null : "recent")}
/>
{skills && (
<>
<Row
icon={<ScrollText className="h-4 w-4" />}
label={t("chat.skills")}
more
active={sub === "skills"}
testId="chat-add-skills"
onHover={() => setSub("skills")}
onClick={() => setSub(sub === "skills" ? null : "skills")}
/>
<Row
icon={<Plug className="h-4 w-4" />}
label={t("chat.plugins")}
more
active={sub === "plugins"}
testId="chat-add-plugins"
onHover={() => setSub("plugins")}
onClick={() => setSub(sub === "plugins" ? null : "plugins")}
/>
</>
)}
{sub === "project" && (
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1 w-72 pt-2`}>
<ProjectFiles cwd={cwd} onPick={(rel) => attach(projectAttachment(rel))} />
</div>
)}
{(sub === "context" || sub === "recent") && (
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
<div className="max-h-72 overflow-y-auto">
{(sub === "context" ? context : recent)?.length ? (
(sub === "context" ? context! : recent).map((it) => (
<Row
key={it.path}
icon={<FileText className="h-4 w-4" />}
label={it.name}
title={it.path.replace(/^@/, "")}
testId={`chat-add-${sub}-${it.name}`}
onClick={() => attach(it)}
/>
))
) : (
<p className="px-3 py-1.5 text-xs text-fg-muted">
{sub === "context" && !context ? "…" : t("chat.none")}
</p>
)}
</div>
</div>
)}
{sub === "skills" && (
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
<div className="max-h-72 overflow-y-auto">{entries(lists?.skills ?? [], "")}</div>
<div className="my-1 border-t border-border" />
<Row
icon={<Settings2 className="h-4 w-4" />}
label={t("chat.manageSkills")}
onClick={() => manage("skills")}
/>
</div>
)}
{sub === "plugins" && (
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
<div className="max-h-72 overflow-y-auto">
{lists && lists.plugins.length === 0 && (
<p className="px-3 py-1.5 text-xs text-fg-muted">{t("chat.none")}</p>
)}
{!lists && <p className="px-3 py-1.5 text-xs text-fg-muted">…</p>}
{lists?.plugins.map((p) => (
<Row
key={p.name}
icon={<Plug className="h-4 w-4" />}
label={p.name}
more
active={plugin === p.name}
testId={`chat-add-plugin-${p.name}`}
onHover={() => setPlugin(p.name)}
onClick={() => setPlugin(plugin === p.name ? null : p.name)}
/>
))}
</div>
<div className="my-1 border-t border-border" />
<Row
icon={<Settings2 className="h-4 w-4" />}
label={t("chat.managePlugins")}
onClick={() => manage("plugins")}
/>
{shownPlugin && (
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
<div className="max-h-72 overflow-y-auto">
{entries(
shownPlugin.items,
`${shownPlugin.name}:`,
t("chat.fromPlugin", { name: shownPlugin.name })
)}
</div>
</div>
)}
</div>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,258 @@
/**
* @file ChatFilePreview.tsx
* @description A file a Chat pane's session touched, shown the way Claude
* Desktop shows one: `FilePreview` (name · EXT, Copy path, Close, then the
* content) and `FileListModal` (a list on the left, the picked file's preview
* on the right — Claude's Uploads dialog). Content comes only through routes
* that already guard it: a file inside the workspace through
* `GET /api/lanes/:id/git/file` (or `/git/raw` for an image), a file under
* `~/.claude` (a memory file) through `GET /api/cc-config/file`, an attached
* image through `GET /api/run/drop/:name`. Anything else shows its path only.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { Copy, FileText, Loader2, X } from "lucide-react";
import { api, gitTarget } from "../../lib/api";
import { copyText } from "../../lib/clipboard";
import { useDialog } from "../../hooks/useDialog";
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
/** Where a Chat pane's files can be read from. */
export interface PreviewScope {
laneId: number | null;
/** The workspace's own folder. */
laneCwd: string;
/** The pane's folder — the workspace's, or the session's own worktree. */
cwd: string;
sessionId: string;
}
const IMAGE_RE = /\.(png|jpe?g|gif|webp)$/i;
const DROP_RE = /\/agenthub-drops-\d+\/([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.[A-Za-z0-9]+)$/;
export const baseName = (p: string) => p.split("/").pop() || p;
/** `report.v2.xlsx` → `XLSX`; none → "". */
export const extOf = (p: string) => {
const m = /\.([A-Za-z0-9]+)$/.exec(baseName(p));
return m ? m[1]!.toUpperCase() : "";
};
/** An attached file's name without the drop dir's random prefix. */
export const displayName = (p: string) =>
DROP_RE.test(p) ? baseName(p).replace(/^[0-9a-f]{16}-?/, "") || baseName(p) : baseName(p);
type Content =
| { kind: "loading" }
| { kind: "image"; src: string }
| { kind: "text"; text: string; truncated: boolean }
| { kind: "none" };
function useContent(path: string, scope: PreviewScope): Content {
const [content, setContent] = useState<Content>({ kind: "loading" });
useEffect(() => {
const drop = DROP_RE.exec(path);
if (drop) {
setContent(
IMAGE_RE.test(path)
? { kind: "image", src: `/api/run/drop/${encodeURIComponent(drop[1]!)}` }
: { kind: "none" }
);
return;
}
let live = true;
const show = (p: Promise<Content>) => {
setContent({ kind: "loading" });
p.then((c) => live && setContent(c)).catch(() => live && setContent({ kind: "none" }));
};
const base = scope.cwd.replace(/\/+$/, "");
if (scope.laneId === null || !base || !path.startsWith(`${base}/`)) {
// Claude Code's own files (memory): the config pages' guarded read.
if (/\/\.claude\//.test(path) && !IMAGE_RE.test(path)) {
show(
api.ccConfig
.file(path)
.then((f) => ({ kind: "text", text: f.text, truncated: !!f.truncated }))
);
} else setContent({ kind: "none" });
return () => {
live = false;
};
}
const rel = path.slice(base.length + 1);
const target =
base === scope.laneCwd.replace(/\/+$/, "")
? scope.laneId
: gitTarget(scope.laneId, scope.sessionId);
if (IMAGE_RE.test(path)) {
setContent({ kind: "image", src: api.lanes.gitRawUrl(target, rel) });
return;
}
show(
api.lanes
.gitFile(target, rel)
.then((f) =>
f.available && !f.binary
? { kind: "text", text: f.lines.join("\n"), truncated: f.truncated }
: { kind: "none" }
)
);
return () => {
live = false;
};
}, [path, scope.cwd, scope.laneCwd, scope.laneId, scope.sessionId]);
return content;
}
function Body({ path, scope }: { path: string; scope: PreviewScope }) {
const { t } = useTranslation("run");
const content = useContent(path, scope);
if (content.kind === "loading") {
return (
<div className="flex flex-1 items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin text-fg-muted" />
</div>
);
}
if (content.kind === "image") {
return (
<div className="flex flex-1 items-center justify-center overflow-auto p-4">
<img
src={content.src}
alt={baseName(path)}
className="max-h-full max-w-full object-contain"
/>
</div>
);
}
if (content.kind === "none") {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-2 p-6 text-center text-sm text-fg-muted">
<FileText className="h-8 w-8" />
<p>{t("chat.outline.noPreview")}</p>
<p className="break-all font-mono text-xs">{path}</p>
</div>
);
}
return (
<div className="min-h-0 flex-1 overflow-auto">
{isMarkdown(path) ? (
<MarkdownPreview text={content.text} className="p-4" />
) : (
<pre className="p-4 font-mono text-xs leading-relaxed text-fg-secondary">
{content.text}
</pre>
)}
{content.truncated && (
<p className="px-4 pb-4 text-xs text-fg-muted">{t("chat.outline.truncated")}</p>
)}
</div>
);
}
/** One file: header (name · EXT, Copy path, Close) over its content. */
export function FilePreview({
path,
scope,
onClose,
className = "",
}: {
path: string;
scope: PreviewScope;
onClose: () => void;
className?: string;
}) {
const { t } = useTranslation("run");
return (
<section
data-testid="chat-file-preview"
className={`flex min-h-0 min-w-0 flex-col overflow-hidden bg-surface-1 ${className}`}
>
<header className="flex items-center gap-2 border-b border-border px-4 py-2.5">
<span className="min-w-0 truncate text-sm text-fg-primary" title={path}>
{displayName(path).replace(/\.[A-Za-z0-9]+$/, "")}
</span>
<span className="text-xs text-fg-muted">{extOf(path)}</span>
<div className="ml-auto flex items-center gap-1">
<button
type="button"
onClick={() => void copyText(path)}
title={t("chat.outline.copyPath", { path })}
aria-label={t("chat.outline.copyPath", { path })}
className="flex h-7 w-7 items-center justify-center rounded text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
>
<Copy className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={onClose}
title={t("chat.outline.close")}
aria-label={t("chat.outline.close")}
className="flex h-7 w-7 items-center justify-center rounded text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
>
<X className="h-4 w-4" />
</button>
</div>
</header>
<Body path={path} scope={scope} />
</section>
);
}
/** Claude's Uploads dialog: the files on the left, the picked one on the right. */
export function FileListModal({
title,
paths,
scope,
onClose,
}: {
title: string;
paths: string[];
scope: PreviewScope;
onClose: () => void;
}) {
const [picked, setPicked] = useState(paths[0] ?? "");
const panelRef = useRef<HTMLDivElement>(null);
useDialog(true, panelRef, onClose);
// Into document.body, like ImageLightbox: a pane's own stacking layer
// would otherwise draw over a `fixed` overlay opened from inside it.
return createPortal(
<div
className="fixed inset-0 z-[200] flex items-center justify-center bg-surface-0/70 p-6"
onClick={onClose}
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-label={title}
data-testid="chat-file-modal"
onClick={(e) => e.stopPropagation()}
className="flex h-[80vh] w-full max-w-6xl overflow-hidden rounded-xl border border-border bg-surface-1 shadow-2xl"
>
<div className="flex w-80 flex-shrink-0 flex-col border-r border-border">
<h2 className="px-5 pb-3 pt-5 text-lg text-fg-primary">{title}</h2>
<ul className="min-h-0 flex-1 space-y-0.5 overflow-y-auto px-2 pb-3">
{paths.map((p) => (
<li key={p}>
<button
type="button"
onClick={() => setPicked(p)}
title={p}
aria-current={p === picked}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm ${p === picked ? "bg-surface-3 text-fg-primary" : "text-fg-secondary hover:bg-surface-2"}`}
>
<FileText className="h-4 w-4 flex-shrink-0 text-fg-muted" />
<span className="min-w-0 truncate">{displayName(p)}</span>
</button>
</li>
))}
</ul>
</div>
{picked && <FilePreview path={picked} scope={scope} onClose={onClose} className="flex-1" />}
</div>
</div>,
document.body
);
}
+302
View File
@@ -0,0 +1,302 @@
/**
* @file ChatOutline.tsx
* @description The right-hand panel of a Chat pane shown alone, laid out like
* Claude Desktop's activity panel: the session's Progress (its
* TaskCreate/TaskUpdate or TodoWrite plan, only when it has one), the Outputs
* it wrote or edited (name + EXT tag; a click previews the file), and "Used in
* this session" — the files attached to its prompts, the files it read and the
* memory files it used (a click opens the list with a preview), its skills
* and MCP servers (a click opens the Claude config page). Read from
* `GET /api/sessions/:id/outline` by `useSessionOutline`, which the pane also
* uses for the collapsed button's count.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState, type ComponentType, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import {
BookOpen,
Check,
ChevronDown,
FileCode,
FileImage,
FileSearch,
FileSpreadsheet,
FileText,
History,
Paperclip,
Plug,
X,
} from "lucide-react";
import { api, type SessionOutline } from "../../lib/api";
import { displayName, extOf } from "./ChatFilePreview";
const POLL_MS = 5000;
const EMPTY: SessionOutline = {
progress: [],
outputs: [],
files: [],
uploads: [],
memory: [],
skills: [],
mcp: [],
};
/** The session's outline, re-read on a visibility-gated poll. */
export function useSessionOutline(sessionId: string): SessionOutline {
const [outline, setOutline] = useState<SessionOutline>(EMPTY);
useEffect(() => {
let live = true;
const load = () =>
api.sessions
.outline(sessionId)
.then((o) => live && setOutline({ ...EMPTY, ...o }))
.catch(() => {});
void load();
const timer = window.setInterval(() => {
if (document.visibilityState === "visible") void load();
}, POLL_MS);
return () => {
live = false;
window.clearInterval(timer);
};
}, [sessionId]);
return outline;
}
export type OutlineList = "uploads" | "files" | "memory";
function fileIcon(path: string) {
const ext = extOf(path).toLowerCase();
if (["xlsx", "xls", "csv", "tsv"].includes(ext)) return FileSpreadsheet;
if (["png", "jpg", "jpeg", "gif", "webp", "svg"].includes(ext)) return FileImage;
if (
/^(c|cc|cpp|cs|css|go|h|html|java|js|jsx|json|kt|mjs|cjs|php|py|rb|rs|sh|sql|swift|ts|tsx|vue|ya?ml|toml)$/.test(
ext
)
)
return FileCode;
return FileText;
}
function Section({
title,
count,
action,
grow,
children,
}: {
title: string;
count?: number;
action?: ReactNode;
/** Takes the panel's free height and scrolls inside it. */
grow?: boolean;
children: ReactNode;
}) {
const [open, setOpen] = useState(true);
return (
<section className={`flex flex-col px-3 pt-3 ${grow && open ? "min-h-0 flex-1" : ""}`}>
<div className="flex items-center gap-1.5 px-1 pb-1">
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
className="flex items-center gap-1.5 text-sm font-medium text-fg-primary"
>
{title}
{count ? <span className="text-xs font-normal text-fg-muted">{count}</span> : null}
<ChevronDown
className={`h-3.5 w-3.5 text-fg-muted transition-transform ${open ? "" : "-rotate-90"}`}
/>
</button>
<div className="ml-auto flex items-center">{action}</div>
</div>
{open && (
<div
className={
grow
? "min-h-0 flex-1 overflow-y-auto [mask-image:linear-gradient(to_bottom,transparent,black_8px,black_calc(100%-8px),transparent)]"
: ""
}
>
{children}
</div>
)}
</section>
);
}
const rowClass =
"flex w-full items-center gap-2 rounded-md px-1.5 py-1.5 text-left text-[13px] text-fg-secondary enabled:hover:bg-surface-3";
/** A "Used in this session" row: icon, label, the first names, +N. */
function UsedRow({
icon: Icon,
label,
names,
onClick,
to,
}: {
icon: ComponentType<{ className?: string }>;
label: string;
names: string[];
onClick?: () => void;
to?: string;
}) {
const shown = names.slice(0, 2).join(", ");
const inner = (
<>
<Icon className="h-4 w-4 flex-shrink-0 text-fg-muted" />
<span className="flex-shrink-0 text-fg-primary">{label}</span>
<span className="min-w-0 flex-1 truncate text-xs text-fg-muted">{shown}</span>
{names.length > 2 && (
<span className="flex-shrink-0 text-xs text-fg-muted">+{names.length - 2}</span>
)}
</>
);
const title = `${label}: ${names.join(", ")}`;
return to ? (
<Link to={to} title={title} className={`${rowClass} hover:bg-surface-3`}>
{inner}
</Link>
) : (
<button type="button" title={title} onClick={onClick} className={rowClass}>
{inner}
</button>
);
}
const stem = (p: string) => displayName(p).replace(/\.[A-Za-z0-9]+$/, "");
export function ChatOutline({
outline: o,
onClose,
onOpenFile,
onOpenList,
}: {
outline: SessionOutline;
onClose: () => void;
onOpenFile: (path: string) => void;
onOpenList: (list: OutlineList) => void;
}) {
const { t } = useTranslation("run");
const used = o.uploads.length + o.files.length + o.memory.length + o.skills.length + o.mcp.length;
const close = (
<button
type="button"
onClick={onClose}
title={t("chat.outline.hide")}
aria-label={t("chat.outline.hide")}
className="flex h-6 w-6 items-center justify-center rounded text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
>
<X className="h-4 w-4" />
</button>
);
return (
<aside
data-testid="chat-outline"
aria-label={t("chat.outline.panel")}
className="m-3 ml-0 hidden w-80 flex-shrink-0 flex-col overflow-hidden rounded-xl border border-border bg-surface-1 pb-2 lg:flex"
>
{o.progress.length > 0 && (
<Section title={t("chat.outline.progress")} action={close}>
{o.progress.map((task) => {
const done = task.status === "completed";
return (
<div key={task.id} className={rowClass} title={task.subject}>
{done ? (
<span className="flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full bg-accent-solid text-white">
<Check className="h-3 w-3" />
</span>
) : (
<span
className={`h-4 w-4 flex-shrink-0 rounded-full border-2 ${task.status === "in_progress" ? "animate-pulse border-accent" : "border-border"}`}
/>
)}
<span
className={`min-w-0 flex-1 truncate ${done ? "text-fg-muted line-through" : task.status === "in_progress" ? "text-fg-primary" : ""}`}
>
{task.subject}
</span>
</div>
);
})}
</Section>
)}
<Section
title={t("chat.outline.outputs")}
count={o.outputs.length}
action={o.progress.length ? undefined : close}
grow
>
{o.outputs.length ? (
o.outputs.map((p) => {
const Icon = fileIcon(p);
return (
<button
key={p}
type="button"
title={p}
aria-label={`${extOf(p)}: ${stem(p)}`}
onClick={() => onOpenFile(p)}
className={rowClass}
>
<Icon className="h-4 w-4 flex-shrink-0 text-fg-muted" />
<span className="min-w-0 flex-1 truncate">{stem(p)}</span>
<span className="flex-shrink-0 text-[11px] text-fg-muted">{extOf(p)}</span>
</button>
);
})
) : (
<p className="px-1.5 py-1 text-xs text-fg-muted">{t("chat.outline.empty")}</p>
)}
</Section>
{used > 0 && (
<Section title={t("chat.outline.used")}>
{o.uploads.length > 0 && (
<UsedRow
icon={Paperclip}
label={t("chat.outline.uploads")}
names={o.uploads.map(displayName)}
onClick={() => onOpenList("uploads")}
/>
)}
{o.files.length > 0 && (
<UsedRow
icon={FileSearch}
label={t("chat.outline.files")}
names={o.files.map(displayName)}
onClick={() => onOpenList("files")}
/>
)}
{o.memory.length > 0 && (
<UsedRow
icon={History}
label={t("chat.outline.memory")}
names={o.memory.map(stem)}
onClick={() => onOpenList("memory")}
/>
)}
{o.skills.length > 0 && (
<UsedRow
icon={BookOpen}
label={t("chat.skills")}
names={o.skills}
to="/settings/agents/claude-code?tab=skills"
/>
)}
{o.mcp.length > 0 && (
<UsedRow
icon={Plug}
label={t("chat.outline.connectors")}
names={o.mcp}
to="/settings/agents/claude-code?tab=mcp"
/>
)}
</Section>
)}
</aside>
);
}
+12 -6
View File
@@ -14,8 +14,9 @@
* tab) attaches to that tmux session only; nothing is started or resumed. * tab) attaches to that tmux session only; nothing is started or resumed.
* A Claude or Codex pane also has a Chat view (`PaneChat`): the session's * A Claude or Codex pane also has a Chat view (`PaneChat`): the session's
* conversation laid OVER the terminal, which stays mounted and attached under * conversation laid OVER the terminal, which stays mounted and attached under
* it. Chat or terminal is the viewer's own Chat UI setting (Settings → * it. Chat or terminal is each SESSION's own (`useSessionChat`, the toggle
* Appearance, `lib/chatUi.ts`, per account) — one choice for every pane. * left of the pane's split buttons); the Chat UI setting (Settings →
* Appearance, `lib/chatUi.ts`) is only what a session shows first.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -28,7 +29,7 @@ import type { Lane } from "../../lib/types";
import { useAiTools } from "../../hooks/useAiTools"; import { useAiTools } from "../../hooks/useAiTools";
import { TerminalView } from "./TerminalView"; import { TerminalView } from "./TerminalView";
import { PaneChat } from "./PaneChat"; import { PaneChat } from "./PaneChat";
import { useChatUi } from "../../lib/chatUi"; import { useSessionChat } from "../../lib/chatUi";
const CHAT_TOOLS = new Set(["claude", "codex"]); const CHAT_TOOLS = new Set(["claude", "codex"]);
@@ -50,6 +51,8 @@ export interface LaneConsolePaneProps {
onRunExit?: () => void; onRunExit?: () => void;
/** The terminal takes the keyboard when it appears (default). */ /** The terminal takes the keyboard when it appears (default). */
autoFocus?: boolean; autoFocus?: boolean;
/** The only pane shown: its Chat view gets the right panel. */
solo?: boolean;
} }
/** The tmux session a lane's tab owns (see server/lib/pty-run.js). */ /** The tmux session a lane's tab owns (see server/lib/pty-run.js). */
@@ -68,6 +71,7 @@ export function LaneConsolePane({
onNewSession, onNewSession,
onRunExit, onRunExit,
autoFocus, autoFocus,
solo = false,
}: LaneConsolePaneProps) { }: LaneConsolePaneProps) {
const { t } = useTranslation("run"); const { t } = useTranslation("run");
const [handle, setHandle] = useState<RunHandle | null>(null); const [handle, setHandle] = useState<RunHandle | null>(null);
@@ -78,11 +82,11 @@ export function LaneConsolePane({
// The tool of the run on screen — a session's own, which need not be the // The tool of the run on screen — a session's own, which need not be the
// workspace's; the workspace's with no run. // workspace's; the workspace's with no run.
const paneTool = handle?.tool ?? currentLane?.tool ?? "claude"; const paneTool = handle?.tool ?? currentLane?.tool ?? "claude";
const chat = useChatUi();
// A run learns its session id after it starts (Codex): the page's poll has it. // A run learns its session id after it starts (Codex): the page's poll has it.
const chatSession = handle const chatSession = handle
? (activeRuns?.items.find((r) => r.id === handle.id)?.sessionId ?? handle.sessionId) ? (activeRuns?.items.find((r) => r.id === handle.id)?.sessionId ?? handle.sessionId)
: null; : null;
const chat = useSessionChat(chatSession);
const chatable = !!handle && !!chatSession && CHAT_TOOLS.has(handle.tool); const chatable = !!handle && !!chatSession && CHAT_TOOLS.has(handle.tool);
useEffect(() => { useEffect(() => {
@@ -192,10 +196,12 @@ export function LaneConsolePane({
{chatable && chat && ( {chatable && chat && (
<div className="absolute inset-0 z-20 flex flex-col bg-surface-0"> <div className="absolute inset-0 z-20 flex flex-col bg-surface-0">
<PaneChat <PaneChat
runId={handle.id} run={handle}
sessionId={chatSession} sessionId={chatSession}
tool={handle.tool}
cwd={handle.cwd || currentLane?.cwd || ""} cwd={handle.cwd || currentLane?.cwd || ""}
laneId={laneId}
laneCwd={currentLane?.cwd || ""}
outline={solo}
/> />
</div> </div>
)} )}
+277 -42
View File
@@ -5,84 +5,319 @@
* above a prompt box. It lies OVER the pane's terminal, which stays mounted and * above a prompt box. It lies OVER the pane's terminal, which stays mounted and
* attached underneath — a message is sent through that terminal * attached underneath — a message is sent through that terminal
* (`submitToRun`: a bracketed paste, then Enter), exactly as if typed there, so * (`submitToRun`: a bracketed paste, then Enter), exactly as if typed there, so
* nothing new reaches the server. A permission prompt or a menu still needs the * nothing new reaches the server. The box carries Claude Desktop's controls:
* terminal; the hint under the box says so. * the `+` menu (`ChatAddMenu`: files — new, the project's, the workspace's
* context files, recent uploads —, skills, plugins' skills), and for a
* Claude pane the Model/Effort chip — a change is typed into the pane as
* `/model <id>` / `/effort <level>` — and the permission mode, which only
* Shift+Tab changes in a running session, so the server steps it and reads
* every step back off the pane (`POST /api/run/:id/permission-mode`); it sits
* in the box beside `+`. A permission prompt or a menu still needs the terminal.
* Shown alone (`outline`), it also has Claude Desktop's activity panel
* (`ChatOutline`: progress, outputs, what the session used): collapsed into a
* file button with the output count until clicked (remembered per browser);
* a file clicked there opens its preview in the panel's place
* (`FilePreview`) — so does one in a turn's "Edited N files" card outside
* the workspace's folder —, a group
* of files a list with a preview (`FileListModal`). A file in a turn's
* "Edited N files" card opens in the Workspace's Changes panel
* (`openInChanges`) when it lies in the pane's folder. A send scrolls the
* conversation to its end and keeps it there (`follow`), so the message and
* the step running after it come into sight on their own. While the session's
* main agent is working (a prompt submitted, no Stop yet) an empty box shows
* Stop in Send's place: Esc pressed in the pane (`interruptRun`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ArrowUp } from "lucide-react"; import { ArrowUp, FileText, Square } from "lucide-react";
import { ConversationView } from "../conversation/ConversationView"; import { ConversationView } from "../conversation/ConversationView";
import { PromptEditor, promptWithImages, type PromptImage } from "./RunSetup"; import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "./RunSetup";
import { submitToRun } from "../../lib/panePaste"; import { ChatAddMenu } from "./ChatAddMenu";
import { addAttachments } from "../../lib/promptAttach";
import { ChatOutline, useSessionOutline, type OutlineList } from "./ChatOutline";
import { FileListModal, FilePreview, type PreviewScope } from "./ChatFilePreview";
import { Select } from "../Select";
import { interruptRun, submitToRun } from "../../lib/panePaste";
import { useLiveRefresh } from "../../hooks/useLiveRefresh";
import { openInChanges } from "../../lib/openInChanges";
import { toast } from "../../lib/toast"; import { toast } from "../../lib/toast";
import { api, type EffortLevel, type PermissionMode, type RunHandle } from "../../lib/api";
const MODES: [PermissionMode, string][] = [
["default", "Default"],
["acceptEdits", "AcceptEdits"],
["plan", "Plan"],
["auto", "Auto"],
["bypassPermissions", "Bypass"],
];
const isSettable = (m: unknown): m is PermissionMode => MODES.some(([v]) => v === m);
const OUTLINE_KEY = "agenthub.chat.outlineOpen";
/** The pane's permission mode as it shows it; a pick steps it there. */
function PermissionChip({ runId, initial }: { runId: string; initial: PermissionMode | null }) {
const { t } = useTranslation(["run", "lanes"]);
const [mode, setMode] = useState<PermissionMode>(initial ?? "default");
const [busy, setBusy] = useState(false);
const read = useCallback(
() =>
api.run
.permissionMode(runId)
.then((r) => isSettable(r.mode) && setMode(r.mode))
.catch(() => {}),
[runId]
);
useEffect(() => {
void read();
}, [read]);
// Bypass is in a session's cycle only when it was started with it.
const offered = MODES.filter(([v]) => v !== "bypassPermissions" || initial === v || mode === v);
return (
<div data-testid="pane-chat-mode">
<Select<PermissionMode>
variant="ghost"
label={t("run:fields.permissionMode")}
value={mode}
disabled={busy}
onChange={(v) => {
setBusy(true);
api.run
.setPermissionMode(runId, v)
.then((r) => setMode(r.mode))
.catch((err: unknown) => {
toast.error(
t("run:chat.modeFailed", { message: err instanceof Error ? err.message : "" })
);
void read();
})
.finally(() => setBusy(false));
}}
options={offered.map(([value, key]) => ({
value,
label: t(`lanes:home.permission${key}`),
hint: t(`run:fields.permission${key}`),
}))}
/>
</div>
);
}
export function PaneChat({ export function PaneChat({
runId, run,
sessionId, sessionId,
tool,
cwd, cwd,
laneId = null,
laneCwd = "",
outline = false,
}: { }: {
runId: string; run: Pick<RunHandle, "id" | "tool" | "model" | "effort" | "permissionMode">;
sessionId: string; sessionId: string;
tool: string;
cwd: string; cwd: string;
/** The workspace and its folder: a file under `cwd` is previewed through it. */
laneId?: number | null;
laneCwd?: string;
/** The pane is shown alone: room for the right panel. */
outline?: boolean;
}) { }) {
const { t } = useTranslation("run"); const { t } = useTranslation("run");
const runId = run.id;
const claude = run.tool === "claude";
const [prompt, setPrompt] = useState(""); const [prompt, setPrompt] = useState("");
const [images, setImages] = useState<PromptImage[]>([]); const [images, setImages] = useState<PromptImage[]>([]);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [follow, setFollow] = useState(0);
const [model, setModel] = useState(run.model ?? "");
const [effort, setEffort] = useState<EffortLevel>(run.effort ?? "");
const pickFiles = useRef<(() => void) | null>(null);
// Collapsed until asked for, like Claude Desktop's activity panel.
const [outlineOpen, setOutlineOpen] = useState(() => localStorage.getItem(OUTLINE_KEY) === "1");
const openOutline = (open: boolean) => {
localStorage.setItem(OUTLINE_KEY, open ? "1" : "0");
setOutlineOpen(open);
setPreview(null);
};
const [preview, setPreview] = useState<string | null>(null);
const [list, setList] = useState<OutlineList | null>(null);
const activity = useSessionOutline(sessionId);
const scope: PreviewScope = { laneId, laneCwd: laneCwd || cwd, cwd, sessionId };
const text = promptWithImages(prompt, images); const text = promptWithImages(prompt, images);
// The main agent is "working" from a prompt's submit to its Stop hook.
const [working, setWorking] = useState(false);
const readWorking = useCallback(
() =>
api.agents
.list({ session_id: sessionId, status: "working" })
// With session_id the server answers every agent of the session, its
// status filter ignored: check the status here.
.then((r) => setWorking(r.agents.some((a) => a.type === "main" && a.status === "working")))
.catch(() => {}),
[sessionId]
);
useEffect(() => {
void readWorking();
}, [readWorking]);
useLiveRefresh(readWorking, {
throttleMs: 500,
// While Stop shows, also poll: a missed agent_updated (the hook reached
// another dashboard on the same database, or the socket dropped) would
// otherwise leave Stop up after the turn ended.
intervalMs: working ? 5000 : undefined,
match: (msg) =>
msg.type === "agent_updated" &&
(msg.data as { session_id?: string } | undefined)?.session_id === sessionId,
});
// A control is a command typed into the pane, like the user would.
const command = (line: string) => {
const ok = submitToRun(runId, line);
if (!ok) toast.error(t("chat.notSent"));
return ok;
};
const send = () => { const send = () => {
if (!text || uploading) return; if (!text || uploading) return;
if (!submitToRun(runId, text)) { if (!command(text)) return;
toast.error(t("chat.notSent")); setFollow((n) => n + 1);
return;
}
setPrompt(""); setPrompt("");
images.forEach((i) => i.url && URL.revokeObjectURL(i.url)); images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
setImages([]); setImages([]);
}; };
return ( return (
<div data-testid="pane-chat" className="flex min-h-0 flex-1 flex-col gap-2 pb-3"> <div className="relative flex min-h-0 flex-1">
<div className="min-h-0 flex-1 overflow-hidden"> <div data-testid="pane-chat" className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 pb-3">
<ConversationView sessionId={sessionId} fill /> <div className="relative min-h-0 flex-1 overflow-hidden">
</div> <ConversationView
<div className="mx-4"> sessionId={sessionId}
<div className="mx-auto w-full max-w-3xl rounded-2xl border border-border bg-surface-1 p-2 shadow-sm transition-colors focus-within:border-accent/50"> fill
<PromptEditor live
bare follow={follow}
images={images} onOpenFile={(path) => {
onImagesChange={setImages} // The workspace's Changes panel when the file is in its folder;
onBusyChange={setUploading} // else this view's own preview, when it has room for one.
value={prompt} if (laneId !== null && openInChanges(laneId, cwd, path)) return;
onChange={setPrompt} if (outline) setPreview(path);
onSubmit={send} }}
placeholder={t("chat.placeholder")}
rows={2}
slash={tool === "claude"}
fileCwd={cwd}
lane={runId}
/> />
<div className="mt-1 flex items-center gap-2"> {outline && !outlineOpen && !preview && (
<span className="text-[11px] text-fg-muted">{t("chat.hint")}</span>
<button <button
type="button" type="button"
data-testid="pane-chat-send" data-testid="chat-outline-show"
onClick={send} onClick={() => openOutline(true)}
disabled={uploading || !text} title={t("chat.outline.show")}
aria-label={t("chat.send")} aria-label={t("chat.outline.show")}
title={t("chat.send")} className="absolute right-3 top-2 hidden h-7 items-center gap-1 rounded-md border border-border bg-surface-1 px-2 text-xs text-fg-secondary hover:bg-surface-3 hover:text-fg-primary lg:flex"
className="ml-auto flex h-8 w-8 items-center justify-center rounded-full bg-accent-solid text-white transition-opacity hover:opacity-90 disabled:opacity-40"
> >
<ArrowUp className="h-4 w-4" /> <FileText className="h-3.5 w-3.5" />
{activity.outputs.length}
</button> </button>
)}
</div>
<div className="mx-4">
<div className="mx-auto w-full max-w-[max(48rem,70%)]">
<div className="rounded-2xl border border-border bg-surface-1 p-2 shadow-sm transition-colors focus-within:border-accent/50">
<PromptEditor
bare
images={images}
onImagesChange={setImages}
onBusyChange={setUploading}
pickFiles={pickFiles}
value={prompt}
onChange={setPrompt}
onSubmit={send}
placeholder={t("chat.placeholder")}
rows={2}
slash={claude}
fileCwd={cwd}
lane={runId}
/>
<div className="mt-1 flex items-center gap-1">
<ChatAddMenu
cwd={cwd}
laneId={laneId}
skills={claude}
onUpload={() => pickFiles.current?.()}
onInsert={(cmd) => setPrompt((p) => `${cmd} ${p.replace(/^\s+/, "")}`)}
onAttach={(items) => setImages((prev) => addAttachments(prev, items))}
/>
{claude && <PermissionChip runId={runId} initial={run.permissionMode} />}
<div className="ml-auto flex items-center gap-1">
{claude && (
<ModelMenu
value={model}
onChange={(v) => {
if (command(`/model ${v || "default"}`)) setModel(v);
}}
tool={run.tool}
effort={effort}
onEffortChange={(e) => {
if (command(`/effort ${e || "auto"}`)) setEffort(e);
}}
/>
)}
{working && !text ? (
<button
type="button"
data-testid="pane-chat-stop"
onClick={() => {
// An interrupted turn fires no Stop hook: clear it here.
if (interruptRun(runId)) setWorking(false);
else toast.error(t("chat.notStopped"));
}}
aria-label={t("chat.stop")}
title={t("chat.stop")}
className="flex h-8 w-8 items-center justify-center rounded-full bg-surface-3 text-fg-primary transition-colors hover:bg-surface-4"
>
<Square className="h-3 w-3 fill-current" />
</button>
) : (
<button
type="button"
data-testid="pane-chat-send"
onClick={send}
disabled={uploading || !text}
aria-label={t("chat.send")}
title={t("chat.send")}
className="flex h-8 w-8 items-center justify-center rounded-full bg-accent-solid text-white transition-opacity hover:opacity-90 disabled:opacity-40"
>
<ArrowUp className="h-4 w-4" />
</button>
)}
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
{outline &&
(preview ? (
<FilePreview
path={preview}
scope={scope}
onClose={() => setPreview(null)}
// Beside the chat when there is room, over it when not.
className="absolute inset-0 z-30 rounded-xl border border-border lg:static lg:m-3 lg:ml-0 lg:w-[45%] lg:max-w-3xl lg:flex-shrink-0"
/>
) : (
outlineOpen && (
<ChatOutline
outline={activity}
onClose={() => openOutline(false)}
onOpenFile={setPreview}
onOpenList={setList}
/>
)
))}
{list && (
<FileListModal
title={t(`chat.outline.${list}`)}
paths={activity[list]}
scope={scope}
onClose={() => setList(null)}
/>
)}
</div> </div>
); );
} }
+36 -3
View File
@@ -29,6 +29,7 @@ import {
ChevronDown, ChevronDown,
Columns2, Columns2,
GripVertical, GripVertical,
MessageSquare,
Minimize2, Minimize2,
Rows2, Rows2,
SquareTerminal, SquareTerminal,
@@ -55,6 +56,7 @@ import {
} from "../../lib/paneLayout"; } from "../../lib/paneLayout";
import { AiToolIcon } from "../AiToolIcon"; import { AiToolIcon } from "../AiToolIcon";
import { RunDot, type RunState } from "./ConsoleTabs"; import { RunDot, type RunState } from "./ConsoleTabs";
import { setSessionChat, useSessionChat } from "../../lib/chatUi";
export const PANE_DRAG_TYPE = "application/x-agenthub-pane"; export const PANE_DRAG_TYPE = "application/x-agenthub-pane";
/** The slot being dragged: `dataTransfer` data cannot be read during dragover. */ /** The slot being dragged: `dataTransfer` data cannot be read during dragover. */
@@ -191,9 +193,37 @@ function PaneMenu({
const ITEM = const ITEM =
"flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-4 hover:text-fg-primary"; "flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-4 hover:text-fg-primary";
/** A pane's split-right / split-down menus: move a hidden run beside /** Chat UI ⇄ terminal for a Claude/Codex pane — this session's own view
* `slot`, or start a new terminal or AI tool there. In a pane header when the view is * (`lib/chatUi.ts`); a session with no id yet has no chat to show. */
* split, beside Tools in the tab bar while one pane is shown. */ function ChatToggle({ run }: { run?: RunHandle }) {
const { t } = useTranslation("lanes");
const chat = useSessionChat(run?.sessionId);
if (!run?.sessionId || (run.tool !== "claude" && run.tool !== "codex")) return null;
const sessionId = run.sessionId;
const label = chat ? t("panes.showTerminal") : t("panes.showChat");
return (
<button
type="button"
data-testid={`pane-chat-toggle-${run.slot ?? 0}`}
aria-pressed={chat}
title={label}
aria-label={label}
onClick={() => setSessionChat(sessionId, !chat)}
className="flex h-6 w-6 items-center justify-center rounded text-fg-muted hover:bg-surface-4 hover:text-fg-primary"
>
{chat ? (
<SquareTerminal className="h-3.5 w-3.5" />
) : (
<MessageSquare className="h-3.5 w-3.5" />
)}
</button>
);
}
/** A pane's chat/terminal toggle and split-right / split-down menus: move a
* hidden run beside `slot`, or start a new terminal or AI tool there. In a
* pane header when the view is split, beside Tools in the tab bar while one
* pane is shown. */
export function SplitMenus({ export function SplitMenus({
slot, slot,
shown, shown,
@@ -222,6 +252,7 @@ export function SplitMenus({
const full = shown.length >= MAX_PANES; const full = shown.length >= MAX_PANES;
return ( return (
<> <>
<ChatToggle run={runs.find((x) => (x.slot ?? 0) === slot)} />
{(["right", "bottom"] as const).map((edge) => ( {(["right", "bottom"] as const).map((edge) => (
<PaneMenu <PaneMenu
key={edge} key={edge}
@@ -446,6 +477,8 @@ export function PaneGrid({
key={slot} key={slot}
data-testid={`pane-${slot}`} data-testid={`pane-${slot}`}
data-focused={r && focused ? "true" : undefined} data-focused={r && focused ? "true" : undefined}
// A menu opened in this pane stays inside it (`menuRoom`).
data-menu-bounds
className={r ? "absolute flex flex-col" : "hidden"} className={r ? "absolute flex flex-col" : "hidden"}
style={r ? pct(r) : undefined} style={r ? pct(r) : undefined}
onPointerDownCapture={() => r && focus(slot)} onPointerDownCapture={() => r && focus(slot)}
+92 -31
View File
@@ -6,8 +6,12 @@
* box (`WorkspaceHome.tsx`): * box (`WorkspaceHome.tsx`):
* - `PromptEditor` — the prompt textarea with `/` (commands and skills, * - `PromptEditor` — the prompt textarea with `/` (commands and skills,
* Claude only) and `@` (`GET /api/run/files`) autocomplete, Shift/Alt+Enter * Claude only) and `@` (`GET /api/run/files`) autocomplete, Shift/Alt+Enter
* newlines, and pasted or dropped files (images as thumbnails, any other * newlines, and pasted, dropped or picked files (`pickFiles`, a composer's
* as a name chip) attached by their saved paths (`POST /api/run/drop-file`); * + menu; images as thumbnails, opened full size in `ImageLightbox`, any
* other as a name chip) attached by their saved paths (`POST /api/run/drop-file`,
* remembered for the + menu's Recent uploads), and a Files panel row
* dropped on it (`lib/promptAttach.ts`) as an `@path` chip;
* a borderless (`bare`) one grows with its text up to 40vh;
* - `CwdAutocomplete` — the folder field the Add-workspace modal uses; * - `CwdAutocomplete` — the folder field the Add-workspace modal uses;
* - `ModelPicker` — a tool's own model list (`GET /api/ai-tools/:id/models`) * - `ModelPicker` — a tool's own model list (`GET /api/ai-tools/:id/models`)
* or a fixed one (an AI Proxy's models), plus Custom…; * or a fixed one (an AI Proxy's models), plus Custom…;
@@ -19,7 +23,17 @@
* @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 Dispatch, type SetStateAction } from "react"; import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type Dispatch,
type MutableRefObject,
type SetStateAction,
} from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
Check, Check,
@@ -42,9 +56,16 @@ import {
} from "../../lib/api"; } from "../../lib/api";
import type { CwdSuggestion, CcMdItem } from "../../lib/api"; import type { CwdSuggestion, CcMdItem } from "../../lib/api";
import type { LaneTool } from "../../lib/types"; import type { LaneTool } from "../../lib/types";
import { Select } from "../Select"; import { Select, menuRoom } from "../Select";
import { ContextMenu, MenuItem, menuPoint, type MenuPoint } from "../ContextMenu"; import { ContextMenu, MenuItem, menuPoint, type MenuPoint } from "../ContextMenu";
import { isSubmitEnter } from "../../lib/keys"; import { isSubmitEnter } from "../../lib/keys";
import { ImageLightbox } from "../ImageLightbox";
import {
ATTACH_MIME,
addAttachments,
droppedAttachment,
rememberUploads,
} from "../../lib/promptAttach";
export interface SlashCommand { export interface SlashCommand {
name: string; name: string;
@@ -158,13 +179,16 @@ interface PromptEditorProps {
* sends them with the text through `promptWithImages`. */ * sends them with the text through `promptWithImages`. */
images: PromptImage[]; images: PromptImage[];
onImagesChange: Dispatch<SetStateAction<PromptImage[]>>; onImagesChange: Dispatch<SetStateAction<PromptImage[]>>;
/** Borderless, for a composer box that draws its own frame. */ /** Borderless, for a composer box that draws its own frame — and grows
* with its text, from `rows` up to 40% of the screen, then scrolls. */
bare?: boolean; bare?: boolean;
/** An image upload started (true) or settled (false): the caller holds its /** An image upload started (true) or settled (false): the caller holds its
* send button meanwhile, or the prompt would go out without the image. */ * send button meanwhile, or the prompt would go out without the image. */
onBusyChange?: (busy: boolean) => void; onBusyChange?: (busy: boolean) => void;
/** The workspace (id, or a pane's run id) whose panes read the files. */ /** The workspace (id, or a pane's run id) whose panes read the files. */
lane?: number | string; lane?: number | string;
/** Set to a function that opens a file picker (a composer's + menu). */
pickFiles?: MutableRefObject<(() => void) | null>;
} }
/** An attached file: its path on the server's machine, and a local preview /** An attached file: its path on the server's machine, and a local preview
@@ -177,6 +201,9 @@ export function promptWithImages(text: string, images: PromptImage[]): string {
return [text.trim(), ...images.map((i) => i.path)].filter(Boolean).join(" "); return [text.trim(), ...images.map((i) => i.path)].filter(Boolean).join(" ");
} }
/** A plain-text paste longer than this is attached as `pasted-text.txt`. */
export const PASTE_AS_FILE_CHARS = 5000;
export function PromptEditor({ export function PromptEditor({
value, value,
onChange, onChange,
@@ -190,9 +217,18 @@ export function PromptEditor({
bare, bare,
onBusyChange, onBusyChange,
lane, lane,
pickFiles,
}: PromptEditorProps) { }: PromptEditorProps) {
const { t } = useTranslation("run"); const { t } = useTranslation("run");
const taRef = useRef<HTMLTextAreaElement | null>(null); const taRef = useRef<HTMLTextAreaElement | null>(null);
// A composer grows with its text: reset to its rows, then take what the
// text needs; `max-h-[40vh]` caps it and it scrolls from there.
useLayoutEffect(() => {
const ta = taRef.current;
if (!bare || !ta) return;
ta.style.height = "auto";
ta.style.height = `${ta.scrollHeight}px`;
}, [bare, value]);
const [trigger, setTrigger] = useState<Trigger | null>(null); const [trigger, setTrigger] = useState<Trigger | null>(null);
const [active, setActive] = useState(0); const [active, setActive] = useState(0);
const [files, setFiles] = useState<string[]>([]); const [files, setFiles] = useState<string[]>([]);
@@ -324,7 +360,7 @@ export function PromptEditor({
const busyRef = useRef(onBusyChange); const busyRef = useRef(onBusyChange);
busyRef.current = onBusyChange; busyRef.current = onBusyChange;
useEffect(() => busyRef.current?.(uploading), [uploading]); useEffect(() => busyRef.current?.(uploading), [uploading]);
const attachImages = (list: FileList | null | undefined) => { const attachImages = (list: FileList | File[] | null | undefined) => {
const picked = Array.from(list ?? []); const picked = Array.from(list ?? []);
if (picked.length === 0) return false; if (picked.length === 0) return false;
setUpload("uploading"); setUpload("uploading");
@@ -336,6 +372,7 @@ export function PromptEditor({
url: f.type.startsWith("image/") ? URL.createObjectURL(f) : undefined, url: f.type.startsWith("image/") ? URL.createObjectURL(f) : undefined,
name: f.name, name: f.name,
})); }));
rememberUploads(added.map((a) => ({ path: a.path, name: a.name, image: !!a.url })));
onImagesChange((prev) => [...prev, ...added]); onImagesChange((prev) => [...prev, ...added]);
}) })
.catch(() => setUpload("failed")); .catch(() => setUpload("failed"));
@@ -345,12 +382,11 @@ export function PromptEditor({
if (img.url) URL.revokeObjectURL(img.url); if (img.url) URL.revokeObjectURL(img.url);
onImagesChange((prev) => prev.filter((x) => x !== img)); onImagesChange((prev) => prev.filter((x) => x !== img));
}; };
useEffect(() => { const closeViewer = useCallback(() => setViewing(null), []);
if (!viewing) return; // The composer's own "Upload or add files" opens this picker; what it picks
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setViewing(null); // takes the same path as a paste or a drop.
document.addEventListener("keydown", onKey); const fileInput = useRef<HTMLInputElement | null>(null);
return () => document.removeEventListener("keydown", onKey); if (pickFiles) pickFiles.current = () => fileInput.current?.click();
}, [viewing]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (kind && count > 0) { if (kind && count > 0) {
@@ -454,6 +490,19 @@ export function PromptEditor({
)} )}
</div> </div>
)} )}
{pickFiles && (
<input
ref={fileInput}
type="file"
multiple
hidden
data-testid="prompt-file-input"
onChange={(e) => {
attachImages(e.target.files);
e.target.value = "";
}}
/>
)}
<textarea <textarea
ref={taRef} ref={taRef}
value={value} value={value}
@@ -467,8 +516,28 @@ export function PromptEditor({
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onPaste={(e) => { onPaste={(e) => {
if (attachImages(e.clipboardData?.files)) e.preventDefault(); if (attachImages(e.clipboardData?.files)) e.preventDefault();
else {
// A very long paste goes in as a text file, like Claude Desktop:
// the prompt then carries its path, never the text (#68: a CLI's
// argument and a tmux message both have a size limit).
const text = e.clipboardData?.getData("text/plain") ?? "";
if (text.length > PASTE_AS_FILE_CHARS) {
e.preventDefault();
attachImages([new File([text], "pasted-text.txt", { type: "text/plain" })]);
}
}
}}
onDragOver={(e) => {
// A Files panel row is not a file: say a drop is welcome.
if (e.dataTransfer?.types.includes(ATTACH_MIME)) e.preventDefault();
}} }}
onDrop={(e) => { onDrop={(e) => {
const att = droppedAttachment(e.dataTransfer);
if (att) {
e.preventDefault();
onImagesChange((prev) => addAttachments(prev, [att]));
return;
}
if (attachImages(e.dataTransfer?.files)) e.preventDefault(); if (attachImages(e.dataTransfer?.files)) e.preventDefault();
}} }}
placeholder={placeholder} placeholder={placeholder}
@@ -476,7 +545,7 @@ export function PromptEditor({
spellCheck={false} spellCheck={false}
className={ className={
bare bare
? "block w-full resize-none bg-transparent px-1 py-1 text-sm text-fg-primary placeholder:text-fg-muted focus:outline-none" ? "block max-h-[40vh] w-full resize-none overflow-y-auto bg-transparent px-1 py-1 text-sm text-fg-primary placeholder:text-fg-muted focus:outline-none"
: "w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-none" : "w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-none"
} }
/> />
@@ -545,20 +614,7 @@ export function PromptEditor({
)} )}
</div> </div>
)} )}
{viewing && ( {viewing?.url && <ImageLightbox src={viewing.url} alt={viewing.name} onClose={closeViewer} />}
<div
role="dialog"
aria-label={viewing.name}
onClick={() => setViewing(null)}
className="fixed inset-0 z-50 flex cursor-zoom-out items-center justify-center bg-black/60 p-8"
>
<img
src={viewing.url}
alt={viewing.name}
className="max-h-full max-w-full rounded-lg object-contain shadow-2xl"
/>
</div>
)}
</div> </div>
); );
} }
@@ -673,7 +729,10 @@ export function CwdAutocomplete({
/> />
</div> </div>
{open && ( {open && (
<div className="absolute z-30 left-0 right-0 mt-1 rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 max-h-72 overflow-auto py-1"> <div
data-cwd-suggestions
className="absolute z-30 left-0 right-0 mt-1 rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 max-h-72 overflow-auto py-1"
>
{groups.length === 0 ? ( {groups.length === 0 ? (
<div className="px-3 py-2 text-[11px] text-fg-muted">{t("fields.cwdNoMatches")}</div> <div className="px-3 py-2 text-[11px] text-fg-muted">{t("fields.cwdNoMatches")}</div>
) : ( ) : (
@@ -856,9 +915,11 @@ export function ModelMenu({
const ref = useRef<HTMLDivElement | null>(null); const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
// Below the chip unless the viewport has more room above it. // Below the chip unless its pane (or the viewport) has more room above it.
const r = ref.current?.getBoundingClientRect(); if (ref.current) {
if (r) setUp(window.innerHeight - r.bottom < 360 && r.top > window.innerHeight - r.bottom); const { above, below } = menuRoom(ref.current, ref.current.getBoundingClientRect());
setUp(below < 360 && above > below);
}
const away = (e: MouseEvent) => { const away = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node)) setOpen(false); if (!ref.current?.contains(e.target as Node)) setOpen(false);
}; };
+7 -1
View File
@@ -236,6 +236,9 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
fontSize: 13, fontSize: 13,
cursorBlink: true, cursorBlink: true,
fontFamily: TERMINAL_FONTS[readTerminalFont()], fontFamily: TERMINAL_FONTS[readTerminalFont()],
// A step heavier than stock, like the rest of the UI (main.tsx loads these weights).
fontWeight: 500,
fontWeightBold: 700,
theme: paletteRef.current, theme: paletteRef.current,
screenReaderMode: readFlag(SCREEN_READER_KEY), screenReaderMode: readFlag(SCREEN_READER_KEY),
}); });
@@ -556,7 +559,10 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
// On screen (a console hidden with CSS measures 0×0) AND attached: // On screen (a console hidden with CSS measures 0×0) AND attached:
// `onData` drops input while the socket is connecting or reconnecting, // `onData` drops input while the socket is connecting or reconnecting,
// so a caller waits or says the paste failed instead. // so a caller waits or says the paste failed instead.
() => !!containerRef.current?.offsetWidth && wsRef.current?.readyState === WebSocket.OPEN () => !!containerRef.current?.offsetWidth && wsRef.current?.readyState === WebSocket.OPEN,
(data) => {
if (wsRef.current?.readyState === WebSocket.OPEN) wsRef.current.send(data);
}
), ),
[runId] [runId]
); );
@@ -0,0 +1,103 @@
/**
* @file ChatAddMenu.test.tsx
* @description The `+` menu's existing-resource pickers: Project files (a
* search with folders, attached as `@path`), the workspace's Context files
* (files only, none missing) and this browser's Recent uploads.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
import { ChatAddMenu } from "../ChatAddMenu";
import { api } from "../../../lib/api";
import { rememberUploads } from "../../../lib/promptAttach";
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
run: { files: vi.fn().mockResolvedValue({ items: ["src/", "src/a.ts"] }) },
lanes: {
sources: vi.fn().mockResolvedValue({
sources: [
{ id: 1, kind: "file", path: "/data/ctx/1/brief.md", name: "brief.md", missing: false },
{ id: 2, kind: "file", path: "/data/ctx/1/gone.md", name: "gone.md", missing: true },
{ id: 3, kind: "folder", path: "/srv/docs", name: "docs", missing: false },
],
}),
},
ccConfig: {
skills: vi.fn().mockResolvedValue({ items: [] }),
plugins: vi.fn().mockResolvedValue({ plugins: [] }),
},
},
};
});
function renderMenu(laneId: number | null = 7) {
const onAttach = vi.fn();
render(
<MemoryRouter>
<ChatAddMenu
cwd="/repo"
laneId={laneId}
skills={false}
onUpload={() => {}}
onInsert={() => {}}
onAttach={onAttach}
/>
</MemoryRouter>
);
fireEvent.click(screen.getByTestId("chat-add"));
return onAttach;
}
beforeEach(() => {
i18n.changeLanguage("en");
localStorage.clear();
vi.clearAllMocks();
});
describe("ChatAddMenu — existing resources", () => {
it("searches the project, folders included, and attaches a pick as @path", async () => {
const onAttach = renderMenu();
fireEvent.mouseEnter(screen.getByTestId("chat-add-project"));
fireEvent.click(await screen.findByTestId("chat-add-project-src/"));
expect(api.run.files).toHaveBeenCalledWith("/repo", "", true);
expect(onAttach).toHaveBeenCalledWith([{ path: "@src/", name: "src/" }]);
});
it("offers the workspace's context files that are still there, files only", async () => {
const onAttach = renderMenu();
fireEvent.mouseEnter(screen.getByTestId("chat-add-context"));
fireEvent.click(await screen.findByTestId("chat-add-context-brief.md"));
expect(api.lanes.sources).toHaveBeenCalledWith(7);
expect(screen.queryByTestId("chat-add-context-gone.md")).toBeNull();
expect(onAttach).toHaveBeenCalledWith([{ path: "@/data/ctx/1/brief.md", name: "brief.md" }]);
});
it("has no Context files without a workspace", () => {
renderMenu(null);
expect(screen.queryByTestId("chat-add-context")).toBeNull();
});
it("lists recent uploads newest first and attaches one as it was sent", () => {
rememberUploads([{ path: "/tmp/d/old.pdf", name: "old.pdf", image: false }]);
rememberUploads([{ path: "/tmp/d/0123456789abcdef.png", name: "shot.png", image: true }]);
const onAttach = renderMenu();
fireEvent.mouseEnter(screen.getByTestId("chat-add-recent"));
const rows = screen.getAllByTestId(/^chat-add-recent-/);
expect(rows.map((r) => r.textContent)).toEqual(["shot.png", "old.pdf"]);
fireEvent.click(rows[0]!);
expect(onAttach).toHaveBeenCalledWith([
{
path: "/tmp/d/0123456789abcdef.png",
name: "shot.png",
url: "/api/run/drop/0123456789abcdef.png",
},
]);
});
});
@@ -0,0 +1,78 @@
/**
* @file ChatFilePreview.test.tsx
* @description Where a Chat pane's file preview reads from: a file under the
* pane's folder through the lane's git file read, a memory file under
* `~/.claude` through the config pages' read, anything else by path only; and
* the list dialog previewing the file picked in it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { FileListModal, FilePreview, displayName } from "../ChatFilePreview";
import { api } from "../../../lib/api";
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
lanes: {
gitFile: vi.fn().mockResolvedValue({
available: true,
path: "src/a.ts",
rev: "work",
binary: false,
truncated: false,
lines: ["export const a = 1;"],
}),
gitRawUrl: () => "",
},
ccConfig: {
file: vi
.fn()
.mockResolvedValue({ ok: true, file: "", text: "Launch notes", size: 12, mtime: 0 }),
},
},
};
});
const scope = { laneId: 4, laneCwd: "/w", cwd: "/w", sessionId: "s1" };
describe("FilePreview", () => {
it("reads a workspace file through the lane", async () => {
render(<FilePreview path="/w/src/a.ts" scope={scope} onClose={() => {}} />);
expect(await screen.findByText("export const a = 1;")).toBeTruthy();
expect(vi.mocked(api.lanes.gitFile)).toHaveBeenCalledWith(4, "src/a.ts");
});
it("reads a memory file through the config read", async () => {
const mem = "/h/.claude/projects/-w/memory/launch.md";
render(<FilePreview path={mem} scope={scope} onClose={() => {}} />);
expect(await screen.findByText("Launch notes")).toBeTruthy();
expect(vi.mocked(api.ccConfig.file)).toHaveBeenCalledWith(mem);
});
it("shows only the path of a file it may not read", () => {
render(<FilePreview path="/etc/hosts" scope={scope} onClose={() => {}} />);
expect(screen.getByText("No preview for this file here")).toBeTruthy();
});
});
describe("FileListModal", () => {
it("previews the file picked in the list", async () => {
const drop = "/tmp/agenthub-drops-1000/0123456789abcdef-minutes.xlsx";
expect(displayName(drop)).toBe("minutes.xlsx");
render(
<FileListModal
title="Uploads"
paths={[drop, "/w/src/a.ts"]}
scope={scope}
onClose={() => {}}
/>
);
expect(screen.getByText("No preview for this file here")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "a.ts" }));
expect(await screen.findByText("export const a = 1;")).toBeTruthy();
});
});
@@ -0,0 +1,87 @@
/**
* @file ChatOutline.test.tsx
* @description The Chat pane's activity panel renders a session's outline the
* way Claude Desktop does: progress with done/current items, outputs by name
* with an EXT tag (a click opens that file), and "Used in this session" rows —
* uploads, files read and memory open their list, skills link to the config.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { describe, expect, it, vi } from "vitest";
import { ChatOutline } from "../ChatOutline";
const outline = {
progress: [
{ id: "1", subject: "Parse plan", status: "completed" },
{ id: "2", subject: "Build report", status: "in_progress" },
],
outputs: ["/w/out/Progress report.xlsx"],
files: ["/w/src/a.ts", "/w/src/b.ts", "/w/src/c.ts"],
uploads: ["/tmp/agenthub-drops-1000/0123456789abcdef-minutes.xlsx"],
memory: ["/h/.claude/projects/-w/memory/release.md"],
skills: ["xlsx"],
mcp: ["vercel_vercel"],
};
describe("ChatOutline", () => {
it("shows progress, outputs and what the session used", () => {
const onClose = vi.fn();
const onOpenFile = vi.fn();
const onOpenList = vi.fn();
render(
<MemoryRouter>
<ChatOutline
outline={outline}
onClose={onClose}
onOpenFile={onOpenFile}
onOpenList={onOpenList}
/>
</MemoryRouter>
);
expect(screen.getByText("Parse plan").className).toContain("line-through");
expect(screen.getByText("Build report").className).not.toContain("line-through");
fireEvent.click(screen.getByRole("button", { name: "XLSX: Progress report" }));
expect(onOpenFile).toHaveBeenCalledWith("/w/out/Progress report.xlsx");
// An upload is named without the drop dir's random prefix.
fireEvent.click(screen.getByTitle("Uploads: minutes.xlsx"));
expect(onOpenList).toHaveBeenCalledWith("uploads");
expect(screen.getByText("+1")).toBeTruthy(); // three files read, two named
fireEvent.click(screen.getByTitle("Memory: release"));
expect(onOpenList).toHaveBeenCalledWith("memory");
expect(screen.getByTitle("Skills: xlsx").getAttribute("href")).toBe(
"/settings/agents/claude-code?tab=skills"
);
expect(screen.getByText("vercel_vercel")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Hide side panel" }));
expect(onClose).toHaveBeenCalled();
});
it("hides progress and the used section when there is none", () => {
render(
<MemoryRouter>
<ChatOutline
outline={{
...outline,
progress: [],
files: [],
uploads: [],
memory: [],
skills: [],
mcp: [],
}}
onClose={() => {}}
onOpenFile={() => {}}
onOpenList={() => {}}
/>
</MemoryRouter>
);
expect(screen.queryByText("Progress")).toBeNull();
expect(screen.queryByText("Used in this session")).toBeNull();
expect(screen.getByRole("button", { name: "Hide side panel" })).toBeTruthy();
});
});
@@ -7,13 +7,13 @@
*/ */
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { act, render, screen, fireEvent, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom"; import { MemoryRouter } from "react-router-dom";
import { LaneConsolePane } from "../LaneConsolePane"; import { LaneConsolePane } from "../LaneConsolePane";
import { api } from "../../../lib/api"; import { api } from "../../../lib/api";
import type { Lane } from "../../../lib/types"; import type { Lane } from "../../../lib/types";
import { __resetAiTools } from "../../../hooks/useAiTools"; import { __resetAiTools } from "../../../hooks/useAiTools";
import { __resetChatUi } from "../../../lib/chatUi"; import { VIEWS_KEY, __resetChatUi, saveChatUi, setSessionChat } from "../../../lib/chatUi";
vi.mock("../TerminalView", () => ({ vi.mock("../TerminalView", () => ({
// The exit button stands in for the server's `exit` frame (a double Ctrl+C // The exit button stands in for the server's `exit` frame (a double Ctrl+C
@@ -33,7 +33,10 @@ vi.mock("../PaneChat", () => ({
vi.mock("../../../lib/api", () => ({ vi.mock("../../../lib/api", () => ({
api: { api: {
me: { prefs: vi.fn().mockResolvedValue({ prefs: {}, onboardedAt: null }) }, me: {
prefs: vi.fn().mockResolvedValue({ prefs: {}, onboardedAt: null }),
savePrefs: vi.fn(),
},
lanes: { lanes: {
ensure: vi.fn(), ensure: vi.fn(),
action: vi.fn(), action: vi.fn(),
@@ -171,7 +174,41 @@ describe("LaneConsolePane", () => {
render(<LaneConsolePane {...baseProps()} activeRuns={{ items: [live] }} />); render(<LaneConsolePane {...baseProps()} activeRuns={{ items: [live] }} />);
expect(await screen.findByTestId("pane-chat")).toHaveAttribute("data-session-id", "s-1"); expect(await screen.findByTestId("pane-chat")).toHaveAttribute("data-session-id", "s-1");
expect(screen.getByTestId("terminal-view")).toBeInTheDocument(); expect(screen.getByTestId("terminal-view")).toBeInTheDocument();
expect(screen.queryByTestId("pane-view-chat")).toBeNull(); // no per-pane switch __resetChatUi();
});
it("keeps each session's own view: the Chat UI setting is only a new session's default", async () => {
vi.mocked(api.me.prefs).mockResolvedValueOnce({
prefs: { "agenthub.chatUi": "1" },
onboardedAt: null,
});
__resetChatUi();
const live = {
id: "agenthub-lane-1",
laneId: 1,
status: "running" as const,
cwd: "/workspace/a",
model: null,
permissionMode: null,
effort: null,
resumeSessionId: null,
sessionId: "s-1",
startedAt: null,
promptPreview: null,
tool: "claude" as const,
};
render(<LaneConsolePane {...baseProps()} activeRuns={{ items: [live] }} />);
expect(await screen.findByTestId("pane-chat")).toBeInTheDocument();
// Seen once, the session keeps chat: turning the default off leaves it alone.
await waitFor(() =>
expect(JSON.parse(localStorage.getItem(VIEWS_KEY) || "{}")).toEqual({ "s-1": true })
);
vi.mocked(api.me.savePrefs).mockResolvedValueOnce({ prefs: {}, onboardedAt: null });
await act(() => saveChatUi(false));
expect(screen.getByTestId("pane-chat")).toBeInTheDocument();
// Its own toggle switches it.
act(() => setSessionChat("s-1", false));
expect(screen.queryByTestId("pane-chat")).toBeNull();
__resetChatUi(); __resetChatUi();
}); });
@@ -0,0 +1,274 @@
/**
* @file PaneChat.test.tsx
* @description The Chat view's controls: the `+` menu (Upload, Skills, Plugins
* → a plugin's skills, each inserted as `/<name>`), the Model/Effort chip typed
* into the pane as `/model` / `/effort`, and the permission mode read off the
* pane and stepped by the server — none of them for a Codex pane; and the
* activity panel, collapsed into a file button with the output count, whose
* files open a preview in its place; a file in a turn's "Edited N files" card
* opens in the Workspace's Changes panel when it is in the pane's folder; and
* Stop in Send's place while the main agent works, pressing Esc in the pane.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PaneChat } from "../PaneChat";
import { api } from "../../../lib/api";
import { interruptRun, submitToRun } from "../../../lib/panePaste";
import { OPEN_IN_CHANGES_EVENT } from "../../../lib/openInChanges";
vi.mock("../../conversation/ConversationView", () => ({
// Stands in for a turn's "Edited N files" card: a click names a file.
ConversationView: ({ onOpenFile }: { onOpenFile?: (path: string) => void }) => (
<div data-testid="conversation">
<button data-testid="edited-in" onClick={() => onOpenFile?.("/w/src/a.ts")} />
<button data-testid="edited-out" onClick={() => onOpenFile?.("/elsewhere/b.ts")} />
</div>
),
}));
vi.mock("../../../lib/panePaste", () => ({
submitToRun: vi.fn(() => true),
interruptRun: vi.fn(() => true),
}));
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
return {
...actual,
api: {
agents: { list: r({ agents: [] }) },
aiTools: {
models: r({
flag: true,
models: [
{ id: "opus", label: "Opus" },
{ id: "sonnet", label: "Sonnet" },
],
defaultModel: null,
}),
},
run: {
files: r({ items: [] }),
dropFile: r({ path: "/tmp/agenthub-drops-1/a.png" }),
permissionMode: r({ mode: "acceptEdits" }),
setPermissionMode: r({ mode: "plan" }),
},
sessions: {
outline: r({
progress: [],
outputs: ["/w/notes.md", "/w/report.xlsx"],
files: [],
uploads: [],
memory: [],
skills: [],
mcp: [],
}),
},
lanes: {
gitFile: r({
available: true,
path: "notes.md",
rev: "work",
binary: false,
truncated: false,
lines: ["# Notes", "", "hello"],
}),
gitRawUrl: () => "",
},
ccConfig: {
commands: r({ items: [] }),
skills: r({
items: [{ scope: "user", name: "morning", frontmatter: { description: "Plan the day" } }],
}),
plugins: r({
plugins: [
{
name: "doox",
enabled: true,
contributes: {
items: {
skills: [{ name: "plan-consolidation", description: "Merge plans" }],
commands: [],
},
},
},
{
name: "off",
enabled: false,
contributes: { items: { skills: [{ name: "x" }], commands: [] } },
},
],
}),
},
},
};
});
const RUN = {
id: "agenthub-lane-1",
tool: "claude" as "claude" | "codex",
model: "opus",
effort: "high" as const,
permissionMode: "auto" as const,
};
function renderChat(run: Partial<typeof RUN> = {}) {
return render(
<MemoryRouter>
<PaneChat run={{ ...RUN, ...run }} sessionId="s1" cwd="/w" />
</MemoryRouter>
);
}
beforeEach(() => vi.mocked(submitToRun).mockClear());
describe("PaneChat — the + menu", () => {
it("puts a skill, or a plugin's skill, at the front of the message", async () => {
renderChat();
const box = screen.getByPlaceholderText("Write a message…") as HTMLTextAreaElement;
fireEvent.change(box, { target: { value: "today" } });
fireEvent.click(screen.getByTestId("chat-add"));
fireEvent.click(screen.getByTestId("chat-add-skills"));
fireEvent.click(await screen.findByTestId("chat-add-skill-morning"));
expect(box.value).toBe("/morning today");
fireEvent.click(screen.getByTestId("chat-add"));
fireEvent.click(screen.getByTestId("chat-add-plugins"));
expect(screen.queryByTestId("chat-add-plugin-off")).toBeNull(); // disabled plugin
fireEvent.click(await screen.findByTestId("chat-add-plugin-doox"));
fireEvent.click(screen.getByTestId("chat-add-skill-doox:plan-consolidation"));
expect(box.value).toBe("/doox:plan-consolidation /morning today");
});
it("Upload opens the prompt box's file picker", () => {
renderChat();
const input = screen.getByTestId("prompt-file-input") as HTMLInputElement;
const clicked = vi.spyOn(input, "click");
fireEvent.click(screen.getByTestId("chat-add"));
fireEvent.click(screen.getByTestId("chat-add-upload"));
expect(clicked).toHaveBeenCalled();
});
});
describe("PaneChat — Stop", () => {
it("replaces Send while the main agent works and the box is empty, and presses Esc", async () => {
vi.mocked(api.agents.list).mockResolvedValueOnce({
agents: [{ type: "main", status: "working" }],
} as never);
renderChat();
fireEvent.click(await screen.findByTestId("pane-chat-stop"));
expect(interruptRun).toHaveBeenCalledWith("agenthub-lane-1");
expect(screen.queryByTestId("pane-chat-stop")).toBeNull();
expect(screen.getByTestId("pane-chat-send")).toBeInTheDocument();
});
it("drops Stop once the turn ended even when no live update says so", async () => {
// A missed agent_updated (another dashboard on the same database got the
// hook, or the socket dropped): the poll while working catches it.
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
vi.mocked(api.agents.list).mockResolvedValueOnce({
agents: [{ type: "main", status: "working" }],
} as never);
renderChat();
expect(await screen.findByTestId("pane-chat-stop")).toBeInTheDocument();
await act(async () => {
await vi.advanceTimersByTimeAsync(5000);
});
await waitFor(() => expect(screen.queryByTestId("pane-chat-stop")).toBeNull());
} finally {
vi.useRealTimers();
}
});
it("keeps Send for a session at rest", async () => {
// With session_id the server answers EVERY agent of the session (its
// status filter does not apply): the main agent is there, waiting.
vi.mocked(api.agents.list).mockResolvedValueOnce({
agents: [{ type: "main", status: "waiting" }],
} as never);
renderChat();
await waitFor(() => expect(api.agents.list).toHaveBeenCalled());
expect(screen.queryByTestId("pane-chat-stop")).toBeNull();
});
});
describe("PaneChat — model, effort and permission mode", () => {
it("types /model into the pane", async () => {
renderChat();
fireEvent.click(screen.getByRole("button", { name: /Opus/ }));
fireEvent.click(await screen.findByRole("menuitemradio", { name: /Sonnet/ }));
expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "/model sonnet");
});
it("shows the mode the pane shows, and asks the server to step it", async () => {
renderChat();
const chip = byId("pane-chat-mode");
await waitFor(() => expect(chip()).toHaveTextContent("Accept edits"));
fireEvent.click(chip().querySelector("button")!);
fireEvent.click(await screen.findByRole("option", { name: /Plan/ }));
await waitFor(() =>
expect(api.run.setPermissionMode).toHaveBeenCalledWith("agenthub-lane-1", "plan")
);
await waitFor(() => expect(chip()).toHaveTextContent("Plan"));
});
it("a Codex pane gets the + menu's Upload only — no skills, model or mode", () => {
renderChat({ tool: "codex" });
expect(screen.queryByTestId("pane-chat-mode")).toBeNull();
expect(screen.queryByRole("button", { name: /Opus/ })).toBeNull();
fireEvent.click(screen.getByTestId("chat-add"));
expect(screen.getByTestId("chat-add-upload")).toBeTruthy();
expect(screen.queryByTestId("chat-add-skills")).toBeNull();
});
});
const byId = (id: string) => () => screen.getByTestId(id);
describe("PaneChat — the activity panel", () => {
it("opens from the file button, previews a file, and closes back to the panel", async () => {
localStorage.removeItem("agenthub.chat.outlineOpen");
render(
<MemoryRouter>
<PaneChat run={RUN} sessionId="s1" cwd="/w" laneId={7} laneCwd="/w" outline />
</MemoryRouter>
);
expect(screen.queryByTestId("chat-outline")).toBeNull();
const show = screen.getByTestId("chat-outline-show");
await waitFor(() => expect(show.textContent).toBe("2"));
fireEvent.click(show);
fireEvent.click(screen.getByRole("button", { name: "MD: notes" }));
expect(screen.queryByTestId("chat-outline")).toBeNull();
await screen.findByTestId("markdown-preview");
expect(vi.mocked(api.lanes.gitFile)).toHaveBeenCalledWith(7, "notes.md");
fireEvent.click(screen.getByRole("button", { name: "Close" }));
expect(screen.getByTestId("chat-outline")).toBeTruthy();
expect(localStorage.getItem("agenthub.chat.outlineOpen")).toBe("1");
});
});
describe("PaneChat — an edited file", () => {
it("opens in the Changes panel when it is in the pane's folder, else in its own preview", async () => {
const seen: unknown[] = [];
const listen = (e: Event) => seen.push((e as CustomEvent).detail);
window.addEventListener(OPEN_IN_CHANGES_EVENT, listen);
render(
<MemoryRouter>
<PaneChat run={RUN} sessionId="s1" cwd="/w" laneId={7} laneCwd="/w" outline />
</MemoryRouter>
);
fireEvent.click(screen.getByTestId("edited-in"));
expect(seen).toEqual([{ laneId: 7, path: "src/a.ts" }]);
expect(screen.queryByTestId("chat-file-preview")).toBeNull();
// Outside the folder the Changes panel cannot show it: the chat's preview does.
fireEvent.click(screen.getByTestId("edited-out"));
expect(seen).toHaveLength(1);
expect(await screen.findByTestId("chat-file-preview")).toBeInTheDocument();
window.removeEventListener(OPEN_IN_CHANGES_EVENT, listen);
});
});
@@ -3,7 +3,10 @@
* @description The run-form pieces that outlived the pane's run form: * @description The run-form pieces that outlived the pane's run form:
* `PromptEditor`'s `/` and `@` autocomplete (Enter picks while the list is * `PromptEditor`'s `/` and `@` autocomplete (Enter picks while the list is
* open, `/` only where slash commands apply), Shift/Alt+Enter newlines, a * open, `/` only where slash commands apply), Shift/Alt+Enter newlines, a
* pasted image shown as a thumbnail and sent as its saved path, and `ModelPicker` — a tool's own * pasted image shown as a thumbnail and sent as its saved path (and
* remembered as a recent upload), a Files panel row dropped as an `@path`
* chip, a composer
* (`bare`) box growing with its text up to a cap, and `ModelPicker` — a tool's own
* list from the server, a fixed list (an AI Proxy's models) when given one, * list from the server, a fixed list (an AI Proxy's models) when given one,
* and Custom…, which is a sentinel, never a model id — and `ModelMenu`, the * and Custom…, which is a sentinel, never a model id — and `ModelMenu`, the
* same list as one chip: aliases up front, pinned versions under More models, * same list as one chip: aliases up front, pinned versions under More models,
@@ -13,17 +16,19 @@
*/ */
import { useState } from "react"; import { useState } from "react";
import { fireEvent, render, screen } from "@testing-library/react"; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next"; import i18n from "i18next";
import { import {
ModelMenu, ModelMenu,
ModelPicker, ModelPicker,
PASTE_AS_FILE_CHARS,
PromptEditor, PromptEditor,
promptWithImages, promptWithImages,
type PromptImage, type PromptImage,
} from "../RunSetup"; } from "../RunSetup";
import { api } from "../../../lib/api"; import { api } from "../../../lib/api";
import { dragProjectPath, recentUploads } from "../../../lib/promptAttach";
vi.mock("../../../lib/api", async (importOriginal) => { vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>(); const actual = await importOriginal<Record<string, unknown>>();
@@ -158,6 +163,34 @@ describe("PromptEditor — / and @ suggestions", () => {
}); });
}); });
describe("PromptEditor — a composer grows with its text", () => {
it("takes the height its text needs, capped by max-h, and only when bare", () => {
const tall = vi
.spyOn(HTMLTextAreaElement.prototype, "scrollHeight", "get")
.mockReturnValue(180);
const props = {
placeholder: "Ask anything…",
slash: false,
fileCwd: "/Users/tester",
images: [],
onImagesChange: () => {},
onChange: () => {},
onSubmit: () => {},
};
const view = render(<PromptEditor {...props} value={"a\nb\nc\nd\ne"} bare />);
expect(box().style.height).toBe("180px");
expect(box().className).toContain("max-h-[40vh]");
tall.mockReturnValue(60);
view.rerender(<PromptEditor {...props} value="a" bare />);
expect(box().style.height).toBe("60px");
view.unmount();
// The framed editor keeps its fixed rows.
render(<PromptEditor {...props} value={"a\nb\nc"} />);
expect(box().style.height).toBe("");
tall.mockRestore();
});
});
describe("PromptEditor — newlines and images", () => { describe("PromptEditor — newlines and images", () => {
it("Alt+Enter inserts a newline and does not send", () => { it("Alt+Enter inserts a newline and does not send", () => {
const spies = renderEditor("ab"); const spies = renderEditor("ab");
@@ -202,7 +235,8 @@ describe("PromptEditor — newlines and images", () => {
expect(screen.getByTestId("sent").textContent).toBe("see /tmp/agenthub-drops-1/a.png"); expect(screen.getByTestId("sent").textContent).toBe("see /tmp/agenthub-drops-1/a.png");
fireEvent.click(thumb); fireEvent.click(thumb);
expect(screen.getByRole("dialog", { name: "shot.png" })).toBeTruthy(); // On the page, not in the composer's layer: nothing stacked over a pane covers it.
expect(screen.getByRole("dialog", { name: "shot.png" }).parentElement).toBe(document.body);
fireEvent.keyDown(document, { key: "Escape" }); fireEvent.keyDown(document, { key: "Escape" });
expect(screen.queryByRole("dialog")).toBeNull(); expect(screen.queryByRole("dialog")).toBeNull();
@@ -212,6 +246,38 @@ describe("PromptEditor — newlines and images", () => {
expect(screen.getByTestId("sent").textContent).toBe("see"); expect(screen.getByTestId("sent").textContent).toBe("see");
}); });
it("a composer's Upload opens a picker whose files attach like a paste", async () => {
const pick: { current: (() => void) | null } = { current: null };
function Harness() {
const [images, setImages] = useState<PromptImage[]>([]);
return (
<>
<PromptEditor
value=""
onChange={() => {}}
onSubmit={() => {}}
placeholder="Ask anything…"
slash
fileCwd="/Users/tester"
images={images}
onImagesChange={setImages}
pickFiles={pick}
/>
<output data-testid="sent">{promptWithImages("", images)}</output>
</>
);
}
render(<Harness />);
const input = screen.getByTestId("prompt-file-input") as HTMLInputElement;
const clicked = vi.spyOn(input, "click");
pick.current?.();
expect(clicked).toHaveBeenCalled();
const doc = new File(["x"], "spec.pdf", { type: "application/pdf" });
fireEvent.change(input, { target: { files: [doc] } });
expect(api.run.dropFile).toHaveBeenCalledWith(doc, undefined); // no workspace named
expect(await screen.findByText("spec.pdf")).toBeTruthy();
});
it("attaches a dropped non-image file as a name chip sent by its path", async () => { it("attaches a dropped non-image file as a name chip sent by its path", async () => {
vi.mocked(api.run.dropFile).mockResolvedValueOnce({ path: "/tmp/agenthub-drops-1/b-spec.pdf" }); vi.mocked(api.run.dropFile).mockResolvedValueOnce({ path: "/tmp/agenthub-drops-1/b-spec.pdf" });
function Harness() { function Harness() {
@@ -237,13 +303,70 @@ describe("PromptEditor — newlines and images", () => {
fireEvent.drop(box(), { dataTransfer: { files: [pdf] } }); fireEvent.drop(box(), { dataTransfer: { files: [pdf] } });
expect(await screen.findByText("spec.pdf")).toBeTruthy(); expect(await screen.findByText("spec.pdf")).toBeTruthy();
expect(screen.getByTestId("sent").textContent).toBe("read /tmp/agenthub-drops-1/b-spec.pdf"); expect(screen.getByTestId("sent").textContent).toBe("read /tmp/agenthub-drops-1/b-spec.pdf");
// Remembered for the + menu's Recent uploads.
expect(recentUploads()).toContainEqual({
path: "/tmp/agenthub-drops-1/b-spec.pdf",
name: "spec.pdf",
});
});
it("attaches a Files panel row dropped on it as an @path chip, once", () => {
function Harness() {
const [images, setImages] = useState<PromptImage[]>([]);
return (
<>
<PromptEditor
value="read"
onChange={() => {}}
onSubmit={() => {}}
placeholder="Ask anything…"
slash
fileCwd="/Users/tester"
images={images}
onImagesChange={setImages}
/>
<output data-testid="sent">{promptWithImages("read", images)}</output>
</>
);
}
render(<Harness />);
const data: Record<string, string> = {};
const dataTransfer = {
setData: (k: string, v: string) => (data[k] = v),
getData: (k: string) => data[k] ?? "",
get types() {
return Object.keys(data);
},
files: [],
};
// What a Files panel row's onDragStart puts on the drag.
dragProjectPath({ dataTransfer } as unknown as React.DragEvent, "src/lib/");
fireEvent.drop(box(), { dataTransfer });
fireEvent.drop(box(), { dataTransfer });
expect(screen.getByText("lib/")).toBeTruthy();
expect(api.run.dropFile).not.toHaveBeenCalled();
expect(screen.getByTestId("sent").textContent).toBe("read @src/lib/");
}); });
it("leaves a text paste to the textarea", () => { it("leaves a text paste to the textarea", () => {
renderEditor(""); renderEditor("");
fireEvent.paste(box(), { clipboardData: { files: [] } }); fireEvent.paste(box(), {
clipboardData: { files: [], getData: () => "a short note" },
});
expect(api.run.dropFile).not.toHaveBeenCalled(); expect(api.run.dropFile).not.toHaveBeenCalled();
}); });
it("attaches a very long text paste as a text file, like a pasted file (#68)", async () => {
renderEditor("");
const long = "x".repeat(PASTE_AS_FILE_CHARS + 1);
fireEvent.paste(box(), { clipboardData: { files: [], getData: () => long } });
await waitFor(() => expect(api.run.dropFile).toHaveBeenCalledTimes(1));
const file = vi.mocked(api.run.dropFile).mock.calls[0]![0] as File;
expect(file.name).toBe("pasted-text.txt");
expect(file.type).toBe("text/plain");
expect(file.size).toBe(long.length); // the whole paste, nothing cut
expect(box()).toHaveValue(""); // not typed into the box
});
}); });
describe("ModelPicker", () => { describe("ModelPicker", () => {
@@ -1,9 +1,9 @@
/** /**
* @file AppearanceSettings.tsx * @file AppearanceSettings.tsx
* @description Settings → Appearance (`/settings/appearance`): the interface language, the * @description Settings → Appearance (`/settings/appearance`): the per-user
* Chat UI default (`ChatUiSettings`, `#chat-ui`), the interface language, the
* terminal font (`lib/terminalFont.ts`, with a preview line), the * terminal font (`lib/terminalFont.ts`, with a preview line), the
* Dark / Light / System mode switch and one named-theme picker per mode, and the * Dark / Light / System mode switch and one named-theme picker per mode, and the
* per-user Chat UI switch (`ChatUiSettings`, `#chat-ui`), the
* corner pet (`PetSettings`, deep-linked as `#pet`) — the old General and Pet * corner pet (`PetSettings`, deep-linked as `#pet`) — the old General and Pet
* pages redirect here. Theme state is in `hooks/useTheme.ts`, palettes in * pages redirect here. Theme state is in `hooks/useTheme.ts`, palettes in
* `index.css`. Each card's swatch re-scopes the theme's CSS variables on its * `index.css`. Each card's swatch re-scopes the theme's CSS variables on its
@@ -20,6 +20,7 @@ import { Monitor, Moon, RotateCcw, Sun } from "lucide-react";
import { SettingsRow, SettingsSection, SettingsSegmented } from "./SettingsRow"; import { SettingsRow, SettingsSection, SettingsSegmented } from "./SettingsRow";
import { PetSettings } from "./PetSettings"; import { PetSettings } from "./PetSettings";
import { ChatUiSettings } from "./ChatUiSettings"; import { ChatUiSettings } from "./ChatUiSettings";
import { Select } from "../Select";
import { THEMES, useTheme, type Theme, type ThemeMode } from "../../hooks/useTheme"; import { THEMES, useTheme, type Theme, type ThemeMode } from "../../hooks/useTheme";
import { confirmDialog } from "../../lib/confirm"; import { confirmDialog } from "../../lib/confirm";
import { resetOnboarding } from "../../lib/userPrefs"; import { resetOnboarding } from "../../lib/userPrefs";
@@ -98,6 +99,8 @@ export function AppearanceSettings() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<ChatUiSettings />
<SettingsSection <SettingsSection
id="language" id="language"
title={t("language.title")} title={t("language.title")}
@@ -128,8 +131,9 @@ export function AppearanceSettings() {
label={t("terminalFont.label")} label={t("terminalFont.label")}
description={termFont === "system" ? t("terminalFont.systemNote") : undefined} description={termFont === "system" ? t("terminalFont.systemNote") : undefined}
control={ control={
<SettingsSegmented // A dropdown: five font names overflow a segmented row.
ariaLabel={t("terminalFont.title")} <Select<TerminalFont>
label={t("terminalFont.title")}
value={termFont} value={termFont}
onChange={pickTermFont} onChange={pickTermFont}
options={(Object.keys(TERMINAL_FONTS) as TerminalFont[]).map((f) => ({ options={(Object.keys(TERMINAL_FONTS) as TerminalFont[]).map((f) => ({
@@ -150,8 +154,6 @@ export function AppearanceSettings() {
</p> </p>
</SettingsSection> </SettingsSection>
<ChatUiSettings />
<section id="theme" className="card p-5 space-y-5 scroll-mt-24"> <section id="theme" className="card p-5 space-y-5 scroll-mt-24">
<h3 className="text-sm font-semibold text-fg-primary">{t("appearance.theme")}</h3> <h3 className="text-sm font-semibold text-fg-primary">{t("appearance.theme")}</h3>
<SettingsRow <SettingsRow
@@ -88,16 +88,17 @@ describe("AppearanceSettings", () => {
it("picks the terminal font, JetBrains Mono by default, and warns on System", async () => { it("picks the terminal font, JetBrains Mono by default, and warns on System", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<AppearanceSettings />); render(<AppearanceSettings />);
const group = screen.getByRole("radiogroup", { name: "Terminal font" }); const trigger = screen.getByRole("button", { name: /Terminal font/ });
expect(within(group).getByRole("radio", { name: "JetBrains Mono" })).toHaveAttribute( expect(trigger).toHaveTextContent("JetBrains Mono");
"aria-checked", const pick = async (name: string) => {
"true" await user.click(screen.getByRole("button", { name: /Terminal font/ }));
); await user.click(screen.getByRole("option", { name }));
await user.click(within(group).getByRole("radio", { name: "Maple Mono (rounded)" })); };
await pick("Maple Mono (rounded)");
expect(localStorage.getItem("agenthub.terminal.font")).toBe("maple"); expect(localStorage.getItem("agenthub.terminal.font")).toBe("maple");
expect(screen.getByTestId("terminal-font-preview").style.fontFamily).toContain("Maple Mono"); expect(screen.getByTestId("terminal-font-preview").style.fontFamily).toContain("Maple Mono");
expect(screen.queryByText(/may lack some Vietnamese letters/)).toBeNull(); expect(screen.queryByText(/may lack some Vietnamese letters/)).toBeNull();
await user.click(within(group).getByRole("radio", { name: "System font" })); await pick("System font");
expect(localStorage.getItem("agenthub.terminal.font")).toBe("system"); expect(localStorage.getItem("agenthub.terminal.font")).toBe("system");
expect(screen.getByText(/may lack some Vietnamese letters/)).toBeInTheDocument(); expect(screen.getByText(/may lack some Vietnamese letters/)).toBeInTheDocument();
}); });
+4 -3
View File
@@ -7,7 +7,8 @@
* frames missed while disconnected are never replayed. The copy is dropped * frames missed while disconnected are never replayed. The copy is dropped
* when the last component unmounts, so a fresh mount always reads fresh rows. * when the last component unmounts, so a fresh mount always reads fresh rows.
* `useWorkLanes()` is the same list narrowed to the workspaces this user may * `useWorkLanes()` is the same list narrowed to the workspaces this user may
* work in (server mode: an analyst's own), for the Workspace page and tree. * work in (server mode: an analyst's own), for the Workspace page; the sidebar
* tree lists them all, other users' read only.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -97,10 +98,10 @@ export function useLanes() {
} }
/** /**
* The workspaces this user WORKS in — the Workspace page and its sidebar tree. * The workspaces this user WORKS in — the Workspace page.
* Server mode: an analyst gets every workspace from the API (to read) but may * Server mode: an analyst gets every workspace from the API (to read) but may
* change only their own, so only those are listed here; other users' work is * change only their own, so only those are listed here; other users' work is
* read on the Sessions, Analytics and Users pages. * read in the sidebar tree (read only), and on Sessions, Analytics and Users.
*/ */
export function useWorkLanes() { export function useWorkLanes() {
const all = useLanes(); const all = useLanes();
+25 -3
View File
@@ -7,11 +7,15 @@
* themselves are CSS-variable blocks in `index.css`. State is never cached in * themselves are CSS-variable blocks in `index.css`. State is never cached in
* the module — every instance reads storage + `matchMedia` through * the module — every instance reads storage + `matchMedia` through
* `useSyncExternalStore`, so the Sidebar and Settings can never disagree, and * `useSyncExternalStore`, so the Sidebar and Settings can never disagree, and
* another tab's change arrives through the `storage` event. * another tab's change arrives through the `storage` event. Every change is also
* saved to the account (`/api/me/prefs`, same keys); a sign-in loads it back
* through `lib/userPrefs.ts` (`hydrate`), while the login page wears the OS's
* light/dark (`applySystemTheme`) because nobody has chosen yet.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { useCallback, useLayoutEffect, useSyncExternalStore } from "react"; import { useCallback, useLayoutEffect, useSyncExternalStore } from "react";
import { api } from "../lib/api";
export type Theme = "dark" | "light"; export type Theme = "dark" | "light";
export type ThemeMode = Theme | "system"; export type ThemeMode = Theme | "system";
@@ -19,7 +23,7 @@ export type ThemeMode = Theme | "system";
/** Named themes per mode, in picker order. `default` is the stock palette. */ /** Named themes per mode, in picker order. `default` is the stock palette. */
export const THEMES = { export const THEMES = {
dark: ["default", "darcula", "apple-dark", "orca-dark"], dark: ["default", "darcula", "apple-dark", "orca-dark"],
light: ["default", "apple-light", "canvas", "orca-light"], light: ["default", "canvas", "apple-light", "orca-light"],
} as const satisfies Record<Theme, readonly string[]>; } as const satisfies Record<Theme, readonly string[]>;
const MODE_KEY = "theme"; const MODE_KEY = "theme";
@@ -35,13 +39,20 @@ function read(key: string): string | null {
} }
} }
function write(key: string, value: string): void { function store(key: string, value: string): void {
try { try {
localStorage.setItem(key, value); localStorage.setItem(key, value);
} catch { } catch {
/* ignore quota / disabled storage */ /* ignore quota / disabled storage */
} }
}
/** A user's own choice: this browser, every reader, and the account. */
function write(key: string, value: string): void {
store(key, value);
window.dispatchEvent(new Event(CHANGE_EVENT)); window.dispatchEvent(new Event(CHANGE_EVENT));
// This browser keeps it even when the save fails (offline, signed out).
api.me.savePrefs({ [key]: value }).catch(() => {});
} }
function systemQuery(): MediaQueryList | undefined { function systemQuery(): MediaQueryList | undefined {
@@ -86,6 +97,17 @@ function apply(resolved: Theme, name: string): void {
else root.dataset.theme = name; else root.dataset.theme = name;
} }
/** Paint what this browser stores (index.html's job at page load). */
export function applyStoredTheme(): void {
const [, resolved, dark, light] = getSnapshot().split("|") as [ThemeMode, Theme, string, string];
apply(resolved, resolved === "dark" ? dark : light);
}
/** The login page: the OS's light/dark in the stock palette. */
export function applySystemTheme(): void {
apply(systemQuery()?.matches === false ? "light" : "dark", "default");
}
/** /**
* Read/write the dashboard's appearance. * Read/write the dashboard's appearance.
* @returns `theme` — the RESOLVED mode (`system` already answered); `mode` — what * @returns `theme` — the RESOLVED mode (`system` already answered); `mode` — what
+5 -1
View File
@@ -186,14 +186,18 @@
"keep": "Keep editing" "keep": "Keep editing"
}, },
"auth": { "auth": {
"title": "Sign in to AgentHub", "title": "Welcome back",
"subtitle": "Sign in to AgentHub with your account",
"username": "Username", "username": "Username",
"password": "Password", "password": "Password",
"showPassword": "Show password",
"hidePassword": "Hide password",
"submit": "Sign in", "submit": "Sign in",
"submitting": "Signing in…", "submitting": "Signing in…",
"failed": "Wrong username or password", "failed": "Wrong username or password",
"tooMany": "Too many failed attempts — try again in a few minutes", "tooMany": "Too many failed attempts — try again in a few minutes",
"logout": "Sign out", "logout": "Sign out",
"noAccount": "No account yet? Ask your administrator to create one.",
"signedInAs": "Signed in as {{name}} ({{role}})" "signedInAs": "Signed in as {{name}} ({{role}})"
}, },
"insights": { "insights": {
+9 -5
View File
@@ -91,10 +91,8 @@
"changes.range.work": "Uncommitted", "changes.range.work": "Uncommitted",
"changes.range.workHint": "Working tree vs HEAD — what has been touched and not committed", "changes.range.workHint": "Working tree vs HEAD — what has been touched and not committed",
"changes.refresh": "Refresh", "changes.refresh": "Refresh",
"changes.resize": "Resize the changes panel (next: {{size}})",
"changes.size.compact": "Narrow",
"changes.size.half": "Medium",
"changes.size.full": "Full screen", "changes.size.full": "Full screen",
"changes.restore": "Restore size",
"changes.fileCount_one": "{{count}} file", "changes.fileCount_one": "{{count}} file",
"changes.fileCount_other": "{{count}} files", "changes.fileCount_other": "{{count}} files",
"changes.dropEdits": "Discard your unsaved edits to this file?", "changes.dropEdits": "Discard your unsaved edits to this file?",
@@ -102,6 +100,7 @@
"changes.title": "Changes", "changes.title": "Changes",
"changes.truncated": "Some changes were left out to keep this panel responsive.", "changes.truncated": "Some changes were left out to keep this panel responsive.",
"changes.unavailable": "This workspace's directory is not a readable git repository.", "changes.unavailable": "This workspace's directory is not a readable git repository.",
"changes.unavailableFolder": "This folder is not a readable git repository.",
"changes.unstage": "Unstage this file", "changes.unstage": "Unstage this file",
"changes.changesView": "Changes", "changes.changesView": "Changes",
"changes.wholeFile": "Whole file", "changes.wholeFile": "Whole file",
@@ -134,8 +133,6 @@
"destructive.reset.survives": "Gitignored files such as node_modules and .env are kept; untracked files are deleted.", "destructive.reset.survives": "Gitignored files such as node_modules and .env are kept; untracked files are deleted.",
"destructive.reset.title": "Reset managed worktree?", "destructive.reset.title": "Reset managed worktree?",
"destructive.warning.no-remote": "No Git remote is configured, so nothing here is backed up remotely. You can still proceed.", "destructive.warning.no-remote": "No Git remote is configured, so nothing here is backed up remotely. You can still proceed.",
"empty.body": "A workspace is one working directory the dashboard follows — its pipeline, sessions and terminal. Click anywhere on this card to add one, or from a terminal:",
"empty.title": "No workspaces yet",
"features.archived": "archived", "features.archived": "archived",
"features.live": "Live", "features.live": "Live",
"features.viewingArchived": "Viewing archived feature \"{{slug}}\" — the workspace keeps running; this is a read-only snapshot.", "features.viewingArchived": "Viewing archived feature \"{{slug}}\" — the workspace keeps running; this is a read-only snapshot.",
@@ -298,6 +295,7 @@
"menu.remove": "Delete workspace…", "menu.remove": "Delete workspace…",
"panel.changes": "Changes", "panel.changes": "Changes",
"panel.analyst": "Analyst", "panel.analyst": "Analyst",
"panel.folder": "Folder to show changes for",
"replay.placeholder": "Message to continue this session…", "replay.placeholder": "Message to continue this session…",
"replay.hint": "Sending resumes the session in a free pane.", "replay.hint": "Sending resumes the session in a free pane.",
"replay.send": "Send", "replay.send": "Send",
@@ -316,6 +314,10 @@
"tree.search": "Search workspaces and sessions", "tree.search": "Search workspaces and sessions",
"tree.toolFilter": "Filter by tool", "tree.toolFilter": "Filter by tool",
"tree.allTools": "All tools", "tree.allTools": "All tools",
"tree.ownerYou": "{{name}} (you)",
"tree.noOwner": "No owner",
"tree.ownerToggle": "Show or hide {{name}}'s workspaces",
"tree.readOnly": "{{name}}'s workspace — read only. A session opens its analysis page.",
"tree.newSession": "New session", "tree.newSession": "New session",
"tree.noSessions": "No sessions yet", "tree.noSessions": "No sessions yet",
"tree.transcriptHint": "No live pane — opens its conversation; a message you send resumes it", "tree.transcriptHint": "No live pane — opens its conversation; a message you send resumes it",
@@ -389,6 +391,8 @@
"panes.split.bottom": "Split down", "panes.split.bottom": "Split down",
"panes.full": "A tab holds at most 4 panes", "panes.full": "A tab holds at most 4 panes",
"panes.newTerminal": "New terminal", "panes.newTerminal": "New terminal",
"panes.showChat": "Show as chat",
"panes.showTerminal": "Show the terminal",
"panes.close": "Move to its own tab (the session keeps running)", "panes.close": "Move to its own tab (the session keeps running)",
"panes.resize": "Resize panes", "panes.resize": "Resize panes",
"tabs.home": "Workspace home", "tabs.home": "Workspace home",
+1 -1
View File
@@ -64,7 +64,7 @@
"goWorkspaces": "Go to Workspaces", "goWorkspaces": "Go to Workspaces",
"add": { "add": {
"title": "Create your first workspace", "title": "Create your first workspace",
"body": "A workspace is one project folder where your agents work. Click here to add one." "body": "A workspace is one project folder where your agents work. Click + beside Workspaces in the sidebar to add one."
}, },
"folder": { "folder": {
"title": "Choose the project folder", "title": "Choose the project folder",
+33 -2
View File
@@ -85,7 +85,38 @@
"chat": { "chat": {
"placeholder": "Write a message…", "placeholder": "Write a message…",
"send": "Send", "send": "Send",
"hint": "Enter sends · permission prompts and menus need the terminal (Settings → Appearance → Chat UI)", "notSent": "The terminal is not connected — the message was not sent.",
"notSent": "The terminal is not connected — the message was not sent." "stop": "Stop",
"notStopped": "The terminal is not connected — nothing was stopped.",
"add": "Add files, skills…",
"upload": "Upload or add files",
"projectFiles": "Project files",
"contextFiles": "Context files",
"recentUploads": "Recent uploads",
"searchFiles": "Search files and folders…",
"skills": "Skills",
"plugins": "Plugins",
"manageSkills": "Manage skills",
"managePlugins": "Manage plugins",
"none": "Nothing here yet",
"fromPlugin": "{{name}} plugin",
"modeFailed": "Could not change the permission mode: {{message}}",
"outline": {
"progress": "Progress",
"outputs": "Outputs",
"connectors": "Connectors",
"empty": "Nothing yet",
"hide": "Hide side panel",
"show": "Activity panel",
"copyPath": "Copy path: {{path}}",
"panel": "Session activity panel",
"used": "Used in this session",
"uploads": "Uploads",
"files": "Files read",
"memory": "Memory",
"close": "Close",
"noPreview": "No preview for this file here",
"truncated": "Showing the first part of this file"
}
} }
} }
+4 -1
View File
@@ -196,7 +196,10 @@
"agents_other": "ran {{count}} agents", "agents_other": "ran {{count}} agents",
"tools_one": "used a tool", "tools_one": "used a tool",
"tools_other": "used {{count}} tools" "tools_other": "used {{count}} tools"
} },
"edited_one": "Edited {{count}} file",
"edited_other": "Edited {{count}} files",
"working": "Thinking…"
}, },
"overview": { "overview": {
"agent": "Agent", "agent": "Agent",
+3 -3
View File
@@ -1022,9 +1022,9 @@
}, },
"chatUi": { "chatUi": {
"title": "Chat UI", "title": "Chat UI",
"description": "How a Claude or Codex session opens in its workspace. Saved for your account only.", "description": "How a new Claude or Codex session opens in its workspace. Saved for your account only.",
"label": "Open sessions as a chat", "label": "Open new sessions as a chat",
"labelDesc": "Show every Claude or Codex session's conversation like Claude Desktop, with a message box, instead of its terminal. Permission prompts and menus still need the terminal: turn this off to answer them.", "labelDesc": "A Claude or Codex session first shows its conversation like Claude Desktop, with a message box, instead of its terminal. Each session then keeps its own view: the chat/terminal button beside a pane's split buttons switches it. Permission prompts and menus still need the terminal.",
"saveFailed": "Could not save: {{message}}" "saveFailed": "Could not save: {{message}}"
} }
} }
+3 -1
View File
@@ -69,5 +69,7 @@
"readOnly": "Analysts can view accounts but not change them.", "readOnly": "Analysts can view accounts but not change them.",
"auditTitle": "Audit log", "auditTitle": "Audit log",
"auditDescription": "Account changes made here, and each time an admin or analyst opened a session a developer owns.", "auditDescription": "Account changes made here, and each time an admin or analyst opened a session a developer owns.",
"auditEmpty": "Nothing recorded yet." "auditEmpty": "Nothing recorded yet.",
"activeCount": "{{n}} active",
"cost30d": "Cost · 30 days"
} }
+5 -1
View File
@@ -186,14 +186,18 @@
"keep": "Tiếp tục sửa" "keep": "Tiếp tục sửa"
}, },
"auth": { "auth": {
"title": "Đăng nhập AgentHub", "title": "Chào mừng trở lại",
"subtitle": "Đăng nhập AgentHub bằng tài khoản của bạn",
"username": "Tên đăng nhập", "username": "Tên đăng nhập",
"password": "Mật khẩu", "password": "Mật khẩu",
"showPassword": "Hiện mật khẩu",
"hidePassword": "Ẩn mật khẩu",
"submit": "Đăng nhập", "submit": "Đăng nhập",
"submitting": "Đang đăng nhập…", "submitting": "Đang đăng nhập…",
"failed": "Sai tên đăng nhập hoặc mật khẩu", "failed": "Sai tên đăng nhập hoặc mật khẩu",
"tooMany": "Sai quá nhiều lần — hãy thử lại sau vài phút", "tooMany": "Sai quá nhiều lần — hãy thử lại sau vài phút",
"logout": "Đăng xuất", "logout": "Đăng xuất",
"noAccount": "Chưa có tài khoản? Hãy nhờ quản trị viên tạo cho bạn.",
"signedInAs": "Đang đăng nhập: {{name}} ({{role}})" "signedInAs": "Đang đăng nhập: {{name}} ({{role}})"
}, },
"insights": { "insights": {
+9 -5
View File
@@ -91,10 +91,8 @@
"changes.range.work": "Chưa commit", "changes.range.work": "Chưa commit",
"changes.range.workHint": "Working tree so với HEAD — những gì vừa sửa mà chưa commit", "changes.range.workHint": "Working tree so với HEAD — những gì vừa sửa mà chưa commit",
"changes.refresh": "Tải lại", "changes.refresh": "Tải lại",
"changes.resize": "Đổi độ rộng khung changes (kế tiếp: {{size}})",
"changes.size.compact": "Hẹp",
"changes.size.half": "Vừa",
"changes.size.full": "Toàn màn hình", "changes.size.full": "Toàn màn hình",
"changes.restore": "Thu nhỏ",
"changes.fileCount_one": "{{count}} tệp", "changes.fileCount_one": "{{count}} tệp",
"changes.fileCount_other": "{{count}} tệp", "changes.fileCount_other": "{{count}} tệp",
"changes.dropEdits": "Bỏ các chỉnh sửa chưa lưu của file này?", "changes.dropEdits": "Bỏ các chỉnh sửa chưa lưu của file này?",
@@ -102,6 +100,7 @@
"changes.title": "Thay đổi", "changes.title": "Thay đổi",
"changes.truncated": "Một phần thay đổi đã bị lược bớt để bảng này còn mượt.", "changes.truncated": "Một phần thay đổi đã bị lược bớt để bảng này còn mượt.",
"changes.unavailable": "Thư mục của workspace này không phải repo git đọc được.", "changes.unavailable": "Thư mục của workspace này không phải repo git đọc được.",
"changes.unavailableFolder": "Thư mục này không phải repo git đọc được.",
"changes.unstage": "Bỏ file này khỏi index", "changes.unstage": "Bỏ file này khỏi index",
"changes.changesView": "Thay đổi", "changes.changesView": "Thay đổi",
"changes.wholeFile": "Toàn bộ file", "changes.wholeFile": "Toàn bộ file",
@@ -134,8 +133,6 @@
"destructive.reset.survives": "Các tệp Git bỏ qua như node_modules và .env được giữ lại; các tệp chưa theo dõi sẽ bị xóa.", "destructive.reset.survives": "Các tệp Git bỏ qua như node_modules và .env được giữ lại; các tệp chưa theo dõi sẽ bị xóa.",
"destructive.reset.title": "Đặt lại worktree được quản lý?", "destructive.reset.title": "Đặt lại worktree được quản lý?",
"destructive.warning.no-remote": "Chưa cấu hình Git remote, nên không có bản sao lưu ở xa. Bạn vẫn có thể tiếp tục.", "destructive.warning.no-remote": "Chưa cấu hình Git remote, nên không có bản sao lưu ở xa. Bạn vẫn có thể tiếp tục.",
"empty.body": "Mỗi workspace là một thư mục làm việc mà dashboard theo dõi — pipeline, session và terminal của nó. Bấm vào bất kỳ đâu trên ô này để thêm, hoặc từ terminal:",
"empty.title": "Chưa có workspace nào",
"features.archived": "đã lưu trữ", "features.archived": "đã lưu trữ",
"features.live": "Phiên bản trực tiếp", "features.live": "Phiên bản trực tiếp",
"features.viewingArchived": "Xem tính năng đã lưu trữ \"{{slug}}\" — workspace tiếp tục chạy; đây là ảnh chụp nhanh chỉ đọc.", "features.viewingArchived": "Xem tính năng đã lưu trữ \"{{slug}}\" — workspace tiếp tục chạy; đây là ảnh chụp nhanh chỉ đọc.",
@@ -298,6 +295,7 @@
"menu.remove": "Xóa workspace…", "menu.remove": "Xóa workspace…",
"panel.changes": "Thay đổi", "panel.changes": "Thay đổi",
"panel.analyst": "Analyst", "panel.analyst": "Analyst",
"panel.folder": "Thư mục để xem thay đổi",
"replay.placeholder": "Nhắn để tiếp tục session này…", "replay.placeholder": "Nhắn để tiếp tục session này…",
"replay.hint": "Gửi tin sẽ resume session trong một pane trống.", "replay.hint": "Gửi tin sẽ resume session trong một pane trống.",
"replay.send": "Gửi", "replay.send": "Gửi",
@@ -316,6 +314,10 @@
"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",
"tree.allTools": "Mọi tool", "tree.allTools": "Mọi tool",
"tree.ownerYou": "{{name}} (bạn)",
"tree.noOwner": "Không có chủ",
"tree.ownerToggle": "Hiện hoặc ẩn workspace của {{name}}",
"tree.readOnly": "Workspace của {{name}} — chỉ xem. Bấm một phiên để mở trang phân tích.",
"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ở hội thoại; gửi tin nhắn sẽ tiếp tục (resume) session", "tree.transcriptHint": "Không còn pane — mở hội thoại; gửi tin nhắn sẽ tiếp tục (resume) session",
@@ -389,6 +391,8 @@
"panes.split.bottom": "Tách xuống dưới", "panes.split.bottom": "Tách xuống dưới",
"panes.full": "Một tab chứa tối đa 4 pane", "panes.full": "Một tab chứa tối đa 4 pane",
"panes.newTerminal": "Terminal mới", "panes.newTerminal": "Terminal mới",
"panes.showChat": "Xem dạng chat",
"panes.showTerminal": "Xem dạng terminal",
"panes.close": "Tách ra tab riêng (session vẫn chạy)", "panes.close": "Tách ra tab riêng (session vẫn chạy)",
"panes.resize": "Đổi kích thước pane", "panes.resize": "Đổi kích thước pane",
"tabs.home": "Trang chủ workspace", "tabs.home": "Trang chủ workspace",
+1 -1
View File
@@ -64,7 +64,7 @@
"goWorkspaces": "Mở Workspaces", "goWorkspaces": "Mở Workspaces",
"add": { "add": {
"title": "Tạo workspace đầu tiên", "title": "Tạo workspace đầu tiên",
"body": "Workspace là một thư mục dự án nơi các agent làm việc. Bấm vào đây để thêm." "body": "Workspace là một thư mục dự án nơi các agent làm việc. Bấm dấu + cạnh mục Không gian làm việc ở sidebar để thêm."
}, },
"folder": { "folder": {
"title": "Chọn thư mục dự án", "title": "Chọn thư mục dự án",
+33 -2
View File
@@ -85,7 +85,38 @@
"chat": { "chat": {
"placeholder": "Viết tin nhắn…", "placeholder": "Viết tin nhắn…",
"send": "Gửi", "send": "Gửi",
"hint": "Enter để gửi · hỏi quyền và menu cần terminal (Settings → Appearance → Giao diện chat)", "notSent": "Terminal chưa kết nối — tin nhắn chưa được gửi.",
"notSent": "Terminal chưa kết nối — tin nhắn chưa được gửi." "stop": "Ngưng",
"notStopped": "Terminal chưa kết nối — chưa ngưng được.",
"add": "Thêm file, skill…",
"upload": "Tải lên hoặc thêm file",
"projectFiles": "File trong dự án",
"contextFiles": "File ngữ cảnh",
"recentUploads": "Đã tải lên gần đây",
"searchFiles": "Tìm file và thư mục…",
"skills": "Skills",
"plugins": "Plugins",
"manageSkills": "Quản lý skills",
"managePlugins": "Quản lý plugins",
"none": "Chưa có gì",
"fromPlugin": "Plugin {{name}}",
"modeFailed": "Không đổi được permission mode: {{message}}",
"outline": {
"progress": "Tiến độ",
"outputs": "Kết quả",
"connectors": "Kết nối",
"empty": "Chưa có gì",
"hide": "Ẩn thanh bên",
"show": "Bảng hoạt động",
"copyPath": "Sao chép đường dẫn: {{path}}",
"panel": "Bảng hoạt động của phiên",
"used": "Đã dùng trong phiên",
"uploads": "Tệp tải lên",
"files": "Tệp đã đọc",
"memory": "Bộ nhớ",
"close": "Đóng",
"noPreview": "Không xem trước được tệp này ở đây",
"truncated": "Chỉ hiện phần đầu của tệp"
}
} }
} }
+4 -1
View File
@@ -196,7 +196,10 @@
"agents_other": "chạy {{count}} agent", "agents_other": "chạy {{count}} agent",
"tools_one": "dùng 1 công cụ", "tools_one": "dùng 1 công cụ",
"tools_other": "dùng {{count}} công cụ" "tools_other": "dùng {{count}} công cụ"
} },
"edited_one": "Đã sửa {{count}} tệp",
"edited_other": "Đã sửa {{count}} tệp",
"working": "Đang suy nghĩ…"
}, },
"overview": { "overview": {
"agent": "Agent", "agent": "Agent",
+3 -3
View File
@@ -1022,9 +1022,9 @@
}, },
"chatUi": { "chatUi": {
"title": "Giao diện chat", "title": "Giao diện chat",
"description": "Cách một session Claude hoặc Codex mở trong workspace. Chỉ lưu cho tài khoản của bạn.", "description": "Cách một session Claude hoặc Codex mới mở trong workspace. Chỉ lưu cho tài khoản của bạn.",
"label": "Mở session dạng chat", "label": "Mở session mới dạng chat",
"labelDesc": "Hiện hội thoại của mọi session Claude hoặc Codex như Claude Desktop, kèm ô nhập tin, thay cho terminal. Hỏi quyền và menu vẫn cần terminal: tắt mục này để trả lời chúng.", "labelDesc": "Session Claude hoặc Codex sẽ mở hội thoại như Claude Desktop, kèm ô nhập tin, thay cho terminal. Sau đó mỗi session giữ dạng xem riêng: nút chat/terminal cạnh nút split của pane để chuyển. Hỏi quyền và menu vẫn cần terminal.",
"saveFailed": "Không lưu được: {{message}}" "saveFailed": "Không lưu được: {{message}}"
} }
} }
+3 -1
View File
@@ -69,5 +69,7 @@
"readOnly": "Analyst chỉ xem được tài khoản, không thay đổi được.", "readOnly": "Analyst chỉ xem được tài khoản, không thay đổi được.",
"auditTitle": "Nhật ký audit", "auditTitle": "Nhật ký audit",
"auditDescription": "Các thay đổi tài khoản thực hiện ở đây, và mỗi lần admin hoặc analyst mở phiên của một developer.", "auditDescription": "Các thay đổi tài khoản thực hiện ở đây, và mỗi lần admin hoặc analyst mở phiên của một developer.",
"auditEmpty": "Chưa ghi nhận gì." "auditEmpty": "Chưa ghi nhận gì.",
"activeCount": "{{n}} đang hoạt động",
"cost30d": "Chi phí · 30 ngày"
} }
+7 -2
View File
@@ -351,9 +351,14 @@
} }
@layer base { @layer base {
body {
font-weight: 500;
}
* { * {
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: rgb(var(--border)) rgb(var(--surface-2)); /* Claude's: a faint thumb on no track. */
scrollbar-color: rgb(var(--fg-muted) / 0.35) transparent;
} }
*::-webkit-scrollbar { *::-webkit-scrollbar {
@@ -362,7 +367,7 @@
} }
*::-webkit-scrollbar-track { *::-webkit-scrollbar-track {
background: rgb(var(--surface-2)); background: transparent;
} }
*::-webkit-scrollbar-thumb { *::-webkit-scrollbar-thumb {
+19 -2
View File
@@ -3,12 +3,13 @@
* @description Pins where a pasted diff comment lands: the workspace's pane * @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 * focused last, else any of its panes, never another workspace's (lane 1 is
* not lane 12), nowhere once the pane unmounted, and never into a pane that * not lane 12), nowhere once the pane unmounted, and never into a pane that
* is not on screen (a hidden console behind a full-width Changes panel). * is not on screen (a hidden console behind a full-width Changes panel); and
* `interruptRun`, the Chat view's Stop, presses Esc only in a visible pane.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { notePaneFocus, pasteToLane, registerPane } from "../panePaste"; import { interruptRun, notePaneFocus, pasteToLane, registerPane } from "../panePaste";
describe("panePaste", () => { describe("panePaste", () => {
it("pastes into the workspace's last-focused pane, and only its own", () => { it("pastes into the workspace's last-focused pane, and only its own", () => {
@@ -48,4 +49,20 @@ describe("panePaste", () => {
expect(got).toEqual(["y"]); expect(got).toEqual(["y"]);
off(); off();
}); });
it("interruptRun presses Esc in a visible pane only", () => {
const keys: string[] = [];
let shown = false;
const off = registerPane(
"agenthub-lane-6",
() => {},
() => shown,
(d) => keys.push(d)
);
expect(interruptRun("agenthub-lane-6")).toBe(false);
shown = true;
expect(interruptRun("agenthub-lane-6")).toBe(true);
expect(keys).toEqual(["\x1b"]);
off();
});
}); });
+50 -8
View File
@@ -644,22 +644,30 @@ async function send<T>(path: string, options?: RequestInit): Promise<T> {
*/ */
/** /**
* What a workspace git call works on: the workspace (its id) or, with * What a workspace git call works on: the workspace (its id) or, with
* `session`, that session's own worktree (`claude --worktree`) — the server * `session`, that session's own worktree (`claude --worktree`), or, with
* resolves the tree from the session row (`?session=`), never from a path. * `source`, one of the workspace's extra folders — the server resolves the
* tree from the session or `lane_sources` row (`?session=` / `?source=`),
* never from a path.
*/ */
export type GitTarget = number | { lane: number; session?: string }; export type GitTarget = number | { lane: number; session?: string; source?: number };
/** `/lanes/<id>/git<sub>` plus `q` and, for a session target, `session=`. */ /** `/lanes/<id>/git<sub>` plus `q` and the target's `session=` / `source=`. */
function gitUrl(target: GitTarget, sub: string, q?: URLSearchParams): string { function gitUrl(target: GitTarget, sub: string, q?: URLSearchParams): string {
const params = new URLSearchParams(q); const params = new URLSearchParams(q);
const lane = typeof target === "number" ? target : target.lane; const lane = typeof target === "number" ? target : target.lane;
if (typeof target !== "number" && target.session) params.set("session", target.session); if (typeof target !== "number" && target.session) params.set("session", target.session);
if (typeof target !== "number" && target.source) params.set("source", String(target.source));
const qs = params.toString(); const qs = params.toString();
return `/lanes/${lane}/git${sub}${qs ? `?${qs}` : ""}`; return `/lanes/${lane}/git${sub}${qs ? `?${qs}` : ""}`;
} }
/** The target for `laneId`, in `session`'s worktree when one is given. */ /** The target for `laneId`: an extra folder's repo, else `session`'s worktree, else the workspace. */
export function gitTarget(laneId: number, session?: string | null): GitTarget { export function gitTarget(
laneId: number,
session?: string | null,
source?: number | null
): GitTarget {
if (source) return { lane: laneId, source };
return session ? { lane: laneId, session } : laneId; return session ? { lane: laneId, session } : laneId;
} }
@@ -911,6 +919,9 @@ 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`),
/** GET /api/sessions/:id/outline — the session's plan, the files it wrote
* and the skills / MCP servers it used (a Chat pane's side panel). */
outline: (id: string) => request<SessionOutline>(`/sessions/${encodeURIComponent(id)}/outline`),
/** PATCH /api/sessions/:id — rename, or archive (hides it from its /** PATCH /api/sessions/:id — rename, or archive (hides it from its
* workspace, deletes nothing). */ * workspace, deletes nothing). */
patch: (id: string, body: { name?: string; archived?: boolean }) => patch: (id: string, body: { name?: string; archived?: boolean }) =>
@@ -2437,10 +2448,12 @@ export const api = {
tmuxAvailable: () => request<{ available: boolean }>("/run/tmux"), tmuxAvailable: () => request<{ available: boolean }>("/run/tmux"),
/** GET /api/run/cwds - suggested working directories for the cwd picker. */ /** GET /api/run/cwds - suggested working directories for the cwd picker. */
cwds: () => request<{ items: CwdSuggestion[] }>("/run/cwds"), cwds: () => request<{ items: CwdSuggestion[] }>("/run/cwds"),
/** GET /api/run/files - path-completion suggestions under `cwd`. */ /** GET /api/run/files - path-completion suggestions under `cwd`; `dirs`
files: (cwd: string, q?: string) => { * adds the folders too, as `dir/`. */
files: (cwd: string, q?: string, dirs = false) => {
const qs = new URLSearchParams({ cwd }); const qs = new URLSearchParams({ cwd });
if (q) qs.set("q", q); if (q) qs.set("q", q);
if (dirs) qs.set("dirs", "1");
return request<{ items: string[] }>(`/run/files?${qs.toString()}`); return request<{ items: string[] }>(`/run/files?${qs.toString()}`);
}, },
/** POST /api/run/drop-image - save an image dropped on the terminal on the /** POST /api/run/drop-image - save an image dropped on the terminal on the
@@ -2470,6 +2483,19 @@ export const api = {
request<RunHandle>("/run", { method: "POST", body: JSON.stringify(args) }), request<RunHandle>("/run", { method: "POST", body: JSON.stringify(args) }),
/** GET /api/run/:id - one run's current handle. */ /** GET /api/run/:id - one run's current handle. */
get: (id: string) => request<RunHandle>(`/run/${encodeURIComponent(id)}`), get: (id: string) => request<RunHandle>(`/run/${encodeURIComponent(id)}`),
/** GET /api/run/:id/permission-mode - the mode a live claude pane shows now
* (null while a menu hides it). */
permissionMode: (id: string) =>
request<{ mode: PermissionMode | "dontAsk" | null }>(
`/run/${encodeURIComponent(id)}/permission-mode`
),
/** POST /api/run/:id/permission-mode - step the pane's mode (Shift+Tab, each
* step read back) to `mode`; 409 when it cannot be reached. */
setPermissionMode: (id: string, mode: PermissionMode) =>
request<{ mode: PermissionMode }>(`/run/${encodeURIComponent(id)}/permission-mode`, {
method: "POST",
body: JSON.stringify({ mode }),
}),
/** DELETE /api/run/:id - kill the tmux session. */ /** DELETE /api/run/:id - kill the tmux session. */
kill: (id: string) => kill: (id: string) =>
request<{ ok: true }>(`/run/${encodeURIComponent(id)}`, { method: "DELETE" }), request<{ ok: true }>(`/run/${encodeURIComponent(id)}`, { method: "DELETE" }),
@@ -4294,6 +4320,22 @@ export interface CodexMarketplaceActionArgs {
/** Maps 1:1 to the `claude --permission-mode` CLI flag. */ /** Maps 1:1 to the `claude --permission-mode` CLI flag. */
export type PermissionMode = "auto" | "acceptEdits" | "default" | "plan" | "bypassPermissions"; export type PermissionMode = "auto" | "acceptEdits" | "default" | "plan" | "bypassPermissions";
/** Maps 1:1 to the `claude --effort` CLI flag; "" omits the flag (model default). */ /** Maps 1:1 to the `claude --effort` CLI flag; "" omits the flag (model default). */
/** `GET /api/sessions/:id/outline`. `status` is the agent's own word
* (`pending` / `in_progress` / `completed`). */
export interface SessionOutline {
progress: { id: string; subject: string; status: string }[];
/** Files it wrote or edited, newest first. */
outputs: string[];
/** Files it only read, newest first. */
files: string[];
/** Files attached to its prompts (the composer's drop dir), newest first. */
uploads: string[];
/** Claude Code memory files it read or wrote, newest first. */
memory: string[];
skills: string[];
mcp: string[];
}
export type EffortLevel = "" | "low" | "medium" | "high" | "xhigh" | "max"; export type EffortLevel = "" | "low" | "medium" | "high" | "xhigh" | "max";
/** "running" — a live tmux session exists; "gone" — it doesn't (killed, /** "running" — a live tmux session exists; "gone" — it doesn't (killed,
* crashed, claude exited and closed the pane). Computed fresh from tmux * crashed, claude exited and closed the pane). Computed fresh from tmux
+11
View File
@@ -0,0 +1,11 @@
/**
* @file brand.ts
* @description SmartGift logo URLs shared by the sidebar and the login page.
* Remote URLs: the logo is owned by the brand, not vendored here — a machine
* with no network shows the alt text instead.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
export const BRAND_LOGO =
"https://smg-services.s3.ap-southeast-1.amazonaws.com/smartgift/assets/logo/logo-main.png";
export const BRAND_MARK = "https://smartgift.vn/favicon.ico";
+64 -15
View File
@@ -1,11 +1,15 @@
/** /**
* @file chatUi.ts — the viewer's Chat UI choice: whether a Claude/Codex pane * @file chatUi.ts — whether a Claude/Codex session shows as a chat
* opens as a chat (`PaneChat`, like Claude Desktop) instead of its terminal. * (`PaneChat`, like Claude Desktop) or as its terminal. Each SESSION has its
* Kept by the server per ACCOUNT (`GET/PUT /api/me/prefs`, key * own view (`useSessionChat`, toggled by `setSessionChat` from the pane's
* `agenthub.chatUi` = "1"; local mode is one account), so it follows a user to * chat/terminal button), kept in this browser's localStorage
* any browser and differs between users of one dashboard. Read once per page * (`agenthub.chatUi.sessions`, the newest `MAX_VIEWS`). The account's Chat UI
* load and shared through `useChatUi`; `saveChatUi` updates every reader at * setting is only the DEFAULT: kept by the server per account
* once and puts the old value back if the save fails. * (`GET/PUT /api/me/prefs`, key `agenthub.chatUi` = "1"; local mode is one
* account), it is written onto a session the first time that session is
* shown, so changing it later leaves every session already seen alone. Read
* once per page load and shared through `useChatUi`; `saveChatUi` updates
* every reader at once and puts the old value back if the save fails.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -14,21 +18,44 @@ import { api } from "./api";
export const CHAT_UI_KEY = "agenthub.chatUi"; export const CHAT_UI_KEY = "agenthub.chatUi";
export const VIEWS_KEY = "agenthub.chatUi.sessions";
const MAX_VIEWS = 200;
let chatUi = false; let chatUi = false;
let loaded = false;
let loading: Promise<void> | null = null; let loading: Promise<void> | null = null;
const listeners = new Set<() => void>(); const listeners = new Set<() => void>();
const subscribe = (fn: () => void) => {
listeners.add(fn);
return () => {
listeners.delete(fn);
};
};
const emit = (next: boolean) => { const emit = (next: boolean) => {
chatUi = next; chatUi = next;
listeners.forEach((fn) => fn()); listeners.forEach((fn) => fn());
}; };
function readViews(): Record<string, boolean> {
try {
const v: unknown = JSON.parse(localStorage.getItem(VIEWS_KEY) || "{}");
return v && typeof v === "object" ? (v as Record<string, boolean>) : {};
} catch {
return {};
}
}
let views = readViews();
function load() { function load() {
// A failed read (logged out, offline) must never take a page down with it. // A failed read (logged out, offline) must never take a page down with it.
loading ??= Promise.resolve() loading ??= Promise.resolve()
.then(() => api.me.prefs()) .then(() => api.me.prefs())
.then( .then(
(r) => emit(r.prefs[CHAT_UI_KEY] === "1"), (r) => {
loaded = true;
emit(r.prefs[CHAT_UI_KEY] === "1");
},
() => { () => {
loading = null; // the next reader asks again loading = null; // the next reader asks again
} }
@@ -37,13 +64,32 @@ function load() {
export function useChatUi(): boolean { export function useChatUi(): boolean {
useEffect(load, []); useEffect(load, []);
return useSyncExternalStore( return useSyncExternalStore(subscribe, () => chatUi);
(fn) => { }
listeners.add(fn);
return () => listeners.delete(fn); /** Shows `sessionId` as a chat (`true`) or its terminal, for this browser. */
}, export function setSessionChat(sessionId: string, on: boolean) {
() => chatUi const { [sessionId]: _old, ...rest } = views;
); // Newest last, so the cap drops the sessions touched longest ago.
views = Object.fromEntries(Object.entries({ ...rest, [sessionId]: on }).slice(-MAX_VIEWS));
try {
localStorage.setItem(VIEWS_KEY, JSON.stringify(views));
} catch {
// Storage full or blocked: the choice still holds for this page load.
}
listeners.forEach((fn) => fn());
}
/** The session's own view; a session seen for the first time takes the
* account's default (once it is read) and keeps it. */
export function useSessionChat(sessionId: string | null | undefined): boolean {
const fallback = useChatUi();
const own = useSyncExternalStore(subscribe, () => (sessionId ? views[sessionId] : undefined));
const ready = useSyncExternalStore(subscribe, () => loaded);
useEffect(() => {
if (sessionId && own === undefined && ready) setSessionChat(sessionId, chatUi);
}, [sessionId, own, ready]);
return own ?? fallback;
} }
export async function saveChatUi(on: boolean): Promise<void> { export async function saveChatUi(on: boolean): Promise<void> {
@@ -61,5 +107,8 @@ export async function saveChatUi(on: boolean): Promise<void> {
/** Tests: forget what was read. */ /** Tests: forget what was read. */
export function __resetChatUi() { export function __resetChatUi() {
chatUi = false; chatUi = false;
loaded = false;
loading = null; loading = null;
views = {};
localStorage.removeItem(VIEWS_KEY);
} }
+23
View File
@@ -0,0 +1,23 @@
/**
* @file openInChanges.ts — asks the Workspace page to open a file in its
* Changes panel: a chat's "Edited N files" card names absolute paths deep in
* a pane, the panel lives on the page, so the request travels as a window
* event (like the sidebar's `LIVE_RUNS_EVENT`). The page opens the panel and
* that file there, if the panel shows the same workspace; else nothing.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
export const OPEN_IN_CHANGES_EVENT = "agenthub:open-in-changes";
/** A file to open: its workspace, and its path relative to the panel's folder. */
export type OpenInChanges = { laneId: number; path: string };
/** Opens `path` (absolute) when it lies under `cwd`, the pane's folder — the
* folder the Changes panel reads for it. False when it does not. */
export function openInChanges(laneId: number, cwd: string, path: string): boolean {
const base = cwd.replace(/\/+$/, "");
if (!base || !path.startsWith(`${base}/`)) return false;
const detail: OpenInChanges = { laneId, path: path.slice(base.length + 1) };
window.dispatchEvent(new CustomEvent(OPEN_IN_CHANGES_EVENT, { detail }));
return true;
}
+17 -5
View File
@@ -9,25 +9,28 @@
* hidden behind a full-width Changes panel or a replay would swallow it * hidden behind a full-width Changes panel or a replay would swallow it
* unseen); the caller says where it went, or that it went nowhere. The one * unseen); the caller says where it went, or that it went nowhere. The one
* exception is `submitToRun`: a pane's own Chat view (`PaneChat.tsx`) sends * exception is `submitToRun`: a pane's own Chat view (`PaneChat.tsx`) sends
* what the user typed there, so it pastes AND presses Enter. * what the user typed there, so it pastes AND presses Enter — and its Stop
* button presses Esc through `interruptRun`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
interface Pane { interface Pane {
paste: (text: string, submit?: boolean) => void; paste: (text: string, submit?: boolean) => void;
visible: () => boolean; visible: () => boolean;
key?: (data: string) => void;
} }
const panes = new Map<string, Pane>(); const panes = new Map<string, Pane>();
let lastFocused: string | null = null; let lastFocused: string | null = null;
/** Registers a mounted terminal (`visible`: is it on screen now); returns its /** Registers a mounted terminal (`visible`: is it on screen now; `key`: send
* unregister. */ * raw input, as a key press); returns its unregister. */
export function registerPane( export function registerPane(
runId: string, runId: string,
paste: (text: string, submit?: boolean) => void, paste: (text: string, submit?: boolean) => void,
visible: () => boolean = () => true visible: () => boolean = () => true,
key?: (data: string) => void
): () => void { ): () => void {
const entry = { paste, visible }; const entry = { paste, visible, key };
panes.set(runId, entry); panes.set(runId, entry);
return () => { return () => {
if (panes.get(runId) === entry) panes.delete(runId); if (panes.get(runId) === entry) panes.delete(runId);
@@ -63,6 +66,15 @@ export function submitToRun(runId: string, text: string): boolean {
return true; return true;
} }
/** Presses Esc in the pane of `runId` — how `claude` and `codex` stop the turn
* they are running. False when the pane is not mounted or not attached. */
export function interruptRun(runId: string): boolean {
const p = panes.get(runId);
if (!p?.visible() || !p.key) return false;
p.key("\x1b");
return true;
}
/** Pastes `text` into one of the workspace's VISIBLE panes — the one focused /** Pastes `text` into one of the workspace's VISIBLE panes — the one focused
* last when it is — and returns its run id; null when none is on screen. */ * last when it is — and returns its run id; null when none is on screen. */
export function pasteToLane(laneId: number, text: string): string | null { export function pasteToLane(laneId: number, text: string): string | null {
+8 -1
View File
@@ -2,7 +2,7 @@
* @file projectTreePrefs — the sidebar workspace list's viewer preferences, in * @file projectTreePrefs — the sidebar workspace list's viewer preferences, in
* ONE localStorage key (`agenthub.sidebar.projects`): which workspaces are * ONE localStorage key (`agenthub.sidebar.projects`): which workspaces are
* expanded to their sessions (by lane id), the tool the list is filtered to, * expanded to their sessions (by lane id), the tool the list is filtered to,
* and — Settings → Worktrees — * which owner groups an admin/analyst folded, and — Settings → Worktrees —
* whether a workspace home's Add folder suggests the repo's untracked * whether a workspace home's Add folder suggests the repo's untracked
* worktrees and which sources of them (`claude` = `<repo>/.claude/worktrees/*`, * worktrees and which sources of them (`claude` = `<repo>/.claude/worktrees/*`,
* `agenthub` = LANES_ROOT) it leaves out. A `useSyncExternalStore` reader that caches nothing * `agenthub` = LANES_ROOT) it leaves out. A `useSyncExternalStore` reader that caches nothing
@@ -20,6 +20,8 @@ export interface ProjectTreePrefs {
expanded: Record<string, boolean>; expanded: Record<string, boolean>;
/** Show only this tool's workspaces; null = every tool. */ /** Show only this tool's workspaces; null = every tool. */
tool: string | null; tool: string | null;
/** Server mode, admin/analyst: owner key (user id, or "none") → its group is folded. */
collapsedOwners: Record<string, boolean>;
showUntracked: boolean; showUntracked: boolean;
hideSources: string[]; hideSources: string[];
} }
@@ -27,6 +29,7 @@ export interface ProjectTreePrefs {
const DEFAULTS: ProjectTreePrefs = { const DEFAULTS: ProjectTreePrefs = {
expanded: {}, expanded: {},
tool: null, tool: null,
collapsedOwners: {},
showUntracked: true, showUntracked: true,
hideSources: [], hideSources: [],
}; };
@@ -50,6 +53,10 @@ export function readProjectTreePrefs(): ProjectTreePrefs {
value = { value = {
expanded: parsed.expanded && typeof parsed.expanded === "object" ? parsed.expanded : {}, expanded: parsed.expanded && typeof parsed.expanded === "object" ? parsed.expanded : {},
tool: typeof parsed.tool === "string" && parsed.tool ? parsed.tool : null, tool: typeof parsed.tool === "string" && parsed.tool ? parsed.tool : null,
collapsedOwners:
parsed.collapsedOwners && typeof parsed.collapsedOwners === "object"
? parsed.collapsedOwners
: {},
showUntracked: parsed.showUntracked !== false, showUntracked: parsed.showUntracked !== false,
hideSources: Array.isArray(parsed.hideSources) ? parsed.hideSources : [], hideSources: Array.isArray(parsed.hideSources) ? parsed.hideSources : [],
}; };
+101
View File
@@ -0,0 +1,101 @@
/**
* @file promptAttach.ts
* @description What a prompt box can attach besides a fresh upload: a project
* file or folder dragged from the Files panel (`ATTACH_MIME`, read back by
* `PromptEditor`'s drop) and the files uploaded through a prompt box in the
* last day (`recentUploads`, kept in this browser). An attachment is a path
* sent with the prompt, so nothing here touches the server.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { DragEvent } from "react";
/** One attachment, shaped like `PromptImage`: what is sent, and its label. */
export interface Attachment {
path: string;
name: string;
url?: string;
}
export const ATTACH_MIME = "application/x-agenthub-attach";
/** A project path (relative to the box's folder) as Claude Code's `@` mention. */
export function projectAttachment(rel: string): Attachment {
const name = rel.replace(/\/$/, "").split("/").pop() || rel;
return { path: `@${rel}`, name: rel.endsWith("/") ? `${name}/` : name };
}
/** Start a drag carrying `rel` (a Files panel row) to a prompt box; plain
* text too, so a drop elsewhere (a terminal, an editor) gets the path. */
export function dragProjectPath(e: DragEvent, rel: string) {
e.dataTransfer.setData(ATTACH_MIME, JSON.stringify(projectAttachment(rel)));
e.dataTransfer.setData("text/plain", rel);
e.dataTransfer.effectAllowed = "copy";
}
/** The attachment a drop carries, if it is one of ours. */
export function droppedAttachment(dt: DataTransfer | null | undefined): Attachment | null {
try {
const v: unknown = JSON.parse(dt?.getData(ATTACH_MIME) || "null");
const a = v as Attachment | null;
return a && typeof a.path === "string" && typeof a.name === "string"
? { path: a.path, name: a.name }
: null;
} catch {
return null;
}
}
/** `prev` plus whichever of `items` it does not hold yet. */
export function addAttachments<T extends Attachment>(prev: T[], items: T[]): T[] {
const fresh = items.filter(
(a, i) =>
!prev.some((p) => p.path === a.path) && items.findIndex((b) => b.path === a.path) === i
);
return fresh.length ? [...prev, ...fresh] : prev;
}
const RECENT_KEY = "agenthub.prompt.recentUploads";
const RECENT_MAX = 20;
// The server prunes an upload a day after it was written (`saveDrop`).
// ponytail: a client-side guess of that TTL, a few minutes short; a listing
// route instead if uploads must outlive it or follow the user to another browser.
const RECENT_TTL_MS = 23.9 * 60 * 60_000;
interface Recent extends Attachment {
at: number;
}
function readRecent(): Recent[] {
try {
const v: unknown = JSON.parse(localStorage.getItem(RECENT_KEY) || "[]");
const cutoff = Date.now() - RECENT_TTL_MS;
return (Array.isArray(v) ? (v as Recent[]) : []).filter(
(r) => typeof r?.path === "string" && typeof r.name === "string" && r.at > cutoff
);
} catch {
return [];
}
}
export function recentUploads(): Attachment[] {
return readRecent().map(({ path, name, url }) => ({ path, name, ...(url ? { url } : {}) }));
}
/** Remember uploads just saved, newest first; an image keeps a thumbnail
* through `GET /api/run/drop/:name` (its local blob URL dies with the page). */
export function rememberUploads(items: { path: string; name: string; image: boolean }[]) {
const at = Date.now();
const added: Recent[] = items.map(({ path, name, image }) => ({
path,
name,
at,
...(image ? { url: `/api/run/drop/${encodeURIComponent(path.split("/").pop() || "")}` } : {}),
}));
const kept = readRecent().filter((r) => !added.some((a) => a.path === r.path));
try {
localStorage.setItem(RECENT_KEY, JSON.stringify([...added, ...kept].slice(0, RECENT_MAX)));
} catch {
/* storage full or blocked: the list is a convenience */
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
/** /**
* @file terminalFont — the lane terminal's font, a viewer preference kept in * @file terminalFont — the lane terminal's font, a viewer preference kept in
* `localStorage` (`agenthub.terminal.font`) and read by `TerminalView` when a * `localStorage` (`agenthub.terminal.font`) and read by `TerminalView` when a
* terminal opens. `jetbrains` (the default) and `maple` (rounded) are bundled * terminal opens. `jetbrains` (the default) and the rounded `maple` are
* webfonts that cover every Vietnamese letter; `system` is the browser's own * bundled webfonts that cover every Vietnamese letter; `system` is the browser's own
* fixed-width font, which may not (DejaVu Sans Mono on Linux lacks ấ, ế, ố…, * fixed-width font, which may not (DejaVu Sans Mono on Linux lacks ấ, ế, ố…,
* and those glyphs then fall back to a face of another width). * and those glyphs then fall back to a face of another width).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
+8
View File
@@ -2661,6 +2661,8 @@ export interface Lane {
cwd: string; cwd: string;
/** Server mode: the account that owns this workspace (null = nobody yet). */ /** Server mode: the account that owns this workspace (null = nobody yet). */
owner_id?: number | null; owner_id?: number | null;
/** The owner's username (`users.username`); null with no owner. */
owner_name?: string | null;
branch: string | null; branch: string | null;
kind: "managed" | "adopted"; kind: "managed" | "adopted";
/** The CLI this lane's runs spawn. Immutable once the lane exists. */ /** The CLI this lane's runs spawn. Immutable once the lane exists. */
@@ -3205,6 +3207,9 @@ export interface TranscriptContent {
output?: string; output?: string;
/** Present for "tool_result" blocks: whether the tool call errored. */ /** Present for "tool_result" blocks: whether the tool call errored. */
is_error?: boolean; is_error?: boolean;
/** Present on a Write/Edit/MultiEdit "tool_result": the lines it added and
* removed (from Claude Code's `structuredPatch`). */
diff?: { added: number; removed: number };
} }
/** Who actually sent a transcript message. A JSONL `type:"user"` line can be the /** Who actually sent a transcript message. A JSONL `type:"user"` line can be the
@@ -3239,6 +3244,9 @@ export interface TranscriptMessage {
* Example: `"claude-opus-4-8"`. */ * Example: `"claude-opus-4-8"`. */
model?: string; model?: string;
/** Token accounting reported alongside an assistant message; absent otherwise. */ /** Token accounting reported alongside an assistant message; absent otherwise. */
/** Assistant lines: why the model stopped — "end_turn" closes the turn,
* "tool_use" means more work follows. Absent from an older server. */
stop_reason?: string;
usage?: { usage?: {
/** Fresh (non-cached) input tokens for this message. */ /** Fresh (non-cached) input tokens for this message. */
input_tokens: number; input_tokens: number;
+3
View File
@@ -18,6 +18,7 @@
import { useSyncExternalStore } from "react"; import { useSyncExternalStore } from "react";
import { api } from "./api"; import { api } from "./api";
import i18n from "../i18n"; import i18n from "../i18n";
import { applyStoredTheme } from "../hooks/useTheme";
/** The localStorage keys an account's preferences are made of (server/routes/me.js). */ /** The localStorage keys an account's preferences are made of (server/routes/me.js). */
export const PREF_KEYS = [ export const PREF_KEYS = [
@@ -100,6 +101,8 @@ function readLocal(key: string): string | null {
/** Make what reads these keys read them again. */ /** Make what reads these keys read them again. */
function reapply(prefs: Record<string, string>) { function reapply(prefs: Record<string, string>) {
// Painted now, not only on the next useTheme render: the login page wore the OS's.
applyStoredTheme();
window.dispatchEvent(new Event("agenthub-theme")); window.dispatchEvent(new Event("agenthub-theme"));
window.dispatchEvent(new Event("sagi:prefs")); window.dispatchEvent(new Event("sagi:prefs"));
const lng = prefs.i18nextLng; const lng = prefs.i18nextLng;
+3
View File
@@ -57,15 +57,18 @@ import "@fontsource/inter/latin-600.css";
import "@fontsource/inter/latin-700.css"; import "@fontsource/inter/latin-700.css";
import "@fontsource/jetbrains-mono/latin-400.css"; import "@fontsource/jetbrains-mono/latin-400.css";
import "@fontsource/jetbrains-mono/latin-500.css"; import "@fontsource/jetbrains-mono/latin-500.css";
import "@fontsource/jetbrains-mono/latin-700.css";
// The lane terminal renders live Vietnamese output in this font (TerminalView), // The lane terminal renders live Vietnamese output in this font (TerminalView),
// which the latin subset above doesn't cover — without it, missing glyphs fall // which the latin subset above doesn't cover — without it, missing glyphs fall
// back to another installed face with a different advance width and get // back to another installed face with a different advance width and get
// visually mangled inside xterm's fixed-width cells. // visually mangled inside xterm's fixed-width cells.
import "@fontsource/jetbrains-mono/vietnamese-400.css"; import "@fontsource/jetbrains-mono/vietnamese-400.css";
import "@fontsource/jetbrains-mono/vietnamese-500.css"; import "@fontsource/jetbrains-mono/vietnamese-500.css";
import "@fontsource/jetbrains-mono/vietnamese-700.css";
// The rounded terminal font (Settings → Appearance). Its one file already holds // The rounded terminal font (Settings → Appearance). Its one file already holds
// the Vietnamese glyphs; a face is only downloaded once something uses it. // the Vietnamese glyphs; a face is only downloaded once something uses it.
import "@fontsource/maple-mono/400.css"; import "@fontsource/maple-mono/400.css";
import "@fontsource/maple-mono/500.css";
import "@fontsource/maple-mono/700.css"; import "@fontsource/maple-mono/700.css";
import App from "./App"; import App from "./App";
import { AuthGate } from "./components/AuthGate"; import { AuthGate } from "./components/AuthGate";
+242 -106
View File
@@ -7,13 +7,30 @@
* an admin — the account changes: add a user, change a role, disable/enable, * an admin — the account changes: add a user, change a role, disable/enable,
* reset a password, give a workspace. Every change asks for the admin's OWN * reset a password, give a workspace. Every change asks for the admin's OWN
* password again (the server refuses without it); an analyst only reads. * password again (the server refuses without it); an analyst only reads.
* Laid out like the other list pages: icon header, a row of totals
* (`StatCard`), the accounts in a `card` table, the audit log in a card.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode } from "react"; import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode } from "react";
import { Navigate } from "react-router-dom"; import { Navigate } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FolderInput, KeyRound, Lock, LockOpen, RefreshCw, Shield, UserPlus } from "lucide-react"; import {
AlertCircle,
DollarSign,
Eye,
FolderInput,
FolderOpen,
KeyRound,
Lock,
LockOpen,
MessagesSquare,
RefreshCw,
ScrollText,
Shield,
UserPlus,
Users as UsersIcon,
} from "lucide-react";
import { api, type AuditEntry, type AuthUser, type ServerUserUsage } from "../lib/api"; import { api, type AuditEntry, type AuthUser, type ServerUserUsage } from "../lib/api";
import type { Lane } from "../lib/types"; import type { Lane } from "../lib/types";
import { fmt, fmtCost, formatDateTime } from "../lib/format"; import { fmt, fmtCost, formatDateTime } from "../lib/format";
@@ -22,7 +39,8 @@ import { isSubmitEnter } from "../lib/keys";
import { useSpin } from "../hooks/useSpin"; import { useSpin } from "../hooks/useSpin";
import { useAuth } from "../components/AuthGate"; import { useAuth } from "../components/AuthGate";
import { ConfirmModal } from "../components/ConfirmModal"; import { ConfirmModal } from "../components/ConfirmModal";
import { SettingsSection } from "../components/settings/SettingsRow"; import { StatCard } from "../components/StatCard";
import { TableRowSkeleton } from "../components/Skeleton";
type Role = AuthUser["role"]; type Role = AuthUser["role"];
const ROLES: Role[] = ["admin", "analyst", "developer"]; const ROLES: Role[] = ["admin", "analyst", "developer"];
@@ -67,12 +85,22 @@ export function Users() {
// Local mode, or a developer: there is no Users page for them. // Local mode, or a developer: there is no Users page for them.
if (!me || me.role === "developer") return <Navigate to="/" replace />; if (!me || me.role === "developer") return <Navigate to="/" replace />;
const sum = (pick: (u: ServerUserUsage) => number) =>
(users ?? []).reduce((n, u) => n + pick(u), 0);
const active = (users ?? []).filter((u) => !u.disabled).length;
const th = "px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
return ( return (
<div className="animate-fade-in space-y-6"> <div className="animate-fade-in space-y-6">
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
<div> <div className="flex items-center gap-3">
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1> <div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center">
<p className="text-xs text-fg-muted">{t("description")}</p> <UsersIcon className="w-4.5 h-4.5 text-accent" />
</div>
<div>
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
<p className="text-xs text-fg-muted">{t("description")}</p>
</div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
@@ -97,115 +125,206 @@ export function Users() {
</div> </div>
</div> </div>
<SettingsSection title={t("title")}> <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{error && ( <StatCard
<p role="alert" className="py-2 text-xs text-status-danger"> label={t("title")}
{error} value={users?.length ?? 0}
</p> icon={UsersIcon}
)} trend={users ? t("activeCount", { n: active }) : undefined}
{!isAdmin && <p className="pb-2 text-xs text-fg-muted">{t("readOnly")}</p>} loading={!users && !error}
{users && ( />
<div className="overflow-x-auto"> <StatCard
<table className="w-full text-sm" data-testid="users-table"> label={t("workspaces")}
<thead className="text-left text-[11px] uppercase text-fg-muted"> value={sum((u) => u.workspaces)}
<tr> icon={FolderOpen}
<th className="py-2 pr-3 font-medium">{t("name")}</th> loading={!users && !error}
<th className="py-2 pr-3 font-medium">{t("role")}</th> />
<th className="py-2 pr-3 text-right font-medium">{t("workspaces")}</th> <StatCard
<th className="py-2 pr-3 text-right font-medium">{t("sessions")}</th> label={t("sessions")}
<th className="py-2 pr-3 text-right font-medium">{t("last7d")}</th> value={sum((u) => u.sessions)}
<th className="py-2 pr-3 text-right font-medium">{t("last30d")}</th> icon={MessagesSquare}
{isAdmin && <th className="py-2 text-right font-medium">{t("actions")}</th>} loading={!users && !error}
</tr> />
</thead> <StatCard
<tbody className="divide-y divide-border/60"> label={t("cost30d")}
{users.map((u) => { value={fmtCost(sum((u) => u.last30d.cost))}
const self = u.id === me.id; icon={DollarSign}
return ( trend={users ? `${t("last7d")}: ${fmtCost(sum((u) => u.last7d.cost))}` : undefined}
<tr key={u.id} className={u.disabled ? "text-fg-muted" : "text-fg-secondary"}> raw={t("tokens", { n: fmt(sum((u) => u.last30d.tokens)) })}
<td className="py-2 pr-3"> loading={!users && !error}
{u.username} />
{self && <span className="ml-2 text-[11px] text-accent">({t("you")})</span>} </div>
{u.disabled && <span className="ml-2 text-[11px]">({t("disabled")})</span>}
</td>
<td className="py-2 pr-3" title={t(`roleHint.${u.role}`)}>
{t(`roles.${u.role}`)}
</td>
<td className="py-2 pr-3 text-right tabular-nums">{u.workspaces}</td>
<td className="py-2 pr-3 text-right tabular-nums">{u.sessions}</td>
<td
className="py-2 pr-3 text-right tabular-nums"
title={t("tokens", { n: fmt(u.last7d.tokens) })}
>
{fmtCost(u.last7d.cost)}
</td>
<td
className="py-2 pr-3 text-right tabular-nums"
title={t("tokens", { n: fmt(u.last30d.tokens) })}
>
{fmtCost(u.last30d.cost)}
</td>
{isAdmin && (
<td className="py-1 text-right whitespace-nowrap">
<RowAction
label={t("changeRole")}
disabled={self}
onClick={() => setDialog({ kind: "role", user: u })}
>
<Shield className="h-4 w-4" />
</RowAction>
<RowAction
label={t("resetPassword")}
onClick={() => setDialog({ kind: "password", user: u })}
>
<KeyRound className="h-4 w-4" />
</RowAction>
<RowAction
label={t("assignWorkspace")}
onClick={() => setDialog({ kind: "workspace", user: u })}
>
<FolderInput className="h-4 w-4" />
</RowAction>
<RowAction
label={u.disabled ? t("enable") : t("disable")}
disabled={self}
onClick={() =>
setDialog({ kind: u.disabled ? "enable" : "disable", user: u })
}
>
{u.disabled ? (
<LockOpen className="h-4 w-4" />
) : (
<Lock className="h-4 w-4" />
)}
</RowAction>
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
)}
</SettingsSection>
<SettingsSection title={t("auditTitle")} description={t("auditDescription")}> {error && (
{audit?.length === 0 && <p className="py-2 text-sm text-fg-muted">{t("auditEmpty")}</p>} <div
role="alert"
className="card flex flex-wrap items-center gap-3 p-3 border-status-danger/40 text-sm text-status-danger"
>
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span className="flex-1 min-w-0">{error}</span>
<button type="button" onClick={refresh} className="btn-ghost text-xs">
{t("common:retry")}
</button>
</div>
)}
{!isAdmin && (
<p className="flex items-center gap-1.5 text-xs text-fg-muted">
<Eye className="w-3.5 h-3.5" /> {t("readOnly")}
</p>
)}
<div className="card overflow-x-auto">
<table className="w-full min-w-[760px]" data-testid="users-table">
<thead>
<tr className="border-b border-border text-left">
<th className={th}>{t("name")}</th>
<th className={th}>{t("role")}</th>
<th className={`${th} text-right`}>{t("workspaces")}</th>
<th className={`${th} text-right`}>{t("sessions")}</th>
<th className={`${th} text-right`}>{t("last7d")}</th>
<th className={`${th} text-right`}>{t("last30d")}</th>
{isAdmin && <th className={`${th} text-right`}>{t("actions")}</th>}
</tr>
</thead>
<tbody className="divide-y divide-border">
{!users &&
!error &&
Array.from({ length: 4 }).map((_, i) => (
<TableRowSkeleton
key={i}
columns={isAdmin ? 7 : 6}
widths={["w-36", "w-20", "w-8", "w-8", "w-14", "w-14", "w-28"]}
/>
))}
{users?.map((u) => {
const self = u.id === me.id;
return (
<tr
key={u.id}
className={`hover:bg-surface-4/40 transition-colors ${u.disabled ? "opacity-60" : ""}`}
>
<td className="px-5 py-3">
<div className="flex items-center gap-3 min-w-0">
<span
aria-hidden
className="w-8 h-8 flex-shrink-0 rounded-full bg-surface-4 flex items-center justify-center text-xs font-semibold uppercase text-fg-secondary"
>
{u.username.slice(0, 2)}
</span>
<span className="truncate text-sm font-medium text-fg-primary">
{u.username}
</span>
{self && (
<span className="badge py-0.5 text-[11px] bg-accent/15 text-accent border-accent/20">
{t("you")}
</span>
)}
{u.disabled && (
<span className="badge py-0.5 text-[11px] bg-status-danger/10 text-status-danger border-status-danger/20">
{t("disabled")}
</span>
)}
</div>
</td>
<td className="px-5 py-3">
<span
className={`badge py-0.5 text-[11px] ${ROLE_BADGE[u.role]}`}
title={t(`roleHint.${u.role}`)}
>
{t(`roles.${u.role}`)}
</span>
</td>
<td className="px-5 py-3 text-right text-sm tabular-nums text-fg-secondary">
{u.workspaces}
</td>
<td className="px-5 py-3 text-right text-sm tabular-nums text-fg-secondary">
{u.sessions}
</td>
<UsageCell usage={u.last7d} />
<UsageCell usage={u.last30d} />
{isAdmin && (
<td className="px-5 py-2 text-right whitespace-nowrap">
<RowAction
label={t("changeRole")}
disabled={self}
onClick={() => setDialog({ kind: "role", user: u })}
>
<Shield className="h-4 w-4" />
</RowAction>
<RowAction
label={t("resetPassword")}
onClick={() => setDialog({ kind: "password", user: u })}
>
<KeyRound className="h-4 w-4" />
</RowAction>
<RowAction
label={t("assignWorkspace")}
onClick={() => setDialog({ kind: "workspace", user: u })}
>
<FolderInput className="h-4 w-4" />
</RowAction>
<RowAction
label={u.disabled ? t("enable") : t("disable")}
disabled={self}
onClick={() =>
setDialog({ kind: u.disabled ? "enable" : "disable", user: u })
}
>
{u.disabled ? (
<LockOpen className="h-4 w-4" />
) : (
<Lock className="h-4 w-4" />
)}
</RowAction>
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
<section className="card">
<div className="flex items-center gap-2 px-5 py-4 border-b border-border">
<ScrollText className="w-4 h-4 text-fg-muted" />
<div>
<h2 className="text-sm font-semibold text-fg-primary">{t("auditTitle")}</h2>
<p className="text-xs text-fg-muted">{t("auditDescription")}</p>
</div>
</div>
{audit?.length === 0 && (
<p className="px-5 py-8 text-center text-sm text-fg-muted">{t("auditEmpty")}</p>
)}
{audit && audit.length > 0 && ( {audit && audit.length > 0 && (
<ul className="divide-y divide-border/60 text-xs" data-testid="audit-list"> <ul
className="divide-y divide-border text-xs max-h-[28rem] overflow-y-auto"
data-testid="audit-list"
>
{audit.map((a) => ( {audit.map((a) => (
<li key={a.id} className="flex gap-3 py-1.5"> <li
<span className="w-36 flex-shrink-0 text-fg-muted">{formatDateTime(a.at)}</span> key={a.id}
<span className="w-40 flex-shrink-0 text-fg-secondary"> className="flex items-center gap-3 px-5 py-2 hover:bg-surface-4/40 transition-colors"
{a.reader} → {a.owner} >
<span className="w-36 flex-shrink-0 tabular-nums text-fg-muted">
{formatDateTime(a.at)}
</span>
<span className="w-44 flex-shrink-0 truncate text-fg-secondary">
<span className="font-medium text-fg-primary">{a.reader}</span>
<span className="mx-1.5 text-fg-muted">→</span>
{a.owner}
</span>
<span className="badge py-0 px-1.5 text-[10px] font-mono bg-surface-4 text-fg-muted border-border">
{a.method}
</span>
<span className="min-w-0 truncate font-mono text-fg-muted" title={a.path}>
{a.path}
</span> </span>
<span className="min-w-0 truncate font-mono text-fg-muted">{a.path}</span>
</li> </li>
))} ))}
</ul> </ul>
)} )}
</SettingsSection> </section>
{dialog && ( {dialog && (
<AccountDialog <AccountDialog
@@ -223,6 +342,23 @@ export function Users() {
); );
} }
const ROLE_BADGE: Record<Role, string> = {
admin: "bg-accent/15 text-accent border-accent/20",
analyst: "bg-status-warning/10 text-status-warning border-status-warning/20",
developer: "bg-surface-4 text-fg-secondary border-border",
};
/** A period's cost, with its tokens underneath. */
function UsageCell({ usage }: { usage: { cost: number; tokens: number } }) {
const { t } = useTranslation("users");
return (
<td className="px-5 py-3 text-right tabular-nums">
<div className="text-sm text-fg-primary">{fmtCost(usage.cost)}</div>
<div className="text-[11px] text-fg-muted">{t("tokens", { n: fmt(usage.tokens) })}</div>
</td>
);
}
function RowAction({ function RowAction({
label, label,
disabled, disabled,
+166 -105
View File
@@ -15,8 +15,9 @@
* a tab click replaces the history entry (Back leaves the session view, not * a tab click replaces the history entry (Back leaves the session view, not
* the last tab). Keyboard, outside a terminal: Alt+1–4 focus a pane, Alt+\\ * the last tab). Keyboard, outside a terminal: Alt+1–4 focus a pane, Alt+\\
* splits the focused one right with a new terminal, Alt+W hides it. The * splits the focused one right with a new terminal, Alt+W hides it. The
* Changes panel resizes by its button's presets or by dragging (or arrow keys * Changes panel has Full screen (Restore at full screen) and Collapse
* on) its splitter; an unsaved edit in it is asked about before the panel * buttons, and resizes by dragging (or arrow keys on) its splitter; a file in
* a chat's "Edited N files" card opens in it (`OPEN_IN_CHANGES_EVENT`); an unsaved edit in it is asked about before the panel
* closes, switches tab, or moves to another workspace or session (it stays on * closes, switches tab, or moves to another workspace or session (it stays on
* the old one until saved or discarded). Errors are toasts. * the old one until saved or discarded). Errors are toasts.
* *
@@ -54,17 +55,15 @@ import { runWorktreeSession, type RunWorktree } from "../lib/runWorktree";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useNavigate, useSearchParams } from "react-router-dom"; import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import { PanelLeftClose, PanelRightClose, Maximize2, Minimize2, ChartColumn } from "lucide-react";
Plus,
Columns2,
Layers,
PanelLeftClose,
PanelRightClose,
Maximize2,
ChartColumn,
} from "lucide-react";
import { api } from "../lib/api"; import { api } from "../lib/api";
import type { CwdSuggestion, RunHandle, RunListResponse } from "../lib/api"; import type {
CwdSuggestion,
LaneSource,
LaneSourcesView,
RunHandle,
RunListResponse,
} from "../lib/api";
import type { Lane, LaneFeature, ProofFeature, Session } from "../lib/types"; import type { Lane, LaneFeature, ProofFeature, Session } from "../lib/types";
import { toast } from "../lib/toast"; import { toast } from "../lib/toast";
import { confirmDialog, confirmDiscard } from "../lib/confirm"; import { confirmDialog, confirmDiscard } from "../lib/confirm";
@@ -109,6 +108,7 @@ import PipelineMap from "../components/lanes/PipelineMap";
import LaneCard, { since } from "../components/lanes/LaneCard"; import LaneCard, { since } 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 { OPEN_IN_CHANGES_EVENT, type OpenInChanges } from "../lib/openInChanges";
import { LaneGitRailCounts } from "../components/lanes/LaneGitHeader"; import { LaneGitRailCounts } from "../components/lanes/LaneGitHeader";
import { WorkspaceHome } from "../components/lanes/WorkspaceHome"; import { WorkspaceHome } from "../components/lanes/WorkspaceHome";
import { SessionReplay } from "../components/lanes/SessionReplay"; import { SessionReplay } from "../components/lanes/SessionReplay";
@@ -118,39 +118,37 @@ import { DestructiveLaneModal } from "../components/lanes/DestructiveLaneModal";
const CHANGES_OPEN_KEY = "agenthub.workspace.changesOpen"; const CHANGES_OPEN_KEY = "agenthub.workspace.changesOpen";
/** localStorage key for how wide the Changes panel is. */ /** localStorage key for how wide the Changes panel is. */
const CHANGES_SIZE_KEY = "agenthub.workspace.changesSize"; const CHANGES_SIZE_KEY = "agenthub.workspace.changesSize";
/** localStorage key for the last width the user dragged the panel to — what
* it reopens at after the step control closes it. */
const CHANGES_DRAGGED_KEY = "agenthub.workspace.changesDragged";
/** localStorage key for the last workspace shown, restored on a bare /workspace. */ /** localStorage key for the last workspace shown, restored on a bare /workspace. */
const SELECTED_KEY = "agenthub.workspace.selected"; const SELECTED_KEY = "agenthub.workspace.selected";
/** The three widths the Changes panel cycles through, then closed. Reading a /** The Changes panel's two preset widths. Its header has two controls, like a
* diff and watching a console want opposite amounts of room, and which one is * window's: Full screen (Restore once at full screen, back to the last
* "work" changes several times an hour — hence ONE control that steps * dragged width) and Collapse (to the rail). */
* 30/70 → 50/50 → full → closed, rather than a width button beside a const CHANGES_SIZES = ["compact", "full"] as const;
* collapse button that both live in the panel's tightest corner. */
const CHANGES_SIZES = ["compact", "half", "full"] as const;
type ChangesPreset = (typeof CHANGES_SIZES)[number]; type ChangesPreset = (typeof CHANGES_SIZES)[number];
/** A preset, or a width (panel fraction 0.15–0.85) the splitter was dragged to. */ /** A preset, or a width (panel fraction 0.15–0.85) the splitter was dragged to. */
type ChangesSize = ChangesPreset | number; type ChangesSize = ChangesPreset | number;
const CHANGES_WIDTH: Record<ChangesPreset, string> = { const CHANGES_WIDTH: Record<ChangesPreset, string> = {
compact: "w-[30%]", compact: "w-[25%]",
half: "w-1/2",
// The console beside it is HIDDEN, not unmounted: a live run's terminal // The console beside it is HIDDEN, not unmounted: a live run's terminal
// and a half-typed prompt must survive a round trip through full width. // and a half-typed prompt must survive a round trip through full width.
full: "w-full", full: "w-full",
}; };
/** The icon the one resize control shows AT each size: what the next click
* gives you — an even split, the full width, then closed. */
const CHANGES_NEXT_ICON: Record<ChangesPreset, typeof Columns2> = {
compact: Columns2,
half: Maximize2,
full: PanelRightClose,
};
/** The panel's share of the row, for the splitter. */ /** The panel's share of the row, for the splitter. */
const changesRatio = (s: ChangesSize) => const changesRatio = (s: ChangesSize) => (typeof s === "number" ? s : s === "compact" ? 0.25 : 1);
typeof s === "number" ? s : s === "compact" ? 0.3 : s === "half" ? 0.5 : 1;
/** The preset a size counts as for the button: under ~45% is narrow. */
const changesStep = (s: ChangesSize): ChangesPreset =>
s === "full" ? "full" : changesRatio(s) < 0.45 ? "compact" : "half";
const clampChanges = (r: number) => Math.min(0.85, Math.max(0.15, r)); const clampChanges = (r: number) => Math.min(0.85, Math.max(0.15, r));
/** The width the user last dragged the panel to, else `compact`. */
function lastDraggedChanges(): ChangesSize {
try {
const n = Number(window.localStorage.getItem(CHANGES_DRAGGED_KEY) || NaN);
return Number.isFinite(n) ? clampChanges(n) : "compact";
} catch {
return "compact";
}
}
/** The panel's own minimum (`min-w-[18rem]`), in px. */ /** The panel's own minimum (`min-w-[18rem]`), in px. */
const CHANGES_MIN_PX = 288; const CHANGES_MIN_PX = 288;
@@ -502,6 +500,7 @@ function ConsoleArea({
onRunExit={() => ended(slot)} onRunExit={() => ended(slot)}
// Only the focused pane takes the keyboard when it appears. // Only the focused pane takes the keyboard when it appears.
autoFocus={slot === focusSlot} autoFocus={slot === focusSlot}
solo={shown.length === 1}
attachRequest={ attachRequest={
attachRequest && slotOf(attachRequest.runId) === slot ? attachRequest : null attachRequest && slotOf(attachRequest.runId) === slot ? attachRequest : null
} }
@@ -646,17 +645,14 @@ export function Workspace() {
useEffect(() => { useEffect(() => {
try { try {
window.localStorage.setItem(CHANGES_SIZE_KEY, String(changesSize)); window.localStorage.setItem(CHANGES_SIZE_KEY, String(changesSize));
if (typeof changesSize === "number")
window.localStorage.setItem(CHANGES_DRAGGED_KEY, String(changesSize));
} catch { } catch {
/* same as above: a forgotten width, not an error */ /* same as above: a forgotten width, not an error */
} }
}, [changesSize]); }, [changesSize]);
const changesFull = changesSize === "full"; const changesFull = changesSize === "full";
const changesPreset = changesStep(changesSize); const changesSizeLabel = changesFull ? tLanes("changes.restore") : tLanes("changes.size.full");
const nextChangesSize =
CHANGES_SIZES[(CHANGES_SIZES.indexOf(changesPreset) + 1) % CHANGES_SIZES.length]!;
const changesNextLabel = changesFull
? tLanes("changes.collapse")
: tLanes("changes.resize", { size: tLanes(`changes.size.${nextChangesSize}`) });
const changesRowRef = useRef<HTMLDivElement>(null); const changesRowRef = useRef<HTMLDivElement>(null);
// The row's width: the panel never gets narrower than its 18rem minimum, // The row's width: the panel never gets narrower than its 18rem minimum,
// so the splitter is drawn — and dragged — no further than that edge. // so the splitter is drawn — and dragged — no further than that edge.
@@ -713,16 +709,49 @@ export function Workspace() {
? sessionParam ? sessionParam
: undefined; : undefined;
// The workspace's extra folders: each is its own repo the Changes panel can
// show instead (`?source=`), picked in its header. Read per workspace, and
// again from the home's Folder list whenever it adds or removes one.
const [gitFolders, setGitFolders] = useState<{ laneId: number; items: LaneSource[] } | null>(
null
);
const takeGitFolders = useCallback(
(laneId: number, view: LaneSourcesView) =>
setGitFolders({
laneId,
items: view.sources.filter((s) => s.kind === "folder" && !s.missing),
}),
[]
);
useEffect(() => {
if (selectedLaneId === null) return;
api.lanes.sources(selectedLaneId).then(
(v) => takeGitFolders(selectedLaneId, v),
() => setGitFolders({ laneId: selectedLaneId, items: [] })
);
}, [selectedLaneId, takeGitFolders]);
const folders = gitFolders?.laneId === selectedLaneId ? gitFolders.items : [];
const [gitSourcePick, setGitSourcePick] = useState<{ laneId: number; id: number } | null>(null);
// A pick holds for its own workspace, while that folder is still listed.
const gitSource =
gitSourcePick?.laneId === selectedLaneId && folders.some((f) => f.id === gitSourcePick.id)
? gitSourcePick.id
: undefined;
// An unsaved whole-file edit in the Changes panel. While it is open the // An unsaved whole-file edit in the Changes panel. While it is open the
// panel stays on the tree it was opened in — moving it (another workspace, // panel stays on the tree it was opened in — moving it (another workspace,
// session or worktree) would remount it and drop the edit — until the user // session, worktree or folder) would remount it and drop the edit — until
// says to discard it, or saves. // the user says to discard it, or saves.
const [changesDirty, setChangesDirty] = useState(false); const [changesDirty, setChangesDirty] = useState(false);
const wantPanel = const wantPanel =
selectedLaneId === null ? null : { laneId: selectedLaneId, session: gitSession }; selectedLaneId === null
const wantPanelKey = wantPanel ? `${wantPanel.laneId}:${wantPanel.session ?? ""}` : ""; ? null
: { laneId: selectedLaneId, session: gitSession, source: gitSource };
const keyOf = (p: typeof wantPanel) =>
p ? `${p.laneId}:${p.session ?? ""}:${p.source ?? ""}` : "";
const wantPanelKey = keyOf(wantPanel);
const [panelAt, setPanelAt] = useState(wantPanel); const [panelAt, setPanelAt] = useState(wantPanel);
const panelKey = panelAt ? `${panelAt.laneId}:${panelAt.session ?? ""}` : ""; const panelKey = keyOf(panelAt);
const asking = useRef(false); const asking = useRef(false);
useEffect(() => { useEffect(() => {
if (wantPanelKey === panelKey) return; if (wantPanelKey === panelKey) return;
@@ -740,6 +769,33 @@ export function Workspace() {
}); });
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the keys // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the keys
}, [wantPanelKey, panelKey, changesDirty]); }, [wantPanelKey, panelKey, changesDirty]);
// A chat's "Edited N files" card asks for a file: open the panel on it, if
// the panel shows that workspace.
const [changesRequest, setChangesRequest] = useState<{ path: string; nonce: number } | null>(
null
);
const panelLaneRef = useRef<number | null>(null);
panelLaneRef.current = panelAt?.laneId ?? null;
useEffect(() => {
const onOpen = (e: Event) => {
const { laneId, path } = (e as CustomEvent<OpenInChanges>).detail;
if (laneId !== panelLaneRef.current) return;
// Its path is the workspace folder's: back from an extra folder first.
setGitSourcePick(null);
setChangesOpen(true);
setChangesRequest((r) => ({ path, nonce: (r?.nonce ?? 0) + 1 }));
};
window.addEventListener(OPEN_IN_CHANGES_EVENT, onOpen);
return () => window.removeEventListener(OPEN_IN_CHANGES_EVENT, onOpen);
}, []);
// Asked once: a panel reopened or moved to another tree must not replay it.
useEffect(() => {
if (!changesOpen) setChangesRequest(null);
}, [changesOpen]);
// Not on a folder pick: the request above makes one, to land in the panel
// it remounts; the picker drops the request itself.
const panelTreeKey = panelAt ? `${panelAt.laneId}:${panelAt.session ?? ""}` : "";
useEffect(() => setChangesRequest(null), [panelTreeKey]);
/** Runs `fn` once an unsaved edit (if any) may be dropped. */ /** Runs `fn` once an unsaved edit (if any) may be dropped. */
const afterDrop = (fn: () => void) => { const afterDrop = (fn: () => void) => {
if (!changesDirty) return fn(); if (!changesDirty) return fn();
@@ -958,41 +1014,9 @@ export function Workspace() {
: "space-y-5" : "space-y-5"
} }
> >
{/* Workspaces are picked in the sidebar's project tree; this page shows {/* Workspaces are picked — and added, with the + — in the sidebar's
the selected one. With none at all, one banner offers to create it. */} project tree; this page shows the selected one. */}
<div className="flex min-h-0 flex-1 flex-col gap-4"> <div className="flex min-h-0 flex-1 flex-col gap-4">
{lanesLoaded && lanes.length === 0 && (
/* With no lanes the strip was a dashed tile beside a stray sentence,
which reads as a half-rendered row rather than a state. One banner
instead — and a compact one on purpose: the run setup form below
is what the user actually needs the height for. */
<button
type="button"
data-testid="lane-empty"
onClick={() => setAddLaneOpen(true)}
className="card flex w-full shrink-0 flex-wrap items-center gap-4 border-dashed px-5 py-4 text-left transition-colors hover:border-accent/50 hover:bg-surface-2"
>
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-surface-4 text-fg-muted">
<Layers className="h-5 w-5" />
</span>
<div className="min-w-0 flex-1">
<span className="block text-sm font-medium text-fg-primary">
{tLanes("empty.title")}
</span>
<p className="mt-0.5 text-xs text-fg-muted">
{tLanes("empty.body")}{" "}
<code className="rounded bg-surface-2 px-1.5 py-0.5 text-[11px] text-fg-secondary">
agenthub workspace add --cwd $(pwd)
</code>
</p>
</div>
<span className="flex shrink-0 items-center gap-1.5 text-xs font-medium text-accent">
<Plus className="h-4 w-4" />
{tLanes("add")}
</span>
</button>
)}
{/* The selected lane's pipeline — the thing you actually come to this {/* The selected lane's pipeline — the thing you actually come to this
page to read, beside the list rather than stacked under it. page to read, beside the list rather than stacked under it.
The section itself is ALWAYS rendered, even with no lane selected: The section itself is ALWAYS rendered, even with no lane selected:
@@ -1011,6 +1035,7 @@ export function Workspace() {
<WorkspaceHome <WorkspaceHome
lane={currentLane} lane={currentLane}
onRenamed={() => void refreshLanes()} onRenamed={() => void refreshLanes()}
onSourcesChange={(v) => takeGitFolders(currentLane.id, v)}
onMenuAction={(action) => { onMenuAction={(action) => {
if (action === "new-here") { if (action === "new-here") {
setAddLanePrefill({ sourceRepo: currentLane.source_repo || currentLane.cwd }); setAddLanePrefill({ sourceRepo: currentLane.source_repo || currentLane.cwd });
@@ -1232,7 +1257,7 @@ export function Workspace() {
typeof changesSize === "number" ? "" : CHANGES_WIDTH[changesSize] typeof changesSize === "number" ? "" : CHANGES_WIDTH[changesSize]
}${changesFull ? "" : " min-w-[18rem] border-l border-border"}`} }${changesFull ? "" : " min-w-[18rem] border-l border-border"}`}
> >
{/* Header: the title, Analyst, then the resize control — the {/* Header: the title, Analyst, then Full screen and Collapse — the
same height as the console's tab bar, so the two read as same height as the console's tab bar, so the two read as
one header strip. Files and Changes are ONE panel now; its one header strip. Files and Changes are ONE panel now; its
own mode switcher moves between them. */} own mode switcher moves between them. */}
@@ -1243,6 +1268,34 @@ export function Workspace() {
<span className="px-1 text-xs font-medium text-fg-secondary"> <span className="px-1 text-xs font-medium text-fg-secondary">
{tLanes("panel.changes")} {tLanes("panel.changes")}
</span> </span>
{/* Which repo the panel shows: the workspace's folder or
one of its extra folders. Only when it has any. */}
{folders.length > 0 && (
<select
data-testid="panel-git-folder"
value={gitSource ?? ""}
onChange={(e) => {
setChangesRequest(null);
setGitSourcePick(
e.target.value
? { laneId: currentLane.id, id: Number(e.target.value) }
: null
);
}}
aria-label={tLanes("panel.folder")}
title={tLanes("panel.folder")}
className="min-w-0 max-w-[12rem] truncate rounded-md border border-border bg-surface-2 px-1.5 py-0.5 text-xs text-fg-primary"
>
<option value="">
{currentLane.cwd.split(/[\\/]/).filter(Boolean).pop() || currentLane.cwd}
</option>
{folders.map((f) => (
<option key={f.id} value={f.id}>
{f.name}
</option>
))}
</select>
)}
{/* The opened session's analysis page — a page of its own. */} {/* The opened session's analysis page — a page of its own. */}
<button <button
type="button" type="button"
@@ -1258,37 +1311,42 @@ export function Workspace() {
> >
<ChartColumn className="h-4 w-4" /> <ChartColumn className="h-4 w-4" />
</button> </button>
{/* A window's two controls: Full screen (Restore at full
screen, to the last dragged width) and Collapse. */}
<button <button
type="button" type="button"
data-testid="lane-changes-resize" data-testid="lane-changes-resize"
onClick={() => { onClick={() => setChangesSize(changesFull ? lastDraggedChanges() : "full")}
// Past the widest step the next state is "gone", which aria-label={changesSizeLabel}
// is what the rail button reopens at `compact` — and title={changesSizeLabel}
// what drops an unsaved edit, so that asks first.
if (changesFull) {
afterDrop(() => {
setChangesOpen(false);
setChangesSize("compact");
});
} else {
setChangesSize(nextChangesSize);
}
}}
// The control names the same region the rail button does,
// so a screen reader reads one panel that is open or
// closed rather than two unrelated buttons.
aria-expanded
aria-controls="lane-changes-panel"
aria-label={changesNextLabel}
title={changesNextLabel}
className="ml-auto flex h-6 w-6 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary" className="ml-auto flex h-6 w-6 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary"
> >
{/* Icon-only, and it draws the width the click LANDS on, {changesFull ? (
not the current one — the label the tooltip carries. */} <Minimize2 className="h-4 w-4" />
{(() => { ) : (
const Next = CHANGES_NEXT_ICON[changesPreset]; <Maximize2 className="h-4 w-4" />
return <Next className="h-4 w-4" />; )}
})()} </button>
<button
type="button"
data-testid="lane-changes-collapse"
// Closing drops an unsaved edit, so that asks first; it
// reopens from the rail at the last dragged width.
onClick={() =>
afterDrop(() => {
setChangesOpen(false);
setChangesSize(lastDraggedChanges());
})
}
// The same region the rail button names, so a screen
// reader reads one panel that is open or closed.
aria-expanded
aria-controls="lane-changes-panel"
aria-label={tLanes("changes.collapse")}
title={tLanes("changes.collapse")}
className="flex h-6 w-6 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary"
>
<PanelRightClose className="h-4 w-4" />
</button> </button>
</div> </div>
<div className="flex min-h-0 flex-1 flex-col px-3 pt-2"> <div className="flex min-h-0 flex-1 flex-col px-3 pt-2">
@@ -1300,9 +1358,11 @@ export function Workspace() {
key={panelKey} key={panelKey}
laneId={panelAt.laneId} laneId={panelAt.laneId}
session={panelAt.session} session={panelAt.session}
source={panelAt.source}
full={changesFull} full={changesFull}
wide={changesPreset !== "compact"} wide={changesRatio(changesSize) >= 0.45}
onDirtyChange={setChangesDirty} onDirtyChange={setChangesDirty}
openRequest={panelAt.source ? null : changesRequest}
// A sibling worktree forks from the lane's SOURCE repo when // A sibling worktree forks from the lane's SOURCE repo when
// it has one (this lane is itself a worktree of it); an // it has one (this lane is itself a worktree of it); an
// adopted lane is its own repo. // adopted lane is its own repo.
@@ -1341,6 +1401,7 @@ export function Workspace() {
<LaneGitRailCounts <LaneGitRailCounts
laneId={panelAt.laneId} laneId={panelAt.laneId}
session={panelAt.session} session={panelAt.session}
source={panelAt.source}
onShowAll={() => setChangesOpen(true)} onShowAll={() => setChangesOpen(true)}
/> />
)} )}
@@ -1,7 +1,8 @@
/** /**
* @file Workspace.changesPanel.test.tsx * @file Workspace.changesPanel.test.tsx
* @description The Changes panel is ONE control: it steps 30/70 → 50/50 → * @description The Changes panel has two controls: Full screen (Restore at
* full → closed, and the rail reopens it at 30/70. The width survives a reload. * full screen, back to the last dragged width) and Collapse; the rail reopens
* it at the last dragged width, else 25/75. The width survives a reload.
* All of it lives in localStorage and reaches no server — the same rule as the * All of it lives in localStorage and reaches no server — the same rule as the
* lane strip's order. * lane strip's order.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -82,7 +83,22 @@ vi.mock("../../lib/api", async (importOriginal) => {
}), }),
pipelines: r({ pipelines: [] }), pipelines: r({ pipelines: [] }),
git: r({ available: false }), git: r({ available: false }),
// Workspace 1 has one extra folder, its own repo (`?source=`).
sources: vi.fn((id: number) =>
Promise.resolve({
sources:
id === 1
? [
{ id: 7, kind: "folder", path: "/x/shop", name: "shop", missing: false },
{ id: 8, kind: "file", path: "/d/notes.md", name: "notes.md", missing: false },
]
: [],
})
),
gitTree: r({ entries: [] }), gitTree: r({ entries: [] }),
// A file opened in the panel on a chat's request.
changes: r({ available: false }),
gitFile: r({ available: false }),
runtime: r({ up: false, ports: {}, lastError: null }), runtime: r({ up: false, ports: {}, lastError: null }),
features: { list: r({ features: [] }), show: r({ feature: null }) }, features: { list: r({ features: [] }), show: r({ feature: null }) },
proof: { list: r({ features: [] }), imageUrl: () => "" }, proof: { list: r({ features: [] }), imageUrl: () => "" },
@@ -104,6 +120,8 @@ vi.mock("../../lib/eventBus", () => ({
})); }));
import { Workspace } from "../Workspace"; import { Workspace } from "../Workspace";
import { openInChanges } from "../../lib/openInChanges";
import { api } from "../../lib/api";
class ObserverStub { class ObserverStub {
observe() {} observe() {}
@@ -150,43 +168,115 @@ describe("Workspace — the Changes panel's width", () => {
} }
} }
it("steps 30/70 → 50/50 → full → closed on one control, with no 70/30 step", async () => { it("offers Full screen and Collapse, then Restore and Collapse at full screen", async () => {
await openPanel(); await openPanel();
const card = () => screen.getByTestId("lane-changes-card"); const card = () => screen.getByTestId("lane-changes-card");
expect(card().className).toContain("w-[30%]"); const size = () => screen.getByTestId("lane-changes-resize");
expect(card().className).toContain("w-[25%]");
fireEvent.click(screen.getByTestId("lane-changes-resize")); expect(size()).toHaveAttribute("aria-label", "Full screen");
expect(card().className).toContain("w-1/2"); expect(screen.getByTestId("lane-changes-collapse")).toBeInTheDocument();
// Full width HIDES the console beside it — hidden, still mounted. // Full width HIDES the console beside it — hidden, still mounted.
fireEvent.click(screen.getByTestId("lane-changes-resize")); fireEvent.click(size());
expect(card().className).toContain("w-full"); expect(card().className).toContain("w-full");
expect(screen.getByTestId("lane-detail").className).toContain("hidden"); expect(screen.getByTestId("lane-detail").className).toContain("hidden");
expect(size()).toHaveAttribute("aria-label", "Restore size");
expect(screen.getByTestId("lane-changes-collapse")).toBeInTheDocument();
// Past the widest step the panel closes — and reopens at 30/70, not back // Restore brings the console back beside it.
// at full width, which is why closing resets the width. fireEvent.click(size());
fireEvent.click(screen.getByTestId("lane-changes-resize")); expect(card().className).toContain("w-[25%]");
expect(screen.getByTestId("lane-detail").className).not.toContain("hidden");
// Collapsing from full screen reopens at 25/75, not back at full width.
fireEvent.click(size());
fireEvent.click(screen.getByTestId("lane-changes-collapse"));
await settle(); await settle();
expect(screen.queryByTestId("lane-changes-card")).toBeNull(); expect(screen.queryByTestId("lane-changes-card")).toBeNull();
fireEvent.click(screen.getByTestId("lane-changes-expand")); fireEvent.click(screen.getByTestId("lane-changes-expand"));
await settle(); await settle();
expect(card().className).toContain("w-[30%]"); expect(card().className).toContain("w-[25%]");
}); });
it("remembers the width across a reload, and never asks the server", async () => { it("remembers the width across a reload, and never asks the server", async () => {
await openPanel(); await openPanel();
fireEvent.click(screen.getByTestId("lane-changes-resize")); fireEvent.click(screen.getByTestId("lane-changes-resize"));
expect(window.localStorage.getItem("agenthub.workspace.changesSize")).toBe("half"); expect(window.localStorage.getItem("agenthub.workspace.changesSize")).toBe("full");
cleanup(); cleanup();
await openPanel(); await openPanel();
expect(screen.getByTestId("lane-changes-card").className).toContain("w-1/2"); expect(screen.getByTestId("lane-changes-card").className).toContain("w-full");
}); });
it("keeps the rail as the only way back in", async () => { it("restores, and reopens, at the width last dragged to, not the preset", async () => {
window.localStorage.setItem("agenthub.workspace.changesSize", "full");
window.localStorage.setItem("agenthub.workspace.changesDragged", "0.4");
await openPanel(); await openPanel();
expect(screen.queryByTestId("lane-changes-collapse")).toBeNull(); fireEvent.click(screen.getByTestId("lane-changes-resize"));
expect(screen.getByTestId("lane-changes-card").style.width).toBe("40%");
fireEvent.click(screen.getByTestId("lane-changes-resize"));
fireEvent.click(screen.getByTestId("lane-changes-collapse"));
await settle();
fireEvent.click(screen.getByTestId("lane-changes-expand"));
await settle();
expect(screen.getByTestId("lane-changes-card").style.width).toBe("40%");
});
it("opens on a chat's request for a file of the workspace it shows, not another's", async () => {
window.localStorage.setItem("agenthub.workspace.changesOpen", "0");
render(
<MemoryRouter initialEntries={["/workspace?lane=1"]}>
<Workspace />
</MemoryRouter>
);
await settle();
expect(screen.queryByTestId("lane-changes-card")).toBeNull();
act(() => {
openInChanges(2, "/workspace/beta", "/workspace/beta/a.ts");
});
await settle();
expect(screen.queryByTestId("lane-changes-card")).toBeNull();
act(() => {
openInChanges(1, "/workspace/alpha", "/workspace/alpha/src/a.ts");
});
await settle();
expect(screen.getByTestId("lane-changes-card")).toBeInTheDocument(); expect(screen.getByTestId("lane-changes-card")).toBeInTheDocument();
expect(api.lanes.gitFile).toHaveBeenCalledWith(1, "src/a.ts", "work");
});
it("shows an extra folder's repo when picked, and only a workspace's own folders", async () => {
await openPanel();
const pick = screen.getByTestId("panel-git-folder") as HTMLSelectElement;
// The workspace folder, then its folders — never a context file.
expect([...pick.options].map((o) => o.text)).toEqual(["alpha", "shop"]);
vi.mocked(api.lanes.git).mockClear();
fireEvent.change(pick, { target: { value: "7" } });
await settle();
expect(api.lanes.git).toHaveBeenCalledWith({ lane: 1, source: 7 });
// A chat's file is the workspace folder's: the panel goes back there.
act(() => {
openInChanges(1, "/workspace/alpha", "/workspace/alpha/src/b.ts");
});
await settle();
expect(pick.value).toBe("");
expect(api.lanes.gitFile).toHaveBeenCalledWith(1, "src/b.ts", "work");
});
it("has no folder picker for a workspace with no extra folders", async () => {
render(
<MemoryRouter initialEntries={["/workspace?lane=2"]}>
<Workspace />
</MemoryRouter>
);
await settle();
expect(screen.queryByTestId("panel-git-folder")).toBeNull();
});
it("collapses from its narrow width too, back to the rail", async () => {
await openPanel();
fireEvent.click(screen.getByTestId("lane-changes-collapse"));
await settle();
expect(screen.queryByTestId("lane-changes-card")).toBeNull();
expect(screen.getByTestId("lane-changes-expand")).toBeInTheDocument();
}); });
}); });
@@ -103,6 +103,8 @@ vi.mock("../../lib/api", async (importOriginal) => {
api: { api: {
aiTools: { iconUrl: (id: string) => `/api/ai-tools/${id}/icon` }, aiTools: { iconUrl: (id: string) => `/api/ai-tools/${id}/icon` },
lanes: { lanes: {
// The Changes panel's folder picker reads the workspace's extra folders.
sources: vi.fn().mockResolvedValue({ sources: [] }),
list: vi.fn().mockImplementation(async () => { list: vi.fn().mockImplementation(async () => {
recordCall("GET", "/api/lanes"); recordCall("GET", "/api/lanes");
return { lanes: lanesToReturn, counts: countsToReturn }; return { lanes: lanesToReturn, counts: countsToReturn };
@@ -434,15 +436,10 @@ describe("Workspace page — lane integration", () => {
}); });
describe("Workspace layout", () => { describe("Workspace layout", () => {
it("opens the add-workspace modal from the empty banner when there is none", async () => { it("shows no empty banner when there is no workspace (the sidebar's + adds one)", async () => {
const user = userEvent.setup();
lanesToReturn = []; lanesToReturn = [];
await renderWorkspace(); await renderWorkspace();
expect(screen.queryByTestId("lane-empty")).toBeNull();
await user.click(screen.getByTestId("lane-empty"));
expect(
await screen.findByText("Create a workspace from a working directory")
).toBeInTheDocument();
}); });
it("shows the full card only for the selected lane, in the detail panel", async () => { it("shows the full card only for the selected lane, in the detail panel", async () => {
@@ -5563,82 +5563,6 @@ exports[`screen snapshots > Run 1`] = `
<div <div
class="flex min-h-0 flex-1 flex-col gap-4" class="flex min-h-0 flex-1 flex-col gap-4"
> >
<button
class="card flex w-full shrink-0 flex-wrap items-center gap-4 border-dashed px-5 py-4 text-left transition-colors hover:border-accent/50 hover:bg-surface-2"
data-testid="lane-empty"
type="button"
>
<span
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-surface-4 text-fg-muted"
>
<svg
class="lucide lucide-layers h-5 w-5"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z"
/>
<path
d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12"
/>
<path
d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17"
/>
</svg>
</span>
<div
class="min-w-0 flex-1"
>
<span
class="block text-sm font-medium text-fg-primary"
>
No workspaces yet
</span>
<p
class="mt-0.5 text-xs text-fg-muted"
>
A workspace is one working directory the dashboard follows — its pipeline, sessions and terminal. Click anywhere on this card to add one, or from a terminal:
<code
class="rounded bg-surface-2 px-1.5 py-0.5 text-[11px] text-fg-secondary"
>
agenthub workspace add --cwd $(pwd)
</code>
</p>
</div>
<span
class="flex shrink-0 items-center gap-1.5 text-xs font-medium text-accent"
>
<svg
class="lucide lucide-plus h-4 w-4"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5 12h14"
/>
<path
d="M12 5v14"
/>
</svg>
Add workspace
</span>
</button>
<div <div
class="relative flex min-h-0 flex-1" class="relative flex min-h-0 flex-1"
> >
@@ -5662,6 +5586,7 @@ exports[`screen snapshots > Run 1`] = `
<div <div
class="absolute flex flex-col" class="absolute flex flex-col"
data-focused="true" data-focused="true"
data-menu-bounds="true"
data-testid="pane-0" data-testid="pane-0"
style="left: 0%; top: 0%; width: 100%; height: 100%;" style="left: 0%; top: 0%; width: 100%; height: 100%;"
> >
@@ -7249,7 +7174,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span <span
class="text-status-warning/90" class="text-status-warning/90"
> >
UI build v2.5.13 (rebuild client to match) UI build v2.6.0 (rebuild client to match)
</span> </span>
</p> </p>
</div> </div>
+2
View File
@@ -111,6 +111,8 @@ export default {
}, },
backgroundColor: tinted, backgroundColor: tinted,
borderColor: tinted, borderColor: tinted,
// Text a step heavier than stock: regular (and `font-normal`) is 500.
fontWeight: { normal: "500" },
fontFamily: { fontFamily: {
sans: ["Inter", "-apple-system", "BlinkMacSystemFont", "Segoe UI", "sans-serif"], sans: ["Inter", "-apple-system", "BlinkMacSystemFont", "Segoe UI", "sans-serif"],
mono: ["JetBrains Mono", "Fira Code", "Consolas", "monospace"], mono: ["JetBrains Mono", "Fira Code", "Consolas", "monospace"],
+44 -1
View File
@@ -694,6 +694,20 @@ folder, and `git worktree list` of the workspace's repo must register it —
otherwise `400 ENOWORKTREE`, never a fall back to the main folder. With it, otherwise `400 ENOWORKTREE`, never a fall back to the main folder. With it,
`GET /git` also answers `worktree: {path, branch}`. `GET /git` also answers `worktree: {path, branch}`.
#### An extra folder's own repo (`?source=`)
Every `/api/lanes/:id/git…` route also takes `?source=<id>`: the read or write
then works in one of the workspace's extra folders (Context → Folder), each
its own repo — the Changes panel's folder picker. The tree is resolved from the
`lane_sources` ROW (`sourceFolder` in `server/lib/lane-sources.js`): a `folder`
row of THIS workspace that is still a directory — otherwise `404 ENOSOURCE`
(another workspace's row and a context-file row included), never a fall back
to the main folder. The folder is its own boundary: every guard above
(`resolvePaths`, the secret check, the `confirm: true` gates, `ESUBDIR` for a
folder below its repo root) applies to it as to a workspace folder, and
`GET /git` reports `base` as that repo's own default branch. A folder that is
not a git repo answers `{available: false}`.
#### Remove a session's worktree #### Remove a session's worktree
```http ```http
@@ -1551,6 +1565,32 @@ curl http://localhost:4820/api/sessions/sess_abc123
--- ---
#### Get Session Outline
```http
GET /api/sessions/:id/outline
```
The session's plan, the files it changed and what it used, read off its successful tool calls (`PostToolUse` events) and its prompts (`UserPromptSubmit`) — the activity panel of a Workspace Chat pane shown alone. `progress` follows `TaskCreate`/`TaskUpdate` by task id (a `TodoWrite` replaces the list wholesale); `outputs` are the paths written or edited with `Write`/`Edit`/`MultiEdit`/`NotebookEdit`; `files` the paths it only `Read` (a file it later changed moves to `outputs`); `uploads` the files attached to its prompts through the composer (paths under the `agenthub-drops-<uid>` dir that `POST /api/run/drop-image|drop-file` made); `memory` the Claude Code memory files (`.claude/**/memory/`) it read or wrote, kept out of the other lists. The path lists are newest first. `skills` are `Skill` calls; `mcp` are MCP server names (`mcp__<server>__<tool>`, a `plugin_` prefix dropped). A file made by a Bash command is not an output here. 404 for an unknown session.
```bash
curl http://localhost:4820/api/sessions/sess_abc123/outline
```
```json
{
"progress": [{ "id": "1", "subject": "Build the report", "status": "completed" }],
"outputs": ["/home/me/work/Progress report.xlsx"],
"files": ["/home/me/work/data/kpi.csv"],
"uploads": ["/tmp/agenthub-drops-1000/3f9a0c1d2e4b5a6c-minutes.xlsx"],
"memory": ["/home/me/.claude/projects/-home-me-work/memory/launch.md"],
"skills": ["xlsx"],
"mcp": ["vercel_vercel"]
}
```
---
#### Get Session Stats #### Get Session Stats
```http ```http
@@ -2540,9 +2580,12 @@ GET /api/run/tmux { available: bool } — whether tmux is on
GET /api/run/binary?tool= { found, path } for the `claude`, `codex` or `agy` binary GET /api/run/binary?tool= { found, path } for the `claude`, `codex` or `agy` binary
GET /api/run/cwds Suggested cwds (dashboard, home, recent) GET /api/run/cwds Suggested cwds (dashboard, home, recent)
GET /api/run/history?limit=&laneId=&tool= Persisted run history; laneId narrows to one lane's runs, tool (with laneId) to one tool's — `shell` = Terminal tabs GET /api/run/history?limit=&laneId=&tool= Persisted run history; laneId narrows to one lane's runs, tool (with laneId) to one tool's — `shell` = Terminal tabs
GET /api/run/files?cwd=&q= Fuzzy file search inside cwd for the @-file autocomplete GET /api/run/files?cwd=&q=&dirs=1 Fuzzy file search inside cwd for the @-file autocomplete (dirs=1 adds folders as `dir/`, for the + menu)
POST /api/run/drop-image Raw image body (png/jpeg/gif/webp, ≤20 MB) → {path}: saved 0600 under <data dir>/drops/ (0700; S3: the member's ~/.agenthub/handoff/drops/) with a random name, pruned after a day; the terminal pastes the path POST /api/run/drop-image Raw image body (png/jpeg/gif/webp, ≤20 MB) → {path}: saved 0600 under <data dir>/drops/ (0700; S3: the member's ~/.agenthub/handoff/drops/) with a random name, pruned after a day; the terminal pastes the path
POST /api/run/drop-file?name=&laneId= Any raw file body (≤20 MB) → {path}: same dir (S3: the handoff dir of the account workspace `laneId`'s panes run as, else the requester's), 0600, named <random>-<name reduced to a [A-Za-z0-9._-] basename>; what the terminal, the prompt box and its paste use for every dropped file or image POST /api/run/drop-file?name=&laneId= Any raw file body (≤20 MB) → {path}: same dir (S3: the handoff dir of the account workspace `laneId`'s panes run as, else the requester's), 0600, named <random>-<name reduced to a [A-Za-z0-9._-] basename>; what the terminal, the prompt box and its paste use for every dropped file or image
GET /api/run/drop/:name A dropped png/jpg/gif/webp served back (nosniff, sandbox CSP) from the requester's own drop dir (S3: their member account's, owner-checked); only a name drop-image/drop-file made → 404 otherwise
GET /api/run/:id/permission-mode A live claude pane's mode, read off its mode line → {mode} (null while a menu hides it)
POST /api/run/:id/permission-mode {mode} → Shift+Tab steps, each read back, until the pane shows it; 409 EMODEUNKNOWN/EMODEUNAVAILABLE/EBUSY
(skips node_modules, .git, dist, build, .next, .cache, coverage, vendor) (skips node_modules, .git, dist, build, .next, .cache, coverage, vendor)
POST /api/run Start/attach run — Body: { laneId, cwd?, model?, permissionMode?, effort?, resumeSessionId?, initialPrompt?, sandbox?, approval?, resumeLast?, bypass? } POST /api/run Start/attach run — Body: { laneId, cwd?, model?, permissionMode?, effort?, resumeSessionId?, initialPrompt?, sandbox?, approval?, resumeLast?, bypass? }
GET /api/run/:id Run handle (returns live run state) GET /api/run/:id Run handle (returns live run state)
+13 -14
View File
File diff suppressed because one or more lines are too long
+4 -2
View File
@@ -85,8 +85,10 @@ Internal build — all rights reserved.
light: Default, Apple Light, Canvas, Orca Light. Each mode light: Default, Apple Light, Canvas, Orca Light. Each mode
remembers its own theme, so the sidebar's quick toggle swaps between the two. remembers its own theme, so the sidebar's quick toggle swaps between the two.
It also picks the workspace terminals' font: JetBrains Mono (default), the It also picks the workspace terminals' font: JetBrains Mono (default), the
rounded Maple Mono, or the browser's own fixed-width font (which may lack some rounded Maple Mono, or the browser's own fixed-width
Vietnamese letters). Per-browser only. font (which may lack some Vietnamese letters). Per-browser only. Text across
the UI and the terminals is one step heavier than stock (regular 500, bold
700), and the Chat UI default sits at the top of the page.
- **Notification sounds.** Settings → Notifications picks a sound (None, Chime, - **Notification sounds.** Settings → Notifications picks a sound (None, Chime,
Pop, Alert — off by default) played with every notification shown, and each Pop, Alert — off by default) played with every notification shown, and each
notification type can pick its own. notification type can pick its own.
+8 -1
View File
@@ -113,7 +113,11 @@ check; the AI proxy page shows them only their personal profiles' Edit. An
analyst's Workspace page and sidebar list only analyst's Workspace page and sidebar list only
their own workspaces (`useWorkLanes`); other users' work is read on Sessions, their own workspaces (`useWorkLanes`); other users' work is read on Sessions,
Analytics and Users, where Resume, Fork, Snapshot and "Open in workspace" are Analytics and Users, where Resume, Fork, Snapshot and "Open in workspace" are
hidden for a session that is not theirs. An analyst's alert feed hidden for a session that is not theirs. The sidebar's workspace list shows an
admin or analyst EVERY workspace, grouped under its owner (each lane carries
`owner_name`; yours first, no owner last, each group folds): for an analyst
another user's workspace there is read only — no menu, rename or new session,
no live tabs, and a session opens its analysis page (`/sessions/<id>`). An analyst's alert feed
(`GET /api/alerts`) carries `can_ack` on each alert — true only for their own (`GET /api/alerts`) carries `can_ack` on each alert — true only for their own
sessions' — and the feed offers Acknowledge only there; Ack all acknowledges sessions' — and the feed offers Acknowledge only there; Ack all acknowledges
only theirs. only theirs.
@@ -200,6 +204,9 @@ user makes the folder (it is the Linux account's home).
under `/api` answers `401 EAUTH` until someone logs in, and the UI shows the under `/api` answers `401 EAUTH` until someone logs in, and the UI shows the
login page. Paths are checked the way Express routes them: decoded, with login page. Paths are checked the way Express routes them: decoded, with
repeated slashes collapsed, and case-insensitive. repeated slashes collapsed, and case-insensitive.
- The login page follows the OS's light/dark. After a login the dashboard
wears that account's own appearance (Settings → Appearance, saved in
`/api/me/prefs`); an account that never chose one follows the OS.
- Hooks (`/api/hooks/*`) are accepted only from this machine: a loopback socket - Hooks (`/api/hooks/*`) are accepted only from this machine: a loopback socket
with no forwarding header. A request through the proxy gets with no forwarding header. A request through the proxy gets
`403 ELOCALONLY`. `POST /api/hooks/ingest-batch` keeps its own bearer token. `403 ELOCALONLY`. `POST /api/hooks/ingest-batch` keeps its own bearer token.
+3 -3
View File
@@ -31,10 +31,10 @@ Asked 2026-10-08 (modelled on Orca's 3-step welcome). Decisions:
3. Pet: none or an installed pet, and its size. 3. Pet: none or an installed pet, and its size.
4. Notifications: enable, sound and preview, send a test. 4. Notifications: enable, sound and preview, send a test.
The footer has Skip, Back and Continue (Ctrl+Enter). The last step's button is "Add your first workspace", which starts the tour. The footer has Skip, Back and Continue (Ctrl+Enter). The last step's button is "Add your first workspace", which starts the tour on the Dashboard (`/`).
- [x] `components/onboarding/Tour.tsx`: a spotlight on `data-tour` targets. Each step moves on when its condition holds, and the step reached is saved in prefs (`agenthub.tour`). The steps: - [x] `components/onboarding/Tour.tsx`: a spotlight on `data-tour` targets. Each step moves on when its condition holds, and the step reached is saved in prefs (`agenthub.tour`). The steps:
- Add workspace - Add workspace: the sidebar's **+** (the rail's tree icon while the sidebar is folded)
- the folder field - the folder field (its suggestions list included while it is open) — or the folder browser while it is open over it
- Create - Create
- the prompt box - the prompt box
- sending the prompt - sending the prompt
+175
View File
@@ -0,0 +1,175 @@
# Remote clients — work runs on each user's machine, the hub only manages
Status: **PLAN, not built.** Decided 2026-10-09. Branch `server-s3` (one Linux
user per account on the hub) stays as it is until this plan is proven or dropped.
## Why
Everyone logging into one machine (server mode, S3) does not scale past ~10
concurrent users: every pane, build and test runs on the hub. Here the compute
moves to each user's machine; the hub keeps identity, the shared accounts, and
every workspace/session/conversation/token record.
## Decisions (user, 2026-10-09)
1. Each user installs **AgentHub local** on their own machine.
2. The **full conversation** is uploaded to the hub — only for sessions started
through AgentHub.
3. Shared accounts (the Claude pool, shared AI Proxies) are usable **only through
AgentHub**. The company has at least as many seats as people; a seat may be
used by more than one person.
4. Use without AgentHub is **detected and warned about**, and **blocked when an
admin turns blocking on**.
5. The hub is reached over the company LAN at `http://10.1.13.7:4820` — **no
HTTPS**.
6. An admin sees, in one click from the Users page, which machines a user has,
which workspaces they used, and what each session did.
7. `server-s3` is kept until this architecture is settled.
## Shape
```
user machine — AgentHub LOCAL hub 10.1.13.7 — AgentHub SERVER mode
┌───────────────────────────────────┐ ┌──────────────────────────────────┐
│ pane: claude │─hooks +▶│ /api/remote/ingest (device token)│
│ ANTHROPIC_BASE_URL = hub gateway│ transcr.│ → sessions/events/tokens, owned │
│ ANTHROPIC_AUTH_TOKEN = ahdev_… │ │ by the device's user │
│ │─/v1/… ─▶│ /api/proxy-gw → pool token added │
└───────────────────────────────────┘ └──────────────────────────────────┘
```
No pool token or proxy key ever reaches a user's machine; it holds one device
token. Plain HTTP means anyone sniffing the LAN can read that token and the
conversations — accepted for an internal network. Moving to HTTPS later is a
reverse proxy in front of the hub (Caddy `tls internal`), no code change.
## What the hub can see (per tool, checked 2026-10-09)
Uploads cover every session the machine's AgentHub started, in ANY of its
workspaces — company repo or the user's own project alike. A session run
outside AgentHub on the user's own login is invisible; only pool use is gated.
| Tool | Conversation | Tokens / cost | Pool / gate | Reader today |
|---|---|---|---|---|
| Claude Code | yes | yes | yes (R3) | hooks + transcript |
| Codex | yes | yes | not yet (Codex pool = later) | `codex-usage.js` rollouts |
| Antigravity | no — needs a renderer for `transcript_full.jsonl` | yes | no (own login) | `antigravity-usage.js` |
| Oh My Pi, other catalog tools | no | no | no | none: only the pane run (workspace, time) |
| DeepSeek CLI / not in catalog | no | no | no | not launchable as a tool; a shell pane records nothing |
R2 therefore uploads Claude transcripts, Codex rollouts and agy conversation
files (`remote-sync.js` today mirrors only the first two). Any tool beyond that
needs its own reader first — one per tool, on demand.
## Gating
Verified 2026-10-09 against Claude Code 2.1.295 with a capture server: every
`/v1/messages` call carries `x-claude-code-session-id: <id>` (same id in
`metadata.user_id`), and `--session-id <uuid>` sets it — `spawnRun` already
starts Claude panes that way.
The gateway checks each request:
- bearer = a device token that is not revoked, of a user who is not disabled —
else **always 401**;
- `x-claude-code-session-id` = a session the hub received uploads for, from
THAT device, within `REMOTE_LIVE_SEC` (300 s; 60 s grace for a new session).
A request failing the second check is a session AgentHub is not uploading
(token copied out of AgentHub, or uploads switched off). Setting
`remote_gate` decides: `warn` (default) forwards it and records the session
as **unmonitored** with its token count from the response's `usage`, and
fires an alert (existing `fireAlert`); `block` refuses it with 403. Ceiling:
someone who also fakes the upload stream passes — detection, not proof.
Relay spike (2026-10-09, Claude Code 2.1.295, script kept in
`~/.cache/agenthub-spike/relay.js`): a client with NO login and
`ANTHROPIC_AUTH_TOKEN=<fake>` worked through a relay that swapped in a team
subscription's OAuth token (+ `anthropic-beta: oauth-2025-04-20`). Checked:
answers, `--resume`, a local Bash tool call, prompt cache reads on every turn
(18–26k tokens), `anthropic-ratelimit-unified-*` headers passed back. Three
findings for R3:
- Through a base URL Claude Code writes **5-minute** cache entries; set
`ENABLE_PROMPT_CACHING_1H=1` in the pane (verified: 1h entries) to keep the
subscription's 1-hour TTL.
- The cache is per ACCOUNT (a fresh client read 18k cached tokens on its first
call), so the gateway must keep a session on ONE pool account — switching
accounts mid-session re-writes the whole cache.
- A repo's `.claude/settings*.json` `env` overrides the pane's
`ANTHROPIC_BASE_URL`/`ANTHROPIC_AUTH_TOKEN` (seen in this repo) — give the
gateway through `--settings=<file>`, the layer that outranks project settings,
as AI Proxy panes already do.
Not checked yet (interactive features that call claude.ai with the user's own
login, likely unavailable): `/usage` quota view, claude.ai MCP connectors,
Remote Control / web sessions, org plugins from claude.ai.
What is and is not guaranteed:
- **Guaranteed:** no hub account (or a disabled user / revoked device) → no
issued credential, because the credential never leaves the hub. This holds
only while the ADMIN alone holds each shared seat's login (setup-tokens
made by the admin). A seat whose login (email/SSO) an employee knows can
always be used directly at claude.ai or in a plain `claude` — AgentHub cannot
stop that.
- **Not guaranteed:** that the AgentHub APP was used. The device token sits on
a machine the user controls; `block` stops a plain `claude` using it, unless
the user also fakes the upload stream. Hard enforcement needs managed,
locked-down machines.
- **Closes the visibility gap (R3b):** the gateway sees every request body, so
it can record the conversation of every pool request ITSELF — new messages
per request by session id, plus the streamed answer and its `usage` — no
matter which client sent it. Then nothing that uses an issued credential is
invisible to the admin, AgentHub app or not.
- **Plain HTTP:** a LAN sniffer can take a device token and act as that user.
If that matters, HTTPS (reverse proxy + an internal CA every machine trusts,
`NODE_EXTRA_CA_CERTS` for Claude Code).
## Phases
Each one ships alone and changes nothing for a local-only install.
**R0 — user activity view (hub, useful today).** Users page row → a user
page: their devices (once R1 exists), workspaces, and sessions by day with
tokens/cost; a session opens the existing session page (conversation, tool
calls). Backend: an `owner=<user id>` filter for staff on `GET /api/sessions`
and `GET /api/lanes`, reusing `sessionWhere`/`laneWhere` for that user — no
new query logic. Admin reads are audited as they already are.
Verify: server test for the filter (developer cannot use it); client snapshot.
**R1 — devices (hub + CLI).** Table `devices` (id, user_id, name, token_hash,
created_at, last_seen_at, revoked_at). `agenthub remote login
http://10.1.13.7:4820` signs in with the user's hub password (throttled like
`/api/auth/login`), receives an `ahdev_` token once, stores it 0600 in the
runtime dir. The user page lists devices; an admin revokes one (re-auth +
`audit_log`).
Verify: mint/hash/revoke tests; a revoked token gets 401.
**R2 — upload (both sides).** An enrolled local AgentHub uploads each of ITS
sessions (`dashboard_runs`): hook events as they arrive, the session files of
the table above (Claude transcript, Codex rollout, agy conversation)
incrementally (byte offset, retry queue on disk, never blocking a hook). The
hub stores transcripts under `<data dir>/remote/<user id>/<device id>/projects/`
and imports them with the SAME `importFromDirectory` `remote-sync.js` uses, so
tokens and cost match local sessions. Sessions are owned by the device's user
(`source = device:<id>`); workspaces are mirrored as records (title, folder,
tool) — shown, not driven. The existing `ingest-batch` stays as it is.
Verify: two local instances → one hub; R0's page shows both users' work.
**R3 — remote gateway.** The local `spawnRun` points an enrolled machine's
Claude pane at the hub (`ANTHROPIC_BASE_URL`, device token as
`ANTHROPIC_AUTH_TOKEN`, through tmux `-e`). `proxy-gateway.js` (from
origin/main since 84e2a9f0) gains the remote path and checks above; for the pool it picks an
account with `pickAccount` and sends its OAuth token. **Spike first** on ONE
real pool account: which headers a setup-token needs through a relay.
Verify: a pane gets "pong"; a curl with the device token and a session id
nobody uploads is logged (`warn`) or 403 (`block`).
**R4 — shared skills/plugins.** No AgentHub code: the company marketplace (git)
plus a `managed-settings.json` template (`extraKnownMarketplaces`,
`enabledPlugins`) and an install script for user machines.
Codex comes after R3 works for Claude: ChatGPT-account auth differs from an API
key, and its session-id header still has to be checked.
## Open risks
- A hub outage stops every pool session; a machine can still use its own login.
- The hub stores every conversation of an AgentHub session; tell users.
+77 -3
View File
@@ -3,7 +3,7 @@
openapi: 3.0.3 openapi: 3.0.3
info: info:
title: Agent Dashboard for Claude Code API title: Agent Dashboard for Claude Code API
version: 2.5.13 version: 2.6.0
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence. description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
contact: contact:
name: Nguyễn Ngọc Trí Vĩ name: Nguyễn Ngọc Trí Vĩ
@@ -504,12 +504,23 @@ components:
description: Tool output text when type === tool_result. description: Tool output text when type === tool_result.
is_error: is_error:
type: boolean type: boolean
diff:
type: object
description: 'On the tool_result of a Write/Edit/MultiEdit: the lines it added and removed (Claude Code''s structuredPatch; every line of a file it created).'
properties:
added:
type: integer
removed:
type: integer
TranscriptMessage: TranscriptMessage:
type: object type: object
required: required:
- type - type
- content - content
properties: properties:
stop_reason:
type: string
description: 'Assistant lines only: why the model stopped — `end_turn` closes the turn, `tool_use` means a tool call (and more work) follows.'
type: type:
type: string type: string
enum: enum:
@@ -5203,6 +5214,64 @@ paths:
error: error:
code: NOT_FOUND code: NOT_FOUND
message: Session not found message: Session not found
/api/sessions/{id}/outline:
get:
tags:
- Sessions
summary: Get a session's plan, outputs and what it used
description: 'Read off the session''s successful tool calls (PostToolUse events) and its prompts: `progress` is its TaskCreate/TaskUpdate plan (a TodoWrite replaces it wholesale), `outputs` the paths it wrote or edited with Write/Edit/MultiEdit/NotebookEdit, `files` the paths it only read, `uploads` the files attached to its prompts through the composer (`/api/run/drop-*` paths), `memory` the Claude Code memory files (`.claude/**/memory/`) it read or wrote — the path lists newest first, a memory file in `memory` only —, `skills` the skills it invoked and `mcp` the MCP servers it called. Powers the activity panel of a Chat pane shown alone.'
operationId: getSessionOutline
parameters:
- $ref: '#/components/parameters/SessionIdPath'
responses:
'200':
description: The session's outline
content:
application/json:
schema:
type: object
properties:
progress:
type: array
items:
type: object
properties:
id:
type: string
subject:
type: string
status:
type: string
outputs:
type: array
items:
type: string
files:
type: array
items:
type: string
uploads:
type: array
items:
type: string
memory:
type: array
items:
type: string
skills:
type: array
items:
type: string
mcp:
type: array
items:
type: string
'404':
description: Session not found
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/sessions/{id}/transcripts: /api/sessions/{id}/transcripts:
get: get:
tags: tags:
@@ -11735,7 +11804,7 @@ paths:
tags: tags:
- Lanes - Lanes
summary: Suggest a commit message for the staged changes summary: Suggest a commit message for the staged changes
description: Runs the user's `claude` CLI (`-p --safe-mode --no-session-persistence --tools "" --model haiku`) on the STAGED diff of the lane's cwd (or `?session=` worktree), sent on stdin with the last ten commit subjects; a `settings.local.json` is never included. Reads only — nothing is staged or committed, no tool runs and no session is saved. Same-origin, since each call spends the user's Claude quota. 400 ENOSTAGED when nothing is staged, 400 ENOTOOL when `claude` is not on PATH, 502 EAI when the CLI fails, answers empty or runs past 120 s. description: Runs the user's `claude` CLI (`-p --safe-mode --no-session-persistence --tools "" --model haiku`) on the STAGED diff of the lane's cwd (or `?session=` worktree, or `?source=` extra folder), sent on stdin with the last ten commit subjects; a `settings.local.json` is never included. Reads only — nothing is staged or committed, no tool runs and no session is saved. Same-origin, since each call spends the user's Claude quota. 400 ENOSTAGED when nothing is staged, 400 ENOTOOL when `claude` is not on PATH, 502 EAI when the CLI fails, answers empty or runs past 120 s.
operationId: suggestLaneCommitMessage operationId: suggestLaneCommitMessage
parameters: parameters:
- name: id - name: id
@@ -11748,6 +11817,11 @@ paths:
required: false required: false
schema: schema:
type: string type: string
- name: source
in: query
required: false
schema:
type: integer
responses: responses:
'200': '200':
description: The suggested message description: The suggested message
@@ -11765,7 +11839,7 @@ paths:
'403': '403':
description: Cross-origin request refused description: Cross-origin request refused
'404': '404':
description: No such workspace description: No such workspace, or ENOSOURCE
'502': '502':
description: EAI — the claude CLI failed description: EAI — the claude CLI failed
/api/lanes/{id}/git/{action}: /api/lanes/{id}/git/{action}:
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "agenthub", "name": "agenthub",
"version": "2.5.13", "version": "2.6.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "agenthub", "name": "agenthub",
"version": "2.5.13", "version": "2.6.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "agenthub", "name": "agenthub",
"version": "2.5.13", "version": "2.6.0",
"bin": { "bin": {
"agenthub": "bin/agenthub.js" "agenthub": "bin/agenthub.js"
}, },
+3 -1
View File
@@ -577,7 +577,9 @@ async function parseSubagentFile(filePath) {
const metaPath = filePath.replace(/\.jsonl$/, ".meta.json"); const metaPath = filePath.replace(/\.jsonl$/, ".meta.json");
try { try {
if (fs.existsSync(metaPath)) { if (fs.existsSync(metaPath)) {
const meta = JSON.parse(require("../server/lib/run-as").readFileSafe(metaPath, "utf8", { max: 1 << 20 })); const meta = JSON.parse(
require("../server/lib/run-as").readFileSafe(metaPath, "utf8", { max: 1 << 20 })
);
if (meta.agentType) agentType = meta.agentType; if (meta.agentType) agentType = meta.agentType;
} }
} catch { } catch {
+3
View File
@@ -764,6 +764,9 @@ HTTP surface for the tmux-backed terminal run: one session per workspace, named
| `GET` | `/api/run/cwds` | Suggested cwds (dashboard, home, recent from sessions) | | `GET` | `/api/run/cwds` | Suggested cwds (dashboard, home, recent from sessions) |
| `POST` | `/api/run/drop-image` | An image dropped on the Workspace terminal. Raw body, `Content-Type` one of `image/png\|jpeg\|gif\|webp`, ≤20 MB. Saved `0600` under `<data dir>/drops/` (`0700`, never a guessable name in the shared tmp; S3: the member's own `~/.agenthub/handoff/drops/`) with a RANDOM name (nothing from the request names the file); files older than a day are pruned on each write. Answers `{ path }`, which `TerminalView` pastes into the pane — what a local terminal does on a drop, and how `claude` attaches the image. 400 for any other type | | `POST` | `/api/run/drop-image` | An image dropped on the Workspace terminal. Raw body, `Content-Type` one of `image/png\|jpeg\|gif\|webp`, ≤20 MB. Saved `0600` under `<data dir>/drops/` (`0700`, never a guessable name in the shared tmp; S3: the member's own `~/.agenthub/handoff/drops/`) with a RANDOM name (nothing from the request names the file); files older than a day are pruned on each write. Answers `{ path }`, which `TerminalView` pastes into the pane — what a local terminal does on a drop, and how `claude` attaches the image. 400 for any other type |
| `POST` | `/api/run/drop-file?name=…` | Any file (or image) dropped on the Workspace terminal or pasted/dropped into a prompt box. Raw body of any type, ≤20 MB, same directory (S3: `?laneId=` saves it AS the account that workspace's panes run as, so the pane can read it), `0600` and day-old pruning as `drop-image`. The name is the file's own, reduced to a `[A-Za-z0-9._-]` basename behind a random 16-hex prefix (so it can neither leave the directory nor collide, and space-joined paths stay parseable). Answers `{ path }`; 400 for an empty body or a name that sanitizes to nothing. The client uses this for every drop; `drop-image` is kept for API callers | | `POST` | `/api/run/drop-file?name=…` | Any file (or image) dropped on the Workspace terminal or pasted/dropped into a prompt box. Raw body of any type, ≤20 MB, same directory (S3: `?laneId=` saves it AS the account that workspace's panes run as, so the pane can read it), `0600` and day-old pruning as `drop-image`. The name is the file's own, reduced to a `[A-Za-z0-9._-]` basename behind a random 16-hex prefix (so it can neither leave the directory nor collide, and space-joined paths stay parseable). Answers `{ path }`; 400 for an empty body or a name that sanitizes to nothing. The client uses this for every drop; `drop-image` is kept for API callers |
| `GET` | `/api/run/drop/:name` | A dropped IMAGE served back, so a chat turn shows the picture instead of its path. Only a name `drop-image`/`drop-file` made (16-hex prefix, no separator) with a png/jpg/gif/webp extension, only a regular file in the REQUESTER's drop dir (S3: their member account's, owned by that account — an admin viewing a member's session gets the name chip, not the image); under `nosniff` and a `sandbox` CSP like `/git/raw`. 404 otherwise, and once the day-old prune removed it |
| `GET` | `/api/run/:id/permission-mode` | A live **claude** pane's permission mode, read off the mode line Claude Code prints under its prompt (`server/lib/pane-mode.js`): `{ mode }` — `default`, `acceptEdits`, `plan`, `auto`, `bypassPermissions`, `dontAsk`, or `null` while a menu hides it. 404 for a pane that is not running, 400 `EUNSUPPORTED` for a non-claude pane |
| `POST` | `/api/run/:id/permission-mode` | `{ mode }` → steps the pane there with Shift+Tab, the only way a running session changes mode, reading every step back before the next key. `{ mode }` on success; 400 `EBADMODE` outside the five settable modes; 409 `EMODEUNKNOWN` (no mode line — nothing is pressed — or a step did not change it), `EMODEUNAVAILABLE` (not in this session's cycle, e.g. Bypass in a session not started with it; the pane is back on its starting mode), `EBUSY` (a change is already running on that pane) |
| `GET` | `/api/run/files?cwd=…&q=…` | Fuzzy file search inside `cwd` for the run panel's `@`-file autocomplete. Skips `node_modules`, `.git`, `dist`, `build`, `.next`, `.cache`, `coverage`, `vendor`, etc. Cwd is required and must exist; results are capped and ranked by basename match | | `GET` | `/api/run/files?cwd=…&q=…` | Fuzzy file search inside `cwd` for the run panel's `@`-file autocomplete. Skips `node_modules`, `.git`, `dist`, `build`, `.next`, `.cache`, `coverage`, `vendor`, etc. Cwd is required and must exist; results are capped and ranked by basename match |
| `POST` | `/api/run` | Create-or-adopt the workspace's tmux session. Body: `{ laneId, cwd, initialPrompt?, model?, permissionMode?, effort?, resumeSessionId? }`. The CLI comes from the workspace row, never from the body. An existing session whose pane sits at a shell prompt gets the argv typed into it; a pane already running a program is adopted unchanged | | `POST` | `/api/run` | Create-or-adopt the workspace's tmux session. Body: `{ laneId, cwd, initialPrompt?, model?, permissionMode?, effort?, resumeSessionId? }`. The CLI comes from the workspace row, never from the body. An existing session whose pane sits at a shell prompt gets the argv typed into it; a pane already running a program is adopted unchanged |
| `GET` | `/api/run/:id` | One run handle, or 404 once its tmux session is gone | | `GET` | `/api/run/:id` | One run handle, or 404 once its tmux session is gone |
+85
View File
@@ -3035,3 +3035,88 @@ describe("Nested Agent Spawning", () => {
); );
}); });
}); });
describe("GET /api/sessions/:id/outline", () => {
it("reads progress, outputs and context off the session's tool events", async () => {
const sid = "outline-" + Date.now();
const pre = (tool_name, tool_input, tool_response) =>
post("/api/hooks/event", {
hook_type: "PostToolUse",
data: { session_id: sid, tool_name, tool_input, tool_response },
});
await post("/api/hooks/event", { hook_type: "SessionStart", data: { session_id: sid } });
await pre(
"TaskCreate",
{ subject: "Parse plan" },
{ task: { id: "1", subject: "Parse plan" } }
);
await pre(
"TaskCreate",
{ subject: "Parse plan" },
{ task: { id: "1", subject: "Parse plan" } }
);
await pre(
"TaskCreate",
{ subject: "Build report" },
{ task: { id: "2", subject: "Build report" } }
);
await pre("TaskUpdate", { taskId: "1", status: "completed" });
await pre("TaskUpdate", { taskId: "2", status: "in_progress" });
await pre("Write", { file_path: "/w/a.xlsx", content: "x" });
await pre("Write", { file_path: "/w/b.md", content: "x" });
await pre("Write", { file_path: "/w/a.xlsx", content: "y" });
await pre("Skill", { skill: "xlsx" });
await pre("Skill", { skill: "xlsx" });
await pre("mcp__plugin_vercel_vercel__list_projects", {});
const res = await fetch(`/api/sessions/${sid}/outline`);
assert.equal(res.status, 200);
assert.deepEqual(res.body.progress, [
{ id: "1", subject: "Parse plan", status: "completed" },
{ id: "2", subject: "Build report", status: "in_progress" },
]);
assert.deepEqual(res.body.outputs, ["/w/a.xlsx", "/w/b.md"]);
assert.deepEqual(res.body.skills, ["xlsx"]);
assert.deepEqual(res.body.mcp, ["vercel_vercel"]);
// A TodoWrite replaces the list wholesale.
await pre("TodoWrite", { todos: [{ content: "Verify", status: "pending" }] });
const again = await fetch(`/api/sessions/${sid}/outline`);
assert.deepEqual(again.body.progress, [{ id: "0", subject: "Verify", status: "pending" }]);
assert.equal((await fetch("/api/sessions/nope-outline/outline")).status, 404);
});
it("splits what a session used (uploads, files, memory) from what it changed", async () => {
const sid = "outline-used-" + Date.now();
const pre = (tool_name, tool_input) =>
post("/api/hooks/event", {
hook_type: "PostToolUse",
data: { session_id: sid, tool_name, tool_input, tool_response: {} },
});
const drop = "/tmp/agenthub-drops-1000/0123456789abcdef-minutes.xlsx";
await post("/api/hooks/event", { hook_type: "SessionStart", data: { session_id: sid } });
await post("/api/hooks/event", {
hook_type: "UserPromptSubmit",
data: {
session_id: sid,
prompt: `compare these ${drop}\n/tmp/agenthub-drops-1000/fedcba9876543210.png`,
},
});
const mem = "/home/u/.claude/projects/-w/memory/release.md";
await pre("Read", { file_path: drop });
await pre("Read", { file_path: "/w/src/a.ts" });
await pre("Read", { file_path: "/w/src/c.ts" });
await pre("Read", { file_path: mem });
await pre("Edit", { file_path: "/w/src/a.ts", old_string: "a", new_string: "b" });
await pre("MultiEdit", { file_path: "/w/src/b.ts", edits: [] });
await pre("Write", { file_path: mem, content: "x" });
const { body } = await fetch(`/api/sessions/${sid}/outline`);
assert.deepEqual(body.uploads, ["/tmp/agenthub-drops-1000/fedcba9876543210.png", drop]);
// a.ts was read, then edited: an output, no longer a file it only used.
assert.deepEqual(body.files, ["/w/src/c.ts"]);
assert.deepEqual(body.memory, [mem]);
assert.deepEqual(body.outputs, ["/w/src/b.ts", "/w/src/a.ts"]);
});
});
+64
View File
@@ -2017,6 +2017,70 @@ describe("a session's own worktree (claude --worktree)", () => {
}); });
}); });
describe("an extra folder's own repo (?source=)", () => {
const { execFileSync } = require("child_process");
const gitEnv = Object.fromEntries(
Object.entries(process.env).filter(([k]) => !k.startsWith("GIT_"))
);
const gitIn = (dir, ...args) =>
execFileSync("git", ["-C", dir, "-c", "user.name=t", "-c", "user.email=t@t", ...args], {
encoding: "utf8",
env: gitEnv,
});
let ws, extra, laneId, otherLaneId, sourceId, fileSourceId;
before(async () => {
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "lane-src-git-")));
ws = path.join(root, "ws");
extra = path.join(root, "extra");
for (const dir of [ws, extra]) {
fs.mkdirSync(dir);
gitIn(dir, "init", "-q", "-b", "main");
fs.writeFileSync(path.join(dir, "a.txt"), "a\n");
gitIn(dir, "add", ".");
gitIn(dir, "commit", "-qm", "init");
}
laneId = (await request("POST", "/api/lanes", { cwd: ws })).body.lane.id;
otherLaneId = (await request("POST", "/api/lanes", { cwd: extra })).body.lane.id;
const src = require("../lib/lane-sources");
sourceId = src.addFolder(laneId, extra).id;
fileSourceId = require("../db")
.db.prepare(
"INSERT INTO lane_sources (lane_id, kind, path, name, added_at) VALUES (?, 'file', ?, 'f', '2026-10-09T00:00:00Z')"
)
.run(laneId, path.join(extra, "a.txt")).lastInsertRowid;
});
it("reads and writes git in the extra folder, never falling back", async () => {
fs.writeFileSync(path.join(extra, "only-in-extra.txt"), "x\n");
const inExtra = await request("GET", `/api/lanes/${laneId}/git/changes?source=${sourceId}`);
assert.ok(inExtra.body.files.some((f) => f.path === "only-in-extra.txt"));
const facts = await request("GET", `/api/lanes/${laneId}/git?source=${sourceId}`);
assert.equal(facts.body.base, "main");
const main = await request("GET", `/api/lanes/${laneId}/git/changes`);
assert.ok(!main.body.files.some((f) => f.path === "only-in-extra.txt"));
const ok = await request("POST", `/api/lanes/${laneId}/git/stage?source=${sourceId}`, {
paths: ["only-in-extra.txt"],
});
assert.equal(ok.status, 200);
assert.match(gitIn(extra, "diff", "--cached", "--name-only"), /only-in-extra\.txt/);
});
it("answers ENOSOURCE for a source that is not this workspace's folder", async () => {
for (const id of ["nope", "999999", String(fileSourceId)]) {
const r = await request("POST", `/api/lanes/${laneId}/git/stage?source=${id}`, {
paths: ["only-in-extra.txt"],
});
assert.equal(r.status, 404, id);
assert.equal(r.body.error.code, "ENOSOURCE", id);
}
// Another workspace's request may not borrow this one's source id.
const r = await request("GET", `/api/lanes/${otherLaneId}/git/changes?source=${sourceId}`);
assert.equal(r.status, 404);
assert.equal(r.body.error.code, "ENOSOURCE");
});
});
// The server, not a stale client list, picks the tab and refuses a second pane // The server, not a stale client list, picks the tab and refuses a second pane
// on a session one already holds. // on a session one already holds.
describe("start: slot auto and a session already live", () => { describe("start: slot auto and a session already live", () => {
+175 -2
View File
@@ -2,7 +2,8 @@
/** /**
* @file run.test.js * @file run.test.js
* @description Route tests for the terminal-run feature: same-origin guard, * @description Route tests for the terminal-run feature: same-origin guard,
* laneId/cwd validation, spawn/kill/list against a mocked tmux backend. * laneId/cwd validation, spawn/kill/list against a mocked tmux backend, a
* dropped image served back, and a live pane's permission mode.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
const { describe, it, before, after, beforeEach } = require("node:test"); const { describe, it, before, after, beforeEach } = require("node:test");
@@ -55,7 +56,7 @@ function fetchJson(p, opts = {}) {
} catch { } catch {
parsed = raw; parsed = raw;
} }
resolve({ status: res.statusCode, body: parsed }); resolve({ status: res.statusCode, body: parsed, headers: res.headers });
}); });
} }
); );
@@ -209,6 +210,27 @@ describe("/api/run", () => {
}); });
}); });
describe("GET /api/run/files", () => {
let srv;
const DIR = path.join(TMP, "files-proj");
before(async () => {
fs.mkdirSync(path.join(DIR, "src", "lib"), { recursive: true });
fs.writeFileSync(path.join(DIR, "src", "lib", "a.js"), "");
srv = http.createServer(createApp());
await new Promise((r) => srv.listen(0, r));
BASE = `http://127.0.0.1:${srv.address().port}`;
});
after(() => new Promise((r) => srv.close(r)));
const list = async (qs) =>
(await fetchJson(`/api/run/files?cwd=${encodeURIComponent(DIR)}${qs}`)).body.items;
it("lists files only, unless dirs=1 adds the folders with a trailing slash", async () => {
assert.deepEqual(await list(""), ["src/lib/a.js"]);
assert.deepEqual(await list("&dirs=1"), ["src/", "src/lib/", "src/lib/a.js"]);
assert.deepEqual(await list("&dirs=1&q=lib"), ["src/lib/", "src/lib/a.js"]);
});
});
describe("POST /api/run/drop-image", () => { describe("POST /api/run/drop-image", () => {
let srv; let srv;
before(async () => { before(async () => {
@@ -274,3 +296,154 @@ describe("POST /api/run/drop-image", () => {
assert.equal(r.status, 400); assert.equal(r.status, 400);
}); });
}); });
describe("GET /api/run/drop/:name", () => {
let srv;
before(async () => {
srv = http.createServer(createApp());
await new Promise((r) => srv.listen(0, r));
BASE = `http://127.0.0.1:${srv.address().port}`;
});
after(() => new Promise((r) => srv.close(r)));
const PNG = Buffer.from("89504e470d0a1a0a0000", "hex");
it("serves a dropped image back as an image that runs nothing", async () => {
const saved = await fetchJson("/api/run/drop-image", {
method: "POST",
raw: { type: "image/png", buf: PNG },
});
const r = await fetchJson(`/api/run/drop/${path.basename(saved.body.path)}`);
assert.equal(r.status, 200);
assert.equal(r.headers["content-type"], "image/png");
assert.equal(r.headers["x-content-type-options"], "nosniff");
assert.match(r.headers["content-security-policy"], /sandbox/);
fs.unlinkSync(saved.body.path);
});
it("serves no file that is not a dropped image", async () => {
const link = path.join(
os.tmpdir(),
`agenthub-drops-${os.userInfo().uid}`,
"fedcba9876543210.png"
);
fs.rmSync(link, { force: true });
fs.symlinkSync("/etc/passwd", link);
const pdf = await fetchJson("/api/run/drop-file?name=a.pdf", {
method: "POST",
raw: { type: "application/pdf", buf: Buffer.from("%PDF") },
});
for (const name of [
path.basename(pdf.body.path), // a drop, but no image
"0123456789abcdef.png", // the right shape, never dropped
"..%2F..%2Fetc%2Fpasswd",
"passwd.png", // no random prefix
"fedcba9876543210.png", // a link planted under a drop's name
]) {
const r = await fetchJson(`/api/run/drop/${name}`);
assert.equal(r.status, 404, name);
}
fs.unlinkSync(pdf.body.path);
fs.unlinkSync(link);
});
});
describe("/api/run/:id/permission-mode", () => {
let srv;
before(async () => {
srv = http.createServer(createApp());
await new Promise((r) => srv.listen(0, r));
BASE = `http://127.0.0.1:${srv.address().port}`;
});
after(() => new Promise((r) => srv.close(r)));
beforeEach(() => tmux.__reset());
// A live claude pane whose mode line follows Shift+Tab around Claude Code's cycle.
const LINES = {
default: " ⏸ manual mode on · ← for agents",
acceptEdits: " ⏵⏵ accept edits on (shift+tab to cycle) · ← for agents",
plan: " ⏸ plan mode on (shift+tab to cycle) · ← for agents",
auto: " ⏵⏵ auto mode on (shift+tab to cycle) · ← for agents",
};
function fakePane(start) {
const cycle = ["default", "acceptEdits", "plan", "auto"];
const state = { mode: start, presses: 0 };
tmux.__setExecImpl((args) => {
if (args[0] === "capture-pane") return `❯ \n${"─".repeat(20)}\n${LINES[state.mode]}\n`;
if (args[0] === "send-keys" && args.at(-1) === "BTab") {
state.presses++;
state.mode = cycle[(cycle.indexOf(state.mode) + 1) % cycle.length];
}
return "";
});
return state;
}
it("GET reads the mode off the pane", async () => {
fakePane("plan");
const r = await fetchJson("/api/run/agenthub-lane-9/permission-mode");
assert.equal(r.status, 200);
assert.equal(r.body.mode, "plan");
});
it("POST presses Shift+Tab until the pane shows the mode asked for", async () => {
const state = fakePane("default");
const r = await fetchJson("/api/run/agenthub-lane-9/permission-mode", {
method: "POST",
body: { mode: "auto" },
});
assert.equal(r.status, 200);
assert.equal(r.body.mode, "auto");
assert.equal(state.presses, 3);
});
it("POST of a mode not in the cycle ends where it started, and says so", async () => {
const state = fakePane("acceptEdits");
const r = await fetchJson("/api/run/agenthub-lane-9/permission-mode", {
method: "POST",
body: { mode: "bypassPermissions" },
});
assert.equal(r.status, 409);
assert.equal(r.body.error.code, "EMODEUNAVAILABLE");
assert.equal(state.mode, "acceptEdits");
});
it("POST presses nothing when the mode line cannot be read", async () => {
let presses = 0;
tmux.__setExecImpl((args) => {
if (args[0] === "capture-pane") return "Select model\n❯ 1. Default\n";
if (args[0] === "send-keys") presses++;
return "";
});
const r = await fetchJson("/api/run/agenthub-lane-9/permission-mode", {
method: "POST",
body: { mode: "plan" },
});
assert.equal(r.status, 409);
assert.equal(r.body.error.code, "EMODEUNKNOWN");
assert.equal(presses, 0);
});
it("POST refuses a mode outside the allowlist", async () => {
fakePane("default");
const r = await fetchJson("/api/run/agenthub-lane-9/permission-mode", {
method: "POST",
body: { mode: "dontAsk; rm -rf ~" },
});
assert.equal(r.status, 400);
assert.equal(r.body.error.code, "EBADMODE");
});
it("answers 404 for a pane that is not running", async () => {
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") {
const e = new Error("gone");
e.status = 1;
throw e;
}
return "";
});
const r = await fetchJson("/api/run/agenthub-lane-999/permission-mode");
assert.equal(r.status, 404);
});
});
@@ -185,6 +185,15 @@ describe("server mode", () => {
body: { prefs: { "agenthub.chatUi": null } }, body: { prefs: { "agenthub.chatUi": null } },
}); });
assert.deepEqual((await call("GET", "/api/me/prefs", { cookie: boss })).body.prefs, {}); assert.deepEqual((await call("GET", "/api/me/prefs", { cookie: boss })).body.prefs, {});
// The appearance (useTheme's storage keys) follows the account too.
const look = {
theme: "light",
"agenthub.theme.light": "canvas",
"agenthub.theme.dark": "darcula",
};
const saved = await call("PUT", "/api/me/prefs", { cookie: ana, body: { prefs: look } });
assert.equal(saved.status, 200);
assert.deepEqual(saved.body.prefs, look);
}); });
it("checks a WebSocket upgrade's login and Origin", async () => { it("checks a WebSocket upgrade's login and Origin", async () => {

Some files were not shown because too many files have changed in this diff Show More