diff --git a/src/main/ipc/worktree-icon-generation.ts b/src/main/ipc/worktree-icon-generation.ts index 4a55360b706..74065cce1bc 100644 --- a/src/main/ipc/worktree-icon-generation.ts +++ b/src/main/ipc/worktree-icon-generation.ts @@ -3,7 +3,10 @@ import path from 'node:path' import { z } from 'zod' import type { Store } from '../persistence/loading-store/store' import type { CommitMessageAgentEnvironmentResolvers } from '../text-generation/commit-message-agent-environment' -import { generateWorktreeIconRequest } from '../text-generation/worktree-icon-requests' +import { + clearWorktreeIconCache, + generateWorktreeIconRequest +} from '../text-generation/worktree-icon-requests' import { addFanoutListener } from '../runtime/events/event-listener-fanout' const CancelWorktreeIcon = z.object({ requestId: z.string().min(1).max(128) }) @@ -47,4 +50,5 @@ export function registerWorktreeIconGenerationHandlers( ipcMain.handle('worktreeIcons:cancel', (event, args: unknown) => { requests.get(event.sender)?.get(CancelWorktreeIcon.parse(args).requestId)?.abort() }) + ipcMain.handle('worktreeIcons:clearCache', () => clearWorktreeIconCache(host)) } diff --git a/src/main/text-generation/worktree-icon-batch-generation.ts b/src/main/text-generation/worktree-icon-batch-generation.ts new file mode 100644 index 00000000000..d9204972a42 --- /dev/null +++ b/src/main/text-generation/worktree-icon-batch-generation.ts @@ -0,0 +1,164 @@ +import os from 'node:os' +import { LOCAL_COMMIT_MESSAGE_HOST_KEY } from '../../shared/source-control/ai/generation/commit-message-host-key' +import { renderSourceControlActionCommandTemplate } from '../../shared/source-control/ai/recipes/source-control-ai-actions' +import type { ResolvedSourceControlAiGenerationParams } from '../../shared/source-control/ai/source-control-ai-operation-resolution' +import { + buildWorktreeIconBatchPrompt, + extractWorktreeIconSvgs, + formatWorktreeIconBatchList, + type WorktreeIconGenerationResult +} from '../../shared/worktree/worktree-icon-generation' +import { runGenerationRequest } from './agent-generation-request' +import { + prepareLocalCommitMessageAgentEnv, + type CommitMessageAgentEnvironmentResolvers +} from './commit-message-agent-environment' +import { resolveTextGenerationParams } from './commit-message-text-generation' +import { spawnSourceControlAgent } from './source-control-agent-launch' +import { remember } from './worktree-icon-cache' + +export type WorktreeIconGenerationHost = { + getSettings: () => Parameters[0] + getAgentEnvironment: () => CommitMessageAgentEnvironmentResolvers | undefined + cacheFile: string +} + +export const canceledIconResult = (): WorktreeIconGenerationResult => ({ + success: false, + canceled: true, + error: 'Generation canceled.' +}) + +type BatchAttempt = { params: ResolvedSourceControlAiGenerationParams; ownTemplate: boolean } + +// The icon recipe runs first; the branch-name agent covers a broken icon recipe with the built-in prompt. +function resolveAttempts(host: WorktreeIconGenerationHost): BatchAttempt[] | string { + const settings = host.getSettings() + const icon = resolveTextGenerationParams(settings, LOCAL_COMMIT_MESSAGE_HOST_KEY, 'workspaceIcon') + const fallback = resolveTextGenerationParams( + settings, + LOCAL_COMMIT_MESSAGE_HOST_KEY, + 'branchName' + ) + const attempts: BatchAttempt[] = [] + if (icon.ok) { + attempts.push({ params: icon.params, ownTemplate: true }) + } + if ( + fallback.ok && + !( + icon.ok && + fallback.params.agentId === icon.params.agentId && + fallback.params.model === icon.params.model + ) + ) { + attempts.push({ params: fallback.params, ownTemplate: false }) + } + return attempts.length > 0 ? attempts : icon.ok ? 'No icon agent is available.' : icon.error +} + +type AttemptOutcome = { svgs: (string | null)[]; error?: string } | { canceled: true } + +async function runAttempt( + host: WorktreeIconGenerationHost, + attempt: BatchAttempt, + names: string[], + signal: AbortSignal +): Promise { + const { params } = attempt + const localEnv = await prepareLocalCommitMessageAgentEnv( + params.agentId, + host.getAgentEnvironment() + ) + if (signal.aborted) { + return { canceled: true } + } + if (!localEnv.ok) { + return { svgs: names.map(() => null), error: localEnv.error } + } + const basePrompt = buildWorktreeIconBatchPrompt(names) + const result = await runGenerationRequest({ + params, + prompt: + attempt.ownTemplate && params.commandInputTemplate !== undefined + ? renderSourceControlActionCommandTemplate(params.commandInputTemplate, { + basePrompt, + workspaces: formatWorktreeIconBatchList(names) + }) + : basePrompt, + target: { kind: 'local', cwd: os.homedir(), ...(localEnv.env ? { env: localEnv.env } : {}) }, + spawnAgent: spawnSourceControlAgent, + operation: 'worktree-icon', + shouldExecute: () => !signal.aborted + }) + if (signal.aborted || (!result.success && result.canceled)) { + return { canceled: true } + } + if (!result.success) { + return { svgs: names.map(() => null), error: result.error } + } + const svgs = extractWorktreeIconSvgs(result.rawOutput, names.length) + const missing = svgs.filter((svg) => svg === null).length + return missing === 0 + ? { svgs } + : { + svgs, + error: `${result.agentLabel ?? 'The agent'} did not return a usable SVG icon for ${missing} of ${names.length} workspaces.` + } +} + +export async function generateWorktreeIconBatch( + host: WorktreeIconGenerationHost, + names: string[], + signal: AbortSignal +): Promise { + const attempts = resolveAttempts(host) + if (typeof attempts === 'string') { + return names.map(() => ({ success: false, error: attempts })) + } + const svgs: (string | null)[] = names.map(() => null) + const errors: string[] = [] + for (const attempt of attempts) { + const pending = names.flatMap((name, index) => (svgs[index] === null ? [{ name, index }] : [])) + if (pending.length === 0) { + break + } + const outcome = await runAttempt( + host, + attempt, + pending.map((entry) => entry.name), + signal + ) + if ('canceled' in outcome) { + break + } + outcome.svgs.forEach((svg, position) => { + svgs[pending[position].index] = svg + }) + if (outcome.error) { + errors.push(outcome.error) + } + } + const generated = new Map( + names.flatMap((name, index) => { + const svg = svgs[index] + return svg ? [[name, svg] as const] : [] + }) + ) + // Finished icons are kept even when every card stopped waiting: they would be requested again. + if (generated.size > 0) { + remember(host.cacheFile, generated) + } + if (signal.aborted) { + return names.map((name) => { + const svg = generated.get(name) + return svg ? { success: true, svg } : canceledIconResult() + }) + } + return names.map((name) => { + const svg = generated.get(name) + return svg + ? { success: true, svg } + : { success: false, error: errors.join('\n') || 'The agent did not return an SVG icon.' } + }) +} diff --git a/src/main/text-generation/worktree-icon-cache.ts b/src/main/text-generation/worktree-icon-cache.ts new file mode 100644 index 00000000000..42d1d69280b --- /dev/null +++ b/src/main/text-generation/worktree-icon-cache.ts @@ -0,0 +1,56 @@ +import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import path from 'node:path' + +const MAX_CACHED_ICONS = 500 + +let cache: Map | null = null + +export function loadCache(file: string): Map { + if (cache) { + return cache + } + try { + const parsed: unknown = JSON.parse(readFileSync(file, 'utf8')) + cache = new Map( + parsed && typeof parsed === 'object' + ? Object.entries(parsed).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string' + ) + : [] + ) + } catch { + cache = new Map() + } + return cache +} + +export function remember(file: string, icons: ReadonlyMap): void { + const cached = loadCache(file) + for (const [name, svg] of icons) { + cached.delete(name) + cached.set(name, svg) + } + while (cached.size > MAX_CACHED_ICONS) { + const oldest = cached.keys().next().value + if (oldest === undefined) { + break + } + cached.delete(oldest) + } + try { + mkdirSync(path.dirname(file), { recursive: true }) + writeFileSync(file, JSON.stringify(Object.fromEntries(cached))) + } catch { + // The in-memory copy still serves this session. + } +} + +/** Drops every cached icon so the next render regenerates with the current recipe. */ +export function clearWorktreeIconCache(cacheFile: string): void { + cache = new Map() + try { + rmSync(cacheFile, { force: true }) + } catch { + // An unreadable file is ignored on load; the empty in-memory cache already wins. + } +} diff --git a/src/main/text-generation/worktree-icon-requests.test.ts b/src/main/text-generation/worktree-icon-requests.test.ts index 5e8a970a925..88d55e97b37 100644 --- a/src/main/text-generation/worktree-icon-requests.test.ts +++ b/src/main/text-generation/worktree-icon-requests.test.ts @@ -12,7 +12,7 @@ vi.mock('./agent-generation-request', () => ({ runGenerationRequest: mocks.gener vi.mock('./source-control-generation-lanes', () => ({ cancelLocalGeneration: mocks.cancel })) vi.mock('./source-control-agent-launch', () => ({ spawnSourceControlAgent: vi.fn() })) vi.mock('./commit-message-text-generation', () => ({ - resolveTextGenerationParams: () => ({ ok: false }) + resolveTextGenerationParams: () => ({ ok: true, params: { agentId: 'claude', model: 'haiku' } }) })) let directory: string @@ -85,3 +85,21 @@ it('keeps shared generation alive for another consumer and allows reacquiring a preparation.resolve({ ok: true }) expect(await renewed).toMatchObject({ success: true }) }) + +it('draws icons requested together in one agent run, matched by icon number', async () => { + const request = await setup() + mocks.generate.mockResolvedValue({ + success: true, + rawOutput: [ + '', + '' + ].join('\n') + }) + const [alpha, beta] = await Promise.all([ + request('alpha', new AbortController()), + request('beta', new AbortController()) + ]) + expect(mocks.generate).toHaveBeenCalledTimes(1) + expect(alpha).toMatchObject({ success: true, svg: expect.stringContaining('M1 1h1') }) + expect(beta).toMatchObject({ success: true, svg: expect.stringContaining('M2 2h2') }) +}) diff --git a/src/main/text-generation/worktree-icon-requests.ts b/src/main/text-generation/worktree-icon-requests.ts index 90173a56695..b50a2a4fe34 100644 --- a/src/main/text-generation/worktree-icon-requests.ts +++ b/src/main/text-generation/worktree-icon-requests.ts @@ -1,165 +1,38 @@ -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' import os from 'node:os' -import path from 'node:path' -import { LOCAL_COMMIT_MESSAGE_HOST_KEY } from '../../shared/source-control/ai/generation/commit-message-host-key' -import type { ResolvedSourceControlAiGenerationParams } from '../../shared/source-control/ai/source-control-ai-operation-resolution' import { - buildWorktreeIconPrompt, - extractWorktreeIconSvg, + MAX_WORKTREE_ICON_BATCH, normalizeWorktreeIconName, type WorktreeIconGenerationResult } from '../../shared/worktree/worktree-icon-generation' -import { runGenerationRequest } from './agent-generation-request' -import { - prepareLocalCommitMessageAgentEnv, - type CommitMessageAgentEnvironmentResolvers -} from './commit-message-agent-environment' -import { resolveTextGenerationParams } from './commit-message-text-generation' -import { spawnSourceControlAgent } from './source-control-agent-launch' import { cancelLocalGeneration } from './source-control-generation-lanes' +import { + canceledIconResult as canceledResult, + generateWorktreeIconBatch, + type WorktreeIconGenerationHost +} from './worktree-icon-batch-generation' +import { clearWorktreeIconCache as clearCacheFile, loadCache } from './worktree-icon-cache' -export type WorktreeIconGenerationHost = { - getSettings: () => Parameters[0] - getAgentEnvironment: () => CommitMessageAgentEnvironmentResolvers | undefined - cacheFile: string -} +export type { WorktreeIconGenerationHost } -const MAX_CACHED_ICONS = 500 +// Cards mounting in one render send their requests within a few ms; wait briefly so they share a batch. +const BATCH_COLLECT_MS = 100 -let cache: Map | null = null type IconRequest = { host: WorktreeIconGenerationHost name: string - controller: AbortController consumers: number settled: boolean result: ReturnType> } +type IconBatch = { members: IconRequest[]; controller: AbortController } const inFlight = new Map() const queued = new Set() -let active: IconRequest | undefined -const canceledResult = (): WorktreeIconGenerationResult => ({ - success: false, - canceled: true, - error: 'Generation canceled.' -}) +let active: IconBatch | undefined +let collectTimer: ReturnType | undefined -function loadCache(file: string): Map { - if (cache) { - return cache - } - try { - const parsed: unknown = JSON.parse(readFileSync(file, 'utf8')) - cache = new Map( - parsed && typeof parsed === 'object' - ? Object.entries(parsed).filter( - (entry): entry is [string, string] => typeof entry[1] === 'string' - ) - : [] - ) - } catch { - cache = new Map() - } - return cache -} - -function remember(file: string, name: string, svg: string): void { - const icons = loadCache(file) - icons.delete(name) - icons.set(name, svg) - while (icons.size > MAX_CACHED_ICONS) { - const oldest = icons.keys().next().value - if (oldest === undefined) { - break - } - icons.delete(oldest) - } - try { - mkdirSync(path.dirname(file), { recursive: true }) - writeFileSync(file, JSON.stringify(Object.fromEntries(icons))) - } catch { - // The in-memory copy still serves this session. - } -} - -// Icons want a fast model that draws well; the user's naming agent is the fallback. -const PREFERRED_ICON_PARAMS: ResolvedSourceControlAiGenerationParams = { - agentId: 'codex', - model: 'gpt-6.1-terra', - thinkingLevel: 'low' -} - -async function generateWith( - host: WorktreeIconGenerationHost, - params: ResolvedSourceControlAiGenerationParams, - name: string, - signal: AbortSignal -): Promise { - if (signal.aborted) { - return canceledResult() - } - const localEnv = await prepareLocalCommitMessageAgentEnv( - params.agentId, - host.getAgentEnvironment() - ) - if (signal.aborted) { - return canceledResult() - } - if (!localEnv.ok) { - return { success: false, error: localEnv.error } - } - const result = await runGenerationRequest({ - params, - prompt: buildWorktreeIconPrompt(name), - target: { kind: 'local', cwd: os.homedir(), ...(localEnv.env ? { env: localEnv.env } : {}) }, - spawnAgent: spawnSourceControlAgent, - operation: 'worktree-icon', - shouldExecute: () => !signal.aborted - }) - if (!result.success) { - return { success: false, error: result.error, canceled: result.canceled } - } - const svg = extractWorktreeIconSvg(result.rawOutput) - return svg - ? { success: true, svg } - : { success: false, error: `${result.agentLabel ?? 'The agent'} did not return an SVG icon.` } -} - -async function generate( - host: WorktreeIconGenerationHost, - name: string, - signal: AbortSignal -): Promise { - const preferred = await generateWith(host, PREFERRED_ICON_PARAMS, name, signal) - if (signal.aborted) { - return canceledResult() - } - let result = preferred - if (!preferred.success && !preferred.canceled) { - const configured = resolveTextGenerationParams( - host.getSettings(), - LOCAL_COMMIT_MESSAGE_HOST_KEY, - 'branchName', - null - ) - if ( - configured.ok && - (configured.params.agentId !== PREFERRED_ICON_PARAMS.agentId || - configured.params.model !== PREFERRED_ICON_PARAMS.model) - ) { - const fallback = await generateWith(host, configured.params, name, signal) - result = fallback.success - ? fallback - : { ...fallback, error: `${preferred.error}\n${fallback.error}` } - } - } - if (signal.aborted) { - return canceledResult() - } - if (result.success) { - remember(host.cacheFile, name, result.svg) - } - return result +/** Drops every cached icon so the next render regenerates with the current recipe. */ +export function clearWorktreeIconCache(host: WorktreeIconGenerationHost): void { + clearCacheFile(host.cacheFile) } function forget(request: IconRequest): void { @@ -168,32 +41,92 @@ function forget(request: IconRequest): void { } } -// One agent at a time; canceled waiting cards are removed before they can acquire a process lane. -function drainQueue(): void { - if (active) { +function settle(request: IconRequest, result: WorktreeIconGenerationResult): void { + if (request.settled) { return } - const request = queued.values().next().value - if (!request) { - return - } - queued.delete(request) - active = request - void generate(request.host, request.name, request.controller.signal) - .catch((error: unknown): WorktreeIconGenerationResult => ({ - success: false, - error: error instanceof Error ? error.message : String(error) - })) - .then((result) => { - request.settled = true - forget(request) - request.result.resolve(result) - active = undefined - drainQueue() - }) + request.settled = true + forget(request) + request.result.resolve(result) } -/** The cached icon for this name, or a queued generation that caches its result. */ +function takeBatch(): IconRequest[] { + const first = queued.values().next().value + if (!first) { + return [] + } + const members: IconRequest[] = [] + for (const request of queued) { + if (members.length >= MAX_WORKTREE_ICON_BATCH) { + break + } + if (request.host !== first.host) { + continue + } + queued.delete(request) + // A name an earlier batch finished after its cards left needs no second agent run. + const cached = loadCache(request.host.cacheFile).get(request.name) + if (cached) { + settle(request, { success: true, svg: cached }) + continue + } + members.push(request) + } + return members +} + +// One agent at a time, many names per agent: the CLI's fixed prompt overhead dominates icon cost. +function drainQueue(): void { + if (active || collectTimer) { + return + } + collectTimer = setTimeout(() => { + collectTimer = undefined + if (active) { + return + } + const members = takeBatch() + if (members.length === 0) { + if (queued.size > 0) { + drainQueue() + } + return + } + const batch: IconBatch = { members, controller: new AbortController() } + active = batch + void generateWorktreeIconBatch( + members[0].host, + members.map((member) => member.name), + batch.controller.signal + ) + .catch((error: unknown): WorktreeIconGenerationResult[] => + members.map(() => ({ + success: false, + error: error instanceof Error ? error.message : String(error) + })) + ) + .then((results) => { + members.forEach((member, index) => settle(member, results[index])) + active = undefined + drainQueue() + }) + }, BATCH_COLLECT_MS) +} + +function abandon(request: IconRequest): void { + forget(request) + if (queued.delete(request)) { + settle(request, canceledResult()) + return + } + const batch = active + if (batch?.members.includes(request) && batch.members.every((member) => !member.consumers)) { + batch.controller.abort() + cancelLocalGeneration('worktree-icon', os.homedir()) + } +} + +/** The cached icon for this name, or a batched generation that caches its result. */ export function generateWorktreeIconRequest( host: WorktreeIconGenerationHost, rawName: string, @@ -215,7 +148,6 @@ export function generateWorktreeIconRequest( request = { host, name, - controller: new AbortController(), consumers: 0, settled: false, result: Promise.withResolvers() @@ -235,15 +167,7 @@ export function generateWorktreeIconRequest( signal.removeEventListener('abort', cancel) owned.consumers-- if (!owned.consumers && !owned.settled) { - owned.controller.abort() - queued.delete(owned) - forget(owned) - if (active === owned) { - cancelLocalGeneration('worktree-icon', os.homedir()) - } else { - owned.settled = true - owned.result.resolve(canceledResult()) - } + abandon(owned) } resolve(result) } diff --git a/src/preload/api/worktree-icons-bridge.ts b/src/preload/api/worktree-icons-bridge.ts index a5d2f6b9bef..4b7e7542960 100644 --- a/src/preload/api/worktree-icons-bridge.ts +++ b/src/preload/api/worktree-icons-bridge.ts @@ -8,9 +8,11 @@ import type { export type WorktreeIconsApi = { generate: (args: WorktreeIconGenerationRequest) => Promise cancel: (args: WorktreeIconGenerationCancellation) => Promise + clearCache: () => Promise } export const worktreeIconsApi: WorktreeIconsApi = { generate: (args) => ipcRenderer.invoke('worktreeIcons:generate', args), - cancel: (args) => ipcRenderer.invoke('worktreeIcons:cancel', args) + cancel: (args) => ipcRenderer.invoke('worktreeIcons:cancel', args), + clearCache: () => ipcRenderer.invoke('worktreeIcons:clearCache') } diff --git a/src/renderer/src/app-shell/use-visible-hosted-review-refresh.ts b/src/renderer/src/app-shell/use-visible-hosted-review-refresh.ts index f8d3b84ca7a..89bfd885592 100644 --- a/src/renderer/src/app-shell/use-visible-hosted-review-refresh.ts +++ b/src/renderer/src/app-shell/use-visible-hosted-review-refresh.ts @@ -4,7 +4,7 @@ import { useAppStore } from '@/store/index' import { isWindowVisible } from '@/lib/window-activity/window-visibility-interval' import { isWebClientLocation } from '@/app-shell/platform/web-client-location' import { createVisibleHostedReviewRefreshScheduler } from '@/store/slices/hosted-review/refresh/visible-hosted-review-refresh-scheduler' -import { getWorkspaceReferenceRefreshTargets } from '@/components/sidebar/workspace-card/review/workspace-reference-refresh-targets' +import { createWorkspaceReferenceRefreshTargetsSelector } from '@/components/sidebar/workspace-card/review/workspace-reference-refresh-targets' import { getVisibleHostedReviewRefreshTargets, visibleHostedReviewRefreshInputsChanged @@ -16,28 +16,36 @@ export function useVisibleHostedReviewRefresh({ enabled }: { enabled: boolean }) return } const scheduler = createVisibleHostedReviewRefreshScheduler() - const update = (): void => { + const selectedOnly = isWebClientLocation() + const selectReferences = createWorkspaceReferenceRefreshTargetsSelector({ selectedOnly }) + let hostedTargets: ReturnType = [] + let referenceTargets: ReturnType = [] + const update = (reviewInputsChanged: boolean): void => { const state = useAppStore.getState() - const selectedOnly = isWebClientLocation() + const references = selectReferences(state, reviewInputsChanged) + if (!reviewInputsChanged && references === referenceTargets) { + return + } + referenceTargets = references + if (reviewInputsChanged) { + hostedTargets = getVisibleHostedReviewRefreshTargets(state, useAppStore.getState, { + selectedOnly: selectedOnly && getWorktreeCardStyle(state.settings) !== 'experimental-2' + }) + } scheduler.update([ // Collapsed review rollups also need background reads on paired clients; this shared queue bounds them. - ...getVisibleHostedReviewRefreshTargets(state, useAppStore.getState, { - selectedOnly: selectedOnly && getWorktreeCardStyle(state.settings) !== 'experimental-2' - }), - ...getWorkspaceReferenceRefreshTargets(state, { selectedOnly }) + ...hostedTargets, + ...referenceTargets ]) } const visibilityChanged = (): void => { scheduler.setVisible(false) - update() + update(true) scheduler.setVisible(isWindowVisible()) } const unsubscribe = useAppStore.subscribe((state, previous) => { - if (visibleHostedReviewRefreshInputsChanged(state, previous)) { - update() - } + update(visibleHostedReviewRefreshInputsChanged(state, previous)) }) - update() visibilityChanged() document.addEventListener('visibilitychange', visibilityChanged) return () => { diff --git a/src/renderer/src/components/native-chat/session/status/structured-agent-session-tabs.ts b/src/renderer/src/components/native-chat/session/status/structured-agent-session-tabs.ts index c59c783c480..6c51e90185a 100644 --- a/src/renderer/src/components/native-chat/session/status/structured-agent-session-tabs.ts +++ b/src/renderer/src/components/native-chat/session/status/structured-agent-session-tabs.ts @@ -182,11 +182,17 @@ export function getStructuredAgentSessionTabs( return list } +const emptyPaneTabs: ReadonlyMap = new Map() const paneTabs = new WeakMap>() export function getStructuredAgentSessionTabsByPaneKey( - tabs: TabsByWorktree + tabs: TabsByWorktree | Tab[] | undefined ): ReadonlyMap { - const structuredTabs = getStructuredAgentSessionTabs(tabs) + if (!tabs) { + return emptyPaneTabs + } + const structuredTabs = Array.isArray(tabs) + ? structuredTabsOf(tabs, undefined) + : getStructuredAgentSessionTabs(tabs) const cached = paneTabs.get(structuredTabs) if (cached) { return cached diff --git a/src/renderer/src/components/settings/chat/ChatNamingSetting.tsx b/src/renderer/src/components/settings/chat/ChatNamingSetting.tsx index 28d39ef24f2..244d9e8c7a8 100644 --- a/src/renderer/src/components/settings/chat/ChatNamingSetting.tsx +++ b/src/renderer/src/components/settings/chat/ChatNamingSetting.tsx @@ -1,20 +1,13 @@ -import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' import type { GlobalSettings } from '../../../../../shared/global-settings-types' import type { SourceControlAiSettingsPatch } from '../../../../../shared/source-control/ai/settings/source-control-ai-settings-types' -import { normalizeSourceControlAiSettings } from '../../../../../shared/source-control/ai/settings/source-control-ai-settings' import { resolveSourceControlAiForOperation } from '../../../../../shared/source-control/ai/source-control-ai-operation-resolution' -import { - DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES, - setSourceControlActionDefault -} from '../../../../../shared/source-control/ai/recipes/source-control-ai-actions' import { CUSTOM_AGENT_ID } from '../../../../../shared/source-control/ai/generation/commit-message-agent-spec' -import { isTuiAgent } from '../../../../../shared/agent-launch/catalog/tui-agent-config' import { getAgentLabel } from '@/lib/agent-catalog/agent-catalog' import { useTranslate } from '@/i18n/use-translate' import { Switch } from '../../ui/switch' import { SourceControlActionRecipeRow } from '../source-control/SourceControlActionRecipeRow' -import { replayGlobalTextModelEdits } from '../source-control/text-actions/source-control-text-action-model-replay' +import { useGlobalTextActionRecipeDraft } from '../source-control/use-global-text-action-recipe-draft' import { CustomAgentCommandField } from '../source-control/CustomAgentCommandField' import { useAppStore } from '../../../store/index' import { matchesSettingsSearch } from '../search/settings-search' @@ -22,7 +15,6 @@ import { SearchableSetting } from '../section-layout/SearchableSetting' import { SettingsGroup } from '../section-layout/SettingsGroup' import { SettingsGroupDisclosure } from '../section-layout/SettingsGroupDisclosure' import { getChatNamingSearchEntry } from './chat-naming-search' -import type { ActionRecipeDraftValue } from '../source-control/source-control-ai-action-recipe-draft' type ChatNamingSettingProps = { settings: GlobalSettings @@ -41,125 +33,32 @@ export function ChatNamingSetting({ }: ChatNamingSettingProps): React.JSX.Element | null { const t = useTranslate() const query = useAppStore((state) => state.settingsSearchQuery) - const config = useMemo( - () => normalizeSourceControlAiSettings(settings.sourceControlAi, settings.commitMessageAi), - [settings.sourceControlAi, settings.commitMessageAi] - ) - const recipe = config.actions?.conversationName - const persistedModelChoice = config.modelOverridesByOperation?.conversationName - const persisted = useMemo( - () => ({ - commandInputTemplate: - recipe?.commandInputTemplate ?? - DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES.conversationName, - agentArgs: recipe?.agentArgs ?? '', - modelChoice: persistedModelChoice - }), - [recipe, persistedModelChoice] - ) - const [draft, setDraft] = useState(null) - const [customCommandDraft, setCustomCommandDraft] = useState(null) - const [isSaving, setIsSaving] = useState(false) - // Without a menu edit the model choice is whatever is saved now, including another editor's. - const value = useMemo( - () => - draft && !draft.modelEdits?.length - ? { ...draft, modelChoice: persisted.modelChoice } - : (draft ?? persisted), - [draft, persisted] - ) - const customCommand = customCommandDraft ?? config.customAgentCommand - const customCommandDirty = customCommand !== config.customAgentCommand - const recipeDirty = JSON.stringify(value) !== JSON.stringify(persisted) - const latestValueRef = useRef({ value, customCommand, customCommandDraft }) - const dirty = recipeDirty || customCommandDirty - const onDirtyChangeRef = useRef(onDirtyChange) - useLayoutEffect(() => { - latestValueRef.current = { value, customCommand, customCommandDraft } - onDirtyChangeRef.current = onDirtyChange - }, [value, customCommand, customCommandDraft, onDirtyChange]) - const isMountedRef = useRef(false) - const clearDirtyOnUnmount = useCallback((node: HTMLSpanElement | null): void => { - isMountedRef.current = node !== null - if (node === null) { - onDirtyChangeRef.current?.(false) - } - }, []) - - const updateDraft = (next: ActionRecipeDraftValue): void => { - latestValueRef.current = { value: next, customCommand, customCommandDraft } - setDraft(next) - onDirtyChange?.(JSON.stringify(next) !== JSON.stringify(persisted) || customCommandDirty) - } - - const updateCustomCommandDraft = (next: string): void => { - const changed = next !== config.customAgentCommand - latestValueRef.current = { value, customCommand: next, customCommandDraft: next } - setCustomCommandDraft(next) - onDirtyChange?.(recipeDirty || changed) - } - - const save = async (): Promise => { - if (!dirty || isSaving) { - return - } - setIsSaving(true) - try { - await writeSourceControlAiSettings((current) => ({ - actions: setSourceControlActionDefault(current.actions, 'conversationName', { - commandInputTemplate: value.commandInputTemplate, - agentArgs: value.agentArgs - }), - ...(value.modelEdits?.length - ? { - modelOverridesByOperation: { - ...current.modelOverridesByOperation, - conversationName: replayGlobalTextModelEdits( - current, - 'conversationName', - value.modelEdits - ) - } - } - : {}), - ...(customCommandDirty ? { customAgentCommand: customCommand } : {}) - })) - if (!isMountedRef.current) { - return - } - const latestValue = latestValueRef.current - const hasNewEdits = JSON.stringify(latestValue.value) !== JSON.stringify(value) - const hasNewCommandEdits = - latestValue.customCommandDraft !== null && latestValue.customCommand !== customCommand - setDraft(hasNewEdits ? latestValue.value : null) - setCustomCommandDraft(hasNewCommandEdits ? latestValue.customCommand : null) - onDirtyChangeRef.current?.(hasNewEdits || hasNewCommandEdits) - } catch (error) { + const { + config, + recipe, + persisted, + value, + dirty, + customCommand, + customCommandDirty, + isSaving, + clearDirtyOnUnmount, + updateDraft, + updateCustomCommandDraft, + discard, + save, + saveAgent, + appendVariable + } = useGlobalTextActionRecipeDraft({ + actionId: 'conversationName', + settings, + writeSourceControlAiSettings, + onDirtyChange, + onSaveError: (error) => { console.error('Failed to save chat name recipe', error) toast.error(t('settings.chat.names.saveFailed', 'Could not save chat name settings.')) - } finally { - setIsSaving(false) } - } - - const saveAgent = async (selected: string): Promise => { - const agentId = - selected === '__default_agent__' - ? null - : selected === CUSTOM_AGENT_ID - ? CUSTOM_AGENT_ID - : isTuiAgent(selected) - ? selected - : null - try { - await writeSourceControlAiSettings((current) => ({ - actions: setSourceControlActionDefault(current.actions, 'conversationName', { agentId }) - })) - } catch (error) { - console.error('Failed to save chat name agent', error) - toast.error(t('settings.chat.names.saveFailed', 'Could not save chat name settings.')) - } - } + }) const cliRecipe = value.agentArgs?.trim() ? resolveSourceControlAiForOperation({ @@ -275,19 +174,8 @@ export function ChatNamingSetting({ updateDraft({ ...value, commandInputTemplate: template }) } onAgentArgsChange={(_id, agentArgs) => updateDraft({ ...value, agentArgs })} - onAppendVariable={(_id, variable) => { - const template = value.commandInputTemplate - const separator = template.endsWith('\n') || template.length === 0 ? '' : ' ' - updateDraft({ - ...value, - commandInputTemplate: `${template}${separator}{${variable}}` - }) - }} - onDiscard={() => { - setDraft(null) - setCustomCommandDraft(null) - onDirtyChange?.(false) - }} + onAppendVariable={(_id, variable) => appendVariable(variable)} + onDiscard={discard} onSave={() => void save()} /> diff --git a/src/renderer/src/components/settings/source-control/GitPane.tsx b/src/renderer/src/components/settings/source-control/GitPane.tsx index d067339468b..bca1315b5bf 100644 --- a/src/renderer/src/components/settings/source-control/GitPane.tsx +++ b/src/renderer/src/components/settings/source-control/GitPane.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import type { GlobalSettings } from '../../../../../shared/global-settings-types' import type { SourceControlGroupOrder } from '../../../../../shared/ui-chrome-types' import type { SourceControlAiSettingsPatch } from '../../../../../shared/source-control/ai/settings/source-control-ai-settings-types' @@ -13,6 +13,8 @@ import { SettingsGroup } from '../section-layout/SettingsGroup' import { BranchPrefixFeedback } from './BranchPrefixFeedback' import { matchesSettingsSearch } from '../search/settings-search' import { AutoRenameBranchFromWorkSetting } from './AutoRenameBranchFromWorkSetting' +import { WorkspaceIconSetting } from './WorkspaceIconSetting' +import { getWorkspaceIconSearchEntry } from './workspace-icon-search' import { JIRA_ISSUE_KEY_CASING_KEYWORDS, JiraIssueKeyCasingSetting, @@ -146,6 +148,22 @@ export function GitPane({ const storeSearchQuery = useAppStore((s) => s.settingsSearchQuery) const searchQuery = settingsSearchQuery ?? storeSearchQuery const keepLocalMainUpToDateTitle = getKeepLocalMainUpToDateTitle(t) + // The branch prompt and icon recipe share the page's single Git dirty guard. + const dirtyPartsRef = useRef({ branch: false, icon: false }) + const reportBranchDirty = useCallback( + (dirty: boolean): void => { + dirtyPartsRef.current.branch = dirty + onBranchPromptDirtyChange?.(dirty || dirtyPartsRef.current.icon) + }, + [onBranchPromptDirtyChange] + ) + const reportIconDirty = useCallback( + (dirty: boolean): void => { + dirtyPartsRef.current.icon = dirty + onBranchPromptDirtyChange?.(dirty || dirtyPartsRef.current.branch) + }, + [onBranchPromptDirtyChange] + ) const isBranchPrefixInputMode = settings.branchPrefix !== 'none' // Local drafts preserve caret/fast typing through async IPC; adopt external reloads but ignore delayed echoes of our own edits. @@ -312,10 +330,20 @@ export function GitPane({ updateSettings={updateSettings} writeSourceControlAiSettings={writeSourceControlAiSettings} forceVisible={hasUnsavedBranchPromptChanges} - onBranchPromptDirtyChange={onBranchPromptDirtyChange} + onBranchPromptDirtyChange={reportBranchDirty} branchPromptDiscardSignal={branchPromptDiscardSignal} settingsSearchQuery={searchQuery} /> + ) : null, + hasUnsavedBranchPromptChanges || + matchesSettingsSearch(searchQuery, getWorkspaceIconSearchEntry()) ? ( + ) : null ].filter(Boolean) diff --git a/src/renderer/src/components/settings/source-control/SourceControlActionRecipeRow.tsx b/src/renderer/src/components/settings/source-control/SourceControlActionRecipeRow.tsx index a985edd5958..3d9b11f5c55 100644 --- a/src/renderer/src/components/settings/source-control/SourceControlActionRecipeRow.tsx +++ b/src/renderer/src/components/settings/source-control/SourceControlActionRecipeRow.tsx @@ -96,7 +96,9 @@ function resolveAgentArgsPlaceholderAgent( function getActionLabel(actionId: AiActionId, t: Translate = translate): string { return actionId === 'conversationName' ? t('settings.chat.names.recipe', 'Chat name') - : SOURCE_CONTROL_ACTION_LABELS[actionId] + : actionId === 'workspaceIcon' + ? t('settings.git.workspaceIcons.recipe', 'Workspace icon') + : SOURCE_CONTROL_ACTION_LABELS[actionId] } export function SourceControlActionRecipeRow({ diff --git a/src/renderer/src/components/settings/source-control/WorkspaceIconSetting.tsx b/src/renderer/src/components/settings/source-control/WorkspaceIconSetting.tsx new file mode 100644 index 00000000000..98d57ac592c --- /dev/null +++ b/src/renderer/src/components/settings/source-control/WorkspaceIconSetting.tsx @@ -0,0 +1,134 @@ +import { useState } from 'react' +import { toast } from 'sonner' +import type { GlobalSettings } from '../../../../../shared/global-settings-types' +import type { SourceControlAiSettingsPatch } from '../../../../../shared/source-control/ai/settings/source-control-ai-settings-types' +import { CUSTOM_AGENT_ID } from '../../../../../shared/source-control/ai/generation/commit-message-agent-spec' +import { + WORKSPACE_ICON_DEFAULT_AGENT, + WORKSPACE_ICON_DEFAULT_MODEL +} from '../../../../../shared/source-control/ai/recipes/source-control-ai-actions' +import { useTranslate } from '@/i18n/use-translate' +import { regenerateWorktreeAiIcons } from '../../sidebar/use-worktree-ai-icon' +import { Button } from '../../ui/button' +import { Label } from '../../ui/label' +import { SearchableSetting } from '../section-layout/SearchableSetting' +import { CustomAgentCommandField } from './CustomAgentCommandField' +import { SourceControlActionRecipeRow } from './SourceControlActionRecipeRow' +import { useGlobalTextActionRecipeDraft } from './use-global-text-action-recipe-draft' +import { getWorkspaceIconSearchEntry } from './workspace-icon-search' + +type WorkspaceIconSettingProps = { + settings: GlobalSettings + writeSourceControlAiSettings: (patch: SourceControlAiSettingsPatch) => Promise + onDirtyChange?: (dirty: boolean) => void + forceVisible?: boolean +} + +export function WorkspaceIconSetting({ + settings, + writeSourceControlAiSettings, + onDirtyChange, + forceVisible = false +}: WorkspaceIconSettingProps): React.JSX.Element { + const t = useTranslate() + const [regenerating, setRegenerating] = useState(false) + const { + config, + recipe, + persisted, + value, + dirty, + customCommand, + customCommandDirty, + isSaving, + clearDirtyOnUnmount, + updateDraft, + updateCustomCommandDraft, + discard, + save, + saveAgent, + appendVariable + } = useGlobalTextActionRecipeDraft({ + actionId: 'workspaceIcon', + settings, + writeSourceControlAiSettings, + onDirtyChange, + onSaveError: (error) => { + console.error('Failed to save workspace icon recipe', error) + toast.error( + t('settings.git.workspaceIcons.saveFailed', 'Could not save workspace icon settings.') + ) + } + }) + const searchEntry = getWorkspaceIconSearchEntry() + + const regenerate = async (): Promise => { + setRegenerating(true) + try { + await regenerateWorktreeAiIcons() + } catch (error) { + console.error('Failed to clear workspace icons', error) + toast.error( + t('settings.git.workspaceIcons.regenerateFailed', 'Could not clear the saved icons.') + ) + } finally { + setRegenerating(false) + } + } + + return ( + +
+
+ +

