源码
packages/desktop/src/renderer/App.tsx
import * as Dialog from "@radix-ui/react-dialog";
import * as Menu from "@radix-ui/react-dropdown-menu";
import {
ActionHint,
ChatComposer,
Conversation,
type FileAction,
type FileLocation,
type FileOpenOrigin,
Notice,
type SubagentTask,
TaskFindBar,
type WebOpenOptions,
} from "@zpi-dev/ui";
import {
Ellipsis,
FileDiff,
Folder,
FolderOpen,
Globe,
ListChevronsDownUp,
ListChevronsUpDown,
MessageCirclePlus,
Moon,
Plus,
Settings,
Sun,
X,
} from "lucide-react";
import { type CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import type { EditUserInput, InterfacePreferences, SessionRecord } from "../shared/bridge.ts";
import { sidebarLimits, taskPinLimit } from "../shared/config.ts";
import { defaultShortcuts, formatShortcut, type ShortcutCommand } from "../shared/shortcuts.ts";
import { taskGroupFor } from "../shared/task-groups.ts";
import type { TaskSearchSnippet } from "../shared/task-search.ts";
import {
captureWorkspaceShortcut,
pinnedTaskShortcut,
visiblePinnedTasks,
workspaceShortcutBlocked,
} from "./app-shortcuts.ts";
import { selectAppState, selectTaskRecords } from "./app-state.ts";
import { inputContext } from "./composer-context.ts";
import { composerShortcutCommand } from "./composer-shortcuts.ts";
import { DirectoryMenuItems } from "./DirectoryMenuItems.tsx";
import { DraftProjectPicker } from "./DraftProjectPicker.tsx";
import { ModelPerformance } from "./ModelPerformance.tsx";
import { PaneFullViewControls } from "./PaneFullViewControls.tsx";
import {
canOpenSideChat,
listenPanes,
openBrowser,
openReview,
openSideChat,
openSubagent,
openTerminal,
openWebLink,
paneTask,
setPaneOpen,
openChanges as showChanges,
openFile as showFile,
togglePaneFullView,
usePane,
useSideChatBlocked,
} from "./pane-store.ts";
import { previewServices } from "./preview-services.ts";
import { RightPane } from "./RightPane.tsx";
import { SessionToolbar } from "./SessionToolbar.tsx";
import { SettingsPage } from "./SettingsPage.tsx";
import { SidebarSections } from "./SidebarSections.tsx";
import { SidebarTaskEmpty, SidebarTaskList } from "./SidebarTaskList.tsx";
import { SidebarTaskRow } from "./SidebarTaskRow.tsx";
import { SortableProjects } from "./SortableProjects.tsx";
import {
compareTasks,
readTaskPreferences,
retainProjectLimits,
taskPage,
taskPreferencesKey,
} from "./sidebar-task-model.ts";
import {
addConversationSelection,
archiveSession,
changeTaskGroups,
drafts,
focusComposer,
historyRevision,
initialize,
loadAllEarlier,
loadEarlier,
markSessionRead,
newSession,
queueActions,
refresh,
refreshSuggestions,
report,
resetSession,
selectSession,
subscribeEvents,
useStore,
} from "./store.ts";
import { TaskActivity } from "./TaskActivity.tsx";
import { TaskGroups, TaskGroupToolbar, useTaskGroupExpansion } from "./TaskGroups.tsx";
import { TaskListHeader } from "./TaskListHeader.tsx";
import { TaskMenu } from "./TaskMenu.tsx";
import { type SearchCommand, TaskSearchDialog } from "./TaskSearchDialog.tsx";
import { expandTaskGroup } from "./task-group-expansion.ts";
import { playTaskNotificationSound } from "./task-notification-sound.ts";
import { PanelLeftClose, PanelLeftOpen, SquareTerminal } from "./task-search-icons.ts";
import { UserQuestionCard } from "./UserQuestionCard.tsx";
import { unwrap } from "./unwrap.ts";
import { useDialogDismiss } from "./use-dialog-dismiss.ts";
import { useStopOnEscape } from "./use-stop-on-escape.ts";
import { useTaskFind } from "./use-task-find.ts";
import { useWorkspace } from "./use-workspace.ts";
import { userErrorMessage } from "./user-error.ts";
import { WindowChrome } from "./WindowChrome.tsx";
import { WorkspaceEditorButton } from "./WorkspaceEditorButton.tsx";
import { WorktreeSettings } from "./WorktreeSettings.tsx";
interface ActionDialog {
title: string;
value?: string;
onConfirm: (value: string) => Promise<void>;
}
function ActionModal({ action, onClose: onClosed }: { action: ActionDialog; onClose: () => void }) {
const [open, onClose] = useDialogDismiss(onClosed);
const opener = useRef(
document.getElementById(
document.activeElement?.closest('[role="menu"]')?.getAttribute("aria-labelledby") ?? "",
) ?? document.activeElement,
);
const [value, setValue] = useState(action.value ?? "");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const confirming = useRef(false);
const compositionActive = useRef(false);
return (
<Dialog.Root
open={open}
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<Dialog.Portal>
<Dialog.Overlay className="modal-backdrop" />
<Dialog.Content
className="modal task-rename-dialog"
aria-label={action.title}
aria-describedby={undefined}
onCloseAutoFocus={(event) => {
event.preventDefault();
if (opener.current instanceof HTMLElement && opener.current.isConnected) opener.current.focus();
}}
>
<form
onKeyDown={(event) => {
if (
event.key === "Enter" &&
(compositionActive.current || event.nativeEvent.isComposing || event.keyCode === 229)
)
event.preventDefault();
}}
onSubmit={(event) => {
event.preventDefault();
if (confirming.current) return;
confirming.current = true;
setBusy(true);
void action.onConfirm(value).then(onClose, (e) => {
setError(userErrorMessage(e));
confirming.current = false;
setBusy(false);
});
}}
>
<Dialog.Title asChild>
<h2>{action.title}</h2>
</Dialog.Title>
<input
aria-label="名称"
placeholder="任务名称"
value={value}
onChange={(e) => setValue(e.target.value)}
onCompositionStart={() => {
compositionActive.current = true;
}}
onCompositionEnd={() => {
compositionActive.current = false;
}}
/>
{error && <Notice scope="input" message={error} />}
<div className="modal-actions">
<button type="button" className="secondary" onClick={onClose}>
取消
</button>
<button type="submit" className="primary" disabled={busy}>
确认
</button>
</div>
</form>
<Dialog.Close className="task-rename-close" aria-label="关闭">
<X aria-hidden="true" />
</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
function dismissError(): void {
useStore.setState({ error: undefined });
}
function openLink(url: string, options?: WebOpenOptions): void {
paneTask(openWebLink(url, options));
}
function showSubagent(task: SubagentTask) {
paneTask(openSubagent(task));
}
export function App() {
const state = useStore(useShallow(selectAppState));
const records = useStore(useShallow(selectTaskRecords));
const fullView = usePane((state) => state.fullView);
const floatingComposer = fullView?.surface === "side-pane";
const paneOpen = usePane((s) => s.open),
paneRatio = usePane((s) => s.ratio);
const sideChatBlocked = useSideChatBlocked(state.selected);
const [searchOpen, setSearchOpen] = useState(false);
const [pendingSearch, setPendingSearch] = useState<{
id: string;
query: string;
snippet?: TaskSearchSnippet;
}>();
const [settingsOpen, setSettingsOpen] = useState(false);
const [taskMenuOpen, setTaskMenuOpen] = useState(false);
const [modelOpen, setModelOpen] = useState(false);
const [environmentOpen, setEnvironmentOpen] = useState(false);
useEffect(() => {
setEnvironmentOpen(false);
setTaskMenuOpen(false);
}, [state.selected]);
const [action, setAction] = useState<ActionDialog>();
const [viewport, setViewport] = useState(window.innerWidth);
const [dragWidth, setDragWidth] = useState<number>();
const resizing = useRef(false);
const oldSelect = useRef("");
const prefs = state.settings?.interface;
const shortcuts = state.settings?.shortcuts ?? defaultShortcuts(window.zpi.platform);
const [shortcutModifierHeld, setShortcutModifierHeld] = useState(false);
useEffect(() => window.zpi.onShortcutModifier(setShortcutModifierHeld), []);
const shortcutHint = (command: ShortcutCommand) =>
shortcuts.enabled && shortcuts.bindings[command]
? formatShortcut(shortcuts.bindings[command], window.zpi.platform)
: "";
const collapsed = prefs?.sidebarCollapsed ?? false;
const [taskPreferences, setTaskPreferences] = useState(() => {
try {
return readTaskPreferences(localStorage.getItem(taskPreferencesKey));
} catch {
return readTaskPreferences(null);
}
});
const { collapsed: collapsedGroups, setCollapsed: setCollapsedGroups } = useTaskGroupExpansion(
state.taskGroups.groups.map((group) => group.id),
state.taskGroupsHydrated,
);
const [newGroupId, setNewGroupId] = useState<string>();
const newTask = useCallback(async () => {
const id = await newSession();
if (!id || taskPreferences.organizeBy !== "grouped") return;
const current = useStore.getState();
const draft = current.sessions.get(id);
if (current.selected !== id || !draft?.draft || draft.parentSessionId) return;
// 普通新建也要显露组内草稿;仅响应新建操作,不覆盖用户在运行期间的手动折叠。
const groupId = taskGroupFor(current.taskGroups, draft.id);
setCollapsedGroups((collapsed) => expandTaskGroup(collapsed, groupId));
}, [taskPreferences.organizeBy, setCollapsedGroups]);
const [projectLimits, setProjectLimits] = useState<Record<string, number>>({});
useEffect(() => {
try {
localStorage.setItem(taskPreferencesKey, JSON.stringify(taskPreferences));
} catch {
/* Optional local preferences. */
}
}, [taskPreferences]);
useEffect(() => {
const visible = new Set(
taskPreferences.organizeBy === "project" && !prefs?.projectsCollapsed
? state.projects.filter((p) => !prefs?.collapsedProjectIds.includes(p.id)).map((p) => p.id)
: [],
);
setProjectLimits((current) => retainProjectLimits(current, visible));
}, [state.projects, prefs?.projectsCollapsed, prefs?.collapsedProjectIds, taskPreferences.organizeBy]);
const boundedWidth = (width: number) =>
Math.max(
sidebarLimits.min,
Math.min(sidebarLimits.max, viewport - sidebarLimits.chatMin - sidebarLimits.resizer, width),
);
const width = collapsed
? sidebarLimits.collapsed
: boundedWidth(dragWidth ?? prefs?.sidebarWidth ?? sidebarLimits.default);
const updatePrefs = (input: Partial<InterfacePreferences>) => {
void window.zpi
.updatePreferences(input)
.then(unwrap)
.then((settings) => useStore.setState({ settings }))
.catch(report);
};
useEffect(() => {
const resize = () => setViewport(window.innerWidth);
window.addEventListener("resize", resize);
return () => {
window.removeEventListener("resize", resize);
if (resizing.current) document.body.style.userSelect = oldSelect.current;
};
}, []);
useEffect(() => {
const refreshCommands = () => {
const state = useStore.getState();
for (const id of state.suggestions.keys()) {
if (state.sessions.has(id))
void refreshSuggestions(id, () => useStore.getState().sessions.has(id)).catch(report);
}
};
window.addEventListener("zpi:commands-changed", refreshCommands);
window.addEventListener("focus", refreshCommands);
return () => {
window.removeEventListener("zpi:commands-changed", refreshCommands);
window.removeEventListener("focus", refreshCommands);
};
}, []);
useEffect(() => {
const unsubscribe = subscribeEvents();
const offPanes = listenPanes();
const offNotificationClick = window.zpi.onTaskNotificationClick((id) => {
void refresh()
.then(async () => {
if (!useStore.getState().sessions.has(id)) return;
setSettingsOpen(false);
setModelOpen(false);
setAction(undefined);
await selectSession(id);
})
.catch(report);
});
const offNotificationSound = window.zpi.onTaskNotificationSound(() => {
void playTaskNotificationSound();
});
const offSettings = window.zpi.onSettings((settings) => {
useStore.setState({ settings });
void refresh().catch(report);
});
void initialize();
return () => {
unsubscribe();
offPanes();
offNotificationClick();
offNotificationSound();
offSettings();
};
}, []);
const selected = state.selected;
const addSelection = useCallback(
(reference: import("@zpi-dev/ui").ConversationSelection) => addConversationSelection(reference, selected),
[selected],
);
const workspace = useWorkspace(selected);
const view = state.activeView;
const find = useTaskFind(selected, Boolean(view) && !settingsOpen && !action);
useEffect(() => {
if (!pendingSearch || selected !== pendingSearch.id || !view) return;
find.start(
pendingSearch.query,
pendingSearch.snippet?.runId,
pendingSearch.snippet?.matchBefore,
pendingSearch.snippet?.matchAfter,
);
setPendingSearch(undefined);
}, [pendingSearch, selected, view, find.start]);
const openSearch = useCallback(() => setSearchOpen(true), []);
const record = state.activeRecord;
const taskTitle = record?.draft ? "新任务" : (record?.title ?? view?.title ?? "新任务");
const diagnostics = [
...new Map(
[...(view?.controls?.diagnostics ?? []), ...(state.activeDiagnostics ?? [])].map((d) => [
`${d.path}:${d.message}`,
d,
]),
).values(),
];
const activeRun = view?.runs.find((r) => r.status === "running");
const stop = useCallback(() => {
const focusedSideId =
document.activeElement?.closest<HTMLElement>("[data-side-chat-session]")?.dataset.sideChatSession;
if (focusedSideId) {
const sideRun = useStore
.getState()
.views.get(focusedSideId)
?.runs.find((run) => run.status === "running");
if (sideRun)
paneTask(window.zpi.abortRun({ sessionId: focusedSideId, runId: sideRun.runId }).then(unwrap));
return;
}
if (selected && activeRun)
void window.zpi.abortRun({ sessionId: selected, runId: activeRun.runId }).then(unwrap).catch(report);
}, [selected, activeRun?.runId]);
useStopOnEscape(stop);
const copyCode = useCallback((text: string) => window.zpi.copyText(text).then(unwrap), []);
const openFile = useCallback(
(path: string, location?: FileLocation, origin?: FileOpenOrigin) => {
if (selected) paneTask(showFile(selected, path, location, origin));
},
[selected],
);
const openChanges = useCallback(
(runId: string, path?: string, toolCallId?: string) => {
if (selected) showChanges(selected, runId, path, toolCallId);
},
[selected],
);
const loadToolPatch = useCallback(
(runId: string, toolCallId: string) => {
if (!selected) return Promise.reject(new Error("任务未选中"));
return window.zpi.readPatch(selected, runId, `operation:${toolCallId}`).then(unwrap);
},
[selected],
);
const fileAction = useCallback(
(path: string, action: FileAction, location?: FileLocation) =>
selected ? window.zpi.fileAction(selected, path, action, location).then(unwrap) : Promise.resolve(),
[selected],
);
const earlier = useCallback(() => (selected ? loadEarlier(selected) : Promise.resolve()), [selected]);
const allEarlier = useCallback(
() => (selected ? loadAllEarlier(selected) : Promise.resolve("stale" as const)),
[selected],
);
const editMessage = useCallback(
async (runId: string, input: EditUserInput) => {
if (!selected) throw new Error("任务未选中");
const result = unwrap(await window.zpi.editUserMessage(selected, runId, input));
if ("conflicts" in result) return result;
resetSession(result);
},
[selected],
);
const forkMessage = useCallback(
async (runId: string) => {
if (!selected) throw new Error("任务未选中");
const child = unwrap(await window.zpi.forkSession(selected, runId));
await refresh();
await selectSession(child.id);
},
[selected],
);
const addProject = async () => {
try {
const p = unwrap(await window.zpi.addProject(useStore.getState().selected ?? null));
if (p) {
await refresh();
if (![...useStore.getState().sessions.values()].some((r) => r.projectId === p.id))
await newSession(p.id);
}
} catch (e) {
report(e);
}
};
const task = (fn: () => Promise<unknown>) => {
void fn().catch(report);
};
const pinned = visiblePinnedTasks(records);
useEffect(() => {
let composing = false;
const begin = () => {
composing = true;
};
const end = () => {
composing = false;
};
const run = (command: ShortcutCommand) => {
if (!shortcuts.enabled || !shortcuts.bindings[command] || workspaceShortcutBlocked(command)) return;
if (command === "toggle-sidebar") updatePrefs({ sidebarCollapsed: !collapsed });
else if (command === "toggle-pane") setPaneOpen(!usePane.getState().open);
else if (command === "pane-full-view") togglePaneFullView();
else if (command === "search") setSearchOpen(true);
else if (command === "new-task") void newTask().catch(report);
else if (command === "find") {
if (find.open) find.close();
else find.show();
} else if (command === "task-menu" && record && !record.draft && !usePane.getState().fullView)
setTaskMenuOpen(true);
else if (command === "focus-composer" || command === "toggle-window") {
const id = useStore.getState().selected;
if (id) focusComposer(id, { onlyIfFocusLost: command === "toggle-window" });
} else if (command.startsWith("pin-")) {
const record = pinned[Number(command.slice(4)) - 1];
if (record) {
setModelOpen(false);
void selectSession(record.id).catch(report);
}
}
};
const keydown = (event: KeyboardEvent) =>
captureWorkspaceShortcut(event, window.zpi.platform, shortcuts, run, composing);
// Capture before xterm consumes Ctrl combinations. Composer and question commands stay local.
window.addEventListener("keydown", keydown, true);
window.addEventListener("compositionstart", begin, true);
window.addEventListener("compositionend", end, true);
const offNative = window.zpi.onShortcut(run);
return () => {
window.removeEventListener("keydown", keydown, true);
window.removeEventListener("compositionstart", begin, true);
window.removeEventListener("compositionend", end, true);
offNative();
};
}, [
shortcuts,
collapsed,
pinned.map((record) => record.id).join(":"),
find.show,
find.open,
find.close,
record?.id,
record?.draft,
newTask,
]);
const unpinned = records
.filter((r) => r.pinnedAt == null && r.archivedAt == null)
.sort((a, b) => compareTasks(a, b, taskPreferences.organizeBy === "chronological"));
const projectIds = new Set(state.projects.map((p) => p.id));
const hasExpandedProjects = state.projects.some((p) => !prefs?.collapsedProjectIds.includes(p.id));
const toggleProjectGroupsLabel = hasExpandedProjects ? "收起全部项目" : "展开全部项目";
const tasks = unpinned.filter((r) => r.projectId === null);
const pinnedCount = pinned.length;
const renameTask = (record: SessionRecord) =>
setAction({
title: "重命名任务",
value: record.title,
onConfirm: async (name) => {
unwrap(await window.zpi.renameSession(record.id, name));
await refresh();
},
});
const renderRow = (r: SessionRecord, variant: "default" | "timeline" = "default") => (
<SidebarTaskRow
key={r.id}
record={r}
active={selected === r.id}
variant={variant}
workspace={r.cwd?.replace(/\\/g, "/").split("/").filter(Boolean).at(-1) ?? "ZPI"}
pinLimitReached={pinnedCount >= taskPinLimit}
shortcut={
shortcutModifierHeld
? pinnedTaskShortcut(
pinned.findIndex((item) => item.id === r.id),
shortcuts,
window.zpi.platform,
)
: undefined
}
onSelect={() => {
setModelOpen(false);
task(() => selectSession(r.id));
}}
onPin={() =>
task(async () => {
unwrap(await window.zpi.setSessionPinned(r.id, r.pinnedAt == null));
await refresh();
})
}
onArchive={() => task(() => archiveSession(r.id))}
/>
);
const scopeKey = state.projects.map((p) => p.id).join(":");
const paneWidth = Math.max(
300,
Math.min((viewport - width) * paneRatio, viewport - width - (viewport >= 1100 ? 380 : 60)),
);
const darkTheme = document.documentElement.dataset.theme === "dark";
const searchCommands: SearchCommand[] = [
{
id: "new-task",
title: "新任务",
section: "建议",
keywords: "new task 任务 新任务",
icon: MessageCirclePlus,
run: newTask,
},
{
id: "open-workspace",
title: "打开工作区",
section: "建议",
keywords: "open workspace folder project 打开 文件夹 项目",
icon: FolderOpen,
run: addProject,
},
{
id: "settings",
title: "设置",
section: "建议",
keywords: "settings preferences 配置 设置",
icon: Settings,
run: () => setSettingsOpen(true),
},
{
id: "sidebar",
title: "切换侧边栏",
section: "面板",
keywords: "sidebar left toggle 侧栏 侧边栏",
icon: collapsed ? PanelLeftOpen : PanelLeftClose,
run: () => updatePrefs({ sidebarCollapsed: !collapsed }),
},
{
id: "terminal",
title: "添加终端标签",
section: "面板",
keywords: "terminal shell console 终端",
icon: SquareTerminal,
disabled: !selected,
run: () => (selected ? openTerminal(selected) : undefined),
},
{
id: "browser",
title: "添加浏览器标签",
section: "面板",
keywords: "browser preview web 浏览器 预览",
icon: Globe,
run: () => openBrowser(),
},
{
id: "review",
title: "添加审查标签",
section: "面板",
keywords: "review git diff changes 文件变更",
icon: FileDiff,
disabled: !selected,
run: () => {
if (selected) openReview(selected);
},
},
{
id: "theme",
title: darkTheme ? "切换主题到浅色" : "切换主题到深色",
section: "配置",
keywords: "theme appearance light dark 主题 外观 浅色 深色",
icon: darkTheme ? Sun : Moon,
run: () => updatePrefs({ theme: darkTheme ? "light" : "dark" }),
},
];
return (
<div
className={`shell ${window.zpi.platform === "darwin" ? "mac-desktop" : ""} ${collapsed ? "left-collapsed" : ""} ${paneOpen ? "pane-open" : ""} ${settingsOpen ? "show-settings" : ""} ${fullView ? `pane-full-view ${fullView.surface === "conversation" ? "pane-full-conversation" : `pane-full-floating pane-float-${fullView.floating}`}` : ""}`}
style={
{
"--left-sidebar-width": `${width}px`,
"--sidebar-content-width": `${boundedWidth(dragWidth ?? prefs?.sidebarWidth ?? sidebarLimits.default)}px`,
"--right-pane-width": `${paneWidth}px`,
"--right-pane-offset": `${paneOpen && !fullView ? paneWidth : 0}px`,
"--workspace-panel-radius": `${window.zpi.workspacePanelRadius}px`,
} as CSSProperties
}
>
{searchOpen && (
<TaskSearchDialog
selected={selected}
workspaceKey={workspace?.cwd ?? record?.cwd ?? "ZPI"}
projects={state.projects}
commands={searchCommands}
onClose={() => setSearchOpen(false)}
onSelectTask={async (task, query, snippet) => {
setSettingsOpen(false);
setModelOpen(false);
await selectSession(task.id);
if (query) setPendingSearch({ id: task.id, query, snippet });
}}
onOpenFile={async (path) => {
if (!selected) await newSession();
const id = selected ?? useStore.getState().selected;
if (id) await showFile(id, path);
}}
/>
)}
<div className="workspace-surface" aria-hidden={settingsOpen} inert={settingsOpen}>
<WindowChrome
leftCollapsed={collapsed}
rightOpen={paneOpen}
hidden={settingsOpen}
onNewTask={() => task(newTask)}
onToggleLeft={() => updatePrefs({ sidebarCollapsed: !collapsed })}
onToggleRight={() => setPaneOpen(!paneOpen)}
shortcutHint={shortcutHint}
/>
{useMemo(
() => (
<aside className="sidebar" hidden={collapsed} inert={collapsed}>
<div className="sidebar-global">
<button aria-label="新建任务" onClick={() => task(newTask)}>
<MessageCirclePlus size={16} />
<span className="sidebar-action-label">新建任务</span>
<span className="sidebar-action-shortcut">{shortcutHint("new-task")}</span>
</button>
</div>
<div className="sidebar-task-list-region" data-testid="sidebar-task-list-region">
<TaskListHeader
value={taskPreferences}
onChange={setTaskPreferences}
onSearch={openSearch}
searchShortcut={shortcutHint("search")}
>
{taskPreferences.organizeBy === "grouped" && (
<TaskGroupToolbar
state={state.taskGroups}
collapsed={collapsedGroups}
onToggleAll={() =>
setCollapsedGroups(
state.taskGroups.groups.every((group) => !collapsedGroups.includes(group.id))
? state.taskGroups.groups.map((group) => group.id)
: [],
)
}
onCreate={() =>
task(async () => {
const next = await changeTaskGroups({ type: "create" });
setNewGroupId(next.groups.at(-1)?.id);
})
}
/>
)}
</TaskListHeader>
<div className="sidebar-sections">
{taskPreferences.organizeBy !== "chronological" && pinned.length > 0 && (
<section className="pinned-tasks" aria-label="置顶任务">
<h3 className="sidebar-heading">已置顶</h3>
{pinned.map((record) => renderRow(record))}
</section>
)}
{taskPreferences.organizeBy === "grouped" ? (
<TaskGroups
state={state.taskGroups}
records={records}
selected={selected}
collapsed={collapsedGroups}
setCollapsed={setCollapsedGroups}
newGroupId={newGroupId}
onSetup={() => setNewGroupId(undefined)}
/>
) : taskPreferences.organizeBy === "chronological" ? (
<SidebarTaskList
key={`timeline:${scopeKey}`}
tasks={records
.filter((record) => record.archivedAt == null)
.sort((a, b) => compareTasks(a, b, true))}
timeline
renderRow={renderRow}
/>
) : (
<SidebarSections
projects={{
title: "项目",
open: !prefs?.projectsCollapsed,
onToggle: () => updatePrefs({ projectsCollapsed: !prefs?.projectsCollapsed }),
action: (
<>
<ActionHint label={toggleProjectGroupsLabel} appearance="control">
<button
className="task-group-toggle"
aria-label={toggleProjectGroupsLabel}
disabled={!state.projects.length}
onClick={() =>
updatePrefs({
projectsCollapsed: false,
collapsedProjectIds: hasExpandedProjects ? [...projectIds] : [],
})
}
>
{hasExpandedProjects ? (
<ListChevronsDownUp size={14} />
) : (
<ListChevronsUpDown size={14} />
)}
</button>
</ActionHint>
<ActionHint label="添加项目" appearance="control">
<button
aria-label="添加项目"
className="muted-icon"
onClick={() => void addProject()}
>
<Plus size={14} aria-hidden="true" />
</button>
</ActionHint>
</>
),
children: (
<div className="projects" hidden={prefs?.projectsCollapsed}>
<SortableProjects
projects={state.projects}
onReorder={(projects) => {
task(async () => {
unwrap(
await window.zpi.reorderProjects(projects.map((project) => project.id)),
);
await refresh();
});
}}
renderProject={(p, { setActivatorNodeRef, attributes, listeners }) => (
<>
<div className="project-title">
<button
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
className="project-name project-toggle-title"
aria-label={`${prefs?.collapsedProjectIds.includes(p.id) ? "展开" : "收起"}项目 ${p.name}`}
aria-expanded={!prefs?.collapsedProjectIds.includes(p.id)}
onClick={() =>
updatePrefs({
collapsedProjectIds: prefs?.collapsedProjectIds.includes(p.id)
? prefs.collapsedProjectIds.filter((id) => id !== p.id)
: [...(prefs?.collapsedProjectIds ?? []), p.id],
})
}
>
{prefs?.collapsedProjectIds.includes(p.id) ? (
<Folder size={16} />
) : (
<FolderOpen size={16} />
)}
<span>{p.name}</span>
{prefs?.collapsedProjectIds.includes(p.id) &&
records.some(
(record) =>
record.projectId === p.id &&
record.archivedAt == null &&
record.unreadAt !== undefined,
) && (
<span
role="img"
className="project-unread-dot"
aria-label="项目有未读任务"
/>
)}
</button>
<Menu.Root>
<ActionHint label="更多" appearance="control">
<Menu.Trigger
className="project-row-action"
aria-label={`项目操作 ${p.name}`}
>
<Ellipsis size={14} />
</Menu.Trigger>
</ActionHint>
<Menu.Portal>
<Menu.Content
className="project-action-menu"
align="end"
sideOffset={4}
aria-label={`项目操作 ${p.name}`}
>
<Menu.Item
className="project-action-menu-item"
onSelect={() =>
task(async () => {
unwrap(await window.zpi.removeProject(p.id));
await refresh();
})
}
>
<X size={14} />
从侧栏移除
</Menu.Item>
<Menu.Separator className="menu-separator" />
<DirectoryMenuItems
className="project-action-menu-item"
getPath={async () => p.path}
/>
</Menu.Content>
</Menu.Portal>
</Menu.Root>
<ActionHint label="新建任务" appearance="control">
<button
aria-label={`新建任务 ${p.name}`}
className="project-row-action"
onClick={() => task(() => newSession(p.id))}
>
<MessageCirclePlus size={14} />
</button>
</ActionHint>
</div>
{!prefs?.projectsCollapsed &&
!prefs?.collapsedProjectIds.includes(p.id) &&
(() => {
const limit = projectLimits[p.id] ?? 5;
const page = taskPage(
unpinned.filter((r) => r.projectId === p.id),
limit,
);
return (
<div
className="project-task-list"
data-project-id={p.id}
data-task-limit={limit}
>
{page.items.length ? (
<div className="task-list-rows">
{page.items.map((record) => renderRow(record))}
</div>
) : (
<SidebarTaskEmpty />
)}
{page.hasMore && (
<div className="task-show-more">
<button
onClick={() =>
setProjectLimits((current) => ({
...current,
[p.id]: (current[p.id] ?? 5) + 5,
}))
}
>
显示更多
</button>
</div>
)}
</div>
);
})()}
</>
)}
/>
</div>
),
}}
tasks={{
title: "任务",
open: !prefs?.tasksCollapsed,
onToggle: () => updatePrefs({ tasksCollapsed: !prefs?.tasksCollapsed }),
action: (
<ActionHint label="新建任务" appearance="control">
<button
aria-label="新建任务"
className="muted-icon"
onClick={() => task(() => newSession(null))}
>
<MessageCirclePlus size={14} aria-hidden="true" />
</button>
</ActionHint>
),
children: !prefs?.tasksCollapsed && (
<SidebarTaskList key={`tasks:${scopeKey}`} tasks={tasks} renderRow={renderRow} />
),
}}
/>
)}
</div>
</div>
<div className="sidebar-footer">
{/* 仅重挂载提示,关闭隐藏触发器的 tooltip,同时保留侧栏分页和滚动位置。 */}
<ActionHint key={settingsOpen ? "settings" : "workspace"} label="设置" appearance="control">
<button aria-label="设置" onClick={() => setSettingsOpen(true)}>
<Settings size={16} />
设置
</button>
</ActionHint>
</div>
</aside>
),
[
settingsOpen,
shortcuts,
shortcutModifierHeld,
openSearch,
collapsed,
width,
records,
state.projects,
selected,
prefs,
taskPreferences,
projectLimits,
state.taskGroups,
collapsedGroups,
newGroupId,
newTask,
],
)}
{!collapsed && (
<hr
className="sidebar-resizer"
aria-label="侧边栏宽度"
aria-orientation="vertical"
aria-valuemin={sidebarLimits.min}
aria-valuemax={Math.max(
sidebarLimits.min,
Math.min(sidebarLimits.max, viewport - sidebarLimits.chatMin - sidebarLimits.resizer),
)}
aria-valuenow={width}
tabIndex={0}
onPointerDown={(e) => {
resizing.current = true;
e.currentTarget.parentElement?.setAttribute("data-sidebar-resizing", "true");
oldSelect.current = document.body.style.userSelect;
document.body.style.userSelect = "none";
e.currentTarget.setPointerCapture(e.pointerId);
e.preventDefault();
}}
onPointerMove={(e) => {
if (resizing.current) setDragWidth(boundedWidth(e.clientX));
}}
onPointerUp={(e) => {
if (!resizing.current) return;
resizing.current = false;
e.currentTarget.parentElement?.removeAttribute("data-sidebar-resizing");
document.body.style.userSelect = oldSelect.current;
e.currentTarget.releasePointerCapture(e.pointerId);
updatePrefs({ sidebarWidth: boundedWidth(e.clientX) });
setDragWidth(undefined);
}}
onPointerCancel={() => {
resizing.current = false;
document.querySelector(".workspace-surface")?.removeAttribute("data-sidebar-resizing");
document.body.style.userSelect = oldSelect.current;
setDragWidth(undefined);
}}
onKeyDown={(e) => {
if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
e.preventDefault();
updatePrefs({ sidebarWidth: boundedWidth(width + (e.key === "ArrowRight" ? 16 : -16)) });
}
}}
onDoubleClick={() => updatePrefs({ sidebarWidth: sidebarLimits.default })}
/>
)}
<main
className={`main ${record?.draft && !floatingComposer ? "draft-main" : ""}`}
onPointerDownCapture={() => selected && paneTask(markSessionRead(selected))}
onFocusCapture={() => selected && paneTask(markSessionRead(selected))}
>
{fullView && <PaneFullViewControls sessionId={selected} value={fullView} />}
<header className="topbar">
<div className="topbar-title" title={taskTitle}>
<Folder size={16} />
<span>{taskTitle}</span>
</div>
{record && !record.draft && (
<TaskMenu
key={record.id}
record={record}
open={taskMenuOpen}
onOpenChange={setTaskMenuOpen}
onRename={() => renameTask(record)}
/>
)}
<div className="topbar-drag-space" aria-hidden="true" />
{selected && <WorkspaceEditorButton sessionId={selected} />}
</header>
{state.settings?.providers.some((p) => p.hasApiKey || p.chatgptAccount?.connected) &&
!state.settings.credentialsPersisted && (
<Notice scope="config" tone="warning" message="系统加密不可用,凭据仅保存在本次应用内存中。" />
)}
{!state.settings?.providers.some((p) => p.models.length) && state.ready && (
<Notice
scope="config"
tone="warning"
message="先在设置中添加提供商和模型。"
action={{ label: "去设置", onClick: () => setSettingsOpen(true) }}
/>
)}
{view ? (
<div className="task-body">
<TaskActivity
key={view.sessionId}
sessionId={view.sessionId}
agents={view.subagents}
commands={view.backgroundTasks}
/>
<Conversation
onRetryModel={(runId) =>
window.zpi.retryModelRequest({ sessionId: view.sessionId, runId }).then(unwrap)
}
suggestions={state.activeSuggestions}
onOpenSubagent={showSubagent}
previewServices={previewServices}
findRequest={find.open && find.scope === "conversation" ? find.request : undefined}
onFindStateChange={find.update}
findBar={
find.open ? (
<TaskFindBar
request={find.request}
state={find.state}
scope={find.scope}
focusRequestId={find.focusRequestId}
onChange={find.change}
onNavigate={find.navigate}
onToggleScope={find.toggleScope}
onClose={find.close}
/>
) : undefined
}
workspace={workspace}
view={view}
onNavigateOrigin={(origin) => {
void (async () => {
if (!useStore.getState().sessions.has(origin.sessionId))
throw new Error("原任务已不存在");
await selectSession(origin.sessionId);
while (
!useStore
.getState()
.views.get(origin.sessionId)
?.runs.some((run) => run.runId === origin.runId) &&
useStore.getState().historyCursors.get(origin.sessionId) != null
) {
if (useStore.getState().selected !== origin.sessionId) return;
const cursor = useStore.getState().historyCursors.get(origin.sessionId);
await loadEarlier(origin.sessionId);
if (useStore.getState().historyCursors.get(origin.sessionId) === cursor) break;
}
requestAnimationFrame(() =>
window.dispatchEvent(new CustomEvent("zpi:scroll-to-run", { detail: origin })),
);
})().catch(report);
}}
onAddSelection={view.question ? undefined : addSelection}
onSideChat={
canOpenSideChat(record) && !view.question
? (reference) => paneTask(openSideChat(view.sessionId, reference))
: undefined
}
sideChatDisabled={sideChatBlocked}
onEdit={editMessage}
onFork={forkMessage}
historyRevision={historyRevision(view.sessionId)}
historyCursor={state.historyCursor}
hasEarlier={state.historyCursor != null}
loadingEarlier={state.loadingEarlier}
onLoadEarlier={earlier}
onLoadAllEarlier={allEarlier}
onLink={openLink}
context={inputContext}
onChanges={openChanges}
onLoadToolPatch={loadToolPatch}
onFileAction={fileAction}
onCopy={copyCode}
onFile={openFile}
/>
<div className="composer-container">
{view.question && (
<UserQuestionCard
key={view.question.id}
sessionId={view.sessionId}
request={view.question}
/>
)}
{Boolean(diagnostics.length) && (
<details className="resource-diagnostics">
<summary>资源加载诊断({diagnostics.length})</summary>
{diagnostics.map((d) => (
<p key={d.path}>
{d.path}: {d.message}
</p>
))}
</details>
)}
{!view.question && (
<ChatComposer
sessionId={view.sessionId}
draftStorage={drafts}
historyScope={record?.cwd}
sentMessages={view.runs
.filter((run) => !run.subagentNotification)
.map((run) => run.userMessage)}
header={
record?.draft && !floatingComposer ? (
<div className="draft-composer-header">
<DraftProjectPicker projectId={record.projectId} />
<WorktreeSettings
key={record.id}
record={record}
open={environmentOpen}
onOpenChange={setEnvironmentOpen}
/>
</div>
) : undefined
}
onFile={openFile}
context={inputContext}
busy={Boolean(activeRun)}
queue={view.queue}
queueActions={queueActions(view.sessionId)}
shortcutCommand={(event) =>
composerShortcutCommand(event, window.zpi.platform, shortcuts)
}
onSelectModel={() => setModelOpen(true)}
onConfigureEnvironment={() => {
if (record?.draft) setEnvironmentOpen(true);
}}
layout={floatingComposer ? "inline" : "stacked"}
showSendButton={floatingComposer || (prefs?.showSendButton ?? false)}
toolbar={
!floatingComposer && (
<SessionToolbar
view={view}
open={modelOpen}
onOpenChange={setModelOpen}
onSettings={() => setSettingsOpen(true)}
/>
)
}
suggestions={state.activeSuggestions}
onSubmit={async (text, input, { draftRevision }) => {
try {
const result = await window.zpi.submitInput({
sessionId: view.sessionId,
text,
...input,
});
if (
!result.ok &&
result.error.code === "configuration" &&
!view.controls?.selectionValid
) {
setModelOpen(true);
throw new Error("selection_required");
}
if (unwrap(result).confirmationRequired) return "confirmationRequired";
useStore.setState({ error: undefined });
void refresh().catch(report);
} catch (e) {
if (!(e instanceof Error && e.message === "selection_required"))
report(e, { input: view.sessionId, revision: draftRevision });
throw e;
}
}}
onStop={stop}
/>
)}
{!view.question && prefs?.showModelPerformance && (
<ModelPerformance value={view.modelPerformance} />
)}
</div>
</div>
) : (
<div className="empty-chat" role="status">
{state.ready ? "新建或选择一个任务开始对话" : "正在准备任务…"}
</div>
)}
</main>
<RightPane
onSettings={() => setSettingsOpen(true)}
available={viewport - width}
sessionId={selected}
findRequest={find.open && find.scope === "changes" ? find.request : undefined}
onFindStateChange={find.update}
/>
</div>
{state.error && (
<Notice key={state.errorVersion} scope="window" message={state.error} onDismiss={dismissError} />
)}
{action && <ActionModal action={action} onClose={() => setAction(undefined)} />}
{useMemo(
() => (settingsOpen ? <SettingsPage onClose={() => setSettingsOpen(false)} /> : null),
[settingsOpen],
)}
</div>
);
}