feat(workspace): no double resume, server-picked tabs, terminal a11y
- A resume of a session a live pane already holds is refused (409 ESESSIONLIVE with error.run_id) and the client opens that pane, pasting any typed message there; catalog tools refuse resumeSessionId (EUNSUPPORTED) instead of silently starting fresh. - POST /api/lanes/:id/start takes slot: "auto": the server picks the free tab under the lane lock (components/lanes/startSession.ts on the client, kept out of WorkspaceHome so the sidebar does not pull react-markdown into the main bundle). - Unsaved whole-file edits are guarded on every exit (Back, another file, tab switch, close, lane/session change, beforeunload). - Closing an AI tab asks first; hiding a pane has its own icon. Tabs and pane headers show the session name, a state dot, rename by double-click. - Terminal: Ctrl+M toggles Tab-moves-focus (no keyboard trap), Cmd+F / Ctrl+Shift+F find bar, clickable links (noopener), a write-only OSC 52 clipboard, a Reconnecting overlay, debounced resizes, optional screen reader mode. Alt+1-4 / Alt+\ / Alt+W focus, split and hide panes. - Changes panel: draggable splitter, wrapping git bar, history paging, disarming discard/undo/abort, inline diff comments pasted only into a visible pane; drag MIME guard on sidebar rows; IME-safe Enter; Show more in the sidebar and Recents; idle dots readable with sr-only text. - Adds @xterm/addon-clipboard, @xterm/addon-search, @xterm/addon-web-links.
This commit is contained in:
@@ -17,10 +17,16 @@
|
||||
* any other session opens in its workspace too (`?lane=<id>&session=<id>`,
|
||||
* `SessionReplay`): its conversation with a prompt box, resumed only when a
|
||||
* message is SENT, because a resume re-reads the whole transcript into context.
|
||||
* The list shows `SESSION_LIMIT` rows and a **Show N more** under them. A
|
||||
* live tab whose session was archived is not brought back as a New session,
|
||||
* and archiving a live one asks first. F2 renames the focused row too; each
|
||||
* dot carries its meaning as screen-reader text.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { Archive, GitBranch } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
@@ -57,7 +63,7 @@ export function sessionWorktreeName(lane: Lane, s: Session): string | null {
|
||||
}
|
||||
|
||||
/** A session row's dot: waiting for the user, failed, held by a live pane, or
|
||||
* at rest. */
|
||||
* at rest (a ring, which reads on every theme). */
|
||||
export function sessionDot(session: Session, live: boolean): string {
|
||||
if (isSessionAwaitingInput(session)) return "bg-status-warning";
|
||||
if (session.status === "error") return "bg-status-danger";
|
||||
@@ -65,6 +71,13 @@ export function sessionDot(session: Session, live: boolean): string {
|
||||
return "border border-fg-muted";
|
||||
}
|
||||
|
||||
/** What the dot means, for a screen reader (`tree.state.*`). */
|
||||
export function sessionDotLabel(session: Session, live: boolean): string {
|
||||
if (isSessionAwaitingInput(session)) return "awaiting";
|
||||
if (session.status === "error") return "error";
|
||||
return live ? "live" : "idle";
|
||||
}
|
||||
|
||||
export type SessionPage = { sessions: Session[]; total: number };
|
||||
|
||||
/** One page of `GET /api/sessions`, re-read whenever `refreshKey` changes. */
|
||||
@@ -147,7 +160,13 @@ export function SessionFold({
|
||||
onContextMenu={laneId === undefined ? undefined : (e) => ctx.open(e, s)}
|
||||
>
|
||||
<Link
|
||||
to={`/sessions/${s.id}`}
|
||||
// An archived session opens in its workspace like any other
|
||||
// (its replay), not on the analysis page.
|
||||
to={
|
||||
laneId === undefined
|
||||
? `/sessions/${s.id}`
|
||||
: `/workspace?lane=${laneId}&session=${encodeURIComponent(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"
|
||||
>
|
||||
@@ -225,6 +244,8 @@ export function WorkspaceSessionList({
|
||||
const [busy, setBusy] = useState(false);
|
||||
const ctx = useContextMenu<RunHandle>();
|
||||
const sessionCtx = useContextMenu<Session>();
|
||||
// Rows shown; Show more adds a page.
|
||||
const [limit, setLimit] = useState(SESSION_LIMIT);
|
||||
|
||||
// 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.
|
||||
@@ -237,8 +258,9 @@ export function WorkspaceSessionList({
|
||||
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 own = useSessionPage({ lane: lane.id, archived: false, limit }, refreshKey);
|
||||
const sessions = own?.sessions ?? (own === null ? null : []);
|
||||
const more = own ? Math.max(0, own.total - own.sessions.length) : 0;
|
||||
|
||||
const q = query.trim().toLowerCase();
|
||||
const shown = (sessions ?? []).filter(
|
||||
@@ -248,10 +270,41 @@ export function WorkspaceSessionList({
|
||||
// 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.
|
||||
// A tab whose session HAS a row the list does not show is archived (or
|
||||
// past the page): looked up once, so archiving a live session never brings
|
||||
// it back as a New session.
|
||||
const orphanIds =
|
||||
sessions === null
|
||||
? ""
|
||||
: laneRuns
|
||||
.filter((r) => r.sessionId && !sessions.some((s) => s.id === r.sessionId))
|
||||
.map((r) => r.sessionId)
|
||||
.join(",");
|
||||
const [hasRow, setHasRow] = useState<Set<string>>(new Set());
|
||||
useEffect(() => {
|
||||
if (!orphanIds) return;
|
||||
let live = true;
|
||||
void Promise.all(
|
||||
orphanIds.split(",").map((id) =>
|
||||
api.sessions
|
||||
// The row only: a session's events are its whole history.
|
||||
.get(id, { events: false })
|
||||
.then(() => id)
|
||||
.catch(() => null)
|
||||
)
|
||||
).then((found) => live && setHasRow(new Set(found.filter((x): x is string => !!x))));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [orphanIds]);
|
||||
const fresh =
|
||||
sessions === null || q
|
||||
? []
|
||||
: laneRuns.filter((r) => !r.sessionId || !sessions.some((s) => s.id === r.sessionId));
|
||||
: laneRuns.filter(
|
||||
(r) =>
|
||||
!r.sessionId ||
|
||||
(!sessions.some((s) => s.id === r.sessionId) && !hasRow.has(r.sessionId))
|
||||
);
|
||||
const count = sessions === null ? null : shown.length;
|
||||
useEffect(() => {
|
||||
if (count !== null) onMatches?.(count);
|
||||
@@ -274,10 +327,21 @@ export function WorkspaceSessionList({
|
||||
setDraft(s.name || "");
|
||||
setRenaming(s.id);
|
||||
};
|
||||
const archive = (s: Session) =>
|
||||
sessionWorktreeName(lane, s) && !runFor(s)
|
||||
? setArchiving(s)
|
||||
: void patch(s, { archived: true });
|
||||
const archive = async (s: Session) => {
|
||||
if (sessionWorktreeName(lane, s) && !runFor(s)) return setArchiving(s);
|
||||
// A live one keeps running in its tab; archiving only hides its row.
|
||||
if (
|
||||
runFor(s) &&
|
||||
!(await confirmDialog({
|
||||
title: t("tree.archiveLiveTitle"),
|
||||
message: t("tree.archiveLiveBody"),
|
||||
confirmLabel: t("sessionMenu.archive"),
|
||||
destructive: false,
|
||||
}))
|
||||
)
|
||||
return;
|
||||
await patch(s, { archived: true });
|
||||
};
|
||||
const archiveWithWorktree = async (s: Session) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
@@ -347,7 +411,8 @@ export function WorkspaceSessionList({
|
||||
: "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 aria-hidden className="h-1.5 w-1.5 shrink-0 rounded-full bg-status-success" />
|
||||
<span className="sr-only">{t("tree.state.live")}</span>
|
||||
<span className="truncate italic">{t("tree.newSession")}</span>
|
||||
<AiToolIcon
|
||||
tool={{ id: r.tool, label: tools.label(r.tool) }}
|
||||
@@ -373,7 +438,7 @@ export function WorkspaceSessionList({
|
||||
onBlur={() => setRenaming(null)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") setRenaming(null);
|
||||
if (e.key !== "Enter") return;
|
||||
if (!isSubmitEnter(e)) return;
|
||||
setRenaming(null);
|
||||
const name = draft.trim();
|
||||
if (name && name !== s.name) void patch(s, { name });
|
||||
@@ -389,7 +454,10 @@ export function WorkspaceSessionList({
|
||||
type="button"
|
||||
data-testid={`workspace-session-${s.id}`}
|
||||
data-live={run ? "true" : undefined}
|
||||
onClick={(e) =>
|
||||
onClick={(e) => {
|
||||
// A double-click's second click renames; it must not
|
||||
// navigate (a second history entry) on top of the first.
|
||||
if (e.detail > 1) return;
|
||||
navigate(
|
||||
run
|
||||
? `/workspace?lane=${lane.id}&run=${encodeURIComponent(run.id)}`
|
||||
@@ -397,9 +465,15 @@ export function WorkspaceSessionList({
|
||||
// Ctrl/Cmd+click a live one: open it in a split beside the
|
||||
// focused pane (Workspace reads this state, not the URL).
|
||||
run && (e.ctrlKey || e.metaKey) ? { state: { split: true } } : undefined
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
onDoubleClick={() => startRename(s)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "F2") {
|
||||
e.preventDefault();
|
||||
startRename(s);
|
||||
}
|
||||
}}
|
||||
onContextMenu={(e) => sessionCtx.open(e, s)}
|
||||
title={`${s.name || s.id}\n${timeAgo(s.last_activity ?? s.started_at)}${
|
||||
sessionWorktreeName(lane, s)
|
||||
@@ -414,8 +488,10 @@ export function WorkspaceSessionList({
|
||||
>
|
||||
<span
|
||||
data-testid={`workspace-session-dot-${s.id}`}
|
||||
aria-hidden
|
||||
className={`h-1.5 w-1.5 shrink-0 rounded-full ${sessionDot(s, !!run)}`}
|
||||
/>
|
||||
<span className="sr-only">{t(`tree.state.${sessionDotLabel(s, !!run)}`)}</span>
|
||||
<span className="truncate">{s.name || s.id.slice(0, 8)}</span>
|
||||
{sessionWorktreeName(lane, s) && (
|
||||
<GitBranch
|
||||
@@ -432,10 +508,12 @@ export function WorkspaceSessionList({
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`workspace-session-archive-${s.id}`}
|
||||
onClick={() => archive(s)}
|
||||
onClick={() => void archive(s)}
|
||||
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"
|
||||
// Invisible, not `hidden`: a keyboard user must be able to
|
||||
// Tab onto it, and it shows when focused.
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 rounded p-0.5 text-fg-muted opacity-0 hover:bg-surface-4 hover:text-fg-primary focus:opacity-100 group-hover:opacity-100"
|
||||
>
|
||||
<Archive className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@@ -443,6 +521,16 @@ export function WorkspaceSessionList({
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{more > 0 && !q && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`workspace-sessions-more-${lane.id}`}
|
||||
onClick={() => setLimit((n) => n + SESSION_LIMIT)}
|
||||
className="w-full py-1 pl-8 pr-2 text-left text-[11px] text-fg-muted hover:text-fg-primary"
|
||||
>
|
||||
{t("tree.showMore", { count: Math.min(more, SESSION_LIMIT) })}
|
||||
</button>
|
||||
)}
|
||||
{failure}
|
||||
{confirmArchive}
|
||||
{ctx.menu && (
|
||||
@@ -464,7 +552,7 @@ export function WorkspaceSessionList({
|
||||
run={runFor(sessionCtx.menu.item)}
|
||||
onClose={sessionCtx.close}
|
||||
onRename={() => startRename(sessionCtx.menu!.item)}
|
||||
onArchive={() => archive(sessionCtx.menu!.item)}
|
||||
onArchive={() => void archive(sessionCtx.menu!.item)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user