Improve workspace sidebar icons and navigation

- Batch AI icon generation and add settings to customize and regenerate icons.
- Fix agent navigation and unread handling across execution hosts.
- Keep linked reviews visible while loading and reduce unnecessary refresh work.
This commit is contained in:
Jinjing
2026-11-12 23:46:27 -08:00
parent 64a553dba7
commit 2338a9e329
54 changed files with 1590 additions and 526 deletions
+5 -1
View File
@@ -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))
}
@@ -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<typeof resolveTextGenerationParams>[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<AttemptOutcome> {
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<WorktreeIconGenerationResult[]> {
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.' }
})
}
@@ -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<string, string> | null = null
export function loadCache(file: string): Map<string, string> {
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<string, string>): 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.
}
}
@@ -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: [
'<svg id="icon-2" viewBox="0 0 18 15"><path d="M2 2h2"/></svg>',
'<svg id="icon-1" viewBox="0 0 18 15"><path d="M1 1h1"/></svg>'
].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') })
})
@@ -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<typeof resolveTextGenerationParams>[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<string, string> | null = null
type IconRequest = {
host: WorktreeIconGenerationHost
name: string
controller: AbortController
consumers: number
settled: boolean
result: ReturnType<typeof Promise.withResolvers<WorktreeIconGenerationResult>>
}
type IconBatch = { members: IconRequest[]; controller: AbortController }
const inFlight = new Map<string, IconRequest>()
const queued = new Set<IconRequest>()
let active: IconRequest | undefined
const canceledResult = (): WorktreeIconGenerationResult => ({
success: false,
canceled: true,
error: 'Generation canceled.'
})
let active: IconBatch | undefined
let collectTimer: ReturnType<typeof setTimeout> | undefined
function loadCache(file: string): Map<string, string> {
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<WorktreeIconGenerationResult> {
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<WorktreeIconGenerationResult> {
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<WorktreeIconGenerationResult>()
@@ -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)
}
+3 -1
View File
@@ -8,9 +8,11 @@ import type {
export type WorktreeIconsApi = {
generate: (args: WorktreeIconGenerationRequest) => Promise<WorktreeIconGenerationResult>
cancel: (args: WorktreeIconGenerationCancellation) => Promise<void>
clearCache: () => Promise<void>
}
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')
}
@@ -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<typeof getVisibleHostedReviewRefreshTargets> = []
let referenceTargets: ReturnType<typeof selectReferences> = []
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 () => {
@@ -182,11 +182,17 @@ export function getStructuredAgentSessionTabs(
return list
}
const emptyPaneTabs: ReadonlyMap<string, StructuredTab> = new Map()
const paneTabs = new WeakMap<readonly StructuredTab[], ReadonlyMap<string, StructuredTab>>()
export function getStructuredAgentSessionTabsByPaneKey(
tabs: TabsByWorktree
tabs: TabsByWorktree | Tab[] | undefined
): ReadonlyMap<string, StructuredTab> {
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
@@ -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<ActionRecipeDraftValue>(
() => ({
commandInputTemplate:
recipe?.commandInputTemplate ??
DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES.conversationName,
agentArgs: recipe?.agentArgs ?? '',
modelChoice: persistedModelChoice
}),
[recipe, persistedModelChoice]
)
const [draft, setDraft] = useState<ActionRecipeDraftValue | null>(null)
const [customCommandDraft, setCustomCommandDraft] = useState<string | null>(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<void> => {
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<void> => {
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()}
/>
</div>
@@ -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()) ? (
<WorkspaceIconSetting
key={`workspace-icon-${branchPromptDiscardSignal ?? 0}`}
settings={settings}
writeSourceControlAiSettings={writeSourceControlAiSettings}
onDirtyChange={reportIconDirty}
forceVisible={hasUnsavedBranchPromptChanges}
/>
) : null
].filter(Boolean)
@@ -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<ActionId extends AiActionId>({
@@ -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<void>
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<void> => {
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 (
<SearchableSetting
{...searchEntry}
forceVisible={forceVisible || dirty}
className="space-y-3 py-3"
>
<div className="flex items-start justify-between gap-4">
<div className="space-y-0.5">
<Label>{searchEntry.title}</Label>
<p className="text-xs text-muted-foreground">{searchEntry.description}</p>
</div>
<Button
type="button"
variant="secondary"
size="xs"
disabled={regenerating}
onClick={() => void regenerate()}
>
{t('settings.git.workspaceIcons.regenerate', 'Regenerate icons')}
</Button>
</div>
{recipe?.agentId === CUSTOM_AGENT_ID || customCommandDirty ? (
<CustomAgentCommandField
id="workspace-icon-custom-command"
value={customCommand}
onChange={updateCustomCommandDraft}
/>
) : null}
<SourceControlActionRecipeRow
actionId="workspaceIcon"
source={config}
onTextModelChoiceChange={(_id, modelChoice, agentArgs, modelEdits) =>
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()}
/>
<span ref={clearDirtyOnUnmount} />
</SearchableSetting>
)
}
@@ -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()
])
@@ -31,6 +31,10 @@ export const getActionDescriptions = createLocalizedCatalog((): Record<AiActionI
'settings.voice.cleanup.recipeDescription',
'Clean up dictated text before it lands.'
),
workspaceIcon: translate(
'settings.git.workspaceIcons.recipeDescription',
'Draw the AI-generated workspace card icons, many workspaces per call.'
),
commitMessage: translate(
'auto.components.settings.source.control.action.recipe.options.commitMessage',
'Generate the commit message from staged changes.'
@@ -0,0 +1,171 @@
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'
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 {
DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES,
setSourceControlActionDefault,
type GlobalOnlyAiTextActionId
} 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 { replayGlobalTextModelEdits } from './text-actions/source-control-text-action-model-replay'
import type { ActionRecipeDraftValue } from './source-control-ai-action-recipe-draft'
/** Draft, save, and dirty tracking for a recipe card that only global settings own. */
export function useGlobalTextActionRecipeDraft({
actionId,
settings,
writeSourceControlAiSettings,
onDirtyChange,
onSaveError
}: {
actionId: GlobalOnlyAiTextActionId
settings: GlobalSettings
writeSourceControlAiSettings: (patch: SourceControlAiSettingsPatch) => Promise<void>
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<ActionRecipeDraftValue>(
() => ({
commandInputTemplate:
recipe?.commandInputTemplate ?? DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES[actionId],
agentArgs: recipe?.agentArgs ?? '',
modelChoice: persistedModelChoice
}),
[actionId, recipe, persistedModelChoice]
)
const [draft, setDraft] = useState<ActionRecipeDraftValue | null>(null)
const [customCommandDraft, setCustomCommandDraft] = useState<string | null>(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<void> => {
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<void> => {
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
}
}
@@ -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')
]
}))
@@ -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,
@@ -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',
@@ -214,6 +214,7 @@ export function WorktreeCardSlotContent({
const agentLines = showLines ? (
<WorktreeCardSlotAgentLines
worktreeId={worktree.id}
executionHostId={worktree.hostId}
agents={agents}
signals={signals}
now={now}
@@ -268,10 +269,10 @@ export function WorktreeCardSlotContent({
<div
className="group/worktree-card flex min-w-0 flex-col"
data-worktree-card-hover-trigger=""
// The chevron is a small target; a plain click anywhere on the header toggles too.
onClick={(event) => {
if (
canOpen &&
!open &&
!pickingSendTarget &&
!event.defaultPrevented &&
event.detail <= 1 &&
@@ -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))
@@ -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<void> {
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.
@@ -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,
@@ -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.
@@ -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])
@@ -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,
@@ -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([])
})
@@ -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<typeof workspaceReferenceWorkspaces> = []
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<typeof workspaceReferenceWorkspaces>
): VisibleHostedReviewRefreshTarget[] {
const targets = new Map<string, VisibleHostedReviewRefreshTarget>()
for (const { worktree, repo, candidate, selected } of getVisibleHostedReviewWorkspaces(
state,
options
)) {
for (const { worktree, repo, candidate, selected } of workspaces) {
if (selectIsSleepingWorktree(state, worktree.id)) {
continue
}
@@ -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}
</div>
@@ -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<string, AgentStatusEntry[]
return cachedEntriesByTabId
}
/** Only this card's inputs invalidate its ranking; epoch ticks matter when freshness changes. */
export function worktreeLeadingTabOrderInputs(state: AppState, worktreeId: string): unknown[] {
const tabs = state.unifiedTabsByWorktree[worktreeId]
const smart = state.sortBy === 'smart'
const inputs: unknown[] = [
tabs,
state.tabsByWorktree[worktreeId],
state.sortBy,
smart
? getIndexedWorktreesById(selectWorktreesByRepo(state), worktreeId)[0]?.lastActivityAt
: undefined
]
const byTabId = entriesByTabId(state)
const now = Date.now()
const addEntries = (entries: readonly AgentStatusEntry[]): void => {
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
@@ -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)
}
@@ -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}
</span>
))}
{own && !own.state ? <ReviewIcon review={own} className="size-3 shrink-0" /> : null}
</>
)
const buttonClass =
@@ -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)
})
@@ -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<HostedReviewState, PrSummary>
export type PrStates = Record<ReviewRollupState, PrSummary>
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,
@@ -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(
<TooltipProvider>
<WorktreeCardSlotAgentLines
worktreeId={worktree.id}
executionHostId="local"
agents={agents}
signals={buildSlotAgentSignals(agents, [])}
now={now}
offline={false}
cacheTimersActive={false}
/>
</TooltipProvider>
)
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'
)
})
@@ -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 }
@@ -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++) {
@@ -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)
}
}
@@ -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
@@ -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 <svg id="icon-N"> for its number.'
)
case 'linkedIssue':
return t(
'sourceControl.variables.linkedIssue',
+2
View File
@@ -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}}",
+31 -1
View File
@@ -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 <svg id=\"icon-N\"> 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.",
@@ -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<string, string | number>
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
)
}
@@ -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<string, string | number>
@@ -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
}
@@ -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<AppState, 'unifiedTabsByWorktree' | 'terminalLayoutsByTabId' | 'ptyIdsByTabId'>,
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)
}
@@ -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
)
}
@@ -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<string, string>()
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)
}
}
@@ -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])
})
})
+2 -1
View File
@@ -108,7 +108,8 @@ function createWebPreloadApi(): Omit<Partial<PreloadApi>, 'settings' | 'plugins'
success: false,
error: 'AI workspace icons are unavailable in the web client.'
}),
cancel: async () => {}
cancel: async () => {},
clearCache: async () => {}
},
hooks: createHooksApi(),
stats: {
@@ -17,6 +17,7 @@ export const SOURCE_CONTROL_ACTION_VARIABLES: Record<AiActionId, SourceControlAc
branchName: ['basePrompt', 'firstPrompt', 'assistantMessage'],
conversationName: ['basePrompt', 'firstPrompt'],
dictationCleanup: ['basePrompt'],
workspaceIcon: ['basePrompt', 'workspaces'],
fixCommitFailure: ['basePrompt'],
fixPushFailure: ['basePrompt'],
fixChecks: ['basePrompt'],
@@ -81,6 +82,11 @@ export const SOURCE_CONTROL_ACTION_VARIABLE_INFO = {
description: 'The initial agent response, when Orca has one available.',
example: 'I will inspect the failing check, patch the issue, and run tests.'
},
workspaces: {
description:
'The numbered workspace names in this icon batch. Each reply SVG must start with <svg id="icon-N"> 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.',
@@ -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<AiActionId,
branchName: '{basePrompt}',
conversationName: '{basePrompt}',
dictationCleanup: '{basePrompt}',
workspaceIcon: '{basePrompt}',
fixCommitFailure: '{basePrompt}',
fixPushFailure: '{basePrompt}',
fixChecks: '{basePrompt}',
@@ -169,6 +169,8 @@ export function selectPersistedModelId(args: {
hostKey: string
agentId: TuiAgent
defaultModelId: string
/** Outranks shared picks but not this operation's own pick. */
operationDefaultModelId?: string
}): string {
const { source, legacy, repoOverrides, operation, hostKey, agentId, defaultModelId } = args
return (
@@ -185,6 +187,7 @@ export function selectPersistedModelId(args: {
agentId
) ??
(operation === 'dictationCleanup' ? DICTATION_CLEANUP_MODEL_BY_AGENT[agentId] : undefined) ??
args.operationDefaultModelId ??
readSourceControlAiModelChoiceForHost(
{
selectedModelByAgent: source.selectedModelByAgent,
@@ -1,4 +1,9 @@
import { isGlobalAiTextAction } from './recipes/source-control-ai-actions'
import {
WORKSPACE_ICON_DEFAULT_AGENT,
WORKSPACE_ICON_DEFAULT_MODEL
} from './recipes/source-control-ai-actions'
import { isTuiAgentEnabled } from '../../agent-launch/catalog/tui-agent-selection'
import {
CUSTOM_AGENT_ID,
getCommitMessageAgentSpec,
@@ -88,7 +93,8 @@ const COMMAND_TEMPLATE_EMPTY = {
pullRequest: 'commandTemplateEmptyPullRequest',
branchName: 'commandTemplateEmptyBranchName',
conversationName: 'commandTemplateEmptyChatName',
dictationCleanup: 'commandTemplateEmptyDictationCleanup'
dictationCleanup: 'commandTemplateEmptyDictationCleanup',
workspaceIcon: 'commandTemplateEmptyWorkspaceIcon'
} as const satisfies Record<AiTextOperation, TextGenerationFailureCode>
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<AiTextOperation, TextGenerationFailureCode>
// 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<AiTextOperation, TextGenerationFailureCode>
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<AiTextOperation, TextGenerationFailureCode>
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,
@@ -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.',
+39 -13
View File
@@ -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 <svg> element, nothing else, no markdown fences.
- Output ONLY the raw <svg> elements in list order, nothing else, no markdown fences.
- Start each with <svg id="icon-N" where N is the workspace number.
- viewBox="0 0 18 15", no width/height attributes.
- It renders at 18x15 CSS px inside a rounded tile, so use a full-bleed background <rect width="18" height="15" rx="4"/> with a rich gradient, plus ONE bold simple white glyph (strokes >= 1.3) that represents the name.
- At most 6 shapes. No text, no <image>, no scripts, no external refs, no filters.
- Each renders at 18x15 CSS px inside a rounded tile, so use a full-bleed background <rect width="18" height="15" rx="4"/> 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 <image>, 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 `<svg>…</svg>` in the agent's reply, or null when it is missing or unsafe. */
export function extractWorktreeIconSvg(raw: string): string | null {
const start = raw.search(/<svg[\s>]/i)
const end = raw.toLowerCase().lastIndexOf('</svg>')
if (start < 0 || end < start) {
return null
}
const svg = raw.slice(start, end + '</svg>'.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(/^<svg/i, '<svg xmlns="http://www.w3.org/2000/svg"')
}
/** One safe SVG per requested name, matched by `id="icon-N"`, else by order when every icon came back. */
export function extractWorktreeIconSvgs(raw: string, count: number): (string | null)[] {
const blocks = raw.match(/<svg[\s>][\s\S]*?<\/svg>/gi) ?? []
const icons: (string | null)[] = Array.from({ length: count }, () => null)
const unnumbered: string[] = []
for (const block of blocks) {
const id = /^<svg\b[^>]*\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
}