From 1ec3cc674b72eadb8f10a2431f840f2a65cb4593 Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 25 Feb 2026 13:55:07 -0600 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=A4=96=20fix:=20stabilize=20agent=20f?= =?UTF-8?q?allback=20accents=20and=20cache=20hydration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/AgentModePicker.test.tsx | 83 +++ src/browser/components/AgentModePicker.tsx | 23 +- src/browser/components/ChatInput/index.tsx | 6 +- src/browser/contexts/AgentContext.test.tsx | 498 +++++++++++++++++- src/browser/contexts/AgentContext.tsx | 121 ++++- src/browser/utils/agents.ts | 19 + 6 files changed, 715 insertions(+), 35 deletions(-) diff --git a/src/browser/components/AgentModePicker.test.tsx b/src/browser/components/AgentModePicker.test.tsx index 37a1e0eb9b..eb5a72116f 100644 --- a/src/browser/components/AgentModePicker.test.tsx +++ b/src/browser/components/AgentModePicker.test.tsx @@ -283,4 +283,87 @@ describe("AgentModePicker", () => { expect(getByTestId("agentId").textContent).toBe("exec"); }); }); + + test("keeps trigger border and icon colors in sync", () => { + const customColor = "rgb(12, 34, 56)"; + + function Harness() { + const [agentId, setAgentId] = React.useState("exec"); + return ( + Promise.resolve(), + refreshing: false, + ...defaultContextProps, + }} + > + + + + + ); + } + + const { getByLabelText } = render(); + const triggerButton = getByLabelText("Select agent"); + const triggerIcon = triggerButton.querySelector("svg"); + + expect(triggerIcon).toBeTruthy(); + expect(triggerButton.style.borderColor).toBe(customColor); + expect(triggerIcon?.style.color).toBe(customColor); + }); + + test("uses built-in accent colors before agent metadata loads", () => { + const expectedAccents: ReadonlyArray<[string, string]> = [ + ["ask", "var(--color-ask-mode)"], + ["plan", "var(--color-plan-mode)"], + ["exec", "var(--color-exec-mode)"], + ["orchestrator", "var(--color-exec-mode)"], + ["auto", "var(--color-auto-mode)"], + ]; + + for (const [agentId, expectedAccent] of expectedAccents) { + function Harness() { + const [currentAgentId, setAgentId] = React.useState(agentId); + return ( + Promise.resolve(), + refreshing: false, + ...defaultContextProps, + }} + > + + + + + ); + } + + const { getByLabelText, unmount } = render(); + const triggerButton = getByLabelText("Select agent"); + const triggerIcon = triggerButton.querySelector("svg"); + + expect(triggerIcon).toBeTruthy(); + expect(triggerButton.style.borderColor).toBe(expectedAccent); + expect(triggerIcon?.style.color).toBe(expectedAccent); + + unmount(); + } + }); }); diff --git a/src/browser/components/AgentModePicker.tsx b/src/browser/components/AgentModePicker.tsx index ac94a0b1cd..287485ed5d 100644 --- a/src/browser/components/AgentModePicker.tsx +++ b/src/browser/components/AgentModePicker.tsx @@ -29,7 +29,7 @@ import { KEYBINDS, matchNumberedKeybind, } from "@/browser/utils/ui/keybinds"; -import { sortAgentsStable } from "@/browser/utils/agents"; +import { resolveAgentAccentColor, sortAgentsStable } from "@/browser/utils/agents"; import { stopKeyboardPropagation } from "@/browser/utils/events"; interface AgentModePickerProps { @@ -56,7 +56,7 @@ interface AgentOption { subagentRunnable: boolean; } -/** Maps well-known agent IDs to lucide icons for the dropdown */ +/** Maps well-known agent IDs to lucide icons for the expanded dropdown list. */ const AGENT_ICONS: Record = { ask: MessageCircleQuestionMark, plan: Route, @@ -338,13 +338,13 @@ export const AgentModePicker: React.FC = (props) => { } }; - // Resolve display properties for the trigger pill - const activeDisplayName = activeOption?.name ?? formatAgentIdLabel(normalizedAgentId); - const activeStyle: React.CSSProperties | undefined = activeOption?.uiColor - ? { borderColor: activeOption.uiColor } - : undefined; - const activeClassName = activeOption?.uiColor ? "" : "border-exec-mode"; + // Resolve display properties for the trigger pill. const TriggerIcon = getAgentIcon(normalizedAgentId); + const activeDisplayName = activeOption?.name ?? formatAgentIdLabel(normalizedAgentId); + // Keep icon + border colors on the same source value so they can't desync while + // agent metadata is loading. + const activeAccentColor = resolveAgentAccentColor(normalizedAgentId, activeOption?.uiColor); + const activeStyle: React.CSSProperties = { borderColor: activeAccentColor }; return (
@@ -366,13 +366,12 @@ export const AgentModePicker: React.FC = (props) => { }} style={activeStyle} className={cn( - "text-foreground hover:bg-hover flex items-center gap-1.5 rounded-sm border-[0.5px] px-1.5 py-0.5 text-[11px] font-medium transition-[background-color] duration-150", - activeClassName + "text-foreground hover:bg-hover flex items-center gap-1.5 rounded-sm border-[0.5px] px-1.5 py-0.5 text-[11px] font-medium transition-colors duration-150" )} > {activeDisplayName} = (props) => { const autoAvailable = agents.some((entry) => entry.uiSelectable && entry.id === "auto"); const isAutoAgent = normalizedAgentId === "auto" && autoAvailable; - // Use current agent's uiColor, or neutral border until agents load - const focusBorderColor = currentAgent?.uiColor ?? "var(--color-border-light)"; + // Resolve border accent from discovered metadata, with built-in fallback while + // agent descriptors are still loading during workspace switches. + const focusBorderColor = resolveAgentAccentColor(agentId, currentAgent?.uiColor); const { models, hiddenModelsForSelector, diff --git a/src/browser/contexts/AgentContext.test.tsx b/src/browser/contexts/AgentContext.test.tsx index f1e771abc9..8f71348f52 100644 --- a/src/browser/contexts/AgentContext.test.tsx +++ b/src/browser/contexts/AgentContext.test.tsx @@ -3,11 +3,55 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { cleanup, render, waitFor } from "@testing-library/react"; import { GlobalWindow } from "happy-dom"; import { GLOBAL_SCOPE_ID, getAgentIdKey, getProjectScopeId } from "@/common/constants/storage"; +import type { AgentDefinitionDescriptor } from "@/common/types/agentDefinition"; + +interface AgentsListInput { + projectPath?: string; + workspaceId?: string; + disableWorkspaceAgents?: boolean; +} + +interface Deferred { + promise: Promise; + resolve: (value: T) => void; + reject: (error: unknown) => void; +} + +function createDeferred(): Deferred { + let resolve: ((value: T) => void) | undefined; + let reject: ((error: unknown) => void) | undefined; + + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + + if (!resolve || !reject) { + throw new Error("failed to create deferred promise"); + } + + return { promise, resolve, reject }; +} + +function createAgent(id: string, name: string): AgentDefinitionDescriptor { + return { + id, + scope: "built-in", + name, + uiSelectable: true, + subagentRunnable: false, + }; +} + +const listAgentsMock = mock((_input: AgentsListInput) => + Promise.resolve([]) +); +let currentApiMock: { agents: { list: typeof listAgentsMock } } | null = null; void mock.module("@/browser/contexts/API", () => ({ useAPI: () => ({ - api: null, - status: "connecting" as const, + api: currentApiMock, + status: currentApiMock ? ("connected" as const) : ("connecting" as const), error: null, authenticate: () => undefined, retry: () => undefined, @@ -44,6 +88,12 @@ describe("AgentContext", () => { globalThis.window = dom as unknown as Window & typeof globalThis; globalThis.document = dom.document as unknown as Document; globalThis.localStorage = dom.localStorage as unknown as Storage; + + listAgentsMock.mockReset(); + listAgentsMock.mockImplementation((_input: AgentsListInput) => + Promise.resolve([]) + ); + currentApiMock = null; }); afterEach(() => { @@ -51,6 +101,7 @@ describe("AgentContext", () => { globalThis.window = originalWindow; globalThis.document = originalDocument; globalThis.localStorage = originalLocalStorage; + currentApiMock = null; }); test("project-scoped agent falls back to global default when project preference is unset", async () => { @@ -90,4 +141,447 @@ describe("AgentContext", () => { expect(contextValue?.agentId).toBe("plan"); }); }); + + test("uses workspace cache when revisiting a workspace while refetch is pending", async () => { + const projectPath = "/tmp/project-cache"; + const latestByWorkspace = new Map([ + ["ws-a", [createAgent("exec", "Exec")]], + ["ws-b", [createAgent("plan", "Plan")]], + ]); + const pendingByWorkspace = new Map>(); + + listAgentsMock.mockImplementation((input: AgentsListInput) => { + const workspaceId = input.workspaceId; + if (!workspaceId) { + return Promise.resolve([]); + } + + const pending = pendingByWorkspace.get(workspaceId); + if (pending) { + return pending.promise; + } + + return Promise.resolve(latestByWorkspace.get(workspaceId) ?? []); + }); + + currentApiMock = { + agents: { + list: listAgentsMock, + }, + }; + + let contextValue: AgentContextValue | undefined; + const { rerender } = render( + + (contextValue = value)} /> + + ); + + await waitFor(() => { + expect(contextValue?.loaded).toBe(true); + expect(contextValue?.agents.map((agent) => agent.id)).toEqual(["exec"]); + }); + + rerender( + + (contextValue = value)} /> + + ); + + await waitFor(() => { + expect(contextValue?.loaded).toBe(true); + expect(contextValue?.agents.map((agent) => agent.id)).toEqual(["plan"]); + }); + + latestByWorkspace.set("ws-a", [createAgent("ask", "Ask")]); + const wsADeferred = createDeferred(); + pendingByWorkspace.set("ws-a", wsADeferred); + + rerender( + + (contextValue = value)} /> + + ); + + await waitFor(() => { + expect(listAgentsMock.mock.calls.length).toBeGreaterThanOrEqual(3); + expect(contextValue?.loaded).toBe(true); + expect(contextValue?.agents.map((agent) => agent.id)).toEqual(["exec"]); + }); + + pendingByWorkspace.delete("ws-a"); + wsADeferred.resolve(latestByWorkspace.get("ws-a") ?? []); + + await waitFor(() => { + expect(contextValue?.agents.map((agent) => agent.id)).toEqual(["ask"]); + }); + }); + + test("uses project cache for first render in a sibling workspace", async () => { + const projectPath = "/tmp/project-fallback"; + const latestByWorkspace = new Map([ + ["ws-a", [createAgent("exec", "Exec")]], + ["ws-b", [createAgent("plan", "Plan")]], + ]); + const pendingByWorkspace = new Map>(); + + listAgentsMock.mockImplementation((input: AgentsListInput) => { + const workspaceId = input.workspaceId; + if (!workspaceId) { + return Promise.resolve([]); + } + + const pending = pendingByWorkspace.get(workspaceId); + if (pending) { + return pending.promise; + } + + return Promise.resolve(latestByWorkspace.get(workspaceId) ?? []); + }); + + currentApiMock = { + agents: { + list: listAgentsMock, + }, + }; + + let contextValue: AgentContextValue | undefined; + const { rerender } = render( + + (contextValue = value)} /> + + ); + + await waitFor(() => { + expect(contextValue?.loaded).toBe(true); + expect(contextValue?.agents.map((agent) => agent.id)).toEqual(["exec"]); + }); + + const wsBDeferred = createDeferred(); + pendingByWorkspace.set("ws-b", wsBDeferred); + + rerender( + + (contextValue = value)} /> + + ); + + await waitFor(() => { + expect( + listAgentsMock.mock.calls.some( + ([input]: [AgentsListInput, ...unknown[]]) => input.workspaceId === "ws-b" + ) + ).toBe(true); + expect(contextValue?.loaded).toBe(true); + expect(contextValue?.agents.map((agent) => agent.id)).toEqual(["exec"]); + expect(contextValue?.agents.every((agent) => !agent.uiSelectable)).toBe(true); + }); + + pendingByWorkspace.delete("ws-b"); + wsBDeferred.resolve(latestByWorkspace.get("ws-b") ?? []); + + await waitFor(() => { + expect(contextValue?.agents.map((agent) => agent.id)).toEqual(["plan"]); + expect(contextValue?.agents.every((agent) => agent.uiSelectable)).toBe(true); + }); + }); + + test("does not hydrate project scope from workspace-sourced project cache", async () => { + const projectPath = "/tmp/project-scope-cache"; + const projectScopeKey = "__project__"; + const pendingByScope = new Map>(); + + listAgentsMock.mockImplementation((input: AgentsListInput) => { + const scopeKey = input.workspaceId ?? projectScopeKey; + const pending = pendingByScope.get(scopeKey); + if (pending) { + return pending.promise; + } + + if (scopeKey === "ws-a") { + return Promise.resolve([createAgent("exec", "Exec")]); + } + + return Promise.resolve([createAgent("ask", "Ask")]); + }); + + currentApiMock = { + agents: { + list: listAgentsMock, + }, + }; + + let contextValue: AgentContextValue | undefined; + const { rerender } = render( + + (contextValue = value)} /> + + ); + + await waitFor(() => { + expect(contextValue?.loaded).toBe(true); + expect(contextValue?.agents.map((agent) => agent.id)).toEqual(["exec"]); + }); + + const projectDeferred = createDeferred(); + pendingByScope.set(projectScopeKey, projectDeferred); + + rerender( + + (contextValue = value)} /> + + ); + + await waitFor(() => { + expect( + listAgentsMock.mock.calls.some( + ([input]: [AgentsListInput, ...unknown[]]) => + input.projectPath === projectPath && input.workspaceId === undefined + ) + ).toBe(true); + expect(contextValue?.loaded).toBe(false); + expect(contextValue?.agents.length).toBe(0); + }); + + pendingByScope.delete(projectScopeKey); + projectDeferred.resolve([createAgent("ask", "Ask")]); + + await waitFor(() => { + expect(contextValue?.loaded).toBe(true); + expect(contextValue?.agents.map((agent) => agent.id)).toEqual(["ask"]); + }); + }); + + test("keeps project-sourced fallback selectable in workspace scope", async () => { + const projectPath = "/tmp/project-fallback-selectable"; + const projectScopeKey = "__project__"; + const pendingByScope = new Map>(); + + listAgentsMock.mockImplementation((input: AgentsListInput) => { + const scopeKey = input.workspaceId ?? projectScopeKey; + const pending = pendingByScope.get(scopeKey); + if (pending) { + return pending.promise; + } + + if (scopeKey === projectScopeKey) { + return Promise.resolve([createAgent("exec", "Exec")]); + } + + return Promise.resolve([createAgent("plan", "Plan")]); + }); + + currentApiMock = { + agents: { + list: listAgentsMock, + }, + }; + + let contextValue: AgentContextValue | undefined; + const { rerender } = render( + + (contextValue = value)} /> + + ); + + await waitFor(() => { + expect(contextValue?.loaded).toBe(true); + expect(contextValue?.agents.map((agent) => agent.id)).toEqual(["exec"]); + expect(contextValue?.agents.every((agent) => agent.uiSelectable)).toBe(true); + }); + + const workspaceDeferred = createDeferred(); + pendingByScope.set("ws-a", workspaceDeferred); + + rerender( + + (contextValue = value)} /> + + ); + + await waitFor(() => { + expect( + listAgentsMock.mock.calls.some( + ([input]: [AgentsListInput, ...unknown[]]) => + input.projectPath === projectPath && input.workspaceId === "ws-a" + ) + ).toBe(true); + expect(contextValue?.loaded).toBe(true); + expect(contextValue?.agents.map((agent) => agent.id)).toEqual(["exec"]); + expect(contextValue?.agents.every((agent) => agent.uiSelectable)).toBe(true); + }); + + pendingByScope.delete("ws-a"); + workspaceDeferred.resolve([createAgent("plan", "Plan")]); + + await waitFor(() => { + expect(contextValue?.loaded).toBe(true); + expect(contextValue?.agents.map((agent) => agent.id)).toEqual(["plan"]); + expect(contextValue?.agents.every((agent) => agent.uiSelectable)).toBe(true); + }); + }); + + test("does not reuse workspace cache across projects sharing a workspace id", async () => { + const workspaceId = "ws-shared"; + const projectOne = "/tmp/project-one"; + const projectTwo = "/tmp/project-two"; + const latestByScope = new Map([ + [`${projectOne}|${workspaceId}`, [createAgent("exec", "Exec")]], + [`${projectTwo}|${workspaceId}`, [createAgent("plan", "Plan")]], + ]); + const pendingByScope = new Map>(); + + listAgentsMock.mockImplementation((input: AgentsListInput) => { + if (!input.workspaceId) { + return Promise.resolve([]); + } + + const cacheScopeKey = `${input.projectPath ?? ""}|${input.workspaceId}`; + const pending = pendingByScope.get(cacheScopeKey); + if (pending) { + return pending.promise; + } + + return Promise.resolve(latestByScope.get(cacheScopeKey) ?? []); + }); + + currentApiMock = { + agents: { + list: listAgentsMock, + }, + }; + + let contextValue: AgentContextValue | undefined; + const { rerender } = render( + + (contextValue = value)} /> + + ); + + await waitFor(() => { + expect(contextValue?.loaded).toBe(true); + expect(contextValue?.agents.map((agent) => agent.id)).toEqual(["exec"]); + }); + + const projectTwoScopeKey = `${projectTwo}|${workspaceId}`; + const projectTwoDeferred = createDeferred(); + pendingByScope.set(projectTwoScopeKey, projectTwoDeferred); + + rerender( + + (contextValue = value)} /> + + ); + + await waitFor(() => { + expect( + listAgentsMock.mock.calls.some( + ([input]: [AgentsListInput, ...unknown[]]) => + input.projectPath === projectTwo && input.workspaceId === workspaceId + ) + ).toBe(true); + expect(contextValue?.loaded).toBe(false); + expect(contextValue?.agents.length).toBe(0); + }); + + pendingByScope.delete(projectTwoScopeKey); + projectTwoDeferred.resolve(latestByScope.get(projectTwoScopeKey) ?? []); + + await waitFor(() => { + expect(contextValue?.loaded).toBe(true); + expect(contextValue?.agents.map((agent) => agent.id)).toEqual(["plan"]); + }); + }); + + test("cycle-agent keybind skips auto and rotates only manual agents", async () => { + const projectPath = "/tmp/project-cycle"; + + listAgentsMock.mockImplementation((input: AgentsListInput) => { + if (input.workspaceId !== "ws-cycle") { + return Promise.resolve([]); + } + + return Promise.resolve([ + createAgent("exec", "Exec"), + createAgent("plan", "Plan"), + createAgent("auto", "Auto"), + ]); + }); + + currentApiMock = { + agents: { + list: listAgentsMock, + }, + }; + + window.localStorage.setItem(getAgentIdKey("ws-cycle"), JSON.stringify("exec")); + + let contextValue: AgentContextValue | undefined; + + render( + + (contextValue = value)} /> + + ); + + await waitFor(() => { + expect(contextValue?.loaded).toBe(true); + expect(contextValue?.agentId).toBe("exec"); + }); + + window.dispatchEvent(new window.KeyboardEvent("keydown", { key: ".", ctrlKey: true })); + + await waitFor(() => { + expect(contextValue?.agentId).toBe("plan"); + }); + + window.dispatchEvent(new window.KeyboardEvent("keydown", { key: ".", ctrlKey: true })); + + await waitFor(() => { + expect(contextValue?.agentId).toBe("exec"); + expect(contextValue?.agentId).not.toBe("auto"); + }); + }); + + test("cycle-agent keybind exits auto when only one manual agent is available", async () => { + const projectPath = "/tmp/project-cycle-single"; + + listAgentsMock.mockImplementation((input: AgentsListInput) => { + if (input.workspaceId !== "ws-cycle-single") { + return Promise.resolve([]); + } + + return Promise.resolve([ + createAgent("exec", "Exec"), + createAgent("auto", "Auto"), + ]); + }); + + currentApiMock = { + agents: { + list: listAgentsMock, + }, + }; + + window.localStorage.setItem(getAgentIdKey("ws-cycle-single"), JSON.stringify("auto")); + + let contextValue: AgentContextValue | undefined; + + render( + + (contextValue = value)} /> + + ); + + await waitFor(() => { + expect(contextValue?.loaded).toBe(true); + expect(contextValue?.agentId).toBe("auto"); + }); + + window.dispatchEvent(new window.KeyboardEvent("keydown", { key: ".", ctrlKey: true })); + + await waitFor(() => { + expect(contextValue?.agentId).toBe("exec"); + }); + }); }); diff --git a/src/browser/contexts/AgentContext.tsx b/src/browser/contexts/AgentContext.tsx index d261fc0a2f..e2e55df8a3 100644 --- a/src/browser/contexts/AgentContext.tsx +++ b/src/browser/contexts/AgentContext.tsx @@ -61,6 +61,38 @@ function coerceAgentId(value: unknown): string { : WORKSPACE_DEFAULTS.agentId; } +type AgentDiscoveryCacheMode = "enabled" | "disabled"; + +function getAgentDiscoveryCacheMode(disableWorkspaceAgents: boolean): AgentDiscoveryCacheMode { + return disableWorkspaceAgents ? "disabled" : "enabled"; +} + +function getWorkspaceDiscoveryCacheKey( + workspaceId: string | undefined, + projectPath: string | undefined, + mode: AgentDiscoveryCacheMode +): string | undefined { + if (!workspaceId) { + return undefined; + } + + return projectPath ? `${projectPath}:${workspaceId}:${mode}` : `${workspaceId}:${mode}`; +} + +function getProjectDiscoveryCacheKey( + projectPath: string | undefined, + mode: AgentDiscoveryCacheMode +): string | undefined { + return projectPath ? `${projectPath}:${mode}` : undefined; +} + +type ProjectDiscoveryCacheSource = "project" | "workspace"; + +interface ProjectDiscoveryCacheEntry { + agents: AgentDefinitionDescriptor[]; + source: ProjectDiscoveryCacheSource; +} + export function AgentProvider(props: AgentProviderProps) { if ("value" in props) { return {props.children}; @@ -128,6 +160,10 @@ function AgentProviderWithState(props: { const [loadFailed, setLoadFailed] = useState(false); const isMountedRef = useRef(true); + // Keep recently-discovered agents in memory so workspace switches can render + // immediately while the backend refresh completes. + const workspaceDiscoveryCacheRef = useRef(new Map()); + const projectDiscoveryCacheRef = useRef(new Map()); useEffect(() => { isMountedRef.current = true; @@ -150,6 +186,10 @@ function AgentProviderWithState(props: { workspaceId: string | undefined, workspaceAgentsDisabled: boolean ) => { + const cacheMode = getAgentDiscoveryCacheMode(workspaceAgentsDisabled); + const workspaceCacheKey = getWorkspaceDiscoveryCacheKey(workspaceId, projectPath, cacheMode); + const projectCacheKey = getProjectDiscoveryCacheKey(projectPath, cacheMode); + fetchParamsRef.current = { projectPath, workspaceId, @@ -178,6 +218,15 @@ function AgentProviderWithState(props: { current.disableWorkspaceAgents === workspaceAgentsDisabled && isMountedRef.current ) { + if (workspaceCacheKey) { + workspaceDiscoveryCacheRef.current.set(workspaceCacheKey, result); + } + if (projectCacheKey) { + projectDiscoveryCacheRef.current.set(projectCacheKey, { + agents: result, + source: workspaceId ? "workspace" : "project", + }); + } setAgents(result); setLoadFailed(false); setLoaded(true); @@ -200,8 +249,50 @@ function AgentProviderWithState(props: { ); useEffect(() => { - setAgents([]); - setLoaded(false); + const cacheMode = getAgentDiscoveryCacheMode(disableWorkspaceAgents); + const workspaceCacheKey = getWorkspaceDiscoveryCacheKey( + props.workspaceId, + props.projectPath, + cacheMode + ); + const projectCacheKey = getProjectDiscoveryCacheKey(props.projectPath, cacheMode); + const workspaceCachedAgents = workspaceCacheKey + ? workspaceDiscoveryCacheRef.current.get(workspaceCacheKey) + : undefined; + const projectCacheEntry = projectCacheKey + ? projectDiscoveryCacheRef.current.get(projectCacheKey) + : undefined; + // Avoid hydrating project-scoped providers from workspace-sourced cache + // entries; those can include workspace-only agent definitions. + const projectCachedAgents = + projectCacheEntry == null + ? undefined + : props.workspaceId == null && projectCacheEntry.source === "workspace" + ? undefined + : projectCacheEntry.agents; + const optimisticAgents = workspaceCachedAgents ?? projectCachedAgents; + + if (optimisticAgents !== undefined) { + const usingWorkspaceSourcedProjectFallbackForWorkspace = + workspaceCachedAgents === undefined && + projectCachedAgents !== undefined && + props.workspaceId !== undefined && + projectCacheEntry?.source === "workspace"; + // Workspace-sourced project fallback is display-only until workspace + // discovery resolves, so users can't persist cross-workspace agent IDs. + setAgents( + usingWorkspaceSourcedProjectFallbackForWorkspace + ? optimisticAgents.map((agent) => ({ + ...agent, + uiSelectable: false, + })) + : optimisticAgents + ); + setLoaded(true); + } else { + setAgents([]); + setLoaded(false); + } setLoadFailed(false); void fetchAgents(props.projectPath, props.workspaceId, disableWorkspaceAgents); }, [fetchAgents, props.projectPath, props.workspaceId, disableWorkspaceAgents]); @@ -220,34 +311,26 @@ function AgentProviderWithState(props: { } }, [fetchAgents, props.projectPath, props.workspaceId, disableWorkspaceAgents]); - const selectableAgents = useMemo( - () => sortAgentsStable(agents.filter((a) => a.uiSelectable)), + const cycleableAgents = useMemo( + // Keep keyboard cycling aligned with numbered quick-select behavior: auto is + // selectable via explicit toggle only, not via cycle-next shortcuts. + () => sortAgentsStable(agents.filter((agent) => agent.uiSelectable && agent.id !== "auto")), [agents] ); const cycleToNextAgent = useCallback(() => { - if (selectableAgents.length < 2) return; + if (cycleableAgents.length === 0) return; const activeAgentId = coerceAgentId( isProjectScope ? (scopedAgentId ?? globalDefaultAgentId) : scopedAgentId ); - - // Auto mode: ignore the cycle shortcut when auto is a live agent - // (stale persisted "auto" not in list → allow cycling to recover) - const autoAvailable = selectableAgents.some((a) => a.id === "auto"); - if (activeAgentId === "auto" && autoAvailable) return; - - // Never cycle into "auto" — it's toggled explicitly via the picker switch - const cyclableAgents = selectableAgents.filter((a) => a.id !== "auto"); - if (cyclableAgents.length < 2) return; - - const currentIndex = cyclableAgents.findIndex((a) => a.id === activeAgentId); - const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % cyclableAgents.length; - const nextAgent = cyclableAgents[nextIndex]; + const currentIndex = cycleableAgents.findIndex((agent) => agent.id === activeAgentId); + const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % cycleableAgents.length; + const nextAgent = cycleableAgents[nextIndex]; if (nextAgent) { setAgentId(nextAgent.id); } - }, [globalDefaultAgentId, isProjectScope, scopedAgentId, selectableAgents, setAgentId]); + }, [globalDefaultAgentId, isProjectScope, scopedAgentId, cycleableAgents, setAgentId]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { diff --git a/src/browser/utils/agents.ts b/src/browser/utils/agents.ts index e1d289137c..bc6f3e9e60 100644 --- a/src/browser/utils/agents.ts +++ b/src/browser/utils/agents.ts @@ -4,6 +4,14 @@ import type { AgentDefinitionDescriptor } from "@/common/types/agentDefinition"; // Only includes agents that are uiSelectable by default. const BUILTIN_AGENT_ORDER: readonly string[] = ["exec", "plan", "ask"]; +const BUILTIN_AGENT_ACCENTS: Readonly> = { + auto: "var(--color-auto-mode)", + ask: "var(--color-ask-mode)", + plan: "var(--color-plan-mode)", + exec: "var(--color-exec-mode)", + orchestrator: "var(--color-exec-mode)", +}; + /** * Sort agents with stable ordering: built-ins first (exec, plan), * then custom agents alphabetically by name. @@ -23,3 +31,14 @@ export function sortAgentsStable 0) { + return discoveredUiColor; + } + + const normalizedAgentId = + typeof agentId === "string" && agentId.trim().length > 0 ? agentId.trim().toLowerCase() : ""; + + return BUILTIN_AGENT_ACCENTS[normalizedAgentId] ?? "var(--color-border-light)"; +} From 8ef0f0a7e8e4cf1e0c5b915b19ce55bced823030 Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 25 Feb 2026 14:00:19 -0600 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=A4=96=20fix:=20keep=20picker=20in=20?= =?UTF-8?q?loading=20state=20for=20display=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/browser/contexts/AgentContext.test.tsx | 2 +- src/browser/contexts/AgentContext.tsx | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/browser/contexts/AgentContext.test.tsx b/src/browser/contexts/AgentContext.test.tsx index 8f71348f52..791208282f 100644 --- a/src/browser/contexts/AgentContext.test.tsx +++ b/src/browser/contexts/AgentContext.test.tsx @@ -272,7 +272,7 @@ describe("AgentContext", () => { ([input]: [AgentsListInput, ...unknown[]]) => input.workspaceId === "ws-b" ) ).toBe(true); - expect(contextValue?.loaded).toBe(true); + expect(contextValue?.loaded).toBe(false); expect(contextValue?.agents.map((agent) => agent.id)).toEqual(["exec"]); expect(contextValue?.agents.every((agent) => !agent.uiSelectable)).toBe(true); }); diff --git a/src/browser/contexts/AgentContext.tsx b/src/browser/contexts/AgentContext.tsx index e2e55df8a3..f1ec20b643 100644 --- a/src/browser/contexts/AgentContext.tsx +++ b/src/browser/contexts/AgentContext.tsx @@ -288,7 +288,8 @@ function AgentProviderWithState(props: { })) : optimisticAgents ); - setLoaded(true); + // Keep loading state while only display-only fallback data is available. + setLoaded(!usingWorkspaceSourcedProjectFallbackForWorkspace); } else { setAgents([]); setLoaded(false);