diff --git a/src/browser/components/AgentModePicker/AgentModePicker.tsx b/src/browser/components/AgentModePicker/AgentModePicker.tsx index 834c9b8635..a59bc002e3 100644 --- a/src/browser/components/AgentModePicker/AgentModePicker.tsx +++ b/src/browser/components/AgentModePicker/AgentModePicker.tsx @@ -1,8 +1,8 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Bot, ChevronDown, Route, SquareCode } from "lucide-react"; -import type { LucideIcon } from "lucide-react"; +import { ChevronDown } from "lucide-react"; import { useAgent } from "@/browser/contexts/AgentContext"; +import { getAgentIcon } from "@/browser/utils/agentIcons"; import { CUSTOM_EVENTS } from "@/common/constants/events"; import type { AgentDefinitionDescriptor } from "@/common/types/agentDefinition"; import { normalizeAgentId as normalizeStoredAgentId } from "@/common/utils/agentIds"; @@ -43,17 +43,6 @@ interface AgentOption { subagentRunnable: boolean; } -/** Maps well-known agent IDs to lucide icons for the dropdown */ -const AGENT_ICONS: Record = { - plan: Route, - exec: SquareCode, -}; -const DEFAULT_AGENT_ICON: LucideIcon = Bot; - -function getAgentIcon(agentId: string): LucideIcon { - return AGENT_ICONS[agentId] ?? DEFAULT_AGENT_ICON; -} - export function formatAgentIdLabel(agentId: string): string { if (!agentId) { return "Agent"; diff --git a/src/browser/components/InstructionsTab/InstructionsTab.tsx b/src/browser/components/InstructionsTab/InstructionsTab.tsx index 967cb92688..aff6245439 100644 --- a/src/browser/components/InstructionsTab/InstructionsTab.tsx +++ b/src/browser/components/InstructionsTab/InstructionsTab.tsx @@ -5,6 +5,7 @@ import { useAPI } from "@/browser/contexts/API"; import { ErrorBoundary } from "@/browser/components/ErrorBoundary/ErrorBoundary"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/browser/components/Tooltip/Tooltip"; import { ChatInstructionsPanel } from "./AdditionalSystemContextScratchpad"; +import { ModeInstructionsPanel } from "./ModeInstructionsPanel"; import { isAbortError } from "@/browser/utils/isAbortError"; import { setWorkspaceInstructionsFileCount } from "@/browser/utils/workspaceInstructionsStore"; import { cn } from "@/common/lib/utils"; @@ -76,6 +77,7 @@ function InstructionsTabImpl(props: InstructionsTabProps) { onRefresh={refresh} />
+ {error && } {!error && !loading && data?.files.length === 0 && } diff --git a/src/browser/components/InstructionsTab/ModeInstructionsPanel.stories.tsx b/src/browser/components/InstructionsTab/ModeInstructionsPanel.stories.tsx new file mode 100644 index 0000000000..234c68771e --- /dev/null +++ b/src/browser/components/InstructionsTab/ModeInstructionsPanel.stories.tsx @@ -0,0 +1,130 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; + +import { TooltipProvider } from "@/browser/components/Tooltip/Tooltip"; +import { APIProvider } from "@/browser/contexts/API"; +import { AgentProvider, type AgentContextValue } from "@/browser/contexts/AgentContext"; +import { createMockORPCClient } from "@/browser/stories/mocks/orpc"; +import type { AgentDefinitionDescriptor } from "@/common/types/agentDefinition"; + +import { ModeInstructionsPanel } from "./ModeInstructionsPanel"; + +const WORKSPACE_ID = "ws-mode-instructions"; + +const EXEC_AGENT: AgentDefinitionDescriptor = { + id: "exec", + scope: "built-in", + name: "Exec", + description: "Implement changes in the repository", + uiSelectable: true, + uiRoutable: true, + subagentRunnable: true, + uiColor: "var(--color-exec-mode)", +}; + +const PLAN_AGENT: AgentDefinitionDescriptor = { + id: "plan", + scope: "built-in", + name: "Plan", + description: "Create a plan before coding — research, propose, then hand off.", + uiSelectable: true, + uiRoutable: true, + subagentRunnable: false, + base: "plan", + uiColor: "var(--color-plan-mode)", +}; + +const EXEC_BODY = `# Exec mode + +You are in **Exec mode**. Make the requested change with minimal, reviewable +edits and verify your work. + +## Standing orders + +- Read before you write: confirm paths, symbols, and call-sites first. +- Prefer narrow, targeted fixes over large rewrites. +- Run typecheck and the most-relevant tests after every meaningful change. +- Never claim success until validation actually passes. + +## Tools available + +- File editing (replace_string, insert) +- Bash (typecheck, lint, tests, git) +- Sub-agents (\`explore\` for read-only investigation) +`; + +const PLAN_BODY = `# Plan mode + +You are in **Plan mode**. Your job is to think and design — _not_ to write +production code. + +## What "done" looks like + +1. A clearly-scoped problem statement. +2. A proposed implementation plan, broken into concrete steps. +3. Identified risks and the most important review surface. + +> When the plan is accepted, hand off to \`exec\` for implementation. +`; + +function buildContext( + agent: AgentDefinitionDescriptor, + overrides?: Partial +): AgentContextValue { + return { + agentId: agent.id, + setAgentId: () => undefined, + currentAgent: agent, + agents: [agent], + loaded: true, + loadFailed: false, + refresh: () => Promise.resolve(), + refreshing: false, + disableWorkspaceAgents: false, + setDisableWorkspaceAgents: () => undefined, + ...overrides, + }; +} + +function withMockProviders(context: AgentContextValue, bodies: Record) { + return function Decorator(Story: React.ComponentType) { + return ( + + + +
+ +
+
+
+
+ ); + }; +} + +const meta: Meta = { + title: "App/Right Sidebar/Instructions/ModeInstructionsPanel", + component: ModeInstructionsPanel, + parameters: { layout: "fullscreen" }, +}; + +export default meta; +type Story = StoryObj; + +// Snapshot budget is tight (see tests/ui/storybook/budget.test.ts), so we +// cover the two distinct mode colors (exec=purple, plan=blue) — those are +// the visuals most worth protecting against regressions. Custom-scope and +// empty-body variants are exercised indirectly via the implementation tests. +export const ExecMode: Story = { + args: { workspaceId: WORKSPACE_ID }, + decorators: [withMockProviders(buildContext(EXEC_AGENT), { exec: EXEC_BODY })], +}; + +export const PlanMode: Story = { + args: { workspaceId: WORKSPACE_ID }, + decorators: [withMockProviders(buildContext(PLAN_AGENT), { plan: PLAN_BODY })], +}; diff --git a/src/browser/components/InstructionsTab/ModeInstructionsPanel.tsx b/src/browser/components/InstructionsTab/ModeInstructionsPanel.tsx new file mode 100644 index 0000000000..51d1951f86 --- /dev/null +++ b/src/browser/components/InstructionsTab/ModeInstructionsPanel.tsx @@ -0,0 +1,257 @@ +import { useEffect, useMemo, useState } from "react"; +import { ChevronRight, RefreshCw } from "lucide-react"; + +import { useAPI } from "@/browser/contexts/API"; +import { useAgent } from "@/browser/contexts/AgentContext"; +import { MarkdownRenderer } from "@/browser/features/Messages/MarkdownRenderer"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/browser/components/Tooltip/Tooltip"; +import { formatAgentIdLabel } from "@/browser/components/AgentModePicker/AgentModePicker"; +import { getAgentIcon } from "@/browser/utils/agentIcons"; +import { isAbortError } from "@/browser/utils/isAbortError"; +import { cn } from "@/common/lib/utils"; +import type { AgentDefinitionPackage } from "@/common/types/agentDefinition"; +import { getErrorMessage } from "@/common/utils/errors"; + +interface ModeInstructionsPanelProps { + workspaceId: string; +} + +/** + * Mode instructions panel — renders the system prompt body of the currently + * selected agent (a.k.a. "mode") so the user can see exactly what guidance + * the agent is starting each turn with. The panel is keyed on the agent id + * so switching modes triggers a fresh fetch + a smooth color transition. + * + * Styling intentionally pulls from the agent's `uiColor` (the same hue used + * by the mode picker pill and the chat-input focus ring) so the Instructions + * tab visibly tracks the mode you're in. + */ +export function ModeInstructionsPanel(props: ModeInstructionsPanelProps) { + const { api } = useAPI(); + const { agentId, currentAgent, loaded } = useAgent(); + // Cache the (agentId, workspaceId) the loaded `pkg` belongs to so we can + // invalidate it on either dimension. An `exec.md` in workspace A and an + // `exec.md` in workspace B can have completely different bodies, so a bare + // `pkg.id === agentId` check is not enough. + const [pkgKey, setPkgKey] = useState<{ agentId: string; workspaceId: string } | null>(null); + const [pkg, setPkg] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [expanded, setExpanded] = useState(true); + const [refreshTick, setRefreshTick] = useState(0); + + // Use the descriptor's uiColor (already inheritance-resolved on the + // backend); fall back to the neutral border var so the section never has + // an undefined CSS color while agents are still loading. + const color = currentAgent?.uiColor ?? "var(--color-border-light)"; + + useEffect(() => { + if (!api || !agentId) return; + const controller = new AbortController(); + setLoading(true); + setError(null); + const requestWorkspaceId = props.workspaceId; + const requestAgentId = agentId; + api.agents + .get( + { workspaceId: requestWorkspaceId, agentId: requestAgentId }, + { signal: controller.signal } + ) + .then((result) => { + if (controller.signal.aborted) return; + setPkg(result); + setPkgKey({ agentId: requestAgentId, workspaceId: requestWorkspaceId }); + setLoading(false); + }) + .catch((err) => { + if (isAbortError(err) || controller.signal.aborted) return; + setError(getErrorMessage(err)); + setLoading(false); + }); + return () => controller.abort(); + }, [api, agentId, props.workspaceId, refreshTick]); + + // Guard against stale data leaking across mode *and* workspace switches: + // when either dimension changes, the previous fetch could still resolve + // and we'd otherwise keep rendering the wrong body (and its token count) + // under the new mode's color/name until the new response arrives. Treat + // any pkg whose (agentId, workspaceId) doesn't match the current + // selection as "not loaded yet" so the body section falls back to the + // loading/empty state instead of showing the wrong prompt. + const pkgMatchesContext = + pkgKey?.agentId === agentId && pkgKey?.workspaceId === props.workspaceId; + const effectivePkg = pkgMatchesContext ? pkg : null; + + const displayName = currentAgent?.name ?? formatAgentIdLabel(agentId); + const description = currentAgent?.description ?? effectivePkg?.frontmatter.description; + const scope = currentAgent?.scope ?? effectivePkg?.scope; + const body = effectivePkg?.body ?? ""; + const hasBody = body.trim().length > 0; + + // Approximate token count (rough heuristic: 4 chars ≈ 1 token). We don't + // need precision here — the goal is to give a sense of how much prompt is + // being injected, similar to the totals in the Instructions header. + const approxTokens = useMemo(() => { + if (!hasBody) return 0; + return Math.max(1, Math.round(body.length / 4)); + }, [body, hasBody]); + + const Icon = getAgentIcon(agentId); + + // Section background uses a very light tint of the mode color so it stands + // out from the surrounding panel without overpowering the contained + // markdown. The left edge gets a thicker accent bar in the mode color. + // `--mode-color` is exposed as a custom property so descendants can reuse + // the same hue without redefining the mix expression. + const sectionStyle: React.CSSProperties & Record<"--mode-color", string> = { + "--mode-color": color, + backgroundColor: `color-mix(in srgb, ${color} 6%, transparent)`, + borderLeftColor: color, + }; + + return ( +
+
+ + + + + + Re-read mode definition + +
+ + {expanded && ( +
+ {loading && !effectivePkg && ( +
+ Loading mode instructions… +
+ )} + {error && ( +
+ Failed to load mode instructions: {error} +
+ )} + {!loading && !error && !hasBody && ( +
+ This mode does not define any custom instructions. +
+ )} + {hasBody && ( + // Cap the height so a multi-KB prompt doesn't push the rest of + // the panel off-screen; the inner block scrolls independently. +
+ +
+ )} +
+ )} + + {!expanded && hasBody && ( +
+ {firstNonEmptyLine(body) || "(empty)"} +
+ )} +
+ ); +} + +/** + * Find the first non-blank line in a (potentially long) markdown body. Used + * for the collapsed preview so the user can identify the prompt at a glance. + */ +function firstNonEmptyLine(text: string): string { + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (trimmed.length > 0) return trimmed; + } + return ""; +} + +function formatTokens(n: number): string { + if (n < 1000) return String(n); + if (n < 10_000) return `${(n / 1000).toFixed(1)}k`; + return `${Math.round(n / 1000)}k`; +} diff --git a/src/browser/stories/mocks/orpc.ts b/src/browser/stories/mocks/orpc.ts index 1d4b48bd51..1309edfdb1 100644 --- a/src/browser/stories/mocks/orpc.ts +++ b/src/browser/stories/mocks/orpc.ts @@ -126,6 +126,12 @@ export interface MockORPCClientOptions { agentAiDefaults?: AgentAiDefaults; /** Agent definitions to expose via agents.list */ agentDefinitions?: AgentDefinitionDescriptor[]; + /** + * Per-agent system-prompt body returned from `agents.get`. Stories that + * exercise the Instructions tab's mode panel can populate this so the + * markdown renderer has real content to show. Keys are agent IDs. + */ + agentBodies?: Record; /** Initial per-subagent AI defaults for config.getConfig (e.g., Settings → Tasks section) */ subagentAiDefaults?: SubagentAiDefaults; /** Coder lifecycle preferences for config.getConfig (e.g., Settings → Coder section) */ @@ -383,6 +389,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl routePriority: initialRoutePriority = ["direct"], routeOverrides: initialRouteOverrides = {}, agentDefinitions: initialAgentDefinitions, + agentBodies: initialAgentBodies, listBranches: customListBranches, gitInit: customGitInit, runtimeAvailability: customRuntimeAvailability, @@ -936,7 +943,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl ai: descriptor.aiDefaults, tools: descriptor.tools, }, - body: "", + body: initialAgentBodies?.[descriptor.id] ?? "", } satisfies AgentDefinitionPackage; return Promise.resolve(agentPackage); diff --git a/src/browser/utils/agentIcons.ts b/src/browser/utils/agentIcons.ts new file mode 100644 index 0000000000..2da2020f42 --- /dev/null +++ b/src/browser/utils/agentIcons.ts @@ -0,0 +1,18 @@ +import { Bot, Route, SquareCode } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; + +/** + * Maps well-known agent IDs to lucide icons. Shared between the + * AgentModePicker (the small pill in the chat-input bar) and the + * Instructions pane so the same agent always shows the same glyph. + */ +const AGENT_ICONS: Record = { + plan: Route, + exec: SquareCode, +}; + +const DEFAULT_AGENT_ICON: LucideIcon = Bot; + +export function getAgentIcon(agentId: string): LucideIcon { + return AGENT_ICONS[agentId] ?? DEFAULT_AGENT_ICON; +}