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:
@@ -1,19 +1,26 @@
|
||||
/**
|
||||
* @file WorkspaceSessionList — the sessions under an expanded workspace in the
|
||||
* sidebar (`GET /api/sessions?cwd=&tool=`, newest first). 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>`) — attaching, never starting
|
||||
* or resuming anything; any other session opens its read-only transcript
|
||||
* (`/sessions/:id`), where Resume is a separate, explicit action, because a
|
||||
* resume re-reads the whole transcript into context.
|
||||
* sidebar: the ones the workspace OWNS and has not archived
|
||||
* (`GET /api/sessions?lane=<id>&archived=0`, newest first) — never the other
|
||||
* sessions that ran in its folder. A row's dot
|
||||
* shows its state (`sessionDot`: live pane, needs you, error); double-click
|
||||
* 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>`, Ctrl/Cmd+click `&pane=1` to open it in a second
|
||||
* split pane) — attaching, never starting or resuming anything;
|
||||
* any other session opens its read-only transcript (`/sessions/:id`), where
|
||||
* Resume is a separate, explicit action, because a resume re-reads the whole
|
||||
* transcript into context.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { Archive } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { api, type RunHandle } from "../../lib/api";
|
||||
import type { Lane, Session } from "../../lib/types";
|
||||
import { isSessionAwaitingInput, type Lane, type Session } from "../../lib/types";
|
||||
import { timeAgo } from "../../lib/format";
|
||||
|
||||
export const SESSION_LIMIT = 20;
|
||||
@@ -31,6 +38,118 @@ export function liveRunFor(lane: Lane, runs: RunHandle[], sessionId: string): Ru
|
||||
);
|
||||
}
|
||||
|
||||
/** A session row's dot: waiting for the user, failed, held by a live pane, or
|
||||
* at rest. */
|
||||
export function sessionDot(session: Session, live: boolean): string {
|
||||
if (isSessionAwaitingInput(session)) return "bg-status-warning";
|
||||
if (session.status === "error") return "bg-status-danger";
|
||||
if (live) return "bg-status-success";
|
||||
return "border border-fg-muted";
|
||||
}
|
||||
|
||||
export type SessionPage = { sessions: Session[]; total: number };
|
||||
|
||||
/** One page of `GET /api/sessions`, re-read whenever `refreshKey` changes. */
|
||||
export function useSessionPage(
|
||||
params: Parameters<typeof api.sessions.list>[0],
|
||||
refreshKey: string
|
||||
): SessionPage | null {
|
||||
const [page, setPage] = useState<SessionPage | null>(null);
|
||||
const key = JSON.stringify(params);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
api.sessions
|
||||
.list(JSON.parse(key))
|
||||
.then((r) => live && setPage({ sessions: r.sessions, total: r.total ?? r.sessions.length }))
|
||||
.catch(() => live && setPage(null));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [key, refreshKey]);
|
||||
return page;
|
||||
}
|
||||
|
||||
/** A folded list of sessions under a one-line toggle, each with one action
|
||||
* (Unarchive); `onDone` re-reads the lists after it. */
|
||||
export function SessionFold({
|
||||
testId,
|
||||
page,
|
||||
label,
|
||||
hint,
|
||||
actionLabel,
|
||||
actionIcon,
|
||||
onAction,
|
||||
onDone,
|
||||
indent = "",
|
||||
}: {
|
||||
testId: string;
|
||||
page: SessionPage | null;
|
||||
label: (count: number) => string;
|
||||
hint?: string;
|
||||
actionLabel: string;
|
||||
actionIcon: ReactNode;
|
||||
onAction: (s: Session) => Promise<unknown>;
|
||||
onDone: () => void;
|
||||
/** Left padding of the rows, to line up with the list above. */
|
||||
indent?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
if (!page || page.total === 0) return null;
|
||||
const act = async (s: Session) => {
|
||||
setError(null);
|
||||
try {
|
||||
await onAction(s);
|
||||
onDone();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div data-testid={testId}>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
title={hint}
|
||||
className={`w-full py-1 pr-2 text-left text-[11px] text-fg-muted hover:text-fg-primary ${indent}`}
|
||||
>
|
||||
{open ? "▾" : "▸"} {label(page.total)}
|
||||
</button>
|
||||
{open && (
|
||||
<ul className="space-y-px">
|
||||
{page.sessions.map((s) => (
|
||||
<li key={s.id} className={`flex items-center gap-1 pr-1 ${indent}`}>
|
||||
<Link
|
||||
to={`/sessions/${s.id}`}
|
||||
title={`${s.name || s.id}\n${timeAgo(s.last_activity ?? s.started_at)}`}
|
||||
className="min-w-0 flex-1 truncate rounded-md py-1 text-[12px] text-fg-muted hover:text-fg-primary"
|
||||
>
|
||||
{s.name || s.id.slice(0, 8)}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`${testId}-action-${s.id}`}
|
||||
onClick={() => void act(s)}
|
||||
title={actionLabel}
|
||||
aria-label={actionLabel}
|
||||
className="shrink-0 rounded p-0.5 text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
|
||||
>
|
||||
{actionIcon}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className={`text-[11px] text-status-danger ${indent}`}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkspaceSessionList({
|
||||
lane,
|
||||
liveRuns,
|
||||
@@ -54,20 +173,17 @@ export function WorkspaceSessionList({
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const navigate = useNavigate();
|
||||
const [sessions, setSessions] = useState<Session[] | null>(null);
|
||||
// Bumped after a rename or an archive, so the list re-reads.
|
||||
const [version, setVersion] = useState(0);
|
||||
const [renaming, setRenaming] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// The lane's own session/status move on every hook, which is when a new
|
||||
// session can appear — re-read then, not on a timer.
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
api.sessions
|
||||
.list({ cwd: lane.cwd, tool: lane.tool, limit: SESSION_LIMIT })
|
||||
.then((r) => live && setSessions(r.sessions))
|
||||
.catch(() => live && setSessions([]));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [lane.cwd, lane.tool, lane.session_id, lane.status]);
|
||||
// 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}`;
|
||||
const own = useSessionPage({ lane: lane.id, archived: false, limit: SESSION_LIMIT }, refreshKey);
|
||||
const sessions = own?.sessions ?? (own === null ? null : []);
|
||||
|
||||
const q = query.trim().toLowerCase();
|
||||
const shown = (sessions ?? []).filter(
|
||||
@@ -79,47 +195,110 @@ export function WorkspaceSessionList({
|
||||
if (count !== null) onMatches?.(count);
|
||||
}, [count, onMatches]);
|
||||
|
||||
const patch = async (s: Session, body: { name?: string; archived?: boolean }) => {
|
||||
setError(null);
|
||||
try {
|
||||
await api.sessions.patch(s.id, body);
|
||||
setVersion((v) => v + 1);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
if (hidden) return null;
|
||||
const failure = error && (
|
||||
<p role="alert" className="pl-7 text-[11px] text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
);
|
||||
if (sessions !== null && shown.length === 0) {
|
||||
return <p className="py-1 pl-8 text-[11px] text-fg-muted">{t("tree.noSessions")}</p>;
|
||||
return (
|
||||
<>
|
||||
<p className="py-1 pl-8 text-[11px] text-fg-muted">{t("tree.noSessions")}</p>
|
||||
{failure}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ul data-testid={`workspace-sessions-${lane.id}`} className="space-y-px">
|
||||
{shown.map((s) => {
|
||||
const run = runFor(s);
|
||||
const selected = !!run && run.id === selectedRunId;
|
||||
return (
|
||||
<li key={s.id}>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`workspace-session-${s.id}`}
|
||||
data-live={run ? "true" : undefined}
|
||||
onClick={() =>
|
||||
navigate(
|
||||
run
|
||||
? `/workspace?lane=${lane.id}&run=${encodeURIComponent(run.id)}`
|
||||
: `/sessions/${s.id}`
|
||||
)
|
||||
}
|
||||
title={`${s.name || s.id}\n${timeAgo(s.last_activity ?? s.started_at)}${
|
||||
run ? "" : `\n${t("tree.transcriptHint")}`
|
||||
}`}
|
||||
className={`flex w-full items-center gap-2 rounded-md py-1 pl-7 pr-2 text-left text-[12px] ${
|
||||
selected
|
||||
? "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 ${
|
||||
run ? "bg-status-success" : "border border-fg-muted"
|
||||
<>
|
||||
<ul data-testid={`workspace-sessions-${lane.id}`} className="space-y-px">
|
||||
{shown.map((s) => {
|
||||
const run = runFor(s);
|
||||
const selected = !!run && run.id === selectedRunId;
|
||||
if (renaming === s.id) {
|
||||
return (
|
||||
<li key={s.id} className="py-0.5 pl-7 pr-2">
|
||||
<input
|
||||
data-testid={`workspace-session-rename-${s.id}`}
|
||||
aria-label={t("tree.renameSession")}
|
||||
autoFocus
|
||||
value={draft}
|
||||
maxLength={200}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
// Blur CANCELS — Enter is the only save, like a workspace row.
|
||||
onBlur={() => setRenaming(null)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") setRenaming(null);
|
||||
if (e.key !== "Enter") return;
|
||||
setRenaming(null);
|
||||
const name = draft.trim();
|
||||
if (name && name !== s.name) void patch(s, { name });
|
||||
}}
|
||||
className="w-full rounded border border-accent bg-surface-1 px-1.5 py-0.5 text-[12px] text-fg-primary"
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<li key={s.id} className="group relative">
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`workspace-session-${s.id}`}
|
||||
data-live={run ? "true" : undefined}
|
||||
onClick={(e) =>
|
||||
navigate(
|
||||
run
|
||||
? // Ctrl/Cmd+click opens it beside the current pane.
|
||||
`/workspace?lane=${lane.id}&run=${encodeURIComponent(run.id)}${
|
||||
e.ctrlKey || e.metaKey ? "&pane=1" : ""
|
||||
}`
|
||||
: `/sessions/${s.id}`
|
||||
)
|
||||
}
|
||||
onDoubleClick={() => {
|
||||
setDraft(s.name || "");
|
||||
setRenaming(s.id);
|
||||
}}
|
||||
title={`${s.name || s.id}\n${timeAgo(s.last_activity ?? s.started_at)}\n${
|
||||
run ? t("tree.splitHint") : t("tree.transcriptHint")
|
||||
}`}
|
||||
/>
|
||||
<span className="truncate">{s.name || s.id.slice(0, 8)}</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
className={`flex w-full items-center gap-2 rounded-md py-1 pl-7 pr-7 text-left text-[12px] ${
|
||||
selected
|
||||
? "bg-accent/10 text-fg-primary"
|
||||
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
data-testid={`workspace-session-dot-${s.id}`}
|
||||
className={`h-1.5 w-1.5 shrink-0 rounded-full ${sessionDot(s, !!run)}`}
|
||||
/>
|
||||
<span className="truncate">{s.name || s.id.slice(0, 8)}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`workspace-session-archive-${s.id}`}
|
||||
onClick={() => void patch(s, { archived: true })}
|
||||
title={t("tree.archiveSession")}
|
||||
aria-label={t("tree.archiveSession")}
|
||||
className="absolute right-1 top-1/2 hidden -translate-y-1/2 rounded p-0.5 text-fg-muted hover:bg-surface-4 hover:text-fg-primary focus:block group-hover:block"
|
||||
>
|
||||
<Archive className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{failure}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user