From 8e656ce206c9dbf0a1ac29ee978a44fa44a5fee8 Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 26 May 2026 16:45:52 -0500 Subject: [PATCH 1/3] feat: show active mode instructions in Instructions pane Add a ModeInstructionsPanel above the existing Chat Instructions / AGENTS.md sections that renders the currently-selected agent's system prompt body. The panel styles itself with the agent's uiColor (background tint, left accent bar, icon swatch, mode-name color), so the Instructions tab visibly tracks the mode you're in. Also extracts the shared agent -> lucide icon map out of AgentModePicker into src/browser/utils/agentIcons.ts so both surfaces stay in sync. --- .../AgentModePicker/AgentModePicker.tsx | 15 +- .../InstructionsTab/InstructionsTab.tsx | 2 + .../ModeInstructionsPanel.stories.tsx | 130 ++++++++++ .../InstructionsTab/ModeInstructionsPanel.tsx | 235 ++++++++++++++++++ src/browser/stories/mocks/orpc.ts | 9 +- src/browser/utils/agentIcons.ts | 18 ++ 6 files changed, 395 insertions(+), 14 deletions(-) create mode 100644 src/browser/components/InstructionsTab/ModeInstructionsPanel.stories.tsx create mode 100644 src/browser/components/InstructionsTab/ModeInstructionsPanel.tsx create mode 100644 src/browser/utils/agentIcons.ts 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..ec8ffe3751 --- /dev/null +++ b/src/browser/components/InstructionsTab/ModeInstructionsPanel.tsx @@ -0,0 +1,235 @@ +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(); + 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); + api.agents + .get({ workspaceId: props.workspaceId, agentId }, { signal: controller.signal }) + .then((result) => { + if (controller.signal.aborted) return; + setPkg(result); + setLoading(false); + }) + .catch((err) => { + if (isAbortError(err) || controller.signal.aborted) return; + setError(getErrorMessage(err)); + setLoading(false); + }); + return () => controller.abort(); + }, [api, agentId, props.workspaceId, refreshTick]); + + const displayName = currentAgent?.name ?? formatAgentIdLabel(agentId); + const description = currentAgent?.description ?? pkg?.frontmatter.description; + const scope = currentAgent?.scope ?? pkg?.scope; + const body = pkg?.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 && !pkg && ( +
+ 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; +} From bb196b61a6be2db967c46bbc6a6ab18b8e9cc345 Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 26 May 2026 16:52:05 -0500 Subject: [PATCH 2/3] fix: discard stale agent body across mode switches Address Codex P2: when the user switches modes the previous fetch can still be in flight, and we were rendering the old body under the new mode's color/name until the new response arrived. Treat any pkg whose id no longer matches the current agentId as 'not loaded yet' so the body section falls back to the loading state instead of showing the wrong prompt. --- .../InstructionsTab/ModeInstructionsPanel.tsx | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/browser/components/InstructionsTab/ModeInstructionsPanel.tsx b/src/browser/components/InstructionsTab/ModeInstructionsPanel.tsx index ec8ffe3751..b710535a43 100644 --- a/src/browser/components/InstructionsTab/ModeInstructionsPanel.tsx +++ b/src/browser/components/InstructionsTab/ModeInstructionsPanel.tsx @@ -60,10 +60,20 @@ export function ModeInstructionsPanel(props: ModeInstructionsPanelProps) { return () => controller.abort(); }, [api, agentId, props.workspaceId, refreshTick]); + // Guard against stale data leaking across mode switches: when the user picks + // a different agent the previous fetch resolves with the *old* id, and even + // after the new fetch starts we'd otherwise keep rendering the old body + // (and its token count) under the new mode's color/name until the new + // response arrives. Treat any pkg whose id doesn't match the current + // agentId as "not loaded yet" so the body section falls back to the + // loading/empty state instead of showing the wrong prompt. + const pkgMatchesAgent = pkg?.id === agentId; + const effectivePkg = pkgMatchesAgent ? pkg : null; + const displayName = currentAgent?.name ?? formatAgentIdLabel(agentId); - const description = currentAgent?.description ?? pkg?.frontmatter.description; - const scope = currentAgent?.scope ?? pkg?.scope; - const body = pkg?.body ?? ""; + 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 @@ -175,7 +185,7 @@ export function ModeInstructionsPanel(props: ModeInstructionsPanelProps) { backgroundColor: "var(--color-background)", }} > - {loading && !pkg && ( + {loading && !effectivePkg && (
Loading mode instructions…
From 0191593c2797c0d64dc72730d60f2c0225797327 Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 26 May 2026 16:58:02 -0500 Subject: [PATCH 3/3] fix: also invalidate cached mode body on workspace switch Address Codex P2: when workspaceId changes but the mode id stays the same (e.g. exec in workspace A vs B with different exec.md), the previous pkg.id === agentId guard kept rendering the old workspace's prompt until the new request returned. Track the (agentId, workspaceId) pair the cached pkg belongs to so either dimension can invalidate it. --- .../InstructionsTab/ModeInstructionsPanel.tsx | 30 +++++++++++++------ 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/src/browser/components/InstructionsTab/ModeInstructionsPanel.tsx b/src/browser/components/InstructionsTab/ModeInstructionsPanel.tsx index b710535a43..51d1951f86 100644 --- a/src/browser/components/InstructionsTab/ModeInstructionsPanel.tsx +++ b/src/browser/components/InstructionsTab/ModeInstructionsPanel.tsx @@ -29,6 +29,11 @@ interface ModeInstructionsPanelProps { 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); @@ -45,11 +50,17 @@ export function ModeInstructionsPanel(props: ModeInstructionsPanelProps) { const controller = new AbortController(); setLoading(true); setError(null); + const requestWorkspaceId = props.workspaceId; + const requestAgentId = agentId; api.agents - .get({ workspaceId: props.workspaceId, agentId }, { signal: controller.signal }) + .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) => { @@ -60,15 +71,16 @@ export function ModeInstructionsPanel(props: ModeInstructionsPanelProps) { return () => controller.abort(); }, [api, agentId, props.workspaceId, refreshTick]); - // Guard against stale data leaking across mode switches: when the user picks - // a different agent the previous fetch resolves with the *old* id, and even - // after the new fetch starts we'd otherwise keep rendering the old body - // (and its token count) under the new mode's color/name until the new - // response arrives. Treat any pkg whose id doesn't match the current - // agentId as "not loaded yet" so the body section falls back to the + // 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 pkgMatchesAgent = pkg?.id === agentId; - const effectivePkg = pkgMatchesAgent ? pkg : null; + 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;