diff --git a/src/browser/features/RightSidebar/GoalBoardSections.tsx b/src/browser/features/RightSidebar/GoalBoardSections.tsx index 1ce254fe55..db9dfcff26 100644 --- a/src/browser/features/RightSidebar/GoalBoardSections.tsx +++ b/src/browser/features/RightSidebar/GoalBoardSections.tsx @@ -128,6 +128,49 @@ function SectionShell(props: SectionShellProps) { ); } +/** + * Bordered chip-style button for per-row actions (Edit / Promote / Remove / + * Archive / Revive). Matches the visual weight of the Cancel / Queue-goal + * controls below so each row reads as a real button row, not a strip of + * text links — and so the affordance is obvious on first glance without + * relying on hover state. Tone tints the hover color/background: + * • neutral — Edit / Archive / Revive (Inbox-style moves) + * • positive — Promote (upcoming → active) + * • destructive — Remove (drops the goal from the board) + * + * Exported so the active-goal card in `GoalTab.tsx` can render the + * "Archive this goal" / "Clear goal" affordance with the same chip + * styling (the de-emphasized text-link variant was visually inconsistent + * with every other Archive control on the same surface). + */ +export type RowActionTone = "neutral" | "positive" | "destructive"; + +export interface RowActionButtonProps extends React.ButtonHTMLAttributes { + tone?: RowActionTone; +} + +const ROW_ACTION_TONE_CLASS: Record = { + neutral: "text-muted hover:text-foreground hover:bg-surface-tertiary", + positive: "text-muted hover:text-success hover:bg-success/10 hover:border-success/40", + destructive: + "text-muted hover:text-danger-soft hover:bg-danger-soft/10 hover:border-danger-soft/40", +}; + +export function RowActionButton(props: RowActionButtonProps) { + const { tone = "neutral", className, type, ...rest } = props; + return ( + - - + ); @@ -510,15 +549,13 @@ function CompletedSection(props: CompletedSectionProps) { {formatGoalCents(entry.goal.costCents)} - + ))} {error && ( @@ -562,15 +599,13 @@ function ArchivedSection(props: ArchivedSectionProps) { className="border-border-light bg-surface-primary flex items-center gap-2 rounded-md border px-2 py-1.5 text-sm" > {entry.goal.objective} - + ))} {error && ( diff --git a/src/browser/features/RightSidebar/GoalTab.stories.tsx b/src/browser/features/RightSidebar/GoalTab.stories.tsx index f16df0d34f..c47afdba8b 100644 --- a/src/browser/features/RightSidebar/GoalTab.stories.tsx +++ b/src/browser/features/RightSidebar/GoalTab.stories.tsx @@ -1,4 +1,11 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; + +import { TooltipProvider } from "@/browser/components/Tooltip/Tooltip"; +import { APIProvider } from "@/browser/contexts/API"; +import { CHROMATIC_SMOKE_MODES } from "@/browser/stories/meta"; +import { createMockORPCClient } from "@/browser/stories/mocks/orpc"; +import type { GoalBoardEntry, GoalBoardSnapshot, GoalRecordV1 } from "@/common/types/goal"; + import { GoalTab } from "./GoalTab"; const meta: Meta = { @@ -120,3 +127,131 @@ export const ActiveWithBudget: Story = { }, }, }; + +// ───────────────────────────────────────────────────────────────────────── +// CompleteWithBoard — full tab populated with Upcoming / Completed / +// Archived sections so the Archive row button (Completed → Archive), +// Revive (Archived), Promote / Remove / Edit (Upcoming), and the +// de-emphasized "Archive this goal" link on the active complete card all +// render in one screenshot. Required visual coverage for the row-action +// button restyle, which `GoalTab.test.tsx` cannot see (it mocks +// `GoalBoardSections` away). +// +// `GoalBoardSections` only mounts when `workspaceId` is provided, and +// the populated board reaches it through `useGoalBoard → api.workspace +// .getGoalBoard`. The decorator below mounts the story under an +// `APIProvider` backed by the mock client, with a seeded snapshot keyed +// by the story's workspaceId. +// ───────────────────────────────────────────────────────────────────────── + +const STORY_WORKSPACE_ID = "ws-story-goaltab"; +const NOW = Date.UTC(2026, 4, 20, 12, 0, 0); + +function makeBoardGoal( + overrides: Partial & Pick +): GoalRecordV1 { + return { + version: 1, + goalId: overrides.goalId, + objective: overrides.objective ?? "Untitled goal", + status: overrides.status ?? "paused", + budgetCents: overrides.budgetCents ?? null, + turnCap: overrides.turnCap ?? null, + costCents: overrides.costCents ?? 0, + turnsUsed: overrides.turnsUsed ?? 0, + attributedChildren: overrides.attributedChildren ?? [], + budgetLimitInjectedForGoalId: overrides.budgetLimitInjectedForGoalId ?? null, + requireUserAcknowledgmentSinceMs: overrides.requireUserAcknowledgmentSinceMs ?? null, + createdAtMs: overrides.createdAtMs ?? NOW, + updatedAtMs: overrides.updatedAtMs ?? NOW, + ...(overrides.completionSummary != null + ? { completionSummary: overrides.completionSummary } + : {}), + }; +} + +function boardEntry( + section: GoalBoardEntry["section"], + goal: GoalRecordV1, + endedAtMs?: number +): GoalBoardEntry { + return endedAtMs != null ? { section, goal, endedAtMs } : { section, goal }; +} + +const FULL_BOARD: GoalBoardSnapshot = { + entries: [ + boardEntry( + "upcoming", + makeBoardGoal({ + goalId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + objective: "Wire goal-board reorder to the keyboard", + budgetCents: 500, + }) + ), + boardEntry( + "upcoming", + makeBoardGoal({ + goalId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + objective: "Audit goal continuation telemetry", + }) + ), + boardEntry( + "complete", + makeBoardGoal({ + goalId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + objective: "Ship the goal primitive vertical slice", + status: "complete", + budgetCents: 500, + costCents: 412, + turnsUsed: 8, + completionSummary: "Lifecycle controls shipped with persistence and tests.", + }), + NOW - 60_000 + ), + boardEntry( + "archived", + makeBoardGoal({ + goalId: "dddddddd-dddd-4ddd-8ddd-dddddddddddd", + objective: "Sketch goal-board mobile layout", + }), + NOW - 3_600_000 + ), + ], +}; + +export const CompleteWithBoard: Story = { + args: { + workspaceId: STORY_WORKSPACE_ID, + goal: { + goalId: "33333333-3333-4333-8333-333333333333", + status: "complete", + objective: "Ship the goal primitive vertical slice", + budgetCents: null, + costCents: 250, + turnsUsed: 5, + turnCap: null, + completionSummary: "The lifecycle controls shipped with persistence and tests.", + startedAtMs: NOW, + }, + }, + // Dual-theme smoke coverage: the row-action button restyle should not + // regress in either light or dark mode. + parameters: { + chromatic: { modes: CHROMATIC_SMOKE_MODES }, + }, + decorators: [ + (Story) => ( + + +
+ +
+
+
+ ), + ], +}; diff --git a/src/browser/features/RightSidebar/GoalTab.test.tsx b/src/browser/features/RightSidebar/GoalTab.test.tsx index 3c6c4ccd64..44e6ce660d 100644 --- a/src/browser/features/RightSidebar/GoalTab.test.tsx +++ b/src/browser/features/RightSidebar/GoalTab.test.tsx @@ -495,16 +495,17 @@ describe("GoalTab", () => { expect(getByLabelText("Edit goal objective")).toBeTruthy(); }); - test("clear control is de-prominent and relabels for completed goals", () => { + test("active goals expose a de-prominent Clear; completed goals promote Archive as primary", () => { const { getByLabelText, getByText, rerender, queryByText } = render( ); - // Active goal: the clear control exists but is rendered as a small text - // link — no primary-button background classes are applied. + // Lifecycle-active goals: Clear is rendered as a small chip-style + // row-action button — explicitly NOT the primary accent button used + // by Save / Set goal / Archive-on-complete. Keeps a destructive + // option visible without competing with Pause / Mark complete. const clearButton = getByLabelText("Clear goal"); expect(clearButton.className).not.toContain("bg-accent"); - expect(clearButton.className).toContain("underline"); expect(getByText("Clear goal")).toBeTruthy(); rerender( @@ -514,13 +515,19 @@ describe("GoalTab", () => { onClear={mock()} /> ); - // Completed goals: the action is "archive" (moves the goal into the - // board's Archived section via `workspace.archiveGoal`, not into - // history under `endReason: "completed"`). The visible label, - // aria-label, and the absence of the "Clear goal" wording are all - // part of the user-visible UX contract. - expect(getByLabelText("Archive goal")).toBeTruthy(); - expect(getByText("Archive this goal")).toBeTruthy(); + // Completed goals: Archive sits next to Reopen as the accent-colored + // primary action ("file this away" is the obvious next step for a + // finished goal). Reopen stays available as the secondary recovery + // path with a neutral border style — the inverse of the previous + // green Reopen / underlined Archive layout. The "Clear goal" wording + // disappears entirely for completed goals; Archive routes through + // the goal-board endpoint instead. + const archiveButton = getByLabelText("Archive goal"); + expect(archiveButton.className).toContain("bg-accent"); + expect(getByText("Archive")).toBeTruthy(); + const reopenButton = getByLabelText("Reopen goal"); + expect(reopenButton.className).not.toContain("bg-accent"); + expect(getByText("Reopen")).toBeTruthy(); expect(queryByText("Clear goal")).toBeNull(); }); diff --git a/src/browser/features/RightSidebar/GoalTab.tsx b/src/browser/features/RightSidebar/GoalTab.tsx index 77b8d9d3bb..e33cb9b9fc 100644 --- a/src/browser/features/RightSidebar/GoalTab.tsx +++ b/src/browser/features/RightSidebar/GoalTab.tsx @@ -1,4 +1,14 @@ -import { CheckCircle2, Pause, Pencil, Play, RotateCcw, Settings2, Target } from "lucide-react"; +import { + CheckCircle2, + Inbox, + Pause, + Pencil, + Play, + RotateCcw, + Settings2, + Target, + Trash2, +} from "lucide-react"; import { useContext, useEffect, useRef, useState, type KeyboardEvent } from "react"; import { goalActiveMode, @@ -20,7 +30,10 @@ import { cn } from "@/common/lib/utils"; // the tool-call cards as goal status labels evolve. import { formatGoalElapsed, goalStatusLabel } from "@/browser/features/Tools/Goal/goalToolUtils"; import { GoalDefaultsModal } from "@/browser/features/RightSidebar/GoalDefaultsModal"; -import { GoalBoardSections } from "@/browser/features/RightSidebar/GoalBoardSections"; +import { + GoalBoardSections, + RowActionButton, +} from "@/browser/features/RightSidebar/GoalBoardSections"; import { useGoalBoard } from "@/browser/features/RightSidebar/useGoalBoard"; /** @@ -608,29 +621,21 @@ export function GoalTab(props: GoalTabProps) { Pause )} - {canResume && ( - // Resume / Reopen tints with the goal-green accent so the - // primary "get this goal running again" action is the - // obvious next step when the header is amber. The same - // styling covers both `paused → resume` and `complete → - // reopen`; the icon swaps (`Play` vs `RotateCcw`) to make - // the difference between resuming an in-flight goal and - // reviving a closed one explicit. + {/* For paused (lifecycle-active) goals, Resume is the obvious + recovery action and keeps its green-tinted "primary" look. + The lifecycle === "complete" path renders Reopen + Archive + in their own branch below where Archive is the primary and + Reopen is the secondary, so we exclude that case here to + avoid double-rendering Reopen. */} + {canResume && lifecycle !== "complete" && ( )} {canComplete && ( @@ -644,46 +649,68 @@ export function GoalTab(props: GoalTabProps) { Mark complete )} + {/* Completed-goal action pair: Reopen on the left as the + de-emphasized secondary (only reach for it if the agent + declared done too eagerly), Archive on the right as the + accent-colored primary because filing a finished goal is + the obvious next step. Both buttons are sized like the rest + of the action row (`px-3 py-1.5 text-sm`) so they read as + peers, not as a chip-style afterthought. */} + {lifecycle === "complete" && ( + <> + + + + )} )} - {/* - Clear is intentionally de-emphasized so it does not compete visually - with Pause / Resume / Mark complete. Gated on `canEdit` so - transcript-only / pending-persistence goals do not expose a - destructive action. - */} - {canEdit && ( -
- + {/* Clear stays as a small de-emphasized chip below the main row + for lifecycle-active goals, where the goal is still in flight + and clearing it is destructive. For completed goals, Archive + (above) replaces this surface. Gated on `canEdit` so + transcript-only / pending-persistence goals do not expose a + destructive action. */} + {canEdit && lifecycle !== "complete" && ( +
+ void clearGoal()}> +
)} diff --git a/src/browser/stories/mocks/orpc.ts b/src/browser/stories/mocks/orpc.ts index 4662ab1ea1..4488f9c943 100644 --- a/src/browser/stories/mocks/orpc.ts +++ b/src/browser/stories/mocks/orpc.ts @@ -4,6 +4,7 @@ * Creates a client that matches the AppRouter interface with configurable mock data. */ import { DEFAULT_GOAL_DEFAULTS, normalizeGoalDefaults, type GoalDefaults } from "@/constants/goals"; +import type { GoalBoardSnapshot } from "@/common/types/goal"; import type { APIClient } from "@/browser/contexts/API"; import type { AgentDefinitionDescriptor, @@ -145,6 +146,13 @@ export interface MockORPCClientOptions { imageGeneration?: Partial; /** Initial global goal defaults for config.getConfig */ goalDefaults?: GoalDefaults; + /** + * Pre-seeded goal-board snapshots per workspaceId. Stories that want + * the GoalTab's Upcoming / Completed / Archived sections to render + * populated (rather than the default empty board) pass a snapshot here + * keyed by the workspace ID the story uses. + */ + goalBoardSnapshots?: Map; /** Initial route priority for config.getConfig */ routePriority?: string[]; /** Initial per-model route overrides for config.getConfig */ @@ -368,6 +376,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl heartbeatDefaultIntervalMs: initialHeartbeatDefaultIntervalMs, imageGeneration: initialImageGeneration, goalDefaults: initialGoalDefaults, + goalBoardSnapshots = new Map(), routePriority: initialRoutePriority = ["direct"], routeOverrides: initialRouteOverrides = {}, agentDefinitions: initialAgentDefinitions, @@ -1426,11 +1435,14 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl get: () => Promise.resolve(null), set: () => Promise.resolve({ success: true, data: undefined }), }, - // Goal board (multi-goal queue) endpoints. Stories never call into - // these but the GoalTab subscribes to `getGoalBoard` on mount; the - // mutation endpoints exist for stories that simulate user - // interactions (currently none — they resolve voids). - getGoalBoard: () => Promise.resolve({ entries: [] }), + // Goal board (multi-goal queue) endpoints. Stories that want the + // GoalTab's Upcoming / Completed / Archived sections populated + // pass a snapshot via `goalBoardSnapshots` keyed by workspaceId; + // everything else falls back to an empty board. The mutation + // endpoints exist for stories that simulate user interactions + // (currently none — they resolve voids). + getGoalBoard: (input: { workspaceId: string }) => + Promise.resolve(goalBoardSnapshots.get(input.workspaceId) ?? { entries: [] }), addUpcomingGoal: () => Promise.resolve({ version: 1 as const,