diff --git a/docs/hooks/tools.mdx b/docs/hooks/tools.mdx index b92cfb76d3..bf20ba432b 100644 --- a/docs/hooks/tools.mdx +++ b/docs/hooks/tools.mdx @@ -372,6 +372,16 @@ If a value is too large for the environment, it may be omitted (not set). Mux al +
+complete_goal (2) + +| Env var | JSON path | Type | Description | +| ------------------------ | --------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MUX_TOOL_INPUT_GOAL_ID` | `goalId` | string | Optional optimistic-concurrency token. Pass the `goalId` returned by `get_goal` to ensure the completion is rejected with a typed conflict error if the user clears or replaces the goal mid-stream. | +| `MUX_TOOL_INPUT_SUMMARY` | `summary` | string | Required 1-2 sentence justification for completing the current goal. | + +
+
desktop_click (3) diff --git a/src/browser/App.tsx b/src/browser/App.tsx index eda286922d..19c73ef195 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -101,6 +101,7 @@ import { MULTI_PROJECT_SIDEBAR_SECTION_ID } from "@/common/constants/multiProjec import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults"; import { isDesktopMode } from "@/browser/hooks/useDesktopTitlebar"; import { prependInitialAppProxyBasePath } from "@/browser/utils/frontendBasePath"; +import { WorkspaceActiveGoalsWarningToast } from "@/browser/components/ActiveGoalsWarningToast/ActiveGoalsWarningToast"; import { LoadingScreen } from "@/browser/components/LoadingScreen/LoadingScreen"; function RootRouteShell(props: { @@ -186,6 +187,7 @@ function AppInner() { ); const [isMultiProjectWorkspaceModalOpen, setMultiProjectWorkspaceModalOpen] = useState(false); + const goalsEnabled = useExperimentValue(EXPERIMENT_IDS.GOALS); const multiProjectWorkspacesEnabled = useExperimentValue(EXPERIMENT_IDS.MULTI_PROJECT_WORKSPACES); // Left sidebar is drag-resizable (mirrors RightSidebar). Width is persisted globally; @@ -714,6 +716,7 @@ function AppInner() { onStartWorkspaceCreation: openNewWorkspaceFromPalette, onStartMultiProjectWorkspaceCreation: openNewMultiProjectWorkspaceFromPalette, multiProjectWorkspacesEnabled, + goalsEnabled, onArchiveMergedWorkspacesInProject: archiveMergedWorkspacesInProjectFromPalette, getBranchesForProject, onSelectWorkspace: selectWorkspaceFromPalette, @@ -1216,6 +1219,7 @@ function AppInner() { )} + ({ workspaceId: selectedWorkspace?.workspaceId })} /> { + let cleanupDom: (() => void) | null = null; + + beforeEach(() => { + cleanupDom = installDom(); + }); + + afterEach(() => { + cleanup(); + cleanupDom?.(); + cleanupDom = null; + }); + + test("fires once on a rising edge above three active goals", async () => { + const { queryByRole, rerender } = render(); + + expect(queryByRole("status")).toBeNull(); + + rerender(); + await waitFor(() => expect(queryByRole("status")?.textContent).toContain("4 active goals")); + + rerender(); + expect(queryByRole("status")?.textContent).toContain("4 active goals"); + }); + + test("re-arms after the active-goal count falls to three", async () => { + const { queryByRole, rerender } = render(); + + await waitFor(() => expect(queryByRole("status")?.textContent).toContain("4 active goals")); + + rerender(); + await waitFor(() => expect(queryByRole("status")).toBeNull()); + + rerender(); + await waitFor(() => expect(queryByRole("status")?.textContent).toContain("4 active goals")); + }); + + test("announces warnings politely", async () => { + const { getByRole } = render(); + + await waitFor(() => expect(getByRole("status").getAttribute("aria-live")).toBe("polite")); + }); + + test("does not fire when the GOALS experiment is disabled", () => { + // Coder-agents-review P3 DEREM-49: pin the experiment-off short-circuit + // so a regression that removed the `enabled === false` guard would fail. + // Without it, users who toggled the experiment off mid-session would get + // spurious warnings whenever active goals exceed the threshold. + const { queryByRole } = render(); + + expect(queryByRole("status")).toBeNull(); + }); + + test("clears any showing toast when the experiment is toggled off mid-session", async () => { + // The experiment-off branch also clears a *currently showing* warning, + // not just suppresses new ones. Render with enabled=true above the + // threshold (which fires the toast), then flip to enabled=false and + // assert the toast disappears. + const { queryByRole, rerender } = render(); + await waitFor(() => expect(queryByRole("status")?.textContent).toContain("4 active goals")); + + rerender(); + await waitFor(() => expect(queryByRole("status")).toBeNull()); + }); +}); diff --git a/src/browser/components/ActiveGoalsWarningToast/ActiveGoalsWarningToast.tsx b/src/browser/components/ActiveGoalsWarningToast/ActiveGoalsWarningToast.tsx new file mode 100644 index 0000000000..4ed723dc7a --- /dev/null +++ b/src/browser/components/ActiveGoalsWarningToast/ActiveGoalsWarningToast.tsx @@ -0,0 +1,76 @@ +import { useEffect, useRef, useState } from "react"; +import { useActiveGoalCount } from "@/browser/stores/WorkspaceStore"; + +const ACTIVE_GOAL_WARNING_THRESHOLD = 3; +const AUTO_DISMISS_MS = 5_000; +const wrapperClassName = + "pointer-events-none fixed top-4 right-4 z-[10000] max-w-[min(420px,calc(100vw-2rem))] [&>*]:pointer-events-auto"; + +interface ActiveGoalsWarningToastProps { + activeGoalCount: number; + enabled?: boolean; +} + +export function ActiveGoalsWarningToast(props: ActiveGoalsWarningToastProps) { + const [toastCount, setToastCount] = useState(null); + const wasAboveThresholdRef = useRef(false); + + useEffect(() => { + if (props.enabled === false) { + wasAboveThresholdRef.current = false; + setToastCount(null); + return; + } + + const isAboveThreshold = props.activeGoalCount > ACTIVE_GOAL_WARNING_THRESHOLD; + if (!isAboveThreshold) { + wasAboveThresholdRef.current = false; + setToastCount(null); + return; + } + + // Warn on the rising edge only so several stream-end updates during the same elevated + // active-goal period do not spam the user. + if (wasAboveThresholdRef.current) { + return; + } + + wasAboveThresholdRef.current = true; + setToastCount(props.activeGoalCount); + }, [props.activeGoalCount, props.enabled]); + + useEffect(() => { + if (toastCount == null || typeof window === "undefined") { + return; + } + + const timeoutId = window.setTimeout(() => setToastCount(null), AUTO_DISMISS_MS); + return () => window.clearTimeout(timeoutId); + }, [toastCount]); + + if (toastCount == null) { + return null; + } + + return ( +
+
+ + + You have {toastCount} active goals running concurrently. Goal continuations will fire + serially across workspaces. + +
+
+ ); +} + +export function WorkspaceActiveGoalsWarningToast(props: { enabled?: boolean }) { + const activeGoalCount = useActiveGoalCount(); + + return ; +} diff --git a/src/browser/components/AgentListItem/AgentListItem.stories.tsx b/src/browser/components/AgentListItem/AgentListItem.stories.tsx index 94b8643a1f..32f390c76a 100644 --- a/src/browser/components/AgentListItem/AgentListItem.stories.tsx +++ b/src/browser/components/AgentListItem/AgentListItem.stories.tsx @@ -20,6 +20,9 @@ import { getWorkspaceLastReadKey, } from "@/common/constants/storage"; import type { AgentRowRenderMeta } from "@/browser/utils/ui/workspaceFiltering"; +import { EXPERIMENT_IDS, getExperimentKey } from "@/common/constants/experiments"; +import type { GoalStatus } from "@/common/types/goal"; +import type { WorkspaceActivitySnapshot } from "@/common/orpc/types"; const meta: Meta = { title: "Components/AgentListItem", @@ -82,8 +85,10 @@ function StoryScaffold(props: { activeWorkspaceId?: string; workspaces?: ReadonlyArray<(typeof STORY_WORKSPACES)[number]>; rowContainerClassName?: string; + workspaceActivitySnapshots?: Record; }) { const api = createMockORPCClient({ + workspaceActivitySnapshots: props.workspaceActivitySnapshots, onChat: (workspaceId, emit) => { emit({ type: "caught-up", hasOlderHistory: false }); if (workspaceId === "ws-active") { @@ -293,6 +298,49 @@ function renderIdleState(isUnread: boolean) { return renderSingleWorkspaceState(2); } +function renderGoalState( + status: GoalStatus, + accounting?: { budgetCents: number | null; costCents: number } +) { + const workspace = STORY_WORKSPACES[2]; + updatePersistedState(getExperimentKey(EXPERIMENT_IDS.GOALS), true); + return ( + + undefined} + onForkWorkspace={() => Promise.resolve()} + onArchiveWorkspace={() => Promise.resolve()} + onCancelCreation={() => Promise.resolve()} + /> + + ); +} + function renderDraftState() { return ( @@ -530,6 +578,37 @@ export const Question: Story = { render: () => renderSingleWorkspaceState(4), }; +export const GoalActive: Story = { + args: undefined as never, + render: () => renderGoalState("active"), +}; + +export const GoalActiveBudgeted: Story = { + args: undefined as never, + render: () => renderGoalState("active", { budgetCents: 500, costCents: 123 }), +}; + +export const GoalActiveUnbudgeted: Story = { + args: undefined as never, + render: () => renderGoalState("active", { budgetCents: null, costCents: 123 }), +}; + +export const GoalPaused: Story = { + args: undefined as never, + render: () => renderGoalState("paused"), +}; + +export const GoalBudgetLimited: Story = { + args: undefined as never, + name: "Goal/Budget Limited", + render: () => renderGoalState("budget_limited", { budgetCents: 500, costCents: 525 }), +}; + +export const GoalComplete: Story = { + args: undefined as never, + render: () => renderGoalState("complete"), +}; + export const Draft: Story = { args: undefined as never, render: renderDraftState, diff --git a/src/browser/components/AgentListItem/AgentListItem.test.tsx b/src/browser/components/AgentListItem/AgentListItem.test.tsx index e79e145cd7..64f5be4a54 100644 --- a/src/browser/components/AgentListItem/AgentListItem.test.tsx +++ b/src/browser/components/AgentListItem/AgentListItem.test.tsx @@ -289,6 +289,28 @@ describe("AgentListItem", () => { expect(rowView.queryByTestId(`workspace-secondary-row-${TEST_WORKSPACE_ID}`)).toBeNull(); }); + test("renders budget-limited goal pill with stateful aria label", () => { + mockWorkspaceHeartbeatsEnabled = true; + mockWorkspaceSidebarState = createWorkspaceSidebarState({ + goal: { + goalId: "11111111-1111-4111-8111-111111111111", + status: "budget_limited", + objective: "Ship goal budgets", + budgetCents: 500, + costCents: 525, + turnsUsed: 4, + turnCap: null, + startedAtMs: Date.now(), + }, + }); + + const { row } = renderWorkspaceItem(); + const pill = within(row).getByTestId(`workspace-goal-pill-${TEST_WORKSPACE_ID}`); + + expect(pill.textContent).toContain("Target budget limited"); + expect(pill.getAttribute("aria-label")).toBe("Goal budget limited, $5.25 of $5.00 spent"); + }); + test("renders a heartbeat icon directly in the leading slot for seen rows when the heartbeat experiment is enabled", () => { mockWorkspaceHeartbeatsEnabled = true; diff --git a/src/browser/components/AgentListItem/AgentListItem.tsx b/src/browser/components/AgentListItem/AgentListItem.tsx index 33f4220113..471d143b3a 100644 --- a/src/browser/components/AgentListItem/AgentListItem.tsx +++ b/src/browser/components/AgentListItem/AgentListItem.tsx @@ -13,14 +13,17 @@ import { useWorkspaceSidebarState } from "@/browser/stores/WorkspaceStore"; import { stopKeyboardPropagation } from "@/browser/utils/events"; import type { AgentRowRenderMeta } from "@/browser/utils/ui/workspaceFiltering"; import { cn } from "@/common/lib/utils"; +import { formatGoalCents } from "@/common/utils/goals/budgetPricing"; import { TASK_GROUP_KIND, getTaskGroupKindFromMetadata, normalizeTaskGroupLabel, } from "@/common/utils/tools/taskGroups"; import { EXPERIMENT_IDS } from "@/common/constants/experiments"; +import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events"; import { isDevcontainerRuntime } from "@/common/types/runtime"; import { getWorkspaceLastReadKey } from "@/common/constants/storage"; +import type { GoalSnapshot } from "@/common/types/goal"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; import React, { useState, useEffect, useRef, useCallback } from "react"; import { useDrag } from "react-dnd"; @@ -50,6 +53,7 @@ import { EyeOff, ChevronDown, HeartPulse, + Target, } from "lucide-react"; import { WorkspaceStatusIndicator } from "../WorkspaceStatusIndicator/WorkspaceStatusIndicator"; import { ArchiveIcon } from "../icons/ArchiveIcon/ArchiveIcon"; @@ -417,6 +421,31 @@ function DraftAgentListItemInner(props: DraftAgentListItemProps) { ); } +function getGoalPillAriaLabel(goal: GoalSnapshot, fallbackText: string): string { + if (goal.status === "budget_limited") { + const budgetText = + goal.budgetCents == null ? "no budget" : `${formatGoalCents(goal.budgetCents)} spent`; + return `Goal budget limited, ${formatGoalCents(goal.costCents)} of ${budgetText}`; + } + return fallbackText; +} + +function getGoalPillText(goal: GoalSnapshot): string { + if (goal.status === "paused") { + return "Target paused"; + } + if (goal.status === "complete") { + return "Target done"; + } + if (goal.status === "budget_limited") { + return "Target budget limited"; + } + if (goal.budgetCents != null) { + return `Target ${formatGoalCents(goal.costCents)} / ${formatGoalCents(goal.budgetCents)}`; + } + return `Target ${formatGoalCents(goal.costCents)}`; +} + // ───────────────────────────────────────────────────────────────────────────── // Regular Workspace Item (persisted workspace) // ───────────────────────────────────────────────────────────────────────────── @@ -444,6 +473,7 @@ function RegularAgentListItemInner(props: AgentListItemProps) { // Destructure metadata for convenience const { id: workspaceId, namedWorkspacePath } = metadata; const workspaceHeartbeatsEnabled = useExperimentValue(EXPERIMENT_IDS.WORKSPACE_HEARTBEATS); + const goalsEnabled = useExperimentValue(EXPERIMENT_IDS.GOALS); const isInitializing = metadata.isInitializing === true; const isRemoving = isRemovingProp === true || metadata.isRemoving === true; const isDisabled = isRemoving || isArchiving === true; @@ -591,8 +621,11 @@ function RegularAgentListItemInner(props: AgentListItemProps) { agentStatus, terminalActiveCount, lastAbortReason, + goal, } = useWorkspaceSidebarState(workspaceId); + const goalPillText = goal ? getGoalPillText(goal) : null; + const fallbackModel = useWorkspaceFallbackModel(workspaceId); const streamingStatusPhase = getWorkspaceStreamingStatusPhase({ canInterrupt, @@ -1048,6 +1081,24 @@ function RegularAgentListItemInner(props: AgentListItemProps) { {!isInitializing && !isEditing && (
+ {goalsEnabled && goal && goalPillText && ( + + )} {shouldShowInlineArchivingStatus ? (
+ + + + + ); +} + +function RegisterAction(props: { action: CommandAction }) { + const { registerSource } = useCommandRegistry(); + + React.useEffect(() => registerSource(() => [props.action]), [props.action, registerSource]); + + return null; +} + +function renderPalette(action: CommandAction) { + return render( + + + + + + ); +} + +describe("CommandPalette inline goal prompts", () => { + let cleanupDom: (() => void) | null = null; + + beforeEach(() => { + cleanupDom = installDom(); + }); + + afterEach(() => { + cleanup(); + cleanupDom?.(); + cleanupDom = null; + }); + + test("Goal: Set objective traps focus and restores it on dismissal", async () => { + const action: CommandAction = { + id: "goal:set-objective", + title: "Goal: Set objective", + section: "Goals", + run: () => undefined, + prompt: { + fields: [ + { + type: "text", + name: "objective", + label: "Goal objective", + placeholder: "Describe the goal…", + }, + ], + onSubmit: mock(), + }, + }; + const view = renderPalette(action); + + const opener = view.getByRole("button", { name: "Open palette" }); + opener.focus(); + fireEvent.click(opener); + fireEvent.click(await view.findByText("Goal: Set objective")); + + const objectiveInput = await view.findByRole("combobox", { name: "Goal objective" }); + await waitFor(() => expect(document.activeElement).toBe(objectiveInput)); + + fireEvent.keyDown(objectiveInput, { key: "Tab" }); + fireEvent.keyDown(objectiveInput, { key: "Tab" }); + fireEvent.keyDown(objectiveInput, { key: "Tab", shiftKey: true }); + expect(objectiveInput.closest('[cmdk-root=""]')?.contains(document.activeElement)).toBe(true); + + fireEvent.keyDown(objectiveInput, { key: "Escape" }); + await waitFor(() => expect(view.queryByText("Goal: Set objective")).toBeNull()); + expect(document.activeElement).toBe(opener); + }); + + test("Goal: Mark complete traps focus and restores it on dismissal", async () => { + const action: CommandAction = { + id: "goal:mark-complete", + title: "Goal: Mark complete", + section: "Goals", + run: () => undefined, + prompt: { + fields: [ + { + type: "text", + name: "summary", + label: "Completion summary", + placeholder: "Summarize the completed goal…", + }, + ], + onSubmit: mock(), + }, + }; + const view = renderPalette(action); + + const opener = view.getByRole("button", { name: "Open palette" }); + opener.focus(); + fireEvent.click(opener); + fireEvent.click(await view.findByText("Goal: Mark complete")); + + const summaryInput = await view.findByRole("combobox", { name: "Completion summary" }); + await waitFor(() => expect(document.activeElement).toBe(summaryInput)); + + fireEvent.keyDown(summaryInput, { key: "Tab" }); + fireEvent.keyDown(summaryInput, { key: "Tab", shiftKey: true }); + expect(summaryInput.closest('[cmdk-root=""]')?.contains(document.activeElement)).toBe(true); + + fireEvent.keyDown(summaryInput, { key: "Escape" }); + await waitFor(() => expect(view.queryByText("Goal: Mark complete")).toBeNull()); + expect(document.activeElement).toBe(opener); + }); +}); diff --git a/src/browser/components/CommandPalette/CommandPalette.tsx b/src/browser/components/CommandPalette/CommandPalette.tsx index 1dcc382f17..0c64d51778 100644 --- a/src/browser/components/CommandPalette/CommandPalette.tsx +++ b/src/browser/components/CommandPalette/CommandPalette.tsx @@ -25,6 +25,18 @@ interface CommandPaletteProps { type PromptDef = NonNullable>; type PromptField = PromptDef["fields"][number]; +function getCommandInputPlaceholder(field: PromptField | null): string { + if (!field) { + return "Switch workspaces or type > for all commands, / for slash commands…"; + } + + if (field.type === "text") { + return field.placeholder ?? "Type value…"; + } + + return field.placeholder ?? "Search options…"; +} + interface PromptPaletteItem { id: string; title: string; @@ -56,6 +68,8 @@ export const CommandPalette: React.FC = ({ getSlashContext const [agentSkills, setAgentSkills] = useState([]); const agentSkillsCacheRef = useRef>(new Map()); + const commandPanelRef = useRef(null); + const paletteOpenOriginRef = useRef(null); const { isOpen, initialQuery, close, getActions, addRecent, recent } = useCommandRegistry(); const [query, setQuery] = useState(""); const [activePrompt, setActivePrompt] = useState = ({ getSlashContext setQuery(""); }, []); + const restorePaletteOpenFocus = useCallback(() => { + paletteOpenOriginRef.current?.focus(); + paletteOpenOriginRef.current = null; + }, []); + + const dismissPalette = useCallback(() => { + resetPaletteState(); + close(); + restorePaletteOpenFocus(); + }, [close, resetPaletteState, restorePaletteOpenFocus]); + // Close palette with Escape useEffect(() => { const onKey = (e: KeyboardEvent) => { @@ -81,13 +106,12 @@ export const CommandPalette: React.FC = ({ getSlashContext // (e.g., stream interrupt). e.preventDefault(); e.stopPropagation(); - resetPaletteState(); - close(); + dismissPalette(); } }; window.addEventListener("keydown", onKey, { capture: true }); return () => window.removeEventListener("keydown", onKey, { capture: true }); - }, [isOpen, close, resetPaletteState]); + }, [isOpen, dismissPalette]); // useLayoutEffect fires after DOM commit but before browser paint — // ensures ">" appears in the input on the very first visible frame @@ -97,6 +121,7 @@ export const CommandPalette: React.FC = ({ getSlashContext setQuery(initialQuery); } else { resetPaletteState(); + paletteOpenOriginRef.current = null; } }, [isOpen, initialQuery, resetPaletteState]); @@ -348,6 +373,45 @@ export const CommandPalette: React.FC = ({ getSlashContext ? (activePrompt.fields[activePrompt.idx] ?? null) : null; + const trapPromptFocus = useCallback( + (event: React.KeyboardEvent) => { + if (!currentField || event.key !== "Tab") { + return; + } + + const panel = commandPanelRef.current; + if (!panel) { + return; + } + + const focusable = Array.from( + panel.querySelectorAll( + 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' + ) + ); + if (focusable.length === 0) { + return; + } + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + const activeElement = document.activeElement; + if (event.shiftKey && activeElement === first) { + event.preventDefault(); + stopKeyboardPropagation(event); + last.focus(); + return; + } + + if (!event.shiftKey && activeElement === last) { + event.preventDefault(); + stopKeyboardPropagation(event); + first.focus(); + } + }, + [currentField] + ); + useEffect(() => { // Select prompts can return options synchronously or as a promise. This effect normalizes // both flows, keeps the loading state in sync, and bails out early if the prompt switches @@ -426,13 +490,15 @@ export const CommandPalette: React.FC = ({ getSlashContext })), }, ]; - emptyText = isLoadingOptions - ? "Loading options..." - : rankedOptions.length - ? undefined - : selectOptions.length - ? "No results" - : "No options"; + if (isLoadingOptions) { + emptyText = "Loading options..."; + } else if (rankedOptions.length > 0) { + emptyText = undefined; + } else if (selectOptions.length > 0) { + emptyText = "No results"; + } else { + emptyText = "No options"; + } } else { const typed = query.trim(); const fallbackHint = currentField.placeholder ?? "Type value and press Enter"; @@ -455,6 +521,12 @@ export const CommandPalette: React.FC = ({ getSlashContext } } + // Capture during render before Command.Input autoFocus moves focus into the palette. + if (isOpen && !paletteOpenOriginRef.current) { + paletteOpenOriginRef.current = + document.activeElement instanceof HTMLElement ? document.activeElement : null; + } + if (!isOpen) return null; const groupsWithItems = groups.filter((group) => group.items.length > 0); @@ -463,27 +535,22 @@ export const CommandPalette: React.FC = ({ getSlashContext return (
{ - resetPaletteState(); - close(); - }} + onMouseDown={dismissPalette} > e.stopPropagation()} + onKeyDown={trapPromptFocus} shouldFilter={false} > for all commands, / for slash commands…` - } + placeholder={getCommandInputPlaceholder(currentField)} + aria-label={currentField?.label ?? "Command palette"} autoFocus onKeyDown={(e: React.KeyboardEvent) => { if (!currentField && isEditableElement(e.target)) return; @@ -496,8 +563,7 @@ export const CommandPalette: React.FC = ({ getSlashContext } else if (e.key === "Escape") { e.preventDefault(); stopKeyboardPropagation(e); - resetPaletteState(); - close(); + dismissPalette(); } return; } @@ -536,6 +602,7 @@ export const CommandPalette: React.FC = ({ getSlashContext key={item.id} value={item.title} keywords={itemKeywords} + aria-label={item.title} className="hover:bg-hover aria-selected:bg-hover mx-1 my-0.5 grid cursor-pointer grid-cols-[1fr_auto] items-center gap-2 rounded-md px-3 py-2 text-[13px] aria-selected:text-[var(--color-command-foreground)]" onSelect={() => { if ("prompt" in item && item.prompt) { diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index f3864b8c50..19f62bd673 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -73,7 +73,10 @@ import { import { Tooltip, TooltipTrigger, TooltipContent } from "@/browser/components/Tooltip/Tooltip"; import { AgentModePicker } from "@/browser/components/AgentModePicker/AgentModePicker"; import { ContextUsageIndicatorButton } from "@/browser/components/ContextUsageIndicatorButton/ContextUsageIndicatorButton"; -import { useWorkspaceUsage } from "@/browser/stores/WorkspaceStore"; +import { + useOptionalWorkspaceSidebarState, + useWorkspaceUsage, +} from "@/browser/stores/WorkspaceStore"; import { useProviderOptions } from "@/browser/hooks/useProviderOptions"; import { useProvidersConfig } from "@/browser/hooks/useProvidersConfig"; import { useAutoCompactionSettings } from "@/browser/hooks/useAutoCompactionSettings"; @@ -85,6 +88,11 @@ import { KEYBINDS, isEditableElement, } from "@/browser/utils/ui/keybinds"; +import { + hasBudgetedResumableGoal, + modelHasPricingData, + UNPRICED_TARGET_MODEL_GOAL_MESSAGE, +} from "@/common/utils/goals/budgetPricing"; import { stopKeyboardPropagation } from "@/browser/utils/events"; import { ModelSelector, @@ -205,6 +213,9 @@ const ChatInputInner: React.FC = (props) => { const atMentionProjectPath = variant === "creation" ? props.projectPath : null; const workspaceId = variant === "workspace" ? props.workspaceId : null; + const workspaceSidebarState = useOptionalWorkspaceSidebarState(workspaceId); + const workspaceGoal = workspaceSidebarState?.goal ?? null; + // Extract workspace-specific props with defaults const disabled = props.disabled ?? false; const editingMessage = variant === "workspace" ? props.editingMessage : undefined; @@ -651,6 +662,19 @@ const ChatInputInner: React.FC = (props) => { >; const selectedModel = normalizeSelectedModel(model); + if ( + variant === "workspace" && + hasBudgetedResumableGoal(workspaceGoal) && + !modelHasPricingData(selectedModel, providersConfig) + ) { + setToast({ + id: Date.now().toString(), + type: "error", + message: UNPRICED_TARGET_MODEL_GOAL_MESSAGE, + }); + return; + } + ensureModelInSettings(selectedModel); // Ensure model exists in Settings if (onModelChange) { @@ -715,9 +739,11 @@ const ChatInputInner: React.FC = (props) => { agentId, creationProjectPath, ensureModelInSettings, + providersConfig, onModelChange, thinkingLevel, variant, + workspaceGoal, workspaceId, ] ); @@ -1545,6 +1571,24 @@ const ChatInputInner: React.FC = (props) => { window.removeEventListener(CUSTOM_EVENTS.THINKING_LEVEL_TOAST, handler as EventListener); }, [variant, props, pushToast]); + // Show the backend's one-shot child-budget warning on the matching parent workspace. + useEffect(() => { + if (variant !== "workspace") return; + + const handler = (event: Event) => { + const detail = (event as CustomEvent<{ workspaceId: string; message: string }>).detail; + if (detail?.workspaceId !== workspaceId || !detail.message) { + return; + } + + pushToast({ type: "error", message: detail.message }); + }; + + window.addEventListener(CUSTOM_EVENTS.GOAL_CHILD_BUDGET_TOAST, handler as EventListener); + return () => + window.removeEventListener(CUSTOM_EVENTS.GOAL_CHILD_BUDGET_TOAST, handler as EventListener); + }, [variant, workspaceId, pushToast]); + // Show toast feedback for analytics rebuild command palette action. useEffect(() => { const handler = (event: Event) => { @@ -1804,6 +1848,7 @@ const ChatInputInner: React.FC = (props) => { workspaceId: commandWorkspaceId, projectPath: commandProjectPath, openSettings: open, + currentModel: workspaceSidebarState?.currentModel ?? null, sendMessageOptions: commandSendMessageOptions, setInput, setAttachments, diff --git a/src/browser/features/Messages/MessageRenderer.stories.tsx b/src/browser/features/Messages/MessageRenderer.stories.tsx index db2fd92153..cd3f2142fd 100644 --- a/src/browser/features/Messages/MessageRenderer.stories.tsx +++ b/src/browser/features/Messages/MessageRenderer.stories.tsx @@ -8,7 +8,12 @@ import { } from "@/browser/stories/helpers/chatSetup"; import { collapseLeftSidebar } from "@/browser/stories/helpers/uiState"; import { createStaticChatHandler } from "@/browser/stories/mocks/chatHandlers"; -import { createAssistantMessage, createUserMessage } from "@/browser/stories/mocks/messages"; +import { + createAssistantMessage, + createGoalBudgetLimitMessage, + createGoalContinuationMessage, + createUserMessage, +} from "@/browser/stories/mocks/messages"; import { createFileEditTool, createFileReadTool, @@ -212,6 +217,96 @@ export const SyntheticAutoResumeMessages: AppStory = { ), }; +export const GoalContinuationMessages: AppStory = { + render: () => ( + { + collapseLeftSidebar(); + return setupSimpleChatStory({ + workspaceId: "ws-goal-continuation", + messages: [ + createUserMessage("msg-1", "begin", { + historySequence: 1, + timestamp: STABLE_TIMESTAMP - 120000, + }), + createAssistantMessage("msg-2", "I'll start by inspecting the repository state.", { + historySequence: 2, + timestamp: STABLE_TIMESTAMP - 110000, + }), + createGoalContinuationMessage( + "msg-3", + "Continue working on the active workspace goal.\n\nShip the requested feature with tests.", + { + historySequence: 3, + timestamp: STABLE_TIMESTAMP - 60000, + } + ), + createAssistantMessage( + "msg-4", + "Continuing from the active goal, I'll add coverage next.", + { + historySequence: 4, + timestamp: STABLE_TIMESTAMP - 50000, + } + ), + ], + }); + }} + /> + ), +}; + +export const BudgetLimitWrapupMessages: AppStory = { + render: () => ( + { + collapseLeftSidebar(); + return setupSimpleChatStory({ + workspaceId: "ws-goal-budget-wrapup", + messages: [ + createUserMessage("msg-1", "begin", { + historySequence: 1, + timestamp: STABLE_TIMESTAMP - 180000, + }), + createAssistantMessage("msg-2", "I'll keep working through the active goal.", { + historySequence: 2, + timestamp: STABLE_TIMESTAMP - 170000, + }), + createGoalContinuationMessage( + "msg-3", + "Continue working on the active workspace goal.\n\nShip the requested feature with tests.", + { + historySequence: 3, + timestamp: STABLE_TIMESTAMP - 120000, + } + ), + createAssistantMessage("msg-4", "The continuation used the remaining budget.", { + historySequence: 4, + timestamp: STABLE_TIMESTAMP - 110000, + }), + createGoalBudgetLimitMessage( + "msg-5", + "The budget for this goal has been exhausted.\n\nBring the current line of work to a clean stopping point, summarize where things stand, and stop.", + { + historySequence: 5, + timestamp: STABLE_TIMESTAMP - 60000, + } + ), + createAssistantMessage( + "msg-6", + "Stopping here: tests are partially updated and the remaining risk is in the UI smoke coverage.", + { + historySequence: 6, + timestamp: STABLE_TIMESTAMP - 50000, + } + ), + ], + }); + }} + /> + ), +}; + export const WithReasoning: AppStory = { render: () => ( { + beforeEach(() => { + globalThis.window = new GlobalWindow() as unknown as Window & typeof globalThis; + globalThis.document = globalThis.window.document; + globalThis.localStorage = globalThis.window.localStorage; + }); + + afterEach(() => { + cleanup(); + + globalThis.window = undefined as unknown as Window & typeof globalThis; + globalThis.document = undefined as unknown as Document; + globalThis.localStorage = undefined as unknown as Storage; + }); + + test("labels synthetic active-goal continuation user messages", () => { + const message: DisplayedMessage = { + type: "user", + id: "goal-continuation", + historyId: "goal-continuation", + content: "Continue working on the active workspace goal.", + historySequence: 20, + isSynthetic: true, + isGoalContinuation: true, + }; + + const { getByText, queryByText } = render( + + + + ); + + expect(getByText("goal continuation")).toBeDefined(); + expect(queryByText("auto")).toBeNull(); + }); + + test("labels synthetic budget-limit wrap-up messages distinctly", () => { + const message: DisplayedMessage = { + type: "user", + id: "goal-budget-wrapup", + historyId: "goal-budget-wrapup", + content: "The budget for this goal has been exhausted.", + historySequence: 21, + isSynthetic: true, + isBudgetLimitWrapup: true, + }; + + const { getByText, queryByText } = render( + + + + ); + + expect(getByText("budget limit wrap-up")).toBeDefined(); + expect(queryByText("goal continuation")).toBeNull(); + expect(queryByText("auto")).toBeNull(); + }); +}); + describe("MessageRenderer compaction boundary rows", () => { beforeEach(() => { globalThis.window = new GlobalWindow() as unknown as Window & typeof globalThis; diff --git a/src/browser/features/Messages/UserMessage.tsx b/src/browser/features/Messages/UserMessage.tsx index d1b0bf195e..5aa4959bc7 100644 --- a/src/browser/features/Messages/UserMessage.tsx +++ b/src/browser/features/Messages/UserMessage.tsx @@ -14,7 +14,7 @@ import { } from "@/browser/utils/chatEditing"; import { usePersistedState } from "@/browser/hooks/usePersistedState"; import { VIM_ENABLED_KEY } from "@/common/constants/storage"; -import { ChevronLeft, ChevronRight, Clipboard, ClipboardCheck, Pencil } from "lucide-react"; +import { ChevronLeft, ChevronRight, Clipboard, ClipboardCheck, Pencil, Target } from "lucide-react"; /** Navigation info for navigating between user messages */ export interface UserMessageNavigation { @@ -45,6 +45,8 @@ export const UserMessage: React.FC = ({ navigation, }) => { const isSynthetic = message.isSynthetic === true; + const isGoalContinuation = message.isGoalContinuation === true; + const isBudgetLimitWrapup = message.isBudgetLimitWrapup === true; const content = message.content; const [vimEnabled] = usePersistedState(VIM_ENABLED_KEY, false, { listener: true }); const isMobileTouch = @@ -134,12 +136,33 @@ export const UserMessage: React.FC = ({ }, ]; - const label = isSynthetic ? ( - - auto - - ) : null; - const syntheticClassName = cn(className, isSynthetic && "opacity-70"); + let label: React.ReactNode = null; + if (isBudgetLimitWrapup) { + label = ( + + + ); + } else if (isGoalContinuation) { + label = ( + + + ); + } else if (isSynthetic) { + label = ( + + auto + + ); + } + const syntheticClassName = cn( + className, + isSynthetic && "opacity-70", + (isGoalContinuation || isBudgetLimitWrapup) && "italic" + ); return ( = { + title: "Features/RightSidebar/GoalTab", + component: GoalTab, + parameters: { layout: "fullscreen" }, +}; + +export default meta; +type Story = StoryObj; + +export const Active: Story = { + args: { + goal: { + goalId: "11111111-1111-4111-8111-111111111111", + status: "active", + objective: "Ship the goal primitive vertical slice", + budgetCents: null, + costCents: 0, + turnsUsed: 0, + turnCap: null, + startedAtMs: Date.now(), + }, + }, +}; + +export const ActiveWithAccounting: Story = { + args: { + goal: { + goalId: "44444444-4444-4444-8444-444444444444", + status: "active", + objective: "Ship the cost accumulator vertical slice", + budgetCents: 500, + costCents: 125, + turnsUsed: 3, + turnCap: 10, + startedAtMs: Date.now() - 90_000, + }, + }, +}; + +export const Paused: Story = { + args: { + goal: { + goalId: "22222222-2222-4222-8222-222222222222", + status: "paused", + objective: "Ship the goal primitive vertical slice", + budgetCents: null, + costCents: 125, + turnsUsed: 3, + turnCap: null, + startedAtMs: Date.now(), + }, + }, +}; + +export const BudgetLimited: Story = { + args: { + goal: { + goalId: "55555555-5555-4555-8555-555555555555", + status: "budget_limited", + objective: "Ship the budget-limited transition slice", + budgetCents: 500, + costCents: 525, + turnsUsed: 4, + turnCap: 10, + startedAtMs: Date.now() - 120_000, + }, + }, +}; + +export const Complete: Story = { + args: { + 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: Date.now(), + }, + }, +}; diff --git a/src/browser/features/RightSidebar/GoalTab.test.tsx b/src/browser/features/RightSidebar/GoalTab.test.tsx new file mode 100644 index 0000000000..27204ae236 --- /dev/null +++ b/src/browser/features/RightSidebar/GoalTab.test.tsx @@ -0,0 +1,171 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { cleanup, fireEvent, render, waitFor } from "@testing-library/react"; +import { installDom } from "../../../../tests/ui/dom"; +import type { GoalSnapshot } from "@/common/types/goal"; +import { GoalTab } from "./GoalTab"; + +function goal(overrides: Partial = {}): GoalSnapshot { + return { + goalId: "11111111-1111-4111-8111-111111111111", + status: "active", + objective: "Ship the goal lifecycle slice", + budgetCents: null, + costCents: 125, + turnsUsed: 3, + turnCap: null, + startedAtMs: Date.now(), + ...overrides, + }; +} + +describe("GoalTab", () => { + let cleanupDom: (() => void) | null = null; + + beforeEach(() => { + cleanupDom = installDom(); + }); + + afterEach(() => { + cleanup(); + cleanupDom?.(); + cleanupDom = null; + }); + + test("renders lifecycle buttons based on status", () => { + const { rerender, getByLabelText, queryByLabelText } = render( + + ); + + expect(getByLabelText("Pause goal")).toBeTruthy(); + expect(getByLabelText("Mark goal complete")).toBeTruthy(); + expect(queryByLabelText("Resume goal")).toBeNull(); + + rerender(); + expect(getByLabelText("Resume goal")).toBeTruthy(); + expect(queryByLabelText("Pause goal")).toBeNull(); + expect(queryByLabelText("Mark goal complete")).toBeNull(); + + rerender( + + ); + expect(queryByLabelText("Pause goal")).toBeNull(); + expect(queryByLabelText("Resume goal")).toBeNull(); + expect(queryByLabelText("Mark goal complete")).toBeNull(); + expect(getByLabelText("Completion summary").textContent).toContain("All work is complete."); + + // Coder-agents-review P3 DEREM-39: budget_limited must keep the manual + // "Mark goal complete" button so the user can wrap up after exhausting + // the budget. Pause is hidden because the goal is already paused-ish + // (no auto-continuation), and Resume is hidden because the goal is + // not in the `paused` state. + rerender( + + ); + expect(getByLabelText("Mark goal complete")).toBeTruthy(); + expect(queryByLabelText("Pause goal")).toBeNull(); + expect(queryByLabelText("Resume goal")).toBeNull(); + }); + + test("renders accounting breakdown", () => { + const startedAtMs = Date.now() - 90_000; + const { getByText } = render( + + ); + + expect(getByText("$1.25")).toBeTruthy(); + expect(getByText("$5.00")).toBeTruthy(); + expect(getByText("$3.75")).toBeTruthy(); + expect(getByText("3 / 10")).toBeTruthy(); + }); + + test("edits budget inline and restores focus", async () => { + const onUpdateBudget = mock(() => Promise.resolve(undefined)); + const { getByLabelText, getByText } = render( + + ); + + const opener = getByLabelText("Edit goal budget"); + opener.focus(); + fireEvent.click(opener); + + const input = getByLabelText("Goal budget amount"); + await waitFor(() => expect(document.activeElement).toBe(input)); + fireEvent.input(input, { target: { value: "$7.50" } }); + fireEvent.click(getByText("Save budget")); + + await waitFor(() => expect(onUpdateBudget).toHaveBeenCalledWith(750)); + expect(document.activeElement).toBe(opener); + }); + + test("edits turn cap inline", async () => { + const onUpdateTurnCap = mock(() => Promise.resolve(undefined)); + const { getByLabelText, getByText } = render( + + ); + + fireEvent.click(getByLabelText("Edit goal turn cap")); + const input = getByLabelText("Goal turn cap"); + await waitFor(() => expect(document.activeElement).toBe(input)); + fireEvent.input(input, { target: { value: "15" } }); + fireEvent.click(getByText("Save turn cap")); + + await waitFor(() => expect(onUpdateTurnCap).toHaveBeenCalledWith(15)); + }); + + test("opens completion summary input, traps focus, submits, and restores focus", async () => { + const onSetStatus = mock(() => Promise.resolve(undefined)); + const { getByLabelText, getByText, queryByLabelText } = render( + + ); + + const opener = getByLabelText("Mark goal complete"); + opener.focus(); + fireEvent.click(opener); + + const input = getByLabelText("Goal completion summary"); + await waitFor(() => expect(document.activeElement).toBe(input)); + + const cancel = getByText("Cancel"); + cancel.focus(); + fireEvent.keyDown(cancel, { key: "Tab" }); + expect(document.activeElement).toBe(input); + + (input as HTMLTextAreaElement).value = "Finished with tests passing."; + fireEvent.input(input); + fireEvent.click(getByText("Save summary")); + + await waitFor(() => { + expect(onSetStatus).toHaveBeenCalledWith("complete", "Finished with tests passing."); + }); + expect(queryByLabelText("Goal completion summary")).toBeNull(); + expect(document.activeElement).toBe(opener); + }); +}); diff --git a/src/browser/features/RightSidebar/GoalTab.tsx b/src/browser/features/RightSidebar/GoalTab.tsx new file mode 100644 index 0000000000..535d149b91 --- /dev/null +++ b/src/browser/features/RightSidebar/GoalTab.tsx @@ -0,0 +1,443 @@ +import { Target } from "lucide-react"; +import { useEffect, useRef, useState, type KeyboardEvent } from "react"; +import type { GoalSnapshot, GoalStatus } from "@/common/types/goal"; +import { formatGoalCents } from "@/common/utils/goals/budgetPricing"; +import { parseGoalBudgetInputCents } from "@/common/utils/goals/budgetParser"; +// Import shared formatters / status labels from goalToolUtils so the GoalTab +// stays in sync with the tool-call cards (Coder-agents-review nits DEREM-28 +// + DEREM-29). Local copies drifted in case (`active` vs `Active`) and could +// drift further as Goal status grows. +import { formatGoalElapsed, goalStatusLabel } from "@/browser/features/Tools/Goal/goalToolUtils"; + +interface GoalTabProps { + goal: GoalSnapshot | null; + openCompleteInputRequest?: number; + // GoalTab UI only invokes user-facing transitions (pause/resume/complete); + // `budget_limited` is internal-only and is excluded from the public oRPC + // `setGoal` input shape (Coder-agents-review nit DEREM-53). + onSetStatus?: ( + status: Exclude, + completionSummary?: string + ) => Promise | void; + onUpdateBudget?: (budgetCents: number | null) => Promise | void; + onUpdateTurnCap?: (turnCap: number | null) => Promise | void; + onClear?: () => Promise | void; +} + +// `parseBudgetInput` is now a thin alias for the canonical parser shared +// with the slash command and the command palette (Coder-agents-review P3 +// DEREM-21). +const parseBudgetInput = parseGoalBudgetInputCents; + +function parseTurnCapInput(value: string): number | null | undefined { + const trimmed = value.trim(); + if (trimmed.length === 0) { + return null; + } + + const parsed = Number(trimmed); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined; +} + +export function GoalTab(props: GoalTabProps) { + const [isSummaryInputOpen, setIsSummaryInputOpen] = useState(false); + const [editingField, setEditingField] = useState<"budget" | "turnCap" | null>(null); + const [editValue, setEditValue] = useState(""); + const [summary, setSummary] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + const editInputRef = useRef(null); + const inputRef = useRef(null); + const originRef = useRef(null); + const lastCompleteInputRequestRef = useRef(props.openCompleteInputRequest ?? 0); + + const openSummaryInput = (origin: HTMLElement | null) => { + originRef.current = origin; + setSummary(""); + setError(null); + setIsSummaryInputOpen(true); + }; + + const closeSummaryInput = () => { + setIsSummaryInputOpen(false); + setError(null); + originRef.current?.focus(); + }; + + const openBudgetEditor = (origin: HTMLElement | null) => { + originRef.current = origin; + setEditValue(props.goal?.budgetCents == null ? "" : (props.goal.budgetCents / 100).toFixed(2)); + setError(null); + setEditingField("budget"); + }; + + const openTurnCapEditor = (origin: HTMLElement | null) => { + originRef.current = origin; + setEditValue(props.goal?.turnCap == null ? "" : String(props.goal.turnCap)); + setError(null); + setEditingField("turnCap"); + }; + + const closeEditor = () => { + setEditingField(null); + setError(null); + originRef.current?.focus(); + }; + + const submitEditor = async () => { + setIsSubmitting(true); + setError(null); + try { + const submittedValue = editInputRef.current?.value ?? editValue; + if (editingField === "budget") { + const budgetCents = parseBudgetInput(submittedValue); + if (budgetCents === undefined) { + setError("Enter a budget like $5, 500c, or leave blank for no budget."); + return; + } + await props.onUpdateBudget?.(budgetCents); + } else if (editingField === "turnCap") { + const turnCap = parseTurnCapInput(submittedValue); + if (turnCap === undefined) { + setError("Enter a positive whole-number turn cap, or leave blank for no cap."); + return; + } + await props.onUpdateTurnCap?.(turnCap); + } + closeEditor(); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Goal update failed"); + } finally { + setIsSubmitting(false); + } + }; + + useEffect(() => { + if (!isSummaryInputOpen) { + return; + } + inputRef.current?.focus(); + }, [isSummaryInputOpen]); + + useEffect(() => { + const request = props.openCompleteInputRequest ?? 0; + if (request === lastCompleteInputRequestRef.current) { + return; + } + lastCompleteInputRequestRef.current = request; + if (request > 0 && props.goal && props.goal.status !== "complete") { + openSummaryInput( + document.activeElement instanceof HTMLElement ? document.activeElement : null + ); + } + }, [props.openCompleteInputRequest, props.goal]); + + if (!props.goal) { + return ( +
+
+ ); + } + + const canPause = props.goal.status === "active"; + const canResume = props.goal.status === "paused"; + const canComplete = props.goal.status === "active" || props.goal.status === "budget_limited"; + + const setStatus = async ( + status: Exclude, + completionSummary?: string + ) => { + setError(null); + try { + await props.onSetStatus?.(status, completionSummary); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Goal update failed"); + } + }; + + const clearGoal = async () => { + setError(null); + try { + await props.onClear?.(); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Goal clear failed"); + } + }; + + const submitSummary = async () => { + const trimmed = (inputRef.current?.value ?? summary).trim(); + if (!trimmed) { + setError("Completion summary is required."); + inputRef.current?.focus(); + return; + } + + setIsSubmitting(true); + setError(null); + try { + await props.onSetStatus?.("complete", trimmed); + setIsSummaryInputOpen(false); + originRef.current?.focus(); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Goal completion failed"); + inputRef.current?.focus(); + } finally { + setIsSubmitting(false); + } + }; + + const trapSummaryFocus = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + closeSummaryInput(); + return; + } + + if (event.key !== "Tab") { + return; + } + + const focusable = Array.from( + event.currentTarget.querySelectorAll( + 'textarea, button:not([disabled]), [href], input, select, [tabindex]:not([tabindex="-1"])' + ) + ); + if (focusable.length === 0) { + return; + } + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }; + + return ( +
+
+
+
+

{props.goal.objective}

+
+ + {props.goal.status === "complete" && props.goal.completionSummary && ( +
+

Completion summary

+

{props.goal.completionSummary}

+
+ )} + +
+
+
Cost
+
{formatGoalCents(props.goal.costCents)}
+
+
+
Budget
+
+ + {props.goal.budgetCents == null + ? "No budget" + : formatGoalCents(props.goal.budgetCents)} + + +
+
+
+
Remaining
+
+ {props.goal.budgetCents == null + ? "—" + : formatGoalCents(Math.max(0, props.goal.budgetCents - props.goal.costCents))} +
+
+
+
Turns
+
+ + {props.goal.turnCap == null + ? String(props.goal.turnsUsed) + : `${props.goal.turnsUsed} / ${props.goal.turnCap}`} + + +
+
+
+
Elapsed
+
+ {formatGoalElapsed(props.goal.startedAtMs)} +
+
+
+ + {editingField && ( +
+ + event.currentTarget.select()} + onChange={(event) => setEditValue(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key === "Escape") { + event.preventDefault(); + closeEditor(); + } + if (event.key === "Enter") { + event.preventDefault(); + void submitEditor(); + } + }} + /> +

+ {editingField === "budget" + ? "Use $5, 500c, or blank for no budget." + : "Use a positive whole number, or blank for no cap."} +

+
+ + +
+
+ )} + +
+ {canPause && ( + + )} + {canResume && ( + + )} + {canComplete && ( + + )} + +
+ + {isSummaryInputOpen && ( +
+ +