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
+45 -8
View File
@@ -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 && (