Z ZPI 文档 v0.3.8
介绍页

源码

packages/desktop/src/renderer/SidebarTaskRow.tsx

tsx190 行查看原文
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>
  );
}