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:
@@ -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" />
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user