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
+31
View File
@@ -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 {
+50
View File
@@ -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 */
}
}
+24 -11
View File
@@ -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)];
+49 -10
View File
@@ -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;
}
+7
View File
@@ -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",
+3 -1
View File
@@ -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
+1
View File
@@ -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;
+28
View File
@@ -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 */
}
}