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:
2026-10-10 15:10:13 +07:00
parent f0dc1445fc
commit f6f1bf4a76
61 changed files with 2621 additions and 731 deletions
+96 -43
View File
@@ -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>