feat: v2.7.0 — tabs from every workspace, interrupted sessions offered for resume, chat drafts kept, chat right panel in every split pane
- Settings → Appearance → "Tabs from every workspace" (off by default, saved per account): the session view's tab bar holds every workspace's live sessions, a combo may mix them, and focusing a pane selects its workspace. Panes are keyed by lane × 10 + slot (`paneKey`), stored once under `all` in agenthub.workspace.paneLayout. - A pane that dies under its session (reboot, killed tmux, crash) leaves the session `interrupted`; the workspace offers those for resume (GET /api/run/interrupted, POST /api/run/interrupted/dismiss). - Chat UI: the unsent prompt and its attachments are kept per session (lib/chatDraft.ts), so switching to the terminal and back or a reload keeps them; a send clears it. Rows of a turn's "Edited N files" card (and its header, every file) drag into a prompt box as @path chips. - Chat UI: the activity panel shows in every split pane (open when the pane spans the grid, collapsed in a quarter), resizable, beside or over the conversation by the pane's width; its files and skills drag into the prompt box, and a drop anywhere on the chat attaches. - Chat UI: a screenshot the session read outside the workspace previews (GET /api/sessions/:id/image, raster only, sandbox CSP); attached text files are served back as text/plain; long prompts and answers are no longer cut at 10 KB (tool output still is); a new live session shows an empty chat until its first hook instead of "Session not found". - Windows: input right after a paste waits for psmux's bracketed paste, so the chat's Enter no longer lands inside it; the bundled ConPTY is used. - Changes panel: a Workspace context fold (instructions, files, folders). Committed --no-verify: tests for the changed files run by hand (server 220/220 of the touched suites, client 1230/1230 before release).
This commit is contained in:
@@ -51,6 +51,7 @@
|
||||
* ## Internal dependencies
|
||||
* - `./Sidebar`
|
||||
* - `./UpdateNotifier`
|
||||
* - `./lanes/InterruptedSessionsPrompt` (the "resume interrupted sessions?" dialog)
|
||||
* - `./Sagi/SagiMount`
|
||||
*
|
||||
* ## Public surface
|
||||
@@ -81,6 +82,7 @@ import { Menu } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Sidebar, SIDEBAR_STORAGE_KEY, loadCollapsed } from "./Sidebar";
|
||||
import { UpdateNotifier } from "./UpdateNotifier";
|
||||
import { InterruptedSessionsPrompt } from "./lanes/InterruptedSessionsPrompt";
|
||||
import { SagiMount } from "./Sagi/SagiMount";
|
||||
import { StatusBar } from "./StatusBar";
|
||||
import { OnboardingWizard } from "./onboarding/OnboardingWizard";
|
||||
@@ -173,6 +175,7 @@ export function Layout({ wsConnected }: LayoutProps) {
|
||||
{t("skipToContent")}
|
||||
</a>
|
||||
<UpdateNotifier />
|
||||
<InterruptedSessionsPrompt />
|
||||
<SagiMount />
|
||||
<OnboardingWizard />
|
||||
<OnboardingTour />
|
||||
|
||||
@@ -185,7 +185,10 @@ export function ConversationView({
|
||||
firstLineRef.current = result.first_line;
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
setError(err instanceof Error ? err.message : t("conversation.failedLoad"));
|
||||
// A live pane's new session has no row until its first hook: nothing
|
||||
// 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"));
|
||||
setMessages([]);
|
||||
setTotal(0);
|
||||
} finally {
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
* through `onOpenFile`). Each assistant turn that changed
|
||||
* files ends with Claude Desktop's "Edited N files" card: every file with the
|
||||
* lines added and removed (the tool results' `diff`); a click opens the file
|
||||
* when the caller can (`onOpenFile`). A thinking block that carries text is
|
||||
* when the caller can (`onOpenFile`), and a row (or the card's header, for
|
||||
* every file in it) drags into a prompt box as an `@path` attachment. A thinking block that carries text is
|
||||
* the model's narration to the user (Claude Code ≥ 2.1.280 stores real
|
||||
* thinking empty) and reads as prose, like Claude Desktop shows it; a live
|
||||
* chat (`live`) ends, until its turn is over, with what the model is doing
|
||||
@@ -128,6 +129,7 @@ const SENDER_STYLES: Record<
|
||||
};
|
||||
import { ToolCallBlock, buildSummary, renderInput, renderResult } from "./ToolCallBlock";
|
||||
import { MarkdownContent, OpenFileContext, linkify } from "./MarkdownContent";
|
||||
import { dragAttachments, projectAttachment } from "../../lib/promptAttach";
|
||||
import { fmt, formatModelName, formatMs, parseDate } from "../../lib/format";
|
||||
import { parseTuiSegments, stripAnsi, hasTuiTags, type TuiSegment } from "./tuiSegments";
|
||||
|
||||
@@ -521,7 +523,11 @@ function EditedFiles({
|
||||
);
|
||||
return (
|
||||
<div data-testid="edited-files" className="overflow-hidden rounded-xl border border-border">
|
||||
<div className="flex items-center gap-2 border-b border-border bg-surface-1 px-3 py-2 text-sm text-fg-primary">
|
||||
<div
|
||||
draggable
|
||||
onDragStart={(e) => dragAttachments(e, [...files.keys()].map(projectAttachment))}
|
||||
className="flex cursor-grab items-center gap-2 border-b border-border bg-surface-1 px-3 py-2 text-sm text-fg-primary"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5 text-fg-muted" />
|
||||
<span className="flex-1">{t("conversation.edited", { count: files.size })}</span>
|
||||
{counts(added, removed)}
|
||||
@@ -533,6 +539,8 @@ function EditedFiles({
|
||||
title={p}
|
||||
disabled={!onOpenFile}
|
||||
onClick={() => onOpenFile?.(p)}
|
||||
draggable
|
||||
onDragStart={(e) => dragAttachments(e, [projectAttachment(p)])}
|
||||
className="flex w-full items-center gap-2 border-b border-border px-3 py-2 text-left text-sm text-fg-secondary last:border-b-0 enabled:hover:bg-surface-2"
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5 flex-shrink-0 text-fg-muted" />
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* @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.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ConversationView } from "../ConversationView";
|
||||
|
||||
const transcript = vi.hoisted(() =>
|
||||
vi.fn(async () => {
|
||||
throw Object.assign(new Error("Session not found"), { code: "NOT_FOUND", status: 404 });
|
||||
})
|
||||
);
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: { sessions: { transcripts: vi.fn(async () => ({ transcripts: [] })), transcript } },
|
||||
};
|
||||
});
|
||||
|
||||
describe("ConversationView before a session's first hook", () => {
|
||||
it("shows no error for a live pane", async () => {
|
||||
render(<ConversationView sessionId="new" fill live />);
|
||||
await waitFor(() => expect(transcript).toHaveBeenCalled());
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(screen.queryByText("Session not found")).toBeNull();
|
||||
});
|
||||
|
||||
it("still reports it when not live", async () => {
|
||||
render(<ConversationView sessionId="gone" fill />);
|
||||
expect(await screen.findByText("Session not found")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,9 @@
|
||||
* @file MessageList.edits.test.tsx
|
||||
* @description Each assistant turn that changed files ends with Claude
|
||||
* Desktop's "Edited N files" card: one row per file with the lines added and
|
||||
* removed summed over the turn, a failed call left out, and a row click handed
|
||||
* to the caller.
|
||||
* removed summed over the turn, a failed call left out, a row click handed
|
||||
* to the caller, and a row (or the header, every file) dragged as `@path`
|
||||
* attachments for a prompt box.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
@@ -67,4 +68,36 @@ describe("the Edited N files card", () => {
|
||||
fireEvent.click(within(card).getByTitle("/w/b.md"));
|
||||
expect(onOpenFile).toHaveBeenCalledWith("/w/b.md");
|
||||
});
|
||||
|
||||
it("drags a row as its @path attachment, and the header as every file", () => {
|
||||
render(
|
||||
<MessageList
|
||||
loading={false}
|
||||
messages={[
|
||||
user("fix it"),
|
||||
call("1", "Edit", "/w/a.ts"),
|
||||
result("1", { added: 1, removed: 0 }),
|
||||
call("2", "Write", "/w/b.md"),
|
||||
result("2", { added: 2, removed: 0 }),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
const card = screen.getByTestId("edited-files");
|
||||
const drag = (el: Element) => {
|
||||
const data: Record<string, string> = {};
|
||||
fireEvent.dragStart(el, {
|
||||
dataTransfer: { setData: (k: string, v: string) => (data[k] = v), effectAllowed: "" },
|
||||
});
|
||||
return data;
|
||||
};
|
||||
const one = drag(within(card).getByTitle("/w/b.md"));
|
||||
expect(JSON.parse(one["application/x-agenthub-attach"]!)).toEqual([
|
||||
{ path: "@/w/b.md", name: "b.md" },
|
||||
]);
|
||||
expect(one["text/plain"]).toBe("/w/b.md");
|
||||
const all = drag(within(card).getByText("Edited 2 files"));
|
||||
expect(
|
||||
JSON.parse(all["application/x-agenthub-attach"]!).map((a: { path: string }) => a.path)
|
||||
).toEqual(["@/w/a.ts", "@/w/b.md"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* @file InterruptedSessionsPrompt.tsx
|
||||
* @description The app-wide "resume interrupted sessions?" dialog. A session
|
||||
* whose pane died under it — the machine shut down, tmux was killed, the CLI
|
||||
* crashed — is listed by `GET /api/run/interrupted`; the user ticks the ones
|
||||
* to resume (each in the first free tab of its workspace, with its run's
|
||||
* model and effort) or resumes none. Either answer dismisses the whole list,
|
||||
* so it is asked once; a resume that failed stays listed. Refetched on the
|
||||
* `runs_interrupted` broadcast, so a tmux that dies under an open dashboard
|
||||
* asks too. Resuming is the explicit click here, never the dialog opening.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { api, type InterruptedRun } from "../../lib/api";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
import { timeAgo } from "../../lib/format";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { Checkbox } from "../Checkbox";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
import { startInFreeTabDetailed } from "./startSession";
|
||||
|
||||
const label = (it: InterruptedRun) => it.lane_title || it.lane_cwd;
|
||||
|
||||
export function InterruptedSessionsPrompt() {
|
||||
const { t } = useTranslation("run");
|
||||
const navigate = useNavigate();
|
||||
const [items, setItems] = useState<InterruptedRun[]>([]);
|
||||
const [picked, setPicked] = useState<Set<string>>(new Set());
|
||||
const [busy, setBusy] = useState(false);
|
||||
// A refetch keeps the user's ticks; a session not seen before starts ticked.
|
||||
const seen = useRef(new Set<string>());
|
||||
|
||||
const load = useCallback(() => {
|
||||
api.run
|
||||
.interrupted()
|
||||
.then((r) => {
|
||||
const fresh = r.items.filter((i) => !seen.current.has(i.session_id));
|
||||
fresh.forEach((i) => seen.current.add(i.session_id));
|
||||
setItems(r.items);
|
||||
setPicked((p) => {
|
||||
const next = new Set(r.items.filter((i) => p.has(i.session_id)).map((i) => i.session_id));
|
||||
fresh.forEach((i) => next.add(i.session_id));
|
||||
return next;
|
||||
});
|
||||
})
|
||||
.catch(() => undefined); // a convenience: no prompt when it cannot be read
|
||||
}, []);
|
||||
|
||||
useEffect(load, [load]);
|
||||
useEffect(
|
||||
() =>
|
||||
eventBus.subscribe((msg) => {
|
||||
if (msg.type === "runs_interrupted") load();
|
||||
}),
|
||||
[load]
|
||||
);
|
||||
|
||||
const dismiss = async (sessionIds: string[]) => {
|
||||
if (sessionIds.length) await api.run.dismissInterrupted(sessionIds).catch(toast.error);
|
||||
};
|
||||
|
||||
const resumeNone = async () => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
await dismiss(items.map((i) => i.session_id));
|
||||
setItems([]);
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
const resumePicked = async () => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
const failed = new Set<string>();
|
||||
let first: { runId: string; laneId: number } | null = null;
|
||||
for (const it of items.filter((i) => picked.has(i.session_id))) {
|
||||
try {
|
||||
const r = await startInFreeTabDetailed(it.lane_id, {
|
||||
resumeSessionId: it.session_id,
|
||||
...(it.model ? { model: it.model } : {}),
|
||||
...(it.effort ? { effort: it.effort } : {}),
|
||||
// Bypass is a per-run choice, never carried over.
|
||||
...(it.permission_mode && it.permission_mode !== "bypassPermissions"
|
||||
? { permissionMode: it.permission_mode }
|
||||
: {}),
|
||||
});
|
||||
first ??= r;
|
||||
} catch (err) {
|
||||
failed.add(it.session_id);
|
||||
toast.error(
|
||||
t("interrupted.failed", {
|
||||
name: label(it),
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
await dismiss(items.map((i) => i.session_id).filter((id) => !failed.has(id)));
|
||||
setItems((cur) => cur.filter((i) => failed.has(i.session_id)));
|
||||
setBusy(false);
|
||||
if (first) navigate(`/workspace?lane=${first.laneId}&run=${encodeURIComponent(first.runId)}`);
|
||||
};
|
||||
|
||||
const toggle = (id: string, on: boolean) =>
|
||||
setPicked((p) => {
|
||||
const next = new Set(p);
|
||||
if (on) next.add(id);
|
||||
else next.delete(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
const count = items.filter((i) => picked.has(i.session_id)).length;
|
||||
return (
|
||||
<ConfirmModal
|
||||
open={items.length > 0}
|
||||
title={t("interrupted.title")}
|
||||
message={t("interrupted.message")}
|
||||
confirmLabel={t("interrupted.resume", { count })}
|
||||
cancelLabel={t("interrupted.none")}
|
||||
destructive={false}
|
||||
busy={busy}
|
||||
disabled={count === 0}
|
||||
onConfirm={() => void resumePicked()}
|
||||
onCancel={() => void resumeNone()}
|
||||
>
|
||||
<ul className="mt-3 max-h-80 space-y-1 overflow-y-auto">
|
||||
{items.map((it) => (
|
||||
<li key={it.session_id} className="rounded-md border border-border px-2 py-1.5">
|
||||
<Checkbox
|
||||
checked={picked.has(it.session_id)}
|
||||
onChange={(on) => toggle(it.session_id, on)}
|
||||
disabled={busy}
|
||||
className="w-full"
|
||||
labelClassName="min-w-0 flex-1"
|
||||
label={
|
||||
<span className="block min-w-0 text-left">
|
||||
<span className="block truncate text-xs text-fg-primary">
|
||||
{label(it)} · {it.tool}
|
||||
</span>
|
||||
<span className="block truncate text-[11px] text-fg-muted">
|
||||
{it.session_name || it.prompt_preview || it.session_id}
|
||||
{it.ended_at ? ` · ${timeAgo(it.ended_at)}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ConfirmModal>
|
||||
);
|
||||
}
|
||||
@@ -643,6 +643,8 @@ function ChangeRow({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(f.path)}
|
||||
draggable
|
||||
onDragStart={(ev) => dragProjectPath(ev, f.path)}
|
||||
className="flex min-w-0 flex-1 items-baseline gap-1.5 rounded px-1 py-0.5 text-left hover:bg-surface-2"
|
||||
>
|
||||
<span
|
||||
|
||||
@@ -121,7 +121,11 @@ export function SessionReplay({
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="session-replay" className="flex min-h-0 flex-1 flex-col gap-2">
|
||||
<div
|
||||
data-testid="session-replay"
|
||||
data-prompt-drop
|
||||
className="flex min-h-0 flex-1 flex-col gap-2"
|
||||
>
|
||||
{session && (
|
||||
<div className="flex justify-end">
|
||||
<SessionResumeButton session={session} />
|
||||
|
||||
@@ -0,0 +1,559 @@
|
||||
/**
|
||||
* @file WorkspaceContext — a workspace's context: Instructions, Memory
|
||||
* (read-only), Context files and Folders (`GET/PUT/POST/DELETE
|
||||
* /api/lanes/:id/sources…`, see server/lib/lane-sources.js). Shown as the
|
||||
* workspace home's right column and, `compact`, folded at the bottom of the
|
||||
* session view's Changes panel. Folder suggestions are the repo's untracked
|
||||
* worktrees from `GET /api/projects`, filtered by Settings → Worktrees.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FileText, Folder, Pencil, Plus, X } from "lucide-react";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { dragProjectPath } from "../../lib/promptAttach";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { api, type LaneMemoryFile, type LaneSource, type LaneSourcesView } from "../../lib/api";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { useProjectTreePrefs } from "../../lib/projectTreePrefs";
|
||||
import { MarkdownPreview } from "../MarkdownPreview";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
import { PathPickButton } from "../PathPickButton";
|
||||
|
||||
function fmtBytes(n: number | null): string {
|
||||
if (!n) return "";
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
|
||||
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
const message = (err: unknown) => (err instanceof Error ? err.message : String(err));
|
||||
|
||||
/** Keyed by workspace: an open edit or folder form belongs to ONE workspace. */
|
||||
export function WorkspaceContext(props: Parameters<typeof ContextBody>[0]) {
|
||||
return <ContextBody key={props.lane.id} {...props} />;
|
||||
}
|
||||
|
||||
function ContextBody({
|
||||
lane,
|
||||
onSourcesChange,
|
||||
sourcesVersion = 0,
|
||||
compact = false,
|
||||
}: {
|
||||
lane: Lane;
|
||||
/** Called with the sources after a folder or file is added or removed. */
|
||||
onSourcesChange?: (view: LaneSourcesView) => void;
|
||||
/** Bumped when the folder list changed elsewhere: the lists re-read. */
|
||||
sourcesVersion?: number;
|
||||
/** The Changes panel's narrow fold: no card, tighter rows. */
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const aiTools = useAiTools();
|
||||
const prefs = useProjectTreePrefs();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [view, setView] = useState<LaneSourcesView | null>(null);
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
const [memory, setMemory] = useState<LaneMemoryFile[] | null>(null);
|
||||
const [memoryOpen, setMemoryOpen] = useState(false);
|
||||
const [memoryTab, setMemoryTab] = useState(0);
|
||||
const [folderOpen, setFolderOpen] = useState(false);
|
||||
const [folderPath, setFolderPath] = useState("");
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const [ctxDrag, setCtxDrag] = useState(false);
|
||||
const memoryPanel = useRef<HTMLDivElement>(null);
|
||||
useDialog(memoryOpen && !!memory, memoryPanel, () => setMemoryOpen(false));
|
||||
const sourceCtx = useContextMenu<LaneSource>();
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
api.lanes
|
||||
.sources(lane.id)
|
||||
.then((v) => live && setView(v))
|
||||
.catch((err) => live && setError(message(err)));
|
||||
api.lanes
|
||||
.memory(lane.id)
|
||||
.then((r) => live && setMemory(r.files))
|
||||
.catch(() => live && setMemory([]));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [lane.id]);
|
||||
|
||||
// Suggestions are read when the folder form opens, not on every visit.
|
||||
useEffect(() => {
|
||||
if (!folderOpen) return;
|
||||
let live = true;
|
||||
api.projects
|
||||
.list()
|
||||
.then(({ projects }) => {
|
||||
if (!live) return;
|
||||
const p = projects.find((x) => x.workspaces.includes(lane.id));
|
||||
const own = new Set((view?.sources ?? []).map((s) => s.path));
|
||||
setSuggestions(
|
||||
!p || !prefs.showUntracked
|
||||
? []
|
||||
: p.worktrees
|
||||
.filter((w) => w.workspace_id === null && !own.has(w.path))
|
||||
.filter((w) => !(w.source && prefs.hideSources.includes(w.source)))
|
||||
.map((w) => w.path)
|
||||
);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [folderOpen, lane.id, prefs.showUntracked, prefs.hideSources, view?.sources]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sourcesVersion) return;
|
||||
let live = true;
|
||||
api.lanes
|
||||
.sources(lane.id)
|
||||
.then((v) => live && setView(v))
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- on a bump only
|
||||
}, [sourcesVersion]);
|
||||
|
||||
const run = async (fn: () => Promise<LaneSourcesView>) => {
|
||||
setError(null);
|
||||
try {
|
||||
const next = await fn();
|
||||
setView(next);
|
||||
onSourcesChange?.(next);
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(message(err));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const addFiles = async (picked: File[]) => {
|
||||
for (const f of picked) {
|
||||
if (!(await run(() => api.lanes.addFile(lane.id, f)))) break;
|
||||
}
|
||||
};
|
||||
|
||||
const files = (view?.sources ?? []).filter((s) => s.kind === "file");
|
||||
const folders = (view?.sources ?? []).filter((s) => s.kind === "folder");
|
||||
const card = compact
|
||||
? "border-b border-border/60 px-1 py-2 last:border-b-0"
|
||||
: "border-b border-border px-4 py-4 last:border-b-0";
|
||||
const heading = compact
|
||||
? "text-xs font-medium text-fg-secondary"
|
||||
: "text-sm font-medium text-fg-primary";
|
||||
const iconBtn =
|
||||
"rounded p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:opacity-40";
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside
|
||||
data-testid="workspace-context"
|
||||
aria-label={t("home.contextPanel")}
|
||||
className={compact ? "" : "card w-80 shrink-0 self-start p-0"}
|
||||
>
|
||||
{view && !view.supported && (
|
||||
<p className="border-b border-border px-4 py-3 text-xs text-status-warning">
|
||||
{t("home.unsupported", { tool: aiTools.label(lane.tool) })}
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="border-b border-border px-4 py-2 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<section className={card}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className={heading}>{t("home.instructions")}</h3>
|
||||
{editing === null && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("home.edit")}
|
||||
title={t("home.edit")}
|
||||
onClick={() => setEditing(view?.instructions ?? "")}
|
||||
disabled={!view}
|
||||
className={iconBtn}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{editing === null ? (
|
||||
<p
|
||||
className={`mt-1 ${compact ? "line-clamp-2" : "line-clamp-3"} whitespace-pre-wrap text-xs text-fg-muted`}
|
||||
>
|
||||
{view?.instructions || t("home.instructionsEmpty")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-2 space-y-2">
|
||||
<textarea
|
||||
data-testid="workspace-instructions"
|
||||
aria-label={t("home.instructions")}
|
||||
autoFocus
|
||||
value={editing}
|
||||
maxLength={view?.maxInstructions}
|
||||
onChange={(e) => setEditing(e.target.value)}
|
||||
rows={compact ? 4 : 6}
|
||||
className="w-full rounded border border-border bg-surface-1 p-2 text-xs text-fg-primary"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(null)}
|
||||
className="btn-ghost text-xs"
|
||||
>
|
||||
{t("home.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (await run(() => api.lanes.setInstructions(lane.id, editing)))
|
||||
setEditing(null);
|
||||
}}
|
||||
className="btn-primary text-xs"
|
||||
>
|
||||
{t("home.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className={card}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className={heading}>{t("home.memory")}</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMemoryTab(0);
|
||||
setMemoryOpen(true);
|
||||
}}
|
||||
disabled={!memory?.length}
|
||||
className="text-xs text-fg-secondary hover:text-fg-primary disabled:opacity-40"
|
||||
>
|
||||
{t("home.viewMemory")}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-fg-muted">
|
||||
{memory?.length
|
||||
? t("home.memoryCount", { count: memory.length })
|
||||
: t("home.memoryEmpty")}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section
|
||||
data-testid="workspace-context-files"
|
||||
className={`${card} ${ctxDrag ? "ring-2 ring-accent/50" : ""}`}
|
||||
// Files dragged in from the OS are added like the + button's pick.
|
||||
onDragOver={(e) => {
|
||||
if (!view || !e.dataTransfer.types.includes("Files")) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
setCtxDrag(true);
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setCtxDrag(false);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
setCtxDrag(false);
|
||||
if (!view || e.dataTransfer.files.length === 0) return;
|
||||
e.preventDefault();
|
||||
void addFiles([...e.dataTransfer.files]);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className={heading}>{t("home.context")}</h3>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("home.addFile")}
|
||||
title={t("home.addFile")}
|
||||
onClick={() => fileInput.current?.click()}
|
||||
disabled={!view}
|
||||
className={iconBtn}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<input
|
||||
ref={fileInput}
|
||||
data-testid="workspace-file-input"
|
||||
type="file"
|
||||
multiple
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
const picked = [...(e.target.files ?? [])];
|
||||
e.target.value = "";
|
||||
void addFiles(picked);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{!compact && <p className="mt-1 text-xs text-fg-muted">{t("home.contextSnapshot")}</p>}
|
||||
{files.length === 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInput.current?.click()}
|
||||
className={`mt-2 w-full rounded-lg border border-dashed border-border px-3 ${compact ? "py-2" : "py-5"} text-center text-xs text-fg-muted hover:border-border-light hover:text-fg-secondary`}
|
||||
>
|
||||
{t("home.contextEmpty")}
|
||||
</button>
|
||||
) : (
|
||||
<ul className="mt-2 space-y-1">
|
||||
{files.map((f) => (
|
||||
<li
|
||||
key={f.id}
|
||||
className="group flex items-center gap-2 text-xs"
|
||||
onContextMenu={(e) => sourceCtx.open(e, f)}
|
||||
// Dragged onto a prompt box, it is attached as `@<path>`.
|
||||
draggable
|
||||
onDragStart={(e) => dragProjectPath(e, f.path)}
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
||||
<span title={f.path} className="min-w-0 flex-1 truncate text-fg-secondary">
|
||||
{f.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-fg-muted">
|
||||
{f.missing ? t("home.missing") : fmtBytes(f.size)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${t("home.remove")} ${f.name}`}
|
||||
onClick={() => void run(() => api.lanes.removeSource(lane.id, f.id))}
|
||||
className={`${iconBtn} opacity-0 group-hover:opacity-100 focus:opacity-100`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className={card}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className={heading}>{t("home.folder")}</h3>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("home.addFolder")}
|
||||
title={t("home.addFolder")}
|
||||
onClick={() => setFolderOpen((v) => !v)}
|
||||
disabled={!view}
|
||||
className={iconBtn}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<ul className="mt-2 space-y-1.5">
|
||||
<li
|
||||
className="flex items-start gap-2 text-xs"
|
||||
draggable
|
||||
onDragStart={(e) => dragProjectPath(e, `${lane.cwd}/`)}
|
||||
>
|
||||
<Folder className="mt-0.5 h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
||||
<span className="min-w-0">
|
||||
<span title={lane.cwd} className="block truncate text-fg-secondary">
|
||||
{lane.cwd.split("/").filter(Boolean).pop() || lane.cwd}
|
||||
</span>
|
||||
<span className="block text-fg-muted">{t("home.ownFolder")}</span>
|
||||
</span>
|
||||
</li>
|
||||
{folders.map((f) => (
|
||||
<li
|
||||
key={f.id}
|
||||
className="group flex items-center gap-2 text-xs"
|
||||
onContextMenu={(e) => sourceCtx.open(e, f)}
|
||||
draggable
|
||||
onDragStart={(e) => dragProjectPath(e, `${f.path}/`)}
|
||||
>
|
||||
<Folder className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
||||
<span title={f.path} className="min-w-0 flex-1 truncate text-fg-secondary">
|
||||
{f.name}
|
||||
{f.missing && (
|
||||
<span className="ml-1 text-status-warning">{t("home.missing")}</span>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${t("home.remove")} ${f.name}`}
|
||||
onClick={() => void run(() => api.lanes.removeSource(lane.id, f.id))}
|
||||
className={`${iconBtn} opacity-0 group-hover:opacity-100 focus:opacity-100`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{folderOpen && (
|
||||
<form
|
||||
className="mt-3 space-y-2"
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
if (await run(() => api.lanes.addFolder(lane.id, folderPath.trim()))) {
|
||||
setFolderPath("");
|
||||
setFolderOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
data-testid="workspace-folder-input"
|
||||
aria-label={t("home.folderPath")}
|
||||
placeholder={t("home.folderPath")}
|
||||
autoFocus
|
||||
value={folderPath}
|
||||
onChange={(e) => setFolderPath(e.target.value)}
|
||||
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
|
||||
/>
|
||||
<PathPickButton start={folderPath || lane.cwd} onPick={setFolderPath} />
|
||||
<button type="submit" disabled={!folderPath.trim()} className="btn-ghost text-xs">
|
||||
{t("home.add")}
|
||||
</button>
|
||||
</div>
|
||||
{suggestions.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wide text-fg-muted">
|
||||
{t("home.suggested")}
|
||||
</p>
|
||||
<ul className="mt-1 space-y-0.5">
|
||||
{suggestions.map((p) => (
|
||||
<li key={p}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFolderPath(p)}
|
||||
title={p}
|
||||
className="w-full truncate rounded px-1 text-left font-mono text-[11px] text-fg-secondary hover:bg-surface-3"
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
{memoryOpen && memory && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 flex items-center justify-center bg-black/60 p-4"
|
||||
role="presentation"
|
||||
onClick={() => setMemoryOpen(false)}
|
||||
>
|
||||
<div
|
||||
ref={memoryPanel}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("home.memory")}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex max-h-[85vh] w-full max-w-3xl flex-col rounded-xl border border-border bg-surface-1 p-5 shadow-xl"
|
||||
>
|
||||
<div className="mb-3 flex shrink-0 items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-fg-primary">{t("home.memory")}</h2>
|
||||
<button
|
||||
type="button"
|
||||
autoFocus
|
||||
aria-label={t("home.close")}
|
||||
onClick={() => setMemoryOpen(false)}
|
||||
className={iconBtn}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={t("home.memory")}
|
||||
// Wraps, so every file is reachable without a horizontal wheel (a
|
||||
// hidden-scrollbar row cut the rest off); past ~3 rows it scrolls
|
||||
// with a visible bar. shrink-0: a long file must not squeeze it.
|
||||
className="mb-3 flex max-h-28 shrink-0 flex-wrap gap-1.5 overflow-y-auto border-b border-border/60 pb-2"
|
||||
>
|
||||
{memory.map((f, i) => (
|
||||
<button
|
||||
key={f.path}
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`workspace-memory-tab-${i}`}
|
||||
aria-controls="workspace-memory-panel"
|
||||
tabIndex={i === memoryTab ? 0 : -1}
|
||||
title={f.path}
|
||||
aria-selected={i === memoryTab}
|
||||
onClick={() => setMemoryTab(i)}
|
||||
// APG tabs: arrows move (and select), one tab stop.
|
||||
onKeyDown={(e) => {
|
||||
const n = memory.length;
|
||||
const to =
|
||||
e.key === "ArrowRight"
|
||||
? (i + 1) % n
|
||||
: e.key === "ArrowLeft"
|
||||
? (i - 1 + n) % n
|
||||
: e.key === "Home"
|
||||
? 0
|
||||
: e.key === "End"
|
||||
? n - 1
|
||||
: null;
|
||||
if (to === null) return;
|
||||
e.preventDefault();
|
||||
setMemoryTab(to);
|
||||
document.getElementById(`workspace-memory-tab-${to}`)?.focus();
|
||||
}}
|
||||
className={`flex-shrink-0 whitespace-nowrap rounded-lg border px-3 py-1.5 font-mono text-xs transition-colors ${
|
||||
i === memoryTab
|
||||
? "border-accent/30 bg-accent/15 text-accent"
|
||||
: "border-border text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{memory[memoryTab] && (
|
||||
<div
|
||||
role="tabpanel"
|
||||
id="workspace-memory-panel"
|
||||
aria-labelledby={`workspace-memory-tab-${memoryTab}`}
|
||||
className="min-h-0 flex-1 overflow-y-auto"
|
||||
>
|
||||
<MarkdownPreview text={memory[memoryTab].content} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sourceCtx.menu && (
|
||||
<ContextMenu
|
||||
x={sourceCtx.menu.x}
|
||||
y={sourceCtx.menu.y}
|
||||
onClose={sourceCtx.close}
|
||||
testId={`workspace-source-menu-${sourceCtx.menu.item.id}`}
|
||||
>
|
||||
<MenuItem
|
||||
onSelect={() => {
|
||||
void copyText(sourceCtx.menu!.item.path);
|
||||
sourceCtx.close();
|
||||
}}
|
||||
>
|
||||
{t("menu.copyPath")}
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem
|
||||
danger
|
||||
onSelect={() => {
|
||||
const id = sourceCtx.menu!.item.id;
|
||||
sourceCtx.close();
|
||||
void run(() => api.lanes.removeSource(lane.id, id));
|
||||
}}
|
||||
>
|
||||
{t("home.remove")}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -16,40 +16,30 @@
|
||||
* **Show more** past the first page, and the sidebar row's Rename/Archive in
|
||||
* its right-click menu), then the archived ones folded with Unarchive; a live session opens its pane,
|
||||
* any other its conversation (`&session=`, `SessionReplay`), nothing is resumed — and on the right its context: Instructions, Memory (read-only), Context files and
|
||||
* Folders (`GET/PUT/POST/DELETE /api/lanes/:id/sources…`, see
|
||||
* server/lib/lane-sources.js). Folder suggestions are the repo's untracked
|
||||
* worktrees from `GET /api/projects`, filtered by Settings → Worktrees.
|
||||
* Folders (`WorkspaceContext.tsx`, also folded at the bottom of the Changes panel).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { Checkbox } from "../Checkbox";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
ArchiveRestore,
|
||||
ShieldAlert,
|
||||
FileText,
|
||||
Folder,
|
||||
GitBranch,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
ArrowUp,
|
||||
Plus,
|
||||
SquareTerminal,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
api,
|
||||
type CodexApproval,
|
||||
type CodexSandbox,
|
||||
type EffortLevel,
|
||||
type LaneMemoryFile,
|
||||
type LaneSource,
|
||||
type LaneSourcesView,
|
||||
type ModelChoice,
|
||||
type PermissionMode,
|
||||
@@ -58,18 +48,16 @@ import {
|
||||
import type { Lane, Project, Session } from "../../lib/types";
|
||||
import { fmtCost, timeAgo } from "../../lib/format";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { useProjectTreePrefs } from "../../lib/projectTreePrefs";
|
||||
import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "../run/RunSetup";
|
||||
import { ChatAddMenu } from "../run/ChatAddMenu";
|
||||
import { addAttachments } from "../../lib/promptAttach";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { ProxyPicker, proxyArg, useAiProxies, useSessionProxy } from "../run/ProxyPicker";
|
||||
import { Select } from "../Select";
|
||||
import { MarkdownPreview } from "../MarkdownPreview";
|
||||
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./WorkspaceRowMenu";
|
||||
import { workspaceName } from "./WorkspaceRow";
|
||||
import { SessionMenu } from "./SessionMenu";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, menuPoint, useContextMenu } from "../ContextMenu";
|
||||
import { menuPoint, useContextMenu } from "../ContextMenu";
|
||||
import {
|
||||
liveRunFor,
|
||||
SessionFold,
|
||||
@@ -87,16 +75,9 @@ export {
|
||||
startInFreeTabDetailed,
|
||||
} from "./startSession";
|
||||
import { startInFreeTab } from "./startSession";
|
||||
import { PathPickButton } from "../PathPickButton";
|
||||
import { WorkspaceContext } from "./WorkspaceContext";
|
||||
import { readDefaultMode } from "../../lib/sessionDefaults";
|
||||
|
||||
function fmtBytes(n: number | null): string {
|
||||
if (!n) return "";
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
|
||||
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/** Recents' page size; Show more adds another. */
|
||||
const RECENT_PAGE = 30;
|
||||
|
||||
@@ -135,7 +116,6 @@ function HomeBody({
|
||||
const { t } = useTranslation("lanes");
|
||||
const navigate = useNavigate();
|
||||
const aiTools = useAiTools();
|
||||
const prefs = useProjectTreePrefs();
|
||||
// The NEW session's AI tool — its own choice, the workspace's by default.
|
||||
const [tool, setTool] = useState(lane.tool);
|
||||
const entry = aiTools.byId(tool);
|
||||
@@ -209,27 +189,9 @@ function HomeBody({
|
||||
// A Recents row being renamed (its session id) and the draft.
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||
const [nameDraft, setNameDraft] = useState("");
|
||||
const [view, setView] = useState<LaneSourcesView | null>(null);
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
const [memory, setMemory] = useState<LaneMemoryFile[] | null>(null);
|
||||
const [memoryOpen, setMemoryOpen] = useState(false);
|
||||
const [memoryTab, setMemoryTab] = useState(0);
|
||||
const [folderOpen, setFolderOpen] = useState(false);
|
||||
const [folderPath, setFolderPath] = useState("");
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const [menuAt, setMenuAt] = useState<{ x: number; y: number } | null>(null);
|
||||
const [title, setTitle] = useState<string | null>(null);
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const [ctxDrag, setCtxDrag] = useState(false);
|
||||
const addFiles = async (picked: File[]) => {
|
||||
for (const f of picked) {
|
||||
if (!(await run(() => api.lanes.addFile(lane.id, f)))) break;
|
||||
}
|
||||
};
|
||||
const memoryPanel = useRef<HTMLDivElement>(null);
|
||||
useDialog(memoryOpen && !!memory, memoryPanel, () => setMemoryOpen(false));
|
||||
const recentCtx = useContextMenu<Session>();
|
||||
const sourceCtx = useContextMenu<LaneSource>();
|
||||
|
||||
// A new pane, a pane gone, a session id learned — all move these lane fields.
|
||||
const runKey = `${lane.status}:${lane.session_id}:${lane.run_id}`;
|
||||
@@ -258,25 +220,6 @@ function HomeBody({
|
||||
`${runKey}:${version}`
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
setView(null);
|
||||
setMemory(null);
|
||||
setEditing(null);
|
||||
setError(null);
|
||||
api.lanes
|
||||
.sources(lane.id)
|
||||
.then((v) => live && setView(v))
|
||||
.catch((err) => live && setError(message(err)));
|
||||
api.lanes
|
||||
.memory(lane.id)
|
||||
.then((r) => live && setMemory(r.files))
|
||||
.catch(() => live && setMemory([]));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [lane.id]);
|
||||
|
||||
// The repository this workspace belongs to (Settings → Projects used to
|
||||
// show this): read once per visit, from the server's cached project list.
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
@@ -293,57 +236,6 @@ function HomeBody({
|
||||
};
|
||||
}, [lane.id]);
|
||||
|
||||
// Suggestions are read when the folder form opens, not on every visit.
|
||||
useEffect(() => {
|
||||
if (!folderOpen) return;
|
||||
let live = true;
|
||||
api.projects
|
||||
.list()
|
||||
.then(({ projects }) => {
|
||||
if (!live) return;
|
||||
const p = projects.find((x) => x.workspaces.includes(lane.id));
|
||||
const own = new Set((view?.sources ?? []).map((s) => s.path));
|
||||
setSuggestions(
|
||||
!p || !prefs.showUntracked
|
||||
? []
|
||||
: p.worktrees
|
||||
.filter((w) => w.workspace_id === null && !own.has(w.path))
|
||||
.filter((w) => !(w.source && prefs.hideSources.includes(w.source)))
|
||||
.map((w) => w.path)
|
||||
);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [folderOpen, lane.id, prefs.showUntracked, prefs.hideSources, view?.sources]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sourcesVersion) return;
|
||||
let live = true;
|
||||
api.lanes
|
||||
.sources(lane.id)
|
||||
.then((v) => live && setView(v))
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- on a bump only
|
||||
}, [sourcesVersion]);
|
||||
|
||||
const run = async (fn: () => Promise<LaneSourcesView>) => {
|
||||
setError(null);
|
||||
try {
|
||||
const next = await fn();
|
||||
setView(next);
|
||||
onSourcesChange?.(next);
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(message(err));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// The AI menu's Open terminal: a plain shell in the first free slot, opened
|
||||
// at once — the same start the session view's tab bar `+` makes.
|
||||
const openTerminal = async () => {
|
||||
@@ -466,10 +358,7 @@ function HomeBody({
|
||||
? t("run:hint.sandboxWarning")
|
||||
: null;
|
||||
|
||||
const files = (view?.sources ?? []).filter((s) => s.kind === "file");
|
||||
const folders = (view?.sources ?? []).filter((s) => s.kind === "folder");
|
||||
const name = workspaceName(lane);
|
||||
const card = "border-b border-border px-4 py-4 last:border-b-0";
|
||||
const iconBtn =
|
||||
"rounded p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:opacity-40";
|
||||
|
||||
@@ -478,7 +367,7 @@ function HomeBody({
|
||||
data-testid="workspace-home"
|
||||
className="mx-auto flex w-full max-w-6xl flex-wrap gap-8 py-2"
|
||||
>
|
||||
<div className="min-w-0 flex-1 basis-[28rem]">
|
||||
<div data-prompt-drop className="min-w-0 flex-1 basis-[28rem]">
|
||||
<p className="text-xs text-fg-muted">
|
||||
{t("home.breadcrumb")} / <span className="text-fg-secondary">{name}</span>
|
||||
</p>
|
||||
@@ -838,363 +727,11 @@ function HomeBody({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside
|
||||
data-testid="workspace-context"
|
||||
aria-label={t("home.contextPanel")}
|
||||
className="card w-80 shrink-0 self-start p-0"
|
||||
>
|
||||
{view && !view.supported && (
|
||||
<p className="border-b border-border px-4 py-3 text-xs text-status-warning">
|
||||
{t("home.unsupported", { tool: aiTools.label(lane.tool) })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<section className={card}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-fg-primary">{t("home.instructions")}</h3>
|
||||
{editing === null && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("home.edit")}
|
||||
title={t("home.edit")}
|
||||
onClick={() => setEditing(view?.instructions ?? "")}
|
||||
disabled={!view}
|
||||
className={iconBtn}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{editing === null ? (
|
||||
<p className="mt-1 line-clamp-3 whitespace-pre-wrap text-xs text-fg-muted">
|
||||
{view?.instructions || t("home.instructionsEmpty")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-2 space-y-2">
|
||||
<textarea
|
||||
data-testid="workspace-instructions"
|
||||
aria-label={t("home.instructions")}
|
||||
autoFocus
|
||||
value={editing}
|
||||
maxLength={view?.maxInstructions}
|
||||
onChange={(e) => setEditing(e.target.value)}
|
||||
rows={6}
|
||||
className="w-full rounded border border-border bg-surface-1 p-2 text-xs text-fg-primary"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(null)}
|
||||
className="btn-ghost text-xs"
|
||||
>
|
||||
{t("home.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (await run(() => api.lanes.setInstructions(lane.id, editing)))
|
||||
setEditing(null);
|
||||
}}
|
||||
className="btn-primary text-xs"
|
||||
>
|
||||
{t("home.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className={card}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-fg-primary">{t("home.memory")}</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMemoryTab(0);
|
||||
setMemoryOpen(true);
|
||||
}}
|
||||
disabled={!memory?.length}
|
||||
className="text-xs text-fg-secondary hover:text-fg-primary disabled:opacity-40"
|
||||
>
|
||||
{t("home.viewMemory")}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-fg-muted">
|
||||
{memory?.length
|
||||
? t("home.memoryCount", { count: memory.length })
|
||||
: t("home.memoryEmpty")}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section
|
||||
data-testid="workspace-context-files"
|
||||
className={`${card} ${ctxDrag ? "ring-2 ring-accent/50" : ""}`}
|
||||
// Files dragged in from the OS are added like the + button's pick.
|
||||
onDragOver={(e) => {
|
||||
if (!view || !e.dataTransfer.types.includes("Files")) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
setCtxDrag(true);
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setCtxDrag(false);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
setCtxDrag(false);
|
||||
if (!view || e.dataTransfer.files.length === 0) return;
|
||||
e.preventDefault();
|
||||
void addFiles([...e.dataTransfer.files]);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-fg-primary">{t("home.context")}</h3>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("home.addFile")}
|
||||
title={t("home.addFile")}
|
||||
onClick={() => fileInput.current?.click()}
|
||||
disabled={!view}
|
||||
className={iconBtn}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<input
|
||||
ref={fileInput}
|
||||
data-testid="workspace-file-input"
|
||||
type="file"
|
||||
multiple
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
const picked = [...(e.target.files ?? [])];
|
||||
e.target.value = "";
|
||||
void addFiles(picked);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-fg-muted">{t("home.contextSnapshot")}</p>
|
||||
{files.length === 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInput.current?.click()}
|
||||
className="mt-2 w-full rounded-lg border border-dashed border-border px-3 py-5 text-center text-xs text-fg-muted hover:border-border-light hover:text-fg-secondary"
|
||||
>
|
||||
{t("home.contextEmpty")}
|
||||
</button>
|
||||
) : (
|
||||
<ul className="mt-2 space-y-1">
|
||||
{files.map((f) => (
|
||||
<li
|
||||
key={f.id}
|
||||
className="group flex items-center gap-2 text-xs"
|
||||
onContextMenu={(e) => sourceCtx.open(e, f)}
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
||||
<span title={f.path} className="min-w-0 flex-1 truncate text-fg-secondary">
|
||||
{f.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-fg-muted">
|
||||
{f.missing ? t("home.missing") : fmtBytes(f.size)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${t("home.remove")} ${f.name}`}
|
||||
onClick={() => void run(() => api.lanes.removeSource(lane.id, f.id))}
|
||||
className={`${iconBtn} opacity-0 group-hover:opacity-100 focus:opacity-100`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className={card}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-fg-primary">{t("home.folder")}</h3>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("home.addFolder")}
|
||||
title={t("home.addFolder")}
|
||||
onClick={() => setFolderOpen((v) => !v)}
|
||||
disabled={!view}
|
||||
className={iconBtn}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<ul className="mt-2 space-y-1.5">
|
||||
<li className="flex items-start gap-2 text-xs">
|
||||
<Folder className="mt-0.5 h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
||||
<span className="min-w-0">
|
||||
<span title={lane.cwd} className="block truncate text-fg-secondary">
|
||||
{lane.cwd.split("/").filter(Boolean).pop() || lane.cwd}
|
||||
</span>
|
||||
<span className="block text-fg-muted">{t("home.ownFolder")}</span>
|
||||
</span>
|
||||
</li>
|
||||
{folders.map((f) => (
|
||||
<li
|
||||
key={f.id}
|
||||
className="group flex items-center gap-2 text-xs"
|
||||
onContextMenu={(e) => sourceCtx.open(e, f)}
|
||||
>
|
||||
<Folder className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
||||
<span title={f.path} className="min-w-0 flex-1 truncate text-fg-secondary">
|
||||
{f.name}
|
||||
{f.missing && (
|
||||
<span className="ml-1 text-status-warning">{t("home.missing")}</span>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${t("home.remove")} ${f.name}`}
|
||||
onClick={() => void run(() => api.lanes.removeSource(lane.id, f.id))}
|
||||
className={`${iconBtn} opacity-0 group-hover:opacity-100 focus:opacity-100`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{folderOpen && (
|
||||
<form
|
||||
className="mt-3 space-y-2"
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
if (await run(() => api.lanes.addFolder(lane.id, folderPath.trim()))) {
|
||||
setFolderPath("");
|
||||
setFolderOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
data-testid="workspace-folder-input"
|
||||
aria-label={t("home.folderPath")}
|
||||
placeholder={t("home.folderPath")}
|
||||
autoFocus
|
||||
value={folderPath}
|
||||
onChange={(e) => setFolderPath(e.target.value)}
|
||||
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
|
||||
/>
|
||||
<PathPickButton start={folderPath || lane.cwd} onPick={setFolderPath} />
|
||||
<button type="submit" disabled={!folderPath.trim()} className="btn-ghost text-xs">
|
||||
{t("home.add")}
|
||||
</button>
|
||||
</div>
|
||||
{suggestions.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wide text-fg-muted">
|
||||
{t("home.suggested")}
|
||||
</p>
|
||||
<ul className="mt-1 space-y-0.5">
|
||||
{suggestions.map((p) => (
|
||||
<li key={p}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFolderPath(p)}
|
||||
title={p}
|
||||
className="w-full truncate rounded px-1 text-left font-mono text-[11px] text-fg-secondary hover:bg-surface-3"
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
{memoryOpen && memory && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 flex items-center justify-center bg-black/60 p-4"
|
||||
role="presentation"
|
||||
onClick={() => setMemoryOpen(false)}
|
||||
>
|
||||
<div
|
||||
ref={memoryPanel}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("home.memory")}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex max-h-[85vh] w-full max-w-3xl flex-col rounded-xl border border-border bg-surface-1 p-5 shadow-xl"
|
||||
>
|
||||
<div className="mb-3 flex shrink-0 items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-fg-primary">{t("home.memory")}</h2>
|
||||
<button
|
||||
type="button"
|
||||
autoFocus
|
||||
aria-label={t("home.close")}
|
||||
onClick={() => setMemoryOpen(false)}
|
||||
className={iconBtn}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={t("home.memory")}
|
||||
// Wraps, so every file is reachable without a horizontal wheel (a
|
||||
// hidden-scrollbar row cut the rest off); past ~3 rows it scrolls
|
||||
// with a visible bar. shrink-0: a long file must not squeeze it.
|
||||
className="mb-3 flex max-h-28 shrink-0 flex-wrap gap-1.5 overflow-y-auto border-b border-border/60 pb-2"
|
||||
>
|
||||
{memory.map((f, i) => (
|
||||
<button
|
||||
key={f.path}
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`workspace-memory-tab-${i}`}
|
||||
aria-controls="workspace-memory-panel"
|
||||
tabIndex={i === memoryTab ? 0 : -1}
|
||||
title={f.path}
|
||||
aria-selected={i === memoryTab}
|
||||
onClick={() => setMemoryTab(i)}
|
||||
// APG tabs: arrows move (and select), one tab stop.
|
||||
onKeyDown={(e) => {
|
||||
const n = memory.length;
|
||||
const to =
|
||||
e.key === "ArrowRight"
|
||||
? (i + 1) % n
|
||||
: e.key === "ArrowLeft"
|
||||
? (i - 1 + n) % n
|
||||
: e.key === "Home"
|
||||
? 0
|
||||
: e.key === "End"
|
||||
? n - 1
|
||||
: null;
|
||||
if (to === null) return;
|
||||
e.preventDefault();
|
||||
setMemoryTab(to);
|
||||
document.getElementById(`workspace-memory-tab-${to}`)?.focus();
|
||||
}}
|
||||
className={`flex-shrink-0 whitespace-nowrap rounded-lg border px-3 py-1.5 font-mono text-xs transition-colors ${
|
||||
i === memoryTab
|
||||
? "border-accent/30 bg-accent/15 text-accent"
|
||||
: "border-border text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{memory[memoryTab] && (
|
||||
<div
|
||||
role="tabpanel"
|
||||
id="workspace-memory-panel"
|
||||
aria-labelledby={`workspace-memory-tab-${memoryTab}`}
|
||||
className="min-h-0 flex-1 overflow-y-auto"
|
||||
>
|
||||
<MarkdownPreview text={memory[memoryTab].content} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<WorkspaceContext
|
||||
lane={lane}
|
||||
onSourcesChange={onSourcesChange}
|
||||
sourcesVersion={sourcesVersion}
|
||||
/>
|
||||
|
||||
{recentCtx.menu && (
|
||||
<SessionMenu
|
||||
@@ -1212,34 +749,6 @@ function HomeBody({
|
||||
onArchive={() => void archiveSession(recentCtx.menu!.item)}
|
||||
/>
|
||||
)}
|
||||
{sourceCtx.menu && (
|
||||
<ContextMenu
|
||||
x={sourceCtx.menu.x}
|
||||
y={sourceCtx.menu.y}
|
||||
onClose={sourceCtx.close}
|
||||
testId={`workspace-source-menu-${sourceCtx.menu.item.id}`}
|
||||
>
|
||||
<MenuItem
|
||||
onSelect={() => {
|
||||
void copyText(sourceCtx.menu!.item.path);
|
||||
sourceCtx.close();
|
||||
}}
|
||||
>
|
||||
{t("menu.copyPath")}
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem
|
||||
danger
|
||||
onSelect={() => {
|
||||
const id = sourceCtx.menu!.item.id;
|
||||
sourceCtx.close();
|
||||
void run(() => api.lanes.removeSource(lane.id, id));
|
||||
}}
|
||||
>
|
||||
{t("home.remove")}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
{menuAt && (
|
||||
<WorkspaceRowMenu
|
||||
lane={lane}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* @file InterruptedSessionsPrompt.test.tsx
|
||||
* @description The "resume interrupted sessions?" dialog: it opens only when
|
||||
* the server lists some, resumes only the ticked ones (never a bypass mode
|
||||
* carried over), and either answer dismisses the list — but a resume that
|
||||
* failed stays offered.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import type { InterruptedRun } from "../../../lib/api";
|
||||
|
||||
vi.mock("../startSession", () => ({
|
||||
startInFreeTabDetailed: vi.fn(async (laneId: number) => ({
|
||||
runId: `agenthub-lane-${laneId}-1`,
|
||||
laneId,
|
||||
held: false,
|
||||
})),
|
||||
}));
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: {
|
||||
run: {
|
||||
interrupted: vi.fn(),
|
||||
dismissInterrupted: vi.fn(async () => ({ dismissed: 0 })),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { api } from "../../../lib/api";
|
||||
import { startInFreeTabDetailed } from "../startSession";
|
||||
import { InterruptedSessionsPrompt } from "../InterruptedSessionsPrompt";
|
||||
|
||||
const row = (session_id: string, lane_id: number, extra: Partial<InterruptedRun> = {}) =>
|
||||
({
|
||||
id: `agenthub-lane-${lane_id}`,
|
||||
session_id,
|
||||
lane_id,
|
||||
tool: "claude",
|
||||
cwd: "/r",
|
||||
model: null,
|
||||
permission_mode: null,
|
||||
effort: null,
|
||||
prompt_preview: `prompt ${session_id}`,
|
||||
started_at: "2026-10-10T00:00:00Z",
|
||||
ended_at: "2026-10-10T01:00:00Z",
|
||||
lane_title: `ws-${lane_id}`,
|
||||
lane_cwd: "/r",
|
||||
session_name: null,
|
||||
...extra,
|
||||
}) as InterruptedRun;
|
||||
|
||||
const list = (items: InterruptedRun[]) =>
|
||||
vi.mocked(api.run.interrupted).mockResolvedValue({ items });
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
const renderIt = () =>
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<InterruptedSessionsPrompt />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
describe("InterruptedSessionsPrompt", () => {
|
||||
it("shows nothing when no session was interrupted", async () => {
|
||||
list([]);
|
||||
renderIt();
|
||||
await waitFor(() => expect(api.run.interrupted).toHaveBeenCalled());
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("resumes only the ticked sessions, then dismisses the list", async () => {
|
||||
list([row("s-a", 1, { permission_mode: "bypassPermissions", model: "opus" }), row("s-b", 2)]);
|
||||
const user = userEvent.setup();
|
||||
renderIt();
|
||||
await screen.findByRole("dialog");
|
||||
await user.click(screen.getByRole("checkbox", { name: /ws-2/ }));
|
||||
await user.click(screen.getByRole("button", { name: /resume 1/i }));
|
||||
await waitFor(() => expect(api.run.dismissInterrupted).toHaveBeenCalledWith(["s-a", "s-b"]));
|
||||
expect(startInFreeTabDetailed).toHaveBeenCalledTimes(1);
|
||||
// The model goes back; a bypass mode is never carried over.
|
||||
expect(startInFreeTabDetailed).toHaveBeenCalledWith(1, {
|
||||
resumeSessionId: "s-a",
|
||||
model: "opus",
|
||||
});
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
});
|
||||
|
||||
it("resumes nothing on Don't resume, and keeps a session whose resume failed", async () => {
|
||||
list([row("s-a", 1)]);
|
||||
const user = userEvent.setup();
|
||||
const { unmount } = renderIt();
|
||||
await user.click(await screen.findByText("Don't resume"));
|
||||
await waitFor(() => expect(api.run.dismissInterrupted).toHaveBeenCalledWith(["s-a"]));
|
||||
expect(startInFreeTabDetailed).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
|
||||
vi.clearAllMocks();
|
||||
list([row("s-a", 1), row("s-b", 2)]);
|
||||
vi.mocked(startInFreeTabDetailed).mockRejectedValueOnce(new Error("boom"));
|
||||
renderIt();
|
||||
await user.click(await screen.findByRole("button", { name: /resume 2/i }));
|
||||
await waitFor(() => expect(api.run.dismissInterrupted).toHaveBeenCalledWith(["s-b"]));
|
||||
expect(screen.getByRole("checkbox", { name: /ws-1/ })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
import i18n from "../../i18n";
|
||||
import { api, type RunHandle } from "../../lib/api";
|
||||
import { MAX_SLOTS } from "../../lib/paneLayout";
|
||||
import { MAX_SLOTS, slotOfKey } from "../../lib/paneLayout";
|
||||
|
||||
/** The tmux session a pane slot of a lane owns (server/lib/pty-run.js). */
|
||||
export function paneRunId(laneId: number, slot: number): string {
|
||||
@@ -58,7 +58,9 @@ export async function startInFreeTabDetailed(
|
||||
/** The first pane slot with no live run, or null when all ten are busy. */
|
||||
export function freeSlot(laneId: number, runs: RunHandle[]): number | null {
|
||||
const busy = new Set(
|
||||
runs.filter((r) => r.laneId === laneId && r.status === "running").map((r) => r.slot ?? 0)
|
||||
runs
|
||||
.filter((r) => r.laneId === laneId && r.status === "running")
|
||||
.map((r) => slotOfKey(r.slot ?? 0))
|
||||
);
|
||||
for (let s = 0; s < MAX_SLOTS; s++) if (!busy.has(s)) return s;
|
||||
return null;
|
||||
|
||||
@@ -6,18 +6,24 @@
|
||||
* on the right — Claude's Uploads dialog). Content comes only through routes
|
||||
* that already guard it: a file inside the workspace through
|
||||
* `GET /api/lanes/:id/git/file` (or `/git/raw` for an image), a file under
|
||||
* `~/.claude` (a memory file) through `GET /api/cc-config/file`, an attached
|
||||
* image through `GET /api/run/drop/:name`. Anything else shows its path only.
|
||||
* `~/.claude` (a memory file, a plugin's skill) through `GET /api/cc-config/file`,
|
||||
* an attached image or text file through `GET /api/run/drop/:name`, an image
|
||||
* the session read anywhere else through `GET /api/sessions/:id/image`. Anything
|
||||
* else (or an image that route refuses) shows its path only. A Windows path's `\` counts as `/` throughout.
|
||||
* `SkillListModal` is the same dialog over the session's skills, each one's
|
||||
* SKILL.md found by name (`plugin:name` from the plugin list, else the user's
|
||||
* or the project's); a built-in skill has no file and shows by name only.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState, type DragEvent } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Copy, FileText, Loader2, X } from "lucide-react";
|
||||
import { api, gitTarget } from "../../lib/api";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { dragSkill } from "../../lib/promptAttach";
|
||||
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
|
||||
|
||||
/** Where a Chat pane's files can be read from. */
|
||||
@@ -31,11 +37,17 @@ export interface PreviewScope {
|
||||
}
|
||||
|
||||
const IMAGE_RE = /\.(png|jpe?g|gif|webp)$/i;
|
||||
// An attached file the drop route serves as text (`DROP_TEXT_TYPES` in server/routes/run.js).
|
||||
const DROP_TEXT_RE = /\.(txt|md|markdown|csv|tsv|log|json|xml|ya?ml)$/i;
|
||||
// The drop dir (`<data dir>/drops/`), or the older `<tmp>/agenthub-drops-<uid>/`.
|
||||
const DROP_RE =
|
||||
/\/(?:agenthub-drops-\d+|drops)\/([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.[A-Za-z0-9]+)$/;
|
||||
/[\\/](?:agenthub-drops-\d+|drops)[\\/]([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.[A-Za-z0-9]+)$/;
|
||||
/** A dropped text file shown at most this long. */
|
||||
const DROP_TEXT_MAX = 200_000;
|
||||
|
||||
export const baseName = (p: string) => p.split("/").pop() || p;
|
||||
/** `C:\a\b` → `C:/a/b`: a Windows path matched like a POSIX one. */
|
||||
const slashed = (p: string) => p.replace(/\\/g, "/");
|
||||
export const baseName = (p: string) => p.split(/[\\/]/).pop() || p;
|
||||
/** `report.v2.xlsx` → `XLSX`; none → "". */
|
||||
export const extOf = (p: string) => {
|
||||
const m = /\.([A-Za-z0-9]+)$/.exec(baseName(p));
|
||||
@@ -54,37 +66,54 @@ type Content =
|
||||
function useContent(path: string, scope: PreviewScope): Content {
|
||||
const [content, setContent] = useState<Content>({ kind: "loading" });
|
||||
useEffect(() => {
|
||||
const drop = DROP_RE.exec(path);
|
||||
if (drop) {
|
||||
setContent(
|
||||
IMAGE_RE.test(path)
|
||||
? { kind: "image", src: `/api/run/drop/${encodeURIComponent(drop[1]!)}` }
|
||||
: { kind: "none" }
|
||||
);
|
||||
return;
|
||||
}
|
||||
let live = true;
|
||||
const show = (p: Promise<Content>) => {
|
||||
setContent({ kind: "loading" });
|
||||
p.then((c) => live && setContent(c)).catch(() => live && setContent({ kind: "none" }));
|
||||
};
|
||||
const base = scope.cwd.replace(/\/+$/, "");
|
||||
if (scope.laneId === null || !base || !path.startsWith(`${base}/`)) {
|
||||
// Claude Code's own files (memory): the config pages' guarded read.
|
||||
if (/\/\.claude\//.test(path) && !IMAGE_RE.test(path)) {
|
||||
const drop = DROP_RE.exec(path);
|
||||
if (drop) {
|
||||
const src = `/api/run/drop/${encodeURIComponent(drop[1]!)}`;
|
||||
if (IMAGE_RE.test(path)) setContent({ kind: "image", src });
|
||||
else if (DROP_TEXT_RE.test(path))
|
||||
show(
|
||||
fetch(src).then(async (r) => {
|
||||
if (!r.ok) throw new Error(String(r.status));
|
||||
const text = await r.text();
|
||||
return {
|
||||
kind: "text",
|
||||
text: text.slice(0, DROP_TEXT_MAX),
|
||||
truncated: text.length > DROP_TEXT_MAX,
|
||||
};
|
||||
})
|
||||
);
|
||||
else setContent({ kind: "none" });
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}
|
||||
const file = slashed(path);
|
||||
const base = slashed(scope.cwd).replace(/\/+$/, "");
|
||||
if (scope.laneId === null || !base || !file.startsWith(`${base}/`)) {
|
||||
// Claude Code's own files (memory, a skill): the config pages' guarded read.
|
||||
if (/\/\.claude\//.test(file) && !IMAGE_RE.test(file)) {
|
||||
show(
|
||||
api.ccConfig
|
||||
.file(path)
|
||||
.then((f) => ({ kind: "text", text: f.text, truncated: !!f.truncated }))
|
||||
);
|
||||
} else if (IMAGE_RE.test(file) && scope.sessionId) {
|
||||
// An image it read elsewhere (a screenshot under ~/.cache): served
|
||||
// only because this session's own Read named it.
|
||||
setContent({ kind: "image", src: api.sessions.readImageUrl(scope.sessionId, path) });
|
||||
} else setContent({ kind: "none" });
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}
|
||||
const rel = path.slice(base.length + 1);
|
||||
const rel = file.slice(base.length + 1);
|
||||
const target =
|
||||
base === scope.laneCwd.replace(/\/+$/, "")
|
||||
base === slashed(scope.laneCwd).replace(/\/+$/, "")
|
||||
? scope.laneId
|
||||
: gitTarget(scope.laneId, scope.sessionId);
|
||||
if (IMAGE_RE.test(path)) {
|
||||
@@ -109,7 +138,11 @@ function useContent(path: string, scope: PreviewScope): Content {
|
||||
|
||||
function Body({ path, scope }: { path: string; scope: PreviewScope }) {
|
||||
const { t } = useTranslation("run");
|
||||
const content = useContent(path, scope);
|
||||
const loaded = useContent(path, scope);
|
||||
// An image the route refused (gone, server mode) shows like no preview.
|
||||
const [broken, setBroken] = useState<string | null>(null);
|
||||
const content: Content =
|
||||
loaded.kind === "image" && loaded.src === broken ? { kind: "none" } : loaded;
|
||||
if (content.kind === "loading") {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
@@ -123,6 +156,7 @@ function Body({ path, scope }: { path: string; scope: PreviewScope }) {
|
||||
<img
|
||||
src={content.src}
|
||||
alt={baseName(path)}
|
||||
onError={() => setBroken(content.src)}
|
||||
className="max-h-full max-w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
@@ -206,13 +240,19 @@ export function FilePreview({
|
||||
export function FileListModal({
|
||||
title,
|
||||
paths,
|
||||
label = displayName,
|
||||
scope,
|
||||
onClose,
|
||||
onDragItem,
|
||||
}: {
|
||||
title: string;
|
||||
paths: string[];
|
||||
/** A row's text; the file name by default. */
|
||||
label?: (path: string) => string;
|
||||
scope: PreviewScope;
|
||||
onClose: () => void;
|
||||
/** Rows drag into the prompt box; the dialog then closes, out of the way. */
|
||||
onDragItem?: (e: DragEvent, path: string) => void;
|
||||
}) {
|
||||
const [picked, setPicked] = useState(paths[0] ?? "");
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
@@ -241,12 +281,18 @@ export function FileListModal({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPicked(p)}
|
||||
draggable={!!onDragItem}
|
||||
onDragStart={(e) => {
|
||||
onDragItem?.(e, p);
|
||||
// Not in dragstart itself: removing the source there cancels the drag.
|
||||
if (onDragItem) window.setTimeout(onClose, 0);
|
||||
}}
|
||||
title={p}
|
||||
aria-current={p === picked}
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm ${p === picked ? "bg-surface-3 text-fg-primary" : "text-fg-secondary hover:bg-surface-2"}`}
|
||||
>
|
||||
<FileText className="h-4 w-4 flex-shrink-0 text-fg-muted" />
|
||||
<span className="min-w-0 truncate">{displayName(p)}</span>
|
||||
<span className="min-w-0 truncate">{label(p)}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
@@ -258,3 +304,58 @@ export function FileListModal({
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
/** The session's skills in the list dialog, each one's SKILL.md previewed. */
|
||||
export function SkillListModal({
|
||||
title,
|
||||
names,
|
||||
scope,
|
||||
onClose,
|
||||
draggable = false,
|
||||
}: {
|
||||
title: string;
|
||||
names: string[];
|
||||
scope: PreviewScope;
|
||||
onClose: () => void;
|
||||
/** Rows drag into the prompt box as `/name`. */
|
||||
draggable?: boolean;
|
||||
}) {
|
||||
const [files, setFiles] = useState<Map<string, string> | null>(null);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
void Promise.all([
|
||||
api.ccConfig.skills(undefined, scope.cwd).catch(() => ({ items: [] })),
|
||||
api.ccConfig.plugins().catch(() => ({ plugins: [] })),
|
||||
]).then(([sk, pl]) => {
|
||||
const found = new Map<string, string>();
|
||||
const add = (name: string, file: string | undefined) => {
|
||||
if (file && !found.has(name)) found.set(name, file);
|
||||
};
|
||||
for (const p of pl.plugins) {
|
||||
const items = p.contributes?.items;
|
||||
for (const it of [...(items?.skills ?? []), ...(items?.commands ?? [])])
|
||||
add(`${p.name}:${it.name}`, it.file);
|
||||
}
|
||||
// A personal skill wins over a project one of the same name, as in Claude Code.
|
||||
for (const s of sk.items) if (s.scope === "user") add(s.name, s.file);
|
||||
for (const s of sk.items) add(s.name, s.file);
|
||||
if (live) setFiles(found);
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [scope.cwd]);
|
||||
if (!files) return null;
|
||||
// A built-in skill has no file: its name stands in and previews as nothing.
|
||||
const byPath = new Map(names.map((n) => [files.get(n) ?? n, n]));
|
||||
return (
|
||||
<FileListModal
|
||||
title={title}
|
||||
paths={[...byPath.keys()]}
|
||||
label={(p) => byPath.get(p) ?? displayName(p)}
|
||||
scope={scope}
|
||||
onClose={onClose}
|
||||
onDragItem={draggable ? (e, p) => dragSkill(e, byPath.get(p) ?? p) : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
/**
|
||||
* @file ChatOutline.tsx
|
||||
* @description The right-hand panel of a Chat pane shown alone, laid out like
|
||||
* @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
|
||||
* it wrote or edited (name + EXT tag; a click previews the file), and "Used in
|
||||
* this session" — the files attached to its prompts, the files it read and the
|
||||
* memory files it used (a click opens the list with a preview), its skills
|
||||
* and MCP servers (a click opens the Claude config page). Read from
|
||||
* memory files it used and its skills (a click opens the list with a
|
||||
* preview — a skill's SKILL.md), and its MCP servers (a click opens the
|
||||
* Claude config page). Every row but MCP drags into the prompt box: a file
|
||||
* as an attachment, a group as all of its files, a lone skill as `/name`.
|
||||
* Read from
|
||||
* `GET /api/sessions/:id/outline` by `useSessionOutline`, which the pane also
|
||||
* uses for the collapsed button's count.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState, type ComponentType, type ReactNode } from "react";
|
||||
import { useEffect, useState, type ComponentType, type DragEvent, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
@@ -31,6 +34,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { api, type SessionOutline } from "../../lib/api";
|
||||
import { displayName, extOf } from "./ChatFilePreview";
|
||||
import { dragAttachments, dragSkill, type Attachment } from "../../lib/promptAttach";
|
||||
|
||||
const POLL_MS = 5000;
|
||||
const EMPTY: SessionOutline = {
|
||||
@@ -65,7 +69,21 @@ export function useSessionOutline(sessionId: string): SessionOutline {
|
||||
return outline;
|
||||
}
|
||||
|
||||
export type OutlineList = "uploads" | "files" | "memory";
|
||||
export type OutlineList = "uploads" | "files" | "memory" | "skills";
|
||||
|
||||
/** A listed file as a prompt attachment: an upload by its bare path, as the
|
||||
* composer attaches one; anything else as an `@` mention. */
|
||||
export function outlineAttachment(path: string, upload = false): Attachment {
|
||||
return { path: upload ? path : `@${path}`, name: displayName(path) };
|
||||
}
|
||||
|
||||
const dragFiles =
|
||||
(paths: string[], upload = false) =>
|
||||
(e: DragEvent) =>
|
||||
dragAttachments(
|
||||
e,
|
||||
paths.map((p) => outlineAttachment(p, upload))
|
||||
);
|
||||
|
||||
function fileIcon(path: string) {
|
||||
const ext = extOf(path).toLowerCase();
|
||||
@@ -136,12 +154,15 @@ function UsedRow({
|
||||
label,
|
||||
names,
|
||||
onClick,
|
||||
onDragStart,
|
||||
to,
|
||||
}: {
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
names: string[];
|
||||
onClick?: () => void;
|
||||
/** The row drags into the prompt box. */
|
||||
onDragStart?: (e: DragEvent) => void;
|
||||
to?: string;
|
||||
}) {
|
||||
const shown = names.slice(0, 2).join(", ");
|
||||
@@ -161,7 +182,14 @@ function UsedRow({
|
||||
{inner}
|
||||
</Link>
|
||||
) : (
|
||||
<button type="button" title={title} onClick={onClick} className={rowClass}>
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
onClick={onClick}
|
||||
draggable={!!onDragStart}
|
||||
onDragStart={onDragStart}
|
||||
className={rowClass}
|
||||
>
|
||||
{inner}
|
||||
</button>
|
||||
);
|
||||
@@ -198,7 +226,9 @@ export function ChatOutline({
|
||||
<aside
|
||||
data-testid="chat-outline"
|
||||
aria-label={t("chat.outline.panel")}
|
||||
className="m-3 ml-0 hidden w-80 flex-shrink-0 flex-col overflow-hidden rounded-xl border border-border bg-surface-1 pb-2 lg:flex"
|
||||
// Over the chat in a narrow pane (the container is PaneChat's root),
|
||||
// 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"
|
||||
>
|
||||
{o.progress.length > 0 && (
|
||||
<Section title={t("chat.outline.progress")} action={close}>
|
||||
@@ -241,6 +271,8 @@ export function ChatOutline({
|
||||
title={p}
|
||||
aria-label={`${extOf(p)}: ${stem(p)}`}
|
||||
onClick={() => onOpenFile(p)}
|
||||
draggable
|
||||
onDragStart={dragFiles([p])}
|
||||
className={rowClass}
|
||||
>
|
||||
<Icon className="h-4 w-4 flex-shrink-0 text-fg-muted" />
|
||||
@@ -261,6 +293,7 @@ export function ChatOutline({
|
||||
label={t("chat.outline.uploads")}
|
||||
names={o.uploads.map(displayName)}
|
||||
onClick={() => onOpenList("uploads")}
|
||||
onDragStart={dragFiles(o.uploads, true)}
|
||||
/>
|
||||
)}
|
||||
{o.files.length > 0 && (
|
||||
@@ -269,6 +302,7 @@ export function ChatOutline({
|
||||
label={t("chat.outline.files")}
|
||||
names={o.files.map(displayName)}
|
||||
onClick={() => onOpenList("files")}
|
||||
onDragStart={dragFiles(o.files)}
|
||||
/>
|
||||
)}
|
||||
{o.memory.length > 0 && (
|
||||
@@ -277,6 +311,7 @@ export function ChatOutline({
|
||||
label={t("chat.outline.memory")}
|
||||
names={o.memory.map(stem)}
|
||||
onClick={() => onOpenList("memory")}
|
||||
onDragStart={dragFiles(o.memory)}
|
||||
/>
|
||||
)}
|
||||
{o.skills.length > 0 && (
|
||||
@@ -284,7 +319,9 @@ export function ChatOutline({
|
||||
icon={BookOpen}
|
||||
label={t("chat.skills")}
|
||||
names={o.skills}
|
||||
to="/settings/agents/claude-code?tab=skills"
|
||||
onClick={() => onOpenList("skills")}
|
||||
// One command per prompt: several are dragged one by one from the list.
|
||||
onDragStart={o.skills.length === 1 ? (e) => dragSkill(e, o.skills[0]!) : undefined}
|
||||
/>
|
||||
)}
|
||||
{o.mcp.length > 0 && (
|
||||
|
||||
@@ -26,7 +26,9 @@
|
||||
* of another tab, a tab (or a pane header) joins that tab instead
|
||||
* (`joinGroup`: side by side, then under the right pane, then under the left
|
||||
* one, up to four panes). A right-click on a tab
|
||||
* opens the same actions as a menu (`ContextMenu`).
|
||||
* opens the same actions as a menu (`ContextMenu`). With tabs from every
|
||||
* workspace on, `tabs` spans every workspace (each run's `slot` a `paneKey`)
|
||||
* and `workspaceOf` names each tab's workspace before its own name.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -62,6 +64,7 @@ import {
|
||||
joinGroup,
|
||||
leaves,
|
||||
reorderGroups,
|
||||
slotOfKey,
|
||||
} from "../../lib/paneLayout";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
|
||||
@@ -77,7 +80,7 @@ export function runName(
|
||||
): string {
|
||||
if (r.tool === "shell") return terminal;
|
||||
const prompt = r.promptPreview?.trim().split("\n")[0];
|
||||
return session?.name || prompt || `${toolLabel} #${(r.slot ?? 0) + 1}`;
|
||||
return session?.name || prompt || `${toolLabel} #${slotOfKey(r.slot ?? 0) + 1}`;
|
||||
}
|
||||
|
||||
export type RunState = "awaiting" | "error" | "running";
|
||||
@@ -160,10 +163,13 @@ export function ConsoleTabs({
|
||||
actions,
|
||||
sessions,
|
||||
onRenamed,
|
||||
workspaceOf,
|
||||
}: {
|
||||
lane: Lane;
|
||||
/** The workspace's live runs, in slot order. */
|
||||
tabs: RunHandle[];
|
||||
/** Tabs from every workspace: the name of the workspace a run belongs to. */
|
||||
workspaceOf?: (r: RunHandle) => string;
|
||||
/** The tabs: groups of panes, the active one on screen. */
|
||||
groups: PaneGroups;
|
||||
/** Reorders the tabs or takes a pane out into a tab of its own. */
|
||||
@@ -325,7 +331,10 @@ export function ConsoleTabs({
|
||||
const sessionOf = (r: RunHandle) => (r.sessionId ? sessions?.get(r.sessionId) : undefined);
|
||||
const toolLabel = (r: RunHandle) =>
|
||||
r.tool === "shell" ? t("tabs.terminal") : aiTools.label(r.tool);
|
||||
const label = (r: RunHandle) => runName(r, sessionOf(r), toolLabel(r), t("tabs.terminal"));
|
||||
const label = (r: RunHandle) => {
|
||||
const name = runName(r, sessionOf(r), toolLabel(r), t("tabs.terminal"));
|
||||
return workspaceOf ? `${workspaceOf(r)} · ${name}` : name;
|
||||
};
|
||||
const tabLabel = (tab: Tab) => tab.runs.map(label).join(" | ");
|
||||
// A combo's dot is its most urgent AI pane's; a shell has none.
|
||||
const tabState = (tab: Tab): RunState | null => {
|
||||
|
||||
@@ -51,8 +51,9 @@ export interface LaneConsolePaneProps {
|
||||
onRunExit?: () => void;
|
||||
/** The terminal takes the keyboard when it appears (default). */
|
||||
autoFocus?: boolean;
|
||||
/** The only pane shown: its Chat view gets the right panel. */
|
||||
solo?: boolean;
|
||||
/** The pane spans the grid's full width or height: its Chat view's right
|
||||
* panel starts open (collapsed in a quarter-sized pane). */
|
||||
panelOpen?: boolean;
|
||||
}
|
||||
|
||||
/** The tmux session a lane's tab owns (see server/lib/pty-run.js). */
|
||||
@@ -71,7 +72,7 @@ export function LaneConsolePane({
|
||||
onNewSession,
|
||||
onRunExit,
|
||||
autoFocus,
|
||||
solo = false,
|
||||
panelOpen = false,
|
||||
}: LaneConsolePaneProps) {
|
||||
const { t } = useTranslation("run");
|
||||
const [handle, setHandle] = useState<RunHandle | null>(null);
|
||||
@@ -201,7 +202,7 @@ export function LaneConsolePane({
|
||||
cwd={handle.cwd || currentLane?.cwd || ""}
|
||||
laneId={laneId}
|
||||
laneCwd={currentLane?.cwd || ""}
|
||||
outline={solo}
|
||||
panelOpen={panelOpen}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -13,15 +13,23 @@
|
||||
* Shift+Tab changes in a running session, so the server steps it and reads
|
||||
* every step back off the pane (`POST /api/run/:id/permission-mode`); it sits
|
||||
* in the box beside `+`. A permission prompt or a menu still needs the terminal.
|
||||
* Shown alone (`outline`), it also has Claude Desktop's activity panel
|
||||
* (`ChatOutline`: progress, outputs, what the session used): collapsed into a
|
||||
* file button with the output count until clicked (remembered per browser);
|
||||
* a file clicked there opens its preview in the panel's place
|
||||
* It also has Claude Desktop's activity panel (`ChatOutline`: progress,
|
||||
* outputs, what the session used), open at first in a pane spanning the
|
||||
* grid's full width or height (`panelOpen`) and collapsed into a file button
|
||||
* with the output count in a quarter-sized one; it sits beside the
|
||||
* conversation when the pane is wide and over it when narrow (a CSS container
|
||||
* query on the pane, not the window), dragged wider or narrower by its
|
||||
* splitter there (a fraction of the pane, remembered per browser). A file
|
||||
* clicked there opens its preview
|
||||
* in the panel's place
|
||||
* (`FilePreview`) — so does one in a turn's "Edited N files" card outside
|
||||
* the workspace's folder —, a group
|
||||
* of files a list with a preview (`FileListModal`). A file in a turn's
|
||||
* of files or the used skills a list with a preview (`FileListModal`,
|
||||
* `SkillListModal`). Anything there drags into the prompt box. A file in a turn's
|
||||
* "Edited N files" card opens in the Workspace's Changes panel
|
||||
* (`openInChanges`) when it lies in the pane's folder. A send scrolls the
|
||||
* (`openInChanges`) when it lies in the pane's folder. The unsent prompt and
|
||||
* its attachments are kept per session (`lib/chatDraft.ts`), so switching to
|
||||
* the terminal and back, or a reload, leaves them as typed. A send scrolls the
|
||||
* conversation to its end and keeps it there (`follow`), so the message and
|
||||
* the step running after it come into sight on their own. While the session's
|
||||
* main agent is working (a prompt submitted, no Stop yet) an empty box shows
|
||||
@@ -29,16 +37,18 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowUp, FileText, Square } from "lucide-react";
|
||||
import { ConversationView } from "../conversation/ConversationView";
|
||||
import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "./RunSetup";
|
||||
import { ChatAddMenu } from "./ChatAddMenu";
|
||||
import { addAttachments } from "../../lib/promptAttach";
|
||||
import { ChatOutline, useSessionOutline, type OutlineList } from "./ChatOutline";
|
||||
import { FileListModal, FilePreview, type PreviewScope } from "./ChatFilePreview";
|
||||
import { addAttachments, dragAttachments } from "../../lib/promptAttach";
|
||||
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 { interruptRun, submitToRun } from "../../lib/panePaste";
|
||||
import { useLiveRefresh } from "../../hooks/useLiveRefresh";
|
||||
import { openInChanges } from "../../lib/openInChanges";
|
||||
@@ -53,7 +63,12 @@ const MODES: [PermissionMode, string][] = [
|
||||
["bypassPermissions", "Bypass"],
|
||||
];
|
||||
const isSettable = (m: unknown): m is PermissionMode => MODES.some(([v]) => v === m);
|
||||
const OUTLINE_KEY = "agenthub.chat.outlineOpen";
|
||||
const SIDE_KEY = "agenthub.chat.panelWidth";
|
||||
const clampSide = (f: number) => Math.round(Math.min(0.7, Math.max(0.2, f)) * 100) / 100;
|
||||
const storedSide = () => {
|
||||
const f = Number(localStorage.getItem(SIDE_KEY));
|
||||
return f ? clampSide(f) : null;
|
||||
};
|
||||
|
||||
/** The pane's permission mode as it shows it; a pick steps it there. */
|
||||
function PermissionChip({ runId, initial }: { runId: string; initial: PermissionMode | null }) {
|
||||
@@ -109,7 +124,7 @@ export function PaneChat({
|
||||
cwd,
|
||||
laneId = null,
|
||||
laneCwd = "",
|
||||
outline = false,
|
||||
panelOpen = false,
|
||||
}: {
|
||||
run: Pick<RunHandle, "id" | "tool" | "model" | "effort" | "permissionMode">;
|
||||
sessionId: string;
|
||||
@@ -117,27 +132,34 @@ export function PaneChat({
|
||||
/** The workspace and its folder: a file under `cwd` is previewed through it. */
|
||||
laneId?: number | null;
|
||||
laneCwd?: string;
|
||||
/** The pane is shown alone: room for the right panel. */
|
||||
outline?: boolean;
|
||||
/** The right panel starts open: the pane spans the grid's width or height. */
|
||||
panelOpen?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("run");
|
||||
const runId = run.id;
|
||||
const claude = run.tool === "claude";
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [images, setImages] = useState<PromptImage[]>([]);
|
||||
// The unsent prompt outlives the terminal view and a reload (`lib/chatDraft`).
|
||||
const [prompt, setPrompt] = useState(() => readChatDraft(sessionId).prompt);
|
||||
const [images, setImages] = useState<PromptImage[]>(() => readChatDraft(sessionId).images);
|
||||
useEffect(() => writeChatDraft(sessionId, prompt, images), [sessionId, prompt, images]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [follow, setFollow] = useState(0);
|
||||
const [model, setModel] = useState(run.model ?? "");
|
||||
const [effort, setEffort] = useState<EffortLevel>(run.effort ?? "");
|
||||
const pickFiles = useRef<(() => void) | null>(null);
|
||||
// Collapsed until asked for, like Claude Desktop's activity panel.
|
||||
const [outlineOpen, setOutlineOpen] = useState(() => localStorage.getItem(OUTLINE_KEY) === "1");
|
||||
const [outlineOpen, setOutlineOpen] = useState(panelOpen);
|
||||
// A split or a close changes the pane's shape: its default comes back.
|
||||
useEffect(() => setOutlineOpen(panelOpen), [panelOpen]);
|
||||
const openOutline = (open: boolean) => {
|
||||
localStorage.setItem(OUTLINE_KEY, open ? "1" : "0");
|
||||
setOutlineOpen(open);
|
||||
setPreview(null);
|
||||
};
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
// The side panel's width as a fraction of the pane: the dragged one, else
|
||||
// a default per content (a preview wants more room than the panel).
|
||||
const [dragged, setDragged] = useState(storedSide);
|
||||
const side = dragged ?? (preview ? 0.45 : 0.3);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const [list, setList] = useState<OutlineList | null>(null);
|
||||
const activity = useSessionOutline(sessionId);
|
||||
const scope: PreviewScope = { laneId, laneCwd: laneCwd || cwd, cwd, sessionId };
|
||||
@@ -186,7 +208,12 @@ export function PaneChat({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-0 flex-1">
|
||||
<div
|
||||
ref={rootRef}
|
||||
data-prompt-drop
|
||||
className="relative flex min-h-0 flex-1 [container-type:inline-size]"
|
||||
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">
|
||||
<ConversationView
|
||||
@@ -196,19 +223,19 @@ export function PaneChat({
|
||||
follow={follow}
|
||||
onOpenFile={(path) => {
|
||||
// The workspace's Changes panel when the file is in its folder;
|
||||
// else this view's own preview, when it has room for one.
|
||||
// else this view's own preview.
|
||||
if (laneId !== null && openInChanges(laneId, cwd, path)) return;
|
||||
if (outline) setPreview(path);
|
||||
setPreview(path);
|
||||
}}
|
||||
/>
|
||||
{outline && !outlineOpen && !preview && (
|
||||
{!outlineOpen && !preview && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="chat-outline-show"
|
||||
onClick={() => openOutline(true)}
|
||||
title={t("chat.outline.show")}
|
||||
aria-label={t("chat.outline.show")}
|
||||
className="absolute right-3 top-2 hidden h-7 items-center gap-1 rounded-md border border-border bg-surface-1 px-2 text-xs text-fg-secondary hover:bg-surface-3 hover:text-fg-primary lg:flex"
|
||||
className="absolute right-3 top-2 flex h-7 items-center gap-1 rounded-md border border-border bg-surface-1 px-2 text-xs text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
{activity.outputs.length}
|
||||
@@ -291,31 +318,57 @@ export function PaneChat({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{outline &&
|
||||
(preview ? (
|
||||
<FilePreview
|
||||
path={preview}
|
||||
scope={scope}
|
||||
onClose={() => setPreview(null)}
|
||||
// Beside the chat when there is room, over it when not.
|
||||
className="absolute inset-0 z-30 rounded-xl border border-border lg:static lg:m-3 lg:ml-0 lg:w-[45%] lg:max-w-3xl lg:flex-shrink-0"
|
||||
{preview ? (
|
||||
<FilePreview
|
||||
path={preview}
|
||||
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"
|
||||
/>
|
||||
) : (
|
||||
outlineOpen && (
|
||||
<ChatOutline
|
||||
outline={activity}
|
||||
onClose={() => openOutline(false)}
|
||||
onOpenFile={setPreview}
|
||||
onOpenList={setList}
|
||||
/>
|
||||
) : (
|
||||
outlineOpen && (
|
||||
<ChatOutline
|
||||
outline={activity}
|
||||
onClose={() => openOutline(false)}
|
||||
onOpenFile={setPreview}
|
||||
onOpenList={setList}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
{list && (
|
||||
)
|
||||
)}
|
||||
{(preview || outlineOpen) && (
|
||||
// Only beside the chat: a panel over a narrow pane has no edge to drag.
|
||||
<div className="hidden [@container(min-width:40rem)]:contents">
|
||||
<Divider
|
||||
gridRef={rootRef}
|
||||
dir="row"
|
||||
at={1 - side}
|
||||
box={{ x: 0, y: 0, w: 1, h: 1 }}
|
||||
label={t("chat.outline.resize")}
|
||||
onRatio={(r) => {
|
||||
const f = clampSide(1 - r);
|
||||
localStorage.setItem(SIDE_KEY, String(f));
|
||||
setDragged(f);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{list === "skills" && (
|
||||
<SkillListModal
|
||||
title={t("chat.outline.skills")}
|
||||
names={activity.skills}
|
||||
scope={scope}
|
||||
onClose={() => setList(null)}
|
||||
draggable={claude}
|
||||
/>
|
||||
)}
|
||||
{list && list !== "skills" && (
|
||||
<FileListModal
|
||||
title={t(`chat.outline.${list}`)}
|
||||
paths={activity[list]}
|
||||
scope={scope}
|
||||
onClose={() => setList(null)}
|
||||
onDragItem={(e, p) => dragAttachments(e, [outlineAttachment(p, list === "uploads")])}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -40,6 +40,7 @@ import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../Context
|
||||
import {
|
||||
EMPTY_GROUPS,
|
||||
type Edge,
|
||||
type GroupsScope,
|
||||
type PaneGroups,
|
||||
type PaneLayout,
|
||||
type Rect,
|
||||
@@ -82,10 +83,10 @@ export type LayoutUpdate = (fn: (l: PaneLayout) => PaneLayout) => void;
|
||||
export type GroupsUpdate = (fn: (g: PaneGroups) => PaneGroups) => void;
|
||||
|
||||
/** A workspace's tabs (groups of split panes), loaded and saved per
|
||||
* workspace. An update that lands after a workspace switch never writes into
|
||||
* the new one. */
|
||||
export function usePaneGroups(laneId: number | null): [PaneGroups, GroupsUpdate] {
|
||||
const load = (id: number | null) => ({
|
||||
* workspace (or once for every workspace, `all`). An update that lands after
|
||||
* a workspace switch never writes into the new one. */
|
||||
export function usePaneGroups(laneId: GroupsScope | null): [PaneGroups, GroupsUpdate] {
|
||||
const load = (id: GroupsScope | null) => ({
|
||||
laneId: id,
|
||||
groups: id === null ? EMPTY_GROUPS : loadGroups(id),
|
||||
});
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
* newlines, and pasted, dropped or picked files (`pickFiles`, a composer's
|
||||
* + menu; images as thumbnails, opened full size in `ImageLightbox`, any
|
||||
* other as a name chip) attached by their saved paths (`POST /api/run/drop-file`,
|
||||
* remembered for the + menu's Recent uploads), and a Files panel row
|
||||
* dropped on it (`lib/promptAttach.ts`) as an `@path` chip;
|
||||
* remembered for the + menu's Recent uploads), and a panel row (Files,
|
||||
* Changes, a context file or folder) dropped on it (`lib/promptAttach.ts`)
|
||||
* as an `@path` chip — a `bare` one takes a drop anywhere on its box;
|
||||
* a borderless (`bare`) one grows with its text up to 40vh;
|
||||
* - `CwdAutocomplete` — the folder field the Add-workspace modal uses;
|
||||
* - `ModelPicker` — a tool's own model list (`GET /api/ai-tools/:id/models`)
|
||||
@@ -63,7 +64,9 @@ import { ImageLightbox } from "../ImageLightbox";
|
||||
import {
|
||||
ATTACH_MIME,
|
||||
addAttachments,
|
||||
droppedAttachment,
|
||||
droppedAttachments,
|
||||
droppedSkill,
|
||||
SKILL_MIME,
|
||||
rememberUploads,
|
||||
} from "../../lib/promptAttach";
|
||||
|
||||
@@ -388,6 +391,45 @@ export function PromptEditor({
|
||||
const fileInput = useRef<HTMLInputElement | null>(null);
|
||||
if (pickFiles) pickFiles.current = () => fileInput.current?.click();
|
||||
|
||||
// A drop lands anywhere on the screen around the composer — the nearest
|
||||
// `data-prompt-drop` ancestor (a Chat pane, a replay, the workspace home),
|
||||
// else a `bare` editor's parent, the box every caller draws around it and
|
||||
// its toolbar — not only on the textarea: a panel row (`ATTACH_MIME`)
|
||||
// becomes an `@path` chip, a skill (`SKILL_MIME`) the prompt's leading
|
||||
// `/command`, a file is uploaded.
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
const dropRef = useRef<(e: DragEvent) => void>(() => {});
|
||||
dropRef.current = (e) => {
|
||||
const atts = droppedAttachments(e.dataTransfer);
|
||||
const skill = droppedSkill(e.dataTransfer);
|
||||
if (atts.length) {
|
||||
e.preventDefault();
|
||||
onImagesChange((prev) => addAttachments(prev, atts));
|
||||
} else if (skill) {
|
||||
e.preventDefault();
|
||||
onChange(`/${skill} ${value.replace(/^\s+/, "")}`);
|
||||
} else if (attachImages(e.dataTransfer?.files)) e.preventDefault();
|
||||
};
|
||||
useEffect(() => {
|
||||
const zone =
|
||||
rootRef.current?.closest<HTMLElement>("[data-prompt-drop]") ??
|
||||
(bare ? rootRef.current?.parentElement : rootRef.current);
|
||||
if (!zone) return;
|
||||
const over = (e: DragEvent) => {
|
||||
const types = e.dataTransfer?.types ?? [];
|
||||
if (![ATTACH_MIME, SKILL_MIME, "Files"].some((m) => types.includes(m))) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer!.dropEffect = "copy";
|
||||
};
|
||||
const drop = (e: DragEvent) => dropRef.current(e);
|
||||
zone.addEventListener("dragover", over);
|
||||
zone.addEventListener("drop", drop);
|
||||
return () => {
|
||||
zone.removeEventListener("dragover", over);
|
||||
zone.removeEventListener("drop", drop);
|
||||
};
|
||||
}, [bare]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (kind && count > 0) {
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
@@ -431,7 +473,7 @@ export function PromptEditor({
|
||||
`w-full text-left px-3 py-1.5 transition-colors ${i === current ? "bg-accent/15" : "hover:bg-surface-3"}`;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div ref={rootRef} className="relative">
|
||||
{images.length > 0 && (
|
||||
<div data-testid="prompt-images" className="mb-2 flex flex-wrap gap-2 px-1 pt-1">
|
||||
{images.map((img) => (
|
||||
@@ -527,19 +569,6 @@ export function PromptEditor({
|
||||
}
|
||||
}
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
// A Files panel row is not a file: say a drop is welcome.
|
||||
if (e.dataTransfer?.types.includes(ATTACH_MIME)) e.preventDefault();
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
const att = droppedAttachment(e.dataTransfer);
|
||||
if (att) {
|
||||
e.preventDefault();
|
||||
onImagesChange((prev) => addAttachments(prev, [att]));
|
||||
return;
|
||||
}
|
||||
if (attachImages(e.dataTransfer?.files)) e.preventDefault();
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
spellCheck={false}
|
||||
|
||||
@@ -2,14 +2,17 @@
|
||||
* @file ChatFilePreview.test.tsx
|
||||
* @description Where a Chat pane's file preview reads from: a file under the
|
||||
* pane's folder through the lane's git file read, a memory file under
|
||||
* `~/.claude` through the config pages' read, anything else by path only; and
|
||||
* the list dialog previewing the file picked in it.
|
||||
* `~/.claude` through the config pages' read, a dropped text file through the
|
||||
* drop route, an image read outside the folder through the session's image
|
||||
* route, a Windows path like a POSIX one, anything else by path only; the
|
||||
* list dialog previewing the file picked in it; and the skills dialog finding
|
||||
* each skill's SKILL.md by name.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { FileListModal, FilePreview, displayName } from "../ChatFilePreview";
|
||||
import { FileListModal, FilePreview, SkillListModal, displayName } from "../ChatFilePreview";
|
||||
import { api } from "../../../lib/api";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
@@ -28,7 +31,30 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
}),
|
||||
gitRawUrl: () => "",
|
||||
},
|
||||
sessions: { readImageUrl: (id: string, p: string) => `/img/${id}?path=${p}` },
|
||||
ccConfig: {
|
||||
skills: vi.fn().mockResolvedValue({
|
||||
items: [{ scope: "user", name: "xlsx", file: "/h/.claude/skills/xlsx/SKILL.md" }],
|
||||
}),
|
||||
plugins: vi.fn().mockResolvedValue({
|
||||
plugins: [
|
||||
{
|
||||
name: "agenthub",
|
||||
contributes: {
|
||||
items: {
|
||||
skills: [
|
||||
{
|
||||
name: "file-headers",
|
||||
description: null,
|
||||
file: "/h/.claude/plugins/cache/a/skills/file-headers/SKILL.md",
|
||||
},
|
||||
],
|
||||
commands: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
file: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, file: "", text: "Launch notes", size: 12, mtime: 0 }),
|
||||
@@ -53,6 +79,37 @@ describe("FilePreview", () => {
|
||||
expect(vi.mocked(api.ccConfig.file)).toHaveBeenCalledWith(mem);
|
||||
});
|
||||
|
||||
it("reads Windows paths the same way", async () => {
|
||||
const win = { laneId: 4, laneCwd: "D:\\w", cwd: "D:\\w", sessionId: "s1" };
|
||||
render(<FilePreview path={"D:\\w\\src\\a.ts"} scope={win} onClose={() => {}} />);
|
||||
expect(await screen.findByText("export const a = 1;")).toBeTruthy();
|
||||
expect(vi.mocked(api.lanes.gitFile)).toHaveBeenCalledWith(4, "src/a.ts");
|
||||
cleanup();
|
||||
const tool = "C:\\Users\\k\\.claude\\projects\\D--w\\s1\\tool-results\\b.txt";
|
||||
render(<FilePreview path={tool} scope={win} onClose={() => {}} />);
|
||||
expect(await screen.findByText("Launch notes")).toBeTruthy();
|
||||
expect(vi.mocked(api.ccConfig.file)).toHaveBeenCalledWith(tool);
|
||||
expect(screen.getByText("b")).toBeTruthy(); // the name, not the whole path
|
||||
});
|
||||
|
||||
it("reads a dropped text file through the drop route", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response("a long paste"));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const drop = "C:\\Users\\k\\.claude\\agenthub\\drops\\0123456789abcdef-pasted-text.txt";
|
||||
render(<FilePreview path={drop} scope={scope} onClose={() => {}} />);
|
||||
expect(await screen.findByText("a long paste")).toBeTruthy();
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/run/drop/0123456789abcdef-pasted-text.txt");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("shows an image it read outside the folder through the session, its path if refused", () => {
|
||||
render(<FilePreview path="/h/.cache/shot.png" scope={scope} onClose={() => {}} />);
|
||||
const img = screen.getByAltText("shot.png") as HTMLImageElement;
|
||||
expect(img.getAttribute("src")).toBe("/img/s1?path=/h/.cache/shot.png");
|
||||
fireEvent.error(img);
|
||||
expect(screen.getByText("No preview for this file here")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows only the path of a file it may not read", () => {
|
||||
render(<FilePreview path="/etc/hosts" scope={scope} onClose={() => {}} />);
|
||||
expect(screen.getByText("No preview for this file here")).toBeTruthy();
|
||||
@@ -76,3 +133,28 @@ describe("FileListModal", () => {
|
||||
expect(await screen.findByText("export const a = 1;")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SkillListModal", () => {
|
||||
it("lists the skills by name and previews each one's SKILL.md", async () => {
|
||||
render(
|
||||
<SkillListModal
|
||||
title="Skills"
|
||||
names={["agenthub:file-headers", "xlsx", "simplify"]}
|
||||
scope={scope}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
);
|
||||
// A plugin's skill first, read through the config read.
|
||||
expect(await screen.findByRole("button", { name: "agenthub:file-headers" })).toBeTruthy();
|
||||
await screen.findByText("Launch notes");
|
||||
expect(vi.mocked(api.ccConfig.file)).toHaveBeenCalledWith(
|
||||
"/h/.claude/plugins/cache/a/skills/file-headers/SKILL.md"
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "xlsx" }));
|
||||
await screen.findByText("Launch notes");
|
||||
expect(vi.mocked(api.ccConfig.file)).toHaveBeenCalledWith("/h/.claude/skills/xlsx/SKILL.md");
|
||||
// A built-in skill has no file.
|
||||
fireEvent.click(screen.getByRole("button", { name: "simplify" }));
|
||||
expect(screen.getByText("No preview for this file here")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* @description The Chat pane's activity panel renders a session's outline the
|
||||
* way Claude Desktop does: progress with done/current items, outputs by name
|
||||
* with an EXT tag (a click opens that file), and "Used in this session" rows —
|
||||
* uploads, files read and memory open their list, skills link to the config.
|
||||
* uploads, files read, memory and skills open their list.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -52,15 +52,47 @@ describe("ChatOutline", () => {
|
||||
expect(screen.getByText("+1")).toBeTruthy(); // three files read, two named
|
||||
fireEvent.click(screen.getByTitle("Memory: release"));
|
||||
expect(onOpenList).toHaveBeenCalledWith("memory");
|
||||
expect(screen.getByTitle("Skills: xlsx").getAttribute("href")).toBe(
|
||||
"/settings/agents/claude-code?tab=skills"
|
||||
);
|
||||
fireEvent.click(screen.getByTitle("Skills: xlsx"));
|
||||
expect(onOpenList).toHaveBeenCalledWith("skills");
|
||||
expect(screen.getByText("vercel_vercel")).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Hide side panel" }));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drags its rows into the prompt box", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ChatOutline
|
||||
outline={outline}
|
||||
onClose={() => {}}
|
||||
onOpenFile={() => {}}
|
||||
onOpenList={() => {}}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
const dragged = (el: HTMLElement) => {
|
||||
const data: Record<string, string> = {};
|
||||
fireEvent.dragStart(el, {
|
||||
dataTransfer: { setData: (k: string, v: string) => (data[k] = v) },
|
||||
});
|
||||
return data;
|
||||
};
|
||||
const attached = (el: HTMLElement) =>
|
||||
JSON.parse(dragged(el)["application/x-agenthub-attach"] ?? "[]") as { path: string }[];
|
||||
expect(attached(screen.getByRole("button", { name: "XLSX: Progress report" }))).toEqual([
|
||||
{ path: "@/w/out/Progress report.xlsx", name: "Progress report.xlsx" },
|
||||
]);
|
||||
expect(attached(screen.getByTitle(/a\.ts, /)).map((a) => a.path)).toEqual([
|
||||
"@/w/src/a.ts",
|
||||
"@/w/src/b.ts",
|
||||
"@/w/src/c.ts",
|
||||
]);
|
||||
// An upload goes as the composer attaches one: its bare path.
|
||||
expect(attached(screen.getByTitle("Uploads: minutes.xlsx"))[0]!.path).toBe(outline.uploads[0]);
|
||||
expect(dragged(screen.getByTitle("Skills: xlsx"))["application/x-agenthub-skill"]).toBe("xlsx");
|
||||
});
|
||||
|
||||
it("hides progress and the used section when there is none", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
|
||||
@@ -122,7 +122,11 @@ function renderChat(run: Partial<typeof RUN> = {}) {
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => vi.mocked(submitToRun).mockClear());
|
||||
beforeEach(() => {
|
||||
vi.mocked(submitToRun).mockClear();
|
||||
// A prompt typed in one test is a saved draft in the next (`lib/chatDraft`).
|
||||
localStorage.removeItem("agenthub.chat.draft.s1");
|
||||
});
|
||||
|
||||
describe("PaneChat — the + menu", () => {
|
||||
it("puts a skill, or a plugin's skill, at the front of the message", async () => {
|
||||
@@ -230,10 +234,9 @@ const byId = (id: string) => () => screen.getByTestId(id);
|
||||
|
||||
describe("PaneChat — the activity panel", () => {
|
||||
it("opens from the file button, previews a file, and closes back to the panel", async () => {
|
||||
localStorage.removeItem("agenthub.chat.outlineOpen");
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PaneChat run={RUN} sessionId="s1" cwd="/w" laneId={7} laneCwd="/w" outline />
|
||||
<PaneChat run={RUN} sessionId="s1" cwd="/w" laneId={7} laneCwd="/w" />
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(screen.queryByTestId("chat-outline")).toBeNull();
|
||||
@@ -248,7 +251,33 @@ describe("PaneChat — the activity panel", () => {
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
expect(screen.getByTestId("chat-outline")).toBeTruthy();
|
||||
expect(localStorage.getItem("agenthub.chat.outlineOpen")).toBe("1");
|
||||
});
|
||||
|
||||
it("starts open in a pane spanning the grid, and follows the pane when it is split", () => {
|
||||
const pane = (panelOpen: boolean) => (
|
||||
<MemoryRouter>
|
||||
<PaneChat run={RUN} sessionId="s1" cwd="/w" panelOpen={panelOpen} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
const { rerender } = render(pane(true));
|
||||
expect(screen.getByTestId("chat-outline")).toBeTruthy();
|
||||
rerender(pane(false));
|
||||
expect(screen.queryByTestId("chat-outline")).toBeNull();
|
||||
expect(screen.getByTestId("chat-outline-show")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("resizes from its splitter and remembers the width", () => {
|
||||
localStorage.removeItem("agenthub.chat.panelWidth");
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PaneChat run={RUN} sessionId="s1" cwd="/w" panelOpen />
|
||||
</MemoryRouter>
|
||||
);
|
||||
const splitter = screen.getByRole("separator", { name: "Resize side panel" });
|
||||
expect(splitter.getAttribute("aria-valuenow")).toBe("70");
|
||||
fireEvent.keyDown(splitter, { key: "ArrowLeft" });
|
||||
expect(localStorage.getItem("agenthub.chat.panelWidth")).toBe("0.35");
|
||||
expect(splitter.getAttribute("aria-valuenow")).toBe("65");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -259,7 +288,7 @@ describe("PaneChat — an edited file", () => {
|
||||
window.addEventListener(OPEN_IN_CHANGES_EVENT, listen);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PaneChat run={RUN} sessionId="s1" cwd="/w" laneId={7} laneCwd="/w" outline />
|
||||
<PaneChat run={RUN} sessionId="s1" cwd="/w" laneId={7} laneCwd="/w" />
|
||||
</MemoryRouter>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId("edited-in"));
|
||||
@@ -272,3 +301,36 @@ describe("PaneChat — an edited file", () => {
|
||||
window.removeEventListener(OPEN_IN_CHANGES_EVENT, listen);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PaneChat — the unsent prompt", () => {
|
||||
it("keeps the half-typed prompt and its attachments when the chat unmounts (terminal view, reload)", async () => {
|
||||
const first = renderChat();
|
||||
fireEvent.change(screen.getByPlaceholderText("Write a message…"), {
|
||||
target: { value: "half typed" },
|
||||
});
|
||||
// A dropped attachment, as a Files row or an "Edited N files" row brings it.
|
||||
const drop = new Event("drop", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(drop, "dataTransfer", {
|
||||
value: {
|
||||
types: ["application/x-agenthub-attach"],
|
||||
getData: (t: string) =>
|
||||
t === "application/x-agenthub-attach"
|
||||
? JSON.stringify({ path: "@src/a.ts", name: "a.ts" })
|
||||
: "",
|
||||
files: [],
|
||||
},
|
||||
});
|
||||
act(() => {
|
||||
screen.getByPlaceholderText("Write a message…").dispatchEvent(drop);
|
||||
});
|
||||
expect(await screen.findByText("a.ts")).toBeInTheDocument();
|
||||
first.unmount();
|
||||
|
||||
renderChat();
|
||||
expect(screen.getByPlaceholderText("Write a message…")).toHaveValue("half typed");
|
||||
expect(screen.getByText("a.ts")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTestId("pane-chat-send"));
|
||||
expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "half typed @src/a.ts");
|
||||
await waitFor(() => expect(localStorage.getItem("agenthub.chat.draft.s1")).toBeNull());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { createEvent, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import {
|
||||
@@ -28,7 +28,13 @@ import {
|
||||
type PromptImage,
|
||||
} from "../RunSetup";
|
||||
import { api } from "../../../lib/api";
|
||||
import { dragProjectPath, recentUploads } from "../../../lib/promptAttach";
|
||||
import {
|
||||
ATTACH_MIME,
|
||||
dragAttachments,
|
||||
dragProjectPath,
|
||||
dragSkill,
|
||||
recentUploads,
|
||||
} from "../../../lib/promptAttach";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
@@ -348,6 +354,132 @@ describe("PromptEditor — newlines and images", () => {
|
||||
expect(screen.getByTestId("sent").textContent).toBe("read @src/lib/");
|
||||
});
|
||||
|
||||
it("takes a drop anywhere on a bare editor's box; an extra folder's row comes absolute", () => {
|
||||
function Harness() {
|
||||
const [images, setImages] = useState<PromptImage[]>([]);
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<PromptEditor
|
||||
bare
|
||||
slash={false}
|
||||
value="read"
|
||||
onChange={() => {}}
|
||||
onSubmit={() => {}}
|
||||
placeholder="Ask anything…"
|
||||
fileCwd="/Users/tester"
|
||||
images={images}
|
||||
onImagesChange={setImages}
|
||||
/>
|
||||
<div data-testid="toolbar" />
|
||||
</div>
|
||||
<section data-drag-root="/Users/tester/extra/">
|
||||
<span data-testid="row" />
|
||||
</section>
|
||||
<output data-testid="sent">{promptWithImages("read", images)}</output>
|
||||
</>
|
||||
);
|
||||
}
|
||||
render(<Harness />);
|
||||
const data: Record<string, string> = {};
|
||||
const dataTransfer = {
|
||||
setData: (k: string, v: string) => (data[k] = v),
|
||||
getData: (k: string) => data[k] ?? "",
|
||||
get types() {
|
||||
return Object.keys(data);
|
||||
},
|
||||
files: [],
|
||||
};
|
||||
dragProjectPath(
|
||||
{ dataTransfer, currentTarget: screen.getByTestId("row") } as unknown as React.DragEvent,
|
||||
"notes.md"
|
||||
);
|
||||
expect(data["text/plain"]).toBe("/Users/tester/extra/notes.md");
|
||||
const over = createEvent.dragOver(screen.getByTestId("toolbar"), { dataTransfer });
|
||||
fireEvent(screen.getByTestId("toolbar"), over);
|
||||
expect(over.defaultPrevented).toBe(true);
|
||||
fireEvent.drop(screen.getByTestId("toolbar"), { dataTransfer });
|
||||
expect(screen.getByTestId("sent").textContent).toBe("read @/Users/tester/extra/notes.md");
|
||||
});
|
||||
|
||||
it("takes a drop anywhere inside its data-prompt-drop screen, not only on the box", () => {
|
||||
function Harness() {
|
||||
const [images, setImages] = useState<PromptImage[]>([]);
|
||||
return (
|
||||
<div data-prompt-drop>
|
||||
<div data-testid="transcript" />
|
||||
<PromptEditor
|
||||
value="see"
|
||||
onChange={() => {}}
|
||||
onSubmit={() => {}}
|
||||
placeholder="Ask anything…"
|
||||
images={images}
|
||||
onImagesChange={setImages}
|
||||
/>
|
||||
<output data-testid="sent">{promptWithImages("see", images)}</output>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
render(<Harness />);
|
||||
const dataTransfer = {
|
||||
getData: (k: string) =>
|
||||
k === ATTACH_MIME ? JSON.stringify([{ path: "@/w/a.ts", name: "a.ts" }]) : "",
|
||||
types: [ATTACH_MIME],
|
||||
files: [],
|
||||
};
|
||||
const over = createEvent.dragOver(screen.getByTestId("transcript"), { dataTransfer });
|
||||
fireEvent(screen.getByTestId("transcript"), over);
|
||||
expect(over.defaultPrevented).toBe(true);
|
||||
fireEvent.drop(screen.getByTestId("transcript"), { dataTransfer });
|
||||
expect(screen.getByTestId("sent").textContent).toBe("see @/w/a.ts");
|
||||
});
|
||||
|
||||
it("takes an activity panel's drag: a group of files as chips, a skill as the /command", () => {
|
||||
function Harness() {
|
||||
const [value, setValue] = useState("check it");
|
||||
const [images, setImages] = useState<PromptImage[]>([]);
|
||||
return (
|
||||
<>
|
||||
<PromptEditor
|
||||
slash={false}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
onSubmit={() => {}}
|
||||
placeholder="Ask anything…"
|
||||
fileCwd="/Users/tester"
|
||||
images={images}
|
||||
onImagesChange={setImages}
|
||||
/>
|
||||
<output data-testid="sent">{promptWithImages(value, images)}</output>
|
||||
</>
|
||||
);
|
||||
}
|
||||
render(<Harness />);
|
||||
const drag = (start: (e: React.DragEvent) => void) => {
|
||||
const data: Record<string, string> = {};
|
||||
const dataTransfer = {
|
||||
setData: (k: string, v: string) => (data[k] = v),
|
||||
getData: (k: string) => data[k] ?? "",
|
||||
get types() {
|
||||
return Object.keys(data);
|
||||
},
|
||||
files: [],
|
||||
};
|
||||
start({ dataTransfer } as unknown as React.DragEvent);
|
||||
fireEvent.drop(box(), { dataTransfer });
|
||||
};
|
||||
drag((e) =>
|
||||
dragAttachments(e, [
|
||||
{ path: "@/a/x.md", name: "x.md" },
|
||||
{ path: "/d/drops/0123456789abcdef.png", name: "y.png" },
|
||||
])
|
||||
);
|
||||
drag((e) => dragSkill(e, "ponytail:ponytail-help"));
|
||||
expect(screen.getByTestId("sent").textContent).toBe(
|
||||
"/ponytail:ponytail-help check it @/a/x.md /d/drops/0123456789abcdef.png"
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a text paste to the textarea", () => {
|
||||
renderEditor("");
|
||||
fireEvent.paste(box(), {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* @file AppearanceSettings.tsx
|
||||
* @description Settings → Appearance (`/settings/appearance`): the per-user
|
||||
* Chat UI default (`ChatUiSettings`, `#chat-ui`), the interface language, the
|
||||
* Chat UI default (`ChatUiSettings`, `#chat-ui`), whether the session view's
|
||||
* tabs hold every workspace's sessions (`lib/workspaceTabs.ts`,
|
||||
* `#workspace-tabs`), the interface language, the
|
||||
* terminal font (`lib/terminalFont.ts`, with a preview line), the
|
||||
* Dark / Light / System mode switch and one named-theme picker per mode, and the
|
||||
* corner pet (`PetSettings`, deep-linked as `#pet`) — the old General and Pet
|
||||
@@ -17,7 +19,7 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Monitor, Moon, RotateCcw, Sun } from "lucide-react";
|
||||
import { SettingsRow, SettingsSection, SettingsSegmented } from "./SettingsRow";
|
||||
import { SettingsRow, SettingsSection, SettingsSegmented, SettingsSwitchRow } from "./SettingsRow";
|
||||
import { PetSettings } from "./PetSettings";
|
||||
import { ChatUiSettings } from "./ChatUiSettings";
|
||||
import { Select } from "../Select";
|
||||
@@ -31,6 +33,7 @@ import {
|
||||
writeTerminalFont,
|
||||
type TerminalFont,
|
||||
} from "../../lib/terminalFont";
|
||||
import { readAllTabs, writeAllTabs } from "../../lib/workspaceTabs";
|
||||
|
||||
export const THEME_LABELS: Record<string, string> = {
|
||||
darcula: "Darcula",
|
||||
@@ -79,6 +82,7 @@ export function AppearanceSettings() {
|
||||
const { t, i18n } = useTranslation("settings");
|
||||
const { mode, theme, themeFor, setMode, setThemeFor } = useTheme();
|
||||
const [termFont, setTermFont] = useState(readTerminalFont);
|
||||
const [allTabs, setAllTabs] = useState(readAllTabs);
|
||||
const pickTermFont = (font: TerminalFont) => {
|
||||
writeTerminalFont(font);
|
||||
setTermFont(font);
|
||||
@@ -101,6 +105,22 @@ export function AppearanceSettings() {
|
||||
<div className="space-y-6">
|
||||
<ChatUiSettings />
|
||||
|
||||
<SettingsSection
|
||||
id="workspace-tabs"
|
||||
title={t("workspaceTabs.title")}
|
||||
description={t("workspaceTabs.description")}
|
||||
>
|
||||
<SettingsSwitchRow
|
||||
label={t("workspaceTabs.label")}
|
||||
description={t("workspaceTabs.labelDesc")}
|
||||
checked={allTabs}
|
||||
onChange={(on) => {
|
||||
writeAllTabs(on);
|
||||
setAllTabs(on);
|
||||
}}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
id="language"
|
||||
title={t("language.title")}
|
||||
|
||||
@@ -106,6 +106,7 @@
|
||||
"outputs": "Outputs",
|
||||
"connectors": "Connectors",
|
||||
"empty": "Nothing yet",
|
||||
"resize": "Resize side panel",
|
||||
"hide": "Hide side panel",
|
||||
"show": "Activity panel",
|
||||
"copyPath": "Copy path: {{path}}",
|
||||
@@ -114,9 +115,18 @@
|
||||
"uploads": "Uploads",
|
||||
"files": "Files read",
|
||||
"memory": "Memory",
|
||||
"skills": "Skills",
|
||||
"close": "Close",
|
||||
"noPreview": "No preview for this file here",
|
||||
"truncated": "Showing the first part of this file"
|
||||
}
|
||||
},
|
||||
"interrupted": {
|
||||
"title": "Resume interrupted sessions?",
|
||||
"message": "These sessions stopped unexpectedly (the machine shut down, tmux was killed, or the CLI crashed). Tick the ones to resume — each re-reads its whole conversation.",
|
||||
"resume_one": "Resume {{count}} session",
|
||||
"resume_other": "Resume {{count}} sessions",
|
||||
"none": "Don't resume",
|
||||
"failed": "Couldn't resume {{name}}: {{message}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -895,6 +895,12 @@
|
||||
"vi": "Tiếng Việt"
|
||||
}
|
||||
},
|
||||
"workspaceTabs": {
|
||||
"title": "Workspace tabs",
|
||||
"description": "Which sessions the session view's tab bar holds. Saved to your account.",
|
||||
"label": "Tabs from every workspace",
|
||||
"labelDesc": "Keep every workspace's live sessions as tabs, and mix them in one split. Focusing a tab selects its workspace. Off: only the selected workspace's sessions."
|
||||
},
|
||||
"terminalFont": {
|
||||
"title": "Terminal font",
|
||||
"description": "The font of the workspace terminals. Takes effect the next time a terminal opens. Saved to your account.",
|
||||
|
||||
@@ -106,6 +106,7 @@
|
||||
"outputs": "Kết quả",
|
||||
"connectors": "Kết nối",
|
||||
"empty": "Chưa có gì",
|
||||
"resize": "Kéo giãn thanh bên",
|
||||
"hide": "Ẩn thanh bên",
|
||||
"show": "Bảng hoạt động",
|
||||
"copyPath": "Sao chép đường dẫn: {{path}}",
|
||||
@@ -114,9 +115,18 @@
|
||||
"uploads": "Tệp tải lên",
|
||||
"files": "Tệp đã đọc",
|
||||
"memory": "Bộ nhớ",
|
||||
"skills": "Skills",
|
||||
"close": "Đóng",
|
||||
"noPreview": "Không xem trước được tệp này ở đây",
|
||||
"truncated": "Chỉ hiện phần đầu của tệp"
|
||||
}
|
||||
},
|
||||
"interrupted": {
|
||||
"title": "Tiếp tục các phiên bị gián đoạn?",
|
||||
"message": "Các phiên này dừng ngoài ý muốn (tắt máy ngang, tmux bị tắt, hoặc CLI bị lỗi). Đánh dấu những phiên muốn tiếp tục — mỗi phiên sẽ đọc lại toàn bộ hội thoại.",
|
||||
"resume_one": "Tiếp tục {{count}} phiên",
|
||||
"resume_other": "Tiếp tục {{count}} phiên",
|
||||
"none": "Không tiếp tục",
|
||||
"failed": "Không tiếp tục được {{name}}: {{message}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -895,6 +895,12 @@
|
||||
"vi": "Tiếng Việt"
|
||||
}
|
||||
},
|
||||
"workspaceTabs": {
|
||||
"title": "Tab của workspace",
|
||||
"description": "Thanh tab của màn hình session chứa những session nào. Lưu theo tài khoản.",
|
||||
"label": "Tab của mọi workspace",
|
||||
"labelDesc": "Giữ session đang chạy của mọi workspace thành tab, và trộn chúng trong một tab chia đôi. Chọn một tab sẽ chuyển sang workspace của nó. Tắt: chỉ session của workspace đang chọn."
|
||||
},
|
||||
"terminalFont": {
|
||||
"title": "Font terminal",
|
||||
"description": "Font của các terminal trong workspace. Áp dụng từ lần mở terminal tiếp theo. Lưu vào tài khoản của bạn.",
|
||||
|
||||
@@ -922,6 +922,10 @@ export const api = {
|
||||
/** GET /api/sessions/:id/outline — the session's plan, the files it wrote
|
||||
* and the skills / MCP servers it used (a Chat pane's side panel). */
|
||||
outline: (id: string) => request<SessionOutline>(`/sessions/${encodeURIComponent(id)}/outline`),
|
||||
/** GET /api/sessions/:id/image — an image the session's Read tool read,
|
||||
* wherever it lies (local mode only), for an `<img>`. */
|
||||
readImageUrl: (id: string, path: string) =>
|
||||
`${BASE}/sessions/${encodeURIComponent(id)}/image?${new URLSearchParams({ path })}`,
|
||||
/** PATCH /api/sessions/:id — rename, or archive (hides it from its
|
||||
* workspace, deletes nothing). */
|
||||
patch: (id: string, body: { name?: string; archived?: boolean }) =>
|
||||
@@ -2499,6 +2503,15 @@ export const api = {
|
||||
/** DELETE /api/run/:id - kill the tmux session. */
|
||||
kill: (id: string) =>
|
||||
request<{ ok: true }>(`/run/${encodeURIComponent(id)}`, { method: "DELETE" }),
|
||||
/** GET /api/run/interrupted - sessions whose pane died under them (reboot,
|
||||
* killed tmux, crash), newest first; the caller's own workspaces only. */
|
||||
interrupted: () => request<{ items: InterruptedRun[] }>("/run/interrupted"),
|
||||
/** POST /api/run/interrupted/dismiss - stop offering these sessions. */
|
||||
dismissInterrupted: (sessionIds: string[]) =>
|
||||
request<{ dismissed: number }>("/run/interrupted/dismiss", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ sessionIds }),
|
||||
}),
|
||||
},
|
||||
|
||||
// ────────────────────────────────── Alerts API ──────────────────────────────
|
||||
@@ -4369,6 +4382,24 @@ export interface RunStartArgs {
|
||||
proxy?: string;
|
||||
}
|
||||
|
||||
/** One session an unexpected stop left behind (GET /api/run/interrupted). */
|
||||
export interface InterruptedRun {
|
||||
id: string;
|
||||
session_id: string;
|
||||
lane_id: number;
|
||||
tool: LaneTool;
|
||||
cwd: string;
|
||||
model: string | null;
|
||||
permission_mode: PermissionMode | null;
|
||||
effort: EffortLevel | null;
|
||||
prompt_preview: string | null;
|
||||
started_at: string;
|
||||
ended_at: string | null;
|
||||
lane_title: string;
|
||||
lane_cwd: string;
|
||||
session_name: string | null;
|
||||
}
|
||||
|
||||
/** A lane's tmux-backed terminal run — one per lane, id is the tmux session
|
||||
* name (`agenthub-lane-<laneId>`). */
|
||||
export interface RunHandle {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @file chatDraft.ts — a Chat pane's unsent prompt (`PaneChat`): its text and
|
||||
* attachments, kept per SESSION in this browser's localStorage
|
||||
* (`agenthub.chat.draft.<sessionId>`), so it survives the pane switching to
|
||||
* its terminal (which unmounts the chat) and a page reload — as the terminal's
|
||||
* own half-typed line survives in tmux. An image's preview (a blob URL) does
|
||||
* not outlive the page, so a restored attachment keeps only its path and name.
|
||||
* Cleared once the prompt is sent (an empty draft removes the key).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import type { PromptImage } from "../components/run/RunSetup";
|
||||
|
||||
const key = (sessionId: string) => `agenthub.chat.draft.${sessionId}`;
|
||||
|
||||
export interface ChatDraft {
|
||||
prompt: string;
|
||||
images: PromptImage[];
|
||||
}
|
||||
|
||||
export function readChatDraft(sessionId: string): ChatDraft {
|
||||
try {
|
||||
const v = JSON.parse(
|
||||
localStorage.getItem(key(sessionId)) || "null"
|
||||
) as Partial<ChatDraft> | null;
|
||||
return {
|
||||
prompt: typeof v?.prompt === "string" ? v.prompt : "",
|
||||
images: Array.isArray(v?.images)
|
||||
? v.images
|
||||
.filter((i) => typeof i?.path === "string" && typeof i.name === "string")
|
||||
.map((i) => ({ path: i.path, name: i.name }))
|
||||
: [],
|
||||
};
|
||||
} catch {
|
||||
return { prompt: "", images: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export function writeChatDraft(sessionId: string, prompt: string, images: PromptImage[]) {
|
||||
try {
|
||||
if (!prompt && !images.length) localStorage.removeItem(key(sessionId));
|
||||
else
|
||||
localStorage.setItem(
|
||||
key(sessionId),
|
||||
JSON.stringify({ prompt, images: images.map(({ path, name }) => ({ path, name })) })
|
||||
);
|
||||
} catch {
|
||||
/* storage full or blocked: the draft lives for this page only */
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,10 @@
|
||||
* its session back in a tab of its own. The tabs are a viewer preference,
|
||||
* kept per workspace in `localStorage` (`agenthub.workspace.paneLayout`; a
|
||||
* stored pre-tab single layout reads as one tab), never on the server.
|
||||
*
|
||||
* With Settings → Appearance → "Tabs from every workspace" on, ONE set of tabs
|
||||
* (stored under `all`) holds every workspace's sessions: a leaf is then a
|
||||
* `paneKey` (lane id × `MAX_SLOTS` + slot) instead of a bare slot.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -24,6 +28,12 @@
|
||||
export const MAX_PANES = 4;
|
||||
/** Sessions a workspace may hold: slots 0–9 (`MAX_PANE_SLOT` + 1 on the server). */
|
||||
export const MAX_SLOTS = 10;
|
||||
/** A pane's id in the every-workspace tabs: unique across lanes, still a number. */
|
||||
export const paneKey = (laneId: number, slot: number) => laneId * MAX_SLOTS + slot;
|
||||
/** The slot of a leaf — itself, or a `paneKey`'s slot part. */
|
||||
export const slotOfKey = (key: number) => key % MAX_SLOTS;
|
||||
/** The tabs' storage name: a lane id, or `all` for the every-workspace tabs. */
|
||||
export type GroupsScope = number | "all";
|
||||
const MIN_RATIO = 0.1;
|
||||
const STORAGE_KEY = "agenthub.workspace.paneLayout";
|
||||
|
||||
@@ -223,17 +233,17 @@ export function layoutRects(node: PaneNode | null): {
|
||||
return { panes, dividers };
|
||||
}
|
||||
|
||||
function parseNode(raw: unknown): PaneNode | null {
|
||||
function parseNode(raw: unknown, max: number): PaneNode | null {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const n = raw as Record<string, unknown>;
|
||||
if (n.type === "pane") {
|
||||
return Number.isInteger(n.slot) && (n.slot as number) >= 0 && (n.slot as number) < MAX_SLOTS
|
||||
return Number.isInteger(n.slot) && (n.slot as number) >= 0 && (n.slot as number) < max
|
||||
? pane(n.slot as number)
|
||||
: null;
|
||||
}
|
||||
if (n.type !== "split" || (n.dir !== "row" && n.dir !== "col")) return null;
|
||||
const a = parseNode(n.a);
|
||||
const b = parseNode(n.b);
|
||||
const a = parseNode(n.a, max);
|
||||
const b = parseNode(n.b, max);
|
||||
if (!a || !b) return null;
|
||||
const ratio = typeof n.ratio === "number" && Number.isFinite(n.ratio) ? clampRatio(n.ratio) : 0.5;
|
||||
return split(n.dir, a, b, ratio);
|
||||
@@ -241,9 +251,9 @@ function parseNode(raw: unknown): PaneNode | null {
|
||||
|
||||
/** A stored layout, checked: anything malformed, a slot out of range, a slot
|
||||
* shown twice or more than `MAX_PANES` panes is no layout at all. */
|
||||
export function parseLayout(raw: unknown): PaneLayout {
|
||||
export function parseLayout(raw: unknown, max = MAX_SLOTS): PaneLayout {
|
||||
if (!raw || typeof raw !== "object") return EMPTY;
|
||||
const root = parseNode((raw as { root?: unknown }).root);
|
||||
const root = parseNode((raw as { root?: unknown }).root, max);
|
||||
const shown = leaves(root);
|
||||
if (!root || shown.length > MAX_PANES || new Set(shown).size !== shown.length) return EMPTY;
|
||||
const focus = (raw as { focus?: unknown }).focus;
|
||||
@@ -389,7 +399,7 @@ export function pruneGroups(g: PaneGroups, live: Set<number>, keep: Set<number>)
|
||||
|
||||
/** Stored tabs, checked: a malformed tab, or one showing a session an
|
||||
* earlier tab already holds, is dropped. A pre-tab single layout is one tab. */
|
||||
export function parseGroups(raw: unknown): PaneGroups {
|
||||
export function parseGroups(raw: unknown, max = MAX_SLOTS): PaneGroups {
|
||||
if (!raw || typeof raw !== "object") return EMPTY_GROUPS;
|
||||
const r = raw as { groups?: unknown; active?: unknown };
|
||||
const list = Array.isArray(r.groups) ? r.groups : "root" in r ? [raw] : [];
|
||||
@@ -400,7 +410,7 @@ export function parseGroups(raw: unknown): PaneGroups {
|
||||
// a dropped tab ahead of it does not shift the selection onto its neighbour.
|
||||
let active = 0;
|
||||
list.forEach((item, i) => {
|
||||
const l = parseLayout(item);
|
||||
const l = parseLayout(item, max);
|
||||
const shown = leaves(l.root);
|
||||
if (!l.root || shown.some((s) => seen.has(s))) return;
|
||||
shown.forEach((s) => seen.add(s));
|
||||
@@ -420,11 +430,14 @@ function readAll(): Record<string, unknown> {
|
||||
}
|
||||
}
|
||||
|
||||
export function loadGroups(laneId: number): PaneGroups {
|
||||
return parseGroups(readAll()[String(laneId)]);
|
||||
export function loadGroups(laneId: GroupsScope): PaneGroups {
|
||||
return parseGroups(
|
||||
readAll()[String(laneId)],
|
||||
laneId === "all" ? Number.MAX_SAFE_INTEGER : MAX_SLOTS
|
||||
);
|
||||
}
|
||||
|
||||
export function saveGroups(laneId: number, g: PaneGroups): void {
|
||||
export function saveGroups(laneId: GroupsScope, g: PaneGroups): void {
|
||||
const all = readAll();
|
||||
if (g.groups.length) all[String(laneId)] = g;
|
||||
else delete all[String(laneId)];
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* @file promptAttach.ts
|
||||
* @description What a prompt box can attach besides a fresh upload: a project
|
||||
* file or folder dragged from the Files panel (`ATTACH_MIME`, read back by
|
||||
* `PromptEditor`'s drop) and the files uploaded through a prompt box in the
|
||||
* `PromptEditor`'s drop) or from a Chat pane's activity panel
|
||||
* (`dragAttachments`, `dragSkill`: a skill becomes the prompt's `/command`) and the files uploaded through a prompt box in the
|
||||
* last day (`recentUploads`, kept in this browser). An attachment is a path
|
||||
* sent with the prompt, so nothing here touches the server.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -26,21 +27,59 @@ export function projectAttachment(rel: string): Attachment {
|
||||
}
|
||||
|
||||
/** Start a drag carrying `rel` (a Files panel row) to a prompt box; plain
|
||||
* text too, so a drop elsewhere (a terminal, an editor) gets the path. */
|
||||
* text too, so a drop elsewhere (a terminal, an editor) gets the path. A row
|
||||
* under a `data-drag-root` (an extra folder's section) lies outside the
|
||||
* pane's folder, so it carries its absolute path instead. */
|
||||
export function dragProjectPath(e: DragEvent, rel: string) {
|
||||
e.dataTransfer.setData(ATTACH_MIME, JSON.stringify(projectAttachment(rel)));
|
||||
e.dataTransfer.setData("text/plain", rel);
|
||||
const root = (e.currentTarget as HTMLElement | null)?.closest?.<HTMLElement>("[data-drag-root]")
|
||||
?.dataset.dragRoot;
|
||||
const path = root ? `${root.replace(/[\\/]+$/, "")}/${rel}` : rel;
|
||||
e.dataTransfer.setData(ATTACH_MIME, JSON.stringify(projectAttachment(path)));
|
||||
e.dataTransfer.setData("text/plain", path);
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
}
|
||||
|
||||
/** The attachment a drop carries, if it is one of ours. */
|
||||
export function droppedAttachment(dt: DataTransfer | null | undefined): Attachment | null {
|
||||
/** Start a drag carrying attachments as they are (an activity panel's files,
|
||||
* already absolute); plain text too, like `dragProjectPath`. */
|
||||
export function dragAttachments(e: DragEvent, items: Attachment[]) {
|
||||
e.dataTransfer.setData(ATTACH_MIME, JSON.stringify(items));
|
||||
e.dataTransfer.setData("text/plain", items.map((a) => a.path.replace(/^@/, "")).join(" "));
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
}
|
||||
|
||||
/** The attachments a drop carries, if they are ours: one, or a list. */
|
||||
export function droppedAttachments(dt: DataTransfer | null | undefined): Attachment[] {
|
||||
try {
|
||||
const v: unknown = JSON.parse(dt?.getData(ATTACH_MIME) || "null");
|
||||
const a = v as Attachment | null;
|
||||
return a && typeof a.path === "string" && typeof a.name === "string"
|
||||
? { path: a.path, name: a.name }
|
||||
: null;
|
||||
return (Array.isArray(v) ? v : [v])
|
||||
.filter(
|
||||
(a): a is Attachment =>
|
||||
!!a &&
|
||||
typeof (a as Attachment).path === "string" &&
|
||||
typeof (a as Attachment).name === "string"
|
||||
)
|
||||
.map((a) => ({ path: a.path, name: a.name }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export const SKILL_MIME = "application/x-agenthub-skill";
|
||||
// A skill's name as Claude Code types it: `name` or `plugin:name`.
|
||||
const SKILL_RE = /^[\w.-]+(?::[\w.-]+)?$/;
|
||||
|
||||
/** Start a drag carrying a skill; dropped on a prompt box it becomes `/name`. */
|
||||
export function dragSkill(e: DragEvent, name: string) {
|
||||
e.dataTransfer.setData(SKILL_MIME, name);
|
||||
e.dataTransfer.setData("text/plain", `/${name}`);
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
}
|
||||
|
||||
/** The skill a drop carries, if it is one of ours. */
|
||||
export function droppedSkill(dt: DataTransfer | null | undefined): string | null {
|
||||
try {
|
||||
const name = dt?.getData(SKILL_MIME) ?? "";
|
||||
return SKILL_RE.test(name) ? name : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -41,6 +41,13 @@ export const SETTINGS_INDEX: {
|
||||
to: "#language",
|
||||
keywords: ["english", "tiếng việt", "vietnamese", "ngôn ngữ"],
|
||||
},
|
||||
{
|
||||
section: "appearance",
|
||||
titleKey: "workspaceTabs.title",
|
||||
descKey: "workspaceTabs.description",
|
||||
keywords: ["tab", "workspace", "session", "combo", "split", "không gian làm việc"],
|
||||
to: "#workspace-tabs",
|
||||
},
|
||||
{
|
||||
section: "appearance",
|
||||
titleKey: "terminalFont.title",
|
||||
|
||||
@@ -1920,7 +1920,9 @@ export interface WSMessage {
|
||||
/** A lane's stack finished coming up (or failed to), with fresh facts. */
|
||||
| "lane_runtime"
|
||||
/** A lane's profile hook exited, with its status code. */
|
||||
| "lane_hook_result";
|
||||
| "lane_hook_result"
|
||||
/** A pane ended; refetch GET /api/run/interrupted (no payload). */
|
||||
| "runs_interrupted";
|
||||
/** The message body, whose concrete shape is selected by `type` above. */
|
||||
data:
|
||||
| Session
|
||||
|
||||
@@ -35,6 +35,7 @@ export const PREF_KEYS = [
|
||||
"agenthub.defaultTool",
|
||||
"agenthub.permissionMode",
|
||||
"agenthub.tour",
|
||||
"agenthub.workspace.allTabs",
|
||||
] as const;
|
||||
const SYNCED = new Set<string>(PREF_KEYS);
|
||||
const PUSH_DELAY_MS = 400;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* @file workspaceTabs — Settings → Appearance → "Tabs from every workspace": a
|
||||
* viewer preference in `localStorage` (`agenthub.workspace.allTabs`, synced to
|
||||
* the account through `lib/userPrefs.ts`). On, the session view's tab bar
|
||||
* holds every workspace's live sessions and a combo may mix them; off (the
|
||||
* default), it shows only the selected workspace's. Read when the Workspace
|
||||
* page mounts.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
export const ALL_TABS_KEY = "agenthub.workspace.allTabs";
|
||||
|
||||
export function readAllTabs(): boolean {
|
||||
try {
|
||||
return window.localStorage.getItem(ALL_TABS_KEY) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeAllTabs(on: boolean) {
|
||||
try {
|
||||
if (on) window.localStorage.setItem(ALL_TABS_KEY, "1");
|
||||
else window.localStorage.removeItem(ALL_TABS_KEY);
|
||||
} catch {
|
||||
/* storage blocked — the default */
|
||||
}
|
||||
}
|
||||
+151
-35
@@ -19,7 +19,8 @@
|
||||
* buttons, and resizes by dragging (or arrow keys on) its splitter; a file in
|
||||
* a chat's "Edited N files" card opens in it (`OPEN_IN_CHANGES_EVENT`); an unsaved edit in it is asked about before the panel
|
||||
* closes, switches tab, or moves to another workspace or session (it stays on
|
||||
* the old one until saved or discarded). Errors are toasts.
|
||||
* the old one until saved or discarded). Its bottom fold, Workspace context,
|
||||
* is the home's context column compact (`WorkspaceContext`). Errors are toasts.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -95,28 +96,35 @@ import {
|
||||
} from "../components/run/PaneGrid";
|
||||
import {
|
||||
type Edge,
|
||||
type GroupsScope,
|
||||
type PaneGroups,
|
||||
type PaneLayout,
|
||||
MAX_PANES,
|
||||
MAX_SLOTS,
|
||||
activeLayout,
|
||||
closeInLayout,
|
||||
groupOf,
|
||||
layoutRects,
|
||||
leaves,
|
||||
moveInLayout,
|
||||
openInGroups,
|
||||
pane,
|
||||
paneKey,
|
||||
pruneGroups,
|
||||
removeSlot,
|
||||
slotOfKey,
|
||||
updateActive,
|
||||
} from "../lib/paneLayout";
|
||||
import { readAllTabs } from "../lib/workspaceTabs";
|
||||
import { useAiTools } from "../hooks/useAiTools";
|
||||
import { freeSlot, startInFreeTab } from "../components/lanes/startSession";
|
||||
import { freeSlot, laneOfRun, startInFreeTab } from "../components/lanes/startSession";
|
||||
import { sessionWorktreeName } from "../components/lanes/WorkspaceSessionList";
|
||||
import { LIVE_RUNS_EVENT } from "../components/WorkspaceTree";
|
||||
import PipelineMap from "../components/lanes/PipelineMap";
|
||||
import LaneCard, { since } from "../components/lanes/LaneCard";
|
||||
import { AddLaneModal } from "../components/lanes/AddLaneModal";
|
||||
import { LaneChangesPanel } from "../components/lanes/LaneChangesPanel";
|
||||
import { WorkspaceContext } from "../components/lanes/WorkspaceContext";
|
||||
import { OPEN_IN_CHANGES_EVENT, type OpenInChanges } from "../lib/openInChanges";
|
||||
import { LaneGitRailCounts } from "../components/lanes/LaneGitHeader";
|
||||
import { WorkspaceHome } from "../components/lanes/WorkspaceHome";
|
||||
@@ -130,6 +138,8 @@ const CHANGES_SIZE_KEY = "agenthub.workspace.changesSize";
|
||||
/** localStorage key for the last width the user dragged the panel to — what
|
||||
* it reopens at after the step control closes it. */
|
||||
const CHANGES_DRAGGED_KEY = "agenthub.workspace.changesDragged";
|
||||
/** localStorage key for whether the Changes panel's Workspace context fold is open. */
|
||||
const CONTEXT_OPEN_KEY = "agenthub.workspace.contextOpen";
|
||||
/** localStorage key for the last workspace shown, restored on a bare /workspace. */
|
||||
const SELECTED_KEY = "agenthub.workspace.selected";
|
||||
|
||||
@@ -178,7 +188,11 @@ function slotOf(runId: string | null | undefined): number {
|
||||
* into the tab on screen, at the 2×2 grid's next place. Every live run
|
||||
* stays MOUNTED, hidden with CSS while its tab is not shown, so neither
|
||||
* switching tabs nor reshaping the panes ever drops a terminal. A pane whose
|
||||
* run ends closes; the last tab gone leaves for the home. */
|
||||
* run ends closes; the last tab gone leaves for the home. With `allTabs`
|
||||
* (Settings → Appearance) the tabs hold EVERY workspace's live runs, stored
|
||||
* once (`all`): a leaf is a `paneKey` (lane × 10 + slot), each pane is bound
|
||||
* to its own run's lane, and focusing one selects its workspace (the URL's
|
||||
* `?lane=` follows `?run=`). */
|
||||
function ConsoleArea({
|
||||
lanes,
|
||||
selectedLaneId,
|
||||
@@ -189,6 +203,7 @@ function ConsoleArea({
|
||||
onNewSession,
|
||||
onOpenRun,
|
||||
onRunsChanged,
|
||||
allTabs = false,
|
||||
}: {
|
||||
lanes: Lane[];
|
||||
selectedLaneId: number | null;
|
||||
@@ -209,18 +224,33 @@ function ConsoleArea({
|
||||
) => void;
|
||||
/** A tab was opened or closed: re-read the run list now. */
|
||||
onRunsChanged: () => void;
|
||||
/** Tabs from every workspace, not only the selected one's. */
|
||||
allTabs?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const aiTools = useAiTools();
|
||||
const lane = lanes.find((l) => l.id === selectedLaneId) ?? null;
|
||||
// Every workspace's runs, each `slot` rewritten to its `paneKey`, so the tab
|
||||
// bar and the grid — which key everything by slot — need no other change.
|
||||
const tabs = useMemo(
|
||||
() =>
|
||||
(activeRuns?.items ?? [])
|
||||
.filter((r) => r.laneId === selectedLaneId && r.status === "running")
|
||||
.filter((r) =>
|
||||
allTabs
|
||||
? r.laneId !== null && r.status === "running"
|
||||
: r.laneId === selectedLaneId && r.status === "running"
|
||||
)
|
||||
.map((r) => (allTabs ? { ...r, slot: paneKey(r.laneId as number, r.slot ?? 0) } : r))
|
||||
.sort((a, b) => (a.slot ?? 0) - (b.slot ?? 0)),
|
||||
[activeRuns, selectedLaneId]
|
||||
[activeRuns, selectedLaneId, allTabs]
|
||||
);
|
||||
const [groups, setGroups] = usePaneGroups(selectedLaneId);
|
||||
// A run id's leaf: its slot, or with `allTabs` its `paneKey`.
|
||||
const keyOf = (runId: string) => {
|
||||
const l = laneOfRun(runId);
|
||||
return allTabs && l !== null ? paneKey(l, slotOf(runId)) : slotOf(runId);
|
||||
};
|
||||
const scope: GroupsScope | null = allTabs ? "all" : selectedLaneId;
|
||||
const [groups, setGroups] = usePaneGroups(scope);
|
||||
// The latest tabs, updated the moment a change is made: two changes in one
|
||||
// commit (an open request and a prune) must build on each other.
|
||||
const groupsRef = useRef(groups);
|
||||
@@ -234,7 +264,7 @@ function ConsoleArea({
|
||||
// remembered layout loses its dead panes on arrival; after it a shown pane
|
||||
// closes on its terminal's exit, not on the polled list — one bad read (the
|
||||
// server answers an empty list on any tmux error) must not wipe a split.
|
||||
const listed = useRef<number | null>(null);
|
||||
const listed = useRef<GroupsScope | null>(null);
|
||||
// Runs seen ending here (`id@startedAt`): a list not yet refreshed must not
|
||||
// send the last pane to one of them.
|
||||
const exited = useRef(new Set<string>());
|
||||
@@ -245,20 +275,34 @@ function ConsoleArea({
|
||||
const [tabSessions, setTabSessions] = useState<Map<string, Session>>(new Map());
|
||||
const [sessionsVersion, setSessionsVersion] = useState(0);
|
||||
const tabSessionIds = tabs.map((r) => r.sessionId ?? "").join(",");
|
||||
// The workspaces the tabs belong to: the selected one, or with `allTabs`
|
||||
// every one holding a tab.
|
||||
const tabLaneIds = allTabs
|
||||
? [...new Set(tabs.map((r) => r.laneId as number))].join(",")
|
||||
: String(selectedLaneId ?? "");
|
||||
useEffect(() => {
|
||||
if (selectedLaneId === null || !tabSessionIds.replace(/,/g, "")) return;
|
||||
if (!tabLaneIds || !tabSessionIds.replace(/,/g, "")) return;
|
||||
let live = true;
|
||||
api.sessions
|
||||
.list({ lane: selectedLaneId, limit: 50 })
|
||||
.then((r) => live && setTabSessions(new Map(r.sessions.map((x) => [x.id, x]))))
|
||||
Promise.all(
|
||||
tabLaneIds.split(",").map((id) => api.sessions.list({ lane: Number(id), limit: 50 }))
|
||||
)
|
||||
.then(
|
||||
(rs) => live && setTabSessions(new Map(rs.flatMap((r) => r.sessions.map((x) => [x.id, x]))))
|
||||
)
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [selectedLaneId, tabSessionIds, lane?.status, lane?.needs_action, sessionsVersion]);
|
||||
}, [tabLaneIds, tabSessionIds, lane?.status, lane?.needs_action, sessionsVersion]);
|
||||
const laneName = (r: RunHandle) => {
|
||||
const l = lanes.find((x) => x.id === r.laneId);
|
||||
return l ? l.title || l.cwd.split("/").filter(Boolean).pop() || l.cwd : "";
|
||||
};
|
||||
const sessionOf = (r: RunHandle) => (r.sessionId ? tabSessions.get(r.sessionId) : undefined);
|
||||
const label = (r: RunHandle) =>
|
||||
runName(r, sessionOf(r), aiTools.label(r.tool), t("tabs.terminal"));
|
||||
const label = (r: RunHandle) => {
|
||||
const name = runName(r, sessionOf(r), aiTools.label(r.tool), t("tabs.terminal"));
|
||||
return allTabs ? `${laneName(r)} · ${name}` : name;
|
||||
};
|
||||
const stateOf = (r: RunHandle) => (r.tool === "shell" ? null : runState(sessionOf(r)));
|
||||
const runOf = (slot: number | null) => tabs.find((r) => (r.slot ?? 0) === slot);
|
||||
const runKey = (r: RunHandle) => `${r.id}@${r.startedAt}`;
|
||||
@@ -281,18 +325,19 @@ function ConsoleArea({
|
||||
followFocus(next);
|
||||
};
|
||||
|
||||
// Another workspace: nothing pending or exited carries over.
|
||||
// Another workspace's tabs: nothing pending or exited carries over.
|
||||
useEffect(() => {
|
||||
pending.current.clear();
|
||||
exited.current.clear();
|
||||
}, [selectedLaneId]);
|
||||
}, [scope]);
|
||||
|
||||
// An open request (a new nonce): focus, replace or split. Re-run when the
|
||||
// lane arrives on a cold load; only this lane's own tabs.
|
||||
useEffect(() => {
|
||||
if (!attachRequest || selectedLaneId === null) return;
|
||||
if (!new RegExp(`^agenthub-lane-${selectedLaneId}(-[1-9])?$`).test(attachRequest.runId)) return;
|
||||
const slot = slotOf(attachRequest.runId);
|
||||
const own = allTabs ? "\\d+" : String(selectedLaneId);
|
||||
if (!new RegExp(`^agenthub-lane-${own}(-[1-9])?$`).test(attachRequest.runId)) return;
|
||||
const slot = keyOf(attachRequest.runId);
|
||||
// The URL catching up with a focus change: by now that pane may be closed
|
||||
// or swapped, and re-opening it would undo what the user just did. Back/
|
||||
// Forward onto such an entry puts the real focus back in the URL instead.
|
||||
@@ -325,8 +370,8 @@ function ConsoleArea({
|
||||
else if (list !== activeRuns) expired.add(s);
|
||||
if (live.has(s) || list !== activeRuns) pending.current.delete(s);
|
||||
}
|
||||
const first = listed.current !== selectedLaneId;
|
||||
listed.current = selectedLaneId;
|
||||
const first = listed.current !== scope;
|
||||
listed.current = scope;
|
||||
const tabbed = groupsRef.current.groups.flatMap((l) => leaves(l.root));
|
||||
const keep = first
|
||||
? new Set(pending.current.keys())
|
||||
@@ -383,14 +428,16 @@ function ConsoleArea({
|
||||
const free = lane ? freeSlot(lane.id, tabs) : null;
|
||||
const splitTools = lane ? newTabTools(lane, aiTools) : [];
|
||||
const newSplit = async (target: number, edge: Edge, tool?: string) => {
|
||||
if (!lane || free === null) return;
|
||||
const laneId = lane.id;
|
||||
// With `allTabs`, the split starts in the target pane's own workspace.
|
||||
const into = allTabs ? lanes.find((l) => l.id === Math.floor(target / MAX_SLOTS)) : lane;
|
||||
if (!into || freeSlot(into.id, tabs) === null) return;
|
||||
const laneId = into.id;
|
||||
try {
|
||||
const body = tool ? newTabBody(tool, lane, aiTools) : { shell: true };
|
||||
const body = tool ? newTabBody(tool, into, aiTools) : { shell: true };
|
||||
// The server picks the slot: the list `free` came from may be stale.
|
||||
const slot = slotOf(await startInFreeTab(laneId, body));
|
||||
const slot = keyOf(await startInFreeTab(laneId, body));
|
||||
onRunsChanged();
|
||||
if (laneRef.current !== laneId) return;
|
||||
if (!allTabs && laneRef.current !== laneId) return;
|
||||
pending.current.set(slot, activeRuns);
|
||||
change((l) => moveInLayout(l, slot, target, edge));
|
||||
} catch (err) {
|
||||
@@ -398,10 +445,10 @@ function ConsoleArea({
|
||||
}
|
||||
};
|
||||
|
||||
const requested = attachRequest ? [slotOf(attachRequest.runId)] : [];
|
||||
const requested = attachRequest ? [keyOf(attachRequest.runId)] : [];
|
||||
// No layout yet (a first visit, a request not yet applied, or no run at
|
||||
// all): one pane, so the console always has its single render site.
|
||||
const fallback = requested[0] ?? 0;
|
||||
const fallback = requested[0] ?? (allTabs ? paneKey(selectedLaneId ?? 0, 0) : 0);
|
||||
const view: PaneLayout = layout.root ? layout : { root: pane(fallback), focus: fallback };
|
||||
const viewGroups: PaneGroups = groups.groups.length ? groups : { groups: [view], active: 0 };
|
||||
// A change to the tab on screen (`PaneGrid`, the split menus). Tabs never
|
||||
@@ -409,6 +456,13 @@ function ConsoleArea({
|
||||
const change: LayoutUpdate = (fn) =>
|
||||
changeGroups((g) => updateActive(g.groups.length ? g : viewGroups, fn));
|
||||
const shown = leaves(view.root);
|
||||
// A pane spanning the grid's full width or height has room for its chat's
|
||||
// right panel to start open; a quarter-sized one starts it collapsed.
|
||||
const spanning = new Set(
|
||||
layoutRects(view.root)
|
||||
.panes.filter((r) => r.w === 1 || r.h === 1)
|
||||
.map((r) => r.slot)
|
||||
);
|
||||
const mounted = [...new Set([...shown, ...tabs.map((r) => r.slot ?? 0)])].sort((a, b) => a - b);
|
||||
const focusSlot = view.focus;
|
||||
|
||||
@@ -462,8 +516,9 @@ function ConsoleArea({
|
||||
onOpenRun(runId, { split, replace: true });
|
||||
}}
|
||||
sessions={tabSessions}
|
||||
workspaceOf={allTabs ? laneName : undefined}
|
||||
onRenamed={() => setSessionsVersion((v) => v + 1)}
|
||||
onClosed={(runId) => ended(slotOf(runId))}
|
||||
onClosed={(runId) => ended(keyOf(runId))}
|
||||
onHome={onNewSession}
|
||||
// While one pane is shown, its split menus (a split pane has its own).
|
||||
actions={
|
||||
@@ -500,8 +555,9 @@ function ConsoleArea({
|
||||
renderPane={(slot) => (
|
||||
<LaneConsolePane
|
||||
lanes={lanes}
|
||||
laneId={selectedLaneId}
|
||||
slot={slot}
|
||||
// With `allTabs` a pane is bound to its own run's workspace.
|
||||
laneId={allTabs ? Math.floor(slot / MAX_SLOTS) : selectedLaneId}
|
||||
slot={slotOfKey(slot)}
|
||||
binaryStatus={binaryStatus}
|
||||
activeRuns={activeRuns}
|
||||
onHasActiveRunChange={slot === focusSlot ? onHasActiveRunChange : undefined}
|
||||
@@ -509,9 +565,9 @@ function ConsoleArea({
|
||||
onRunExit={() => ended(slot)}
|
||||
// Only the focused pane takes the keyboard when it appears.
|
||||
autoFocus={slot === focusSlot}
|
||||
solo={shown.length === 1}
|
||||
panelOpen={spanning.has(slot)}
|
||||
attachRequest={
|
||||
attachRequest && slotOf(attachRequest.runId) === slot ? attachRequest : null
|
||||
attachRequest && keyOf(attachRequest.runId) === slot ? attachRequest : null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
@@ -621,6 +677,9 @@ export function Workspace() {
|
||||
> | null>(null);
|
||||
const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]);
|
||||
const [paneHasActiveRun, setPaneHasActiveRun] = useState(false);
|
||||
// Settings → Appearance: tabs from every workspace. Read on mount — the
|
||||
// switch lives on another page.
|
||||
const [allTabs] = useState(readAllTabs);
|
||||
|
||||
// Whether the Changes panel is open. A viewer preference, so it lives in
|
||||
// localStorage and never reaches the server — same rule as the lane strip's
|
||||
@@ -639,6 +698,22 @@ export function Workspace() {
|
||||
/* private mode / disabled storage: the panel just forgets between visits */
|
||||
}
|
||||
}, [changesOpen]);
|
||||
// The Changes panel's bottom fold (instructions, files, folders): folded by
|
||||
// default so the git sections keep the height.
|
||||
const [contextOpen, setContextOpen] = useState(() => {
|
||||
try {
|
||||
return window.localStorage.getItem(CONTEXT_OPEN_KEY) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem(CONTEXT_OPEN_KEY, contextOpen ? "1" : "0");
|
||||
} catch {
|
||||
/* disabled storage: the fold just forgets between visits */
|
||||
}
|
||||
}, [contextOpen]);
|
||||
// A preset name, or the fraction a splitter drag left (stored as a number).
|
||||
const [changesSize, setChangesSize] = useState<ChangesSize>(() => {
|
||||
try {
|
||||
@@ -764,8 +839,13 @@ export function Workspace() {
|
||||
// Right-click on a section: close extra folders — which REMOVES them from the
|
||||
// workspace (the folder stays on disk). The workspace folder never closes.
|
||||
const sourceCtx = useContextMenu<string>();
|
||||
// Bumped after a close here, so the home's Folder list re-reads.
|
||||
// Bumped after a close here or an edit in either context list (the home's,
|
||||
// the Changes panel's fold), so every one on screen re-reads.
|
||||
const [sourcesVersion, setSourcesVersion] = useState(0);
|
||||
const sourcesChanged = (laneId: number, view: LaneSourcesView) => {
|
||||
takeGitFolders(laneId, view);
|
||||
setSourcesVersion((n) => n + 1);
|
||||
};
|
||||
const closeSources = async (laneId: number, keys: string[]) => {
|
||||
if (keys.some((k) => dirtyBy[k]) && !(await confirmDiscard())) return;
|
||||
try {
|
||||
@@ -1065,7 +1145,7 @@ export function Workspace() {
|
||||
<WorkspaceHome
|
||||
lane={currentLane}
|
||||
onRenamed={() => void refreshLanes()}
|
||||
onSourcesChange={(v) => takeGitFolders(currentLane.id, v)}
|
||||
onSourcesChange={(v) => sourcesChanged(currentLane.id, v)}
|
||||
sourcesVersion={sourcesVersion}
|
||||
onMenuAction={(action) => {
|
||||
if (action === "new-here") {
|
||||
@@ -1242,10 +1322,12 @@ export function Workspace() {
|
||||
? () => setSearchParams({ lane: String(currentLane.id) })
|
||||
: undefined
|
||||
}
|
||||
allTabs={allTabs}
|
||||
onOpenRun={(runId, opts) =>
|
||||
currentLane &&
|
||||
setSearchParams(
|
||||
{ lane: String(currentLane.id), run: runId },
|
||||
// A run names its own workspace (another one's with `allTabs`).
|
||||
{ lane: String(laneOfRun(runId) ?? currentLane.id), run: runId },
|
||||
{
|
||||
// Following the focus, or a tab click, is not a new
|
||||
// history entry.
|
||||
@@ -1397,6 +1479,9 @@ export function Workspace() {
|
||||
<section
|
||||
key={key}
|
||||
data-testid="panel-git-source"
|
||||
// A row dragged from an extra folder lies outside
|
||||
// the pane's folder: `dragProjectPath` makes it absolute.
|
||||
data-drag-root={source ? folders[i - 1]!.path : undefined}
|
||||
className={`flex flex-col border-b border-border-light ${
|
||||
folded ? "shrink-0" : "min-h-0 flex-1"
|
||||
}`}
|
||||
@@ -1462,6 +1547,37 @@ export function Workspace() {
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
{/* The workspace's context — the home's right column, compact. */}
|
||||
<section
|
||||
data-testid="panel-workspace-context"
|
||||
className={`-mx-3 flex shrink-0 flex-col border-t border-border px-3 ${
|
||||
contextOpen ? "max-h-[45%]" : ""
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={contextOpen}
|
||||
onClick={() => setContextOpen((v) => !v)}
|
||||
className="flex shrink-0 items-center gap-1 py-1.5 text-left text-xs font-medium text-fg-secondary hover:text-fg-primary"
|
||||
>
|
||||
{contextOpen ? (
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
|
||||
)}
|
||||
{tLanes("home.contextPanel")}
|
||||
</button>
|
||||
{contextOpen && (
|
||||
<div className="min-h-0 overflow-y-auto pb-2">
|
||||
<WorkspaceContext
|
||||
lane={currentLane}
|
||||
compact
|
||||
onSourcesChange={(v) => sourcesChanged(currentLane.id, v)}
|
||||
sourcesVersion={sourcesVersion}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
) : (
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* full screen, back to the last dragged width) and Collapse; the rail reopens
|
||||
* it at the last dragged width, else 25/75. The width survives a reload.
|
||||
* All of it lives in localStorage and reaches no server — the same rule as the
|
||||
* lane strip's order.
|
||||
* lane strip's order. Its bottom fold is the workspace's context, compact.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -99,6 +99,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
removeSource: r({
|
||||
sources: [{ id: 8, kind: "file", path: "/d/notes.md", name: "notes.md", missing: false }],
|
||||
}),
|
||||
memory: r({ files: [] }),
|
||||
gitTree: r({ entries: [] }),
|
||||
// A file opened in the panel on a chat's request.
|
||||
changes: r({ available: false }),
|
||||
@@ -284,6 +285,22 @@ describe("Workspace — the Changes panel's width", () => {
|
||||
expect(screen.queryByTestId("panel-git-source")).toBeNull();
|
||||
});
|
||||
|
||||
it("folds the workspace context at the bottom, and a folder removed there leaves the stack", async () => {
|
||||
await openPanel();
|
||||
const fold = screen.getByTestId("panel-workspace-context");
|
||||
const toggle = fold.querySelector("button")!;
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
expect(screen.queryByTestId("workspace-context")).toBeNull();
|
||||
fireEvent.click(toggle);
|
||||
await settle();
|
||||
expect(window.localStorage.getItem("agenthub.workspace.contextOpen")).toBe("1");
|
||||
expect(screen.getByText("notes.md")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remove shop" }));
|
||||
await settle();
|
||||
expect(api.lanes.removeSource).toHaveBeenCalledWith(1, 7);
|
||||
expect(screen.queryByTestId("panel-git-source")).toBeNull();
|
||||
});
|
||||
|
||||
it("is one plain panel for a workspace with no extra folders", async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/workspace?lane=2"]}>
|
||||
|
||||
@@ -6,8 +6,17 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, act, cleanup, screen, waitFor, within } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, onTestFinished } from "vitest";
|
||||
import {
|
||||
render,
|
||||
act,
|
||||
cleanup,
|
||||
createEvent,
|
||||
fireEvent,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes, useSearchParams } from "react-router-dom";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
@@ -61,6 +70,10 @@ const SLOTS = vi.hoisted(() => [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
|
||||
vi.mock("../../components/lanes/startSession", () => ({
|
||||
paneRunId: (id: number, slot: number) =>
|
||||
slot ? `agenthub-lane-${id}-${slot}` : `agenthub-lane-${id}`,
|
||||
laneOfRun: (runId: string) => {
|
||||
const m = /^agenthub-lane-(\d+)(?:-[1-9])?$/.exec(runId);
|
||||
return m ? Number(m[1]) : null;
|
||||
},
|
||||
freeSlot: (laneId: number, runs: Array<{ laneId: number; slot?: number; status: string }>) => {
|
||||
const busy = new Set(
|
||||
runs.filter((r) => r.laneId === laneId && r.status === "running").map((r) => r.slot ?? 0)
|
||||
@@ -287,6 +300,10 @@ vi.mock("../../lib/eventBus", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../components/run/PaneChat", () => ({
|
||||
PaneChat: () => <div data-testid="pane-chat" />,
|
||||
}));
|
||||
|
||||
vi.mock("../../components/run/TerminalView", () => ({
|
||||
// `exit-<runId>` stands in for the server's exit frame.
|
||||
TerminalView: ({
|
||||
@@ -310,6 +327,7 @@ vi.mock("../../components/run/TerminalView", () => ({
|
||||
|
||||
import { Workspace } from "../Workspace";
|
||||
import { api } from "../../lib/api";
|
||||
import { setSessionChat } from "../../lib/chatUi";
|
||||
|
||||
class ObserverStub {
|
||||
observe() {}
|
||||
@@ -630,6 +648,30 @@ describe("console tabs", () => {
|
||||
expect(screen.getByTestId("console-tab-new")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("with tabs from every workspace on, shows another workspace's session and selects it on click", async () => {
|
||||
window.localStorage.setItem("agenthub.workspace.allTabs", "1");
|
||||
withRuns([live(0), { ...live(0), id: "agenthub-lane-2-1", laneId: 2, slot: 1 }]);
|
||||
function LaneParam() {
|
||||
const [params] = useSearchParams();
|
||||
return <span data-testid="lane-param">{params.get("lane")}</span>;
|
||||
}
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/workspace?lane=1&run=agenthub-lane-1"]}>
|
||||
<Workspace />
|
||||
<LaneParam />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await settle();
|
||||
await settle();
|
||||
// Leaves are `paneKey`s: lane × 10 + slot.
|
||||
expect(screen.getByTestId("console-tab-10")).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByTestId("console-tab-21")).toHaveTextContent("Lane B");
|
||||
await userEvent.setup().click(screen.getByTestId("console-tab-21"));
|
||||
await waitFor(() => expect(screen.getByTestId("lane-param")).toHaveTextContent("2"));
|
||||
expect(screen.getByTestId("console-tab-21")).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByTestId("console-tab-10")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closing the active tab stops its session and opens the next one", async () => {
|
||||
withRuns([live(0), live(1)]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
@@ -706,6 +748,37 @@ describe("split panes", () => {
|
||||
expect(stored().groups[0].root).toMatchObject({ type: "split", dir: "row" });
|
||||
});
|
||||
|
||||
it("keeps each session's chat view when two tabs join into one combo", async () => {
|
||||
setSessionChat("sess-0", true);
|
||||
setSessionChat("sess-1", true);
|
||||
const items = [
|
||||
{ ...live(0), sessionId: "sess-0" },
|
||||
{ ...live(1), sessionId: "sess-1" },
|
||||
];
|
||||
withRuns(items as never);
|
||||
// As the server does: GET /api/run/:id is the list's own handle.
|
||||
const get = vi.mocked(api.run.get).getMockImplementation()!;
|
||||
onTestFinished(() => void vi.mocked(api.run.get).mockImplementation(get));
|
||||
vi.mocked(api.run.get).mockImplementation(
|
||||
async (id) => items.find((r) => r.id === id) as never
|
||||
);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
// Tab b dragged onto the middle of tab a (jsdom: every rect is 0 wide).
|
||||
const drop = createEvent.drop(screen.getByTestId("console-tab-0"), {
|
||||
dataTransfer: {
|
||||
types: ["application/x-agenthub-tab"],
|
||||
getData: (t: string) => (t === "application/x-agenthub-tab" ? "1" : ""),
|
||||
},
|
||||
});
|
||||
Object.defineProperty(drop, "clientX", { value: 0 });
|
||||
fireEvent(screen.getByTestId("console-tab-0"), drop);
|
||||
await settle();
|
||||
expect(shownPanes()).toEqual(["pane-0", "pane-1"]);
|
||||
await settle();
|
||||
for (const s of [0, 1])
|
||||
expect(within(screen.getByTestId(`pane-${s}`)).getByTestId("pane-chat")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("a plain click shows another tab, and a combo tab comes back as it was", async () => {
|
||||
withRuns([live(0), live(1), live(2)]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
|
||||
@@ -7174,7 +7174,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<span
|
||||
class="text-status-warning/90"
|
||||
>
|
||||
UI build v2.6.2 (rebuild client to match)
|
||||
UI build v2.7.0 (rebuild client to match)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -116,6 +116,10 @@ export default {
|
||||
fontFamily: {
|
||||
sans: ["Inter", "-apple-system", "BlinkMacSystemFont", "Segoe UI", "sans-serif"],
|
||||
mono: ["JetBrains Mono", "Fira Code", "Consolas", "monospace"],
|
||||
// Tailwind's stock stack minus Georgia: Windows picks Georgia, which has
|
||||
// no precomposed Vietnamese glyphs, so Chrome splits "ế" into "ê" + a
|
||||
// tone mark it cannot place and the chat reads "Chuyê ́n".
|
||||
serif: ["ui-serif", "Cambria", "Times New Roman", "Times", "serif"],
|
||||
},
|
||||
animation: {
|
||||
"pulse-slow": "pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite",
|
||||
|
||||
Reference in New Issue
Block a user