diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index e14a3e86..5912ada3 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -242,6 +242,7 @@ import { ProjectIcon } from "./projects/ProjectIcon"; import { SourceControlModal } from "./git/SourceControl"; import { workspaceStateForCwd, type WorkspaceLoadState } from "./git/state"; import { CommandPalette, type Command } from "./palette/CommandPalette"; +import { currentFirstSessionHits } from "./palette/merge"; import { RemoteModal } from "./remote/Remote"; import { IssuesModal } from "./issues/Issues"; import { PreviewModal } from "./editor/Preview"; @@ -420,6 +421,11 @@ import { unlinkTaskPullRequest, type BoardTask, } from "./taskboard/taskBoard"; +import { + continueTaskBoardPrompt, + taskBoardTranscriptPreview, +} from "./taskboard/taskBoardContinuation"; +import type { TaskBoardTranscriptPreview } from "./taskboard/workspaceTypes"; import { actionForEvent, @@ -983,7 +989,7 @@ export default function App() { // Split whichever pane is passed (focusing it first, so the reducer's focused-relative split // lands on that pane), seeding an empty draft for the new leaf. const splitPaneById = useCallback( - (targetId: string, edge: PaneEdge) => { + (targetId: string, edge: PaneEdge): string => { const id = nextPaneId(); const nextContents = { ...paneContentsRef.current, @@ -1001,6 +1007,7 @@ export default function App() { activeSessionRef.current = null; activeSessionProvenanceRef.current = null; setPaneLayout((layout) => splitFocused(focusPane(layout, targetId), edge, id)); + return id; }, [nextPaneId, updatePaneTranscriptState], ); @@ -2703,6 +2710,13 @@ export default function App() { ], [archivedSessions, runningSessions, sessions], ); + const loadTaskBoardTranscript = useCallback( + async (sessionId: string): Promise => { + const page = await getTranscriptPage(sessionId, null, 12); + return taskBoardTranscriptPreview(page.entries); + }, + [], + ); const quickQuotaProvider = useMemo(() => { const focused = [...sessions, ...archivedSessions].find( @@ -4541,44 +4555,70 @@ export default function App() { [createSession, setDocMode, setTaskContext, t], ); + const answerPermissionRequest = useCallback( + async (request: PermissionQueueItem, optionId: string | null): Promise => { + try { + const accepted = await answerPermission( + request.session, + request.requestId, + optionId, + ); + setPermissionQueue((previous) => + permissionQueueAfterAnswer( + previous, + request.session, + request.requestId, + accepted, + ), + ); + if (!accepted) toast(t("taskboard.answerNotAccepted"), "error"); + return accepted; + } catch { + toast(t("taskboard.answerFailed"), "error"); + return false; + } + }, + [t, toast], + ); + + const answerElicitationRequest = useCallback( + async (request: PermissionQueueItem, value: ElicitationAnswer): Promise => { + try { + const accepted = await answerElicitation( + request.session, + request.requestId, + value, + ); + setPermissionQueue((previous) => + permissionQueueAfterAnswer( + previous, + request.session, + request.requestId, + accepted, + ), + ); + if (!accepted) toast(t("taskboard.answerNotAccepted"), "error"); + return accepted; + } catch { + toast(t("taskboard.answerFailed"), "error"); + return false; + } + }, + [t, toast], + ); + const answer = useCallback( async (optionId: string | null) => { - if (!permission) return; - const accepted = await answerPermission( - permission.session, - permission.requestId, - optionId, - ); - setPermissionQueue((previous) => - permissionQueueAfterAnswer( - previous, - permission.session, - permission.requestId, - accepted, - ), - ); + if (permission) await answerPermissionRequest(permission, optionId); }, - [permission], + [answerPermissionRequest, permission], ); const answerQuestion = useCallback( async (value: ElicitationAnswer) => { - if (!permission) return; - const accepted = await answerElicitation( - permission.session, - permission.requestId, - value, - ); - setPermissionQueue((previous) => - permissionQueueAfterAnswer( - previous, - permission.session, - permission.requestId, - accepted, - ), - ); + if (permission) await answerElicitationRequest(permission, value); }, - [permission], + [answerElicitationRequest, permission], ); /** @@ -4910,6 +4950,36 @@ export default function App() { return () => dispose?.(); }, [selectSession]); + const splitTaskBoardSession = useCallback( + (sessionId: string, edge: "right" | "bottom") => { + const existingPane = paneBoundToSession(paneContentsRef.current, sessionId); + setShowTaskBoard(false); + if (existingPane) { + void selectSession(sessionId, existingPane); + return; + } + const paneId = splitPaneById(focusedPaneRef.current, edge); + void selectSession(sessionId, paneId); + }, + [selectSession, splitPaneById], + ); + + const forkTaskBoardSession = useCallback( + (sessionId: string, throughSeq: number, title: string) => { + const paneId = focusedPaneRef.current; + const insertSession = paneEditorRefsFor(paneId).insertSessionRef.current; + if (!Number.isSafeInteger(throughSeq) || throughSeq <= 0 || !insertSession) { + toast(t("turn.forkFailed"), "error"); + return; + } + setShowTaskBoard(false); + if (!createTaskDraft()) return; + insertSession({ id: sessionId, title, throughSeq }); + toast(t("turn.forked"), "success"); + }, + [createTaskDraft, paneEditorRefsFor, t, toast], + ); + const activatePaneById = useCallback( (paneId: string) => { const session = paneContentsRef.current[paneId]?.sessionId ?? null; @@ -5052,7 +5122,11 @@ export default function App() { const searchPaletteCommands = useCallback( async (query: string): Promise => { const hits = await searchSessions(query, 12); - return hits.map((hit) => { + const uniqueHits = currentFirstSessionHits( + [...new Map(hits.map((hit) => [hit.session_id, hit])).values()], + activeSession, + ); + return uniqueHits.map((hit) => { const stored = sessions.find((session) => session.id === hit.session_id) ?? archivedSessions.find((session) => session.id === hit.session_id); @@ -5067,13 +5141,24 @@ export default function App() { category: "session", label: hit.title, detail: `${t(hit.role === "user" ? "palette.you" : "palette.agent")}: ${hit.snippet}`, - hint: hit.archived ? t("palette.archived") : project, + hint: hit.session_id === activeSession + ? t("palette.current") + : hit.archived + ? t("palette.archived") + : project, keywords: `${sourcePath} ${hit.cwd}`, + preview: { + title: hit.title, + body: hit.snippet, + context: project, + current: hit.session_id === activeSession, + archived: hit.archived, + }, run: () => void selectSession(hit.session_id), }; }); }, - [projects, sessions, archivedSessions, selectSession, t], + [activeSession, projects, sessions, archivedSessions, selectSession, t], ); // Skills depend on the workspace: harness skill directories (.claude/skills …) are rescanned @@ -7755,19 +7840,36 @@ export default function App() { {showTaskBoard && ( { setShowTaskBoard(false); void selectSession(id); }} onAskSession={(id, prompt) => { - setShowTaskBoard(false); - void selectSession(id).then(() => { - clearEditorRef.current?.(); - setDocMode(true); - setTimeout(() => { - void insertMarkdownRef.current?.(prompt, "replace"); - focusEditorRef.current?.(); - }, 0); + const paneId = + paneBoundToSession(paneContentsRef.current, id) ?? + focusedPaneRef.current; + return continueTaskBoardPrompt({ + target: { paneId, sessionId: id }, + prompt, + selectSession: (sessionId, targetPaneId) => + selectSession(sessionId, targetPaneId, true, true), + isTargetActive: () => + focusedPaneRef.current === paneId && + paneContentsRef.current[paneId]?.sessionId === id, + openDocumentMode: () => setDocMode(true), + insertMarkdown: (markdown, mode) => + paneEditorRefsFor(paneId).insertMarkdownRef.current?.( + markdown, + mode, + ) ?? Promise.resolve(), + // Keep TaskBoard active; the draft is staged until the Session is opened. + focusEditor: () => undefined, }); }} onStartTask={startBoardTask} diff --git a/apps/desktop/src/components/ui/command.tsx b/apps/desktop/src/components/ui/command.tsx index 73c78056..77d6d2a7 100644 --- a/apps/desktop/src/components/ui/command.tsx +++ b/apps/desktop/src/components/ui/command.tsx @@ -33,6 +33,8 @@ function CommandDialog({ children, className, showCloseButton = true, + commandValue, + onCommandValueChange, ...props }: Omit, "children"> & { children: React.ReactNode @@ -40,6 +42,8 @@ function CommandDialog({ description?: string className?: string showCloseButton?: boolean + commandValue?: string + onCommandValueChange?: (value: string) => void }) { return ( @@ -51,7 +55,12 @@ function CommandDialog({ {title} {description} - + {children} diff --git a/apps/desktop/src/i18n/strings.ts b/apps/desktop/src/i18n/strings.ts index c71f2f8e..d2640f84 100644 --- a/apps/desktop/src/i18n/strings.ts +++ b/apps/desktop/src/i18n/strings.ts @@ -462,6 +462,9 @@ export const en = { "palette.you": "You", "palette.agent": "Agent", "palette.archived": "Archived", + "palette.current": "Current", + "palette.preview": "Session search preview", + "palette.readOnlyPreview": "Read-only preview", // composer "composer.placeholder": "Write your prompt — / for skills, @ for files", @@ -1981,6 +1984,7 @@ export const en = { "taskboard.title": "Task board", "taskboard.description": "Plan, advance, and deliver your work", "taskboard.allTasks": "All tasks", + "taskboard.views": "Task views", "taskboard.workspaceDescription": "Plan, execute, and track work with Sessions. Each Session owns at most one current pull request.", "taskboard.breadcrumb": "Task board breadcrumb", "taskboard.titleHeader": "Title", @@ -1997,11 +2001,13 @@ export const en = { "taskboard.currentSession": "Current", "taskboard.archivedSession": "Archived", "taskboard.openPullRequestCount": "{count} open pull requests", + "taskboard.openPullRequestCountPending": "Open pull request count is still loading", "taskboard.noSessions": "No Sessions", "taskboard.noSessionsDescription": "Start this Task to create its first execution Session.", "taskboard.noCheckout": "No checkout", "taskboard.worktreeDiscarded": "Worktree discarded", "taskboard.noPullRequest": "No PR", + "taskboard.checkingPullRequest": "Checking PR…", "taskboard.noPullRequestForSession": "This Session's checkout has no current pull request.", "taskboard.inspector": "Task inspector", "taskboard.inspectorViews": "Inspector views", @@ -2010,8 +2016,10 @@ export const en = { "taskboard.inspector.insights": "Insights", "taskboard.showInspector": "Show inspector", "taskboard.hideInspector": "Hide inspector", + "taskboard.closeInspectorDrawer": "Close inspector and return to Task list", "taskboard.selectTask": "Select a Task to inspect it.", "taskboard.currentSessionTitle": "Current Session", + "taskboard.selectedSession": "Selected Session", "taskboard.taskLabel": "Task", "taskboard.activityLabel": "Activity", "taskboard.checkoutTitle": "Checkout", @@ -2027,6 +2035,23 @@ export const en = { "taskboard.askAgentPlaceholder": "Add a prompt and continue in this Session…", "taskboard.openSession": "Open Session", "taskboard.continueWithPrompt": "Continue with prompt", + "taskboard.promptFailed": "The prompt could not be staged. Your draft is still here.", + "taskboard.transcript.title": "Recent transcript", + "taskboard.transcript.loading": "Loading transcript…", + "taskboard.transcript.failed": "The transcript preview is unavailable.", + "taskboard.transcript.empty": "No durable transcript text yet.", + "taskboard.transcript.you": "You", + "taskboard.transcript.agent": "Agent", + "taskboard.attention.actionRequired": "Action required", + "taskboard.answerNotAccepted": "The request changed before Core accepted that answer. Your draft is still here.", + "taskboard.answerFailed": "Core could not accept that answer. Your draft is still here.", + "taskboard.forkFromPreview": "Fork from preview", + "taskboard.splitRight": "Split right", + "taskboard.splitBelow": "Split below", + "taskboard.keyboardHints": "Task list keyboard shortcuts", + "taskboard.keyboard.select": "Select", + "taskboard.keyboard.preview": "Preview", + "taskboard.keyboard.open": "Open", "taskboard.taskDetails": "Task details", "taskboard.noDescription": "No description", "taskboard.relationshipTitle": "Relationship", @@ -2048,6 +2073,7 @@ export const en = { "taskboard.lane.needsYou": "Needs you", "taskboard.lane.done": "Done", "taskboard.attentionSummary": "{count} tasks need your attention", + "taskboard.attentionEmpty": "Nothing needs your attention", "taskboard.cardAction": "{action}: {title}", "taskboard.readyToContinue": "Ready to continue", "taskboard.runningNow": "Running", @@ -3121,6 +3147,9 @@ export const zhCN: Record = { "palette.you": "你", "palette.agent": "代理", "palette.archived": "已归档", + "palette.current": "当前", + "palette.preview": "Session 搜索预览", + "palette.readOnlyPreview": "只读预览", "composer.placeholder": "写下你的提示词 — / 插入技能, @ 引用文件", "composer.documentInput": "提示词文档", @@ -4549,6 +4578,7 @@ export const zhCN: Record = { "taskboard.title": "任务看板", "taskboard.description": "规划、推进并交付你的工作", "taskboard.allTasks": "全部任务", + "taskboard.views": "任务视图", "taskboard.workspaceDescription": "通过 Session 规划、执行并跟踪工作;每个 Session 最多对应一个当前 pull request。", "taskboard.breadcrumb": "任务看板路径", "taskboard.titleHeader": "标题", @@ -4565,11 +4595,13 @@ export const zhCN: Record = { "taskboard.currentSession": "当前", "taskboard.archivedSession": "已归档", "taskboard.openPullRequestCount": "{count} 个打开的 pull request", + "taskboard.openPullRequestCountPending": "正在加载打开的 pull request 数量", "taskboard.noSessions": "暂无 Session", "taskboard.noSessionsDescription": "开始这个任务以创建第一个执行 Session。", "taskboard.noCheckout": "没有检出目录", "taskboard.worktreeDiscarded": "Worktree 已丢弃", "taskboard.noPullRequest": "无 PR", + "taskboard.checkingPullRequest": "正在检查 PR…", "taskboard.noPullRequestForSession": "这个 Session 的检出目录没有当前 pull request。", "taskboard.inspector": "任务检查器", "taskboard.inspectorViews": "检查器视图", @@ -4578,8 +4610,10 @@ export const zhCN: Record = { "taskboard.inspector.insights": "洞察", "taskboard.showInspector": "显示检查器", "taskboard.hideInspector": "隐藏检查器", + "taskboard.closeInspectorDrawer": "关闭检查器并返回任务列表", "taskboard.selectTask": "选择一个任务以查看详情。", "taskboard.currentSessionTitle": "当前 Session", + "taskboard.selectedSession": "选中的 Session", "taskboard.taskLabel": "任务", "taskboard.activityLabel": "状态", "taskboard.checkoutTitle": "检出目录", @@ -4595,6 +4629,23 @@ export const zhCN: Record = { "taskboard.askAgentPlaceholder": "添加提示并在这个 Session 中继续…", "taskboard.openSession": "打开 Session", "taskboard.continueWithPrompt": "带提示继续", + "taskboard.promptFailed": "无法暂存这条提示;你的草稿仍保留在这里。", + "taskboard.transcript.title": "最近对话", + "taskboard.transcript.loading": "正在加载对话…", + "taskboard.transcript.failed": "暂时无法预览对话。", + "taskboard.transcript.empty": "还没有可预览的持久化对话文本。", + "taskboard.transcript.you": "你", + "taskboard.transcript.agent": "Agent", + "taskboard.attention.actionRequired": "需要你操作", + "taskboard.answerNotAccepted": "在 Core 接受前,请求已发生变化;你的草稿仍保留在这里。", + "taskboard.answerFailed": "Core 无法接受这个答案;你的草稿仍保留在这里。", + "taskboard.forkFromPreview": "从预览分叉", + "taskboard.splitRight": "向右分屏", + "taskboard.splitBelow": "向下分屏", + "taskboard.keyboardHints": "任务列表键盘快捷键", + "taskboard.keyboard.select": "选择", + "taskboard.keyboard.preview": "预览", + "taskboard.keyboard.open": "打开", "taskboard.taskDetails": "任务详情", "taskboard.noDescription": "暂无描述", "taskboard.relationshipTitle": "关系", @@ -4616,6 +4667,7 @@ export const zhCN: Record = { "taskboard.lane.needsYou": "需要你处理", "taskboard.lane.done": "已完成", "taskboard.attentionSummary": "有 {count} 项任务需要你处理", + "taskboard.attentionEmpty": "当前没有需要你处理的任务", "taskboard.cardAction": "{action}:{title}", "taskboard.readyToContinue": "可继续处理", "taskboard.runningNow": "正在运行", diff --git a/apps/desktop/src/palette/CommandPalette.tsx b/apps/desktop/src/palette/CommandPalette.tsx index 9c7c0472..1b4f387b 100644 --- a/apps/desktop/src/palette/CommandPalette.tsx +++ b/apps/desktop/src/palette/CommandPalette.tsx @@ -16,6 +16,14 @@ import { mergeCommandResults } from "./merge"; export type CommandCategory = "action" | "session" | "setting"; +export interface CommandPreview { + title: string; + body: string; + context?: string; + current?: boolean; + archived?: boolean; +} + export interface Command { id: string; /** Stable entity identity lets a richer async result replace its metadata-only row. */ @@ -25,6 +33,7 @@ export interface Command { hint?: string; detail?: string; keywords?: string; + preview?: CommandPreview; run: () => void; } @@ -43,6 +52,7 @@ export function CommandPalette({ const [matches, setMatches] = useState([]); const [searchState, setSearchState] = useState<"idle" | "pending" | "loading" | "success" | "error">("idle"); const [filter, setFilter] = useState<"all" | CommandCategory>("all"); + const [selectedId, setSelectedId] = useState(commands[0]?.id ?? ""); useEffect(() => { const value = query.trim(); @@ -114,6 +124,19 @@ export function CommandPalette({ : searchState === "pending" || searchState === "loading" ? t("palette.searching") : null; + const listedCommands = useMemo( + () => groups.flatMap((group) => group.commands), + [groups], + ); + const selectedCommand = listedCommands.find((command) => command.id === selectedId) + ?? listedCommands[0] + ?? null; + + useEffect(() => { + if (selectedCommand && selectedCommand.id !== selectedId) { + setSelectedId(selectedCommand.id); + } + }, [selectedCommand, selectedId]); return ( ))} - - {searchStatus ? null : t("palette.empty")} - {groups.map((group) => ( - + + {searchStatus ? null : t("palette.empty")} + {groups.map((group) => ( + + {group.commands.map((command) => ( + setSelectedId(command.id)} + onMouseMove={() => setSelectedId(command.id)} + onSelect={() => { + onClose(); + command.run(); + }} + > + + {command.label} + {command.detail && ( + {command.detail} + )} + + {command.hint && {command.hint}} + + ))} + + ))} + {searchStatus && (filter === "all" || filter === "session") && ( +

+ {searchStatus} +

+ )} +
+ {selectedCommand?.preview ? ( + + ) : null} +
↑↓ {t("palette.navigate")} diff --git a/apps/desktop/src/palette/merge.ts b/apps/desktop/src/palette/merge.ts index 849b13f8..7a1b1229 100644 --- a/apps/desktop/src/palette/merge.ts +++ b/apps/desktop/src/palette/merge.ts @@ -14,3 +14,22 @@ export function mergeCommandResults(base: T[], matc const ids = new Set(remaining.map((command) => command.id)); return [...remaining, ...matches.filter((command) => !ids.has(command.id))]; } + +interface SessionSearchRankable { + session_id: string; + archived: boolean; +} + +/** Keep backend relevance stable except for the two product-level navigation priorities. */ +export function currentFirstSessionHits( + hits: readonly T[], + currentSession: string | null, +): T[] { + return [...hits].sort((left, right) => { + const leftCurrent = left.session_id === currentSession; + const rightCurrent = right.session_id === currentSession; + if (leftCurrent !== rightCurrent) return leftCurrent ? -1 : 1; + if (left.archived !== right.archived) return left.archived ? 1 : -1; + return 0; + }); +} diff --git a/apps/desktop/src/session/QuestionDialog.tsx b/apps/desktop/src/session/QuestionDialog.tsx index d95d92d2..a1768aa7 100644 --- a/apps/desktop/src/session/QuestionDialog.tsx +++ b/apps/desktop/src/session/QuestionDialog.tsx @@ -173,51 +173,91 @@ export function QuestionDialog({ }: { form: ElicitationForm; onAnswer: (answer: ElicitationAnswer) => void; +}) { + return ( + !open && onAnswer({ action: "cancel" })}> + + + + + ); +} + +/** + * The shared, stateful elicitation body. TaskBoard embeds this directly so an unanswered draft + * stays mounted when Core rejects or fails an answer; the chat wraps the same body in a dialog. + */ +export function QuestionForm({ + form, + onAnswer, + embedded = false, +}: { + form: ElicitationForm; + onAnswer: ( + answer: ElicitationAnswer, + ) => boolean | void | Promise; + embedded?: boolean; }) { const t = useT(); const [values, setValues] = useState({}); + const [answering, setAnswering] = useState(false); const questions = questionFields(form); // With one question the message *is* the question, so repeating it above the options would just // read as the same sentence twice. const single = questions.length === 1; + const answer = async (value: ElicitationAnswer) => { + if (answering) return; + setAnswering(true); + try { + await onAnswer(value); + } finally { + setAnswering(false); + } + }; + return ( - !open && onAnswer({ action: "cancel" })}> - - +
+ + {embedded ? ( +

+ + {form.message} +

+ ) : ( {form.message} -
+ )} + -
- {questions.map((field) => ( - - ))} -
+
+ {questions.map((field) => ( + + ))} +
- - - - - - -
+ + + + + +
); } diff --git a/apps/desktop/src/taskboard/TaskBoardHeader.tsx b/apps/desktop/src/taskboard/TaskBoardHeader.tsx index cf89c298..97eccc93 100644 --- a/apps/desktop/src/taskboard/TaskBoardHeader.tsx +++ b/apps/desktop/src/taskboard/TaskBoardHeader.tsx @@ -17,10 +17,16 @@ import type { Translate } from "@/i18n" import { PRIORITIES, type TaskPriority } from "./taskBoard" import { taskPriorityLabel } from "./TaskEditorDialog" +import type { TaskBoardView } from "./workspaceTypes" interface TaskBoardHeaderProps { t: Translate taskCount: number + pageTitle: string + pageDescription: string + view: TaskBoardView + attentionCount: number + onViewChange: (view: TaskBoardView) => void headerLeadingAction?: ReactNode inspectorOpen: boolean onShowInspector: () => void @@ -49,11 +55,12 @@ export function TaskBoardHeader(props: TaskBoardHeaderProps) {
{!props.inspectorOpen ? ( + +
void + onOpenChange: (open: boolean) => void +} + +export function TaskBoardInspectorSurface(props: TaskBoardInspectorSurfaceProps) { + const contents = ( + <> + + + + ) + + if (props.isNarrow) { + return ( + + + {props.t("taskboard.inspector")} + {contents} + + + ) + } + + return props.inspectorOpen ? ( + + ) : null +} diff --git a/apps/desktop/src/taskboard/TaskBoardList.tsx b/apps/desktop/src/taskboard/TaskBoardList.tsx index a86e2c45..5da9474e 100644 --- a/apps/desktop/src/taskboard/TaskBoardList.tsx +++ b/apps/desktop/src/taskboard/TaskBoardList.tsx @@ -5,11 +5,12 @@ import type { SidebarPullRequestStatus } from "@/sidebar/sidebarGitStatus" import { TaskListItem } from "./TaskListItem" import type { BoardTask, TaskStatus } from "./taskBoard" import { INITIAL_TASK_LIMIT } from "./workspaceModel" -import type { ProjectedTask } from "./workspaceTypes" +import type { ProjectedTask, TaskBoardView } from "./workspaceTypes" interface TaskBoardListProps { t: Translate locale: Locale + view: TaskBoardView projectedTasks: readonly ProjectedTask[] renderedTasks: readonly ProjectedTask[] remainingTaskCount: number @@ -39,7 +40,11 @@ export function TaskBoardList(props: TaskBoardListProps) { {t("taskboard.updatedHeader")} {props.renderedTasks.length > 0 ? ( -
    +
      {props.renderedTasks.map((projected) => ( ) : (
      - {props.activeFilterCount > 0 ? t("taskboard.emptyFiltered") : t("taskboard.emptyList")} + {props.activeFilterCount > 0 + ? t("taskboard.emptyFiltered") + : props.view === "attention" + ? t("taskboard.attentionEmpty") + : t("taskboard.emptyList")}
      )} -
      +
      {t("taskboard.visibleCount", { visible: props.renderedTasks.length, total: props.projectedTasks.length, })} + + ↑↓ / J K {t("taskboard.keyboard.select")} + Space {t("taskboard.keyboard.preview")} + {t("taskboard.keyboard.open")} + {props.remainingTaskCount > 0 ? ( - - - + + open ? setInspectorOpen(true) : closeInspector()} + />
      {editor ? ( Promise + onAnswerElicitation?: (request: PermissionQueueItem, answer: ElicitationAnswer) => Promise + onAccepted: () => void +} + +export function TaskBoardPendingInput(props: TaskBoardPendingInputProps) { + const [answering, setAnswering] = useState(false) + + if (props.request.form && props.onAnswerElicitation) { + return ( +
      + { + const accepted = await props.onAnswerElicitation?.(props.request, answer) ?? false + if (accepted) props.onAccepted() + return accepted + }} + /> +
      + ) + } + + if (!props.onAnswerPermission) return null + const answer = async (optionId: string | null): Promise => { + if (answering) return + setAnswering(true) + try { + const accepted = await props.onAnswerPermission?.(props.request, optionId) ?? false + if (accepted) props.onAccepted() + } finally { + setAnswering(false) + } + } + + return ( +
      +
      + +
      +

      {props.t("taskboard.attention.actionRequired")}

      +

      + {props.request.title} +

      +
      +
      +
      + {props.request.options.map(([id, label]) => ( + + ))} + +
      +
      + ) +} diff --git a/apps/desktop/src/taskboard/TaskInspector.tsx b/apps/desktop/src/taskboard/TaskInspector.tsx index f2a4080f..287772d4 100644 --- a/apps/desktop/src/taskboard/TaskInspector.tsx +++ b/apps/desktop/src/taskboard/TaskInspector.tsx @@ -1,27 +1,42 @@ import type { FormEvent } from "react" +import type { ElicitationAnswer } from "@/bridge" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import type { Translate } from "@/i18n" +import type { PermissionQueueItem } from "@/session/sessionEvents" import type { SidebarPullRequestStatus } from "@/sidebar/sidebarGitStatus" -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" -import { TaskInspectorAgent } from "./TaskInspectorAgent" -import { TaskInspectorDetails, TaskInspectorInsights } from "./TaskInspectorSummary" import type { BoardTask } from "./taskBoard" -import type { InspectorTab, SessionProjection } from "./workspaceTypes" +import { TaskInspectorAgent } from "./TaskInspectorAgent" +import { TaskInspectorDetails } from "./TaskInspectorSummary" +import type { + InspectorTab, + SessionProjection, + TranscriptPreviewState, +} from "./workspaceTypes" -interface TaskInspectorProps { +export interface TaskInspectorProps { t: Translate task: BoardTask | null session: SessionProjection | null - pullRequest: SidebarPullRequestStatus | null + pullRequest: SidebarPullRequestStatus | null | undefined + transcript: TranscriptPreviewState + pendingInput: PermissionQueueItem | null tab: InspectorTab prompt: string + promptSubmitting: boolean + canAskSession: boolean onTabChange: (tab: InspectorTab) => void onPromptChange: (value: string) => void onSubmitPrompt: (event: FormEvent) => void onOpenSession?: (id: string) => void onStartTask?: (task: BoardTask) => void onCopyCheckout: (path: string) => void + onAnswerPermission?: (request: PermissionQueueItem, optionId: string | null) => Promise + onAnswerElicitation?: (request: PermissionQueueItem, answer: ElicitationAnswer) => Promise + onAttentionAccepted: () => void + onSplitSession?: (id: string, edge: "right" | "bottom") => void + onForkSession?: (id: string, throughSeq: number, title: string) => void } export function TaskInspector(props: TaskInspectorProps) { @@ -44,17 +59,19 @@ export function TaskInspector(props: TaskInspectorProps) { {props.t("taskboard.inspector.agent")} {props.t("taskboard.inspector.details")} - {props.t("taskboard.inspector.insights")}
      - - - - + ) diff --git a/apps/desktop/src/taskboard/TaskInspectorAgent.tsx b/apps/desktop/src/taskboard/TaskInspectorAgent.tsx index 89ceac47..cb366fd2 100644 --- a/apps/desktop/src/taskboard/TaskInspectorAgent.tsx +++ b/apps/desktop/src/taskboard/TaskInspectorAgent.tsx @@ -1,47 +1,52 @@ import type { FormEvent } from "react" -import { openExternal } from "@/bridge" +import { StatusIndicator, type StatusIndicatorTone } from "@/components/business/status-indicator" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" import { - CheckCircle2, - Copy, - ExternalLink, - GitBranch, - GitPullRequest, + GitFork, + Loader2, MessageSquareText, + PanelBottom, + PanelRight, Plus, Send, } from "@/components/ui/icons" -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" import { Textarea } from "@/components/ui/textarea" import type { Translate } from "@/i18n" -import { cn } from "@/lib/utils" -import type { SidebarPullRequestStatus } from "@/sidebar/sidebarGitStatus" +import type { PermissionQueueItem } from "@/session/sessionEvents" import { InspectorSection } from "./InspectorSection" +import { TaskBoardPendingInput, type TaskBoardPendingInputProps } from "./TaskBoardPendingInput" import type { BoardTask } from "./taskBoard" -import { - checkoutLabel, - PULL_REQUEST_TONES, - pullRequestStatusLabel, - sessionActivityDescription, - sessionCheckoutPath, - sessionStatusLabel, - sessionStatusTone, -} from "./workspaceModel" -import type { SessionProjection } from "./workspaceTypes" +import { sessionActivityDescription, sessionActivityKind } from "./workspaceModel" +import type { SessionProjection, TranscriptPreviewState } from "./workspaceTypes" interface TaskInspectorAgentProps { t: Translate task: BoardTask session: SessionProjection | null - pullRequest: SidebarPullRequestStatus | null + transcript: TranscriptPreviewState + pendingInput: PermissionQueueItem | null prompt: string + promptSubmitting: boolean + canAskSession: boolean onPromptChange: (value: string) => void onSubmitPrompt: (event: FormEvent) => void onOpenSession?: (id: string) => void onStartTask?: (task: BoardTask) => void - onCopyCheckout: (path: string) => void + onAnswerPermission?: TaskBoardPendingInputProps["onAnswerPermission"] + onAnswerElicitation?: TaskBoardPendingInputProps["onAnswerElicitation"] + onAttentionAccepted: () => void + onSplitSession?: (id: string, edge: "right" | "bottom") => void + onForkSession?: (id: string, throughSeq: number, title: string) => void +} + +function indicatorTone(session: SessionProjection): StatusIndicatorTone { + const activity = sessionActivityKind(session) + if (activity === "awaiting_input") return "warning" + if (activity === "failed") return "destructive" + return activity === "running" || session.archived ? "neutral" : "success" } export function TaskInspectorAgent(props: TaskInspectorAgentProps) { @@ -64,116 +69,59 @@ export function TaskInspectorAgent(props: TaskInspectorAgentProps) { ) } - const checkoutPath = sessionCheckoutPath(session) - const statusDescription = sessionActivityDescription(t, session) - const pullRequestTone = props.pullRequest - ? PULL_REQUEST_TONES[props.pullRequest.state] - : "text-muted-foreground" - + const preview = props.transcript.status === "success" ? props.transcript.preview : null return ( -
      - -
      -
      - - - {t("taskboard.sessionOrdinal", { number: session.number })} · {session.title} - - {session.current ? ( - - {t("taskboard.currentSession")} - - ) : null} -
      -
      - {t("taskboard.taskLabel")} - {task.title} - {t("taskboard.activityLabel")} - {sessionStatusLabel(t, session)} -
      -
      -
      - - -
      - -
      - - {checkoutLabel(t, session, checkoutPath)} - - - {session.worktreePath ? t("checkout.worktreeBadge") : t("checkout.projectBadge")} - -
      - {checkoutPath ? ( - +
      +
      +
      + + {t("taskboard.sessionOrdinal", { number: session.number })} · {session.title} + + {session.current ? ( + + {t("taskboard.currentSession")} + ) : null}
      - - - - {props.pullRequest ? ( -
      -
      - - - #{props.pullRequest.number} · {pullRequestStatusLabel(t, props.pullRequest.state)} - - -
      -
      - ) : ( -
      - {t("taskboard.noPullRequestForSession")} -
      - )} -
      - - -
      -
      - {props.pullRequest?.state === "merged" || props.pullRequest?.state === "open" ? ( - - ) : ( - - )} - {t("taskboard.deliveryCheck")} - - {props.pullRequest - ? pullRequestStatusLabel(t, props.pullRequest.state) - : t("taskboard.notAvailable")} - -
      +
      +
      - +
      - -
      -
      - - {statusDescription} -
      + {props.pendingInput ? ( + + ) : null} + + +
      + {props.transcript.status === "loading" ? ( +
      + + {t("taskboard.transcript.loading")} +
      + ) : props.transcript.status === "error" ? ( +

      {t("taskboard.transcript.failed")}

      + ) : preview && preview.entries.length > 0 ? preview.entries.map((entry) => ( +
      + + {t(entry.role === "user" ? "taskboard.transcript.you" : "taskboard.transcript.agent")} + +

      {entry.text}

      +
      + )) : ( +

      {t("taskboard.transcript.empty")}

      + )}
      - {props.onOpenSession ? ( -
      + {props.canAskSession ? ( + @@ -182,18 +130,55 @@ export function TaskInspectorAgent(props: TaskInspectorAgentProps) { rows={3} value={props.prompt} placeholder={t("taskboard.askAgentPlaceholder")} + disabled={props.promptSubmitting} onChange={(event) => props.onPromptChange(event.currentTarget.value)} /> -
      - -
      ) : null} + +
      + {props.onOpenSession ? ( + + ) : null} + {props.onForkSession ? ( + + ) : null} + {props.onSplitSession ? ( + <> + + + + ) : null} +
      ) } diff --git a/apps/desktop/src/taskboard/TaskInspectorSummary.tsx b/apps/desktop/src/taskboard/TaskInspectorSummary.tsx index 8430e479..aedd8875 100644 --- a/apps/desktop/src/taskboard/TaskInspectorSummary.tsx +++ b/apps/desktop/src/taskboard/TaskInspectorSummary.tsx @@ -1,34 +1,114 @@ +import { openExternal } from "@/bridge" import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + CheckCircle2, + Copy, + ExternalLink, + GitBranch, + GitPullRequest, +} from "@/components/ui/icons" import type { Translate } from "@/i18n" +import { cn } from "@/lib/utils" import type { SidebarPullRequestStatus } from "@/sidebar/sidebarGitStatus" import { InspectorSection } from "./InspectorSection" import { taskPriorityLabel, taskStatusLabel } from "./TaskEditorDialog" import type { BoardTask } from "./taskBoard" +import { + checkoutLabel, + PULL_REQUEST_TONES, + pullRequestStatusLabel, + sessionCheckoutPath, +} from "./workspaceModel" import type { SessionProjection } from "./workspaceTypes" -interface TaskInspectorSummaryProps { +interface TaskInspectorDetailsProps { t: Translate task: BoardTask session: SessionProjection | null - pullRequest: SidebarPullRequestStatus | null + pullRequest: SidebarPullRequestStatus | null | undefined + onCopyCheckout: (path: string) => void } -export function TaskInspectorDetails({ t, task }: TaskInspectorSummaryProps) { +export function TaskInspectorDetails(props: TaskInspectorDetailsProps) { + const { t, task, session, pullRequest } = props + const checkoutPath = sessionCheckoutPath(session ?? undefined) + return (
      -
      - {t("taskboard.editor.status")} - {taskStatusLabel(t, task.status)} - {t("taskboard.editor.priority")} - {taskPriorityLabel(t, task.priority)} - {t("taskboard.sessionsHeader")} - {t("taskboard.sessionCount", { count: task.sessionIds.length })} -
      +
      +
      {t("taskboard.editor.status")}
      +
      {taskStatusLabel(t, task.status)}
      +
      {t("taskboard.editor.priority")}
      +
      {taskPriorityLabel(t, task.priority)}
      +
      {t("taskboard.sessionsHeader")}
      +
      {t("taskboard.sessionCount", { count: task.sessionIds.length })}
      +
      + + {session ? ( + <> + +
      + +
      + + {checkoutLabel(t, session, checkoutPath)} + + + {session.worktreePath ? t("checkout.worktreeBadge") : t("checkout.projectBadge")} + +
      + {checkoutPath ? ( + + ) : null} +
      +
      + + +
      + {pullRequest === undefined ? ( + {t("taskboard.checkingPullRequest")} + ) : pullRequest ? ( + <> + {pullRequest.state === "merged" || pullRequest.state === "open" ? ( + + ) : ( + + )} + + #{pullRequest.number} · {pullRequestStatusLabel(t, pullRequest.state)} + + + + ) : ( + {t("taskboard.noPullRequestForSession")} + )} +
      +
      + + ) : null} + -

      +

      {task.description || t("taskboard.noDescription")}

      @@ -42,30 +122,3 @@ export function TaskInspectorDetails({ t, task }: TaskInspectorSummaryProps) {
      ) } - -export function TaskInspectorInsights({ - t, - task, - session, - pullRequest, -}: TaskInspectorSummaryProps) { - return ( -
      - -

      - {t("taskboard.relationshipDescription")} -

      -
      - -
      - {t("taskboard.taskLabel")} - {task.title} - {t("taskboard.currentSession")} - {session?.title ?? t("taskboard.none")} - {t("taskboard.primaryPullRequest")} - {pullRequest ? `#${pullRequest.number}` : t("taskboard.none")} -
      -
      -
      - ) -} diff --git a/apps/desktop/src/taskboard/TaskListItem.tsx b/apps/desktop/src/taskboard/TaskListItem.tsx index 0bd4be9a..beccf0d6 100644 --- a/apps/desktop/src/taskboard/TaskListItem.tsx +++ b/apps/desktop/src/taskboard/TaskListItem.tsx @@ -62,6 +62,7 @@ export function TaskListItem({ >