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:
+151
-35
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* full screen, back to the last dragged width) and Collapse; the rail reopens
|
||||
* it at the last dragged width, else 25/75. The width survives a reload.
|
||||
* All of it lives in localStorage and reaches no server — the same rule as the
|
||||
* lane strip's order.
|
||||
* lane strip's order. Its bottom fold is the workspace's context, compact.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -99,6 +99,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
removeSource: r({
|
||||
sources: [{ id: 8, kind: "file", path: "/d/notes.md", name: "notes.md", missing: false }],
|
||||
}),
|
||||
memory: r({ files: [] }),
|
||||
gitTree: r({ entries: [] }),
|
||||
// A file opened in the panel on a chat's request.
|
||||
changes: r({ available: false }),
|
||||
@@ -284,6 +285,22 @@ describe("Workspace — the Changes panel's width", () => {
|
||||
expect(screen.queryByTestId("panel-git-source")).toBeNull();
|
||||
});
|
||||
|
||||
it("folds the workspace context at the bottom, and a folder removed there leaves the stack", async () => {
|
||||
await openPanel();
|
||||
const fold = screen.getByTestId("panel-workspace-context");
|
||||
const toggle = fold.querySelector("button")!;
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
expect(screen.queryByTestId("workspace-context")).toBeNull();
|
||||
fireEvent.click(toggle);
|
||||
await settle();
|
||||
expect(window.localStorage.getItem("agenthub.workspace.contextOpen")).toBe("1");
|
||||
expect(screen.getByText("notes.md")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remove shop" }));
|
||||
await settle();
|
||||
expect(api.lanes.removeSource).toHaveBeenCalledWith(1, 7);
|
||||
expect(screen.queryByTestId("panel-git-source")).toBeNull();
|
||||
});
|
||||
|
||||
it("is one plain panel for a workspace with no extra folders", async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/workspace?lane=2"]}>
|
||||
|
||||
@@ -6,8 +6,17 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, act, cleanup, screen, waitFor, within } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, onTestFinished } from "vitest";
|
||||
import {
|
||||
render,
|
||||
act,
|
||||
cleanup,
|
||||
createEvent,
|
||||
fireEvent,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes, useSearchParams } from "react-router-dom";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
@@ -61,6 +70,10 @@ const SLOTS = vi.hoisted(() => [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
|
||||
vi.mock("../../components/lanes/startSession", () => ({
|
||||
paneRunId: (id: number, slot: number) =>
|
||||
slot ? `agenthub-lane-${id}-${slot}` : `agenthub-lane-${id}`,
|
||||
laneOfRun: (runId: string) => {
|
||||
const m = /^agenthub-lane-(\d+)(?:-[1-9])?$/.exec(runId);
|
||||
return m ? Number(m[1]) : null;
|
||||
},
|
||||
freeSlot: (laneId: number, runs: Array<{ laneId: number; slot?: number; status: string }>) => {
|
||||
const busy = new Set(
|
||||
runs.filter((r) => r.laneId === laneId && r.status === "running").map((r) => r.slot ?? 0)
|
||||
@@ -287,6 +300,10 @@ vi.mock("../../lib/eventBus", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../components/run/PaneChat", () => ({
|
||||
PaneChat: () => <div data-testid="pane-chat" />,
|
||||
}));
|
||||
|
||||
vi.mock("../../components/run/TerminalView", () => ({
|
||||
// `exit-<runId>` stands in for the server's exit frame.
|
||||
TerminalView: ({
|
||||
@@ -310,6 +327,7 @@ vi.mock("../../components/run/TerminalView", () => ({
|
||||
|
||||
import { Workspace } from "../Workspace";
|
||||
import { api } from "../../lib/api";
|
||||
import { setSessionChat } from "../../lib/chatUi";
|
||||
|
||||
class ObserverStub {
|
||||
observe() {}
|
||||
@@ -630,6 +648,30 @@ describe("console tabs", () => {
|
||||
expect(screen.getByTestId("console-tab-new")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("with tabs from every workspace on, shows another workspace's session and selects it on click", async () => {
|
||||
window.localStorage.setItem("agenthub.workspace.allTabs", "1");
|
||||
withRuns([live(0), { ...live(0), id: "agenthub-lane-2-1", laneId: 2, slot: 1 }]);
|
||||
function LaneParam() {
|
||||
const [params] = useSearchParams();
|
||||
return <span data-testid="lane-param">{params.get("lane")}</span>;
|
||||
}
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/workspace?lane=1&run=agenthub-lane-1"]}>
|
||||
<Workspace />
|
||||
<LaneParam />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await settle();
|
||||
await settle();
|
||||
// Leaves are `paneKey`s: lane × 10 + slot.
|
||||
expect(screen.getByTestId("console-tab-10")).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByTestId("console-tab-21")).toHaveTextContent("Lane B");
|
||||
await userEvent.setup().click(screen.getByTestId("console-tab-21"));
|
||||
await waitFor(() => expect(screen.getByTestId("lane-param")).toHaveTextContent("2"));
|
||||
expect(screen.getByTestId("console-tab-21")).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByTestId("console-tab-10")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closing the active tab stops its session and opens the next one", async () => {
|
||||
withRuns([live(0), live(1)]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
@@ -706,6 +748,37 @@ describe("split panes", () => {
|
||||
expect(stored().groups[0].root).toMatchObject({ type: "split", dir: "row" });
|
||||
});
|
||||
|
||||
it("keeps each session's chat view when two tabs join into one combo", async () => {
|
||||
setSessionChat("sess-0", true);
|
||||
setSessionChat("sess-1", true);
|
||||
const items = [
|
||||
{ ...live(0), sessionId: "sess-0" },
|
||||
{ ...live(1), sessionId: "sess-1" },
|
||||
];
|
||||
withRuns(items as never);
|
||||
// As the server does: GET /api/run/:id is the list's own handle.
|
||||
const get = vi.mocked(api.run.get).getMockImplementation()!;
|
||||
onTestFinished(() => void vi.mocked(api.run.get).mockImplementation(get));
|
||||
vi.mocked(api.run.get).mockImplementation(
|
||||
async (id) => items.find((r) => r.id === id) as never
|
||||
);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
// Tab b dragged onto the middle of tab a (jsdom: every rect is 0 wide).
|
||||
const drop = createEvent.drop(screen.getByTestId("console-tab-0"), {
|
||||
dataTransfer: {
|
||||
types: ["application/x-agenthub-tab"],
|
||||
getData: (t: string) => (t === "application/x-agenthub-tab" ? "1" : ""),
|
||||
},
|
||||
});
|
||||
Object.defineProperty(drop, "clientX", { value: 0 });
|
||||
fireEvent(screen.getByTestId("console-tab-0"), drop);
|
||||
await settle();
|
||||
expect(shownPanes()).toEqual(["pane-0", "pane-1"]);
|
||||
await settle();
|
||||
for (const s of [0, 1])
|
||||
expect(within(screen.getByTestId(`pane-${s}`)).getByTestId("pane-chat")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("a plain click shows another tab, and a combo tab comes back as it was", async () => {
|
||||
withRuns([live(0), live(1), live(2)]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
|
||||
@@ -7174,7 +7174,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<span
|
||||
class="text-status-warning/90"
|
||||
>
|
||||
UI build v2.6.2 (rebuild client to match)
|
||||
UI build v2.7.0 (rebuild client to match)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user