feat: workspaces own their sessions; desktop parity; sidebar and settings cleanup

Workspace sessions (docs/plans/desktop-parity, phases 1-5 and 7):
- sessions.lane_id: a workspace lists only the sessions its panes held,
  stamped by bindRunSession (first writer wins, nested `claude -p`
  excluded); GET /api/sessions?lane=&archived=
- session row state dot, double-click rename, archive (sessions.archived_at,
  hides only); the activity bar's duplicate Sessions tab removed
- Fork beside Resume (--fork-session, Claude only), Ctrl/Cmd+click a live
  session opens it in a second pane, a diff line's comment is pasted into
  the workspace's last-focused pane (lib/panePaste.ts)

dashboard_runs stability:
- a pane's previous run is archived as <pane>@<started_at> instead of being
  overwritten by INSERT OR REPLACE
- a run is flagged abandoned only once its tmux session is gone, at boot and
  on the run-heal beat (panes outlive a server restart)

UI:
- sidebar: no footer, language or theme block; small collapse button in the
  brand corner; version + update check in the connection details modal, a
  status-bar dot while a release is out; language in Settings -> General
- one Switch component for every switch, the themed Checkbox for every
  checkbox (no native checkboxes)

Also: AI Tools catalog trimmed to 10 built-ins (custom-file prompt modes
tested through fixtures), and the workspace plans under docs/plans/ (the
multi-user server plan lives in the AgentHub-Server repo).
This commit is contained in:
2026-10-02 12:05:00 +07:00
parent 5b269698f6
commit a6bd33bcff
121 changed files with 2699 additions and 1552 deletions
+35 -49
View File
@@ -43,7 +43,7 @@
*
* ----------------------------------------------------------------------------- */
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { useLocation, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
@@ -58,7 +58,6 @@ import {
Maximize2,
FolderTree,
GitCompare,
History,
X,
} from "lucide-react";
import { api } from "../lib/api";
@@ -79,7 +78,6 @@ import LaneCard, {
} from "../components/lanes/LaneCard";
import { AddLaneModal } from "../components/lanes/AddLaneModal";
import { LaneChangesPanel } from "../components/lanes/LaneChangesPanel";
import { WorkspaceSessionsTab } from "../components/lanes/WorkspaceSessionsTab";
import { WorkspaceHome } from "../components/lanes/WorkspaceHome";
import { DestructiveLaneModal } from "../components/lanes/DestructiveLaneModal";
import { readSplitViewState, writeSplitViewState } from "../lib/splitViewStorage";
@@ -97,7 +95,6 @@ const SELECTED_KEY = "agenthub.workspace.selected";
const PANEL_TABS = [
{ id: "files", Icon: FolderTree },
{ id: "changes", Icon: GitCompare },
{ id: "sessions", Icon: History },
] as const;
type PanelTab = (typeof PANEL_TABS)[number]["id"];
@@ -233,7 +230,6 @@ function ConsoleArea({
onLaneCreated,
onLaneIdChange,
showToggle = true,
resumeRequest,
attachRequest,
}: {
lanes: Lane[];
@@ -250,10 +246,8 @@ function ConsoleArea({
onLaneCreated: (lane: Lane) => void;
onLaneIdChange: (id: number | null) => void;
showToggle?: boolean;
/** A session to resume in slot 0, from the Sessions tab. */
resumeRequest: { sessionId: string; model: string | null; nonce: number } | null;
/** A live run to attach slot 0 to, from the sidebar (`?run=`). */
attachRequest: { runId: string; nonce: string } | null;
/** A live run to attach a pane to, from the sidebar (`?run=`, `&pane=`). */
attachRequest: { runId: string; nonce: string; pane: number } | null;
}) {
// Every split pane shows the SELECTED lane, each in its own tmux session
// (slot = pane index). Tracking what each pane shows lets the others leave
@@ -297,8 +291,7 @@ function ConsoleArea({
wsConnected={wsConnected}
defaultCwd={defaultCwd}
onHasActiveRunChange={onHasActiveRunChange}
resumeRequest={resumeRequest}
attachRequest={attachRequest}
attachRequest={attachRequest?.pane === 0 ? attachRequest : null}
/>
) : (
<div
@@ -329,8 +322,7 @@ function ConsoleArea({
externalSessions={externalSessions}
wsConnected={wsConnected}
defaultCwd={defaultCwd}
resumeRequest={i === 0 ? resumeRequest : null}
attachRequest={i === 0 ? attachRequest : null}
attachRequest={attachRequest?.pane === i ? attachRequest : null}
/>
</div>
))}
@@ -354,9 +346,12 @@ export function Workspace() {
// second pick of the same session a new request.
const location = useLocation();
const runParam = searchParams.get("run");
// `&pane=<n>` — which split pane attaches it (Ctrl/Cmd+click in the sidebar
// asks for pane 1); pane 0 otherwise.
const paneParam = Math.min(Math.max(Number(searchParams.get("pane")) || 0, 0), 3);
const attachRequest = useMemo(
() => (runParam ? { runId: runParam, nonce: location.key } : null),
[runParam, location.key]
() => (runParam ? { runId: runParam, nonce: location.key, pane: paneParam } : null),
[runParam, location.key, paneParam]
);
const paramId = laneParam === null ? null : Number(laneParam);
const fallbackId = (() => {
@@ -417,11 +412,6 @@ export function Workspace() {
>([]);
const [viewedFeature, setViewedFeature] = useState<LaneFeature | null>(null);
const [proofFeatures, setProofFeatures] = useState<ProofFeature[]>([]);
const [resumeRequest, setResumeRequest] = useState<{
sessionId: string;
model: string | null;
nonce: number;
} | null>(null);
// Run state kept at page level: shared across every pane, or drives the
// lane strip itself rather than any one pane's form.
@@ -503,6 +493,16 @@ export function Workspace() {
});
}, []);
// A pane the layout does not show yet (`&pane=1` on one pane) widens the
// split so the run lands where it was asked for — once per request, so the
// user can still go back to one pane afterwards.
const layoutRef = useRef(splitView.layout);
layoutRef.current = splitView.layout;
useEffect(() => {
const pane = attachRequest?.pane ?? 0;
if (pane >= layoutRef.current) setLayout(pane < 2 ? 2 : 4);
}, [attachRequest, setLayout]);
// Probe the CLI of every tool a lane uses — fresh on the server, so a CLI
// installed since the page loaded clears its missing-binary banner. Claude is
// always probed: the console's no-lane state runs it.
@@ -1002,7 +1002,6 @@ export function Workspace() {
onLaneCreated={() => void refreshLanes()}
onLaneIdChange={setSelectedLaneId}
showToggle={!currentLane}
resumeRequest={resumeRequest}
attachRequest={attachRequest}
/>
</div>
@@ -1065,34 +1064,21 @@ export function Workspace() {
})()}
</button>
</div>
{panelTab === "sessions" ? (
<WorkspaceSessionsTab
lane={currentLane}
onResume={(session) =>
setResumeRequest({
sessionId: session.id,
model: session.model,
nonce: Date.now(),
})
}
/>
) : (
<LaneChangesPanel
laneId={currentLane.id}
tab={panelTab}
full={changesSize === "full"}
// A sibling worktree forks from the lane's SOURCE repo when
// it has one (this lane is itself a worktree of it); an
// adopted lane is its own repo.
onNewLane={(branch) => {
setAddLanePrefill({
sourceRepo: currentLane.source_repo || currentLane.cwd,
branch,
});
setAddLaneOpen(true);
}}
/>
)}
<LaneChangesPanel
laneId={currentLane.id}
tab={panelTab}
full={changesSize === "full"}
// A sibling worktree forks from the lane's SOURCE repo when
// it has one (this lane is itself a worktree of it); an
// adopted lane is its own repo.
onNewLane={(branch) => {
setAddLanePrefill({
sourceRepo: currentLane.source_repo || currentLane.cwd,
branch,
});
setAddLaneOpen(true);
}}
/>
</aside>
) : (
<button