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:
@@ -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 */
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user