diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index e14a3e86..0dc952b4 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -420,6 +420,7 @@ import { unlinkTaskPullRequest, type BoardTask, } from "./taskboard/taskBoard"; +import { continueTaskBoardPrompt } from "./taskboard/taskBoardContinuation"; import { actionForEvent, @@ -7760,14 +7761,25 @@ export default function App() { void selectSession(id); }} onAskSession={(id, prompt) => { + const paneId = + paneBoundToSession(paneContentsRef.current, id) ?? + focusedPaneRef.current; setShowTaskBoard(false); - void selectSession(id).then(() => { - clearEditorRef.current?.(); - setDocMode(true); - setTimeout(() => { - void insertMarkdownRef.current?.(prompt, "replace"); - focusEditorRef.current?.(); - }, 0); + void continueTaskBoardPrompt({ + target: { paneId, sessionId: id }, + prompt, + selectSession: (sessionId, targetPaneId) => + selectSession(sessionId, targetPaneId), + isTargetActive: () => + focusedPaneRef.current === paneId && + paneContentsRef.current[paneId]?.sessionId === id, + openDocumentMode: () => setDocMode(true), + insertMarkdown: (markdown, mode) => + paneEditorRefsFor(paneId).insertMarkdownRef.current?.( + markdown, + mode, + ) ?? Promise.resolve(), + focusEditor: () => paneEditorRefsFor(paneId).focusRef.current?.(), }); }} onStartTask={startBoardTask} diff --git a/apps/desktop/src/components/business/page-header.tsx b/apps/desktop/src/components/business/page-header.tsx index 158b5fa0..432b4c0f 100644 --- a/apps/desktop/src/components/business/page-header.tsx +++ b/apps/desktop/src/components/business/page-header.tsx @@ -2,20 +2,24 @@ import type { ReactNode } from "react" interface PageHeaderProps { title: ReactNode + titleAccessory?: ReactNode description?: ReactNode actions?: ReactNode } -function PageHeader({ title, description, actions }: PageHeaderProps) { +function PageHeader({ title, titleAccessory, description, actions }: PageHeaderProps) { return (
-

- {title} -

+
+

+ {title} +

+ {titleAccessory} +
{description ? (

= { light: { - surface: 1, + surface: 2, raised: 0.25, sidebar: 3, muted: 6, diff --git a/apps/desktop/src/design/tokens.css b/apps/desktop/src/design/tokens.css index 71d958a3..a8d48862 100644 --- a/apps/desktop/src/design/tokens.css +++ b/apps/desktop/src/design/tokens.css @@ -63,7 +63,7 @@ /* Foundation: light palette. */ --ds-foundation-light-canvas: oklch(0.976 0.003 250); --ds-foundation-light-sidebar: oklch(0.956 0.004 250); - --ds-foundation-light-surface: oklch(0.992 0.002 250); + --ds-foundation-light-surface: oklch(0.984 0.002 250); --ds-foundation-light-raised: oklch(0.998 0.001 250); --ds-foundation-light-modal: oklch(1 0 0); --ds-foundation-light-text: oklch(0.18 0.02 265); diff --git a/apps/desktop/src/i18n/strings.ts b/apps/desktop/src/i18n/strings.ts index c71f2f8e..c3a3bdde 100644 --- a/apps/desktop/src/i18n/strings.ts +++ b/apps/desktop/src/i18n/strings.ts @@ -1992,16 +1992,18 @@ export const en = { "taskboard.expandTask": "Expand task: {title}", "taskboard.collapseTask": "Collapse task: {title}", "taskboard.taskSessions": "Sessions for {title}", - "taskboard.selectSession": "Select Session {number}: {title}", + "taskboard.selectSession": "Select Session {number}: {title}, status: {status}", "taskboard.sessionOrdinal": "S-{number}", "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", @@ -2009,9 +2011,11 @@ export const en = { "taskboard.inspector.details": "Details", "taskboard.inspector.insights": "Insights", "taskboard.showInspector": "Show inspector", + "taskboard.backToTasks": "Back to tasks", "taskboard.hideInspector": "Hide inspector", "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", @@ -4560,16 +4564,18 @@ export const zhCN: Record = { "taskboard.expandTask": "展开任务:{title}", "taskboard.collapseTask": "收起任务:{title}", "taskboard.taskSessions": "{title} 的 Sessions", - "taskboard.selectSession": "选择 Session {number}:{title}", + "taskboard.selectSession": "选择 Session {number}:{title},状态:{status}", "taskboard.sessionOrdinal": "S-{number}", "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": "检查器视图", @@ -4577,9 +4583,11 @@ export const zhCN: Record = { "taskboard.inspector.details": "详情", "taskboard.inspector.insights": "洞察", "taskboard.showInspector": "显示检查器", + "taskboard.backToTasks": "返回任务列表", "taskboard.hideInspector": "隐藏检查器", "taskboard.selectTask": "选择一个任务以查看详情。", "taskboard.currentSessionTitle": "当前 Session", + "taskboard.selectedSession": "选中的 Session", "taskboard.taskLabel": "任务", "taskboard.activityLabel": "状态", "taskboard.checkoutTitle": "检出目录", diff --git a/apps/desktop/src/taskboard/TaskBoardHeader.tsx b/apps/desktop/src/taskboard/TaskBoardHeader.tsx index cf89c298..46578098 100644 --- a/apps/desktop/src/taskboard/TaskBoardHeader.tsx +++ b/apps/desktop/src/taskboard/TaskBoardHeader.tsx @@ -1,6 +1,5 @@ -import type { ReactNode } from "react" - -import { ChevronRight, Filter, PanelRight, Plus } from "@/components/ui/icons" +import { Filter, Plus } from "@/components/ui/icons" +import { PageHeader } from "@/components/business/page-header" import { SearchField } from "@/components/business/search-field" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" @@ -21,9 +20,6 @@ import { taskPriorityLabel } from "./TaskEditorDialog" interface TaskBoardHeaderProps { t: Translate taskCount: number - headerLeadingAction?: ReactNode - inspectorOpen: boolean - onShowInspector: () => void filtersOpen: boolean onFiltersOpenChange: (open: boolean) => void activeFilterCount: number @@ -42,43 +38,16 @@ export function TaskBoardHeader(props: TaskBoardHeaderProps) { const { t } = props return (

-
- {props.headerLeadingAction ? ( -
{props.headerLeadingAction}
- ) : null} - -
- {!props.inspectorOpen ? ( - - ) : null} -
- -
-
-
-

{t("taskboard.allTasks")}

+
+ {props.taskCount} -
-

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

-
- -
+ } + description={t("taskboard.workspaceDescription")} + actions={<> {t("taskboard.new")} -
+ } + />
) diff --git a/apps/desktop/src/taskboard/TaskBoardList.tsx b/apps/desktop/src/taskboard/TaskBoardList.tsx index a86e2c45..c5a9768f 100644 --- a/apps/desktop/src/taskboard/TaskBoardList.tsx +++ b/apps/desktop/src/taskboard/TaskBoardList.tsx @@ -1,4 +1,5 @@ import { Button } from "@/components/ui/button" +import { Separator } from "@/components/ui/separator" import type { Locale, Translate } from "@/i18n" import type { SidebarPullRequestStatus } from "@/sidebar/sidebarGitStatus" @@ -31,13 +32,15 @@ export function TaskBoardList(props: TaskBoardListProps) { const { t } = props return (
-
+ +
{t("taskboard.titleHeader")} {t("taskboard.sessionsHeader")} {t("taskboard.openPullRequestsHeader")} {t("taskboard.updatedHeader")}
+ {props.renderedTasks.length > 0 ? (
    {props.renderedTasks.map((projected) => ( diff --git a/apps/desktop/src/taskboard/TaskBoardPage.tsx b/apps/desktop/src/taskboard/TaskBoardPage.tsx index 99359d15..999bdf23 100644 --- a/apps/desktop/src/taskboard/TaskBoardPage.tsx +++ b/apps/desktop/src/taskboard/TaskBoardPage.tsx @@ -1,8 +1,9 @@ -import { useEffect, useState } from "react" +import { useEffect, useLayoutEffect, useRef, useState } from "react" import { githubCurrentPullRequest } from "@/bridge" import { Button } from "@/components/ui/button" -import { X } from "@/components/ui/icons" +import { ChevronLeft, ChevronRight, PanelRight } from "@/components/ui/icons" +import { Separator } from "@/components/ui/separator" import { useLanguage } from "@/i18n" import { TaskBoardHeader } from "./TaskBoardHeader" @@ -17,15 +18,13 @@ import { useTaskPullRequests } from "./useTaskPullRequests" import { INITIAL_TASK_LIMIT, sessionCheckoutPath } from "./workspaceModel" import type { EditorState, InspectorTab, TaskBoardPageProps } from "./workspaceTypes" import "./task-board.css" - export type { TaskBoardSession } from "./workspaceTypes" - +const NARROW_BOARD_WIDTH_REM = 48 function toggleValue(values: readonly T[], value: T): T[] { return values.includes(value) ? values.filter((candidate) => candidate !== value) : [...values, value] } - export function TaskBoardPage({ sessions = [], onOpenSession, @@ -41,25 +40,58 @@ export function TaskBoardPage({ const [editor, setEditor] = useState(null) const [filtersOpen, setFiltersOpen] = useState(false) const [inspectorOpen, setInspectorOpen] = useState(true) + const [isNarrow, setIsNarrow] = useState(false) const [inspectorTab, setInspectorTab] = useState("agent") const [prompt, setPrompt] = useState("") const [visibleTaskLimit, setVisibleTaskLimit] = useState(INITIAL_TASK_LIMIT) + const pageRef = useRef(null) + const showInspectorButtonRef = useRef(null) + const backToTasksButtonRef = useRef(null) + const restoreInspectorFocus = useRef(false) + const wasNarrow = useRef(null) const data = useTaskBoardData(locale, t, sessions, query, priorities, labels) - const selection = useTaskBoardSelection(data.allProjectedTasks, data.projectedTasks) - useEffect(() => setPrompt(""), [selection.selectedSessionId]) useEffect(() => setVisibleTaskLimit(INITIAL_TASK_LIMIT), [data.deferredQuery, labels, priorities]) - - const pullRequestsByPath = useTaskPullRequests(data.allProjectedTasks, loadPullRequest) + useLayoutEffect(() => { + const page = pageRef.current + if (!page || typeof ResizeObserver === "undefined") return + const updateLayout = (): void => { + const rootFontSize = Number.parseFloat(getComputedStyle(document.documentElement).fontSize) || 16 + const width = page.clientWidth + if (width <= 0) return + const narrow = width <= NARROW_BOARD_WIDTH_REM * rootFontSize + if (narrow && wasNarrow.current !== true) setInspectorOpen(false) + if (!narrow) setInspectorOpen(true) + setIsNarrow(narrow) + wasNarrow.current = narrow + } + updateLayout() + const observer = new ResizeObserver(updateLayout) + observer.observe(page) + return () => observer.disconnect() + }, []) + useLayoutEffect(() => { + if (isNarrow && inspectorOpen) { + backToTasksButtonRef.current?.focus() + return + } + if (inspectorOpen || !restoreInspectorFocus.current) return + restoreInspectorFocus.current = false + showInspectorButtonRef.current?.focus() + }, [inspectorOpen, isNarrow]) const selectedCheckoutPath = sessionCheckoutPath(selection.selectedSession ?? undefined) + const pullRequestsByPath = useTaskPullRequests( + data.allProjectedTasks, + selectedCheckoutPath, + loadPullRequest, + ) const selectedPullRequest = selectedCheckoutPath - ? pullRequestsByPath.get(selectedCheckoutPath) ?? null + ? pullRequestsByPath.get(selectedCheckoutPath) : null const activeFilterCount = (query.trim() ? 1 : 0) + priorities.length + labels.length const renderedTasks = data.projectedTasks.slice(0, visibleTaskLimit) const remainingTaskCount = Math.max(0, data.projectedTasks.length - renderedTasks.length) - const clearFilters = (): void => { setQuery("") setPriorities([]) @@ -83,92 +115,123 @@ export function TaskBoardPage({ setInspectorTab, setPrompt, clearFilters, + keepInspectorInPlace: isNarrow, }) const moveTask = (task: BoardTask, status: BoardTask["status"]): void => { data.dispatch({ type: "move", id: task.id, status, now: Date.now() }) } - + const changeInspectorOpen = (open: boolean): void => { + if (!open && isNarrow) restoreInspectorFocus.current = true + setInspectorOpen(open) + } return (
    -
    -
    - setInspectorOpen(true)} - filtersOpen={filtersOpen} - onFiltersOpenChange={setFiltersOpen} - activeFilterCount={activeFilterCount} - query={query} - onQueryChange={setQuery} - priorities={priorities} - onTogglePriority={(priority) => setPriorities((values) => toggleValue(values, priority))} - labels={labels} - availableLabels={data.availableLabels} - onToggleLabel={(label) => setLabels((values) => toggleValue(values, label))} - onClearFilters={clearFilters} - onCreateTask={() => actions.openEditor(null, "todo")} - /> - {data.warning ? ( -

    - {data.warning} -

    - ) : null} - actions.openEditor(task, task.status)} - onDeleteTask={(task) => void actions.deleteTask(task)} - onMoveTask={moveTask} - onStartTask={onStartTask} - onShowMore={() => setVisibleTaskLimit((limit) => limit + INITIAL_TASK_LIMIT)} - /> -
    - + ) : null} +
    + +
    + {!isNarrow || !inspectorOpen ? ( +
    + setPriorities((values) => toggleValue(values, priority))} + labels={labels} + availableLabels={data.availableLabels} + onToggleLabel={(label) => setLabels((values) => toggleValue(values, label))} + onClearFilters={clearFilters} + onCreateTask={() => actions.openEditor(null, "todo")} + /> + {data.warning ? ( +

    + {data.warning} +

    + ) : null} + actions.openEditor(task, task.status)} + onDeleteTask={(task) => void actions.deleteTask(task)} + onMoveTask={moveTask} + onStartTask={onStartTask} + onShowMore={() => setVisibleTaskLimit((limit) => limit + INITIAL_TASK_LIMIT)} + /> +
    + ) : null} + {!isNarrow || inspectorOpen ? ( + + ) : null}
    {editor ? ( void @@ -38,15 +39,16 @@ export function TaskInspector(props: TaskInspectorProps) { props.onTabChange(value as InspectorTab)} - className="min-h-0 flex-1 gap-0" + className="min-h-0 flex-1 flex-col gap-0" > -
    - +
    + {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..7cdc9b72 100644 --- a/apps/desktop/src/taskboard/TaskInspectorAgent.tsx +++ b/apps/desktop/src/taskboard/TaskInspectorAgent.tsx @@ -14,6 +14,7 @@ import { import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Textarea } from "@/components/ui/textarea" +import { StatusIndicator } from "@/components/business/status-indicator" import type { Translate } from "@/i18n" import { cn } from "@/lib/utils" import type { SidebarPullRequestStatus } from "@/sidebar/sidebarGitStatus" @@ -35,7 +36,7 @@ interface TaskInspectorAgentProps { t: Translate task: BoardTask session: SessionProjection | null - pullRequest: SidebarPullRequestStatus | null + pullRequest: SidebarPullRequestStatus | null | undefined prompt: string onPromptChange: (value: string) => void onSubmitPrompt: (event: FormEvent) => void @@ -72,10 +73,9 @@ export function TaskInspectorAgent(props: TaskInspectorAgentProps) { return (
    - -
    + +
    - {t("taskboard.sessionOrdinal", { number: session.number })} · {session.title} @@ -95,7 +95,7 @@ export function TaskInspectorAgent(props: TaskInspectorAgentProps) { -
    +
    @@ -120,8 +120,12 @@ export function TaskInspectorAgent(props: TaskInspectorAgentProps) { - {props.pullRequest ? ( -
    + {props.pullRequest === undefined ? ( +
    + {t("taskboard.checkingPullRequest")} +
    + ) : props.pullRequest ? ( +
    @@ -139,14 +143,14 @@ export function TaskInspectorAgent(props: TaskInspectorAgentProps) {
    ) : ( -
    +
    {t("taskboard.noPullRequestForSession")}
    )} -
    +
    {props.pullRequest?.state === "merged" || props.pullRequest?.state === "open" ? ( @@ -155,25 +159,24 @@ export function TaskInspectorAgent(props: TaskInspectorAgentProps) { )} {t("taskboard.deliveryCheck")} - {props.pullRequest - ? pullRequestStatusLabel(t, props.pullRequest.state) - : t("taskboard.notAvailable")} + {props.pullRequest === undefined + ? t("taskboard.checkingPullRequest") + : props.pullRequest + ? pullRequestStatusLabel(t, props.pullRequest.state) + : t("taskboard.notAvailable")}
    -
    -
    - - {statusDescription} -
    +
    +
    {props.onOpenSession ? ( -
    + diff --git a/apps/desktop/src/taskboard/TaskInspectorSummary.tsx b/apps/desktop/src/taskboard/TaskInspectorSummary.tsx index 8430e479..05bb841e 100644 --- a/apps/desktop/src/taskboard/TaskInspectorSummary.tsx +++ b/apps/desktop/src/taskboard/TaskInspectorSummary.tsx @@ -11,14 +11,14 @@ interface TaskInspectorSummaryProps { t: Translate task: BoardTask session: SessionProjection | null - pullRequest: SidebarPullRequestStatus | null + pullRequest: SidebarPullRequestStatus | null | undefined } export function TaskInspectorDetails({ t, task }: TaskInspectorSummaryProps) { return (
    -
    +
    {t("taskboard.editor.status")} {taskStatusLabel(t, task.status)} {t("taskboard.editor.priority")} @@ -28,7 +28,7 @@ export function TaskInspectorDetails({ t, task }: TaskInspectorSummaryProps) {
    -

    +

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

    @@ -52,15 +52,15 @@ export function TaskInspectorInsights({ return (
    -

    +

    {t("taskboard.relationshipDescription")}

    -
    +
    {t("taskboard.taskLabel")} {task.title} - {t("taskboard.currentSession")} + {t("taskboard.selectedSession")} {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..72b9b8d6 100644 --- a/apps/desktop/src/taskboard/TaskListItem.tsx +++ b/apps/desktop/src/taskboard/TaskListItem.tsx @@ -1,5 +1,7 @@ import { ChevronRight, Plus } from "@/components/ui/icons" import { Button } from "@/components/ui/button" +import { StatusIndicator } from "@/components/business/status-indicator" +import { Separator } from "@/components/ui/separator" import type { Locale, Translate } from "@/i18n" import { cn } from "@/lib/utils" import type { SidebarPullRequestStatus } from "@/sidebar/sidebarGitStatus" @@ -9,7 +11,8 @@ import { TaskSessionRow } from "./TaskSessionRow" import type { BoardTask, TaskStatus } from "./taskBoard" import { formatUpdatedAt, - LANE_DOT_TONES, + LANE_TONES, + laneLabel, openPullRequestCount, sessionCheckoutPath, } from "./workspaceModel" @@ -51,7 +54,7 @@ export function TaskListItem({ const selected = selectedTaskId === task.id return ( -
  • +
  • - + {task.title} - {openPrs} + {openPrs ?? "—"} {formatUpdatedAt(task.updatedAt, locale, t)} @@ -121,7 +128,7 @@ export function TaskListItem({ {expanded ? (
    {sessions.length > 0 ? sessions.map((session) => { @@ -134,7 +141,7 @@ export function TaskListItem({ task={task} session={session} selected={session.id === selectedSessionId} - pullRequest={path ? pullRequestsByPath.get(path) ?? null : null} + pullRequest={path ? pullRequestsByPath.get(path) : null} onSelect={() => onSelectSession(session.id)} /> ) @@ -151,6 +158,7 @@ export function TaskListItem({ )}
    ) : null} +
  • ) } diff --git a/apps/desktop/src/taskboard/TaskSessionRow.tsx b/apps/desktop/src/taskboard/TaskSessionRow.tsx index ae444732..f1cb8d81 100644 --- a/apps/desktop/src/taskboard/TaskSessionRow.tsx +++ b/apps/desktop/src/taskboard/TaskSessionRow.tsx @@ -1,6 +1,7 @@ import { GitBranch, GitPullRequest } from "@/components/ui/icons" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" +import { StatusIndicator } from "@/components/business/status-indicator" import type { Locale, Translate } from "@/i18n" import { cn } from "@/lib/utils" import type { SidebarPullRequestStatus } from "@/sidebar/sidebarGitStatus" @@ -12,6 +13,7 @@ import { PULL_REQUEST_TONES, pullRequestStatusLabel, sessionCheckoutPath, + sessionStatusLabel, sessionStatusTone, sessionUpdatedAt, } from "./workspaceModel" @@ -23,7 +25,7 @@ interface TaskSessionRowProps { task: BoardTask session: SessionProjection selected: boolean - pullRequest: SidebarPullRequestStatus | null + pullRequest: SidebarPullRequestStatus | null | undefined onSelect: () => void } @@ -53,11 +55,15 @@ export function TaskSessionRow({ aria-label={t("taskboard.selectSession", { number: session.number, title: session.title, + status: sessionStatusLabel(t, session), })} onClick={onSelect} > - + {t("taskboard.sessionOrdinal", { number: session.number })} · {session.title} @@ -79,9 +85,13 @@ export function TaskSessionRow({ - {pullRequest ? ( + {pullRequest === undefined ? ( + + {t("taskboard.checkingPullRequest")} + + ) : pullRequest ? ( diff --git a/apps/desktop/src/taskboard/task-board.css b/apps/desktop/src/taskboard/task-board.css index 008207b7..e8eb6de5 100644 --- a/apps/desktop/src/taskboard/task-board.css +++ b/apps/desktop/src/taskboard/task-board.css @@ -1,20 +1,18 @@ .task-board-page { container-type: inline-size; - position: relative; + display: flex; + flex-direction: column; } .task-board-layout { display: grid; - grid-template-columns: minmax(0, 1fr) 22.5rem; - position: relative; -} - -.task-board-page[data-inspector-open="false"] .task-board-layout { - grid-template-columns: minmax(0, 1fr); + grid-template-columns: minmax(0, 1fr) 24rem; + min-height: 0; + flex: 1; } -.task-board-page[data-inspector-open="false"] .task-board-inspector { - display: none; +.task-board-panel-header { + min-height: 6rem; } .task-board-task-row, @@ -31,8 +29,15 @@ .task-board-inspector { display: flex; flex-direction: column; + margin: 0.75rem; overflow: hidden; - position: relative; + border-radius: var(--ds-radius-module); + box-shadow: var(--ds-elevation-surface); +} + +.task-board-inspector .task-board-panel-header { + min-height: 5.25rem; + padding-block: 0.5rem; } .task-board-inspector > [data-slot="tabs"] { @@ -41,7 +46,6 @@ .task-board-row-action { opacity: 0; - transition: opacity 150ms ease; } .task-board-task-row:hover .task-board-row-action, @@ -51,7 +55,7 @@ @container (max-width: 64rem) { .task-board-layout { - grid-template-columns: minmax(0, 1fr) 20rem; + grid-template-columns: minmax(0, 1fr) 21.5rem; } .task-board-task-row, @@ -70,27 +74,24 @@ } @container (max-width: 48rem) { - .task-board-layout, - .task-board-page[data-inspector-open="false"] .task-board-layout { + .task-board-layout { display: block; } - .task-board-workspace { + .task-board-workspace, + .task-board-inspector { height: 100%; } .task-board-inspector { - box-shadow: var(--ds-elevation-modal); - height: 100%; - inset-block: 0; - inset-inline-end: 0; - position: absolute; - width: min(22.5rem, calc(100% - 4rem)); - z-index: 30; + margin: 0; + border-radius: 0; + box-shadow: none; } - .task-board-page[data-inspector-open="false"] .task-board-inspector { - display: none; + .task-board-inspector .task-board-panel-header { + min-height: 6rem; + padding-block: 1.25rem; } .task-board-task-row, diff --git a/apps/desktop/src/taskboard/taskBoardContinuation.ts b/apps/desktop/src/taskboard/taskBoardContinuation.ts new file mode 100644 index 00000000..13e64ff6 --- /dev/null +++ b/apps/desktop/src/taskboard/taskBoardContinuation.ts @@ -0,0 +1,39 @@ +interface TaskBoardPromptTarget { + paneId: string + sessionId: string +} + +interface ContinueTaskBoardPromptOptions { + target: TaskBoardPromptTarget + prompt: string + selectSession: (sessionId: string, paneId: string) => Promise + isTargetActive: () => boolean + openDocumentMode: () => void + insertMarkdown: (markdown: string, mode: "append") => Promise + focusEditor: () => void +} + +/** Continue only in the pane that accepted the Session navigation, preserving its existing draft. */ +async function continueTaskBoardPrompt({ + target, + prompt, + selectSession, + isTargetActive, + openDocumentMode, + insertMarkdown, + focusEditor, +}: ContinueTaskBoardPromptOptions): Promise { + await selectSession(target.sessionId, target.paneId) + if (!isTargetActive()) return false + + openDocumentMode() + await insertMarkdown(prompt, "append") + if (isTargetActive()) focusEditor() + return true +} + +export { + continueTaskBoardPrompt, + type ContinueTaskBoardPromptOptions, + type TaskBoardPromptTarget, +} diff --git a/apps/desktop/src/taskboard/useTaskBoardActions.ts b/apps/desktop/src/taskboard/useTaskBoardActions.ts index 4a018379..570be886 100644 --- a/apps/desktop/src/taskboard/useTaskBoardActions.ts +++ b/apps/desktop/src/taskboard/useTaskBoardActions.ts @@ -38,6 +38,7 @@ interface TaskBoardActionsOptions { setInspectorTab: Dispatch> setPrompt: Dispatch> clearFilters: () => void + keepInspectorInPlace: boolean } function nextColumnOrder(tasks: readonly BoardTask[], status: TaskStatus): number { @@ -69,7 +70,7 @@ export function useTaskBoardActions(options: TaskBoardActionsOptions) { options.dispatch({ type: "create", task }) options.setSelectedTaskId(task.id) options.setExpandedTaskIds((current) => new Set(current).add(task.id)) - options.setInspectorOpen(true) + if (!options.keepInspectorInPlace) options.setInspectorOpen(true) if (filterBoardTasks([task], options.filters).length === 0) { options.toast(options.t("taskboard.createdHidden", { title: task.title }), "info", { label: options.t("taskboard.clearFilters"), @@ -96,7 +97,7 @@ export function useTaskBoardActions(options: TaskBoardActionsOptions) { else next.add(projected.task.id) return next }) - options.setInspectorOpen(true) + if (!options.keepInspectorInPlace) options.setInspectorOpen(true) } const selectSession = (taskId: string, sessionId: string): void => { diff --git a/apps/desktop/src/taskboard/useTaskPullRequests.ts b/apps/desktop/src/taskboard/useTaskPullRequests.ts index 4fb62fe7..43be11d2 100644 --- a/apps/desktop/src/taskboard/useTaskPullRequests.ts +++ b/apps/desktop/src/taskboard/useTaskPullRequests.ts @@ -11,23 +11,26 @@ import type { ProjectedTask } from "./workspaceTypes" export function useTaskPullRequests( tasks: readonly ProjectedTask[], + selectedCheckoutPath: string | null, loadPullRequest: (path: string) => Promise, ): ReadonlyMap { const [refresh, setRefresh] = useState(0) const [pullRequestsByPath, setPullRequestsByPath] = useState< ReadonlyMap >(() => new Map()) - const targetPaths = useMemo( - () => [...new Set( - tasks - .flatMap(({ sessions }) => sessions) - .flatMap((session) => { - const path = sessionCheckoutPath(session) - return path ? [path] : [] - }), - )].slice(0, 48), - [tasks], - ) + const targetPaths = useMemo(() => { + const paths = new Set() + for (const { sessions } of tasks) { + for (const session of sessions) { + const path = sessionCheckoutPath(session) + if (path) paths.add(path) + if (paths.size === 48) break + } + if (paths.size === 48) break + } + if (selectedCheckoutPath) paths.add(selectedCheckoutPath) + return [...paths] + }, [tasks, selectedCheckoutPath]) const targetKey = targetPaths.join("\u0000") useEffect(() => { @@ -41,7 +44,12 @@ export function useTaskPullRequests( targetPaths.map((path) => ({ path })), loadPullRequest, ).then((statuses) => { - if (active) setPullRequestsByPath(statuses) + if (!active) return + const completedStatuses = new Map(statuses) + for (const path of targetPaths) { + if (!completedStatuses.has(path)) completedStatuses.set(path, null) + } + setPullRequestsByPath(completedStatuses) }) return () => { active = false diff --git a/apps/desktop/src/taskboard/workspaceModel.ts b/apps/desktop/src/taskboard/workspaceModel.ts index 9cca323c..5e8fdd97 100644 --- a/apps/desktop/src/taskboard/workspaceModel.ts +++ b/apps/desktop/src/taskboard/workspaceModel.ts @@ -1,4 +1,5 @@ import type { Locale, Translate } from "@/i18n" +import type { StatusIndicatorTone } from "@/components/business/status-indicator" import type { SidebarPullRequestStatus } from "@/sidebar/sidebarGitStatus" import { taskBoardLane, type BoardTask, type TaskSessionActivityKind } from "./taskBoard" @@ -6,11 +7,11 @@ import type { ProjectedTask, SessionProjection } from "./workspaceTypes" export const INITIAL_TASK_LIMIT = 40 -export const LANE_DOT_TONES: Record = { - queue: "bg-muted-foreground/55", - running: "bg-primary", - needs_you: "bg-warning", - done: "bg-success", +export const LANE_TONES: Record = { + queue: "neutral", + running: "success", + needs_you: "warning", + done: "success", } export const PULL_REQUEST_TONES: Record = { @@ -70,12 +71,19 @@ export function sessionStatusLabel(t: Translate, session: SessionProjection): st return t("session.completed") } -export function sessionStatusTone(session: SessionProjection): string { +export function sessionStatusTone(session: SessionProjection): StatusIndicatorTone { const kind = sessionActivityKind(session) - if (kind === "awaiting_input") return "bg-warning" - if (kind === "failed") return "bg-destructive" - if (kind === "running") return "bg-primary" - return session.archived ? "bg-muted-foreground/40" : "bg-success" + if (kind === "awaiting_input") return "warning" + if (kind === "failed") return "destructive" + if (kind === "running") return "success" + return session.archived ? "neutral" : "success" +} + +export function laneLabel(t: Translate, lane: ProjectedTask["lane"]): string { + if (lane === "queue") return t("taskboard.lane.queue") + if (lane === "running") return t("taskboard.lane.running") + if (lane === "needs_you") return t("taskboard.lane.needsYou") + return t("taskboard.lane.done") } export function sessionActivityDescription(t: Translate, session: SessionProjection): string { @@ -120,12 +128,16 @@ export function projectTasks( export function openPullRequestCount( sessions: readonly SessionProjection[], pullRequestsByPath: ReadonlyMap, -): number { - return sessions.filter((session) => { +): number | null { + let count = 0 + let unresolved = false + for (const session of sessions) { const path = sessionCheckoutPath(session) + if (path && !pullRequestsByPath.has(path)) unresolved = true const state = path ? pullRequestsByPath.get(path)?.state : null - return Boolean(state && state !== "merged" && state !== "closed") - }).length + if (state && state !== "merged" && state !== "closed") count += 1 + } + return unresolved ? null : count } export function pullRequestStatusLabel( diff --git a/apps/desktop/tests/taskBoard.test.ts b/apps/desktop/tests/taskBoard.test.ts index bbc41906..944cbee1 100644 --- a/apps/desktop/tests/taskBoard.test.ts +++ b/apps/desktop/tests/taskBoard.test.ts @@ -36,6 +36,7 @@ import { type TaskPriority, type TaskStatus, } from "../src/taskboard/taskBoard"; +import { continueTaskBoardPrompt } from "../src/taskboard/taskBoardContinuation"; class MemoryStorage implements StorageLike { readonly values = new Map(); @@ -51,6 +52,54 @@ class MemoryStorage implements StorageLike { const BASE_TIME = Date.UTC(2026, 7, 13, 10); +describe("TaskBoard prompt continuation", () => { + test("appends to an existing destination draft without clearing it", async () => { + const events: string[] = []; + + const inserted = await continueTaskBoardPrompt({ + target: { paneId: "pane-a", sessionId: "session-a" }, + prompt: "Review this approach", + selectSession: async () => { events.push("selected"); }, + isTargetActive: () => true, + openDocumentMode: () => events.push("document"), + insertMarkdown: async (markdown, mode) => { events.push(`${mode}:${markdown}`); }, + focusEditor: () => events.push("focused"), + }); + + expect(inserted).toBe(true); + expect(events).toEqual([ + "selected", + "document", + "append:Review this approach", + "focused", + ]); + }); + + test("drops a stale insertion when focus changes during Session loading", async () => { + let resolveSelection: (() => void) | null = null; + let active = true; + const events: string[] = []; + const selection = new Promise((resolve) => { + resolveSelection = resolve; + }); + const continuation = continueTaskBoardPrompt({ + target: { paneId: "pane-a", sessionId: "session-a" }, + prompt: "Do not redirect this", + selectSession: () => selection, + isTargetActive: () => active, + openDocumentMode: () => events.push("document"), + insertMarkdown: async () => { events.push("inserted"); }, + focusEditor: () => events.push("focused"), + }); + + active = false; + resolveSelection?.(); + + expect(await continuation).toBe(false); + expect(events).toEqual([]); + }); +}); + function task( id: string, status: TaskStatus = "todo", diff --git a/apps/desktop/tests/taskBoardRendered.test.tsx b/apps/desktop/tests/taskBoardRendered.test.tsx index 04801de4..3d3b4e00 100644 --- a/apps/desktop/tests/taskBoardRendered.test.tsx +++ b/apps/desktop/tests/taskBoardRendered.test.tsx @@ -27,6 +27,7 @@ const { TaskBoardPage } = await import("../src/taskboard/TaskBoardPage") const mountedRoots = [] const previousLocalStorage = globalThis.localStorage const originalConfirm = dom.window.confirm +const originalResizeObserver = globalThis.ResizeObserver function installStorage() { Object.defineProperty(globalThis, "localStorage", { @@ -43,6 +44,8 @@ afterEach(async () => { dom.document.body.replaceChildren() dom.window.localStorage.clear() dom.window.confirm = originalConfirm + if (originalResizeObserver === undefined) delete globalThis.ResizeObserver + else globalThis.ResizeObserver = originalResizeObserver if (previousLocalStorage === undefined) delete globalThis.localStorage else { Object.defineProperty(globalThis, "localStorage", { @@ -142,6 +145,8 @@ describe("TaskBoardPage rendered", () => { expect(view.container.textContent).toContain("每个 Session 最多对应一个当前 pull request") expect(view.container.querySelector("[data-task-board-page]")).not.toBeNull() expect(view.container.querySelectorAll("[data-task-column]")).toHaveLength(0) + expect(view.container.querySelectorAll('[data-slot="status-indicator"]').length).toBeGreaterThan(0) + expect(view.container.textContent).toContain("队列") expect(view.container.querySelectorAll("[data-task-item]")).toHaveLength(9) expect(view.container.querySelector('[aria-label="任务列表"]')).not.toBeNull() expect(view.container.querySelector('[aria-label="任务检查器"]')).not.toBeNull() @@ -286,9 +291,10 @@ describe("TaskBoardPage rendered", () => { "/worktrees/session-current", ])) - await click(button(view.container, "选择 Session 1:历史实现")) + await click(button(view.container, "选择 Session 1:历史实现,状态:已完成")) expect(view.container.querySelector('[aria-label="任务检查器"]')?.textContent).toContain("#101 · 未合并") expect(view.container.querySelector('[aria-label="任务检查器"]')?.textContent).toContain("worktrees/session-old") + expect(view.container.querySelector('[aria-label="任务检查器"]')?.textContent).toContain("选中的 Session") await click(button(view.container, "打开 Session")) expect(opened).toEqual(["session-old"]) }) @@ -312,6 +318,68 @@ describe("TaskBoardPage rendered", () => { expect(prompts).toEqual([["session-1", "检查这个方案"]]) }) + test("loads a selected historical Session PR beyond the initial lookup cap", async () => { + const sessionIds = Array.from({ length: 49 }, (_, index) => `session-${index + 1}`) + const task = createBoardTask( + { title: "大型历史", status: "in_progress", sessionIds }, + { id: "TASK-LARGE-HISTORY", now: 1_700_000_000_000 }, + ) + storeTasks([task]) + const loadedPaths = [] + let resolveSelectedPullRequest = null + const view = await renderBoard({ + sessions: sessionIds.map((id, index) => ({ + id, + title: `历史 ${index + 1}`, + worktreePath: `/worktrees/${id}`, + })), + loadPullRequest: async (path) => { + loadedPaths.push(path) + if (path === "/worktrees/session-1") { + return new Promise((resolve) => { + resolveSelectedPullRequest = () => resolve(githubPullRequest(1)) + }) + } + return githubPullRequest(Number(path.match(/\d+$/)?.[0] ?? 0)) + }, + }) + + await waitFor(() => expect(new Set(loadedPaths).size).toBe(48)) + expect(loadedPaths).not.toContain("/worktrees/session-1") + await click(button(view.container, "展开任务:大型历史")) + await click(button(view.container, "选择 Session 1:历史 1,状态:已完成")) + + await waitFor(() => expect(loadedPaths).toContain("/worktrees/session-1")) + expect(view.container.querySelector('[aria-label="任务检查器"]')?.textContent).toContain("正在检查 PR…") + await reactAct(async () => resolveSelectedPullRequest?.()) + await flush() + await waitFor(() => + expect(view.container.querySelector('[aria-label="任务检查器"]')?.textContent).toContain("#1 · 未合并"), + ) + }) + + test("settles a failed PR lookup as unavailable instead of checking forever", async () => { + const task = createBoardTask( + { title: "离线检出", status: "in_progress", sessionIds: ["session-offline"] }, + { id: "TASK-OFFLINE-PR", now: 1_700_000_000_000 }, + ) + storeTasks([task]) + const view = await renderBoard({ + sessions: [{ + id: "session-offline", + title: "离线执行", + worktreePath: "/worktrees/offline", + }], + loadPullRequest: async () => { throw new Error("offline") }, + }) + + const inspector = view.container.querySelector('[aria-label="任务检查器"]') + await waitFor(() => + expect(inspector?.textContent).toContain("这个 Session 的检出目录没有当前 pull request。"), + ) + expect(inspector?.textContent).not.toContain("正在检查 PR…") + }) + test("searches from the Filter popover and clears the query", async () => { const view = await renderBoard() await click(button(view.container, "筛选")) @@ -426,13 +494,60 @@ describe("TaskBoardPage rendered", () => { expect(started).toEqual(["TASK-2002"]) }) - test("closes and restores the Inspector without losing selection", async () => { + test("keeps the Inspector integrated and persistent on wide layouts", async () => { const view = await renderBoard() const page = view.container.querySelector("[data-task-board-page]") - await click(button(view.container, "隐藏检查器")) - expect(page?.getAttribute("data-inspector-open")).toBe("false") - await click(button(view.container, "显示检查器")) expect(page?.getAttribute("data-inspector-open")).toBe("true") + expect(view.container.querySelector('aside[aria-label="任务检查器"]')).not.toBeNull() + expect(view.container.querySelector('[aria-label="隐藏检查器"]')).toBeNull() + expect(view.container.querySelector('[aria-label="显示检查器"]')).toBeNull() + expect(dom.document.body.querySelector('[data-slot="dialog-content"]')).toBeNull() + }) + + test("keeps Task expansion in the narrow list and switches in place to the Inspector", async () => { + let notifyResize = null + globalThis.ResizeObserver = class ResizeObserver { + constructor(callback) { + this.callback = callback + } + observe(element) { + if (element.hasAttribute?.("data-task-board-page")) notifyResize = this.callback + } + disconnect() {} + } + const view = await renderBoard() + const page = view.container.querySelector("[data-task-board-page]") + Object.defineProperty(page, "clientWidth", { + configurable: true, + value: 760, + }) + expect(page.clientWidth).toBe(760) + expect(notifyResize).not.toBeNull() + await reactAct(async () => notifyResize([])) + await flush() + + await waitFor(() => { + expect( + view.container.querySelector("[data-task-board-page]")?.getAttribute("data-inspector-open"), + ).toBe("false") + }) + await click(button(view.container, "展开任务:确认任务流转规则")) + expect(page?.getAttribute("data-inspector-open")).toBe("false") + + const showInspector = button(view.container, "显示检查器") + showInspector.focus() + await click(showInspector) + expect(dom.document.body.querySelector('[data-slot="dialog-content"]')).toBeNull() + expect(dom.document.body.querySelector('[data-slot="dialog-overlay"]')).toBeNull() + expect(view.container.querySelector(".task-board-workspace")).toBeNull() + expect(view.container.querySelector('aside[aria-label="任务检查器"]')).not.toBeNull() + await waitFor(() => expect(dom.document.activeElement).toBe(button(view.container, "返回任务列表"))) + + await click(button(view.container, "返回任务列表")) + expect(page?.getAttribute("data-inspector-open")).toBe("false") + expect(view.container.querySelector(".task-board-workspace")).not.toBeNull() + expect(view.container.querySelector('aside[aria-label="任务检查器"]')).toBeNull() + await waitFor(() => expect(dom.document.activeElement).toBe(button(view.container, "显示检查器"))) }) }) diff --git a/apps/desktop/tests/taskBoardWorkspaceModel.test.ts b/apps/desktop/tests/taskBoardWorkspaceModel.test.ts index 6aa46bc4..5a6f0552 100644 --- a/apps/desktop/tests/taskBoardWorkspaceModel.test.ts +++ b/apps/desktop/tests/taskBoardWorkspaceModel.test.ts @@ -6,7 +6,7 @@ import type { BoardTask } from "../src/taskboard/taskBoard" import { checkoutLabel, formatUpdatedAt, - LANE_DOT_TONES, + LANE_TONES, openPullRequestCount, projectTasks, PULL_REQUEST_TONES, @@ -62,11 +62,11 @@ afterEach(() => { describe("TaskBoard workspace model", () => { test("keeps every lane and pull-request tone explicit", () => { - expect(LANE_DOT_TONES).toEqual({ - queue: "bg-muted-foreground/55", - running: "bg-primary", - needs_you: "bg-warning", - done: "bg-success", + expect(LANE_TONES).toEqual({ + queue: "neutral", + running: "success", + needs_you: "warning", + done: "success", }) expect(PULL_REQUEST_TONES).toEqual({ merged: "text-success", @@ -134,11 +134,11 @@ describe("TaskBoard workspace model", () => { expect(sessionStatusLabel(t, failed)).toBe("session.failed:") expect(sessionStatusLabel(t, running)).toBe("session.running:") expect(sessionStatusLabel(t, session())).toBe("session.completed:") - expect(sessionStatusTone(awaiting)).toBe("bg-warning") - expect(sessionStatusTone(failed)).toBe("bg-destructive") - expect(sessionStatusTone(running)).toBe("bg-primary") - expect(sessionStatusTone(session({ archived: true }))).toBe("bg-muted-foreground/40") - expect(sessionStatusTone(session())).toBe("bg-success") + expect(sessionStatusTone(awaiting)).toBe("warning") + expect(sessionStatusTone(failed)).toBe("destructive") + expect(sessionStatusTone(running)).toBe("success") + expect(sessionStatusTone(session({ archived: true }))).toBe("neutral") + expect(sessionStatusTone(session())).toBe("success") }) test("describes attention, failure, and ordinary activity", () => { @@ -191,6 +191,7 @@ describe("TaskBoard workspace model", () => { const pullRequests = new Map(states.map((state, index) => [`/${index}`, pullRequest(state)])) expect(openPullRequestCount(sessions, pullRequests)).toBe(4) expect(openPullRequestCount([], pullRequests)).toBe(0) + expect(openPullRequestCount(sessions, new Map())).toBeNull() }) test("uses the matching pull-request translation key", () => { diff --git a/apps/desktop/tests/theme.test.ts b/apps/desktop/tests/theme.test.ts index 39eda0fc..d37ee789 100644 --- a/apps/desktop/tests/theme.test.ts +++ b/apps/desktop/tests/theme.test.ts @@ -15,7 +15,7 @@ describe("theme color resolution", () => { expect(properties).toMatchObject({ "--background": palette.background, "--ds-color-canvas": palette.background, - "--ds-color-surface": "color-mix(in oklch, #172033 1%, #ffffff)", + "--ds-color-surface": "color-mix(in oklch, #172033 2%, #ffffff)", "--ds-color-raised": "color-mix(in oklch, #172033 0.25%, #ffffff)", "--ds-color-modal": palette.background, "--ds-color-fill-quiet": "color-mix(in oklch, #172033 2.5%, #ffffff)", diff --git a/docs/sdlc/changes/2026-09-01-task-session-workspace-polish/change.md b/docs/sdlc/changes/2026-09-01-task-session-workspace-polish/change.md new file mode 100644 index 00000000..ea9b9ed7 --- /dev/null +++ b/docs/sdlc/changes/2026-09-01-task-session-workspace-polish/change.md @@ -0,0 +1,129 @@ +--- +id: change-2026-09-01-task-session-workspace-polish +kind: change +schema: 2 +status: verified +risk: medium +owner: codex +approvers: user via direct review-fix, no-drawer, floating-panel, surface-tone, and PR requests +approved_at: 2026-09-01 +created: 2026-09-01 +updated: 2026-09-01 +source: follow-up feedback and review after merged PR #214 +inputs: merged TaskBoard workspace, selected Session state, scoped composer drafts, and per-checkout pull-request projection +outputs: safe Session continuation and a flat responsive TaskBoard inspector +scope: apps/desktop/src/App.tsx, apps/desktop/src/components/business/page-header.tsx, apps/desktop/src/design/theme.ts, apps/desktop/src/design/tokens.css, apps/desktop/src/i18n/strings.ts, apps/desktop/src/taskboard, apps/desktop/tests/taskBoard.test.ts, apps/desktop/tests/taskBoardRendered.test.tsx, apps/desktop/tests/taskBoardWorkspaceModel.test.ts, apps/desktop/tests/theme.test.ts, docs/sdlc/changes/2026-09-01-task-session-workspace-polish +next_trigger: human review of the Draft PR +verification_mode: owner +verified_by: codex +verified_at: 2026-09-01 +--- + +# Polish the Task-to-Session workspace + +## Intent + +PR #214 established the Task-to-Session workspace. Follow-up review found two composer draft-loss +races, inaccurate historical Session and pull-request states, inaccessible color-only status, and +a narrow inspector that behaved like a drawer. The user's visual feedback also requires a wide +floating panel that remains flat, borderless, and distinguishable from the white page. + +The accepted outcome preserves the merged modular TaskBoard architecture. It does not reintroduce +the former monolithic page, change Task or Session persistence, merge the PR, or release software. + +## Spec + +- Continuing from the inspector appends to the destination pane's existing draft and cancels if + asynchronous navigation no longer targets that pane and Session. +- Selected historical Sessions use selected-Session copy and always trigger their checkout's PR + lookup, including histories beyond the initial 48-path batch. Loading and failed lookup states + must not render false `No PR` results. +- Task and Session states use the shared labelled status indicator. +- At wide widths the inspector is a 360-pixel inset `surface` panel with no border or shadow. At + narrow widths the page switches in place between list and detail with an explicit Back action; + expansion alone never opens the detail and no drawer, overlay, or scrim mounts. +- The shared light `surface` tone mixes 2% foreground into the white canvas. Dark mode remains + unchanged. + +### Acceptance criteria + +- [x] AC-1: Existing drafts survive inspector continuation and stale navigation cannot target a + different pane or Session. +- [x] AC-2: Historical, loading, failed, and beyond-cap PR states render accurately and status is + exposed without relying on color. +- [x] AC-3: Wide and narrow layouts match the accepted floating/in-place behavior with no drawer, + border, shadow, scrim, or horizontal overflow. +- [x] AC-4: Focused tests, lint, TypeScript, renderer build, documentation/SDLC Gates, diff hygiene, + and live rendered inspection pass. + +## Decision and gates + +The user approved the fixes and explicitly requested a PR. Ponytail selected a port onto current +`origin/main`: reuse the merged responsibility-owned TaskBoard modules and change only their +existing seams. The stale pre-merge working tree is not used as a branch because doing so would +delete the merged modular architecture. + +This remains medium risk because composer state and the primary TaskBoard navigation are affected. +Opening the follow-up PR is authorized; merge and release remain human Gates. + +## Plan + +1. Add a pane-scoped prompt continuation helper and regression tests. +2. Extend existing TaskBoard selection, PR projection, status, and responsive layout modules. +3. Strengthen the shared light surface tone through the central theme resolver and fallback token. +4. Run focused and full validation, inspect desktop and narrow renders, then open a Draft PR. + +Rollback reverts this follow-up commit. Task and Session persistence formats are unchanged. + +## Build + +Implementation is complete on `codex/task-session-workspace-polish`, based on the merge commit for +PR #214. It adds pane-scoped safe continuation, truthful Session/PR state projection, labelled +status indicators, the flat responsive inspector, and the adjusted shared surface tone. The +earlier dirty `main` checkout and unrelated OpenClaw research remain outside the branch. + +## Verification + +Verdict: verified + +- Focused behavior and theme suite: 64 passing tests, 259 assertions. +- Full desktop suite: 839 passing tests, 5,005 assertions, zero failures. +- `bunx tsc --noEmit`, ESLint, Stylelint, and `bun run build` passed. The build retained only the + repository's advisory large-chunk warning. +- Live browser inspection at 1440 by 900 confirmed a persistent 360-pixel inspector with a 12-pixel + inset, 16-pixel radius, distinct `surface` background, zero border width, no shadow, and no + horizontal overflow. +- Live browser inspection at 760 by 900 confirmed Task expansion remains in the list; explicit + inspector navigation switches in place with no dialog, overlay, or scrim, and Back restores + focus to the inspector control. +- Documentation, SDLC, worktree SDLC, and proposed-file diff-hygiene checks passed. + +### Acceptance evidence + +- AC-1: PASS — the focused `bun test` command passed 64 tests, including pane-scoped + destination-draft append and stale-navigation cancellation. +- AC-2: PASS — the same focused `bun test` command covers selected historical copy, labelled status, + loading and failed PR states, a selected Session beyond the 48-checkout lookup cap, semantic + tones, and unresolved PR counts. +- AC-3: PASS — live 1440 by 900 inspection measured the accepted floating `surface` panel, while + live 760 by 900 interaction proved the in-place list/detail switch, focus restoration, zero + dialogs, overlays, scrims, borders, shadows, and horizontal overflow. +- AC-4: PASS — `cd apps/desktop && bun test` passed 839/839; `bunx tsc --noEmit`, `bun run lint`, + `bun run build`, `bun script/verify/docs.ts`, `bun script/verify/sdlc.ts`, + `bun script/verify/sdlc.ts --worktree`, `git diff --check`, and the isolated-index + `git diff --cached --check` all passed. + +Residual risk: GitHub status remains best effort and depends on a usable checkout plus local +authentication and network. Human review remains required before merge. + +## Review and release + +Approval: the user requested the follow-up PR on 2026-09-01. +Release target: none requested. +No merge or release is authorized by this Artifact. + +## Feedback + +The user rejected the earlier drawer treatment, requested a floating but clean right panel, then +asked for a stronger `surface` color and a follow-up PR. The implementation keeps those visual +decisions while preserving the responsibility-owned modules merged in PR #214.