{searchEntry.description}

+
+ +
+ {recipe?.agentId === CUSTOM_AGENT_ID || customCommandDirty ? ( + + ) : null} + + updateDraft({ ...value, modelChoice, agentArgs, modelEdits }) + } + writeSourceControlAiSettings={writeSourceControlAiSettings} + selectedAgent={recipe?.agentId ?? null} + defaultAgentLabel={t('settings.git.workspaceIcons.defaultAgent', 'Default (Claude Haiku)')} + defaultAgentArgsPlaceholder={`--model ${WORKSPACE_ICON_DEFAULT_MODEL}`} + draftValue={value} + baseValue={persisted} + hasUnsavedChanges={dirty} + defaultTuiAgent={WORKSPACE_ICON_DEFAULT_AGENT} + isSavingTemplate={isSaving} + onAgentChange={(_id, selected) => void saveAgent(selected)} + onTemplateChange={(_id, template) => + updateDraft({ ...value, commandInputTemplate: template }) + } + onAgentArgsChange={(_id, agentArgs) => updateDraft({ ...value, agentArgs })} + onAppendVariable={(_id, variable) => appendVariable(variable)} + onDiscard={discard} + onSave={() => void save()} + /> + +
+ ) +} diff --git a/src/renderer/src/components/settings/source-control/git-search.ts b/src/renderer/src/components/settings/source-control/git-search.ts index 6db1b198774..95934885630 100644 --- a/src/renderer/src/components/settings/source-control/git-search.ts +++ b/src/renderer/src/components/settings/source-control/git-search.ts @@ -1,4 +1,5 @@ import { getAutoRenameBranchSearchEntries } from './auto-rename-branch-search' +import { getWorkspaceIconSearchEntry } from './workspace-icon-search' import { translate } from '@/i18n/i18n' import { translateSearchKeyword } from '../search/settings-search-keywords' import { createLocalizedCatalog } from '@/i18n/localized-catalog' @@ -132,5 +133,6 @@ export const getGitPaneSearchEntries = createLocalizedCatalog(() => [ ) ] }, - ...getAutoRenameBranchSearchEntries() + ...getAutoRenameBranchSearchEntries(), + getWorkspaceIconSearchEntry() ]) diff --git a/src/renderer/src/components/settings/source-control/source-control-action-recipe-options.ts b/src/renderer/src/components/settings/source-control/source-control-action-recipe-options.ts index 95559c36dbc..74094f799dd 100644 --- a/src/renderer/src/components/settings/source-control/source-control-action-recipe-options.ts +++ b/src/renderer/src/components/settings/source-control/source-control-action-recipe-options.ts @@ -31,6 +31,10 @@ export const getActionDescriptions = createLocalizedCatalog((): Record Promise + onDirtyChange?: (dirty: boolean) => void + onSaveError: (error: unknown) => void +}) { + const config = useMemo( + () => normalizeSourceControlAiSettings(settings.sourceControlAi, settings.commitMessageAi), + [settings.sourceControlAi, settings.commitMessageAi] + ) + const recipe = config.actions?.[actionId] + const persistedModelChoice = config.modelOverridesByOperation?.[actionId] + const persisted = useMemo( + () => ({ + commandInputTemplate: + recipe?.commandInputTemplate ?? DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES[actionId], + agentArgs: recipe?.agentArgs ?? '', + modelChoice: persistedModelChoice + }), + [actionId, recipe, persistedModelChoice] + ) + const [draft, setDraft] = useState(null) + const [customCommandDraft, setCustomCommandDraft] = useState(null) + const [isSaving, setIsSaving] = useState(false) + // Without a menu edit the model choice is whatever is saved now, including another editor's. + const value = useMemo( + () => + draft && !draft.modelEdits?.length + ? { ...draft, modelChoice: persisted.modelChoice } + : (draft ?? persisted), + [draft, persisted] + ) + const customCommand = customCommandDraft ?? config.customAgentCommand + const customCommandDirty = customCommand !== config.customAgentCommand + const recipeDirty = JSON.stringify(value) !== JSON.stringify(persisted) + const latestValueRef = useRef({ value, customCommand, customCommandDraft }) + const dirty = recipeDirty || customCommandDirty + const onDirtyChangeRef = useRef(onDirtyChange) + useLayoutEffect(() => { + latestValueRef.current = { value, customCommand, customCommandDraft } + onDirtyChangeRef.current = onDirtyChange + }, [value, customCommand, customCommandDraft, onDirtyChange]) + const isMountedRef = useRef(false) + const clearDirtyOnUnmount = useCallback((node: HTMLSpanElement | null): void => { + isMountedRef.current = node !== null + if (node === null) { + onDirtyChangeRef.current?.(false) + } + }, []) + + const updateDraft = (next: ActionRecipeDraftValue): void => { + latestValueRef.current = { value: next, customCommand, customCommandDraft } + setDraft(next) + onDirtyChange?.(JSON.stringify(next) !== JSON.stringify(persisted) || customCommandDirty) + } + + const updateCustomCommandDraft = (next: string): void => { + const changed = next !== config.customAgentCommand + latestValueRef.current = { value, customCommand: next, customCommandDraft: next } + setCustomCommandDraft(next) + onDirtyChange?.(recipeDirty || changed) + } + + const discard = (): void => { + setDraft(null) + setCustomCommandDraft(null) + onDirtyChange?.(false) + } + + const save = async (): Promise => { + if (!dirty || isSaving) { + return + } + setIsSaving(true) + try { + await writeSourceControlAiSettings((current) => ({ + actions: setSourceControlActionDefault(current.actions, actionId, { + commandInputTemplate: value.commandInputTemplate, + agentArgs: value.agentArgs + }), + ...(value.modelEdits?.length + ? { + modelOverridesByOperation: { + ...current.modelOverridesByOperation, + [actionId]: replayGlobalTextModelEdits(current, actionId, value.modelEdits) + } + } + : {}), + ...(customCommandDirty ? { customAgentCommand: customCommand } : {}) + })) + if (!isMountedRef.current) { + return + } + const latestValue = latestValueRef.current + const hasNewEdits = JSON.stringify(latestValue.value) !== JSON.stringify(value) + const hasNewCommandEdits = + latestValue.customCommandDraft !== null && latestValue.customCommand !== customCommand + setDraft(hasNewEdits ? latestValue.value : null) + setCustomCommandDraft(hasNewCommandEdits ? latestValue.customCommand : null) + onDirtyChangeRef.current?.(hasNewEdits || hasNewCommandEdits) + } catch (error) { + onSaveError(error) + } finally { + setIsSaving(false) + } + } + + const saveAgent = async (selected: string): Promise => { + const agentId = + selected === '__default_agent__' + ? null + : selected === CUSTOM_AGENT_ID + ? CUSTOM_AGENT_ID + : isTuiAgent(selected) + ? selected + : null + try { + await writeSourceControlAiSettings((current) => ({ + actions: setSourceControlActionDefault(current.actions, actionId, { agentId }) + })) + } catch (error) { + onSaveError(error) + } + } + + const appendVariable = (variable: string): void => { + const template = value.commandInputTemplate + const separator = template.endsWith('\n') || template.length === 0 ? '' : ' ' + updateDraft({ ...value, commandInputTemplate: `${template}${separator}{${variable}}` }) + } + + return { + config, + recipe, + persisted, + value, + dirty, + customCommand, + customCommandDirty, + isSaving, + clearDirtyOnUnmount, + updateDraft, + updateCustomCommandDraft, + discard, + save, + saveAgent, + appendVariable + } +} diff --git a/src/renderer/src/components/settings/source-control/workspace-icon-search.ts b/src/renderer/src/components/settings/source-control/workspace-icon-search.ts new file mode 100644 index 00000000000..396e992ff66 --- /dev/null +++ b/src/renderer/src/components/settings/source-control/workspace-icon-search.ts @@ -0,0 +1,18 @@ +import { translate } from '@/i18n/i18n' +import { createLocalizedCatalog } from '@/i18n/localized-catalog' +import type { SettingsSearchEntry } from '../search/settings-search' + +export const getWorkspaceIconSearchEntry = createLocalizedCatalog((): SettingsSearchEntry => ({ + title: translate('settings.git.workspaceIcons.title', 'AI workspace icons'), + description: translate( + 'settings.git.workspaceIcons.description', + 'Agent, model, and prompt for the AI-generated card icon. Orca draws up to 16 icons per agent call and keeps each one by workspace name.' + ), + keywords: [ + translate('settings.git.workspaceIcons.searchIcon', 'icon'), + translate('settings.git.workspaceIcons.searchCard', 'workspace card'), + translate('settings.git.workspaceIcons.searchModel', 'model'), + translate('settings.git.workspaceIcons.searchTemplate', 'command template'), + translate('settings.git.workspaceIcons.searchRegenerate', 'regenerate') + ] +})) diff --git a/src/renderer/src/components/sidebar/WorktreeCardSlotAgentLines.tsx b/src/renderer/src/components/sidebar/WorktreeCardSlotAgentLines.tsx index 78f7b5b42d4..f4a41e3b8eb 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardSlotAgentLines.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardSlotAgentLines.tsx @@ -1,4 +1,5 @@ import React, { useCallback } from 'react' +import type { ExecutionHostId } from '../../../../shared/execution-host' import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData' import { translate } from '@/i18n/i18n' import { viewClaudeAgentFromRow } from '@/lib/agent-session/navigation/claude-agent-row-view' @@ -14,6 +15,7 @@ function stopPropagation(event: React.SyntheticEvent): void { /** Experimental 2.0 agent lines: lead agents, their subagents on demand, and the shared picking/unread/focus model. */ export function WorktreeCardSlotAgentLines({ worktreeId, + executionHostId, agents, signals, now, @@ -21,6 +23,7 @@ export function WorktreeCardSlotAgentLines({ cacheTimersActive }: { worktreeId: string + executionHostId?: ExecutionHostId agents: DashboardAgentRow[] signals: SlotAgentSignals now: number @@ -28,7 +31,7 @@ export function WorktreeCardSlotAgentLines({ /** False while the list is folded away, so hidden rows don't run countdowns. */ cacheTimersActive: boolean }): React.JSX.Element | null { - const model = useWorktreeAgentListModel(worktreeId, agents) + const model = useWorktreeAgentListModel(worktreeId, agents, executionHostId) const { showAgentModels, isAgentSendTargetModeActive, diff --git a/src/renderer/src/components/sidebar/WorktreeCardSlotContent.fixture.tsx b/src/renderer/src/components/sidebar/WorktreeCardSlotContent.fixture.tsx index 0791eeb1db7..a5f7613d47e 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardSlotContent.fixture.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardSlotContent.fixture.tsx @@ -332,6 +332,18 @@ export const nesting = defineFixture({ ) }) +export const nestingNoIcon = defineFixture({ + ...nesting, + setup: () => { + nesting.setup?.() + useAppStore.setState(({ settings }) => ({ + settings: settings ? { ...settings, worktreeCardIcon: 'none' } : settings + })) + } +}) + +export const nestingNoIconNarrow = defineFixture({ ...nestingNoIcon, width: 220 }) + const PR_FAMILY: WorkspaceSeed[] = [ { id: 'wt-pr-parent', diff --git a/src/renderer/src/components/sidebar/WorktreeCardSlotContent.tsx b/src/renderer/src/components/sidebar/WorktreeCardSlotContent.tsx index ca1a3e76135..51944b698a0 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardSlotContent.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardSlotContent.tsx @@ -214,6 +214,7 @@ export function WorktreeCardSlotContent({ const agentLines = showLines ? ( { if ( canOpen && + !open && !pickingSendTarget && !event.defaultPrevented && event.detail <= 1 && diff --git a/src/renderer/src/components/sidebar/use-worktree-ai-icon.test.tsx b/src/renderer/src/components/sidebar/use-worktree-ai-icon.test.tsx index 67e6e20d938..f3e4207744c 100644 --- a/src/renderer/src/components/sidebar/use-worktree-ai-icon.test.tsx +++ b/src/renderer/src/components/sidebar/use-worktree-ai-icon.test.tsx @@ -31,7 +31,8 @@ it('releases the last enabled card and ignores the abandoned attempt after remou generate: vi.fn().mockReturnValueOnce(abandoned.promise).mockReturnValueOnce(renewed.promise), cancel: async ({ requestId }) => { canceled.push(requestId) - } + }, + clearCache: async () => {} } vi.stubGlobal('api', { worktreeIcons: api }) const first = renderHook(() => useWorktreeAiIcon('shared mounted icon', true)) diff --git a/src/renderer/src/components/sidebar/use-worktree-ai-icon.ts b/src/renderer/src/components/sidebar/use-worktree-ai-icon.ts index 40ee2d2f1e4..422a94a449f 100644 --- a/src/renderer/src/components/sidebar/use-worktree-ai-icon.ts +++ b/src/renderer/src/components/sidebar/use-worktree-ai-icon.ts @@ -46,6 +46,22 @@ export function retryFailedWorktreeAiIcons(): void { notify() } +/** Forget every saved icon, here and on disk, and redraw the mounted cards with the current recipe. */ +export async function regenerateWorktreeAiIcons(): Promise { + await window.api.worktreeIcons.clearCache() + failureToastShown = false + for (const [name, entry] of entries) { + // A running batch settles into the new recipe's cache anyway; respawning it would double the cost. + if (entry.status !== 'pending') { + entries.delete(name) + } + } + for (const name of consumers.keys()) { + request(name) + } + notify() +} + function request(name: string): void { const entry = entries.get(name) // Failures retry after a while; cards remount on scroll, so an immediate retry would respawn agents. diff --git a/src/renderer/src/components/sidebar/workspace-card/activity/activate-retained-agent-row.ts b/src/renderer/src/components/sidebar/workspace-card/activity/activate-retained-agent-row.ts index ebe30752f1a..f08e19283d6 100644 --- a/src/renderer/src/components/sidebar/workspace-card/activity/activate-retained-agent-row.ts +++ b/src/renderer/src/components/sidebar/workspace-card/activity/activate-retained-agent-row.ts @@ -1,3 +1,4 @@ +import { structuredAgentSessionPaneKey } from '../../../../../../shared/agent-session/view/structured-agent-session-projection' import type { ExecutionHostId } from '../../../../../../shared/execution-host' import { useAppStore } from '@/store' import { activateAndRevealWorkspace } from '@/lib/workspace/activation/worktree-activation' @@ -19,23 +20,28 @@ export function activateRetainedAgentRow( executionHostId?: ExecutionHostId ): void { const parsed = parsePaneKey(paneKey) - if (!parsed || parsed.tabId !== tabId) { - return - } const state = useAppStore.getState() const nativeTab = state.unifiedTabsByWorktree[worktreeId]?.find( (tab) => tab.id === tabId && tab.worktreeId === worktreeId && tab.contentType === 'agent-session' ) + if ( + parsed + ? parsed.tabId !== tabId + : !nativeTab || structuredAgentSessionPaneKey(nativeTab.id, nativeTab.entityId) !== paneKey + ) { + return + } const target = nativeTab ? structuredAgentSessionTargetForTab(state, nativeTab) : null const layout = state.terminalLayoutsByTabId[tabId] const hasLiveTerminal = + parsed && state.tabsByWorktree[worktreeId]?.some((tab) => tab.id === tabId) && stablePaneHasLivePty(tabId, parsed.leafId, state.ptyIdsByTabId, layout) const owner = nativeTab ? structuredAgentSessionOwnerForTab(state, nativeTab) : getPtyExecutionHost( - layout?.ptyIdsByLeafId?.[parsed.leafId] ?? state.ptyIdsByTabId[tabId]?.[0] + (parsed && layout?.ptyIdsByLeafId?.[parsed.leafId]) ?? state.ptyIdsByTabId[tabId]?.[0] ) if ( (nativeTab ? !target : !hasLiveTerminal) || @@ -54,7 +60,7 @@ export function activateRetainedAgentRow( } if (nativeTab && target) { activateStructuredAgentSessionTab({ worktreeId, tabId, target }) - } else { + } else if (parsed) { activateTabAndFocusPane(tabId, parsed.leafId, { ackPaneKeyOnSuccess: paneKey, flashFocusedPane: true, diff --git a/src/renderer/src/components/sidebar/workspace-card/activity/use-worktree-agent-activation.ts b/src/renderer/src/components/sidebar/workspace-card/activity/use-worktree-agent-activation.ts index a094e014b92..bc987863c96 100644 --- a/src/renderer/src/components/sidebar/workspace-card/activity/use-worktree-agent-activation.ts +++ b/src/renderer/src/components/sidebar/workspace-card/activity/use-worktree-agent-activation.ts @@ -1,4 +1,6 @@ import type { ExecutionHostId } from '../../../../../../shared/execution-host' +import { getStructuredAgentSessionTabsByPaneKey } from '@/components/native-chat/session/status/structured-agent-session-tabs' +import { activateRetainedAgentRow } from './activate-retained-agent-row' import { useCallback } from 'react' import { useAppStore } from '@/store' import { activateAndRevealWorkspace } from '@/lib/workspace/activation/worktree-activation' @@ -8,10 +10,11 @@ import { parsePaneKey } from '../../../../../../shared/workspace-layout/stable-p import { dismissStaleAgentRowByKey } from '../../../terminal-pane/agent-status/stale-agent-row' import { activateStructuredAgentSessionTab } from '@/lib/agent-session/structured-agent-session-tab-activation' -export function useWorktreeAgentActivation(worktreeId: string) { +export function useWorktreeAgentActivation(worktreeId: string, executionHostId?: ExecutionHostId) { return useCallback( - (tabId: string, paneKey: string) => activateWorktreeAgentPane(worktreeId, tabId, paneKey), - [worktreeId] + (tabId: string, paneKey: string) => + activateWorktreeAgentPane(worktreeId, tabId, paneKey, executionHostId), + [worktreeId, executionHostId] ) } @@ -22,6 +25,12 @@ export function activateWorktreeAgentPane( paneKey: string, executionHostId?: ExecutionHostId ): void { + const state = useAppStore.getState() + const nativeTab = getStructuredAgentSessionTabsByPaneKey(state.unifiedTabsByWorktree).get(paneKey) + if (nativeTab?.worktreeId === worktreeId && nativeTab.id === tabId) { + activateRetainedAgentRow(worktreeId, tabId, paneKey, executionHostId) + return + } const parsed = parsePaneKey(paneKey) if (!parsed) { // Legacy/malformed pane keys cannot resolve after replay; discard stale rows rather than guessing. diff --git a/src/renderer/src/components/sidebar/workspace-card/activity/use-worktree-agent-list-model.ts b/src/renderer/src/components/sidebar/workspace-card/activity/use-worktree-agent-list-model.ts index a08e26b738a..142395e560e 100644 --- a/src/renderer/src/components/sidebar/workspace-card/activity/use-worktree-agent-list-model.ts +++ b/src/renderer/src/components/sidebar/workspace-card/activity/use-worktree-agent-list-model.ts @@ -1,3 +1,4 @@ +import type { ExecutionHostId } from '../../../../../../shared/execution-host' import { recomputeOn } from '@/lib/recompute-on' import { useCallback, useMemo } from 'react' import { useShallow } from 'zustand/react/shallow' @@ -34,7 +35,11 @@ export function dispatchSuppressScrollAdjustment(): void { } /** Everything a worktree card's agent list needs besides presentation: lineage, unread, picking, focus, disclosure. */ -export function useWorktreeAgentListModel(worktreeId: string, agents: DashboardAgentRowData[]) { +export function useWorktreeAgentListModel( + worktreeId: string, + agents: DashboardAgentRowData[], + executionHostId?: ExecutionHostId +) { const languageRevision = useUiLanguageRevision() const { agentActivityDisplayMode, showAgentModels } = useWorktreeAgentDisplay() const dropAgentStatus = useAppStore((s) => s.dropAgentStatus) @@ -115,10 +120,11 @@ export function useWorktreeAgentListModel(worktreeId: string, agents: DashboardA [sendPromptToSidebarAgentTarget] ) - const handleActivateAgentTab = useWorktreeAgentActivation(worktreeId) + const handleActivateAgentTab = useWorktreeAgentActivation(worktreeId, executionHostId) const handleActivateRetainedAgent = useCallback( - (tabId: string, paneKey: string) => activateRetainedAgentRow(worktreeId, tabId, paneKey), - [worktreeId] + (tabId: string, paneKey: string) => + activateRetainedAgentRow(worktreeId, tabId, paneKey, executionHostId), + [worktreeId, executionHostId] ) const lineageTree = useMemo(() => buildAgentRowLineageTree(agents), [agents]) diff --git a/src/renderer/src/components/sidebar/workspace-card/activity/worktree-agent-rows-projection.ts b/src/renderer/src/components/sidebar/workspace-card/activity/worktree-agent-rows-projection.ts index d0577639168..3e4efd1b214 100644 --- a/src/renderer/src/components/sidebar/workspace-card/activity/worktree-agent-rows-projection.ts +++ b/src/renderer/src/components/sidebar/workspace-card/activity/worktree-agent-rows-projection.ts @@ -22,12 +22,8 @@ import { selectWorktreeAgentOrchestration as selectRuntimeAgentOrchestrationForW import { EMPTY_TABS } from './worktree-card-empty-inputs' import { createWorktreeAgentFreshnessSelector } from './worktree-agent-freshness-selector' -import { getPtyExecutionHost } from '../../../../../../shared/terminal/pty/lifecycle/terminal-execution-host' -import { - getConnectionExecutionHostId, - type ExecutionHostId -} from '../../../../../../shared/execution-host' -import { parsePaneKey } from '../../../../../../shared/workspace-layout/stable-pane-id' +import type { ExecutionHostId } from '../../../../../../shared/execution-host' +import { getAgentPaneExecutionHost } from '@/store/slices/agent-status/agent-pane-execution-host' export const EMPTY_AGENT_ROWS: DashboardAgentRow[] = [] @@ -37,22 +33,11 @@ function rowBelongsToHost( hostId: ExecutionHostId ): boolean { const paneKey = row.activationPaneKey ?? row.paneKey - const tab = getStructuredAgentSessionTabsByPaneKey(state.unifiedTabsByWorktree).get(paneKey) - if (tab?.executionHostId) { - return tab.executionHostId === hostId - } const entry = state.agentStatusByPaneKey[paneKey] ?? state.retainedAgentsByPaneKey[paneKey]?.entry ?? row.entry - const parsed = parsePaneKey(paneKey) - const ptyId = - (parsed && state.terminalLayoutsByTabId[parsed.tabId]?.ptyIdsByLeafId?.[parsed.leafId]) ?? - entry.terminalHandle ?? - row.tab.ptyId ?? - (parsed ? state.ptyIdsByTabId[parsed.tabId]?.[0] : undefined) - const ptyHost = getPtyExecutionHost(ptyId) - return (ptyHost ?? getConnectionExecutionHostId(entry.connectionId)) === hostId + return getAgentPaneExecutionHost(state, paneKey, entry, row.tab.ptyId) === hostId } /** The row model shared by mounted cards and hidden-workspace summaries. */ @@ -73,7 +58,9 @@ export function createWorktreeAgentRowsSelector(worktreeId: string, hostId?: Exe worktreeId ) const paneForegroundAgentByPaneKey = selectPaneForegroundAgentsForWorktree(state, worktreeId) - const representedTabs = getStructuredAgentSessionTabsByPaneKey(state.unifiedTabsByWorktree) + const representedTabs = getStructuredAgentSessionTabsByPaneKey( + state.unifiedTabsByWorktree[worktreeId] + ) const inputs = [ tabs, liveEntries, diff --git a/src/renderer/src/components/sidebar/workspace-card/review/workspace-reference-refresh-targets.test.ts b/src/renderer/src/components/sidebar/workspace-card/review/workspace-reference-refresh-targets.test.ts index dca8f87ade3..9f149237b69 100644 --- a/src/renderer/src/components/sidebar/workspace-card/review/workspace-reference-refresh-targets.test.ts +++ b/src/renderer/src/components/sidebar/workspace-card/review/workspace-reference-refresh-targets.test.ts @@ -1,8 +1,9 @@ // @vitest-environment happy-dom import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' +import { makePaneKey } from '../../../../../../shared/workspace-layout/stable-pane-id' -import { getWorkspaceReferenceRefreshTargets } from './workspace-reference-refresh-targets' +import { createWorkspaceReferenceRefreshTargetsSelector } from './workspace-reference-refresh-targets' import { referenceAttachment, referenceRepo, @@ -17,7 +18,7 @@ vi.mock('@/store', async () => { const read = vi.hoisted(() => vi.fn(async () => null)) vi.mock('./workspace-reference-detail-read', () => ({ readWorkspaceReferenceDetails: read })) const workspace = { ...referenceWorkspace, linkedPR: null, linkedItems: [referenceAttachment()] } -const update = () => getWorkspaceReferenceRefreshTargets(useAppStore.getState()) +const update = () => createWorkspaceReferenceRefreshTargetsSelector()(useAppStore.getState(), true) beforeEach(() => { vi.useFakeTimers() vi.clearAllMocks() @@ -86,6 +87,35 @@ it('retains both explicit owners when equal visible workspace IDs exist on two h activeWorkspaceExecutionHostId: 'ssh:remote' }) expect( - getWorkspaceReferenceRefreshTargets(useAppStore.getState(), { selectedOnly: true }) + createWorkspaceReferenceRefreshTargetsSelector({ selectedOnly: true })( + useAppStore.getState(), + true + ) ).toHaveLength(1) }) + +it('adds and removes exact review targets when a headless agent wakes and sleeps its workspace', () => { + useAppStore.setState({ browserTabsByWorktree: {} }) + const select = createWorkspaceReferenceRefreshTargetsSelector() + expect(select(useAppStore.getState(), true)).toEqual([]) + const paneKey = makePaneKey('headless', '11111111-1111-4111-8111-111111111111') + useAppStore.setState({ + agentStatusByPaneKey: { + [paneKey]: { + paneKey, + worktreeId: workspace.id, + agentType: 'codex', + state: 'working', + prompt: '', + updatedAt: Date.now(), + stateStartedAt: Date.now(), + stateHistory: [] + } + } + }) + expect(select(useAppStore.getState(), false)).toMatchObject([ + { selected: false, fetchedAt: null } + ]) + useAppStore.setState({ agentStatusByPaneKey: {} }) + expect(select(useAppStore.getState(), false)).toEqual([]) +}) diff --git a/src/renderer/src/components/sidebar/workspace-card/review/workspace-reference-refresh-targets.ts b/src/renderer/src/components/sidebar/workspace-card/review/workspace-reference-refresh-targets.ts index ed931ad621f..dddca91f06b 100644 --- a/src/renderer/src/components/sidebar/workspace-card/review/workspace-reference-refresh-targets.ts +++ b/src/renderer/src/components/sidebar/workspace-card/review/workspace-reference-refresh-targets.ts @@ -16,15 +16,54 @@ import { readWorkspaceReferenceDetails } from './workspace-reference-detail-read import { getDetachedWorkspaceReviewRequest } from './workspace-detached-review-request' import { selectIsSleepingWorktree } from '../../workspace-actions/sleep/use-worktree-sleep-state' -export function getWorkspaceReferenceRefreshTargets( +function workspaceReferenceWorkspaces(state: AppState, options?: { selectedOnly?: boolean }) { + return [...getVisibleHostedReviewWorkspaces(state, options)].filter(({ worktree }) => + getWorkspaceAttachments(worktree).some((item) => item.type !== 'issue') + ) +} + +export function createWorkspaceReferenceRefreshTargetsSelector(options?: { + selectedOnly?: boolean +}) { + let workspaces: ReturnType = [] + let inputs: readonly unknown[] = [] + let sleeping: boolean[] = [] + let result: VisibleHostedReviewRefreshTarget[] = [] + return (state: AppState, reviewInputsChanged: boolean): VisibleHostedReviewRefreshTarget[] => { + const reset = inputs.length === 0 || reviewInputsChanged + if (reset) { + workspaces = workspaceReferenceWorkspaces(state, options) + } + const next = [ + state.tabsByWorktree, + state.ptyIdsByTabId, + state.browserTabsByWorktree, + state.unifiedTabsByWorktree, + state.structuredChatSleptAtByPaneKey, + state.agentStatusByPaneKey, + state.agentStatusEpoch + ] + if (!reset && next.every((value, index) => value === inputs[index])) { + return result + } + inputs = next + const nextSleeping = workspaces.map(({ worktree }) => + selectIsSleepingWorktree(state, worktree.id) + ) + if (reset || nextSleeping.some((value, index) => value !== sleeping[index])) { + result = referenceRefreshTargets(state, workspaces) + } + sleeping = nextSleeping + return result + } +} + +function referenceRefreshTargets( state: AppState, - options?: { selectedOnly?: boolean } + workspaces: ReturnType ): VisibleHostedReviewRefreshTarget[] { const targets = new Map() - for (const { worktree, repo, candidate, selected } of getVisibleHostedReviewWorkspaces( - state, - options - )) { + for (const { worktree, repo, candidate, selected } of workspaces) { if (selectIsSleepingWorktree(state, worktree.id)) { continue } diff --git a/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-surface.tsx b/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-surface.tsx index 035b18a8c75..ce434107389 100644 --- a/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-surface.tsx +++ b/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-surface.tsx @@ -17,16 +17,20 @@ import { } from '../../worktree-list/rows/indentation' import { DEFAULT_WORKSPACE_CARD_TEMPLATE } from '../template/default-workspace-card' import { renderWorkspaceCardTemplate } from '../template/render-workspace-card-template' +import { getWorktreeCardIconStyle } from '../../../../../../shared/worktree/card-display-settings-types' -/** 2.0's leading icon moves the title column right; children start on it. */ +/** Children follow the title column, including the leading icon only when shown. */ function slotLineageChildrenStyle( - style: React.CSSProperties | undefined + style: React.CSSProperties | undefined, + hasIcon: boolean ): React.CSSProperties | undefined { if (style?.marginLeft === undefined) { return style } const offset = typeof style.marginLeft === 'number' ? `${style.marginLeft}px` : style.marginLeft - return getLineageChildrenInlineStyle(`calc(${offset} + ${SLOT_LINEAGE_TITLE_COLUMN_SHIFT}px)`) + // The hidden icon removes its 18px tile and 6px header gap from the title column. + const titleColumnShift = SLOT_LINEAGE_TITLE_COLUMN_SHIFT - (hasIcon ? 0 : 24) + return getLineageChildrenInlineStyle(`calc(${offset} + ${titleColumnShift}px)`) } export function WorktreeCardSurface({ card }: { card: WorktreeCardController }): React.JSX.Element { @@ -165,7 +169,14 @@ export function WorktreeCardSurface({ card }: { card: WorktreeCardController }): slotLayout && 'worktree-card-slot-lineage' )} data-worktree-lineage-children="" - style={slotLayout ? slotLineageChildrenStyle(lineageChildrenStyle) : lineageChildrenStyle} + style={ + slotLayout + ? slotLineageChildrenStyle( + lineageChildrenStyle, + getWorktreeCardIconStyle(card.settings) !== 'none' + ) + : lineageChildrenStyle + } > {lineageChildren} diff --git a/src/renderer/src/components/sidebar/worktree-card-leading-tab-order.ts b/src/renderer/src/components/sidebar/worktree-card-leading-tab-order.ts index a3687554930..9f6a3c44fc4 100644 --- a/src/renderer/src/components/sidebar/worktree-card-leading-tab-order.ts +++ b/src/renderer/src/components/sidebar/worktree-card-leading-tab-order.ts @@ -3,7 +3,11 @@ import { getIndexedWorktreesById } from '@/store/slices/worktrees/listing/worktr import { selectWorktreesByRepo } from '@/store/workspaces/catalog/workspace-record-fields' import type { Tab } from '../../../../shared/tab-types' import type { AgentStatusEntry } from '../../../../shared/agent-status/contracts/agent-status-types' -import { isAgentStatusFresh } from '../../../../shared/agent-status/contracts/agent-status-freshness' +import { + isAgentStatusFresh, + AGENT_STATUS_STALE_AFTER_MS +} from '../../../../shared/agent-status/contracts/agent-status-freshness' +import { agentEntryCompletionAt } from '../../../../shared/agent-status/lifecycle/agent-completion-time' import { structuredAgentSessionPaneKey } from '../../../../shared/agent-session/view/structured-agent-session-projection' import { IDLE, @@ -28,6 +32,48 @@ function entriesByTabId(state: AppState): ReadonlyMap { + for (const entry of entries) { + inputs.push(entry, isAgentStatusFresh(entry, now)) + if (smart && entry.state === 'done') { + const completedAt = agentEntryCompletionAt(entry) + inputs.push(completedAt !== null && now - completedAt <= AGENT_STATUS_STALE_AFTER_MS) + } + } + } + for (const tab of tabs ?? []) { + if (tab.contentType === 'terminal') { + const entries = byTabId.get(tab.entityId) ?? [] + inputs.push(entries.length, state.terminalLayoutsByTabId[tab.entityId]) + addEntries(entries) + if (smart) { + inputs.push(state.ptyIdsByTabId[tab.entityId], state.runtimePaneTitlesByTabId[tab.entityId]) + } + } else if (tab.contentType === 'agent-session') { + const entry = state.agentStatusByPaneKey[structuredAgentSessionPaneKey(tab.id, tab.entityId)] + inputs.push(entry) + if (entry) { + addEntries([entry]) + } + } + } + return inputs +} + /** Newest human focus or agent state change; tool pings move `updatedAt`, not `stateStartedAt`. */ function latestEventAt(tab: Tab, entries: readonly AgentStatusEntry[], now: number): number { let at = tab.lastFocusedAt ?? 0 diff --git a/src/renderer/src/components/sidebar/worktree-card-leading-tabs.ts b/src/renderer/src/components/sidebar/worktree-card-leading-tabs.ts index 7c6b69dfc19..e7e9ce71e0c 100644 --- a/src/renderer/src/components/sidebar/worktree-card-leading-tabs.ts +++ b/src/renderer/src/components/sidebar/worktree-card-leading-tabs.ts @@ -1,11 +1,13 @@ import type { AppState } from '@/store/types' -import { getIndexedWorktreesById } from '@/store/slices/worktrees/listing/worktree-catalog-index' import { selectWorktreesByRepo } from '@/store/workspaces/catalog/workspace-record-fields' import type { Tab } from '../../../../shared/tab-types' import type { TerminalAgent } from '../../../../shared/agent-status/identity/terminal-agent' import { agentTypeToIconAgent } from '../../../../shared/agent-status/presentation/agent-type-icon-agent' import { selectTabBarAgentProjections } from '../tab-bar/tab-agent-types-by-tab-id' -import { orderWorktreeLeadingTabs } from './worktree-card-leading-tab-order' +import { + orderWorktreeLeadingTabs, + worktreeLeadingTabOrderInputs +} from './worktree-card-leading-tab-order' /** The card's leading icon shows this many tabs: the front one and up to three layered behind it. */ export const LEADING_TAB_LIMIT = 4 @@ -90,10 +92,11 @@ export function selectWorktreeLeadingTabs(state: AppState, worktreeId: string): /** Keep ordering and icon projection out of unrelated store notifications. */ export function createWorktreeLeadingTabsSelector(worktreeId: string) { + let previousSources: readonly unknown[] = [] let previous: readonly unknown[] = [] let result: string[] = [] return (state: AppState): string[] => { - const inputs = [ + const sources = [ state.unifiedTabsByWorktree[worktreeId], state.tabsByWorktree[worktreeId], state.browserTabsByWorktree[worktreeId], @@ -104,11 +107,24 @@ export function createWorktreeLeadingTabsSelector(worktreeId: string) { state.runtimePaneTitlesByTabId, state.terminalLayoutsByTabId, state.sortBy, - state.sortBy === 'smart' - ? getIndexedWorktreesById(selectWorktreesByRepo(state), worktreeId)[0]?.lastActivityAt - : undefined + state.sortBy === 'smart' ? selectWorktreesByRepo(state) : undefined ] - if (!inputs.every((value, index) => value === previous[index])) { + if (sources.every((value, index) => value === previousSources[index])) { + return result + } + previousSources = sources + const agents = selectTabBarAgentProjections(state).tabAgentTypesByTabId + const inputs = [ + ...worktreeLeadingTabOrderInputs(state, worktreeId), + state.browserTabsByWorktree[worktreeId], + ...(state.unifiedTabsByWorktree[worktreeId] ?? []) + .filter((tab) => tab.contentType === 'terminal') + .map((tab) => agents[tab.entityId]) + ] + if ( + inputs.length !== previous.length || + !inputs.every((value, index) => value === previous[index]) + ) { previous = inputs result = selectWorktreeLeadingTabs(state, worktreeId) } diff --git a/src/renderer/src/components/sidebar/worktree-card-pr-rollup.tsx b/src/renderer/src/components/sidebar/worktree-card-pr-rollup.tsx index 2850ec8450a..f5123d2f81a 100644 --- a/src/renderer/src/components/sidebar/worktree-card-pr-rollup.tsx +++ b/src/renderer/src/components/sidebar/worktree-card-pr-rollup.tsx @@ -5,7 +5,6 @@ import { lineageIdentities, type WorktreeLineageSource } from '../../../../shared/sidebar-list/worktree-lineage-range' -import type { HostedReviewState } from '../../../../shared/source-control/hosted-review/hosted-review-contracts' import type { Worktree } from '../../../../shared/worktree/types' import { getWorktreeHostIdentity } from '../../../../shared/worktree/host-qualified-identity' import { @@ -33,12 +32,13 @@ import { addReview as add, ownWorktreeReview as ownReview, createHiddenReviewStatesSelector, - type PrStates + type PrStates, + type ReviewRollupState } from './worktree-card-review-range' type ReviewTarget = { worktree: Worktree; review: WorktreeCardPrDisplay } -function stateLabel(state: HostedReviewState, count: number, t: Translate): string { +function stateLabel(state: ReviewRollupState, count: number, t: Translate): string { switch (state) { case 'open': return t('sidebar.cardSlot.prOpen', '{{count}} open', { count }) @@ -48,6 +48,8 @@ function stateLabel(state: HostedReviewState, count: number, t: Translate): stri return t('sidebar.cardSlot.prMerged', '{{count}} merged', { count }) case 'closed': return t('sidebar.cardSlot.prClosed', '{{count}} closed', { count }) + case 'unknown': + return t('workspace.links.reviews', 'Reviews') } } @@ -73,11 +75,13 @@ export function WorktreeCardPrRollup({ open: { ...hidden.open }, draft: { ...hidden.draft }, merged: { ...hidden.merged }, - closed: { ...hidden.closed } + closed: { ...hidden.closed }, + unknown: { ...hidden.unknown } } const hasHidden = STATE_ORDER.some((key) => hidden[key].count > 0) - if (ownRepresentsDescendant && own?.state && hidden[own.state].count > 0) { - merge(states[own.state], { count: 0, review: own }) + const ownState = own?.state ?? 'unknown' + if (ownRepresentsDescendant && own && hidden[ownState].count > 0) { + merge(states[ownState], { count: 0, review: own }) } else { add(states, own) } @@ -102,7 +106,6 @@ export function WorktreeCardPrRollup({ ) : null} ))} - {own && !own.state ? : null} ) const buttonClass = diff --git a/src/renderer/src/components/sidebar/worktree-card-review-range.test.ts b/src/renderer/src/components/sidebar/worktree-card-review-range.test.ts index ba1313baef1..c3ef6495dd4 100644 --- a/src/renderer/src/components/sidebar/worktree-card-review-range.test.ts +++ b/src/renderer/src/components/sidebar/worktree-card-review-range.test.ts @@ -103,3 +103,58 @@ it('updates shared review keys, removes old representatives, and excludes review expect(select(closed).open.count).toBe(0) expect(select(closed).closed.count).toBe(2) }) + +it('retains a hidden linked review until its state loads, including an unavailable response', () => { + const repo = { id: 'repo', path: '/repo', displayName: 'repo', badgeColor: 'gray', addedAt: 1 } + const parent = makeWorktree({ id: 'parent', repoId: 'repo' }) + const child = makeWorktree({ id: 'child', repoId: 'repo', linkedGitLabMR: 42 }) + const plan = buildWorktreeOccurrencePlan({ + rows: [parent, child], + identityOf: getWorktreeHostIdentity, + childrenByParentIdentity: new Map([[getWorktreeHostIdentity(parent), [child]]]), + isPinned: () => false, + duplicatePinned: false, + sectionsOf: ({ natural }) => [{ key: 'all', items: natural }] + }) + const range = [...buildWorktreeLineageRanges(plan).values()].find((range) => range.length === 1)! + const state = { + ...useAppStore.getState(), + repos: [repo], + worktreesByRepo: { repo: [parent, child] }, + prCache: {}, + hostedReviewCache: {} + } + const select = createHiddenReviewStatesSelector(range) + expect(select(state).unknown.count).toBe(1) + expect(select(state).unknown.review?.number).toBe(42) + const { hostedReviewCacheKey } = getWorktreeReviewCacheKeys(child, repo, state.settings) + const unavailable = { + ...state, + hostedReviewCache: { [hostedReviewCacheKey]: { data: null, fetchedAt: 1 } } + } + expect(select(unavailable).unknown.count).toBe(1) + expect( + selectHiddenReviewStates(unavailable, [getWorktreeHostIdentity(child)]).unknown.review?.number + ).toBe(42) + const loaded = { + ...state, + hostedReviewCache: { + [hostedReviewCacheKey]: { + data: { + provider: 'gitlab' as const, + number: 42, + title: 'MR', + state: 'merged' as const, + url: 'https://example.test/42', + status: 'success' as const, + mergeable: 'UNKNOWN' as const, + updatedAt: '' + }, + fetchedAt: 2 + } + } + } + expect(select(loaded).unknown.count).toBe(0) + expect(select(loaded).merged.count).toBe(1) + expect(select(loaded).merged.review?.number).toBe(42) +}) diff --git a/src/renderer/src/components/sidebar/worktree-card-review-range.ts b/src/renderer/src/components/sidebar/worktree-card-review-range.ts index c28ec4557b1..96fd7534874 100644 --- a/src/renderer/src/components/sidebar/worktree-card-review-range.ts +++ b/src/renderer/src/components/sidebar/worktree-card-review-range.ts @@ -26,19 +26,22 @@ import { type ReviewSnapshot } from './worktree-card-review-snapshot' -export const REVIEW_STATE_ORDER: readonly HostedReviewState[] = [ +export type ReviewRollupState = HostedReviewState | 'unknown' +export const REVIEW_STATE_ORDER: readonly ReviewRollupState[] = [ 'open', 'draft', 'merged', - 'closed' + 'closed', + 'unknown' ] type PrSummary = { count: number; review: WorktreeCardPrDisplay | null; rank?: number } -export type PrStates = Record +export type PrStates = Record export const emptyReviewStates = (): PrStates => ({ open: { count: 0, review: null }, draft: { count: 0, review: null }, merged: { count: 0, review: null }, - closed: { count: 0, review: null } + closed: { count: 0, review: null }, + unknown: { count: 0, review: null } }) function priority(review: WorktreeCardPrDisplay): number { @@ -63,8 +66,8 @@ export function addReview( review: WorktreeCardPrDisplay | null, rank?: number ): void { - if (review?.state) { - mergeReviewSummary(states[review.state], { count: 1, review, rank }) + if (review) { + mergeReviewSummary(states[review.state ?? 'unknown'], { count: 1, review, rank }) } } function combine(left: PrStates, right: PrStates): PrStates { @@ -192,6 +195,9 @@ function reviewsForTree(state: AppState, tree: WorktreeLineageTree): TreeReviews } export function selectHiddenReviewStates(state: AppState, source: WorktreeLineageSource): PrStates { + if (source.length === 0) { + return emptyReviewStates() + } if (isLineageRange(source)) { return reviewsForTree(state, source.tree).aggregate.read( source.index + 1, diff --git a/src/renderer/src/components/sidebar/worktree-card-slot-navigation.test.tsx b/src/renderer/src/components/sidebar/worktree-card-slot-navigation.test.tsx new file mode 100644 index 00000000000..9d9a9e8acbd --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-card-slot-navigation.test.tsx @@ -0,0 +1,117 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { + makeCreatedAgentWorktree, + seedAlreadyActiveWorktree +} from '@/lib/workspace/activation/worktree-activation-created-agent.test-fixture' +import { structuredAgentSessionPaneKey } from '../../../../shared/agent-session/view/structured-agent-session-projection' +import { makePaneKey } from '../../../../shared/workspace-layout/stable-pane-id' +import { TooltipProvider } from '@/components/ui/tooltip' +import { WorktreeCardSlotAgentLines } from './WorktreeCardSlotAgentLines' +import { buildSlotAgentSignals } from './worktree-card-slot-agent-signals' +import { createWorktreeAgentRowsSelector } from './workspace-card/activity/worktree-agent-rows-projection' +import { openSlotTarget } from './worktree-card-slot-targets' + +vi.mock('@/runtime/rpc/calls/runtime-rpc-call', () => ({ + callRuntimeRpc: vi.fn(async () => ({ ok: true })) +})) + +const original = useAppStore.getState() +const LEAF = '11111111-1111-4111-8111-111111111111' + +afterEach(() => { + cleanup() + useAppStore.setState(original, true) +}) + +it('opens an expanded live agent on the card host when another host has the same workspace ID', () => { + const worktree = { ...makeCreatedAgentWorktree(), hostId: 'local' as const } + const now = Date.now() + const paneKey = makePaneKey('tab-1', LEAF) + seedAlreadyActiveWorktree(worktree, { + worktreesByRepo: { [worktree.repoId]: [worktree, { ...worktree, hostId: 'runtime:other' }] }, + activeWorkspaceExecutionHostId: 'runtime:other', + terminalLayoutsByTabId: { + 'tab-1': { root: { type: 'leaf', leafId: LEAF }, activeLeafId: LEAF, expandedLeafId: null } + }, + agentStatusByPaneKey: { + [paneKey]: { + paneKey, + worktreeId: worktree.id, + agentType: 'codex', + state: 'working', + prompt: 'Local agent', + updatedAt: now, + stateStartedAt: now, + stateHistory: [] + } + } + }) + const agents = createWorktreeAgentRowsSelector(worktree.id, 'local')(useAppStore.getState()) + render( + + + + ) + fireEvent.click(screen.getByRole('button', { name: /Local agent/ })) + expect(useAppStore.getState().activeWorkspaceExecutionHostId).toBe('local') + expect(useAppStore.getState().getActiveTab(worktree.id)?.id).toBe('tab-1') +}) + +it('opens the structured chat tab from a slot target whose tab ID contains a collision suffix', () => { + const worktree = { ...makeCreatedAgentWorktree(), hostId: 'local' as const } + seedAlreadyActiveWorktree(worktree) + const tabId = 'structured-agent-session-chat:history-1' + const paneKey = structuredAgentSessionPaneKey(tabId, 'chat') + useAppStore.setState({ + activeTabType: 'terminal', + groupsByWorktree: { + [worktree.id]: [ + { ...useAppStore.getState().groupsByWorktree[worktree.id][0], tabOrder: ['tab-1', tabId] } + ] + }, + unifiedTabsByWorktree: { + [worktree.id]: [ + ...useAppStore.getState().unifiedTabsByWorktree[worktree.id], + { + id: tabId, + worktreeId: worktree.id, + executionHostId: 'local', + groupId: 'group-1', + contentType: 'agent-session', + agentSessionAgent: 'codex', + entityId: 'chat', + label: 'Chat', + customLabel: null, + color: null, + sortOrder: 1, + createdAt: 1 + } + ] + } + }) + openSlotTarget({ + worktreeId: worktree.id, + executionHostId: 'local', + paneKey, + kind: 'working', + at: 1, + retained: false, + agentType: 'codex', + entry: null + }) + expect(useAppStore.getState().activeTabType).toBe('agent-session') + expect(useAppStore.getState().getActiveTab(worktree.id)?.id).toBe( + 'structured-agent-session-chat:history-1' + ) +}) diff --git a/src/renderer/src/components/sidebar/worktree-card-slot-range.ts b/src/renderer/src/components/sidebar/worktree-card-slot-range.ts index 93289fce8d8..223254982bf 100644 --- a/src/renderer/src/components/sidebar/worktree-card-slot-range.ts +++ b/src/renderer/src/components/sidebar/worktree-card-slot-range.ts @@ -87,8 +87,11 @@ export function createLineageSlotReader( tree: WorktreeLineageTree, index: number ): SlotAggregate { - const slots = slotsForTree(state, tree) - return slots.aggregate.read(index + 1, tree.ends[index]!) + const end = tree.ends[index]! + if (end <= index + 1) { + return emptyAggregate() + } + return slotsForTree(state, tree).aggregate.read(index + 1, end) } return { slotsForTree, descendantSlot } diff --git a/src/renderer/src/components/sidebar/worktree-card-slot-rollup.ts b/src/renderer/src/components/sidebar/worktree-card-slot-rollup.ts index 4c35bac105e..6b331876dd5 100644 --- a/src/renderer/src/components/sidebar/worktree-card-slot-rollup.ts +++ b/src/renderer/src/components/sidebar/worktree-card-slot-rollup.ts @@ -142,6 +142,9 @@ export function collectLineageSlotTargets( source: WorktreeLineageSource ): SlotTarget[] { if (isLineageRange(source)) { + if (source.length === 0) { + return [] + } const slots = slotsForTree(state, source.tree) const result: SlotTarget[] = [] for (let i = source.index + 1; i < source.tree.ends[source.index]!; i++) { diff --git a/src/renderer/src/components/sidebar/worktree-card-slot-targets.ts b/src/renderer/src/components/sidebar/worktree-card-slot-targets.ts index 5027eec9a3c..3340b034377 100644 --- a/src/renderer/src/components/sidebar/worktree-card-slot-targets.ts +++ b/src/renderer/src/components/sidebar/worktree-card-slot-targets.ts @@ -1,3 +1,4 @@ +import { getStructuredAgentSessionTabsByPaneKey } from '@/components/native-chat/session/status/structured-agent-session-tabs' import { setWorktreeCardDisclosure } from './workspace-card/surface/worktree-card-open-state' import { composeWorktreeHostIdentity } from '../../../../shared/worktree/host-qualified-identity' import type { ExecutionHostId } from '../../../../shared/execution-host' @@ -149,26 +150,21 @@ export function openSlotTarget(target: SlotTarget): void { activateUnreadTab(target.worktreeId, target.tabId, target.executionHostId) return } - const parsed = target.paneKey ? parsePaneKey(target.paneKey) : null - if (!target.paneKey || !parsed) { + const representedTab = target.paneKey + ? getStructuredAgentSessionTabsByPaneKey(useAppStore.getState().unifiedTabsByWorktree).get( + target.paneKey + ) + : undefined + const tabId = representedTab?.id ?? (target.paneKey ? parsePaneKey(target.paneKey)?.tabId : null) + if (!target.paneKey || !tabId) { activateAndRevealWorkspace(target.worktreeId, { executionHostId: target.executionHostId }) return } expandWorktreeAgentList(target.worktreeId) if (target.retained) { - activateRetainedAgentRow( - target.worktreeId, - parsed.tabId, - target.paneKey, - target.executionHostId - ) + activateRetainedAgentRow(target.worktreeId, tabId, target.paneKey, target.executionHostId) } else { - activateWorktreeAgentPane( - target.worktreeId, - parsed.tabId, - target.paneKey, - target.executionHostId - ) + activateWorktreeAgentPane(target.worktreeId, tabId, target.paneKey, target.executionHostId) } } diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/indentation.ts b/src/renderer/src/components/sidebar/worktree-list/rows/indentation.ts index 4364a8a45d3..44ae469b87d 100644 --- a/src/renderer/src/components/sidebar/worktree-list/rows/indentation.ts +++ b/src/renderer/src/components/sidebar/worktree-list/rows/indentation.ts @@ -16,7 +16,7 @@ export const LINEAGE_IMMEDIATE_PARENT_STEP = export const LINEAGE_NESTED_ROW_SURFACE_INSET = 0 export const LINEAGE_CHILDREN_INLINE_OFFSET = LINEAGE_IMMEDIATE_PARENT_STEP - WORKTREE_CARD_SURFACE_MARGIN - FLUSH_CARD_MIN_CONTENT_INSET -// 2.0 cards lead with a 22px tab icon, so their children shift to start on the parent's title column. +// 2.0 children start on the parent's title column after its leading icon and gap. export const SLOT_LINEAGE_TITLE_COLUMN_SHIFT = 7 // Why: header icon+gap (22) vs. card margin+border+legacy lane+gap (23) minus pullback lands the title on the header label. const GROUPED_CARD_TITLE_HEADER_LABEL_OFFSET = 3 diff --git a/src/renderer/src/components/source-control/source-control-variable-description.ts b/src/renderer/src/components/source-control/source-control-variable-description.ts index c6f09ec0898..21635c26898 100644 --- a/src/renderer/src/components/source-control/source-control-variable-description.ts +++ b/src/renderer/src/components/source-control/source-control-variable-description.ts @@ -64,6 +64,11 @@ export function sourceControlVariableDescription( 'sourceControl.variables.assistantMessage', 'The initial agent response, when Orca has one available.' ) + case 'workspaces': + return t( + 'sourceControl.variables.workspaces', + 'The numbered workspace names in this icon batch. Each reply SVG must start with for its number.' + ) case 'linkedIssue': return t( 'sourceControl.variables.linkedIssue', diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 805610d2680..5066edbc5fb 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2613,6 +2613,7 @@ "agentCannotGenerateChatName": "Agent \"{{agent}}\" does not support chat names. Supported agents: {{agents}}, or Custom command.", "agentCannotGenerateCommitMessage": "Agent \"{{agent}}\" does not support Source Control AI commit messages. Supported agents: {{agents}}, or Custom command.", "agentCannotGeneratePullRequest": "Agent \"{{agent}}\" does not support Source Control AI pull request details. Supported agents: {{agents}}, or Custom command.", + "agentCannotGenerateWorkspaceIcon": "Agent \"{{agent}}\" does not support workspace icons. Supported agents: {{agents}}, or Custom command.", "agentCouldNotStart": "{{label}} could not be started. Check the agent command in Settings and try again.", "agentFailedToStart": "{{label}} failed to start. Check the agent command in Settings and try again.", "agentReportedError": "{{label}} reported an error.", @@ -2629,6 +2630,7 @@ "commandTemplateEmptyChatName": "Command template is empty for chat names.", "commandTemplateEmptyCommitMessage": "Command template is empty for commit messages.", "commandTemplateEmptyPullRequest": "Command template is empty for pull request details.", + "commandTemplateEmptyWorkspaceIcon": "Command template is empty for workspace icons.", "commitMessageUnparseable": "Generated commit message could not be parsed.", "emptyCommitMessage": "{{label}} returned an empty message.", "emptyCommitMessageWithDetail": "{{label}} returned an empty message. CLI output: {{detail}}", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index bf01c6113cd..77279bf3346 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -283,6 +283,14 @@ "prDraft": "{{count}} draft", "prMerged": "{{count}} merged", "prClosed": "{{count}} closed" + }, + "cardIcon": { + "aiFailed": "Could not generate workspace icons", + "tabs": "Tabs", + "pr": "PR", + "none": "None", + "ai": "AI-generated", + "label": "Icon" } }, "app": { @@ -1454,6 +1462,25 @@ "voice": { "cleanup": { "recipeDescription": "Clean up dictated text before it lands." + + } + }, + "experimental": {}, + "git": { + "workspaceIcons": { + "recipe": "Workspace icon", + "saveFailed": "Could not save workspace icon settings.", + "regenerateFailed": "Could not clear the saved icons.", + "regenerate": "Regenerate icons", + "defaultAgent": "Default (Claude Haiku)", + "recipeDescription": "Draw the AI-generated workspace card icons, many workspaces per call.", + "title": "AI workspace icons", + "description": "Agent, model, and prompt for the AI-generated card icon. Orca draws up to 16 icons per agent call and keeps each one by workspace name.", + "searchIcon": "icon", + "searchCard": "workspace card", + "searchModel": "model", + "searchTemplate": "command template", + "searchRegenerate": "regenerate" } } }, @@ -22833,7 +22860,8 @@ "patch": "The branch diff against the base branch used for PR-details generation.", "firstPrompt": "The first user request for this workspace or chat.", "assistantMessage": "The initial agent response, when Orca has one available.", - "linkedIssue": "The GitHub issue number linked to this workspace. Empty when no GitHub issue is linked (including GitLab-linked workspaces). Prefer instructional templates: a bare \"Fixes #{linkedIssue}\" becomes \"Fixes #\" when unlinked." + "linkedIssue": "The GitHub issue number linked to this workspace. Empty when no GitHub issue is linked (including GitLab-linked workspaces). Prefer instructional templates: a bare \"Fixes #{linkedIssue}\" becomes \"Fixes #\" when unlinked.", + "workspaces": "The numbered workspace names in this icon batch. Each reply SVG must start with for its number." }, "model": { "configDefault": "Config default", @@ -22882,6 +22910,7 @@ "commandTemplateEmptyPullRequest": "Command template is empty for pull request details.", "commandTemplateEmptyBranchName": "Command template is empty for branch names.", "commandTemplateEmptyChatName": "Command template is empty for chat names.", + "commandTemplateEmptyWorkspaceIcon": "Command template is empty for workspace icons.", "sourceControlAgentUnsupported": "Choose a supported Source Control AI agent for this action in Settings -> Git -> Source Control AI. Supported agents: {{agents}}, or Custom command.", "chatNameAgentUnsupported": "Choose a supported chat name agent for this action in Settings -> Chat -> Chat names. Supported agents: {{agents}}, or Custom command.", "sourceControlCustomCommandEmpty": "Custom command is empty. Add one in Settings -> Git -> Source Control AI.", @@ -22890,6 +22919,7 @@ "agentCannotGeneratePullRequest": "Agent \"{{agent}}\" does not support Source Control AI pull request details. Supported agents: {{agents}}, or Custom command.", "agentCannotGenerateBranchName": "Agent \"{{agent}}\" does not support Source Control AI branch names. Supported agents: {{agents}}, or Custom command.", "agentCannotGenerateChatName": "Agent \"{{agent}}\" does not support chat names. Supported agents: {{agents}}, or Custom command.", + "agentCannotGenerateWorkspaceIcon": "Agent \"{{agent}}\" does not support workspace icons. Supported agents: {{agents}}, or Custom command.", "noModelForAgent": "No model is available for {{label}}.", "planCustomCommandEmpty": "Custom command is empty. Add one in Settings → Git → AI Commit Messages.", "planAgentUnsupported": "Agent \"{{agent}}\" does not support AI commit messages.", diff --git a/src/renderer/src/lib/text-action-recipe-error-copy.ts b/src/renderer/src/lib/text-action-recipe-error-copy.ts new file mode 100644 index 00000000000..ba4ac1e81e9 --- /dev/null +++ b/src/renderer/src/lib/text-action-recipe-error-copy.ts @@ -0,0 +1,116 @@ +import { translate } from '@/i18n/i18n' +import { + TEXT_GENERATION_FAILURE_COPY as EN, + type TextGenerationFailureCode +} from '../../../shared/text-generation-failure-copy' + +type CopyValues = Record + +type TextActionRecipeFailureCode = Extract< + TextGenerationFailureCode, + | 'commandTemplateEmptyCommitMessage' + | 'commandTemplateEmptyPullRequest' + | 'commandTemplateEmptyBranchName' + | 'commandTemplateEmptyChatName' + | 'commandTemplateEmptyWorkspaceIcon' + | 'sourceControlAgentUnsupported' + | 'chatNameAgentUnsupported' + | 'sourceControlCustomCommandEmpty' + | 'chatNameCustomCommandEmpty' + | 'agentCannotGenerateCommitMessage' + | 'agentCannotGeneratePullRequest' + | 'agentCannotGenerateBranchName' + | 'agentCannotGenerateChatName' + | 'agentCannotGenerateWorkspaceIcon' +> + +// Recipe misconfiguration copy, merged into the text-generation copy table. +export const TEXT_ACTION_RECIPE_ERROR_COPY: Record< + TextActionRecipeFailureCode, + (values: CopyValues) => string +> = { + commandTemplateEmptyCommitMessage: (values) => + translate( + 'sourceControl.textGeneration.errors.commandTemplateEmptyCommitMessage', + EN.commandTemplateEmptyCommitMessage, + values + ), + commandTemplateEmptyPullRequest: (values) => + translate( + 'sourceControl.textGeneration.errors.commandTemplateEmptyPullRequest', + EN.commandTemplateEmptyPullRequest, + values + ), + commandTemplateEmptyBranchName: (values) => + translate( + 'sourceControl.textGeneration.errors.commandTemplateEmptyBranchName', + EN.commandTemplateEmptyBranchName, + values + ), + commandTemplateEmptyChatName: (values) => + translate( + 'sourceControl.textGeneration.errors.commandTemplateEmptyChatName', + EN.commandTemplateEmptyChatName, + values + ), + commandTemplateEmptyWorkspaceIcon: (values) => + translate( + 'sourceControl.textGeneration.errors.commandTemplateEmptyWorkspaceIcon', + EN.commandTemplateEmptyWorkspaceIcon, + values + ), + sourceControlAgentUnsupported: (values) => + translate( + 'sourceControl.textGeneration.errors.sourceControlAgentUnsupported', + EN.sourceControlAgentUnsupported, + values + ), + chatNameAgentUnsupported: (values) => + translate( + 'sourceControl.textGeneration.errors.chatNameAgentUnsupported', + EN.chatNameAgentUnsupported, + values + ), + sourceControlCustomCommandEmpty: (values) => + translate( + 'sourceControl.textGeneration.errors.sourceControlCustomCommandEmpty', + EN.sourceControlCustomCommandEmpty, + values + ), + chatNameCustomCommandEmpty: (values) => + translate( + 'sourceControl.textGeneration.errors.chatNameCustomCommandEmpty', + EN.chatNameCustomCommandEmpty, + values + ), + agentCannotGenerateCommitMessage: (values) => + translate( + 'sourceControl.textGeneration.errors.agentCannotGenerateCommitMessage', + EN.agentCannotGenerateCommitMessage, + values + ), + agentCannotGeneratePullRequest: (values) => + translate( + 'sourceControl.textGeneration.errors.agentCannotGeneratePullRequest', + EN.agentCannotGeneratePullRequest, + values + ), + agentCannotGenerateBranchName: (values) => + translate( + 'sourceControl.textGeneration.errors.agentCannotGenerateBranchName', + EN.agentCannotGenerateBranchName, + values + ), + agentCannotGenerateChatName: (values) => + translate( + 'sourceControl.textGeneration.errors.agentCannotGenerateChatName', + EN.agentCannotGenerateChatName, + values + ), + agentCannotGenerateWorkspaceIcon: (values) => + translate( + 'sourceControl.textGeneration.errors.agentCannotGenerateWorkspaceIcon', + EN.agentCannotGenerateWorkspaceIcon, + values + ) +} diff --git a/src/renderer/src/lib/text-generation/text-generation-error-message-catalog.ts b/src/renderer/src/lib/text-generation/text-generation-error-message-catalog.ts index 3be2f21497e..d9644b6b3e4 100644 --- a/src/renderer/src/lib/text-generation/text-generation-error-message-catalog.ts +++ b/src/renderer/src/lib/text-generation/text-generation-error-message-catalog.ts @@ -5,6 +5,7 @@ import { } from '../../../../shared/text-generation-failure-copy' import { AGENT_OUTPUT_ERROR_COPY } from './agent-output-error-copy' import { DICTATION_CLEANUP_ERROR_COPY } from './dictation-cleanup-error-copy' +import { TEXT_ACTION_RECIPE_ERROR_COPY } from '../text-action-recipe-error-copy' export type TextGenerationCopyValues = Record @@ -180,78 +181,6 @@ export const TEXT_GENERATION_ERROR_COPY: Record< TEXT_GENERATION_FAILURE_COPY.noBranchChanges, values ), - commandTemplateEmptyCommitMessage: (values) => - translate( - 'sourceControl.textGeneration.errors.commandTemplateEmptyCommitMessage', - TEXT_GENERATION_FAILURE_COPY.commandTemplateEmptyCommitMessage, - values - ), - commandTemplateEmptyPullRequest: (values) => - translate( - 'sourceControl.textGeneration.errors.commandTemplateEmptyPullRequest', - TEXT_GENERATION_FAILURE_COPY.commandTemplateEmptyPullRequest, - values - ), - commandTemplateEmptyBranchName: (values) => - translate( - 'sourceControl.textGeneration.errors.commandTemplateEmptyBranchName', - TEXT_GENERATION_FAILURE_COPY.commandTemplateEmptyBranchName, - values - ), - commandTemplateEmptyChatName: (values) => - translate( - 'sourceControl.textGeneration.errors.commandTemplateEmptyChatName', - TEXT_GENERATION_FAILURE_COPY.commandTemplateEmptyChatName, - values - ), - sourceControlAgentUnsupported: (values) => - translate( - 'sourceControl.textGeneration.errors.sourceControlAgentUnsupported', - TEXT_GENERATION_FAILURE_COPY.sourceControlAgentUnsupported, - values - ), - chatNameAgentUnsupported: (values) => - translate( - 'sourceControl.textGeneration.errors.chatNameAgentUnsupported', - TEXT_GENERATION_FAILURE_COPY.chatNameAgentUnsupported, - values - ), - sourceControlCustomCommandEmpty: (values) => - translate( - 'sourceControl.textGeneration.errors.sourceControlCustomCommandEmpty', - TEXT_GENERATION_FAILURE_COPY.sourceControlCustomCommandEmpty, - values - ), - chatNameCustomCommandEmpty: (values) => - translate( - 'sourceControl.textGeneration.errors.chatNameCustomCommandEmpty', - TEXT_GENERATION_FAILURE_COPY.chatNameCustomCommandEmpty, - values - ), - agentCannotGenerateCommitMessage: (values) => - translate( - 'sourceControl.textGeneration.errors.agentCannotGenerateCommitMessage', - TEXT_GENERATION_FAILURE_COPY.agentCannotGenerateCommitMessage, - values - ), - agentCannotGeneratePullRequest: (values) => - translate( - 'sourceControl.textGeneration.errors.agentCannotGeneratePullRequest', - TEXT_GENERATION_FAILURE_COPY.agentCannotGeneratePullRequest, - values - ), - agentCannotGenerateBranchName: (values) => - translate( - 'sourceControl.textGeneration.errors.agentCannotGenerateBranchName', - TEXT_GENERATION_FAILURE_COPY.agentCannotGenerateBranchName, - values - ), - agentCannotGenerateChatName: (values) => - translate( - 'sourceControl.textGeneration.errors.agentCannotGenerateChatName', - TEXT_GENERATION_FAILURE_COPY.agentCannotGenerateChatName, - values - ), noModelForAgent: (values) => translate( 'sourceControl.textGeneration.errors.noModelForAgent', @@ -288,6 +217,7 @@ export const TEXT_GENERATION_ERROR_COPY: Record< TEXT_GENERATION_FAILURE_COPY.planThinkingLevelInvalid, values ), + ...TEXT_ACTION_RECIPE_ERROR_COPY, ...AGENT_OUTPUT_ERROR_COPY, ...DICTATION_CLEANUP_ERROR_COPY } diff --git a/src/renderer/src/store/slices/agent-status/agent-pane-execution-host.ts b/src/renderer/src/store/slices/agent-status/agent-pane-execution-host.ts new file mode 100644 index 00000000000..c815d26958b --- /dev/null +++ b/src/renderer/src/store/slices/agent-status/agent-pane-execution-host.ts @@ -0,0 +1,26 @@ +import type { AppState } from '../../types' +import type { AgentStatusEntry } from '../../../../../shared/agent-status/contracts/agent-status-types' +import { getConnectionExecutionHostId } from '../../../../../shared/execution-host' +import { getPtyExecutionHost } from '../../../../../shared/terminal/pty/lifecycle/terminal-execution-host' +import { parsePaneKey } from '../../../../../shared/workspace-layout/stable-pane-id' +import { getStructuredAgentSessionTabsByPaneKey } from '@/components/native-chat/session/status/structured-agent-session-tabs' + +/** Resolve the same pane owner for sidebar rows and explicit workspace reads. */ +export function getAgentPaneExecutionHost( + state: Pick, + paneKey: string, + entry: AgentStatusEntry | undefined, + fallbackPtyId?: string | null +) { + const tab = getStructuredAgentSessionTabsByPaneKey(state.unifiedTabsByWorktree).get(paneKey) + if (tab?.executionHostId) { + return tab.executionHostId + } + const parsed = parsePaneKey(paneKey) + const ptyId = + (parsed && state.terminalLayoutsByTabId[parsed.tabId]?.ptyIdsByLeafId?.[parsed.leafId]) ?? + entry?.terminalHandle ?? + fallbackPtyId ?? + (parsed ? state.ptyIdsByTabId[parsed.tabId]?.[0] : undefined) + return getPtyExecutionHost(ptyId) ?? getConnectionExecutionHostId(entry?.connectionId) +} diff --git a/src/renderer/src/store/slices/hosted-review/refresh/visible-hosted-review-refresh-targets.ts b/src/renderer/src/store/slices/hosted-review/refresh/visible-hosted-review-refresh-targets.ts index 8c4853d057b..f7bf974efdc 100644 --- a/src/renderer/src/store/slices/hosted-review/refresh/visible-hosted-review-refresh-targets.ts +++ b/src/renderer/src/store/slices/hosted-review/refresh/visible-hosted-review-refresh-targets.ts @@ -256,12 +256,6 @@ export function visibleHostedReviewRefreshInputsChanged( // A WSL host turning ready brings its rows back into the refresh (E9). state.wslHostStatuses !== previous.wslHostStatuses || state.prCache !== previous.prCache || - state.hostedReviewCache !== previous.hostedReviewCache || - state.tabsByWorktree !== previous.tabsByWorktree || - state.ptyIdsByTabId !== previous.ptyIdsByTabId || - state.browserTabsByWorktree !== previous.browserTabsByWorktree || - state.unifiedTabsByWorktree !== previous.unifiedTabsByWorktree || - state.agentStatusByPaneKey !== previous.agentStatusByPaneKey || - state.agentStatusEpoch !== previous.agentStatusEpoch + state.hostedReviewCache !== previous.hostedReviewCache ) } diff --git a/src/renderer/src/store/slices/workspace-attention/workspace-attention-slice.ts b/src/renderer/src/store/slices/workspace-attention/workspace-attention-slice.ts index ab175c5f2ae..c406fb1758c 100644 --- a/src/renderer/src/store/slices/workspace-attention/workspace-attention-slice.ts +++ b/src/renderer/src/store/slices/workspace-attention/workspace-attention-slice.ts @@ -15,7 +15,9 @@ import { settleWorkspaceUnreadFlag, noteWorkspaceRead } from './workspace-unread-flag' -import { workspaceMarkerTabIds } from './workspace-live-markers' +import { workspaceMarkerTabIds, workspaceMarkerTabs, paneKeyTabId } from './workspace-live-markers' +import { getAgentPaneExecutionHost } from '../agent-status/agent-pane-execution-host' +import { resolveAgentStatusWorktreeId } from '../../../../../shared/agent-status/identity/agent-status-worktree-attribution' import { isTabOnScreen } from '@/hooks/agent-auto-ack-targets' import { isWorkspaceNotificationMutedBySidebarFilters } from '@/attention/hidden-workspace-notification-mute' import { getTerminalTabOwnerWorktreeId } from '../../terminals/terminal-tab-owner-index' @@ -152,8 +154,19 @@ export const createWorkspaceAttentionSlice: StateCreator< ...(removed?.paneKeys ?? []), ...workspaceAgentPaneKeys(get(), worktreeId) ]) - if (paneKeys.size > 0) { - get().acknowledgeAgents([...paneKeys], undefined, 'explicit') + const state = get() + const hostPaneKeys = [...paneKeys].filter((paneKey) => { + const retained = state.retainedAgentsByPaneKey[paneKey] + const entry = state.agentStatusByPaneKey[paneKey] ?? retained?.entry + const tabId = paneKeyTabId(paneKey) + const tab = state.tabsByWorktree[worktreeId]?.find((tab) => tab.id === tabId) + return ( + getAgentPaneExecutionHost(state, paneKey, entry, tab?.ptyId ?? retained?.tab.ptyId) === + executionHostId + ) + }) + if (hostPaneKeys.length > 0) { + get().acknowledgeAgents(hostPaneKeys, undefined, 'explicit') } } } @@ -162,8 +175,17 @@ export const createWorkspaceAttentionSlice: StateCreator< /** Every agent pane, live or retained, that belongs to the workspace. */ function workspaceAgentPaneKeys(state: AppState, worktreeId: string): string[] { const keys: string[] = [] + const tabOwners = new Map() + for (const id of new Set([ + ...Object.keys(state.tabsByWorktree), + ...Object.keys(state.unifiedTabsByWorktree) + ])) { + for (const tab of workspaceMarkerTabs(state, id)) { + tabOwners.set(tab.id, id) + } + } for (const [paneKey, entry] of Object.entries(state.agentStatusByPaneKey)) { - if (entry.worktreeId === worktreeId) { + if (resolveAgentStatusWorktreeId(entry, tabOwners) === worktreeId) { keys.push(paneKey) } } diff --git a/src/renderer/src/store/slices/workspace-attention/workspace-unread-explicit-read.test.ts b/src/renderer/src/store/slices/workspace-attention/workspace-unread-explicit-read.test.ts index 2a7c4d43f45..22c9219519c 100644 --- a/src/renderer/src/store/slices/workspace-attention/workspace-unread-explicit-read.test.ts +++ b/src/renderer/src/store/slices/workspace-attention/workspace-unread-explicit-read.test.ts @@ -13,6 +13,9 @@ import { import { completeAgentTurn, toggleUnreadByUser } from './workspace-unread-characterization-driver' import { isWorkspaceFlagSet } from './workspace-unread-flag' import { selectAgentRowsUnread } from '@/components/sidebar/workspace-card/activity/worktree-card-agent-ack-inputs' +import { makeTab } from '../../terminals/terminal-tab.test-fixture' +import { makeUnifiedTab } from '../tabs/tab-group.test-fixture' +import { structuredAgentSessionPaneKey } from '../../../../../shared/agent-session/view/structured-agent-session-projection' vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn(), dismiss: vi.fn() } @@ -79,4 +82,77 @@ describe('explicit workspace and agent reads', () => { expect(isWorkspaceFlagSet(store.getState(), WS_A)).toBe(true) expect(selectAgentRowsUnread(store.getState(), [pane])).toEqual([false]) }) + + it('reads only the selected host, including retained agents and agents awaiting tab hydration', () => { + const { store } = createAttentionStore({ windowOnScreen: false }) + const local = finishedAgentOffScreen(store) + const entry = store.getState().agentStatusByPaneKey[local.paneKey]! + const sshKey = `ssh-agent:11111111-1111-4111-8111-111111111111` + const runtimeKey = `runtime-agent:11111111-1111-4111-8111-111111111111` + const retainedKey = `retained-agent:11111111-1111-4111-8111-111111111111` + store.setState({ + agentStatusByPaneKey: { + [local.paneKey]: entry, + [sshKey]: { ...entry, paneKey: sshKey, connectionId: 'server' }, + [runtimeKey]: { ...entry, paneKey: runtimeKey, terminalHandle: 'remote:box@@live' } + }, + retainedAgentsByPaneKey: { + [retainedKey]: { + entry: { ...entry, paneKey: retainedKey }, + worktreeId: WS_A, + tab: makeTab({ id: 'retained-agent', worktreeId: WS_A, ptyId: 'remote:box@@retained' }), + agentType: 'claude', + startedAt: 900 + } + } + }) + const panes = [local, { paneKey: sshKey }, { paneKey: runtimeKey }, { paneKey: retainedKey }] + expect(selectAgentRowsUnread(store.getState(), panes)).toEqual([true, true, true, true]) + + store.getState().setWorkspaceUnreadByUser(WS_A, false, 'local') + expect(selectAgentRowsUnread(store.getState(), panes)).toEqual([false, true, true, true]) + store.getState().setWorkspaceUnreadByUser(WS_A, false, 'ssh:server') + expect(selectAgentRowsUnread(store.getState(), panes)).toEqual([false, false, true, true]) + store.getState().setWorkspaceUnreadByUser(WS_A, false, 'runtime:box') + expect(selectAgentRowsUnread(store.getState(), panes)).toEqual([false, false, false, false]) + }) + + it('reads marker-free terminal and structured agents through tab ownership despite missing or stale workspace stamps', () => { + const { store } = createAttentionStore({ windowOnScreen: false }) + const pane = finishedAgentOffScreen(store) + const entry = store.getState().agentStatusByPaneKey[pane.paneKey]! + const stalePane = addLiveTerminalTab(store, WS_A, 'stale-workspace-tab') + const native = makeUnifiedTab({ + id: 'native', + entityId: 'session', + worktreeId: WS_A, + groupId: 'group', + contentType: 'agent-session', + agentSessionAgent: 'claude', + executionHostId: 'runtime:box' + }) + const nativeKey = structuredAgentSessionPaneKey(native.id, native.entityId) + store.setState({ + unreadAgentCompletionPanes: {}, + unifiedTabsByWorktree: { + ...store.getState().unifiedTabsByWorktree, + [WS_A]: [...store.getState().unifiedTabsByWorktree[WS_A]!, native] + }, + agentStatusByPaneKey: { + [pane.paneKey]: { ...entry, worktreeId: undefined }, + [stalePane.paneKey]: { + ...entry, + paneKey: stalePane.paneKey, + worktreeId: 'stale-workspace' + }, + [nativeKey]: { ...entry, paneKey: nativeKey, worktreeId: undefined } + } + }) + const panes = [pane, stalePane, { paneKey: nativeKey }] + expect(selectAgentRowsUnread(store.getState(), panes)).toEqual([true, true, true]) + store.getState().setWorkspaceUnreadByUser(WS_A, false, 'local') + expect(selectAgentRowsUnread(store.getState(), panes)).toEqual([false, false, true]) + store.getState().setWorkspaceUnreadByUser(WS_A, false, 'runtime:box') + expect(selectAgentRowsUnread(store.getState(), panes)).toEqual([false, false, false]) + }) }) diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 43321cd1e69..36d66d4015a 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -108,7 +108,8 @@ function createWebPreloadApi(): Omit, 'settings' | 'plugins' success: false, error: 'AI workspace icons are unavailable in the web client.' }), - cancel: async () => {} + cancel: async () => {}, + clearCache: async () => {} }, hooks: createHooksApi(), stats: { diff --git a/src/shared/source-control/ai/recipes/source-control-ai-action-variables.ts b/src/shared/source-control/ai/recipes/source-control-ai-action-variables.ts index c8bbaa5fbdd..deb5fb1548c 100644 --- a/src/shared/source-control/ai/recipes/source-control-ai-action-variables.ts +++ b/src/shared/source-control/ai/recipes/source-control-ai-action-variables.ts @@ -17,6 +17,7 @@ export const SOURCE_CONTROL_ACTION_VARIABLES: Record for its number.', + example: '1. "fix-login-flow"\n2. "terminal-perf"' + }, linkedIssue: { description: 'The GitHub issue number linked to this workspace. Empty when no GitHub issue is linked (including GitLab-linked workspaces). Prefer instructional templates: a bare "Fixes #{linkedIssue}" becomes "Fixes #" when unlinked.', diff --git a/src/shared/source-control/ai/recipes/source-control-ai-actions.ts b/src/shared/source-control/ai/recipes/source-control-ai-actions.ts index 422b014602b..3b4fabaf14b 100644 --- a/src/shared/source-control/ai/recipes/source-control-ai-actions.ts +++ b/src/shared/source-control/ai/recipes/source-control-ai-actions.ts @@ -18,8 +18,9 @@ import { export type SourceControlTextActionId = 'commitMessage' | 'pullRequest' | 'branchName' /** Text actions outside any repository: they read global settings only, never a repo's overrides. */ -export type GlobalAiTextActionId = 'conversationName' | 'dictationCleanup' +export type GlobalAiTextActionId = 'conversationName' | 'dictationCleanup' | 'workspaceIcon' export type AiTextActionId = SourceControlTextActionId | GlobalAiTextActionId +export type GlobalOnlyAiTextActionId = GlobalAiTextActionId export type SourceControlLaunchActionId = | 'fixCommitFailure' @@ -97,13 +98,19 @@ export const SOURCE_CONTROL_ACTION_IDS = [ export const AI_TEXT_ACTION_IDS = [ ...SOURCE_CONTROL_TEXT_ACTION_IDS, 'conversationName', - 'dictationCleanup' + 'dictationCleanup', + 'workspaceIcon' ] as const satisfies readonly AiTextActionId[] -export function isGlobalAiTextAction(actionId: AiActionId): actionId is GlobalAiTextActionId { - return actionId === 'conversationName' || actionId === 'dictationCleanup' +export function isGlobalAiTextAction(actionId: string): actionId is GlobalAiTextActionId { + return actionId === 'conversationName' || actionId === 'dictationCleanup' || actionId === 'workspaceIcon' } +export const isGlobalOnlyAiTextAction = isGlobalAiTextAction +// Icons are batch work where per-call overhead dominates, so they default to a small tool-light model. +export const WORKSPACE_ICON_DEFAULT_AGENT = 'claude' satisfies TuiAgent +export const WORKSPACE_ICON_DEFAULT_MODEL = 'haiku' + export const AI_ACTION_IDS = [ ...AI_TEXT_ACTION_IDS, ...SOURCE_CONTROL_LAUNCH_ACTION_IDS @@ -134,6 +141,7 @@ export const DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES: Record const AGENT_CANNOT_GENERATE = { @@ -96,7 +102,8 @@ const AGENT_CANNOT_GENERATE = { pullRequest: 'agentCannotGeneratePullRequest', branchName: 'agentCannotGenerateBranchName', conversationName: 'agentCannotGenerateChatName', - dictationCleanup: 'agentCannotGenerateDictationCleanup' + dictationCleanup: 'agentCannotGenerateDictationCleanup', + workspaceIcon: 'agentCannotGenerateWorkspaceIcon' } as const satisfies Record // Each failure names the settings page where the action's agent is chosen. @@ -105,7 +112,8 @@ const AGENT_UNSUPPORTED = { pullRequest: 'sourceControlAgentUnsupported', branchName: 'sourceControlAgentUnsupported', conversationName: 'chatNameAgentUnsupported', - dictationCleanup: 'dictationCleanupAgentUnsupported' + dictationCleanup: 'dictationCleanupAgentUnsupported', + workspaceIcon: 'sourceControlAgentUnsupported' } as const satisfies Record const CUSTOM_COMMAND_EMPTY = { @@ -114,7 +122,8 @@ const CUSTOM_COMMAND_EMPTY = { branchName: 'sourceControlCustomCommandEmpty', conversationName: 'chatNameCustomCommandEmpty', // The custom command is set under Source Control AI for every action that isn't a chat name. - dictationCleanup: 'sourceControlCustomCommandEmpty' + dictationCleanup: 'sourceControlCustomCommandEmpty', + workspaceIcon: 'sourceControlCustomCommandEmpty' } as const satisfies Record function supportedAgentLabels(): string { @@ -150,7 +159,16 @@ export function resolveSourceControlAiForOperation( if (!actionRecipe.commandInputTemplate.trim()) { return resolveFailure(textGenerationFailure(COMMAND_TEMPLATE_EMPTY[input.operation])) } - const preferredAgent = hasActionAgentRecipe(actionRecipe) ? actionRecipe.agentId : source.agentId + // An unpicked icon agent means Orca's cheap default, not the user's general Source Control agent. + const iconDefault = + input.operation === 'workspaceIcon' && + !actionRecipe.agentId && + isTuiAgentEnabled(WORKSPACE_ICON_DEFAULT_AGENT, input.settings.disabledTuiAgents) + const preferredAgent = iconDefault + ? WORKSPACE_ICON_DEFAULT_AGENT + : hasActionAgentRecipe(actionRecipe) + ? actionRecipe.agentId + : source.agentId const agentChoice = resolveCommitMessageAgentChoice( preferredAgent, input.settings.defaultTuiAgent, @@ -214,6 +232,9 @@ export function resolveSourceControlAiForOperation( } const hostKey = input.discoveryHostKey ?? LOCAL_COMMIT_MESSAGE_HOST_KEY const persistedModelId = selectPersistedModelId({ + ...(input.operation === 'workspaceIcon' && resolvedAgent === WORKSPACE_ICON_DEFAULT_AGENT + ? { operationDefaultModelId: WORKSPACE_ICON_DEFAULT_MODEL } + : {}), source, legacy, repoOverrides, diff --git a/src/shared/text-generation-failure-copy.ts b/src/shared/text-generation-failure-copy.ts index a34e4c29f53..b035a8b83ae 100644 --- a/src/shared/text-generation-failure-copy.ts +++ b/src/shared/text-generation-failure-copy.ts @@ -41,6 +41,7 @@ export const TEXT_GENERATION_FAILURE_COPY = { commandTemplateEmptyBranchName: 'Command template is empty for branch names.', commandTemplateEmptyChatName: 'Command template is empty for chat names.', commandTemplateEmptyDictationCleanup: 'Command template is empty for dictation cleanup.', + commandTemplateEmptyWorkspaceIcon: 'Command template is empty for workspace icons.', sourceControlAgentUnsupported: 'Choose a supported Source Control AI agent for this action in Settings -> Git -> Source Control AI. Supported agents: {{agents}}, or Custom command.', chatNameAgentUnsupported: @@ -60,6 +61,8 @@ export const TEXT_GENERATION_FAILURE_COPY = { 'Agent "{{agent}}" does not support chat names. Supported agents: {{agents}}, or Custom command.', agentCannotGenerateDictationCleanup: 'Agent "{{agent}}" does not support dictation cleanup. Supported agents: {{agents}}, or Custom command.', + agentCannotGenerateWorkspaceIcon: + 'Agent "{{agent}}" does not support workspace icons. Supported agents: {{agents}}, or Custom command.', noModelForAgent: 'No model is available for {{label}}.', planCustomCommandEmpty: 'Custom command is empty. Add one in Settings → Git → AI Commit Messages.', diff --git a/src/shared/worktree/worktree-icon-generation.ts b/src/shared/worktree/worktree-icon-generation.ts index 683603e5762..64f81623d52 100644 --- a/src/shared/worktree/worktree-icon-generation.ts +++ b/src/shared/worktree/worktree-icon-generation.ts @@ -9,32 +9,38 @@ export type WorktreeIconGenerationResult = const MAX_NAME_LENGTH = 120 const MAX_SVG_LENGTH = 6000 +// 24 icons took ~49s on Haiku against the 60s generation timeout; 16 took ~28s at the same per-icon cost. +export const MAX_WORKTREE_ICON_BATCH = 16 export function normalizeWorktreeIconName(name: string): string { return name.trim().slice(0, MAX_NAME_LENGTH) } -export function buildWorktreeIconPrompt(name: string): string { - return `Design a tiny app-icon style SVG for a coding workspace named "${normalizeWorktreeIconName(name).replace(/"/g, "'")}". +/** The `{workspaces}` template value: one numbered, quoted name per line. */ +export function formatWorktreeIconBatchList(names: readonly string[]): string { + return names + .map((name, index) => `${index + 1}. "${normalizeWorktreeIconName(name).replace(/"/g, "'")}"`) + .join('\n') +} + +export function buildWorktreeIconBatchPrompt(names: readonly string[]): string { + return `Design tiny app-icon style SVGs for the coding workspaces listed below, one per workspace. +Workspaces: +${formatWorktreeIconBatchList(names)} Rules: -- Output ONLY the raw element, nothing else, no markdown fences. +- Output ONLY the raw elements in list order, nothing else, no markdown fences. +- Start each with with a rich gradient, plus ONE bold simple white glyph (strokes >= 1.3) that represents the name. -- At most 6 shapes. No text, no , no scripts, no external refs, no filters. +- Each renders at 18x15 CSS px inside a rounded tile, so use a full-bleed background with a rich gradient, plus ONE bold simple white glyph (strokes >= 1.3) that represents the name. +- Give every gradient an id that is unique across all icons. +- At most 6 shapes per icon. No text, no , no scripts, no external refs, no filters. This is an icon-only task. Do not edit files, run commands, or use tools.` } const FORBIDDEN = /<(script|foreignObject|image|iframe|use|a)\b|\bon[a-z]+\s*=|javascript:|(?:xlink:)?href\s*=\s*["'](?!#)/i -/** The first well-formed `…` in the agent's reply, or null when it is missing or unsafe. */ -export function extractWorktreeIconSvg(raw: string): string | null { - const start = raw.search(/]/i) - const end = raw.toLowerCase().lastIndexOf('') - if (start < 0 || end < start) { - return null - } - const svg = raw.slice(start, end + ''.length).trim() +function sanitizeSvg(svg: string): string | null { if (svg.length > MAX_SVG_LENGTH || FORBIDDEN.test(svg)) { return null } @@ -42,3 +48,23 @@ export function extractWorktreeIconSvg(raw: string): string | null { ? svg : svg.replace(/^][\s\S]*?<\/svg>/gi) ?? [] + const icons: (string | null)[] = Array.from({ length: count }, () => null) + const unnumbered: string[] = [] + for (const block of blocks) { + const id = /^]*\bid\s*=\s*["']icon-(\d+)["']/i.exec(block)?.[1] + const index = id === undefined ? -1 : Number(id) - 1 + if (index >= 0 && index < count && icons[index] === null) { + icons[index] = sanitizeSvg(block.trim()) + } else { + unnumbered.push(block.trim()) + } + } + if (icons.every((icon) => icon === null) && unnumbered.length === count) { + return unnumbered.map(sanitizeSvg) + } + return icons +}