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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user