源码
packages/desktop/src/renderer/SidebarTaskRow.tsx
import { ActionHint, TaskTitleOverflowText } from "@zpi-dev/ui";
import { Archive, LoaderIcon, Pin } from "lucide-react";
import type { CSSProperties } from "react";
import type { SessionRecord } from "../shared/bridge.ts";
import { formatTaskRelativeTime } from "./sidebar-task-model.ts";
import { report } from "./store.ts";
import { unwrap } from "./unwrap.ts";
export function SidebarTaskRow({
record: r,
active,
workspace,
variant = "default",
pinLimitReached,
shortcut,
onSelect,
onPin,
onArchive,
}: {
record: SessionRecord;
active: boolean;
workspace: string;
variant?: "default" | "timeline";
pinLimitReached: boolean;
shortcut?: string;
onSelect(): void;
onPin(): void;
onArchive(): void;
}) {
const indicator =
r.status === "error" ? "error" : r.unreadAt !== undefined ? "unread" : r.running ? "running" : "none";
const actions = (
<span className="task-row-actions">
<ActionHint label={r.running ? "请先停止运行" : "归档任务"} appearance="control" hideWhenDetached>
<span className="task-archive-hint">
<button
className="row-action task-archive"
aria-label={`归档任务 ${r.title}`}
disabled={r.running || Boolean(r.diagnostic)}
onClick={(event) => {
event.stopPropagation();
onArchive();
}}
>
<Archive size={14} />
</button>
</span>
</ActionHint>
</span>
);
const showShortcut = Boolean(shortcut) && !r.pendingQuestion;
const time = (
<span className="task-row-time" data-task-row-metadata="true">
{showShortcut ? (
<kbd className="task-row-shortcut" title={`切换任务 ${shortcut}`}>
{shortcut}
</kbd>
) : r.pendingQuestion ? (
<QuestionBadge record={r} />
) : (
formatTaskRelativeTime(r.updatedAt)
)}
</span>
);
return (
// biome-ignore lint/a11y/useSemanticElements: Composite task row contains independent pin and archive buttons.
<div
className={`session-row ${active ? "active" : ""} ${variant === "timeline" ? "timeline-row" : ""}`}
role="button"
tabIndex={0}
onClick={onSelect}
// Pointer clicks select the task without retaining the keyboard controls after hover ends.
onMouseDown={(event) => event.preventDefault()}
onKeyDown={(event) => {
if (event.target === event.currentTarget && (event.key === "Enter" || event.key === " ")) {
event.preventDefault();
onSelect();
}
}}
data-testid="session-row"
data-session-id={r.id}
data-task-item-key={r.id}
data-status={r.status ?? "idle"}
data-question={Boolean(r.pendingQuestion)}
data-shortcut={showShortcut}
data-indicator={indicator}
>
<span className="task-leading-slot">
<span className="task-indicator" aria-hidden="true">
{indicator === "error" ? (
<span data-error-indicator="true" className="task-error-dot" />
) : indicator === "unread" ? (
<span data-unread-indicator="true" className="task-unread-dot" />
) : indicator === "running" ? (
<LoaderIcon className="task-running-icon" size={16} data-loading-indicator="true" />
) : variant === "timeline" ? (
<span className="task-idle-dot" />
) : null}
</span>
<ActionHint
appearance="control"
label={
r.pinnedAt != null
? "取消置顶任务"
: pinLimitReached
? "最多置顶 5 个任务,请先取消其他任务的置顶"
: "置顶任务"
}
>
<button
className={`row-action task-pin ${r.pinnedAt != null ? "pinned" : ""}`}
aria-label={`${r.pinnedAt != null ? "取消置顶" : "置顶"}任务 ${r.title}`}
disabled={r.pinnedAt == null && pinLimitReached}
onClick={(event) => {
event.stopPropagation();
onPin();
}}
>
<Pin size={16} />
</button>
</ActionHint>
</span>
{variant === "timeline" ? (
<div className="timeline-row-body">
<button
className="session-name"
tabIndex={-1}
onClick={(event) => {
event.stopPropagation();
onSelect();
}}
>
<TaskTitleOverflowText as="span">{r.title}</TaskTitleOverflowText>
</button>
<div className="timeline-row-metadata">
<span className="task-workspace-label">{workspace}</span>
<div>
{time}
{actions}
</div>
</div>
</div>
) : (
<>
<button
className="session-name"
tabIndex={-1}
onClick={(event) => {
event.stopPropagation();
onSelect();
}}
>
<TaskTitleOverflowText as="span">{r.title}</TaskTitleOverflowText>
</button>
{time}
{actions}
</>
)}
</div>
);
}
export function QuestionBadge({ record }: { record: SessionRecord }) {
const question = record.pendingQuestion;
if (!question) return null;
const state = question.autoResolution;
const timed = state && state.phase !== "snoozed";
const progress = state?.phase === "visibleCountdown";
return (
<button
className="task-question-badge"
aria-label={timed ? "停止提问计时" : "等待回答"}
data-timed={Boolean(timed)}
data-progress={progress}
style={
progress
? ({ "--question-elapsed": `${-Math.max(0, Date.now() - state.visibleAt)}ms` } as CSSProperties)
: undefined
}
onClick={(event) => {
event.stopPropagation();
if (timed) void window.zpi.snoozeQuestion(record.id, question.id).then(unwrap).catch(report);
}}
>
<span>等待确认</span>
{timed && <span>停止计时</span>}
</button>
);
}