fix: v2.7.4 — Changes matches JetBrains (#72), the chat says when Claude waits on a trust prompt (#73), Vietnamese Telex in Safari

- Changes (#72): a file git reports `MM` whose working copy equals HEAD again
  (a staged change since undone on disk) is no longer listed — JetBrains'
  GitChangesCollector drops it the same way. Mode-only changes stay listed.
  statusCounts (the dirty-tree safety checks) is unchanged.
- Chat (#73): Claude Code runs no hook before a folder is trusted, so a new
  pane's session has no row; the chat now says the session has not started,
  offers Open terminal, and asks before typing a message or /model //effort
  into the pane (an Enter would pick the trust prompt's "No, exit").
- Terminal: Safari + macOS's own Vietnamese Telex replaces the whole word on
  each key (insertReplacementText), which xterm.js ignores — only each word's
  first letter reached the pane. The edit is now sent as DELs + the new tail.
- Also: Ctrl+V pastes a screenshot into the terminal as a file path; the
  folder browser fallback lists files for a file field (`files=1`); the macOS
  native picker opens in front of the browser; a hidden tab is marked while a
  chat waits or once a turn ended; chat live line shows running calls, turn
  time and tokens, and "Done in …".
This commit is contained in:
2026-10-12 15:40:47 +07:00
parent 5d8fc9bb98
commit 46e3899945
44 changed files with 1329 additions and 297 deletions
+8 -16
View File
@@ -6,9 +6,9 @@
* user chose. A browser's picker cannot give a page that path, so the dialog
* opens on the server's desktop — which is only this user's screen when the
* browser runs on the same machine. Anywhere else (a LAN browser, a server
* with no desktop or no zenity/kdialog) a folder falls back to the in-app
* `FolderBrowseModal` (as it does on any other failure), and a file to typing
* the path.
* with no desktop or no zenity/kdialog) it falls back to the in-app
* `FolderBrowseModal` (as it does on any other failure) — for a file field
* too, which then lists files and picks one on click.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -16,12 +16,8 @@ import { useState, type Ref } from "react";
import { useTranslation } from "react-i18next";
import { FolderOpen } from "lucide-react";
import { api } from "../lib/api";
import { toast } from "../lib/toast";
import { FolderBrowseModal } from "./lanes/FolderBrowseModal";
/** Server answers that mean "no native picker for this browser" — not a fault. */
const FALLBACK = new Set(["EREMOTE", "EBADORIGIN", "ENODIALOG"]);
export function PathPickButton({
kind = "dir",
start,
@@ -47,15 +43,10 @@ export function PathPickButton({
try {
const r = await api.lanes.pickPath(kind, start?.trim() || undefined);
if (r.path) onPick(r.path);
} catch (err) {
const code = (err as { code?: string }).code;
// A folder always has the in-app browser to fall back on, whatever went
// wrong with the native one (a crashed dialog, one already open).
if (kind === "dir") setFallback(true);
else if (code && FALLBACK.has(code)) toast.info(t("browse.typePath"));
else {
toast.error(err instanceof Error ? err.message : String(err));
}
} catch {
// The in-app browser is the fallback whatever went wrong with the
// native one: a LAN browser (EREMOTE), no desktop, a crashed dialog.
setFallback(true);
} finally {
setBusy(false);
}
@@ -80,6 +71,7 @@ export function PathPickButton({
<div className="contents" onClick={(e) => e.stopPropagation()}>
<FolderBrowseModal
open={fallback}
kind={kind}
initialPath={start?.trim() || undefined}
onSelect={onPick}
onClose={() => setFallback(false)}
+5 -84
View File
@@ -1,7 +1,7 @@
/**
* @file SplashScreen.tsx
* @description Branding splash shown once per browser session on app load. A
* dark-tech "constellation" overlay built around the node-graph brand mark:
* dark-tech "constellation" overlay built around the SmartGift brand mark:
* a time-aware greeting, a bold (localized) tagline, and two subtexts reveal
* in a staggered cascade. The tagline and the subtext pair are picked at
* random (per mount) from localized pools in `splash.json`, so the copy is
@@ -143,7 +143,7 @@ export function SplashScreen() {
<div className="splash-grain" aria-hidden="true" />
<div className="splash-content">
{/* Brand mark - the node-graph hexagon, enlarged and animated */}
{/* Brand mark - the SmartGift logo */}
<div className="splash-mark" aria-hidden="true">
<span className="splash-mark-glow" />
<BrandMark />
@@ -167,74 +167,10 @@ export function SplashScreen() {
);
}
/** The hexagon node-graph brand mark (mirrors public/favicon.svg), scaled up
* with animated connector lines and pulsing outer nodes. */
/** The SmartGift mark — the same file as the favicon, local so the splash
* needs no network. */
function BrandMark() {
return (
<svg viewBox="0 0 32 32" width="96" height="96" className="splash-svg">
<defs>
<linearGradient id="splashBg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#6366f1" />
<stop offset="100%" stopColor="#818cf8" />
</linearGradient>
<linearGradient id="splashGlow" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#a5b4fc" stopOpacity="0.95" />
<stop offset="100%" stopColor="#c7d2fe" stopOpacity="0.6" />
</linearGradient>
</defs>
<polygon
className="splash-hex"
points="16,2 28,9 28,23 16,30 4,23 4,9"
fill="url(#splashBg)"
/>
<circle cx="16" cy="16" r="3" fill="white" opacity="0.95" />
<line
className="splash-line"
x1="16"
y1="13"
x2="16"
y2="7"
stroke="white"
strokeWidth="1.5"
strokeLinecap="round"
/>
<line
className="splash-line splash-line-2"
x1="18.6"
y1="17.5"
x2="24"
y2="20.5"
stroke="white"
strokeWidth="1.5"
strokeLinecap="round"
/>
<line
className="splash-line splash-line-3"
x1="13.4"
y1="17.5"
x2="8"
y2="20.5"
stroke="white"
strokeWidth="1.5"
strokeLinecap="round"
/>
<circle className="splash-node" cx="16" cy="6" r="1.8" fill="url(#splashGlow)" />
<circle
className="splash-node splash-node-2"
cx="24.5"
cy="21"
r="1.8"
fill="url(#splashGlow)"
/>
<circle
className="splash-node splash-node-3"
cx="7.5"
cy="21"
r="1.8"
fill="url(#splashGlow)"
/>
</svg>
);
return <img src="/favicon.svg" alt="" width={96} height={96} className="splash-svg" />;
}
/** Faint background constellation: a handful of nodes joined by thin lines that
@@ -351,13 +287,6 @@ const SPLASH_CSS = `
animation: splashGlowPulse 2.6s ease-in-out infinite;
}
.splash-svg { position: relative; display: block; filter: drop-shadow(0 8px 28px rgba(99, 102, 241, 0.45)); }
.splash-hex { transform-origin: center; animation: splashHexBreathe 3.4s ease-in-out infinite; }
.splash-line { stroke-dasharray: 8; stroke-dashoffset: 8; opacity: 0.75; animation: splashDraw 0.7s ease 0.5s forwards; }
.splash-line-2 { animation-delay: 0.62s; }
.splash-line-3 { animation-delay: 0.74s; }
.splash-node { opacity: 0; animation: splashNodePop 0.5s ease 0.85s forwards, splashNodePulse 2.4s ease-in-out 1.4s infinite; }
.splash-node-2 { animation-delay: 0.98s, 1.6s; }
.splash-node-3 { animation-delay: 1.1s, 1.8s; }
.splash-greeting {
display: flex;
@@ -435,11 +364,7 @@ const SPLASH_CSS = `
@keyframes splashDrift { from { transform: translate3d(0, 0, 0); } to { transform: translate3d(-24px, -16px, 0) scale(1.05); } }
@keyframes splashTwinkle { 0%, 100% { opacity: 0.25; } 50% { opacity: 0.7; } }
@keyframes splashMark { from { opacity: 0; transform: translateY(14px) scale(0.82); } to { opacity: 1; transform: translateY(0) scale(1); } }
@keyframes splashHexBreathe { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.04); } }
@keyframes splashGlowPulse { 0%, 100% { opacity: 0.55; transform: scale(0.96); } 50% { opacity: 1; transform: scale(1.06); } }
@keyframes splashDraw { to { stroke-dashoffset: 0; } }
@keyframes splashNodePop { from { opacity: 0; transform: scale(0); } to { opacity: 1; transform: scale(1); } }
@keyframes splashNodePulse { 0%, 100% { opacity: 0.7; } 50% { opacity: 1; } }
@keyframes splashRise { from { opacity: 0; transform: translateY(18px); } to { opacity: 1; transform: translateY(0); } }
@media (prefers-reduced-motion: reduce) {
@@ -449,9 +374,6 @@ const SPLASH_CSS = `
.splash-star,
.splash-mark,
.splash-mark-glow,
.splash-hex,
.splash-line,
.splash-node,
.splash-dot,
.splash-greeting,
.splash-tagline,
@@ -460,7 +382,6 @@ const SPLASH_CSS = `
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
opacity: 1 !important;
stroke-dashoffset: 0 !important;
transform: none !important;
}
}
@@ -1,14 +1,14 @@
/**
* @file PathPickButton.test.tsx
* @description Unit tests for the Browse button beside path fields: a chosen
* path fills the field, a Cancel changes nothing, a folder falls back to the
* in-app browser on ANY native-picker failure, and a file falls back to a
* "type the path" hint only when no picker exists for this browser.
* path fills the field, a Cancel changes nothing, and a folder or a file falls
* back to the in-app browser on ANY native-picker failure (a LAN browser's
* EREMOTE included).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { api } from "../../lib/api";
import { toast } from "../../lib/toast";
@@ -55,15 +55,24 @@ describe("PathPickButton", () => {
expect(toast.error).not.toHaveBeenCalled();
});
it("hints to type a file path when no picker exists, and reports any other failure", async () => {
vi.mocked(api.lanes.pickPath).mockRejectedValueOnce(fail("ENODIALOG"));
vi.mocked(api.lanes.pickPath).mockRejectedValueOnce(fail("EDIALOG"));
render(<PathPickButton kind="file" onPick={vi.fn()} />);
const button = screen.getByRole("button");
await userEvent.click(button);
await waitFor(() => expect(toast.info).toHaveBeenCalledTimes(1));
await userEvent.click(button);
await waitFor(() => expect(toast.error).toHaveBeenCalledWith("EDIALOG"));
it("opens the in-app browser for a file too, and a click on a file picks it", async () => {
const onPick = vi.fn();
vi.mocked(api.lanes.pickPath).mockRejectedValue(fail("EREMOTE"));
vi.mocked(api.lanes.browse).mockResolvedValue({
path: "/home/u/.ssh",
parent: "/home/u",
entries: [
{ name: "id_ed25519", path: "/home/u/.ssh/id_ed25519", isGitRepo: false, file: true },
],
});
render(<PathPickButton kind="file" start="~/.ssh" onPick={onPick} />);
await userEvent.click(screen.getByRole("button"));
const dialog = await screen.findByRole("dialog", { name: "Browse for a file" });
expect(api.lanes.browse).toHaveBeenCalledWith("~/.ssh", true);
expect(within(dialog).queryByText("Select this folder")).toBeNull();
await userEvent.click(await within(dialog).findByText("id_ed25519"));
expect(onPick).toHaveBeenCalledWith("/home/u/.ssh/id_ed25519");
expect(screen.queryByRole("dialog")).toBeNull();
expect(toast.info).not.toHaveBeenCalled();
});
});
@@ -9,7 +9,11 @@
* 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.
* reader scrolls up; `follow` (a chat's send) sends it back to the end. A
* chat passes in whether the CLI waits on the user (`waiting`, with
* `onOpenTerminal`) and hears how full the context is (`onContext`) and
* whether its session has started (`onNotStarted`: no row yet — the CLI may
* be asking in the terminal, e.g. to trust the folder, which this then says).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -62,7 +66,7 @@ import { Loader2, ArrowDown, MessagesSquare, RefreshCw } from "lucide-react";
import { api } from "../../lib/api";
import { eventBus } from "../../lib/eventBus";
import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents";
import { MessageList } from "./MessageList";
import { MessageList, promptTokens } from "./MessageList";
import { useSpin } from "../../hooks/useSpin";
import { useTranslation } from "react-i18next";
import type { TranscriptMessage, TranscriptInfo, WSMessage } from "../../lib/types";
@@ -89,6 +93,15 @@ interface ConversationViewProps {
live?: boolean;
/** Changed (a chat's send): back to the end, and stick to it. */
follow?: number;
/** The CLI is asking the user something (a permission prompt). */
waiting?: boolean;
/** Shows the pane's terminal, where that question is answered. */
onOpenTerminal?: () => void;
/** The newest reply's prompt size (input + cache) and model: how full the
* context is. */
onContext?: (tokens: number, model: string | null) => void;
/** A live pane's session has no row yet: the CLI has not started it. */
onNotStarted?: (notStarted: boolean) => void;
}
export function ConversationView({
@@ -98,9 +111,26 @@ export function ConversationView({
onOpenFile,
live = false,
follow = 0,
waiting = false,
onOpenTerminal,
onContext,
onNotStarted,
}: ConversationViewProps) {
const { t } = useTranslation("sessions");
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
// A live pane's session has no row until its first hook — and Claude Code
// runs no hook before the folder is trusted, so a trust prompt (or any
// other question before the session starts) waits unseen in the terminal.
const [notStarted, setNotStarted] = useState(false);
useEffect(() => onNotStarted?.(notStarted), [notStarted, onNotStarted]);
useEffect(() => {
if (!onContext) return;
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i]!;
if (m.type === "assistant" && m.usage)
return onContext(promptTokens(m.usage), m.model ?? null);
}
}, [messages, onContext]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [loadingHistory, setLoadingHistory] = useState(false);
@@ -178,6 +208,7 @@ export function ConversationView({
limit: 50,
});
if (cancelled) return;
setNotStarted(false);
setMessages(result.messages);
setTotal(result.total);
setHasMore(result.has_more);
@@ -189,6 +220,7 @@ export function ConversationView({
// to show yet, and the poll below picks it up once it exists.
const notYet = live && (err as { code?: string }).code === "NOT_FOUND";
if (!notYet) setError(err instanceof Error ? err.message : t("conversation.failedLoad"));
setNotStarted(notYet);
setMessages([]);
setTotal(0);
} finally {
@@ -228,6 +260,7 @@ export function ConversationView({
limit: 50,
});
if (gen !== genRef.current) return; // another transcript is shown now
setNotStarted(false);
if (result.messages.length === 0) return;
lastLineRef.current = result.last_line;
@@ -494,7 +527,9 @@ export function ConversationView({
className="flex-1 overflow-y-auto [scrollbar-gutter:stable]"
style={fill ? { minHeight: 0 } : { maxHeight: "calc(100vh - 320px)", minHeight: 200 }}
>
<div ref={contentRef}>
{/* A chat keeps its first turn clear of the pane's top-right controls
(the outline's file button floats there). */}
<div ref={contentRef} className={fill ? "pt-6" : undefined}>
{/* History loading indicator */}
{loadingHistory && (
<div className="flex justify-center py-3">
@@ -516,6 +551,18 @@ export function ConversationView({
<div className="flex items-center justify-center py-12 text-fg-muted text-sm">
{t("conversation.loading")}
</div>
) : messages.length === 0 && notStarted ? (
<div className="mx-auto max-w-md py-12 text-center">
<p className="text-sm text-fg-secondary">{t("conversation.notStarted")}</p>
<p className="mt-2 text-xs leading-relaxed text-fg-muted">
{t("conversation.notStartedBody")}
</p>
{onOpenTerminal && (
<button type="button" onClick={onOpenTerminal} className="btn-secondary mt-3 text-xs">
{t("conversation.openTerminal")}
</button>
)}
</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>
@@ -524,7 +571,14 @@ export function ConversationView({
</p>
</div>
) : (
<MessageList messages={messages} loading={false} onOpenFile={onOpenFile} live={live} />
<MessageList
messages={messages}
loading={false}
onOpenFile={onOpenFile}
live={live}
waiting={waiting}
onOpenTerminal={onOpenTerminal}
/>
)}
</div>
</div>
@@ -6,7 +6,8 @@
* MarkdownContent for prose. Used by ConversationView as the main body of
* the Conversation tab on the Session detail page. An image the prompt box
* attached reaches the transcript as its saved path; a user turn shows the
* image instead (`GET /api/run/drop/:name`), and Claude Code's own
* image instead (`GET /api/run/drop/:name`) — any other attached file as a
* chip that opens its preview through `onOpenFile` —, and Claude Code's own
* `[Image: source: <drop>]` line for it joins that turn rather than showing as
* a System row; URLs and absolute paths in a turn are links (a path opens
* through `onOpenFile`), and its `/command` and `@path` tokens chips coloured
@@ -18,7 +19,12 @@
* 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.
* now — the calls still running (several when it runs them in parallel),
* else "Thinking…", or the question the CLI waits on with a way to its
* terminal (`waiting`) — and, like the terminal's status line, how long the
* turn has run and the output tokens it has made; a call running long shows
* its own time, and a line silent for minutes turns amber. A turn that ended
* closes with how long it took and its output.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -67,7 +73,7 @@
*
* ----------------------------------------------------------------------------- */
import { Fragment, memo, useEffect, useState, useMemo } from "react";
import { Fragment, memo, useContext, useEffect, useRef, useState, useMemo } from "react";
import { ImageLightbox } from "../ImageLightbox";
import { useTranslation } from "react-i18next";
import i18n from "../../i18n";
@@ -86,6 +92,9 @@ import {
Cog,
FileText,
Loader2,
Check,
Hand,
SquareTerminal,
} from "lucide-react";
import type { TranscriptMessage, TranscriptContent, TranscriptSender } from "../../lib/types";
@@ -142,6 +151,11 @@ interface MessageListProps {
onOpenFile?: (path: string) => void;
/** The session is running in a pane: show the step in progress. */
live?: boolean;
/** The CLI is asking the user something (a permission prompt): the live
* line says so instead of "Thinking…". */
waiting?: boolean;
/** Shows the pane's terminal, where that question is answered. */
onOpenTerminal?: () => void;
}
/** Build a map from tool_use id → tool_result for matching */
@@ -160,7 +174,7 @@ function buildToolResultMap(messages: TranscriptMessage[]): Map<string, Transcri
/** 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 {
export function promptTokens(u: NonNullable<TranscriptMessage["usage"]>): number {
return (
(u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0)
);
@@ -444,7 +458,8 @@ type Item =
| { kind: "user"; key: string; msg: TranscriptMessage }
| { kind: "text"; key: string; msg: TranscriptMessage; text: string }
| { kind: "work"; key: string; blocks: TranscriptContent[] }
| { kind: "edits"; key: string; blocks: TranscriptContent[] };
| { kind: "edits"; key: string; blocks: TranscriptContent[] }
| { kind: "done"; key: string; ms: number; output: number };
const EDIT_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
@@ -453,9 +468,20 @@ export function chatItems(messages: TranscriptMessage[], keys: string[]): 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[] = [];
// The turn's prompt time, its newest assistant line and its output so far,
// for the "Done in …" line under a turn that ended.
let start: string | null = null;
let reply: TranscriptMessage | null = null;
let output = outputCounter();
const endTurn = (key: string) => {
if (edits.length) items.push({ kind: "edits", key: `edits:${key}`, blocks: edits });
edits = [];
if (start && reply?.timestamp && reply.stop_reason === "end_turn") {
const ms = parseDate(reply.timestamp).getTime() - parseDate(start).getTime();
items.push({ kind: "done", key: `done:${key}`, ms, output: output.total });
}
reply = null;
output = outputCounter();
};
messages.forEach((msg, idx) => {
const key = keys[idx]!;
@@ -465,6 +491,7 @@ export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[]
const sender = msg.sender ?? (msg.type === "assistant" ? "assistant" : "user");
if (sender === "user") {
endTurn(key);
start = msg.timestamp ?? null;
return items.push({ kind: "user", key, msg });
}
if (sender !== "assistant") {
@@ -487,6 +514,8 @@ export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[]
}
return items.push({ kind: "row", key, msg });
}
reply = msg;
if (msg.usage) output.add(msg.usage);
msg.content.forEach((block, b) => {
const last = items[items.length - 1];
if (block.type === "tool_use" && EDIT_TOOLS.has(block.name ?? "")) edits.push(block);
@@ -575,13 +604,21 @@ function EditedFiles({
);
}
export function MessageList({ messages, loading, onOpenFile, live = false }: MessageListProps) {
export function MessageList({
messages,
loading,
onOpenFile,
live = false,
waiting = false,
onOpenTerminal,
}: MessageListProps) {
const { t } = useTranslation("sessions");
const toolResultMap = useMemo(() => buildToolResultMap(messages), [messages]);
const pending = useMemo(
() => (live ? workingOn(messages, toolResultMap) : null),
[live, messages, toolResultMap]
);
const stats = useMemo(() => (pending ? turnStats(messages) : null), [pending, messages]);
const keys = useMemo(() => messageKeys(messages), [messages]);
const items = useMemo(() => chatItems(messages, keys), [messages, keys]);
@@ -631,9 +668,19 @@ export function MessageList({ messages, loading, onOpenFile, live = false }: Mes
onOpenFile={onOpenFile}
/>
);
case "done":
return <TurnDone key={item.key} ms={item.ms} output={item.output} />;
}
})}
{pending && <RunningStep block={pending.block} since={pending.since} />}
{pending && (
<RunningStep
{...pending}
start={stats?.start ?? null}
tokens={stats?.output ?? 0}
waiting={waiting}
onOpenTerminal={onOpenTerminal}
/>
)}
</div>
</OpenFileContext.Provider>
);
@@ -643,88 +690,290 @@ export function MessageList({ messages, loading, onOpenFile, live = false }: Mes
// 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. */
/** What the model is doing while its turn is not over: the calls of the turn
* still waiting for their results (`blocks`, oldest first — parallel calls
* finish in any order), `since` the oldest one's time, `last` the newest
* line's; `blocks` is empty while it thinks. Null once the turn ended. */
export function workingOn(
messages: TranscriptMessage[],
results: Map<string, TranscriptContent>,
now = Date.now()
): { block: TranscriptContent | null; since: string | null } | null {
): { blocks: TranscriptContent[]; since: string | null; last: string | null } | null {
const blocks: TranscriptContent[] = [];
let since: string | null = null;
let last: string | null = null;
// The newest line's verdict: undefined until it is found.
let alive: boolean | undefined;
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;
const at = msg.timestamp ?? null;
const fresh = !at || now - parseDate(at).getTime() < IDLE_AFTER_MS;
const newest = alive === undefined;
if (newest) last = at;
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;
// "end_turn": over, whatever the transcript failed to pair.
if (newest && msg.stop_reason === "end_turn") return null;
for (const b of [...msg.content].reverse()) {
if (b.type === "tool_use" && b.id && !results.has(b.id)) {
blocks.unshift(b);
since = at;
}
}
// "tool_use": a call follows. None (an older server): over.
if (newest) alive = msg.stop_reason === "tool_use" && fresh;
continue;
}
// 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;
if (newest) alive = fresh;
// The prompt that opened the turn: nothing before it is this turn's.
if (text) break;
}
return null;
if (blocks.length) return { blocks, since, last };
return alive ? { blocks, since: null, last } : null;
}
/** Claude Desktop's live line: the step running now and for how long. */
function RunningStep({ block, since }: { block: TranscriptContent | null; since: string | null }) {
/** Output tokens counted once per API call: Claude Code writes one line per
* content block, each repeating its call's usage — consecutive lines with
* the same prompt size are one call. */
function outputCounter() {
let total = 0;
let call: string | null = null;
let callOutput = 0;
return {
add(u: NonNullable<TranscriptMessage["usage"]>) {
const key = `${u.input_tokens}:${u.cache_read_input_tokens ?? 0}:${u.cache_creation_input_tokens ?? 0}`;
if (key !== call) {
total += callOutput;
call = key;
callOutput = 0;
}
callOutput = Math.max(callOutput, u.output_tokens || 0);
},
get total() {
return total + callOutput;
},
};
}
/** The live turn so far, as the terminal counts it: `start` is the prompt
* that opened it, `output` the output tokens of every API call since. */
export function turnStats(messages: TranscriptMessage[]): { start: string | null; output: number } {
const output = outputCounter();
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]!;
if (msg.type === "assistant") {
if (msg.usage) output.add(msg.usage);
continue;
}
const sender = msg.sender ?? "user";
if (sender === "user" && msg.content.some((c) => c.type === "text")) {
return { start: msg.timestamp ?? null, output: output.total };
}
}
return { start: null, output: output.total };
}
// A call running this long shows its own time beside the turn's.
const STEP_SHOWN_MS = 30_000;
// No new line for this long: the line turns amber — the step may be stuck.
const QUIET_MS = 3 * 60_000;
/** A number that counts up to its new value instead of jumping (none when
* the user asked for reduced motion). */
function useCountUp(value: number): number {
const [shown, setShown] = useState(value);
const shownRef = useRef(value);
useEffect(() => {
const from = shownRef.current;
if (from === value) return;
const reduce = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
if (reduce || typeof requestAnimationFrame === "undefined") {
shownRef.current = value;
setShown(value);
return;
}
const t0 = performance.now();
let raf = 0;
const step = (t: number) => {
const k = Math.min(1, (t - t0) / 600);
shownRef.current = Math.round(from + (value - from) * k);
setShown(shownRef.current);
if (k < 1) raf = requestAnimationFrame(step);
};
raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(raf);
}, [value]);
return shown;
}
/** A call's one-line title: its description (Claude Code's Bash
* `description`), else the tool and its target. */
function stepTitle(block: TranscriptContent): string {
const input = block.input as Record<string, unknown> | undefined;
return typeof input?.description === "string" && input.description
? input.description
: [block.name, buildSummary(block)].filter(Boolean).join(" ");
}
/** The terminal's live line: the step running now (or the question the CLI
* waits on), then the turn's time and output tokens so far. */
function RunningStep({
blocks,
since,
last,
start,
tokens,
waiting,
onOpenTerminal,
}: {
blocks: TranscriptContent[];
since: string | null;
last: string | null;
start: string | null;
tokens: number;
waiting: boolean;
onOpenTerminal?: () => void;
}) {
const { t } = useTranslation("sessions");
const [now, setNow] = useState(() => Date.now());
const [showAll, setShowAll] = useState(false);
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(" ");
const counted = useCountUp(tokens);
const age = (at: string | null) => (at ? now - parseDate(at).getTime() : 0);
const first = blocks[0];
const step = first ? age(since) : 0;
const quiet = !waiting && age(last) > QUIET_MS;
const title = waiting
? first
? t("conversation.waitingFor", { step: stepTitle(first) })
: t("conversation.waiting")
: first
? stepTitle(first)
: t("conversation.working");
const extra = [
start && formatMs(age(start)),
tokens > 0 && t("conversation.turnTokens", { tokens: fmt(counted) }),
quiet && !first && t("conversation.quiet", { time: formatMs(age(last)) }),
].filter(Boolean);
const amber = waiting || quiet;
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())}
<div data-testid="chat-running-step" className="space-y-1 text-sm text-fg-muted">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
{waiting ? (
<Hand className="h-3.5 w-3.5 flex-shrink-0 text-status-warning" />
) : (
<Loader2
className={`h-3.5 w-3.5 flex-shrink-0 animate-spin ${quiet ? "text-status-warning" : "text-accent"}`}
/>
)}
<span className={`min-w-0 truncate ${amber ? "text-status-warning" : "text-shimmer"}`}>
{title}
</span>
{blocks.length > 1 && (
<button
type="button"
onClick={() => setShowAll((v) => !v)}
aria-expanded={showAll}
className="flex-shrink-0 rounded-full border border-border px-1.5 text-xs hover:bg-surface-2"
>
{t("conversation.moreSteps", { count: blocks.length - 1 })}
</button>
)}
{step > STEP_SHOWN_MS && (
<span
className={`flex-shrink-0 font-mono text-xs ${step > QUIET_MS ? "text-status-warning" : ""}`}
>
{formatMs(step)}
</span>
)}
{extra.length > 0 && (
<span className="flex-shrink-0 font-mono text-xs">{`(${extra.join(" · ")})`}</span>
)}
{waiting && onOpenTerminal && (
<button
type="button"
data-testid="chat-open-terminal"
onClick={onOpenTerminal}
className="flex flex-shrink-0 items-center gap-1 rounded-md border border-status-warning/40 px-2 py-0.5 text-xs text-status-warning hover:bg-status-warning/10"
>
<SquareTerminal className="h-3.5 w-3.5" />
{t("conversation.openTerminal")}
</button>
)}
</div>
{showAll && blocks.length > 1 && (
<ul className="ml-5 space-y-0.5 text-xs">
{blocks.map((b, i) => (
<li key={b.id ?? i} className="truncate">
{stepTitle(b)}
</li>
))}
</ul>
)}
</div>
);
}
/** Under a turn that ended: how long it took and the output it made. */
function TurnDone({ ms, output }: { ms: number; output: number }) {
const { t } = useTranslation("sessions");
return (
<div data-testid="chat-turn-done" className="flex items-center gap-1.5 text-xs text-fg-muted">
<Check className="h-3 w-3 flex-shrink-0" />
{[
t("conversation.turnDone", { time: formatMs(ms) }),
output > 0 && t("conversation.turnTokens", { tokens: fmt(output) }),
]
.filter(Boolean)
.join(" · ")}
</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 (`<data dir>/drops/<16 hex>[-name].png`, server/routes/run.js; the
// What the prompt box sends for an attached file: its saved path under the
// drop dir (`<data dir>/drops/<16 hex>[-name].ext`, server/routes/run.js; the
// older `<tmp>/agenthub-drops-<uid>/` still in past transcripts).
const DROP_IMAGE =
/(?<=^|\s)\S*[\\/](?:agenthub-drops--?\d+|drops)[\\/]([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.(?:png|jpe?g|gif|webp))(?=\s|$)/gi;
const DROP_PATH =
/(?<=^|\s)(\S*[\\/](?:agenthub-drops--?\d+|drops)[\\/]([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.[A-Za-z0-9]+))(?=\s|$)/gi;
const IMAGE_EXT = /\.(?:png|jpe?g|gif|webp)$/i;
// Claude Code's own line for an image it read off a pasted path (`isMeta`).
const IMAGE_SOURCE = /^\[Image: source: (.+)\]$/;
/** A user turn's text without its dropped-image paths (and Claude Code's
* `[Image #N]` placeholders for them), and those images' names. */
export function splitDropImages(text: string): { text: string; images: string[] } {
/** A user turn's text without its dropped-file paths (and Claude Code's
* `[Image #N]` placeholders for them), the images' names and the other
* files' full paths. */
export function splitDropImages(text: string): {
text: string;
images: string[];
files: string[];
} {
const images: string[] = [];
const rest = text.replace(DROP_IMAGE, (_all, name: string) => {
images.push(name);
const files: string[] = [];
const rest = text.replace(DROP_PATH, (_all, full: string, name: string) => {
if (IMAGE_EXT.test(name)) images.push(name);
else files.push(full);
return "";
});
return {
text: images.length
? rest
.replace(/\[Image #\d+\]/g, "")
.replace(/[ \t]+$/gm, "")
.trim()
: text,
text:
images.length || files.length
? rest
.replace(/\[Image #\d+\]/g, "")
.replace(/[ \t]+$/gm, "")
.trim()
: text,
images,
files,
};
}
@@ -735,6 +984,30 @@ function imageSourcePaths(msg: TranscriptMessage): string[] | null {
return paths.length && paths.every((p) => splitDropImages(p).images.length) ? paths : null;
}
/** A dropped file other than an image: a chip that opens its preview when the
* page can (`OpenFileContext`). */
function DropFile({ path }: { path: string }) {
const open = useContext(OpenFileContext);
const name = path
.split(/[\\/]/)
.pop()!
.replace(/^[0-9a-f]{16}-?/i, "");
const ext = /\.([A-Za-z0-9]+)$/.exec(name)?.[1]?.toUpperCase();
return (
<button
type="button"
disabled={!open}
onClick={() => open?.(path)}
title={path}
className="flex max-w-64 items-center gap-2 rounded-xl border border-border bg-surface-2 px-3 py-2 text-left text-xs enabled:hover:border-accent/50"
>
<FileText className="h-4 w-4 shrink-0 text-fg-muted" />
<span className="truncate text-fg-primary">{name}</span>
{ext && <span className="shrink-0 font-mono text-[10px] text-fg-muted">{ext}</span>}
</button>
);
}
function DropImage({ name }: { name: string }) {
const [open, setOpen] = useState(false);
const [gone, setGone] = useState(false);
@@ -782,6 +1055,7 @@ const UserBubble = memo(function UserBubble({ msg }: { msg: TranscriptMessage })
[msg.content]
);
const images = blocks.flatMap((b) => b?.images ?? []);
const files = blocks.flatMap((b) => b?.files ?? []);
const hasText = blocks.some((b) => b?.text);
return (
<div
@@ -789,7 +1063,7 @@ const UserBubble = memo(function UserBubble({ msg }: { msg: TranscriptMessage })
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 60px" }}
className="group flex flex-col items-end gap-1"
>
{images.length > 0 && (
{images.length + files.length > 0 && (
<div
data-testid="chat-user-images"
className="flex max-w-[85%] flex-wrap justify-end gap-2"
@@ -797,6 +1071,9 @@ const UserBubble = memo(function UserBubble({ msg }: { msg: TranscriptMessage })
{images.map((name) => (
<DropImage key={name} name={name} />
))}
{files.map((path) => (
<DropFile key={path} path={path} />
))}
</div>
)}
{hasText && (
@@ -1,11 +1,13 @@
/**
* @file ConversationView.notYet.test.tsx
* @description A live pane's new session has no row until its first hook:
* its 404 shows an empty chat, not "Session not found"; a replay still shows it.
* its 404 shows no "Session not found" (a replay still shows it) but says the
* session has not started — the CLI may be asking in the terminal, e.g. to
* trust the folder — offers the terminal, and tells the chat (`onNotStarted`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { ConversationView } from "../ConversationView";
@@ -34,4 +36,23 @@ describe("ConversationView before a session's first hook", () => {
render(<ConversationView sessionId="gone" fill />);
expect(await screen.findByText("Session not found")).toBeTruthy();
});
it("says the session has not started and offers the terminal (a trust prompt)", async () => {
const onOpenTerminal = vi.fn();
const onNotStarted = vi.fn();
render(
<ConversationView
sessionId="new"
fill
live
onOpenTerminal={onOpenTerminal}
onNotStarted={onNotStarted}
/>
);
expect(await screen.findByText("The session has not started yet.")).toBeTruthy();
expect(screen.queryByText("No conversation records found.")).toBeNull();
fireEvent.click(screen.getByText("Open terminal"));
expect(onOpenTerminal).toHaveBeenCalled();
expect(onNotStarted).toHaveBeenLastCalledWith(true);
});
});
@@ -2,8 +2,8 @@
* @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. Claude Code turns a pasted drop path into an image
* `GET /api/run/drop/:name`), opens it full size on the page; any other
* dropped file is a chip that opens its preview, and any other path stays as typed. Claude Code turns a pasted drop path into an image
* block plus an `[Image: source: <path>]` meta line: the image joins the turn.
* URLs and absolute paths in a turn are links.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -19,7 +19,7 @@ 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] });
).toEqual({ text: "Đây là cli và version đã có.", images: [SHOT], files: [] });
// Windows' temp dir, no prefix name.
expect(splitDropImages("C:\\Temp\\agenthub-drops--1\\0123456789abcdef.webp").images).toEqual([
"0123456789abcdef.webp",
@@ -28,16 +28,36 @@ describe("dropped images in a user turn", () => {
expect(splitDropImages(`[Image #1]fix this /home/me/.claude/agenthub/drops/${SHOT}`)).toEqual({
text: "fix this",
images: [SHOT],
files: [],
});
// 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: [] });
// Any other dropped file comes out by its full path.
const pdf = "/tmp/agenthub-drops-1001/0123456789abcdef-spec.pdf";
expect(splitDropImages(`read ${pdf}`)).toEqual({ text: "read", images: [], files: [pdf] });
// Not a drop: left as typed.
for (const text of ["/home/me/shot.png", "/home/me/0123456789abcdef-spec.pdf"]) {
expect(splitDropImages(text)).toEqual({ text, images: [], files: [] });
}
});
it("shows a dropped file as a chip that opens its preview", () => {
const pdf = "/home/me/.claude/agenthub/drops/0123456789abcdef-spec.pdf";
const opened: string[] = [];
const messages = [
{
type: "user",
sender: "user",
timestamp: "2026-10-09T08:00:00.000Z",
content: [{ type: "text", text: `summarise ${pdf}` }],
},
] as TranscriptMessage[];
render(<MessageList messages={messages} loading={false} onOpenFile={(p) => opened.push(p)} />);
const bubble = screen.getByTestId("chat-user");
expect(bubble).not.toHaveTextContent("0123456789abcdef");
fireEvent.click(screen.getByRole("button", { name: /spec\.pdf/ }));
expect(opened).toEqual([pdf]);
});
it("shows the image instead of its path, full size on click", () => {
const messages = [
{
@@ -10,7 +10,7 @@
*/
import { describe, it, expect } from "vitest";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { MessageList, workLabel, workingOn } from "../MessageList";
import { MessageList, turnStats, workLabel, workingOn } from "../MessageList";
import type { TranscriptMessage } from "../../../lib/types";
function msg(partial: Partial<TranscriptMessage>): TranscriptMessage {
@@ -142,11 +142,17 @@ describe("MessageList — sender attribution", () => {
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: "user",
sender: "user",
timestamp: new Date(Date.now() - 65_000).toISOString(),
content: [{ type: "text", text: "test it" }],
}),
msg({
type: "assistant",
sender: "assistant",
timestamp: new Date(Date.now() - 65_000).toISOString(),
timestamp: new Date(Date.now() - 5_000).toISOString(),
usage: { input_tokens: 2, output_tokens: 3500 },
content: [
{
type: "tool_use",
@@ -160,7 +166,8 @@ describe("MessageList — sender attribution", () => {
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/);
// Like the terminal: the turn's time since the prompt, and its output.
expect(row).toHaveTextContent(/\(1m \ds · ↑ 3\.5K tokens\)/);
// Its result arrived: nothing is running any more.
rerender(
<MessageList
@@ -175,6 +182,98 @@ describe("MessageList — sender attribution", () => {
expect(screen.queryByTestId("chat-running-step")).toBeNull();
});
it("counts a turn's output once per API call, since its prompt", () => {
const at = "2026-06-26T08:19:00.000Z";
const line = (cache: number, out: number) =>
msg({
type: "assistant",
sender: "assistant",
content: [{ type: "text", text: "x" }],
usage: { input_tokens: 2, output_tokens: out, cache_read_input_tokens: cache },
});
const messages = [
msg({ sender: "user" }),
line(10, 999), // an earlier turn
msg({ sender: "user", timestamp: at }),
line(100, 40),
line(100, 40), // the same call's second block
msg({ sender: "tool", content: [{ type: "tool_result", id: "t", output: "ok" }] }),
line(200, 25),
];
expect(turnStats(messages)).toEqual({ start: at, output: 65 });
});
it("keeps every parallel call of the turn running until its own result", () => {
const at = "2026-06-26T08:19:00.000Z";
const now = Date.parse("2026-06-26T08:20:00.000Z");
const call = (id: string) =>
msg({
type: "assistant",
sender: "assistant",
timestamp: at,
stop_reason: "tool_use",
content: [{ type: "tool_use", id, name: "Bash", input: { command: id } }],
});
const done = (id: string) =>
msg({ sender: "tool", timestamp: at, content: [{ type: "tool_result", id, output: "ok" }] });
// Claude Code writes each parallel call on its own line; one result is in.
const m = [msg({ sender: "user", timestamp: at }), call("a"), call("b"), call("c"), done("b")];
const results = new Map([["b", m[4]!.content[0]!]]);
expect(workingOn(m, results, now)?.blocks.map((b) => b.id)).toEqual(["a", "c"]);
// A call left unpaired in an EARLIER turn is not this one's.
const next = [
...m,
msg({ sender: "user", timestamp: at, content: [{ type: "text", text: "go" }] }),
];
expect(workingOn(next, results, now)?.blocks).toEqual([]);
});
it("says when the CLI waits on the user, and offers the terminal", () => {
let opened = 0;
render(
<MessageList
loading={false}
live
waiting
onOpenTerminal={() => opened++}
messages={[
msg({ sender: "user", timestamp: new Date().toISOString() }),
msg({
type: "assistant",
sender: "assistant",
timestamp: new Date().toISOString(),
content: [
{ type: "tool_use", id: "t1", name: "Bash", input: { description: "Delete build" } },
],
}),
]}
/>
);
const row = screen.getByTestId("chat-running-step");
expect(row).toHaveTextContent("Waiting for your approval: Delete build");
fireEvent.click(screen.getByTestId("chat-open-terminal"));
expect(opened).toBe(1);
});
it("closes an ended turn with its time and output", () => {
const reply = (out: number, stop_reason: string) =>
msg({
type: "assistant",
sender: "assistant",
timestamp: "2026-06-26T08:16:08.000Z",
stop_reason,
usage: { input_tokens: 5, output_tokens: out, cache_read_input_tokens: out },
content: [{ type: "text", text: "done" }],
});
render(
<MessageList
loading={false}
messages={[msg({ sender: "user" }), reply(1000, "tool_use"), reply(2500, "end_turn")]}
/>
);
expect(screen.getByTestId("chat-turn-done")).toHaveTextContent("Done in 2m 8s · ↑ 3.5K tokens");
});
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";
@@ -205,10 +304,11 @@ describe("MessageList — sender attribution", () => {
);
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
const thinking = { blocks: [], since: null, last: at };
expect(state([prompt])).toEqual(thinking); // thinking on the prompt
expect(state([prompt, call])?.blocks.map((b) => b.id)).toEqual(["t1"]); // the call is running
expect(state([prompt, call, result])).toEqual(thinking); // thinking again
expect(state([prompt, call, result, reply("tool_use")])?.blocks).toEqual([]); // 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({
@@ -1,26 +1,29 @@
/**
* @file A server-backed folder browser for the Add Lane modal's path inputs.
* Browsers refuse to expose an absolute filesystem path from a native folder
* @file A server-backed folder (or file) browser for the path inputs' Browse
* button. Browsers refuse to expose an absolute filesystem path from a native
* picker, so path selection here is done by browsing `GET /api/lanes/browse`
* (immediate subdirectories of a path) instead — breadcrumb-free, just an
* up-one-level button and a click-to-descend list, since this tool is
* local-first and the server already trusts arbitrary local paths.
* (immediate subdirectories of a path, plus its files for `kind="file"`)
* instead — breadcrumb-free, just an up-one-level button and a
* click-to-descend list; in file mode a click on a file picks it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { FolderOpen, FolderGit2, ArrowUp } from "lucide-react";
import { FolderOpen, FolderGit2, ArrowUp, File } from "lucide-react";
import { api } from "../../lib/api";
import { useDialog } from "../../hooks/useDialog";
export function FolderBrowseModal({
open,
kind = "dir",
initialPath,
onSelect,
onClose,
}: {
open: boolean;
/** `file` lists files too, and picking one is the only way to select. */
kind?: "dir" | "file";
/** Path to start browsing from; omitted defaults server-side to the home dir. */
initialPath?: string;
onSelect: (path: string) => void;
@@ -33,7 +36,7 @@ export function FolderBrowseModal({
const load = async (path?: string) => {
setError(null);
try {
setListing(await api.lanes.browse(path));
setListing(await (kind === "file" ? api.lanes.browse(path, true) : api.lanes.browse(path)));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
@@ -65,7 +68,7 @@ export function FolderBrowseModal({
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label={t("browse.title")}
aria-label={kind === "file" ? t("browse.fileTitle") : t("browse.title")}
data-tour="folder-browse"
>
<div className="border-b border-border p-3">
@@ -94,10 +97,16 @@ export function FolderBrowseModal({
<button
key={entry.path}
type="button"
onClick={() => load(entry.path)}
onClick={() => {
if (!entry.file) return void load(entry.path);
onSelect(entry.path);
onClose();
}}
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs text-fg-primary hover:bg-surface-2"
>
{entry.isGitRepo ? (
{entry.file ? (
<File className="h-3.5 w-3.5 text-fg-muted" />
) : entry.isGitRepo ? (
<FolderGit2 className="h-3.5 w-3.5 text-accent" />
) : (
<FolderOpen className="h-3.5 w-3.5 text-fg-muted" />
@@ -118,17 +127,19 @@ export function FolderBrowseModal({
>
{t("destructive.cancel")}
</button>
<button
type="button"
disabled={!listing}
onClick={() => {
if (listing) onSelect(listing.path);
onClose();
}}
className="btn-primary text-xs disabled:opacity-50"
>
{t("browse.select")}
</button>
{kind === "dir" && (
<button
type="button"
disabled={!listing}
onClick={() => {
if (listing) onSelect(listing.path);
onClose();
}}
className="btn-primary text-xs disabled:opacity-50"
>
{t("browse.select")}
</button>
)}
</div>
</div>
</div>
@@ -171,8 +171,16 @@ export function OnboardingTour() {
style = { left: right + 16, top };
} else if (rect.bottom + 180 < window.innerHeight) {
style = { left, top: rect.bottom + 12 };
} else if (rect.top > 200) {
style = { left, top: rect.top - 12, transform: "translateY(-100%)" };
} else {
style = { left, top: Math.max(12, rect.top - 12), transform: "translateY(-100%)" };
// A target filling the screen (the live session): no room above or
// below, so the card sits inside its bottom-right corner.
style = {
left: Math.min(Math.max(12, rect.right - W - 16), window.innerWidth - W - 12),
top: Math.min(rect.bottom, window.innerHeight) - 16,
transform: "translateY(-100%)",
};
}
}
+33 -6
View File
@@ -2,7 +2,8 @@
* @file ChatOutline.tsx
* @description The right-hand panel of a Chat pane, 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
* TaskCreate/TaskUpdate or TodoWrite plan, only when it has one — how many
* steps are done of how many, a bar, and the step in progress highlighted), 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 and its skills (a click opens the list with a
@@ -107,7 +108,8 @@ function Section({
children,
}: {
title: string;
count?: number;
/** Beside the title: a count, or a "3/7". */
count?: number | string;
action?: ReactNode;
/** Takes the panel's free height and scrolls inside it. */
grow?: boolean;
@@ -211,6 +213,7 @@ export function ChatOutline({
}) {
const { t } = useTranslation("run");
const href = useSettingsHref();
const doneSteps = o.progress.filter((p) => p.status === "completed").length;
const used = o.uploads.length + o.files.length + o.memory.length + o.skills.length + o.mcp.length;
const close = (
<button
@@ -228,16 +231,40 @@ export function ChatOutline({
<aside
data-testid="chat-outline"
aria-label={t("chat.outline.panel")}
// Over the chat in a narrow pane (the container is PaneChat's root),
// Over the conversation in a narrow pane (the container is PaneChat's
// root; `--chat-area` keeps the prompt box below it uncovered),
// beside it once the pane has room, as wide as PaneChat's splitter says.
className="absolute inset-y-3 right-3 z-30 flex w-[clamp(15rem,34%,20rem)] max-w-[calc(100%-1.5rem)] flex-shrink-0 flex-col overflow-hidden rounded-xl border border-border bg-surface-1 pb-2 shadow-xl [@container(min-width:40rem)]:static [@container(min-width:40rem)]:m-3 [@container(min-width:40rem)]:ml-0 [@container(min-width:40rem)]:w-[calc(var(--chat-side)-0.75rem)] [@container(min-width:40rem)]:max-w-none [@container(min-width:40rem)]:shadow-none"
className="absolute right-3 top-3 bottom-[calc(100%-var(--chat-area,100%)+0.75rem)] z-30 flex w-[clamp(15rem,34%,20rem)] max-w-[calc(100%-1.5rem)] flex-shrink-0 flex-col overflow-hidden rounded-xl border border-border bg-surface-1 pb-2 shadow-xl [@container(min-width:40rem)]:static [@container(min-width:40rem)]:m-3 [@container(min-width:40rem)]:ml-0 [@container(min-width:40rem)]:w-[calc(var(--chat-side)-0.75rem)] [@container(min-width:40rem)]:max-w-none [@container(min-width:40rem)]:shadow-none"
>
{o.progress.length > 0 && (
<Section title={t("chat.outline.progress")} action={close}>
<Section
title={t("chat.outline.progress")}
count={`${doneSteps}/${o.progress.length}`}
action={close}
>
<div
role="progressbar"
aria-valuemin={0}
aria-valuemax={o.progress.length}
aria-valuenow={doneSteps}
aria-label={t("chat.outline.progress")}
className="mx-1.5 mb-1.5 h-1 overflow-hidden rounded-full bg-surface-3"
>
<div
className="h-full rounded-full bg-accent-solid transition-[width] duration-500"
style={{ width: `${(doneSteps / o.progress.length) * 100}%` }}
/>
</div>
{o.progress.map((task) => {
const done = task.status === "completed";
const now = task.status === "in_progress";
return (
<div key={task.id} className={rowClass} title={task.subject}>
<div
key={task.id}
className={`${rowClass} ${now ? "bg-accent/10 font-medium" : ""}`}
title={task.subject}
aria-current={now ? "step" : undefined}
>
{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" />
+138 -16
View File
@@ -34,7 +34,16 @@
* 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`).
* Stop in Send's place: Esc pressed in the pane (`interruptRun`). While the
* CLI asks the user something (the main agent awaiting input on a
* Notification — a permission prompt), the chat's live line says so and
* offers the terminal (`showTerminal`); before the session has started (no
* row yet: a trust prompt, say, answered only there) anything typed into the
* pane is confirmed first, so an Enter does not pick that prompt's
* preselected "No, exit" unasked. The box shows how full the context
* is (the newest reply's prompt size against the model's window,
* `contextWindow`), and a hidden browser tab is marked while the session
* waits on the user or once its turn ended (`lib/tabBadge.ts`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -49,11 +58,15 @@ import { readChatDraft, writeChatDraft } from "../../lib/chatDraft";
import { ChatOutline, outlineAttachment, useSessionOutline, type OutlineList } from "./ChatOutline";
import { FileListModal, FilePreview, SkillListModal, type PreviewScope } from "./ChatFilePreview";
import { Select } from "../Select";
import { Divider } from "./PaneGrid";
import { Divider, showTerminal } from "./PaneGrid";
import { interruptRun, submitToRun } from "../../lib/panePaste";
import { useLiveRefresh } from "../../hooks/useLiveRefresh";
import { openInChanges } from "../../lib/openInChanges";
import { toast } from "../../lib/toast";
import { confirmDialog } from "../../lib/confirm";
import { setTabBadge } from "../../lib/tabBadge";
import { fmt } from "../../lib/format";
import { agentAwaitingReason } from "../../lib/types";
import { api, type EffortLevel, type PermissionMode, type RunHandle } from "../../lib/api";
const MODES: [PermissionMode, string][] = [
@@ -71,6 +84,58 @@ const storedSide = () => {
return f ? clampSide(f) : null;
};
/** The model's context window. Claude's 200K models are the 4.5 line and
* older; every Claude model since (Sonnet/Opus 4.6+, the 5 family) has 1M
* (LiteLLM's list, 2026-10). Null for another vendor's model. */
// ponytail: a fixed rule, not a table; read LiteLLM's max_input_tokens if a new 200K model ships.
export function contextWindow(model: string | null): number | null {
if (!model?.startsWith("claude-")) return null;
return /^claude-(?:[23]|instant|(?:opus|sonnet|haiku)-4(?:-[0-5])?(?:-\d{8})?$)/.test(model)
? 200_000
: 1_000_000;
}
/** How full the context is: a ring and a percentage, amber then red as
* Claude Code's auto-compact comes near. */
function ContextMeter({ tokens, model }: { tokens: number; model: string | null }) {
const { t } = useTranslation("run");
const size = contextWindow(model);
if (!size || !tokens) return null;
const pct = Math.min(100, Math.round((tokens / size) * 100));
const tone =
pct >= 90 ? "text-status-danger" : pct >= 75 ? "text-status-warning" : "text-fg-muted";
const ring = 2 * Math.PI * 6;
return (
<span
data-testid="pane-chat-context"
title={t("chat.contextTitle", { used: fmt(tokens), size: fmt(size) })}
className={`flex items-center gap-1 px-1 text-xs tabular-nums ${tone}`}
>
<svg viewBox="0 0 16 16" className="h-3.5 w-3.5 -rotate-90" aria-hidden>
<circle
cx="8"
cy="8"
r="6"
fill="none"
stroke="currentColor"
strokeOpacity="0.25"
strokeWidth="2"
/>
<circle
cx="8"
cy="8"
r="6"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeDasharray={`${(pct / 100) * ring} ${ring}`}
/>
</svg>
{pct}%
</span>
);
}
/** 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"]);
@@ -161,20 +226,38 @@ export function PaneChat({
const [dragged, setDragged] = useState(storedSide);
const side = dragged ?? (preview ? 0.45 : 0.3);
const rootRef = useRef<HTMLDivElement>(null);
// The conversation's height, so a panel laid over a narrow pane stops above
// the prompt box instead of covering it.
const areaRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const area = areaRef.current;
if (!area || typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(() =>
rootRef.current?.style.setProperty("--chat-area", `${area.offsetHeight}px`)
);
ro.observe(area);
return () => ro.disconnect();
}, []);
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.
// The main agent is "working" from a prompt's submit to its Stop hook, and
// waits on the user from a Notification (a permission prompt) to the next hook.
const [working, setWorking] = useState(false);
const [waiting, setWaiting] = 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")))
.then((r) => {
const main = r.agents.filter((a) => a.type === "main");
setWorking(main.some((a) => a.status === "working"));
setWaiting(main.some((a) => agentAwaitingReason(a) === "notification"));
})
.catch(() => {}),
[sessionId]
);
@@ -186,22 +269,56 @@ export function PaneChat({
// 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,
intervalMs: working || waiting ? 5000 : undefined,
match: (msg) =>
msg.type === "agent_updated" &&
(msg.data as { session_id?: string } | undefined)?.session_id === sessionId,
});
// A hidden tab says when this session waits on the user or its turn ended.
const was = useRef({ working: false, waiting: false });
useEffect(() => {
if (waiting) setTabBadge(runId, "waiting");
else if (working) setTabBadge(runId, null);
else if (was.current.working || was.current.waiting) setTabBadge(runId, "done");
was.current = { working, waiting };
}, [runId, working, waiting]);
useEffect(() => () => setTabBadge(runId, null), [runId]);
const [context, setContext] = useState<{ tokens: number; model: string | null }>({
tokens: 0,
model: null,
});
const onContext = useCallback(
(tokens: number, model: string | null) => setContext({ tokens, model }),
[]
);
// Before the session starts the CLI may be asking in the terminal (trust
// this folder? with "No, exit" preselected): an Enter from here answers it.
// Asked, not refused: whether the session row appears right after the
// answer depends on Claude Code firing a hook then, and a hard lock would
// strand the chat if it does not.
const [notStarted, setNotStarted] = useState(false);
// A control is a command typed into the pane, like the user would.
const command = (line: string) => {
const command = async (line: string) => {
if (
notStarted &&
!(await confirmDialog({
title: t("chat.notStartedTitle"),
message: t("chat.notStarted"),
confirmLabel: t("chat.sendAnyway"),
}))
)
return false;
const ok = submitToRun(runId, line);
if (!ok) toast.error(t("chat.notSent"));
return ok;
};
const send = () => {
const send = async () => {
if (!text || uploading) return;
if (!command(text)) return;
if (!(await command(text))) return;
setFollow((n) => n + 1);
setPrompt("");
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
@@ -216,12 +333,16 @@ export function PaneChat({
style={{ "--chat-side": `${side * 100}%` } as CSSProperties}
>
<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">
<div ref={areaRef} className="relative min-h-0 flex-1 overflow-hidden">
<ConversationView
sessionId={sessionId}
fill
live
follow={follow}
waiting={waiting}
onOpenTerminal={() => showTerminal(runId, sessionId)}
onContext={onContext}
onNotStarted={setNotStarted}
onOpenFile={(path) => {
// The workspace's Changes panel when the file is in its folder;
// else this view's own preview.
@@ -254,7 +375,7 @@ export function PaneChat({
pickFiles={pickFiles}
value={prompt}
onChange={setPrompt}
onSubmit={send}
onSubmit={() => void send()}
placeholder={t("chat.placeholder")}
rows={2}
slash={claude}
@@ -272,16 +393,17 @@ export function PaneChat({
/>
{claude && <PermissionChip runId={runId} initial={run.permissionMode} />}
<div className="ml-auto flex items-center gap-1">
<ContextMeter tokens={context.tokens} model={context.model} />
{claude && (
<ModelMenu
value={model}
onChange={(v) => {
if (command(`/model ${v || "default"}`)) setModel(v);
onChange={async (v) => {
if (await command(`/model ${v || "default"}`)) setModel(v);
}}
tool={run.tool}
effort={effort}
onEffortChange={(e) => {
if (command(`/effort ${e || "auto"}`)) setEffort(e);
onEffortChange={async (e) => {
if (await command(`/effort ${e || "auto"}`)) setEffort(e);
}}
/>
)}
@@ -304,7 +426,7 @@ export function PaneChat({
<button
type="button"
data-testid="pane-chat-send"
onClick={send}
onClick={() => void send()}
disabled={uploading || !text}
aria-label={t("chat.send")}
title={t("chat.send")}
@@ -325,7 +447,7 @@ export function PaneChat({
scope={scope}
onClose={() => setPreview(null)}
// Beside the chat when the pane has room, over it when not.
className="absolute inset-0 z-30 rounded-xl border border-border [@container(min-width:40rem)]:static [@container(min-width:40rem)]:m-3 [@container(min-width:40rem)]:ml-0 [@container(min-width:40rem)]:w-[calc(var(--chat-side)-0.75rem)] [@container(min-width:40rem)]:flex-shrink-0"
className="absolute inset-x-0 top-0 bottom-[calc(100%-var(--chat-area,100%))] z-30 rounded-xl border border-border [@container(min-width:40rem)]:static [@container(min-width:40rem)]:m-3 [@container(min-width:40rem)]:ml-0 [@container(min-width:40rem)]:w-[calc(var(--chat-side)-0.75rem)] [@container(min-width:40rem)]:flex-shrink-0"
/>
) : (
outlineOpen && (
+14 -13
View File
@@ -201,6 +201,15 @@ const ITEM =
* (`lib/chatUi.ts`); a session with no id yet has no chat to show. The
* unsent prompt goes along: the terminal's input box into the chat's draft,
* the draft (its files as paths) back into the input box, never sent. */
/** A session's chat switched to its terminal: the unsent prompt goes into
* the CLI's input box, without Enter. */
export function showTerminal(runId: string, sessionId: string) {
const draft = readChatDraft(sessionId);
const text = promptWithImages(draft.prompt, draft.images);
if (text && pasteToRun(runId, text)) writeChatDraft(sessionId, "", []);
setSessionChat(sessionId, false);
}
function ChatToggle({ run }: { run?: RunHandle }) {
const { t } = useTranslation("lanes");
const chat = useSessionChat(run?.sessionId);
@@ -215,20 +224,12 @@ function ChatToggle({ run }: { run?: RunHandle }) {
title={label}
aria-label={label}
onClick={() => {
if (chat) return showTerminal(run.id, sessionId);
const draft = readChatDraft(sessionId);
if (chat) {
const text = promptWithImages(draft.prompt, draft.images);
if (text && pasteToRun(run.id, text)) writeChatDraft(sessionId, "", []);
} else {
const text = takeRunInput(run.id);
if (text)
writeChatDraft(
sessionId,
[draft.prompt, text].filter(Boolean).join("\n"),
draft.images
);
}
setSessionChat(sessionId, !chat);
const text = takeRunInput(run.id);
if (text)
writeChatDraft(sessionId, [draft.prompt, text].filter(Boolean).join("\n"), draft.images);
setSessionChat(sessionId, true);
}}
className="flex h-6 w-6 items-center justify-center rounded text-fg-muted hover:bg-surface-4 hover:text-fg-primary"
>
+1 -1
View File
@@ -508,7 +508,7 @@ export function PromptEditor({
) : (
<span
title={img.path}
className="flex h-14 max-w-48 items-center gap-1.5 rounded-lg border border-border bg-surface-3 px-2 text-xs text-fg-secondary"
className="flex h-14 w-40 items-center gap-1.5 rounded-lg border border-border bg-surface-3 px-2 text-xs text-fg-secondary"
>
<FileText className="h-4 w-4 shrink-0 text-fg-muted" />
<span className="truncate">{img.name}</span>
+65 -5
View File
@@ -14,7 +14,7 @@
* (`loadXterm`), not bundled with the Workspace chunk.
* Always painted dark, in the palette of the user's chosen DARK theme
* (`lib/terminalTheme.ts`), recoloured live without re-attaching.
* Files (images or any other) dropped on it are saved on the server
* Files (images or any other) dropped or pasted on it — Ctrl+V included — are saved on the server
* (`POST /api/run/drop-file`) and their PATHS pasted into the pane — what a
* local terminal does on a drop, and how `claude` attaches an image or reads
* a file. The pane also registers with
@@ -37,7 +37,7 @@
* here takes the lead back (`resize` with `drive: true`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useId, useMemo, useRef, useState, type DragEvent } from "react";
import { useEffect, useId, useMemo, useRef, useState, type ClipboardEvent, type DragEvent } from "react";
import { useTranslation } from "react-i18next";
import type { Terminal } from "@xterm/xterm";
import type { CanvasAddon } from "@xterm/addon-canvas";
@@ -184,11 +184,32 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
[]
);
const onDrop = async (e: DragEvent) => {
// Ctrl+V was pressed and its paste has not arrived yet (see the key handler).
const ctrlVRef = useRef(false);
const onDrop = (e: DragEvent) => {
if (!hasFiles(e)) return; // dragged text keeps xterm's own handling
e.preventDefault();
dragDepth.current = 0;
const files = Array.from(e.dataTransfer.files);
void attachFiles(Array.from(e.dataTransfer.files));
};
// A pasted image (a screenshot) goes the way of a drop: xterm only reads
// text/plain and would paste nothing. Capture phase, because xterm's own
// paste listener stops the event. A Ctrl+V with no file in it is the pane's
// ^V again (vim's block select, readline's quoted insert), not a text paste.
const onPaste = (e: ClipboardEvent) => {
const ctrlV = ctrlVRef.current;
ctrlVRef.current = false;
const files = Array.from(e.clipboardData?.files ?? []);
if (files.length === 0 && !ctrlV) return;
e.preventDefault();
e.stopPropagation();
if (files.length > 0) void attachFiles(files);
else termRef.current?.input("\x16", true);
};
const attachFiles = async (files: File[]) => {
if (files.length === 0) return;
setDrop("uploading");
try {
@@ -327,6 +348,18 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
requestAnimationFrame(() => findInput.current?.focus());
return false;
}
// Ctrl+V: xterm would send ^V, and claude would then read the
// SERVER's clipboard, never this browser's. Let the browser paste
// instead (onPaste); a browser that fires none still sends ^V.
if (ev.ctrlKey && !ev.altKey && !ev.metaKey && !ev.shiftKey && key === "v") {
ctrlVRef.current = true;
setTimeout(() => {
if (!ctrlVRef.current) return;
ctrlVRef.current = false;
term.input("\x16", true);
}, 0);
return false;
}
if (ev.key === "Tab" && tabMovesRef.current) return false;
if (ev.key === "Tab" && ev.shiftKey) ev.preventDefault();
return true;
@@ -486,6 +519,30 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
const reassert = takeLead;
term.textarea?.addEventListener("focus", reassert);
// Safari with macOS's own Vietnamese Telex types a word by REPLACING it:
// after the first letter every key fires `insertReplacementText` carrying
// the whole word ("x" → "xi" → "xin"), before its keydown. xterm forwards
// only `insertText`, and its keyCode-229 fallback snapshots the textarea
// after the change, so all but each word's first letter was lost (xterm.js
// #5887). Send the edit ourselves: a DEL per replaced char, then the tail.
let beforeReplace = "";
const onBeforeReplace = (e: Event) => {
if ((e as InputEvent).inputType === "insertReplacementText") {
beforeReplace = term.textarea?.value ?? "";
}
};
const onReplace = (e: Event) => {
if ((e as InputEvent).inputType !== "insertReplacementText") return;
const before = Array.from(beforeReplace);
const after = Array.from(term.textarea?.value ?? "");
let same = 0;
while (same < before.length && before[same] === after[same]) same++;
const edit = "\x7f".repeat(before.length - same) + after.slice(same).join("");
if (edit) term.input(edit, true);
};
term.textarea?.addEventListener("beforeinput", onBeforeReplace);
term.textarea?.addEventListener("input", onReplace);
const refit = () => {
// Never fit a collapsed console — see sendSize.
const el = containerRef.current;
@@ -514,6 +571,8 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
resizeObserver.disconnect();
document.fonts?.removeEventListener("loadingdone", onFontLoaded);
term.textarea?.removeEventListener("focus", reassert);
term.textarea?.removeEventListener("beforeinput", onBeforeReplace);
term.textarea?.removeEventListener("input", onReplace);
resizeDisposable.dispose();
dataDisposable.dispose();
ws?.close();
@@ -634,7 +693,8 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
dragDepth.current = Math.max(0, dragDepth.current - 1);
if (dragDepth.current === 0) setDrop((d) => (d === "hint" ? null : d));
}}
onDrop={(e) => void onDrop(e)}
onDrop={onDrop}
onPasteCapture={onPaste}
onFocus={() => notePaneFocus(runId)}
>
<div ref={containerRef} className="h-full w-full" data-testid="terminal-view" />
@@ -42,6 +42,10 @@ describe("ChatOutline", () => {
);
expect(screen.getByText("Parse plan").className).toContain("line-through");
expect(screen.getByText("Build report").className).not.toContain("line-through");
// How far the plan is, and which step is being worked on.
expect(screen.getByText("1/2")).toBeTruthy();
expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "1");
expect(screen.getByTitle("Build report")).toHaveAttribute("aria-current", "step");
fireEvent.click(screen.getByRole("button", { name: "XLSX: Progress report" }));
expect(onOpenFile).toHaveBeenCalledWith("/w/out/Progress report.xlsx");
@@ -14,20 +14,30 @@
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 { PaneChat, contextWindow } 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 }) => (
ConversationView: ({
onOpenFile,
onNotStarted,
}: {
onOpenFile?: (path: string) => void;
onNotStarted?: (notStarted: boolean) => 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")} />
{/* The session has no row yet: the CLI has not started it. */}
<button data-testid="not-started" onClick={() => onNotStarted?.(true)} />
</div>
),
}));
const confirmDialog = vi.hoisted(() => vi.fn(async () => false));
vi.mock("../../../lib/confirm", () => ({ confirmDialog }));
vi.mock("../../../lib/panePaste", () => ({
submitToRun: vi.fn(() => true),
interruptRun: vi.fn(() => true),
@@ -200,6 +210,44 @@ describe("PaneChat — Stop", () => {
});
});
describe("PaneChat — waiting on the user", () => {
it("marks a hidden tab while a permission prompt waits", async () => {
const vis = vi.spyOn(document, "visibilityState", "get").mockReturnValue("hidden");
document.title = "Workspace · AgentHub";
try {
vi.mocked(api.agents.list).mockResolvedValueOnce({
agents: [
{
type: "main",
status: "waiting",
awaiting_input_since: "2026-10-12T08:00:00.000Z",
awaiting_reason: "notification",
},
],
} as never);
const { unmount } = renderChat();
await waitFor(() => expect(document.title).toBe("● Waiting · Workspace · AgentHub"));
unmount();
expect(document.title).toBe("Workspace · AgentHub");
} finally {
vis.mockRestore();
}
});
});
describe("contextWindow", () => {
it("knows Claude's 200K line from the 1M one", () => {
expect(contextWindow("claude-opus-4-5-20251101")).toBe(200_000);
expect(contextWindow("claude-sonnet-4-20250514")).toBe(200_000);
expect(contextWindow("claude-3-7-sonnet-20250219")).toBe(200_000);
expect(contextWindow("claude-opus-4-6")).toBe(1_000_000);
expect(contextWindow("claude-opus-5-5")).toBe(1_000_000);
expect(contextWindow("claude-haiku-5-5")).toBe(1_000_000);
expect(contextWindow("gpt-5-codex")).toBeNull();
expect(contextWindow(null)).toBeNull();
});
});
describe("PaneChat — model, effort and permission mode", () => {
it("types /model into the pane", async () => {
renderChat();
@@ -333,4 +381,34 @@ describe("PaneChat — the unsent prompt", () => {
expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "half typed @src/a.ts");
await waitFor(() => expect(localStorage.getItem("agenthub.chat.draft.s1")).toBeNull());
});
// Issue #73: before the session starts the CLI may be asking in the terminal
// (trust this folder? — "No, exit" preselected); an Enter sent from here
// would answer it, so a send asks first.
it("asks before sending while the session has not started", async () => {
vi.mocked(submitToRun).mockClear();
confirmDialog.mockClear();
renderChat();
fireEvent.click(screen.getByTestId("not-started"));
const box = screen.getByPlaceholderText("Write a message…");
fireEvent.change(box, { target: { value: "hello" } });
confirmDialog.mockResolvedValueOnce(false);
fireEvent.click(screen.getByTestId("pane-chat-send"));
await waitFor(() => expect(confirmDialog).toHaveBeenCalledTimes(1));
expect(submitToRun).not.toHaveBeenCalled();
expect(box).toHaveValue("hello");
confirmDialog.mockResolvedValueOnce(true);
fireEvent.click(screen.getByTestId("pane-chat-send"));
await waitFor(() => expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "hello"));
});
it("sends without asking once the session has started", async () => {
vi.mocked(submitToRun).mockClear();
confirmDialog.mockClear();
renderChat();
fireEvent.change(screen.getByPlaceholderText("Write a message…"), { target: { value: "hi" } });
fireEvent.click(screen.getByTestId("pane-chat-send"));
await waitFor(() => expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "hi"));
expect(confirmDialog).not.toHaveBeenCalled();
});
});
@@ -2,7 +2,8 @@
* @file TerminalView.test.tsx
* @description Tests for the xterm.js-backed terminal view: verifies it opens
* a WS connection to the right URL, writes incoming binary frames to the
* mocked terminal, forwards typed input as outgoing binary frames, and
* mocked terminal, forwards typed input (Safari Telex word replacements
* included) as outgoing binary frames, and
* re-attaches after the socket drops (server restart) with backoff, but not
* after `exit` or a policy close,
* and recolours live on a dark-theme change without re-attaching.
@@ -21,6 +22,9 @@ const wheelHandlerMock = vi.fn();
const keyHandlerMock = vi.fn();
const focusMock = vi.fn();
const pasteMock = vi.fn();
const inputMock = vi.fn();
// The newest terminal's hidden input, where the browser's IME types.
let textarea: HTMLTextAreaElement;
const dropFileMock = vi.fn();
vi.mock("../../../lib/api", () => ({
api: { run: { dropFile: (f: File, lane?: string) => dropFileMock(f, lane) } },
@@ -69,6 +73,8 @@ vi.mock("@xterm/xterm", () => ({
attachCustomKeyEventHandler: keyHandlerMock,
focus: focusMock,
paste: pasteMock,
input: inputMock,
textarea: (textarea = document.createElement("textarea")),
resize: (cols: number, rows: number) => Object.assign(termState, { cols, rows }),
})),
}));
@@ -503,4 +509,71 @@ describe("TerminalView", () => {
)
);
});
it("uploads a pasted image and pastes its path, like a drop", async () => {
dropFileMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab-image.png" });
pasteMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
const img = new File([new Uint8Array([137, 80])], "image.png", { type: "image/png" });
fireEvent.paste(screen.getByTestId("terminal-view"), {
clipboardData: { files: [img], getData: () => "" },
});
await waitFor(() =>
expect(pasteMock).toHaveBeenCalledWith("/tmp/agenthub-drops-1/ab-image.png ")
);
expect(dropFileMock).toHaveBeenCalledWith(img, "r1");
});
// Ctrl+V is how claude pastes an image; xterm would send ^V, and claude then
// reads the SERVER's clipboard, not the browser's.
it("lets Ctrl+V reach the browser's paste, and still sends ^V when it holds no file", () => {
inputMock.mockClear();
pasteMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean;
expect(handler(new KeyboardEvent("keydown", { key: "v", ctrlKey: true }))).toBe(false);
fireEvent.paste(screen.getByTestId("terminal-view"), {
clipboardData: { files: [], getData: () => "some text" },
});
expect(inputMock).toHaveBeenCalledWith("\x16", true);
expect(pasteMock).not.toHaveBeenCalled();
});
it("sends ^V on Ctrl+V when the browser fires no paste at all", () => {
vi.useFakeTimers();
inputMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean;
handler(new KeyboardEvent("keydown", { key: "v", ctrlKey: true }));
vi.advanceTimersByTime(1);
expect(inputMock).toHaveBeenCalledTimes(1);
expect(inputMock).toHaveBeenCalledWith("\x16", true);
vi.useRealTimers();
});
// Safari + macOS's own Vietnamese Telex: the first letter is an insertText,
// every later key REPLACES the word — the event order captured on a Mac.
it("forwards Safari Telex's word replacements as backspaces plus the new tail", () => {
inputMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
const type = (value: string) => {
textarea.dispatchEvent(new InputEvent("beforeinput", { inputType: "insertReplacementText" }));
textarea.value = value;
textarea.dispatchEvent(new InputEvent("input", { inputType: "insertReplacementText" }));
};
textarea.value = "x"; // xterm itself sent this one (insertText)
type("xi");
type("xin");
textarea.value = "xin vie";
type("xin viet");
type("xin viêt");
type("xin việt");
expect(inputMock.mock.calls).toEqual([
["i", true],
["n", true],
["t", true],
["\x7f\x7fêt", true],
["\x7f\x7fệt", true],
]);
});
});