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
+151 -35
View File
@@ -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>
) : (