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:
@@ -10,6 +10,7 @@
|
||||
* `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
|
||||
* 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>
|
||||
*/
|
||||
|
||||
@@ -17,11 +18,15 @@ import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useState,
|
||||
type FormEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
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 { hydrate } from "../lib/userPrefs";
|
||||
|
||||
@@ -144,9 +149,18 @@ function LoginPage({ onLoggedIn }: { onLoggedIn: (user: AuthUser) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
@@ -164,38 +178,83 @@ function LoginPage({ onLoggedIn }: { onLoggedIn: (user: AuthUser) => void }) {
|
||||
|
||||
return (
|
||||
<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">
|
||||
<h1 className="text-lg font-semibold text-fg-primary">{t("auth.title")}</h1>
|
||||
<label className="block space-y-1 text-sm text-fg-secondary">
|
||||
<span>{t("auth.username")}</span>
|
||||
<input
|
||||
className="input w-full"
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
required
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="block space-y-1 text-sm text-fg-secondary">
|
||||
<span>{t("auth.password")}</span>
|
||||
<input
|
||||
className="input w-full"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<button type="submit" className="btn-primary w-full" disabled={busy}>
|
||||
{busy ? t("auth.submitting") : t("auth.submit")}
|
||||
</button>
|
||||
<form
|
||||
onSubmit={submit}
|
||||
className="card relative w-full max-w-md overflow-hidden px-6 pb-8 pt-12 sm:px-10"
|
||||
>
|
||||
{/* Accent glow and a grid fading out from the top, drawn from the
|
||||
theme's own accent so it holds in every theme. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 top-0 h-56"
|
||||
style={{
|
||||
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%)",
|
||||
backgroundSize: "48px 48px, 48px 48px, 100% 100%",
|
||||
backgroundPosition: "center top",
|
||||
maskImage: "radial-gradient(ellipse 70% 100% at top, black 30%, transparent 100%)",
|
||||
}}
|
||||
/>
|
||||
<div className="relative space-y-5">
|
||||
<div className="text-center">
|
||||
<img src={BRAND_LOGO} alt="SmartGift" className="block h-auto w-full" />
|
||||
<h1 className="mt-5 text-3xl font-semibold tracking-tight text-fg-primary">
|
||||
{t("auth.title")}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-fg-secondary">{t("auth.subtitle")}</p>
|
||||
</div>
|
||||
<label className="block space-y-1.5 text-sm font-medium text-fg-primary">
|
||||
<span>{t("auth.username")}</span>
|
||||
<input
|
||||
className="input h-11 w-full px-4"
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
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>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
* @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 { FolderOpen } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
@@ -27,12 +27,15 @@ export function PathPickButton({
|
||||
start,
|
||||
onPick,
|
||||
showLabel = false,
|
||||
buttonRef,
|
||||
}: {
|
||||
kind?: "dir" | "file";
|
||||
/** The field's current value — the picker opens there. */
|
||||
start?: string;
|
||||
onPick: (path: string) => void;
|
||||
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 [busy, setBusy] = useState(false);
|
||||
@@ -61,6 +64,7 @@ export function PathPickButton({
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={pick}
|
||||
disabled={busy}
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
* @description Generic styled dropdown that replaces native `<select>` elements
|
||||
* for consistent cross-platform rendering. Native macOS/Chromium selects reserve
|
||||
* 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.
|
||||
*
|
||||
* ## Accessibility — WAI-ARIA "listbox button"
|
||||
@@ -74,6 +75,14 @@
|
||||
import { useEffect, useId, useRef, useState, type ReactNode } from "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. */
|
||||
export interface SelectOption<T extends string> {
|
||||
/** Stored value emitted via `onChange`. */
|
||||
@@ -144,9 +153,11 @@ export function Select<T extends string>({
|
||||
const idx = options.findIndex((o) => o.value === value);
|
||||
setActive(idx >= 0 ? idx : 0);
|
||||
if (buttonRef.current) {
|
||||
const rect = buttonRef.current.getBoundingClientRect();
|
||||
const below = window.innerHeight - rect.bottom;
|
||||
setOpenUp(below < 288 && rect.top > below);
|
||||
const { above, below } = menuRoom(
|
||||
buttonRef.current,
|
||||
buttonRef.current.getBoundingClientRect()
|
||||
);
|
||||
setOpenUp(below < 288 && above > below);
|
||||
}
|
||||
listRef.current?.focus({ preventScroll: true });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- only on open
|
||||
|
||||
@@ -74,6 +74,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { BRAND_LOGO, BRAND_MARK } from "../lib/brand";
|
||||
import { useAuth, usePermissions } from "./AuthGate";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import { WorkspaceTree } from "./WorkspaceTree";
|
||||
@@ -88,13 +89,6 @@ const NAV_KEYS = [
|
||||
{ to: "/sessions", icon: FolderOpen, key: "nav:sessions" },
|
||||
] 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 STATS_STORAGE_KEY = "sidebar-connection-stats";
|
||||
const RECENT_EVENTS_CAP = 8;
|
||||
@@ -496,6 +490,7 @@ export function Sidebar({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
data-testid="sidebar-tree-rail"
|
||||
title={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"
|
||||
|
||||
@@ -5,11 +5,15 @@
|
||||
* (`WorkspaceSessionList.tsx`) — no repo grouping, no worktree rows. Picking 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
|
||||
* 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
|
||||
* 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
|
||||
* 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>
|
||||
*/
|
||||
|
||||
@@ -25,10 +29,11 @@ import {
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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 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 { useThrottledValue } from "../hooks/useThrottledValue";
|
||||
import {
|
||||
@@ -70,7 +75,12 @@ export function WorkspaceTree() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
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 [liveRuns, setLiveRuns] = useState<RunHandle[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -132,10 +142,17 @@ export function WorkspaceTree() {
|
||||
const tools = useMemo(() => [...new Set(lanes.map((l) => l.tool))], [lanes]);
|
||||
const toolFilter =
|
||||
tools.length > 1 && prefs.tool && tools.includes(prefs.tool) ? prefs.tool : null;
|
||||
const sorted = useMemo(
|
||||
() => sortByStoredOrder(lanes, order).filter((l) => !toolFilter || l.tool === toolFilter),
|
||||
[lanes, order, toolFilter]
|
||||
);
|
||||
const sorted = useMemo(() => {
|
||||
const list = sortByStoredOrder(lanes, order).filter(
|
||||
(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 nameHit = (l: Lane) =>
|
||||
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">
|
||||
{sorted.map((lane, i) => (
|
||||
<div key={lane.id} className="relative">
|
||||
{dropAt === i && <DropLine />}
|
||||
{visible(lane) && (
|
||||
<WorkspaceRow
|
||||
lane={lane}
|
||||
index={i}
|
||||
selected={selected.lane === lane.id && !selected.run && !selected.session}
|
||||
expanded={expanded(lane.id)}
|
||||
renaming={renamingId === lane.id}
|
||||
onSelect={() => {
|
||||
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) && (
|
||||
<WorkspaceSessionList
|
||||
lane={lane}
|
||||
liveRuns={liveRuns}
|
||||
// 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>
|
||||
))}
|
||||
{sorted.map((lane, i) => {
|
||||
const owner = ownerKey(lane);
|
||||
// A search opens every group, like it opens every workspace.
|
||||
const folded = grouped && !q && !!prefs.collapsedOwners[owner];
|
||||
const readOnly = !canEditLane(lane);
|
||||
const ownerLabel =
|
||||
lane.owner_id == null
|
||||
? t("tree.noOwner")
|
||||
: lane.owner_id === meId
|
||||
? t("tree.ownerYou", { name: lane.owner_name ?? `#${lane.owner_id}` })
|
||||
: (lane.owner_name ?? `#${lane.owner_id}`);
|
||||
const groupStart = grouped && (i === 0 || ownerKey(sorted[i - 1] ?? lane) !== owner);
|
||||
const groupShown = sorted.some((l) => ownerKey(l) === owner && visible(l));
|
||||
return (
|
||||
<div key={lane.id} className="relative">
|
||||
{groupStart && groupShown && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`workspace-owner-${owner}`}
|
||||
aria-expanded={!folded}
|
||||
title={t("tree.ownerToggle", { name: ownerLabel })}
|
||||
onClick={() =>
|
||||
writeProjectTreePrefs({
|
||||
collapsedOwners: { ...prefs.collapsedOwners, [owner]: !folded },
|
||||
})
|
||||
}
|
||||
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"}`}
|
||||
>
|
||||
{folded ? (
|
||||
<ChevronRight className="h-3 w-3 shrink-0" />
|
||||
) : (
|
||||
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||
)}
|
||||
<span
|
||||
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 && (
|
||||
<div className="relative">
|
||||
<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. */
|
||||
function DropLine() {
|
||||
return (
|
||||
|
||||
@@ -5,17 +5,19 @@
|
||||
* and a later 401 `EAUTH` brings the login page back. `usePermissions` and
|
||||
* `useWorkLanes` mirror the server's roles: an admin changes everything, an
|
||||
* 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>
|
||||
*/
|
||||
|
||||
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 "../../i18n";
|
||||
|
||||
const me = vi.fn();
|
||||
const login = vi.fn();
|
||||
const prefs = vi.fn();
|
||||
const LANES = [
|
||||
{ id: 1, owner_id: 5 },
|
||||
{ id: 2, owner_id: 6 },
|
||||
@@ -25,6 +27,7 @@ vi.mock("../../lib/api", () => ({
|
||||
api: {
|
||||
auth: { me: () => me(), login: (u: string, p: string) => login(u, p), logout: vi.fn() },
|
||||
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(() => {
|
||||
me.mockReset();
|
||||
login.mockReset();
|
||||
prefs.mockReset().mockResolvedValue({ prefs: {} });
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
@@ -107,15 +111,15 @@ describe("AuthGate", () => {
|
||||
render(<AuthGate>app</AuthGate>);
|
||||
|
||||
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(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(screen.queryByText("app")).toBeNull();
|
||||
|
||||
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(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", () => {
|
||||
const as = async (state: unknown) => {
|
||||
cleanup();
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* @file Select.test.tsx
|
||||
* @description The shared Select as a WAI-ARIA listbox button: expanded state,
|
||||
* 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>
|
||||
*/
|
||||
|
||||
@@ -90,4 +91,21 @@ describe("Select (listbox button)", () => {
|
||||
fireEvent.click(screen.getByText("Apple"));
|
||||
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
|
||||
* 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
|
||||
* 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>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -79,12 +83,21 @@ interface ConversationViewProps {
|
||||
/** 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. */
|
||||
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({
|
||||
sessionId,
|
||||
initialTranscriptId,
|
||||
fill = false,
|
||||
onOpenFile,
|
||||
live = false,
|
||||
follow = 0,
|
||||
}: ConversationViewProps) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
|
||||
@@ -105,6 +118,7 @@ export function ConversationView({
|
||||
const lastLineRef = useRef(0);
|
||||
const firstLineRef = useRef(0);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const isAtBottomRef = useRef(true);
|
||||
const fetchingRef = useRef(false);
|
||||
// When a fetch is in flight and a new trigger arrives (WS event, poll,
|
||||
@@ -392,6 +406,30 @@ export function ConversationView({
|
||||
}
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
if (!loading && messages.length > 0) {
|
||||
@@ -450,38 +488,42 @@ export function ConversationView({
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
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 }}
|
||||
>
|
||||
{/* History loading indicator */}
|
||||
{loadingHistory && (
|
||||
<div className="flex justify-center py-3">
|
||||
<Loader2 className="w-4 h-4 text-fg-muted animate-spin" />
|
||||
<span className="text-xs text-fg-muted ml-2">{t("conversation.loadingHistory")}</span>
|
||||
</div>
|
||||
)}
|
||||
<div ref={contentRef}>
|
||||
{/* History loading indicator */}
|
||||
{loadingHistory && (
|
||||
<div className="flex justify-center py-3">
|
||||
<Loader2 className="w-4 h-4 text-fg-muted animate-spin" />
|
||||
<span className="text-xs text-fg-muted ml-2">{t("conversation.loadingHistory")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scroll-up for history hint */}
|
||||
{hasMore && !loadingHistory && !loading && (
|
||||
<div className="flex justify-center py-2">
|
||||
<span className="text-[11px] text-fg-muted">↑ {t("conversation.scrollForOlder")}</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Scroll-up for history hint */}
|
||||
{hasMore && !loadingHistory && !loading && (
|
||||
<div className="flex justify-center py-2">
|
||||
<span className="text-[11px] text-fg-muted">
|
||||
↑ {t("conversation.scrollForOlder")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12 text-fg-muted text-sm">
|
||||
{t("conversation.loading")}
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="mx-auto max-w-md py-12 text-center">
|
||||
<p className="text-sm text-fg-secondary">{t("conversation.empty")}</p>
|
||||
<p className="mt-2 text-xs leading-relaxed text-fg-muted">
|
||||
{t("conversation.emptyBody")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<MessageList messages={messages} loading={false} />
|
||||
)}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12 text-fg-muted text-sm">
|
||||
{t("conversation.loading")}
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="mx-auto max-w-md py-12 text-center">
|
||||
<p className="text-sm text-fg-secondary">{t("conversation.empty")}</p>
|
||||
<p className="mt-2 text-xs leading-relaxed text-fg-muted">
|
||||
{t("conversation.emptyBody")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<MessageList messages={messages} loading={false} onOpenFile={onOpenFile} live={live} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Claude Desktop's round "back to the end" button */}
|
||||
|
||||
@@ -4,7 +4,16 @@
|
||||
* transcript: alternating user / assistant rows with collapsible thinking
|
||||
* blocks, inline ToolCallBlocks for tool_use / tool_result pairs, and
|
||||
* 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>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -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 i18n from "../../i18n";
|
||||
import {
|
||||
@@ -69,6 +79,8 @@ import {
|
||||
Pencil,
|
||||
Workflow,
|
||||
Cog,
|
||||
FileText,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import type { TranscriptMessage, TranscriptContent, TranscriptSender } from "../../lib/types";
|
||||
|
||||
@@ -113,12 +125,16 @@ const SENDER_STYLES: Record<
|
||||
};
|
||||
import { ToolCallBlock, buildSummary, renderInput, renderResult } from "./ToolCallBlock";
|
||||
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";
|
||||
|
||||
interface MessageListProps {
|
||||
messages: TranscriptMessage[];
|
||||
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 */
|
||||
@@ -135,6 +151,16 @@ function buildToolResultMap(messages: TranscriptMessage[]): Map<string, Transcri
|
||||
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:") */
|
||||
function isSkillContent(text: string): boolean {
|
||||
return text.startsWith("Base directory for this skill:");
|
||||
@@ -390,34 +416,120 @@ type Item =
|
||||
| { kind: "row"; key: string; msg: TranscriptMessage }
|
||||
| { kind: "user"; key: string; msg: TranscriptMessage }
|
||||
| { 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[] {
|
||||
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) => {
|
||||
const key = keys[idx]!;
|
||||
if (msg.type === "session_event") return items.push({ kind: "event", key, msg });
|
||||
// Purely tool_result: shown inside its call's block.
|
||||
if (msg.type === "user" && !msg.content.some((c) => c.type === "text")) return;
|
||||
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 });
|
||||
msg.content.forEach((block, b) => {
|
||||
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);
|
||||
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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
endTurn(keys[keys.length - 1] ?? "end");
|
||||
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 toolResultMap = useMemo(() => buildToolResultMap(messages), [messages]);
|
||||
const pending = useMemo(
|
||||
() => (live ? workingOn(messages, toolResultMap) : null),
|
||||
[live, messages, toolResultMap]
|
||||
);
|
||||
const keys = useMemo(() => messageKeys(messages), [messages]);
|
||||
const items = useMemo(() => chatItems(messages, keys), [messages, keys]);
|
||||
|
||||
@@ -434,7 +546,7 @@ export function MessageList({ messages, loading }: MessageListProps) {
|
||||
}
|
||||
|
||||
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) => {
|
||||
switch (item.kind) {
|
||||
case "event":
|
||||
@@ -453,14 +565,133 @@ export function MessageList({ messages, loading }: MessageListProps) {
|
||||
return <AssistantText key={item.key} msg={item.msg} text={item.text} />;
|
||||
case "work":
|
||||
return <WorkGroup key={item.key} blocks={item.blocks} toolResultMap={toolResultMap} />;
|
||||
case "edits":
|
||||
// The card closes a finished turn; one still working has none yet.
|
||||
if (pending && item === items[items.length - 1]) return null;
|
||||
return (
|
||||
<EditedFiles
|
||||
key={item.key}
|
||||
blocks={item.blocks}
|
||||
toolResultMap={toolResultMap}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
{pending && <RunningStep block={pending.block} since={pending.since} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
* paste, a compaction summary) shows its first lines until opened. */
|
||||
const LONG_BUBBLE = 800;
|
||||
@@ -468,35 +699,54 @@ const UserBubble = memo(function UserBubble({ msg }: { msg: TranscriptMessage })
|
||||
const { t } = useTranslation("sessions");
|
||||
const long = msg.content.reduce((n, c) => n + (c.text?.length ?? 0), 0) > LONG_BUBBLE;
|
||||
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 (
|
||||
<div
|
||||
data-testid="chat-user"
|
||||
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 60px" }}
|
||||
className="group flex flex-col items-end gap-1"
|
||||
>
|
||||
<div
|
||||
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 ${
|
||||
long && !open
|
||||
? "max-h-40 overflow-hidden [mask-image:linear-gradient(to_bottom,black_60%,transparent)]"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{msg.content.map((block, b) => {
|
||||
if (block.type !== "text" || !block.text) return null;
|
||||
// Plain typing reads as typed; anything structured keeps its rendering.
|
||||
const plain =
|
||||
!hasTuiTags(block.text) &&
|
||||
!isSkillContent(block.text) &&
|
||||
!isTaskNotification(block.text);
|
||||
return plain ? (
|
||||
<p key={b} className="whitespace-pre-wrap break-words leading-relaxed">
|
||||
{stripAnsi(block.text).trim()}
|
||||
</p>
|
||||
) : (
|
||||
renderTextBlock(block.text, b)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{images.length > 0 && (
|
||||
<div
|
||||
data-testid="chat-user-images"
|
||||
className="flex max-w-[85%] flex-wrap justify-end gap-2"
|
||||
>
|
||||
{images.map((name) => (
|
||||
<DropImage key={name} name={name} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{hasText && (
|
||||
<div
|
||||
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 ${
|
||||
long && !open
|
||||
? "max-h-40 overflow-hidden [mask-image:linear-gradient(to_bottom,black_60%,transparent)]"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{blocks.map((block, b) => {
|
||||
const text = block?.text;
|
||||
if (!text) return null;
|
||||
// Plain typing reads as typed; anything structured keeps its rendering.
|
||||
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 && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -540,8 +790,8 @@ const AssistantText = memo(function AssistantText({
|
||||
>
|
||||
{msg.model && <span>{formatModelName(msg.model)}</span>}
|
||||
{msg.usage && (
|
||||
<span>
|
||||
↓ {fmt(msg.usage.input_tokens)} · ↑ {fmt(msg.usage.output_tokens)}
|
||||
<span title={usageTitle(msg.usage)}>
|
||||
↓ {fmt(promptTokens(msg.usage))} · ↑ {fmt(msg.usage.output_tokens)}
|
||||
</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. */
|
||||
const KIND_ORDER = ["commands", "edits", "reads", "searches", "web", "agents", "tools"];
|
||||
|
||||
export function workLabel(blocks: TranscriptContent[]): string {
|
||||
const counts = new Map<string, number>();
|
||||
for (const b of blocks) {
|
||||
@@ -600,7 +852,9 @@ export function workLabel(blocks: TranscriptContent[]): string {
|
||||
counts.set(kind, (counts.get(kind) ?? 0) + 1);
|
||||
}
|
||||
if (!counts.size) return i18n.t("sessions:conversation.thinking");
|
||||
// Claude Desktop's order: commands, edits, reads, … and other tools last.
|
||||
const text = [...counts]
|
||||
.sort(([a], [b]) => KIND_ORDER.indexOf(a) - KIND_ORDER.indexOf(b))
|
||||
.map(([kind, count]) => i18n.t(`sessions:conversation.work.${kind}`, { count }))
|
||||
.join(", ");
|
||||
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||
@@ -751,8 +1005,11 @@ const MessageRow = memo(function MessageRow({
|
||||
</span>
|
||||
)}
|
||||
{msg.usage && (
|
||||
<span className="text-[10px] text-fg-muted font-mono inline-flex items-center gap-1">
|
||||
<span className="text-status-success/70">↓ {fmt(msg.usage.input_tokens)}</span>
|
||||
<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-syntax-number">↑ {fmt(msg.usage.output_tokens)}</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 { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MessageList, workLabel } from "../MessageList";
|
||||
import { MessageList, workLabel, workingOn } from "../MessageList";
|
||||
import type { TranscriptMessage } from "../../../lib/types";
|
||||
|
||||
function msg(partial: Partial<TranscriptMessage>): TranscriptMessage {
|
||||
@@ -76,5 +76,132 @@ describe("MessageList — sender attribution", () => {
|
||||
"Ran 2 commands, edited a file, read a file"
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
* (Claude Code or Codex) — this choice is immutable after creation. Either
|
||||
* mode's path field can be filled by typing, by the CwdAutocomplete
|
||||
* 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 route answers before the actual `git worktree add` runs, so this
|
||||
* component polls `GET /api/lanes/:id` until that finishes before running the
|
||||
@@ -327,6 +328,7 @@ export function AddLaneModal({
|
||||
const titleId = useId();
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const cancelRef = useRef<HTMLButtonElement>(null);
|
||||
const browseRef = useRef<HTMLButtonElement>(null);
|
||||
useDialog(open, panelRef, handleCancel, cancelRef);
|
||||
|
||||
// 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")}
|
||||
</label>
|
||||
<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
|
||||
inputId="add-lane-repo"
|
||||
value={sourceRepo}
|
||||
@@ -467,7 +476,12 @@ export function AddLaneModal({
|
||||
suggestions={cwdSuggestions}
|
||||
/>
|
||||
</div>
|
||||
<PathPickButton start={sourceRepo} onPick={setSourceRepo} showLabel />
|
||||
<PathPickButton
|
||||
start={sourceRepo}
|
||||
onPick={setSourceRepo}
|
||||
showLabel
|
||||
buttonRef={browseRef}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-[10px] text-fg-muted">
|
||||
{mode === "repo" ? t("addLaneRepoHintAdopt") : t("addLaneRepoHint")}
|
||||
|
||||
@@ -66,6 +66,7 @@ export function FolderBrowseModal({
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("browse.title")}
|
||||
data-tour="folder-browse"
|
||||
>
|
||||
<div className="border-b border-border p-3">
|
||||
<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`)
|
||||
* history — recent commits; opening one shows that commit's diff
|
||||
* 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
|
||||
* now), `branch` (`base...HEAD`, what this lane adds on top of its base) and
|
||||
* `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
|
||||
* 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.
|
||||
@@ -121,6 +125,7 @@ import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../Context
|
||||
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
|
||||
import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader";
|
||||
import { changeTree, treeFiles, type ChangeTreeDir } from "../../lib/changeTree";
|
||||
import { dragProjectPath } from "../../lib/promptAttach";
|
||||
import { commitGraph, type GraphRow } from "../../lib/commitGraph";
|
||||
import type {
|
||||
LaneChangeFile,
|
||||
@@ -277,6 +282,8 @@ function TreeNodes({
|
||||
data-tree-ignored={e.ignored || undefined}
|
||||
onClick={() => (e.dir ? onToggleDir(e.path) : onOpenFile(e.path))}
|
||||
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` }}
|
||||
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 ${
|
||||
@@ -792,18 +799,25 @@ function GraphCell({ row, width }: { row: GraphRow; width: number }) {
|
||||
export function LaneChangesPanel({
|
||||
laneId,
|
||||
session,
|
||||
source,
|
||||
onNewLane,
|
||||
full = false,
|
||||
wide = full,
|
||||
onDirtyChange,
|
||||
openRequest = null,
|
||||
}: {
|
||||
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). */
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
/** Show and act on that session's own worktree (`claude --worktree`)
|
||||
* instead of the workspace's folder. The Workspace keys the panel on it,
|
||||
* so a different session is a fresh panel. */
|
||||
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`. */
|
||||
onNewLane?: (branch?: string) => void;
|
||||
/** 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"]);
|
||||
// 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.
|
||||
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.
|
||||
const opOpen = !!(git?.available && git.merging);
|
||||
// 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.
|
||||
const comment = useCallback(
|
||||
(text: string) => {
|
||||
@@ -1232,6 +1246,11 @@ export function LaneChangesPanel({
|
||||
const show = (path: string) => {
|
||||
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.
|
||||
const showHistory = (path: string) => {
|
||||
void confirmDrop().then((ok) => {
|
||||
@@ -2053,6 +2072,8 @@ export function LaneChangesPanel({
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
{p}
|
||||
@@ -2105,6 +2126,7 @@ export function LaneChangesPanel({
|
||||
<LaneGitHeader
|
||||
laneId={laneId}
|
||||
session={session}
|
||||
source={source}
|
||||
git={git}
|
||||
onChanged={rereadAll}
|
||||
onNewLane={onNewLane}
|
||||
@@ -2487,7 +2509,9 @@ export function LaneChangesPanel({
|
||||
) : !data ? (
|
||||
<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>
|
||||
) : !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" ? (
|
||||
<p className="text-[11px] text-fg-muted">{t(`changes.empty.${range}`)}</p>
|
||||
) : mode === "overview" ? (
|
||||
@@ -2678,6 +2702,7 @@ export function LaneChangesPanel({
|
||||
<LaneConflictView
|
||||
laneId={laneId}
|
||||
session={session}
|
||||
source={source}
|
||||
path={openFile}
|
||||
onResolved={() => {
|
||||
refreshGit();
|
||||
|
||||
@@ -38,6 +38,7 @@ function Lines({ lines, className = "" }: { lines: string[]; className?: string
|
||||
export function LaneConflictView({
|
||||
laneId,
|
||||
session,
|
||||
source,
|
||||
path,
|
||||
onResolved,
|
||||
onEdit,
|
||||
@@ -45,6 +46,8 @@ export function LaneConflictView({
|
||||
laneId: number;
|
||||
/** Work in that session's own worktree (see `gitTarget`). */
|
||||
session?: string;
|
||||
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
|
||||
source?: number;
|
||||
path: string;
|
||||
/** The file left the conflict (resolved, or a whole side taken). */
|
||||
onResolved: () => void;
|
||||
@@ -65,7 +68,7 @@ export function LaneConflictView({
|
||||
setChoices([]);
|
||||
setError(null);
|
||||
api.lanes
|
||||
.gitFile(gitTarget(laneId, session), path, "work")
|
||||
.gitFile(gitTarget(laneId, session, source), path, "work")
|
||||
.then((r) => {
|
||||
if (!live) return;
|
||||
setFile(
|
||||
@@ -78,7 +81,7 @@ export function LaneConflictView({
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [laneId, session, path]);
|
||||
}, [laneId, session, source, path]);
|
||||
|
||||
const parts: ConflictPart[] | null = file?.usable ? parseConflicts(file.lines) : null;
|
||||
const total = parts ? parts.filter((p) => p.kind === "conflict").length : 0;
|
||||
@@ -102,15 +105,18 @@ export function LaneConflictView({
|
||||
run(async () => {
|
||||
if (!parts || !file?.sha) return;
|
||||
await api.lanes.gitSaveFile(
|
||||
gitTarget(laneId, session),
|
||||
gitTarget(laneId, session, source),
|
||||
path,
|
||||
applyChoices(parts, choices).join("\n"),
|
||||
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") =>
|
||||
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) =>
|
||||
setChoices((prev) => {
|
||||
|
||||
@@ -45,14 +45,7 @@ import { isSubmitEnter } from "../../lib/keys";
|
||||
import type { LaneGitFacts, LaneWorktree } from "../../lib/types";
|
||||
|
||||
type Action =
|
||||
| "fetch"
|
||||
| "pull"
|
||||
| "push"
|
||||
| "switch"
|
||||
| "branch-create"
|
||||
| "branch-delete"
|
||||
| "merge"
|
||||
| "publish";
|
||||
"fetch" | "pull" | "push" | "switch" | "branch-create" | "branch-delete" | "merge" | "publish";
|
||||
type RunBody = Parameters<typeof api.lanes.gitAction>[2];
|
||||
|
||||
const LOCAL_HOST = /^(localhost|127\.|\[::1\])/;
|
||||
@@ -95,6 +88,7 @@ function Btn({
|
||||
export function LaneGitBar({
|
||||
laneId,
|
||||
session,
|
||||
source,
|
||||
git,
|
||||
onChanged,
|
||||
onNewLane,
|
||||
@@ -103,6 +97,8 @@ export function LaneGitBar({
|
||||
laneId: number;
|
||||
/** Work in that session's own worktree (see `gitTarget`). */
|
||||
session?: string;
|
||||
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
|
||||
source?: number;
|
||||
git: LaneGitFacts;
|
||||
/** 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. */
|
||||
@@ -165,7 +161,7 @@ export function LaneGitBar({
|
||||
setBusy(action);
|
||||
setError(null);
|
||||
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
|
||||
// no longer shown.
|
||||
if (laneNow.current !== lane) return;
|
||||
@@ -218,7 +214,7 @@ export function LaneGitBar({
|
||||
|
||||
const refreshBranches = async () => {
|
||||
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;
|
||||
setBranches(r.branches);
|
||||
setWorktrees(r.worktrees || []);
|
||||
|
||||
@@ -36,7 +36,9 @@ const GIT_REFRESH_MS = 30_000;
|
||||
export function useLaneGitFacts(
|
||||
laneId: number,
|
||||
/** 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] {
|
||||
const [facts, setFacts] = useState<LaneGitFacts | null>(null);
|
||||
// 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;
|
||||
const read = () => {
|
||||
api.lanes
|
||||
.git(gitTarget(laneId, session))
|
||||
.git(gitTarget(laneId, session, source))
|
||||
.then((f) => {
|
||||
if (alive) setFacts(keepIfSame(f));
|
||||
})
|
||||
@@ -73,7 +75,7 @@ export function useLaneGitFacts(
|
||||
alive = false;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [laneId, session, nonce]);
|
||||
}, [laneId, session, source, nonce]);
|
||||
|
||||
return [facts, () => setNonce((n) => n + 1)];
|
||||
}
|
||||
@@ -322,20 +324,23 @@ export function LaneGitCounts({
|
||||
export function LaneGitRailCounts({
|
||||
laneId,
|
||||
session,
|
||||
source,
|
||||
onShowAll,
|
||||
}: {
|
||||
laneId: number;
|
||||
session?: string;
|
||||
source?: number;
|
||||
/** Opens the Changes panel from the file list's "… more files". */
|
||||
onShowAll?: () => void;
|
||||
}) {
|
||||
const [git] = useLaneGitFacts(laneId, session);
|
||||
const [git] = useLaneGitFacts(laneId, session, source);
|
||||
return git?.available ? <LaneGitCounts git={git} vertical onShowAll={onShowAll} /> : null;
|
||||
}
|
||||
|
||||
export function LaneGitHeader({
|
||||
laneId,
|
||||
session,
|
||||
source,
|
||||
git,
|
||||
onChanged,
|
||||
onNewLane,
|
||||
@@ -343,6 +348,8 @@ export function LaneGitHeader({
|
||||
laneId: number;
|
||||
/** Work in that session's own worktree (see `gitTarget`). */
|
||||
session?: string;
|
||||
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
|
||||
source?: number;
|
||||
git: LaneGitFacts | null;
|
||||
/** Called after a git bar action: the facts AND the panel's diff moved. */
|
||||
onChanged: () => void;
|
||||
@@ -374,6 +381,7 @@ export function LaneGitHeader({
|
||||
<LaneGitBar
|
||||
laneId={laneId}
|
||||
session={session}
|
||||
source={source}
|
||||
git={git}
|
||||
onChanged={onChanged}
|
||||
onNewLane={onNewLane}
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* 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,
|
||||
* `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
|
||||
* 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
|
||||
@@ -59,6 +60,8 @@ import { fmtCost, timeAgo } from "../../lib/format";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { useProjectTreePrefs } from "../../lib/projectTreePrefs";
|
||||
import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "../run/RunSetup";
|
||||
import { ChatAddMenu } from "../run/ChatAddMenu";
|
||||
import { addAttachments } from "../../lib/promptAttach";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { ProxyPicker, proxyArg, useAiProxies, useSessionProxy } from "../run/ProxyPicker";
|
||||
import { Select } from "../Select";
|
||||
@@ -115,12 +118,15 @@ function HomeBody({
|
||||
lane,
|
||||
onMenuAction,
|
||||
onRenamed,
|
||||
onSourcesChange,
|
||||
}: {
|
||||
lane: Lane;
|
||||
/** Row-menu items the page carries out (its modals); rename is done here. */
|
||||
onMenuAction: (action: WorkspaceMenuAction) => void;
|
||||
/** Called after a rename, so the page re-reads the lane list. */
|
||||
onRenamed?: () => void;
|
||||
/** Called with the sources after a folder or file is added or removed. */
|
||||
onSourcesChange?: (view: LaneSourcesView) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const navigate = useNavigate();
|
||||
@@ -149,6 +155,7 @@ function HomeBody({
|
||||
}
|
||||
}, [draftKey, prompt]);
|
||||
const [images, setImages] = useState<PromptImage[]>([]);
|
||||
const pickFiles = useRef<(() => void) | null>(null);
|
||||
const [proxy, setProxy] = useSessionProxy();
|
||||
const proxies = useAiProxies();
|
||||
// Claude and Codex, also before the catalog has loaded (as ProxyPicker).
|
||||
@@ -310,7 +317,9 @@ function HomeBody({
|
||||
const run = async (fn: () => Promise<LaneSourcesView>) => {
|
||||
setError(null);
|
||||
try {
|
||||
setView(await fn());
|
||||
const next = await fn();
|
||||
setView(next);
|
||||
onSourcesChange?.(next);
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(message(err));
|
||||
@@ -535,9 +544,18 @@ function HomeBody({
|
||||
images={images}
|
||||
onImagesChange={setImages}
|
||||
onBusyChange={setUploading}
|
||||
pickFiles={pickFiles}
|
||||
bare
|
||||
/>
|
||||
<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">
|
||||
<Select<string>
|
||||
variant="ghost"
|
||||
|
||||
@@ -8,13 +8,14 @@
|
||||
* preference kept in localStorage (`laneOrderStorage.ts`), never written to
|
||||
* 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
|
||||
* 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>
|
||||
*/
|
||||
|
||||
import { useState, type MouseEvent } from "react";
|
||||
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 { isSubmitEnter } from "../../lib/keys";
|
||||
|
||||
@@ -49,6 +50,7 @@ export function workspaceName(lane: Lane): string {
|
||||
export function WorkspaceRow({
|
||||
lane,
|
||||
index,
|
||||
readOnly,
|
||||
selected,
|
||||
expanded,
|
||||
renaming,
|
||||
@@ -63,6 +65,9 @@ export function WorkspaceRow({
|
||||
lane: Lane;
|
||||
/** Position in the list — the unit both reorder paths speak in. */
|
||||
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;
|
||||
expanded: boolean;
|
||||
renaming: boolean;
|
||||
@@ -82,7 +87,12 @@ export function WorkspaceRow({
|
||||
const openMenu = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
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 hoverBtn =
|
||||
@@ -140,22 +150,18 @@ export function WorkspaceRow({
|
||||
aria-current={selected ? "true" : undefined}
|
||||
// A double-click's second click renames instead of opening again.
|
||||
onClick={(e) => e.detail < 2 && onSelect()}
|
||||
onDoubleClick={() => {
|
||||
setDraft(lane.title || "");
|
||||
onRenameStart();
|
||||
}}
|
||||
onDoubleClick={startRename}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "F2") {
|
||||
e.preventDefault();
|
||||
setDraft(lane.title || "");
|
||||
onRenameStart();
|
||||
startRename();
|
||||
return;
|
||||
}
|
||||
if (!e.altKey || (e.key !== "ArrowUp" && e.key !== "ArrowDown")) return;
|
||||
e.preventDefault();
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
)}
|
||||
{!renaming && (
|
||||
{readOnly && <Eye aria-label={readOnly} className="mr-1 h-3 w-3 shrink-0 text-fg-muted" />}
|
||||
{!renaming && !readOnly && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -24,7 +24,9 @@
|
||||
* 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,
|
||||
* 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>
|
||||
*/
|
||||
|
||||
@@ -220,6 +222,7 @@ export function WorkspaceSessionList({
|
||||
selectedSessionId = null,
|
||||
onMatches,
|
||||
hidden = false,
|
||||
readOnly = false,
|
||||
}: {
|
||||
lane: Lane;
|
||||
/** Every live dashboard pane, from `GET /api/run`. */
|
||||
@@ -235,6 +238,8 @@ export function WorkspaceSessionList({
|
||||
onMatches?: (count: number) => void;
|
||||
/** Mounted only to load and count, during a search that hides the row. */
|
||||
hidden?: boolean;
|
||||
/** A workspace this user may only read. */
|
||||
readOnly?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const navigate = useNavigate();
|
||||
@@ -315,7 +320,7 @@ export function WorkspaceSessionList({
|
||||
};
|
||||
}, [orphanIds]);
|
||||
const fresh =
|
||||
sessions === null || q
|
||||
sessions === null || q || readOnly
|
||||
? []
|
||||
: [
|
||||
...laneRuns.filter(
|
||||
@@ -492,6 +497,7 @@ export function WorkspaceSessionList({
|
||||
// A double-click's second click renames; it must not
|
||||
// navigate (a second history entry) on top of the first.
|
||||
if (e.detail > 1) return;
|
||||
if (readOnly) return navigate(`/sessions/${encodeURIComponent(s.id)}`);
|
||||
navigate(
|
||||
run
|
||||
? `/workspace?lane=${lane.id}&run=${encodeURIComponent(run.id)}`
|
||||
@@ -501,19 +507,19 @@ export function WorkspaceSessionList({
|
||||
run && (e.ctrlKey || e.metaKey) ? { state: { split: true } } : undefined
|
||||
);
|
||||
}}
|
||||
onDoubleClick={() => startRename(s)}
|
||||
onDoubleClick={() => !readOnly && startRename(s)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "F2") {
|
||||
if (e.key === "F2" && !readOnly) {
|
||||
e.preventDefault();
|
||||
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)}${
|
||||
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] ${
|
||||
selected
|
||||
? "bg-accent/10 text-fg-primary"
|
||||
@@ -539,21 +545,23 @@ export function WorkspaceSessionList({
|
||||
</span>
|
||||
<AiToolIcon
|
||||
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
|
||||
type="button"
|
||||
data-testid={`workspace-session-archive-${s.id}`}
|
||||
onClick={() => void archive(s)}
|
||||
title={t("tree.archiveSession")}
|
||||
aria-label={t("tree.archiveSession")}
|
||||
// Invisible, not `hidden`: a keyboard user must be able to
|
||||
// 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>
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`workspace-session-archive-${s.id}`}
|
||||
onClick={() => void archive(s)}
|
||||
title={t("tree.archiveSession")}
|
||||
aria-label={t("tree.archiveSession")}
|
||||
// Invisible, not `hidden`: a keyboard user must be able to
|
||||
// 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>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -582,6 +582,19 @@ describe("AddLaneModal — folder browse", () => {
|
||||
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 () => {
|
||||
noNativePicker();
|
||||
vi.mocked(api.lanes.browse).mockResolvedValue({
|
||||
|
||||
@@ -255,6 +255,36 @@ describe("LaneChangesPanel", () => {
|
||||
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 () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
* 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
|
||||
* 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
|
||||
* account and survives a reload; `done` ends it.
|
||||
* @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 }
|
||||
> = {
|
||||
add: {
|
||||
// The sidebar's +; folded to a rail, its tree icon (which unfolds it).
|
||||
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),
|
||||
},
|
||||
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"),
|
||||
info: true,
|
||||
},
|
||||
@@ -113,7 +116,19 @@ export function OnboardingTour() {
|
||||
const moved = def.next?.();
|
||||
if (moved && moved !== step) return write(moved);
|
||||
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;
|
||||
setBox((prev) => (prev && d && prev.right === d.right ? prev : d));
|
||||
// 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);
|
||||
void markOnboarded();
|
||||
startTour();
|
||||
navigate("/workspace");
|
||||
navigate("/");
|
||||
};
|
||||
|
||||
// Ctrl/⌘+Enter continues, like Orca.
|
||||
|
||||
@@ -207,7 +207,7 @@ describe("the tour", () => {
|
||||
it("follows the real UI: add → folder → create → prompt", async () => {
|
||||
const spy = boxed();
|
||||
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 {
|
||||
startTour();
|
||||
render(
|
||||
@@ -239,7 +239,7 @@ describe("the tour", () => {
|
||||
|
||||
it("goes back to Add workspace when the dialog closes with nothing made", async () => {
|
||||
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 {
|
||||
localStorage.setItem("agenthub.tour", "create");
|
||||
render(
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -14,8 +14,9 @@
|
||||
* 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
|
||||
* conversation laid OVER the terminal, which stays mounted and attached under
|
||||
* it. Chat or terminal is the viewer's own Chat UI setting (Settings →
|
||||
* Appearance, `lib/chatUi.ts`, per account) — one choice for every pane.
|
||||
* it. Chat or terminal is each SESSION's own (`useSessionChat`, the toggle
|
||||
* 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>
|
||||
*/
|
||||
|
||||
@@ -28,7 +29,7 @@ import type { Lane } from "../../lib/types";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { TerminalView } from "./TerminalView";
|
||||
import { PaneChat } from "./PaneChat";
|
||||
import { useChatUi } from "../../lib/chatUi";
|
||||
import { useSessionChat } from "../../lib/chatUi";
|
||||
|
||||
const CHAT_TOOLS = new Set(["claude", "codex"]);
|
||||
|
||||
@@ -50,6 +51,8 @@ export interface LaneConsolePaneProps {
|
||||
onRunExit?: () => void;
|
||||
/** The terminal takes the keyboard when it appears (default). */
|
||||
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). */
|
||||
@@ -68,6 +71,7 @@ export function LaneConsolePane({
|
||||
onNewSession,
|
||||
onRunExit,
|
||||
autoFocus,
|
||||
solo = false,
|
||||
}: LaneConsolePaneProps) {
|
||||
const { t } = useTranslation("run");
|
||||
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
|
||||
// workspace's; the workspace's with no run.
|
||||
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.
|
||||
const chatSession = handle
|
||||
? (activeRuns?.items.find((r) => r.id === handle.id)?.sessionId ?? handle.sessionId)
|
||||
: null;
|
||||
const chat = useSessionChat(chatSession);
|
||||
const chatable = !!handle && !!chatSession && CHAT_TOOLS.has(handle.tool);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -192,10 +196,12 @@ export function LaneConsolePane({
|
||||
{chatable && chat && (
|
||||
<div className="absolute inset-0 z-20 flex flex-col bg-surface-0">
|
||||
<PaneChat
|
||||
runId={handle.id}
|
||||
run={handle}
|
||||
sessionId={chatSession}
|
||||
tool={handle.tool}
|
||||
cwd={handle.cwd || currentLane?.cwd || ""}
|
||||
laneId={laneId}
|
||||
laneCwd={currentLane?.cwd || ""}
|
||||
outline={solo}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -5,84 +5,319 @@
|
||||
* above a prompt box. It lies OVER the pane's terminal, which stays mounted and
|
||||
* attached underneath — a message is sent through that terminal
|
||||
* (`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
|
||||
* terminal; the hint under the box says so.
|
||||
* nothing new reaches the server. The box carries Claude Desktop's controls:
|
||||
* 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>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowUp } from "lucide-react";
|
||||
import { ArrowUp, FileText, Square } from "lucide-react";
|
||||
import { ConversationView } from "../conversation/ConversationView";
|
||||
import { PromptEditor, promptWithImages, type PromptImage } from "./RunSetup";
|
||||
import { submitToRun } from "../../lib/panePaste";
|
||||
import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "./RunSetup";
|
||||
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 { 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({
|
||||
runId,
|
||||
run,
|
||||
sessionId,
|
||||
tool,
|
||||
cwd,
|
||||
laneId = null,
|
||||
laneCwd = "",
|
||||
outline = false,
|
||||
}: {
|
||||
runId: string;
|
||||
run: Pick<RunHandle, "id" | "tool" | "model" | "effort" | "permissionMode">;
|
||||
sessionId: string;
|
||||
tool: 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 runId = run.id;
|
||||
const claude = run.tool === "claude";
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [images, setImages] = useState<PromptImage[]>([]);
|
||||
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);
|
||||
|
||||
// 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 = () => {
|
||||
if (!text || uploading) return;
|
||||
if (!submitToRun(runId, text)) {
|
||||
toast.error(t("chat.notSent"));
|
||||
return;
|
||||
}
|
||||
if (!command(text)) return;
|
||||
setFollow((n) => n + 1);
|
||||
setPrompt("");
|
||||
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
|
||||
setImages([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="pane-chat" className="flex min-h-0 flex-1 flex-col gap-2 pb-3">
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<ConversationView sessionId={sessionId} fill />
|
||||
</div>
|
||||
<div className="mx-4">
|
||||
<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">
|
||||
<PromptEditor
|
||||
bare
|
||||
images={images}
|
||||
onImagesChange={setImages}
|
||||
onBusyChange={setUploading}
|
||||
value={prompt}
|
||||
onChange={setPrompt}
|
||||
onSubmit={send}
|
||||
placeholder={t("chat.placeholder")}
|
||||
rows={2}
|
||||
slash={tool === "claude"}
|
||||
fileCwd={cwd}
|
||||
lane={runId}
|
||||
<div className="relative flex min-h-0 flex-1">
|
||||
<div data-testid="pane-chat" className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 pb-3">
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden">
|
||||
<ConversationView
|
||||
sessionId={sessionId}
|
||||
fill
|
||||
live
|
||||
follow={follow}
|
||||
onOpenFile={(path) => {
|
||||
// The workspace's Changes panel when the file is in its folder;
|
||||
// else this view's own preview, when it has room for one.
|
||||
if (laneId !== null && openInChanges(laneId, cwd, path)) return;
|
||||
if (outline) setPreview(path);
|
||||
}}
|
||||
/>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<span className="text-[11px] text-fg-muted">{t("chat.hint")}</span>
|
||||
{outline && !outlineOpen && !preview && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="pane-chat-send"
|
||||
onClick={send}
|
||||
disabled={uploading || !text}
|
||||
aria-label={t("chat.send")}
|
||||
title={t("chat.send")}
|
||||
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"
|
||||
data-testid="chat-outline-show"
|
||||
onClick={() => openOutline(true)}
|
||||
title={t("chat.outline.show")}
|
||||
aria-label={t("chat.outline.show")}
|
||||
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"
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
{activity.outputs.length}
|
||||
</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>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
ChevronDown,
|
||||
Columns2,
|
||||
GripVertical,
|
||||
MessageSquare,
|
||||
Minimize2,
|
||||
Rows2,
|
||||
SquareTerminal,
|
||||
@@ -55,6 +56,7 @@ import {
|
||||
} from "../../lib/paneLayout";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { RunDot, type RunState } from "./ConsoleTabs";
|
||||
import { setSessionChat, useSessionChat } from "../../lib/chatUi";
|
||||
|
||||
export const PANE_DRAG_TYPE = "application/x-agenthub-pane";
|
||||
/** The slot being dragged: `dataTransfer` data cannot be read during dragover. */
|
||||
@@ -191,9 +193,37 @@ function PaneMenu({
|
||||
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";
|
||||
|
||||
/** A pane's 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. */
|
||||
/** Chat UI ⇄ terminal for a Claude/Codex pane — this session's own view
|
||||
* (`lib/chatUi.ts`); a session with no id yet has no chat to show. */
|
||||
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({
|
||||
slot,
|
||||
shown,
|
||||
@@ -222,6 +252,7 @@ export function SplitMenus({
|
||||
const full = shown.length >= MAX_PANES;
|
||||
return (
|
||||
<>
|
||||
<ChatToggle run={runs.find((x) => (x.slot ?? 0) === slot)} />
|
||||
{(["right", "bottom"] as const).map((edge) => (
|
||||
<PaneMenu
|
||||
key={edge}
|
||||
@@ -446,6 +477,8 @@ export function PaneGrid({
|
||||
key={slot}
|
||||
data-testid={`pane-${slot}`}
|
||||
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"}
|
||||
style={r ? pct(r) : undefined}
|
||||
onPointerDownCapture={() => r && focus(slot)}
|
||||
|
||||
@@ -6,8 +6,12 @@
|
||||
* box (`WorkspaceHome.tsx`):
|
||||
* - `PromptEditor` — the prompt textarea with `/` (commands and skills,
|
||||
* Claude only) and `@` (`GET /api/run/files`) autocomplete, Shift/Alt+Enter
|
||||
* newlines, and pasted or dropped files (images as thumbnails, any other
|
||||
* as a name chip) attached by their saved paths (`POST /api/run/drop-file`);
|
||||
* newlines, and pasted, dropped or picked files (`pickFiles`, a composer's
|
||||
* + 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;
|
||||
* - `ModelPicker` — a tool's own model list (`GET /api/ai-tools/:id/models`)
|
||||
* 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>
|
||||
*/
|
||||
|
||||
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 {
|
||||
Check,
|
||||
@@ -42,9 +56,16 @@ import {
|
||||
} from "../../lib/api";
|
||||
import type { CwdSuggestion, CcMdItem } from "../../lib/api";
|
||||
import type { LaneTool } from "../../lib/types";
|
||||
import { Select } from "../Select";
|
||||
import { Select, menuRoom } from "../Select";
|
||||
import { ContextMenu, MenuItem, menuPoint, type MenuPoint } from "../ContextMenu";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { ImageLightbox } from "../ImageLightbox";
|
||||
import {
|
||||
ATTACH_MIME,
|
||||
addAttachments,
|
||||
droppedAttachment,
|
||||
rememberUploads,
|
||||
} from "../../lib/promptAttach";
|
||||
|
||||
export interface SlashCommand {
|
||||
name: string;
|
||||
@@ -158,13 +179,16 @@ interface PromptEditorProps {
|
||||
* sends them with the text through `promptWithImages`. */
|
||||
images: 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;
|
||||
/** An image upload started (true) or settled (false): the caller holds its
|
||||
* send button meanwhile, or the prompt would go out without the image. */
|
||||
onBusyChange?: (busy: boolean) => void;
|
||||
/** The workspace (id, or a pane's run id) whose panes read the files. */
|
||||
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
|
||||
@@ -177,6 +201,9 @@ export function promptWithImages(text: string, images: PromptImage[]): string {
|
||||
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({
|
||||
value,
|
||||
onChange,
|
||||
@@ -190,9 +217,18 @@ export function PromptEditor({
|
||||
bare,
|
||||
onBusyChange,
|
||||
lane,
|
||||
pickFiles,
|
||||
}: PromptEditorProps) {
|
||||
const { t } = useTranslation("run");
|
||||
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 [active, setActive] = useState(0);
|
||||
const [files, setFiles] = useState<string[]>([]);
|
||||
@@ -324,7 +360,7 @@ export function PromptEditor({
|
||||
const busyRef = useRef(onBusyChange);
|
||||
busyRef.current = onBusyChange;
|
||||
useEffect(() => busyRef.current?.(uploading), [uploading]);
|
||||
const attachImages = (list: FileList | null | undefined) => {
|
||||
const attachImages = (list: FileList | File[] | null | undefined) => {
|
||||
const picked = Array.from(list ?? []);
|
||||
if (picked.length === 0) return false;
|
||||
setUpload("uploading");
|
||||
@@ -336,6 +372,7 @@ export function PromptEditor({
|
||||
url: f.type.startsWith("image/") ? URL.createObjectURL(f) : undefined,
|
||||
name: f.name,
|
||||
}));
|
||||
rememberUploads(added.map((a) => ({ path: a.path, name: a.name, image: !!a.url })));
|
||||
onImagesChange((prev) => [...prev, ...added]);
|
||||
})
|
||||
.catch(() => setUpload("failed"));
|
||||
@@ -345,12 +382,11 @@ export function PromptEditor({
|
||||
if (img.url) URL.revokeObjectURL(img.url);
|
||||
onImagesChange((prev) => prev.filter((x) => x !== img));
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!viewing) return;
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setViewing(null);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [viewing]);
|
||||
const closeViewer = useCallback(() => setViewing(null), []);
|
||||
// The composer's own "Upload or add files" opens this picker; what it picks
|
||||
// takes the same path as a paste or a drop.
|
||||
const fileInput = useRef<HTMLInputElement | null>(null);
|
||||
if (pickFiles) pickFiles.current = () => fileInput.current?.click();
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (kind && count > 0) {
|
||||
@@ -454,6 +490,19 @@ export function PromptEditor({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{pickFiles && (
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
multiple
|
||||
hidden
|
||||
data-testid="prompt-file-input"
|
||||
onChange={(e) => {
|
||||
attachImages(e.target.files);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<textarea
|
||||
ref={taRef}
|
||||
value={value}
|
||||
@@ -467,8 +516,28 @@ export function PromptEditor({
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={(e) => {
|
||||
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) => {
|
||||
const att = droppedAttachment(e.dataTransfer);
|
||||
if (att) {
|
||||
e.preventDefault();
|
||||
onImagesChange((prev) => addAttachments(prev, [att]));
|
||||
return;
|
||||
}
|
||||
if (attachImages(e.dataTransfer?.files)) e.preventDefault();
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
@@ -476,7 +545,7 @@ export function PromptEditor({
|
||||
spellCheck={false}
|
||||
className={
|
||||
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"
|
||||
}
|
||||
/>
|
||||
@@ -545,20 +614,7 @@ export function PromptEditor({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{viewing && (
|
||||
<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>
|
||||
)}
|
||||
{viewing?.url && <ImageLightbox src={viewing.url} alt={viewing.name} onClose={closeViewer} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -673,7 +729,10 @@ export function CwdAutocomplete({
|
||||
/>
|
||||
</div>
|
||||
{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 ? (
|
||||
<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);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
// Below the chip unless the viewport has more room above it.
|
||||
const r = ref.current?.getBoundingClientRect();
|
||||
if (r) setUp(window.innerHeight - r.bottom < 360 && r.top > window.innerHeight - r.bottom);
|
||||
// Below the chip unless its pane (or the viewport) has more room above it.
|
||||
if (ref.current) {
|
||||
const { above, below } = menuRoom(ref.current, ref.current.getBoundingClientRect());
|
||||
setUp(below < 360 && above > below);
|
||||
}
|
||||
const away = (e: MouseEvent) => {
|
||||
if (!ref.current?.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
|
||||
@@ -236,6 +236,9 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
fontSize: 13,
|
||||
cursorBlink: true,
|
||||
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,
|
||||
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:
|
||||
// `onData` drops input while the socket is connecting or reconnecting,
|
||||
// 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]
|
||||
);
|
||||
|
||||
@@ -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 { 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 { LaneConsolePane } from "../LaneConsolePane";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { Lane } from "../../../lib/types";
|
||||
import { __resetAiTools } from "../../../hooks/useAiTools";
|
||||
import { __resetChatUi } from "../../../lib/chatUi";
|
||||
import { VIEWS_KEY, __resetChatUi, saveChatUi, setSessionChat } from "../../../lib/chatUi";
|
||||
|
||||
vi.mock("../TerminalView", () => ({
|
||||
// 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", () => ({
|
||||
api: {
|
||||
me: { prefs: vi.fn().mockResolvedValue({ prefs: {}, onboardedAt: null }) },
|
||||
me: {
|
||||
prefs: vi.fn().mockResolvedValue({ prefs: {}, onboardedAt: null }),
|
||||
savePrefs: vi.fn(),
|
||||
},
|
||||
lanes: {
|
||||
ensure: vi.fn(),
|
||||
action: vi.fn(),
|
||||
@@ -171,7 +174,41 @@ describe("LaneConsolePane", () => {
|
||||
render(<LaneConsolePane {...baseProps()} activeRuns={{ items: [live] }} />);
|
||||
expect(await screen.findByTestId("pane-chat")).toHaveAttribute("data-session-id", "s-1");
|
||||
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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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:
|
||||
* `PromptEditor`'s `/` and `@` autocomplete (Enter picks while the list is
|
||||
* 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,
|
||||
* 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,
|
||||
@@ -13,17 +16,19 @@
|
||||
*/
|
||||
|
||||
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 i18n from "i18next";
|
||||
import {
|
||||
ModelMenu,
|
||||
ModelPicker,
|
||||
PASTE_AS_FILE_CHARS,
|
||||
PromptEditor,
|
||||
promptWithImages,
|
||||
type PromptImage,
|
||||
} from "../RunSetup";
|
||||
import { api } from "../../../lib/api";
|
||||
import { dragProjectPath, recentUploads } from "../../../lib/promptAttach";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
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", () => {
|
||||
it("Alt+Enter inserts a newline and does not send", () => {
|
||||
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");
|
||||
|
||||
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" });
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
|
||||
@@ -212,6 +246,38 @@ describe("PromptEditor — newlines and images", () => {
|
||||
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 () => {
|
||||
vi.mocked(api.run.dropFile).mockResolvedValueOnce({ path: "/tmp/agenthub-drops-1/b-spec.pdf" });
|
||||
function Harness() {
|
||||
@@ -237,13 +303,70 @@ describe("PromptEditor — newlines and images", () => {
|
||||
fireEvent.drop(box(), { dataTransfer: { files: [pdf] } });
|
||||
expect(await screen.findByText("spec.pdf")).toBeTruthy();
|
||||
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", () => {
|
||||
renderEditor("");
|
||||
fireEvent.paste(box(), { clipboardData: { files: [] } });
|
||||
fireEvent.paste(box(), {
|
||||
clipboardData: { files: [], getData: () => "a short note" },
|
||||
});
|
||||
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", () => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* @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
|
||||
* 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
|
||||
* 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
|
||||
@@ -20,6 +20,7 @@ import { Monitor, Moon, RotateCcw, Sun } from "lucide-react";
|
||||
import { SettingsRow, SettingsSection, SettingsSegmented } from "./SettingsRow";
|
||||
import { PetSettings } from "./PetSettings";
|
||||
import { ChatUiSettings } from "./ChatUiSettings";
|
||||
import { Select } from "../Select";
|
||||
import { THEMES, useTheme, type Theme, type ThemeMode } from "../../hooks/useTheme";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { resetOnboarding } from "../../lib/userPrefs";
|
||||
@@ -98,6 +99,8 @@ export function AppearanceSettings() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ChatUiSettings />
|
||||
|
||||
<SettingsSection
|
||||
id="language"
|
||||
title={t("language.title")}
|
||||
@@ -128,8 +131,9 @@ export function AppearanceSettings() {
|
||||
label={t("terminalFont.label")}
|
||||
description={termFont === "system" ? t("terminalFont.systemNote") : undefined}
|
||||
control={
|
||||
<SettingsSegmented
|
||||
ariaLabel={t("terminalFont.title")}
|
||||
// A dropdown: five font names overflow a segmented row.
|
||||
<Select<TerminalFont>
|
||||
label={t("terminalFont.title")}
|
||||
value={termFont}
|
||||
onChange={pickTermFont}
|
||||
options={(Object.keys(TERMINAL_FONTS) as TerminalFont[]).map((f) => ({
|
||||
@@ -150,8 +154,6 @@ export function AppearanceSettings() {
|
||||
</p>
|
||||
</SettingsSection>
|
||||
|
||||
<ChatUiSettings />
|
||||
|
||||
<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>
|
||||
<SettingsRow
|
||||
|
||||
@@ -88,16 +88,17 @@ describe("AppearanceSettings", () => {
|
||||
it("picks the terminal font, JetBrains Mono by default, and warns on System", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AppearanceSettings />);
|
||||
const group = screen.getByRole("radiogroup", { name: "Terminal font" });
|
||||
expect(within(group).getByRole("radio", { name: "JetBrains Mono" })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true"
|
||||
);
|
||||
await user.click(within(group).getByRole("radio", { name: "Maple Mono (rounded)" }));
|
||||
const trigger = screen.getByRole("button", { name: /Terminal font/ });
|
||||
expect(trigger).toHaveTextContent("JetBrains Mono");
|
||||
const pick = async (name: string) => {
|
||||
await user.click(screen.getByRole("button", { name: /Terminal font/ }));
|
||||
await user.click(screen.getByRole("option", { name }));
|
||||
};
|
||||
await pick("Maple Mono (rounded)");
|
||||
expect(localStorage.getItem("agenthub.terminal.font")).toBe("maple");
|
||||
expect(screen.getByTestId("terminal-font-preview").style.fontFamily).toContain("Maple Mono");
|
||||
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(screen.getByText(/may lack some Vietnamese letters/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user