mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
780 lines
44 KiB
TypeScript
780 lines
44 KiB
TypeScript
import { inScope, sessionRuntime, type SessionScope } from "./sessionScope";
|
|
import { Fragment, type ReactNode, memo, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
import { hidesLegacySessions, onHidesLegacySessionsChange } from "../state/prefs";
|
|
import { onSessionQuickActionsChange, showsSessionQuickActions } from "../state/sessionQuick";
|
|
import { createPortal } from "react-dom";
|
|
import { t } from "../i18n";
|
|
import type { HubPort, RuntimeView, TreeSession, TreeWorkspace } from "../port/hub";
|
|
import { workspaceContains } from "../port/hub";
|
|
import { HttpError } from "../port/http_error";
|
|
import type { Adder } from "./addws";
|
|
import { pinToViewport } from "./place";
|
|
import { useRailQuery } from "./railsearch";
|
|
import { StudioIcon } from "./StudioIcon";
|
|
import { TreeEmpty, TreeReading } from "./TreeReading";
|
|
import { ListingProgress } from "./ListingProgress";
|
|
import { Cross } from "./glyphs";
|
|
import { copyText } from "./CopyButton";
|
|
import { sessionInfoText } from "./sessionInfo";
|
|
import { saveSessionFile } from "./sessionExport";
|
|
import { useTreeKeys } from "./tree";
|
|
import { useDismiss } from "./dismiss";
|
|
import { asksDelete } from "./keys";
|
|
import { clearDraftForSession } from "./drafts";
|
|
import { WorkspaceOrder, workspaceMenuKeys } from "./WorkspaceOrder";
|
|
import { WorkspaceReveal } from "./WorkspaceReveal";
|
|
import { SessionMenu } from "./SessionMenu";
|
|
import { useSessionMove } from "./useSessionMove";
|
|
import { useSessionMoveTo } from "./useSessionMoveTo";
|
|
import { pinnedFirst } from "./pinorder"; import { Hit } from "./Hit"; import { WsMark } from "./WsMark"; import { ProjectMenuExtras } from "./ProjectMenuExtras";
|
|
import { Confirm, removeHint, sessionDeleteHint } from "./WorkspaceConfirm";
|
|
import { LeadDot, UnreadCount } from "./UnreadMark";
|
|
import { PeerThreadMark } from "./peerthreads";
|
|
import { NO_PAGING, atCap, answers, unloaded, type TreePaging, type TreeView } from "./treewindow";
|
|
import { SessionBatch, useSelectedRows } from "./SessionBatch";
|
|
import { ProjectsHeader } from "./ProjectsHeader";
|
|
import { useWorkspaceDrag } from "./useWorkspaceDrag";
|
|
import { SessionTail } from "./SessionAge";
|
|
import { SessionArchiveAction, SessionMoreAction, SessionPinAction } from "./SessionRowActions";
|
|
import { useSessionMutation, type SessionMutation } from "./useSessionMutation";
|
|
|
|
const parentOf = (root: string) => root.replace(/[/\\]+$/, "").split(/[/\\]/).slice(-2, -1)[0] ?? "";
|
|
|
|
interface Props {
|
|
hub: HubPort;
|
|
tree: TreeWorkspace[];
|
|
// False until the kernel first answers; an empty tree before then is unread.
|
|
treeRead: boolean;
|
|
answered?: TreeView | null;
|
|
runtimes: RuntimeView[];
|
|
active: string;
|
|
// Collapsed workspaces are the window's own state, not the kernel's, so the
|
|
// sidebar keeps them here rather than asking for them back every reload.
|
|
folded: Set<string>;
|
|
onFold: (root: string, folded: boolean) => void;
|
|
reload: () => Promise<void>;
|
|
retry?: () => void;
|
|
paging?: TreePaging;
|
|
previewLimit?: number;
|
|
onToggleAll?: () => void;
|
|
onRecent?: () => void;
|
|
allFolded?: boolean;
|
|
canFold?: boolean;
|
|
// The session path being opened, or empty. The row shows it selected and busy
|
|
// before the kernel answers; the owner clears it on failure.
|
|
opening?: string;
|
|
onOpen: (req: { root?: string; sessionPath?: string }) => Promise<void>;
|
|
onFocus: (id: string) => void;
|
|
onClose: (ids: string[]) => Promise<void>;
|
|
// Which of these panes are mid-turn. A callback rather than a prop: run state
|
|
// changes constantly and this is only ever asked at confirmation time.
|
|
liveIds: (ids: string[]) => string[];
|
|
// Which of these belong in the running list: the live ones, and the ones that finished within the chosen grace.
|
|
listedIds?: (ids: string[]) => string[];
|
|
runs: Record<string, { run: string; live: boolean }>;
|
|
scope?: SessionScope;
|
|
pinned?: Set<string>;
|
|
onPin?: (path: string) => void;
|
|
onPause?: (runtimeId: string) => void;
|
|
onBranch?: (runtimeId: string, name: string) => Promise<void>;
|
|
onArchive?: (path: string, archived: boolean, runtimeId?: string) => Promise<void>;
|
|
onRename: (path: string, title: string, mode?: "inline" | "dialog") => void;
|
|
onError: (e: unknown) => void;
|
|
// 打开项目这个动作归 App —— 首启那条横幅按的是同一个它。
|
|
adder: Adder;
|
|
// Remote projects share the same tree and keyboard navigation as local ones.
|
|
children?: ReactNode;
|
|
hasOtherRows?: (needle: string, hideLegacy: boolean) => boolean;
|
|
}
|
|
|
|
// A conversation a pane holds before its first turn is written has only a file
|
|
// name; calling it that would show a timestamp where every other row shows words.
|
|
const rowLabel = (session: TreeSession) =>
|
|
session.title || (session.runtimeId && !session.turns ? t("common.new_session") : session.name);
|
|
|
|
function WorkspacesView({ hub, tree, treeRead, answered = null, runtimes, active, folded, reload, retry, paging = NO_PAGING, previewLimit = Infinity, onFold, onToggleAll, onRecent, allFolded, canFold, opening = "", onOpen, onFocus, onClose, liveIds, listedIds = liveIds, runs, scope = "all", pinned = new Set(), onPin = () => {}, onPause = () => {}, onBranch, onArchive = async () => {}, onRename, onError, adder, children, hasOtherRows }: Props) {
|
|
const [busy, setBusy] = useState("");
|
|
const [confirm, setConfirm] = useState("");
|
|
// The project whose folder an open just found gone: the question is asked about it, not about a removal chosen from the menu.
|
|
const [gone, setGone] = useState("");
|
|
const failIn = (ws: TreeWorkspace, e: unknown) => {
|
|
if (e instanceof HttpError && e.reason?.code === "workspace.folder_missing") { setGone(ws.root); setConfirm(ws.root); } else onError(e);
|
|
};
|
|
const [selection, setSelection] = useState<Set<string> | null>(null);
|
|
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
|
// The row a Delete key asked about. The question replaces that row, so focus
|
|
// goes back to it once the question is gone, or a keyboard is left nowhere.
|
|
const askedByKey = useRef("");
|
|
const needle = useRailQuery();
|
|
useEffect(() => { setSelection((current) => current === null ? null : new Set()); }, [needle, scope]);
|
|
const toggleSelection = (path: string) => setSelection((current) => {
|
|
if (!current) return current;
|
|
const next = new Set(current);
|
|
if (next.has(path)) next.delete(path); else next.add(path);
|
|
return next;
|
|
});
|
|
const treeKeys = useTreeKeys();
|
|
const mutations = useSessionMutation(onError);
|
|
const [editing, setEditing] = useState("");
|
|
const hideLegacy = useSyncExternalStore(onHidesLegacySessionsChange, hidesLegacySessions, hidesLegacySessions);
|
|
const quickActions = useSyncExternalStore(onSessionQuickActionsChange, showsSessionQuickActions, showsSessionQuickActions);
|
|
const [sessionMenu, setSessionMenu] = useState("");
|
|
const order = useSessionMove(hub, reload, onError, !!needle);
|
|
const moveTo = useSessionMoveTo(hub, tree, reload, rowLabel, () => adder.add());
|
|
const [sessionMenuAt, setSessionMenuAt] = useState({ x: 0, y: 0 });
|
|
const sessionMenuBox = useRef<HTMLDivElement>(null);
|
|
const sessionMenuPortal = useRef<HTMLDivElement>(null);
|
|
const menuTrigger = useRef<HTMLElement | null>(null);
|
|
const dismissMenu = () => {
|
|
if (menuTrigger.current?.dataset.target === sessionMenu) menuTrigger.current.focus();
|
|
setSessionMenu("");
|
|
};
|
|
const openRowMenu = (root: string, trigger: HTMLElement, at?: { x: number; y: number }) => {
|
|
menuTrigger.current = trigger;
|
|
const anchor = trigger.getBoundingClientRect();
|
|
setSessionMenuAt(at ?? { x: anchor.right + 8, y: anchor.top - 7 });
|
|
setSessionMenu(root);
|
|
};
|
|
useDismiss(!!sessionMenu, sessionMenuBox, dismissMenu, sessionMenuPortal);
|
|
useEffect(() => {
|
|
if (sessionMenu && menuTrigger.current?.dataset.target === sessionMenu) {
|
|
sessionMenuPortal.current?.querySelector<HTMLButtonElement>('button[role="menuitem"]:not(:disabled)')?.focus();
|
|
}
|
|
}, [sessionMenu]);
|
|
useLayoutEffect(() => {
|
|
if (sessionMenuPortal.current) pinToViewport(sessionMenuPortal.current, sessionMenuAt.x, sessionMenuAt.y, 12);
|
|
}, [sessionMenu, sessionMenuAt]);
|
|
// Finishing is a transition this run witnessed, not a state a row can hold: a
|
|
// persisted done replays nothing when the window opens or the scope changes.
|
|
const priorRun = useRef<Record<string, string>>({});
|
|
const [justDone, setJustDone] = useState<Set<string>>(new Set());
|
|
|
|
// A first sighting only records what the state already was, so a row that is
|
|
// done as it mounts is history and says so silently. Each timer clears only
|
|
// the ids it added, which is why nothing has to be cancelled when `runs`
|
|
// moves on: the animation has already been spent by then.
|
|
useEffect(() => {
|
|
const fresh = newlyDone(priorRun.current, runs);
|
|
if (fresh.length === 0) return;
|
|
setJustDone((prev) => new Set([...prev, ...fresh]));
|
|
window.setTimeout(() => {
|
|
setJustDone((prev) => {
|
|
const next = new Set(prev);
|
|
for (const id of fresh) next.delete(id);
|
|
return next;
|
|
});
|
|
}, 1200);
|
|
}, [runs]);
|
|
// Folders whose next page is being read, or could not be.
|
|
const [paged, setPaged] = useState<Record<string, "loading" | "failed">>({});
|
|
const more = async (root: string) => {
|
|
setPaged((prev) => ({ ...prev, [root]: "loading" }));
|
|
try {
|
|
await paging.more(root);
|
|
setPaged(({ [root]: _, ...rest }) => rest);
|
|
} catch {
|
|
setPaged((prev) => ({ ...prev, [root]: "failed" }));
|
|
}
|
|
};
|
|
const [spread, setSpread] = useState<Set<string>>(new Set());
|
|
// Two folders can share a name — a worktree copy carries the project's own.
|
|
// Only then is the extra word worth the room it takes.
|
|
const twice = new Set(tree.map((w) => w.name).filter((n, i, all) => all.indexOf(n) !== i));
|
|
|
|
// A row that is already open is a pane to focus, never a second runtime for
|
|
// one transcript — the kernel refuses that, and it is what forked a recovery
|
|
// branch on every save when two writers shared a file.
|
|
const pick = async (ws: TreeWorkspace, session: TreeSession) => {
|
|
// The tree and runtime list arrive from separate reads. During a close or
|
|
// restore the tree can briefly retain an id that no longer exists; focusing
|
|
// that stale id changes no pane and makes the row look unclickable.
|
|
const held = sessionRuntime(session, runtimes);
|
|
if (held) {
|
|
onFocus(held.id);
|
|
return;
|
|
}
|
|
try {
|
|
await onOpen({ root: session.workspaceRoot || ws.root, sessionPath: session.path });
|
|
} catch (e) {
|
|
failIn(ws, e);
|
|
}
|
|
};
|
|
|
|
const startSession = async (ws: TreeWorkspace) => {
|
|
setBusy("new:" + ws.root);
|
|
try {
|
|
await onOpen({ root: ws.root });
|
|
} catch (e) {
|
|
failIn(ws, e);
|
|
} finally {
|
|
setBusy("");
|
|
}
|
|
};
|
|
|
|
// Dragging reorders the whole remembered list, so a search that hides rows turns it off.
|
|
const drag = useWorkspaceDrag(needle ? [] : tree.filter((w) => w.remembered).map((w) => w.root), async (root, before) => {
|
|
try {
|
|
await hub.placeWorkspace(root, before);
|
|
await reload();
|
|
} catch (e) {
|
|
onError(e);
|
|
}
|
|
});
|
|
|
|
const panesOf = (root: string) => {
|
|
const workspace = tree.find((ws) => ws.root === root);
|
|
return runtimes.filter((rt) => !rt.host && (workspace ? workspaceContains(workspace, rt.root) : rt.root === root)).map((rt) => rt.id);
|
|
};
|
|
|
|
const dropWorkspace = async (ws: TreeWorkspace) => {
|
|
if (confirm !== ws.root) {
|
|
setGone("");
|
|
setConfirm(ws.root);
|
|
return;
|
|
}
|
|
setConfirm("");
|
|
setGone("");
|
|
try {
|
|
// Closed here for the same reason dropSession does it: the kernel will not
|
|
// pull a folder out from under a pane that is writing, and leaving that to
|
|
// the reader only means asking which of eight tabs belong to this one.
|
|
const open = panesOf(ws.root);
|
|
if (open.length && liveIds(open).length === 0) await onClose(open);
|
|
await hub.removeWorkspace(ws.root);
|
|
await reload();
|
|
} catch (e) {
|
|
onError(e);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
const path = askedByKey.current;
|
|
if (!path || confirm === path) return;
|
|
askedByKey.current = "";
|
|
// Another question took over: its own button holds focus now.
|
|
if (confirm) return;
|
|
const rows = treeKeys.ref.current?.querySelectorAll<HTMLElement>('[role="treeitem"]') ?? [];
|
|
for (const el of rows) if (el.dataset.target === path) el.focus();
|
|
}, [confirm, treeKeys.ref]);
|
|
|
|
const askDelete = (path: string) => {
|
|
askedByKey.current = path;
|
|
setConfirm(path);
|
|
};
|
|
|
|
const mutate = (session: TreeSession, kind: SessionMutation) => {
|
|
if (sessionMenu) dismissMenu();
|
|
const row = [...(treeKeys.ref.current?.querySelectorAll<HTMLElement>('[role="treeitem"]') ?? [])].find((el) => el.dataset.target === session.path) ?? null;
|
|
void mutations.run(session.path, kind, row, () => kind === "read" ? hub.markSessionRead(session.path).then(reload) : kind === "pin" ? onPin(session.path) : onArchive(session.path, !session.archived, session.runtimeId));
|
|
};
|
|
const archive = (session: TreeSession) => mutate(session, "archive");
|
|
|
|
const dropSession = async (session: TreeSession, withCopies = false) => {
|
|
if (confirm !== session.path) {
|
|
setConfirm(session.path);
|
|
return;
|
|
}
|
|
// The row is about to go, and focus with it: the next row takes it, or the
|
|
// one before when this was the last.
|
|
const rows = [...(treeKeys.ref.current?.querySelectorAll<HTMLElement>('[role="treeitem"]') ?? [])];
|
|
const asked = treeKeys.ref.current?.querySelector('[role="alertdialog"]');
|
|
const after = asked ? rows.findIndex((el) => asked.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING) : -1;
|
|
const heir = after >= 0 ? rows[after] : rows[rows.length - 1];
|
|
setConfirm("");
|
|
try {
|
|
// An idle pane is closed and waited on, since its runtime holds the
|
|
// transcript's lease until it is down. A running one is left for the
|
|
// kernel to refuse: a delete never stops work in progress.
|
|
if (session.runtimeId && liveIds([session.runtimeId]).length === 0) await onClose([session.runtimeId]);
|
|
// A plain delete keeps copies that hold content the conversation lacks; taking
|
|
// them along removes each first, so a refused copy leaves the conversation in place.
|
|
if (withCopies) for (const copy of session.copies ?? []) await hub.removeSession(copy.path);
|
|
await hub.removeSession(session.path);
|
|
clearDraftForSession("", session.path);
|
|
await reload();
|
|
if (document.activeElement && document.activeElement !== document.body) return;
|
|
(heir?.isConnected ? heir : treeKeys.ref.current?.querySelector<HTMLElement>('[role="treeitem"]'))?.focus();
|
|
} catch (e) {
|
|
onError(e);
|
|
}
|
|
};
|
|
|
|
const autoName = async (session: TreeSession) => {
|
|
setBusy("name:" + session.path);
|
|
try {
|
|
const { title } = await hub.autoNameSession(session.path);
|
|
await hub.renameSession(session.path, title);
|
|
await reload();
|
|
} catch (e) {
|
|
onError(e);
|
|
} finally {
|
|
setBusy("");
|
|
setSessionMenu("");
|
|
}
|
|
};
|
|
|
|
const saveSession = async (session: TreeSession, format: "json" | "md" = "json") => {
|
|
setBusy("export:" + session.path);
|
|
try {
|
|
await saveSessionFile(hub, session, format);
|
|
} catch (e) {
|
|
onError(e);
|
|
} finally {
|
|
setBusy("");
|
|
setSessionMenu("");
|
|
}
|
|
};
|
|
|
|
const hit = (ws: TreeWorkspace) => {
|
|
if (!needle) return ws;
|
|
const named = (x: TreeSession) => (x.title || x.name || "").toLowerCase().includes(needle);
|
|
if (ws.name.toLowerCase().includes(needle)) return ws;
|
|
const sessions = ws.sessions.filter(named);
|
|
return sessions.length || ws.countsComplete === false ? { ...ws, sessions } : null;
|
|
};
|
|
const searchedTree = needle ? (tree.map(hit).filter(Boolean) as TreeWorkspace[]) : tree;
|
|
const shownTree = searchedTree
|
|
.map((ws) => ({
|
|
...ws,
|
|
sessions: pinnedFirst(ws.sessions.filter((session) =>
|
|
inScope(session, scope, !!needle, !!session.runtimeId && listedIds([session.runtimeId]).length > 0, pinned.has(session.path), hideLegacy, !!session.runtimeId && runs[session.runtimeId]?.run === "halt")), pinned),
|
|
}))
|
|
.filter((ws) => scope === "all" || ws.sessions.length > 0 || ws.countsComplete === false);
|
|
// A fold is a resting-state preference: while a word is being typed it would
|
|
// hide the very rows that word just found.
|
|
const searching = !!needle;
|
|
const loadedRows = (root: string) => tree.find((x) => x.root === root)?.sessions.length ?? 0;
|
|
// What the kernel is asked to count: the archived rows when that is the view,
|
|
// otherwise the active ones, and everything while a word is being searched
|
|
// for, since a search is allowed to find an archived conversation.
|
|
const kernelScope = scope === "archived" ? "archived" : searching ? "" : "active";
|
|
const [asked, setAsked] = useState(needle);
|
|
useEffect(() => {
|
|
if (!needle) return setAsked("");
|
|
const wait = setTimeout(() => setAsked(needle), 250);
|
|
return () => clearTimeout(wait);
|
|
}, [needle]);
|
|
useEffect(() => {
|
|
paging.view({ scope: kernelScope, q: asked });
|
|
}, [paging, kernelScope, asked]);
|
|
const pagedScope = scope === "all" || scope === "archived";
|
|
const visibleSessions = (ws: TreeWorkspace) => needle || scope !== "all" || selection || expanded.has(ws.root)
|
|
? ws.sessions : ws.sessions.filter((session, index) => index < previewLimit || session.runtimeId === active || session.path === opening);
|
|
const displayed = shownTree.filter((ws) => needle || !folded.has(ws.root))
|
|
.flatMap(visibleSessions);
|
|
const selected = useSelectedRows(displayed, selection, setSelection);
|
|
const otherRows = hasOtherRows ? hasOtherRows(needle, hideLegacy) : !!children;
|
|
|
|
return (
|
|
<>
|
|
{moveTo.dialog}
|
|
<SessionBatch hub={hub} selection={selection} selected={selected} displayed={displayed} onSelect={setSelection}
|
|
onExit={() => treeKeys.ref.current?.parentElement?.previousElementSibling?.querySelector<HTMLElement>('[data-action="machine.menu"]')?.focus()}
|
|
onBusy={(pending) => { setBusy(pending ? "batch" : ""); if (pending) { setSessionMenu(""); setEditing(""); setConfirm(""); } }}
|
|
onClose={onClose} onArchive={onArchive} liveIds={liveIds} reload={reload} onError={onError} />
|
|
<ProjectsHeader adder={adder} onRecent={onRecent} onToggleAll={onToggleAll} allFolded={needle ? false : allFolded} canFold={canFold} searching={!!needle} />
|
|
<div className="scroll">
|
|
<div role="tree" aria-label={t("session.projects_and_conversations")} aria-multiselectable={selection !== null} inert={busy === "batch"} data-action-keydown="tree.navigate" ref={treeKeys.ref} onKeyDown={treeKeys.onKeyDown}>
|
|
{shownTree.map((ws) => {
|
|
// A fold is a resting-state preference; while a query is on it would hide
|
|
// the very rows the query just found.
|
|
const hasChildren = ws.sessions.length > 0 || ws.countsComplete === false || ws.listingState === "degraded" || (pagedScope && unloaded(ws, loadedRows(ws.root)) > 0);
|
|
const visible = visibleSessions(ws);
|
|
const hidden = ws.sessions.length - visible.length;
|
|
const shut = hasChildren && !needle && folded.has(ws.root);
|
|
// Only while the question is on screen: panesOf walks every runtime.
|
|
const doomed = confirm === ws.root ? panesOf(ws.root) : [];
|
|
const busyPanes = liveIds(doomed).length;
|
|
return (
|
|
<div className="wsnode" key={ws.root} data-current={panesOf(ws.root).includes(active) ? "" : undefined} data-missing={ws.missing ? "" : undefined}>
|
|
{confirm === ws.root ? (
|
|
<Confirm
|
|
what={gone === ws.root ? t("session.folder_gone_remove_from_list", { name: ws.name }) : t("session.remove_name_from_the_list", { name: ws.name })}
|
|
hint={removeHint(doomed.length, busyPanes)}
|
|
go={t("common.remove")}
|
|
danger={busyPanes > 0}
|
|
onGo={() => void dropWorkspace(ws)}
|
|
onCancel={() => { setConfirm(""); setGone(""); }}
|
|
/>
|
|
) : (
|
|
<div
|
|
ref={sessionMenu === ws.root ? sessionMenuBox : undefined}
|
|
data-action-contextmenu="workspace.menu"
|
|
data-action-keydown="workspace.menu"
|
|
data-action-dragstart="workspace.reorder"
|
|
data-action-dragover="workspace.reorder"
|
|
data-action-dragleave="workspace.reorder"
|
|
data-action-drop="workspace.reorder"
|
|
data-action-dragend="workspace.reorder"
|
|
data-target={ws.root}
|
|
data-drop={drag.over?.root === ws.root ? drag.over.edge : undefined}
|
|
data-dragging={drag.dragging === ws.root ? "" : undefined}
|
|
draggable={ws.remembered && !needle}
|
|
onDragStart={(ev) => drag.start(ev, ws.root)}
|
|
onDragOver={(ev) => drag.hover(ev, ws.root)}
|
|
onDragLeave={() => drag.leave(ws.root)}
|
|
onDrop={(ev) => drag.drop(ev, ws.root)}
|
|
onDragEnd={drag.end}
|
|
className="wsrow ds-proj"
|
|
role="treeitem"
|
|
aria-level={1}
|
|
aria-haspopup="menu"
|
|
aria-expanded={hasChildren ? !shut : undefined}
|
|
onClick={() => { if (hasChildren) onFold(ws.root, !shut); }}
|
|
onContextMenu={(ev) => {
|
|
if ((ev.target as HTMLElement).closest("input, textarea, [role='menu']")) return;
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
openRowMenu(ws.root, ev.currentTarget, ev.clientX || ev.clientY ? { x: ev.clientX, y: ev.clientY } : undefined);
|
|
}}
|
|
onKeyDown={(ev) => {
|
|
if (ev.target !== ev.currentTarget) return;
|
|
if (ev.key !== "ContextMenu" && !(ev.shiftKey && ev.key === "F10")) return;
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
openRowMenu(ws.root, ev.currentTarget);
|
|
}}
|
|
>
|
|
<WsMark open={hasChildren ? !shut : undefined} />
|
|
<span className="wsname ds-title" title={ws.root}>
|
|
{ws.name}
|
|
</span>
|
|
{/* A tag is this project's identity, so it takes a second line and the name keeps the row. */}
|
|
{(ws.isolated || twice.has(ws.name)) && (
|
|
<span className="wsmeta">
|
|
<em className="wstag">{ws.isolated ? t("common.isolated") : parentOf(ws.root)}</em>
|
|
</span>
|
|
)}
|
|
<span className="wsacts">
|
|
{shut && <UnreadCount n={ws.sessions.filter((x) => x.unread).length} />}
|
|
<span className="rowswap">
|
|
<span className="rowbtns">
|
|
<button
|
|
data-action="session.new"
|
|
className="wsadd"
|
|
data-busy={busy === "new:" + ws.root ? "" : undefined}
|
|
disabled={ws.missing}
|
|
title={t("common.start_a_new_session_in", { name: ws.name })}
|
|
aria-label={t("common.start_a_new_session_in", { name: ws.name })}
|
|
onClick={(ev) => {
|
|
ev.stopPropagation();
|
|
void startSession(ws);
|
|
}}
|
|
>
|
|
<StudioIcon name="chat" />
|
|
</button>
|
|
<button
|
|
className="wsmore"
|
|
data-action="workspace.menu"
|
|
data-target={ws.root}
|
|
title={t("common.more_actions")}
|
|
aria-label={t("common.project_actions_name", { name: ws.name })}
|
|
aria-expanded={sessionMenu === ws.root}
|
|
aria-haspopup="menu"
|
|
onClick={(ev) => {
|
|
ev.stopPropagation();
|
|
if (sessionMenu === ws.root) setSessionMenu("");
|
|
else openRowMenu(ws.root, ev.currentTarget);
|
|
}}
|
|
>
|
|
<StudioIcon name="more" />
|
|
</button>
|
|
</span>
|
|
</span>
|
|
</span>
|
|
{sessionMenu === ws.root && createPortal(
|
|
<div ref={sessionMenuPortal} className="session-pop" role="menu" aria-label={t("common.project_actions")} data-action-keydown="workspace.menu" data-target={ws.root} onKeyDown={workspaceMenuKeys} onClick={(ev) => ev.stopPropagation()}>
|
|
<div className="session-pop-head">
|
|
<b>{ws.name}</b>
|
|
<small className="session-pop-path" title={ws.root}>{ws.root}</small>
|
|
</div>
|
|
<WorkspaceReveal ws={ws} hub={hub} dismiss={dismissMenu} onError={onError} />
|
|
{ws.remembered && <WorkspaceOrder root={ws.root} position={tree.filter((w) => w.remembered).findIndex((w) => w.root === ws.root)} total={tree.filter((w) => w.remembered).length}
|
|
hub={hub} reload={reload} dismiss={dismissMenu} onError={onError} />}
|
|
<ProjectMenuExtras canSelect={displayed.length > 0} selecting={selection !== null} onSelect={() => setSelection(new Set())} dismiss={dismissMenu} />
|
|
<div className="session-pop-group">
|
|
<button className="danger" role="menuitem" data-action="workspace.remove" onClick={() => { setConfirm(ws.root); dismissMenu(); }}>
|
|
<StudioIcon name="close" /><span>{t("common.remove_from_the_list")}</span><small>{t("common.keeps_files")}</small>
|
|
</button>
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 子项自己成一块,才能拿到那条层级引导线。缩进单独说不清楚层级:
|
|
两级差 18px,扫一眼只读得出“有点错位”。 */}
|
|
{!shut && hasChildren && (
|
|
<div className="kids">
|
|
{visible.map((session) => {
|
|
const on = opening ? session.path === opening : session.runtimeId === active;
|
|
const run = session.runtimeId ? runs[session.runtimeId]?.run : undefined;
|
|
if (confirm === session.path) {
|
|
return (
|
|
<Confirm
|
|
key={session.path}
|
|
what={t("common.delete_name", { name: rowLabel(session) })}
|
|
hint={sessionDeleteHint(session.copies?.length ?? 0, !!session.runtimeId, !!session.runtimeId && liveIds([session.runtimeId]).length > 0)}
|
|
go={t("common.delete")}
|
|
danger
|
|
alt={(session.copies?.length ?? 0) > 0 ? { label: t("session.delete_with_n_copies", { n: session.copies!.length }), onGo: () => void dropSession(session, true) } : undefined}
|
|
onGo={() => void dropSession(session)}
|
|
onCancel={() => setConfirm("")}
|
|
/>
|
|
);
|
|
}
|
|
const versions = session.versions ?? [];
|
|
const copies = session.copies ?? [];
|
|
const kept = [...versions.map((s) => ({ s, version: true })), ...copies.map((s) => ({ s, version: false }))];
|
|
const open = spread.has(session.path);
|
|
return (
|
|
<Fragment key={session.path}>
|
|
<div
|
|
ref={sessionMenu === session.path ? sessionMenuBox : undefined}
|
|
data-action-click={selection ? "session.selection" : "session.open"}
|
|
data-action-contextmenu="session.menu"
|
|
aria-haspopup="menu"
|
|
data-action-keydown={selection ? ["session.selection"] : ["session.open", "session.delete", "session.menu", "session.move"]}
|
|
data-target={session.path}
|
|
className="sessrow sessrow-context ds-row"
|
|
role="treeitem"
|
|
aria-level={2}
|
|
aria-label={rowLabel(session)}
|
|
aria-selected={selection ? selection.has(session.path) : on}
|
|
data-on={on ? "" : undefined}
|
|
data-live={session.runtimeId ? "" : undefined}
|
|
data-run={run === "idle" ? undefined : run}
|
|
data-unread={session.unread ? "" : undefined}
|
|
data-just-done={session.runtimeId && justDone.has(session.runtimeId) ? "" : undefined}
|
|
data-busy={opening === session.path || mutations.pending.has(session.path) ? "" : undefined}
|
|
onClick={(ev) => { if (selection) toggleSelection(session.path); else if (ev.detail < 2 && mutations.pending.get(session.path) !== "archive") void pick(ws, session); }}
|
|
data-action-dblclick={selection ? undefined : "session.rename"}
|
|
onDoubleClick={(ev) => {
|
|
if (selection || mutations.pending.has(session.path) || (ev.target as HTMLElement).closest("input, button, [role='menu']")) return;
|
|
setEditing(session.path);
|
|
}}
|
|
onContextMenu={(ev) => {
|
|
if ((ev.target as HTMLElement).closest("input, textarea, [role='menu']")) return;
|
|
if (selection || mutations.pending.has(session.path)) { ev.preventDefault(); return; }
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
openRowMenu(session.path, ev.currentTarget, { x: ev.clientX, y: ev.clientY });
|
|
}}
|
|
tabIndex={0}
|
|
onKeyDown={(ev) => {
|
|
if (ev.target !== ev.currentTarget) return;
|
|
if (mutations.pending.has(session.path)) return;
|
|
if (editing === session.path) return;
|
|
if (selection) {
|
|
if (ev.key === "Enter" || ev.key === " ") { ev.preventDefault(); ev.stopPropagation(); toggleSelection(session.path); }
|
|
return;
|
|
}
|
|
if (ev.key === "ContextMenu" || ev.shiftKey && ev.key === "F10") {
|
|
ev.preventDefault(); ev.stopPropagation(); openRowMenu(session.path, ev.currentTarget);
|
|
} else if (ev.altKey && (ev.key === "ArrowUp" || ev.key === "ArrowDown")) {
|
|
ev.preventDefault(); ev.stopPropagation();
|
|
const direction = ev.key === "ArrowUp" ? -1 : 1;
|
|
if (direction === -1 ? order.canMove(ws, session).up : order.canMove(ws, session).down) void order.move(session, direction);
|
|
} else if (ev.key === "Enter" || ev.key === " ") {
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
void pick(ws, session);
|
|
} else if (asksDelete(ev)) {
|
|
ev.preventDefault();
|
|
askDelete(session.path);
|
|
}
|
|
}}
|
|
>
|
|
{selection && <input type="checkbox" className="session-select" tabIndex={-1} aria-label={t("session.select_session_name", { name: rowLabel(session) })}
|
|
data-action-click="session.selection" data-action-change="session.selection" data-target={session.path} checked={selection.has(session.path)}
|
|
onClick={(ev) => ev.stopPropagation()} onChange={() => toggleSelection(session.path)} />}
|
|
<LeadDot unread={session.unread && run !== "running" && run !== "halt"} />
|
|
{editing === session.path ? (
|
|
<input className="sessedit" aria-label={t("common.rename_this_session")} placeholder={t("common.chat_title")} autoFocus
|
|
defaultValue={session.title || session.name}
|
|
onClick={(ev) => ev.stopPropagation()}
|
|
data-action-blur="session.rename" data-action-keydown="session.rename" data-target={session.path}
|
|
onBlur={(ev) => {
|
|
const next = ev.currentTarget.value.trim();
|
|
setEditing("");
|
|
if (next !== "" && next !== (session.title || session.name).trim()) onRename(session.path, next, "inline");
|
|
}}
|
|
onKeyDown={(ev) => {
|
|
if (ev.key === "Enter") {
|
|
ev.preventDefault();
|
|
ev.currentTarget.blur();
|
|
} else if (ev.key === "Escape") {
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
ev.currentTarget.value = session.title || session.name;
|
|
ev.currentTarget.blur();
|
|
}
|
|
}} />
|
|
) : (
|
|
<span className="sesstitle ds-title" title={rowLabel(session)}>
|
|
<span><Hit text={rowLabel(session)} needle={needle} /></span>{session.isolated && <em className="sessarch" title={session.workspaceRoot}>{t("common.isolated")}</em>}{session.archived && <em className="sessarch">{t("common.archived")}</em>}
|
|
{session.metadataState === "pending" && <em className="sessarch">{t("session.reading_summary")}</em>}
|
|
{session.metadataState === "error" && <em className="sessarch">{t("session.summary_unavailable")}</em>}
|
|
</span>
|
|
)}
|
|
{!selection && <SessionPinAction target={session.path} title={rowLabel(session)}
|
|
pinned={pinned.has(session.path)} enabled={quickActions} disabled={mutations.pending.has(session.path)} onPin={() => mutate(session, "pin")} />}
|
|
<PeerThreadMark path={session.path} />
|
|
<SessionTail run={run} at={session.lastActiveAt} unread={session.unread} dot={false} />
|
|
{!selection && <SessionMoreAction target={session.path} disabled={mutations.pending.has(session.path)}
|
|
onOpen={(trigger) => openRowMenu(session.path, trigger)} />}
|
|
{quickActions && !selection && <SessionArchiveAction target={session.path} title={rowLabel(session)} archived={!!session.archived}
|
|
disabled={mutations.pending.has(session.path) || !!session.runtimeId && liveIds([session.runtimeId]).length > 0} onArchive={() => archive(session)} />}
|
|
{sessionMenu === session.path && (
|
|
<SessionMenu
|
|
ref={sessionMenuPortal}
|
|
session={session}
|
|
label={rowLabel(session)}
|
|
live={!!session.runtimeId && liveIds([session.runtimeId]).length > 0}
|
|
pinned={pinned.has(session.path)}
|
|
exporting={busy === "export:" + session.path}
|
|
autoNaming={busy === "name:" + session.path}
|
|
onPin={() => mutate(session, "pin")}
|
|
onMarkRead={session.unread ? () => mutate(session, "read") : undefined}
|
|
onRename={() => { setSessionMenu(""); onRename(session.path, session.title || session.name); }}
|
|
onAutoName={() => void autoName(session)}
|
|
onPause={() => { onPause(session.runtimeId!); setSessionMenu(""); }}
|
|
onClose={() => {
|
|
const id = session.runtimeId!;
|
|
setSessionMenu("");
|
|
void onClose([id]).catch(onError);
|
|
}}
|
|
onArchive={() => archive(session)}
|
|
onBranch={onBranch && session.runtimeId ? () => { setSessionMenu(""); void onBranch(session.runtimeId!, `${rowLabel(session)} · ${t("common.branch")}`).catch(onError); } : undefined}
|
|
onCopyInfo={() => { void copyText(sessionInfoText(session, session.workspaceRoot || ws.root)).catch(onError); setSessionMenu(""); }}
|
|
onExport={(format) => void saveSession(session, format)}
|
|
keptCount={kept.length} keptOpen={open} onKept={() => { setSpread((prev) => { const next = new Set(prev); if (open) next.delete(session.path); else next.add(session.path); return next; }); setSessionMenu(""); }}
|
|
onDelete={() => { askDelete(session.path); setSessionMenu(""); }}
|
|
onMove={(direction) => { setSessionMenu(""); void order.move(session, direction); }}
|
|
canMove={order.canMove(ws, session)}
|
|
onMoveTo={() => { setSessionMenu(""); moveTo.request(session, ws.root); }}
|
|
/>
|
|
)}
|
|
</div>
|
|
{open &&
|
|
kept.map(({ s: copy, version }) =>
|
|
confirm === copy.path ? (
|
|
<Confirm
|
|
key={copy.path}
|
|
what={version ? t("session.delete_this_earlier_version") : t("session.delete_this_recovery_copy")}
|
|
hint={t("common.deletes_it_together_with_its")}
|
|
go={t("common.delete")}
|
|
danger
|
|
onGo={() => void dropSession(copy)}
|
|
onCancel={() => setConfirm("")}
|
|
/>
|
|
) : (
|
|
<div
|
|
data-action-click="session.open"
|
|
data-action-keydown={["session.open", "session.delete"]}
|
|
data-target={copy.path}
|
|
key={copy.path}
|
|
className="sessrow sesscopy ds-row"
|
|
role="treeitem"
|
|
aria-level={3}
|
|
aria-selected={opening === copy.path}
|
|
data-on={opening === copy.path ? "" : undefined}
|
|
data-busy={opening === copy.path ? "" : undefined}
|
|
onClick={() => void pick(ws, copy)}
|
|
tabIndex={0}
|
|
onKeyDown={(ev) => {
|
|
if (ev.target !== ev.currentTarget) return;
|
|
if (ev.key === "Enter" || ev.key === " ") {
|
|
ev.preventDefault();
|
|
void pick(ws, copy);
|
|
} else if (asksDelete(ev)) {
|
|
ev.preventDefault();
|
|
askDelete(copy.path);
|
|
}
|
|
}}
|
|
>
|
|
<span className="ds-lead" />
|
|
<span className="sesstitle ds-title" title={rowLabel(copy)}>
|
|
<span>{version ? t("session.earlier_version_n_turns", { n: copy.turns ?? 0 }) : t("session.recovery")}</span>
|
|
</span>
|
|
<button
|
|
className="wsdel"
|
|
data-action="session.delete"
|
|
data-target={copy.path}
|
|
title={t("session.delete_this_session")}
|
|
aria-label={t("session.delete_this_session")}
|
|
onClick={(ev) => {
|
|
ev.stopPropagation();
|
|
setConfirm(copy.path);
|
|
}}
|
|
>
|
|
<Cross />
|
|
</button>
|
|
</div>
|
|
),
|
|
)}
|
|
</Fragment>
|
|
);
|
|
})}
|
|
|
|
<ListingProgress workspace={ws} searching={searching} onRetry={retry} />
|
|
{pagedScope && (hidden > 0 || unloaded(ws, loadedRows(ws.root)) > 0) && (
|
|
hidden === 0 && atCap(loadedRows(ws.root)) ? (
|
|
<small className="sessnote">{ws.countsComplete === false ? t("session.search_to_find_more_conversations") : t("session.n_older_conversations_use_search", { n: unloaded(ws, loadedRows(ws.root)) })}</small>
|
|
) : (
|
|
<button
|
|
className="sessmore"
|
|
disabled={paged[ws.root] === "loading"}
|
|
data-action="session.more"
|
|
onClick={() => {
|
|
setExpanded((current) => new Set(current).add(ws.root));
|
|
if (unloaded(ws, loadedRows(ws.root)) > 0 && !atCap(loadedRows(ws.root))) void more(ws.root);
|
|
}}
|
|
>
|
|
{paged[ws.root] === "loading"
|
|
? t("session.loading")
|
|
: paged[ws.root] === "failed"
|
|
? t("common.failed_to_load_retry")
|
|
: ws.countsComplete === false ? t("session.show_more") : t("common.show_more_n_left", { n: hidden + unloaded(ws, loadedRows(ws.root)) })}
|
|
</button>
|
|
)
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
{!otherRows && shownTree.length === 0 && (tree.length > 0 || treeRead && (!!needle || scope !== "all")) && <TreeEmpty settled={tree.every((ws) => ws.countsComplete !== false) && answers(answered, { scope: kernelScope, q: asked })} onRetry={retry} />}
|
|
{tree.length === 0 && (!treeRead || !otherRows && !needle && scope === "all") && (treeRead ? (
|
|
<div className="ws-empty">{t("session.no_folders_yet")}<button className="projects-open" data-action="workspace.add"
|
|
disabled={adder.busy} onClick={() => adder.add()}><StudioIcon name="folder" />{t("session.open_your_first_project")}</button></div>
|
|
) : (
|
|
<TreeReading onRetry={retry} />
|
|
))}
|
|
{children}
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// Panes report upward on every usage round, so the window repaints often; the
|
|
// tree it holds does not change nearly that often.
|
|
export const Workspaces = memo(WorkspacesView);
|
|
|
|
// Which runs reached "done" since `before` last recorded them. A state is not
|
|
// an event: `done` persists, so only a change no first sighting can produce —
|
|
// both sides known and different — is the completion this window watched.
|
|
export function newlyDone(
|
|
before: Record<string, string>,
|
|
runs: Record<string, { run: string; live: boolean }>,
|
|
): string[] {
|
|
const fresh: string[] = [];
|
|
for (const [id, st] of Object.entries(runs)) {
|
|
if (st.run === "done" && before[id] !== undefined && before[id] !== "done") fresh.push(id);
|
|
before[id] = st.run;
|
|
}
|
|
return fresh;
|
|
}
|