mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 09:27:29 +00:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
+8
-2
@@ -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)
|
||||
|
||||
|
||||
+3
-1
@@ -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()
|
||||
])
|
||||
|
||||
+4
@@ -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.'
|
||||
|
||||
+171
@@ -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.
|
||||
|
||||
+11
-5
@@ -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,
|
||||
|
||||
+12
-3
@@ -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.
|
||||
|
||||
+10
-4
@@ -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])
|
||||
|
||||
+6
-19
@@ -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,
|
||||
|
||||
+33
-3
@@ -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([])
|
||||
})
|
||||
|
||||
+45
-6
@@ -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
|
||||
}
|
||||
|
||||
+15
-4
@@ -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
@@ -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}}",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+1
-7
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+76
@@ -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])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user