fix(workspace): a new tab shows in its workspace; a row opens its tabs
A tab opened bare from + had no session row until its first hook, and the sidebar re-read its live panes only when a workspace's status or session moved, which a tab in slots 1-3 never does: the new tab showed nowhere. The sidebar now lists a live AI tab whose session has no row yet as "New session" straight from the run (its session id is known from the start) and follows the Workspace page's own run-list poll (LIVE_RUNS_EVENT). A sidebar workspace row now opens the workspace's live tabs on the pane it last focused, and its home only when it has none; a Home button at the left end of the tab bar reaches the home of a workspace whose tabs keep running.
This commit is contained in:
@@ -3,10 +3,12 @@
|
||||
* Desktop's project list: every workspace (lane) on one line in the stored drag
|
||||
* order (`laneOrderStorage.ts`), each expanding to ITS sessions
|
||||
* (`WorkspaceSessionList.tsx`) — no repo grouping, no worktree rows. Picking a
|
||||
* workspace opens `/workspace?lane=<id>`, the one place a workspace is
|
||||
* selected; `+` on a row opens it for a new session. Live status comes from
|
||||
* the shared lane list (`useLanes`), live panes from `GET /api/run`, re-read
|
||||
* whenever a lane's status or session moves. Owns its own Add-workspace and
|
||||
* workspace opens its tabs (`/workspace?lane=<id>&run=<runId>`, the pane it
|
||||
* last focused) when it has live ones, else its home (`?lane=<id>`, also what
|
||||
* `+` on a row opens for a new session). Live status comes from the shared
|
||||
* lane list (`useLanes`), live panes from `GET /api/run`, re-read whenever a
|
||||
* lane's status or session moves and handed over by the Workspace page's own
|
||||
* poll (`LIVE_RUNS_EVENT`). Owns its own Add-workspace and
|
||||
* destructive modals so the row menu works from any page. Which rows are
|
||||
* expanded lives in `projectTreePrefs.ts` (`expanded`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -18,6 +20,7 @@ import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { Plus, Search } from "lucide-react";
|
||||
import { api, type CwdSuggestion, type RunHandle } from "../lib/api";
|
||||
import type { Lane } from "../lib/types";
|
||||
import { loadLayout } from "../lib/paneLayout";
|
||||
import { useLanes } from "../hooks/useLanes";
|
||||
import { useAiTools } from "../hooks/useAiTools";
|
||||
import {
|
||||
@@ -41,6 +44,9 @@ function selectedFrom(pathname: string, search: string) {
|
||||
return { lane: v === null ? null : Number(v), run: p.get("run"), session: p.get("session") };
|
||||
}
|
||||
|
||||
/** The Workspace page's polled run list, handed to the sidebar as it lands. */
|
||||
export const LIVE_RUNS_EVENT = "agenthub:live-runs";
|
||||
|
||||
export function WorkspaceTree() {
|
||||
const { t } = useTranslation("lanes");
|
||||
const navigate = useNavigate();
|
||||
@@ -62,8 +68,10 @@ export function WorkspaceTree() {
|
||||
const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]);
|
||||
const [orderTick, setOrderTick] = useState(0);
|
||||
|
||||
// A pane starts, ends or learns its session id exactly when a lane's status
|
||||
// or session moves, so that is when the live-pane list is re-read.
|
||||
// A slot-0 pane starts, ends or learns its session id exactly when a lane's
|
||||
// status or session moves, so that is when the live-pane list is re-read;
|
||||
// the Workspace page also hands over every run list it polls (a tab in
|
||||
// slots 1–3 moves no lane column).
|
||||
const runKey = lanes.map((l) => `${l.id}:${l.status}:${l.session_id}:${l.run_id}`).join(",");
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
@@ -75,6 +83,11 @@ export function WorkspaceTree() {
|
||||
live = false;
|
||||
};
|
||||
}, [runKey]);
|
||||
useEffect(() => {
|
||||
const take = (e: Event) => setLiveRuns((e as CustomEvent<RunHandle[]>).detail);
|
||||
window.addEventListener(LIVE_RUNS_EVENT, take);
|
||||
return () => window.removeEventListener(LIVE_RUNS_EVENT, take);
|
||||
}, []);
|
||||
|
||||
const openAdd = (sourceRepo?: string) => {
|
||||
setAddPrefill(sourceRepo ? { sourceRepo } : null);
|
||||
@@ -86,6 +99,16 @@ export function WorkspaceTree() {
|
||||
};
|
||||
|
||||
const select = useCallback((id: number) => navigate(`/workspace?lane=${id}`), [navigate]);
|
||||
// A row click: a workspace with live tabs opens on them — on the pane it
|
||||
// last focused — and one with none on its home (the tab bar's Home button
|
||||
// reaches the home of the other kind).
|
||||
const open = (id: number) => {
|
||||
const runs = liveRuns.filter((r) => r.laneId === id && r.status === "running");
|
||||
const focus = loadLayout(id).focus;
|
||||
const run = runs.find((r) => (r.slot ?? 0) === focus) ?? runs[0];
|
||||
if (run) navigate(`/workspace?lane=${id}&run=${encodeURIComponent(run.id)}`);
|
||||
else select(id);
|
||||
};
|
||||
const expanded = (id: number) => !!prefs.expanded[id] || !!query.trim();
|
||||
const setExpanded = (id: number, open: boolean) =>
|
||||
writeProjectTreePrefs({ expanded: { ...prefs.expanded, [id]: open } });
|
||||
@@ -244,7 +267,7 @@ export function WorkspaceTree() {
|
||||
expanded={expanded(lane.id)}
|
||||
renaming={renamingId === lane.id}
|
||||
onSelect={() => {
|
||||
select(lane.id);
|
||||
open(lane.id);
|
||||
if (!expanded(lane.id)) setExpanded(lane.id, true);
|
||||
}}
|
||||
onToggle={() => setExpanded(lane.id, !expanded(lane.id))}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, within, fireEvent } from "@testing-library/react";
|
||||
import { act, render, screen, waitFor, within, fireEvent } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
||||
import type { Lane, Session } from "../../lib/types";
|
||||
@@ -243,13 +243,93 @@ describe("WorkspaceTree", () => {
|
||||
expect(api.lanes.action).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows a live tab whose session has no row yet as a New session, never a shell", async () => {
|
||||
vi.mocked(api.run.list).mockImplementation(
|
||||
async () =>
|
||||
({
|
||||
items: [
|
||||
{
|
||||
id: "agenthub-lane-1-2",
|
||||
laneId: 1,
|
||||
status: "running",
|
||||
sessionId: "s-live",
|
||||
tool: "claude",
|
||||
},
|
||||
{
|
||||
id: "agenthub-lane-1",
|
||||
laneId: 1,
|
||||
status: "running",
|
||||
sessionId: "s-fresh",
|
||||
tool: "claude",
|
||||
},
|
||||
{
|
||||
id: "agenthub-lane-1-3",
|
||||
laneId: 1,
|
||||
status: "running",
|
||||
sessionId: null,
|
||||
tool: "shell",
|
||||
},
|
||||
],
|
||||
}) as never
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderTree();
|
||||
await user.click(await screen.findByTestId("workspace-toggle-1"));
|
||||
const fresh = await screen.findByTestId("workspace-session-new-agenthub-lane-1");
|
||||
expect(fresh).toHaveTextContent("New session");
|
||||
expect(screen.queryByTestId("workspace-session-new-agenthub-lane-1-3")).toBeNull();
|
||||
expect(screen.queryByTestId("workspace-session-new-agenthub-lane-1-2")).toBeNull();
|
||||
await user.click(fresh);
|
||||
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1&run=agenthub-lane-1");
|
||||
});
|
||||
|
||||
it("selects by URL, and a click selects and expands", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTree("/workspace?lane=2");
|
||||
expect(await screen.findByTestId("workspace-open-2")).toHaveAttribute("aria-current", "true");
|
||||
// Lane 1 has a live tab: its row opens the tab, not the home.
|
||||
await waitFor(() => expect(api.run.list).toHaveBeenCalled());
|
||||
await user.click(screen.getByTestId("workspace-open-1"));
|
||||
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1");
|
||||
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1&run=agenthub-lane-1-2");
|
||||
expect(await screen.findByTestId("workspace-sessions-1")).toBeInTheDocument();
|
||||
// Lane 2 has none: its home.
|
||||
await user.click(screen.getByTestId("workspace-open-2"));
|
||||
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=2");
|
||||
});
|
||||
|
||||
it("opens a workspace on the pane it last focused, and follows the page's run list", async () => {
|
||||
vi.mocked(api.run.list).mockImplementation(async () => ({ items: [] }) as never);
|
||||
localStorage.setItem(
|
||||
"agenthub.workspace.paneLayout",
|
||||
JSON.stringify({
|
||||
1: {
|
||||
root: {
|
||||
type: "split",
|
||||
dir: "row",
|
||||
ratio: 0.5,
|
||||
a: { type: "pane", slot: 0 },
|
||||
b: { type: "pane", slot: 2 },
|
||||
},
|
||||
focus: 2,
|
||||
},
|
||||
})
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderTree();
|
||||
await screen.findByTestId("workspace-open-1");
|
||||
// The Workspace page's poll lands: two live tabs on lane 1.
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("agenthub:live-runs", {
|
||||
detail: [
|
||||
{ id: "agenthub-lane-1", laneId: 1, slot: 0, status: "running", tool: "claude" },
|
||||
{ id: "agenthub-lane-1-2", laneId: 1, slot: 2, status: "running", tool: "claude" },
|
||||
],
|
||||
})
|
||||
);
|
||||
});
|
||||
await user.click(screen.getByTestId("workspace-open-1"));
|
||||
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1&run=agenthub-lane-1-2");
|
||||
});
|
||||
|
||||
it("reorders with Alt+ArrowUp and keeps the order in localStorage", async () => {
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
* renames it and its hover button archives it (`PATCH {name}` / `{archived}`,
|
||||
* which deletes nothing). A session with a LIVE pane (a `GET /api/run` handle
|
||||
* carrying its session id) opens that tmux pane on the Workspace page
|
||||
* (`?lane=<id>&run=<runId>`, its tab in the session view; Ctrl/Cmd+click opens
|
||||
* (`?lane=<id>&run=<runId>`, its tab in the session view; a live tab whose
|
||||
* session has no row yet — a bare tab before its first prompt — is listed as
|
||||
* **New session** from the run itself; Ctrl/Cmd+click opens
|
||||
* it in a split beside the focused pane) — attaching, never starting or
|
||||
* resuming anything;
|
||||
* any other session opens in its workspace too (`?lane=<id>&session=<id>`,
|
||||
@@ -200,9 +202,17 @@ export function WorkspaceSessionList({
|
||||
const [archiving, setArchiving] = useState<Session | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
// This workspace's live AI tabs. A tab started bare (no prompt) has its
|
||||
// session id from the start, but no session row until a hook creates one.
|
||||
const laneRuns = liveRuns.filter(
|
||||
(r) => r.laneId === lane.id && r.status === "running" && r.tool !== "shell"
|
||||
);
|
||||
// The lane's own session/status/needs-you move on every hook, which is when
|
||||
// a session can appear or change state — re-read then, not on a timer.
|
||||
const refreshKey = `${lane.session_id}:${lane.status}:${lane.needs_action}:${version}`;
|
||||
// a session can appear or change state, and a tab opening or getting its
|
||||
// session is too — re-read then, not on a timer.
|
||||
const refreshKey = `${lane.session_id}:${lane.status}:${lane.needs_action}:${version}:${laneRuns
|
||||
.map((r) => `${r.id}=${r.sessionId ?? ""}`)
|
||||
.join(",")}`;
|
||||
const own = useSessionPage({ lane: lane.id, archived: false, limit: SESSION_LIMIT }, refreshKey);
|
||||
const sessions = own?.sessions ?? (own === null ? null : []);
|
||||
|
||||
@@ -211,6 +221,13 @@ export function WorkspaceSessionList({
|
||||
(s) => !q || (s.name ?? "").toLowerCase().includes(q) || s.id.startsWith(q)
|
||||
);
|
||||
const runFor = (s: Session) => liveRunFor(lane, liveRuns, s.id);
|
||||
// A live tab whose session has no row yet: shown as a New session at the
|
||||
// top, like Claude Desktop, until its first hook brings the real row. Not
|
||||
// during a search — it has no name to match.
|
||||
const fresh =
|
||||
sessions === null || q
|
||||
? []
|
||||
: laneRuns.filter((r) => !r.sessionId || !sessions.some((s) => s.id === r.sessionId));
|
||||
const count = sessions === null ? null : shown.length;
|
||||
useEffect(() => {
|
||||
if (count !== null) onMatches?.(count);
|
||||
@@ -270,7 +287,7 @@ export function WorkspaceSessionList({
|
||||
{error}
|
||||
</p>
|
||||
);
|
||||
if (sessions !== null && shown.length === 0) {
|
||||
if (sessions !== null && shown.length === 0 && fresh.length === 0) {
|
||||
return (
|
||||
<>
|
||||
<p className="py-1 pl-8 text-[11px] text-fg-muted">{t("tree.noSessions")}</p>
|
||||
@@ -282,6 +299,34 @@ export function WorkspaceSessionList({
|
||||
return (
|
||||
<>
|
||||
<ul data-testid={`workspace-sessions-${lane.id}`} className="space-y-px">
|
||||
{fresh.map((r) => (
|
||||
<li key={r.id}>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`workspace-session-new-${r.id}`}
|
||||
data-live="true"
|
||||
onClick={(e) =>
|
||||
navigate(
|
||||
`/workspace?lane=${lane.id}&run=${encodeURIComponent(r.id)}`,
|
||||
e.ctrlKey || e.metaKey ? { state: { split: true } } : undefined
|
||||
)
|
||||
}
|
||||
title={`${t("tree.newSession")}\n${t("tree.splitHint")}`}
|
||||
className={`flex w-full items-center gap-2 rounded-md py-1 pl-7 pr-2 text-left text-[12px] ${
|
||||
r.id === selectedRunId
|
||||
? "bg-accent/10 text-fg-primary"
|
||||
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-status-success" />
|
||||
<span className="truncate italic">{t("tree.newSession")}</span>
|
||||
<AiToolIcon
|
||||
tool={{ id: r.tool, label: tools.label(r.tool) }}
|
||||
className="ml-auto h-3.5 w-3.5 shrink-0 !p-0 opacity-70"
|
||||
/>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{shown.map((s) => {
|
||||
const run = runFor(s);
|
||||
const selected = run ? run.id === selectedRunId : s.id === selectedSessionId;
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
* tool started bare on its default model, no prompt (a prompt, router or
|
||||
* worktree is still the workspace home's prompt box) — plus a link to
|
||||
* Settings → Agents. Four tabs is the server's limit (`MAX_PANE_SLOT`). The
|
||||
* bar is the console's only header; `actions` (the workspace's Tools menu)
|
||||
* sits at its right end. A click shows a tab in the focused pane, Ctrl/Cmd+click
|
||||
* bar is the console's only header: Home (the workspace's home) at its left
|
||||
* end, `actions` (the workspace's Tools menu) at its right end. A click shows a tab in the focused pane, Ctrl/Cmd+click
|
||||
* (on a tab or a `+` item) opens it in a split to the right, and a tab drags
|
||||
* onto the panes (`PaneGrid.tsx`); a tab another pane shows is marked.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -18,7 +18,7 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Plus, Settings2, SquareTerminal, X } from "lucide-react";
|
||||
import { House, Plus, Settings2, SquareTerminal, X } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import type { RunHandle } from "../../lib/api";
|
||||
import type { Lane } from "../../lib/types";
|
||||
@@ -34,6 +34,7 @@ export function ConsoleTabs({
|
||||
shownRunIds = [],
|
||||
onOpen,
|
||||
onClosed,
|
||||
onHome,
|
||||
actions,
|
||||
}: {
|
||||
lane: Lane;
|
||||
@@ -47,6 +48,8 @@ export function ConsoleTabs({
|
||||
onOpen: (runId: string, split?: boolean) => void;
|
||||
/** A tab's session was stopped. */
|
||||
onClosed: (runId: string) => void;
|
||||
/** The workspace's home (its prompt box, Recents, context). */
|
||||
onHome?: () => void;
|
||||
/** Right end of the bar (the workspace's Tools menu). */
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
@@ -108,6 +111,18 @@ export function ConsoleTabs({
|
||||
|
||||
return (
|
||||
<div className="flex h-10 shrink-0 items-stretch border-b border-border bg-surface-1">
|
||||
{onHome && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="console-home"
|
||||
onClick={onHome}
|
||||
title={t("tabs.home")}
|
||||
aria-label={t("tabs.home")}
|
||||
className="flex w-10 shrink-0 items-center justify-center border-r border-border text-fg-muted hover:bg-surface-4 hover:text-fg-primary"
|
||||
>
|
||||
<House className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<div role="tablist" aria-label={t("tabs.label")} className="flex min-w-0 items-stretch">
|
||||
{tabs.map((r) => {
|
||||
const active = r.id === activeRunId;
|
||||
|
||||
Reference in New Issue
Block a user