diff --git a/kun/src/adapters/tool/delegation-tool-provider.ts b/kun/src/adapters/tool/delegation-tool-provider.ts index f4000ed3b..0a4ff3083 100644 --- a/kun/src/adapters/tool/delegation-tool-provider.ts +++ b/kun/src/adapters/tool/delegation-tool-provider.ts @@ -36,7 +36,7 @@ export function buildDelegationToolProviders(runtime: DelegationRuntime | undefi additionalProperties: false }, policy: 'auto', - execute: async (args, context) => { + execute: async (args, context, onUpdate) => { const prompt = typeof args.prompt === 'string' ? args.prompt.trim() : '' if (!prompt) return { output: { error: 'prompt is required' }, isError: true } const record = await runtime.runChild({ @@ -48,6 +48,15 @@ export function buildDelegationToolProviders(runtime: DelegationRuntime | undefi ...(typeof args.model === 'string' ? { model: args.model } : {}), ...(typeof args.profile === 'string' ? { profile: args.profile } : {}), ...(args.detach === true ? { detach: true } : {}), + // Emit a partial result the moment the child id exists, so the GUI + // can offer "open session" (and stream the child live) while the + // child is still running — not only after it completes. + onStart: (childId, profile) => { + void onUpdate?.({ + output: { childId, status: 'running', ...(profile ? { profile } : {}) }, + isError: false + }) + }, signal: context.abortSignal }) return { diff --git a/kun/src/delegation/builtin-profiles.ts b/kun/src/delegation/builtin-profiles.ts index 8d34b789e..3d67fa88e 100644 --- a/kun/src/delegation/builtin-profiles.ts +++ b/kun/src/delegation/builtin-profiles.ts @@ -114,5 +114,10 @@ export function mergeBuiltinSubagentProfiles( const override = config.profiles[id] profiles[id] = override ? { ...builtin, ...override } : builtin } - return { ...config, profiles } + // Default a child with no explicit `profile` to the built-in `general` + // profile (always present after the merge). Without this, an omitted profile + // resolves to `undefined`, so the run carries no profile id — the GUI then + // can't label the subagent and falls back to a generic name. + const defaultProfile = config.defaultProfile ?? 'general' + return { ...config, profiles, defaultProfile } } diff --git a/kun/src/delegation/child-agent-executor.ts b/kun/src/delegation/child-agent-executor.ts index e07ffe2d9..95e9c3ad5 100644 --- a/kun/src/delegation/child-agent-executor.ts +++ b/kun/src/delegation/child-agent-executor.ts @@ -18,6 +18,8 @@ import type { TokenEconomyConfig } from '../loop/token-economy.js' import type { MemoryStore } from '../memory/memory-store.js' import type { ModelClient } from '../ports/model-client.js' import { RandomIdGenerator } from '../ports/id-generator.js' +import type { SessionStore } from '../ports/session-store.js' +import type { ThreadStore } from '../ports/thread-store.js' import type { ToolHost } from '../ports/tool-host.js' import type { SkillRuntime } from '../skills/skill-runtime.js' import { RuntimeEventRecorder } from '../services/runtime-event-recorder.js' @@ -41,14 +43,42 @@ export type ChildAgentExecutorOptions = { modelCapabilities?: (model: string) => ModelCapabilityMetadata skillRuntime?: SkillRuntime memoryStore?: MemoryStore + /** + * Persistence wiring. When the main runtime's stores + event recorder are + * supplied, the child runs as a persisted `relation: 'side'` thread on the + * shared event bus: its full session (reasoning, tool calls, results) is + * queryable via `getThreadDetail(childId)` and streams live to UI + * subscribers. The thread is hidden from the default thread list (the store + * filters `side`). When omitted (e.g. in unit tests) the child falls back to + * throwaway in-memory stores, preserving full isolation. + */ + sessionStore?: SessionStore + threadStore?: ThreadStore + events?: RuntimeEventRecorder } export function createChildAgentExecutor(options: ChildAgentExecutorOptions): ChildRunExecutor { return async (input) => { const nowIso = options.nowIso ?? (() => new Date().toISOString()) - const eventBus = new InMemoryEventBus() - const sessionStore = new InMemorySessionStore() - const threadStore = new InMemoryThreadStore() + // Persist into the main runtime's stores + event bus when supplied, so the + // child session is queryable and streams live; otherwise stay isolated in + // throwaway in-memory stores (preserves test behavior). The recorder is + // shared too — events persist-before-publish to the same bus, and seq + // allocation is per-thread (childId), so child events never bleed into the + // parent thread's stream. + const sessionStore: SessionStore = options.sessionStore ?? new InMemorySessionStore() + const threadStore: ThreadStore = options.threadStore ?? new InMemoryThreadStore() + const events = + options.events ?? + (() => { + const eventBus = new InMemoryEventBus() + return new RuntimeEventRecorder({ + eventBus, + sessionStore, + allocateSeq: (threadId) => eventBus.allocateSeq(threadId), + nowIso + }) + })() const usage = new UsageService() const ids = new RandomIdGenerator() const inflight = new InflightTracker() @@ -57,12 +87,6 @@ export function createChildAgentExecutor(options: ChildAgentExecutorOptions): Ch contextCompaction: options.contextCompaction, models: options.models }) - const events = new RuntimeEventRecorder({ - eventBus, - sessionStore, - allocateSeq: (threadId) => eventBus.allocateSeq(threadId), - nowIso - }) const turns = new TurnService({ threadStore, sessionStore, @@ -142,8 +166,9 @@ export function createChildAgentExecutor(options: ChildAgentExecutorOptions): Ch }) const model = input.model?.trim() || options.defaultModel + const title = childThreadTitle(input.childId, input.label, input.profile) const thread = await threads.create({ - title: childThreadTitle(input.childId, input.label), + title, workspace: input.workspace?.trim() || '~', model, mode: 'agent', @@ -155,7 +180,12 @@ export function createChildAgentExecutor(options: ChildAgentExecutorOptions): Ch ...(input.providerId ? { providerId: input.providerId } : {}) }, { id: input.childId, - title: childThreadTitle(input.childId, input.label) + title, + // Persist as a side branch of the parent: hidden from the default thread + // list, but loadable on demand so the user can open the subagent's own + // session from the parent's delegate_task card. + relation: 'side', + parentThreadId: input.parentThreadId }) // A profile preamble rides in the prompt body (not the system prompt) so // the cached stable prefix stays byte-identical to the main agent's. @@ -174,8 +204,21 @@ export function createChildAgentExecutor(options: ChildAgentExecutorOptions): Ch } }) const status = await loop.runTurn(thread.id, started.turnId) + // Only a FATAL error fails the child. Recoverable tool errors — a tool + // rejected by the child's read-only policy, or a tool that crashed — are + // recorded as `severity: 'warning'` error events but the loop hands the + // model an error tool-result it adapts to and the turn still completes. + // Treating those as fatal wrongly marked the whole subagent "failed" for a + // single denied `bash` call. Genuine failures are caught by the `status` + // check below; here we only honor non-warning (fatal) error events. const runtimeError = (await sessionStore.loadEventsSince(thread.id, 0)) - .find((event) => event.kind === 'error' && event.turnId === started.turnId) + .find( + (event) => + event.kind === 'error' && + event.turnId === started.turnId && + event.severity !== 'warning' && + event.severity !== 'info' + ) if (runtimeError?.kind === 'error') { throw new Error(runtimeError.message) } @@ -199,8 +242,8 @@ export function createChildAgentExecutor(options: ChildAgentExecutorOptions): Ch } } -function childThreadTitle(childId: string, label?: string): string { - const suffix = label?.trim() || childId +function childThreadTitle(childId: string, label?: string, profile?: string): string { + const suffix = label?.trim() || profile?.trim() || childId return `Child agent: ${suffix}` } diff --git a/kun/src/delegation/delegation-runtime.ts b/kun/src/delegation/delegation-runtime.ts index 6b218aac1..37f6ce2ac 100644 --- a/kun/src/delegation/delegation-runtime.ts +++ b/kun/src/delegation/delegation-runtime.ts @@ -64,6 +64,8 @@ export type ChildRunExecutor = (input: { parentThreadId: string parentTurnId: string label?: string + /** Resolved subagent profile id (e.g. `general`, `explore`); used for the child thread title. */ + profile?: string prompt: string workspace?: string model?: string @@ -179,6 +181,14 @@ export class DelegationRuntime { * after the parent turn finishes. Default: false (synchronous). */ detach?: boolean + /** + * Invoked once, as soon as the child id is allocated (before the child + * finishes), so the caller can surface the id while the child is still + * running — e.g. the delegate_task tool emits a partial result so the GUI + * can offer "open session" mid-run. Carries the resolved profile id so the + * caller can keep showing the subagent type while it runs. + */ + onStart?: (childId: string, profile?: string) => void signal: AbortSignal }): Promise { const config = this.options.config @@ -235,6 +245,9 @@ export class DelegationRuntime { }) await this.options.store.upsert(record) await this.recordChildEvent(record) + // Surface the child id immediately (both sync + detached paths) so the + // caller can show it while the child is still running. + input.onStart?.(record.id, profileName) if (input.detach) { // Spawn an independent signal so the parent turn's signal aborting @@ -295,6 +308,7 @@ export class DelegationRuntime { parentThreadId: input.parentThreadId, parentTurnId: input.parentTurnId, ...(input.label ? { label: input.label } : {}), + ...(profileName ? { profile: profileName } : {}), prompt: input.prompt, workspace: input.workspace, model: resolvedModel, @@ -397,6 +411,7 @@ export class DelegationRuntime { parentThreadId: args.parentThreadId, parentTurnId: args.parentTurnId, ...(args.label ? { label: args.label } : {}), + ...(args.profileName ? { profile: args.profileName } : {}), prompt: args.prompt, workspace: args.workspace, model: args.resolvedModel, diff --git a/kun/src/server/runtime-factory.ts b/kun/src/server/runtime-factory.ts index a51335261..9b5c5d0c9 100644 --- a/kun/src/server/runtime-factory.ts +++ b/kun/src/server/runtime-factory.ts @@ -318,6 +318,11 @@ export async function createKunServeRuntime( modelCapabilities, skillRuntime, tokenEconomy, + // Persist the child as a hidden `side` thread on the shared stores + + // event bus so its session is loadable and streams live in the GUI. + sessionStore, + threadStore, + events, ...(options.runtime ? { runtime: options.runtime } : {}), ...(memoryStore ? { memoryStore } : {}), nowIso diff --git a/kun/src/services/thread-service.ts b/kun/src/services/thread-service.ts index ed2ed2c40..796c50620 100644 --- a/kun/src/services/thread-service.ts +++ b/kun/src/services/thread-service.ts @@ -110,7 +110,15 @@ export class ThreadService { async create( request: CreateThreadRequest, - options: { id?: string; title?: string; status?: ThreadStatus } = {} + options: { + id?: string + title?: string + status?: ThreadStatus + /** Relationship to a parent thread; `side` threads are hidden from the default list. */ + relation?: ThreadRelation + /** Parent thread this thread branches from (used by `side`/`fork` relations). */ + parentThreadId?: string + } = {} ): Promise { // Always advance the id generator so externally-supplied ids // don't collide with later allocations from `fork`/etc. @@ -129,6 +137,8 @@ export class ThreadService { approvalPolicy: request.approvalPolicy, sandboxMode: request.sandboxMode, ...(request.costBudgetUsd !== undefined ? { costBudgetUsd: request.costBudgetUsd } : {}), + ...(options.relation ? { relation: options.relation } : {}), + ...(options.parentThreadId ? { parentThreadId: options.parentThreadId } : {}), status: options.status }) await this.threadStore.upsert(thread) diff --git a/kun/tests/child-agent-executor.test.ts b/kun/tests/child-agent-executor.test.ts index 75c6f191e..3aab53953 100644 --- a/kun/tests/child-agent-executor.test.ts +++ b/kun/tests/child-agent-executor.test.ts @@ -2,9 +2,13 @@ import { describe, expect, it } from 'vitest' import { CapabilityRegistry } from '../src/adapters/tool/capability-registry.js' import { LocalToolHost, buildDefaultLocalTools } from '../src/adapters/tool/local-tool-host.js' +import { InMemoryEventBus } from '../src/adapters/in-memory-event-bus.js' +import { InMemorySessionStore } from '../src/adapters/in-memory-session-store.js' +import { InMemoryThreadStore } from '../src/adapters/in-memory-thread-store.js' import { createImmutablePrefix } from '../src/cache/immutable-prefix.js' import { createChildAgentExecutor } from '../src/delegation/child-agent-executor.js' import type { ModelClient, ModelRequest, ModelStreamChunk } from '../src/ports/model-client.js' +import { RuntimeEventRecorder } from '../src/services/runtime-event-recorder.js' function model(chunks: ModelStreamChunk[], seen: ModelRequest[] = []): ModelClient { return { @@ -141,6 +145,54 @@ describe('child agent executor', () => { expect(result).toMatchObject({ prefixReused: true, inheritedHistoryItems: 0 }) }) + it('does NOT fail the child when a tool call is rejected by its read-only policy', async () => { + // The child (read-only) calls `bash`, which its policy denies. That is a + // recoverable tool error (warning), not a fatal one: the loop hands the + // model an error result, the model adapts and the turn completes. The + // child run must report success, not "failed". + const registry = new CapabilityRegistry([{ + id: 'builtin', + kind: 'built-in', + enabled: true, + available: true, + tools: buildDefaultLocalTools() + }]) + let calls = 0 + const recoveringModel: ModelClient = { + provider: 'child-test', + model: 'child-test', + async *stream(): AsyncIterable { + calls += 1 + if (calls === 1) { + yield { kind: 'tool_call_complete', callId: 'call_bash', toolName: 'bash', arguments: { command: 'ls' } } + yield { kind: 'completed', stopReason: 'tool_calls' } + } else { + yield { kind: 'assistant_text_delta', text: 'bash was denied, so here is my read-only summary' } + yield { kind: 'completed', stopReason: 'stop' } + } + } + } + const executor = createChildAgentExecutor({ + model: recoveringModel, + toolHost: new LocalToolHost({ registry }), + prefix: createImmutablePrefix({ systemPrompt: 'child system' }), + defaultModel: 'child-test', + nowIso: () => '2026-06-03T00:00:00.000Z' + }) + + const result = await executor({ + childId: 'child_rejected_tool', + parentThreadId: 'thr_parent', + parentTurnId: 'turn_parent', + prompt: 'Investigate the project', + toolPolicy: 'readOnly', + signal: new AbortController().signal + }) + + expect(calls).toBe(2) + expect(result.summary).toContain('read-only summary') + }) + it('threads the input providerId onto the child ModelRequest for routing', async () => { const seen: ModelRequest[] = [] const executor = createChildAgentExecutor({ @@ -167,6 +219,53 @@ describe('child agent executor', () => { expect(seen[0]?.providerId).toBe('minimax') }) + it('persists the child as a hidden side thread when shared stores are supplied', async () => { + const eventBus = new InMemoryEventBus() + const sessionStore = new InMemorySessionStore() + const threadStore = new InMemoryThreadStore() + const events = new RuntimeEventRecorder({ + eventBus, + sessionStore, + allocateSeq: (threadId) => eventBus.allocateSeq(threadId), + nowIso: () => '2026-06-03T00:00:00.000Z' + }) + const executor = createChildAgentExecutor({ + model: model([ + { kind: 'assistant_text_delta', text: 'child answer' }, + { kind: 'completed', stopReason: 'stop' } + ]), + toolHost: new LocalToolHost({ registry: new CapabilityRegistry([]) }), + prefix: createImmutablePrefix({ systemPrompt: 'child system' }), + defaultModel: 'child-test', + nowIso: () => '2026-06-03T00:00:00.000Z', + sessionStore, + threadStore, + events + }) + + await executor({ + childId: 'child_persisted', + parentThreadId: 'thr_parent', + parentTurnId: 'turn_parent', + profile: 'explore', + prompt: 'Investigate', + toolPolicy: 'readOnly', + signal: new AbortController().signal + }) + + // The child thread is queryable from the shared store, flagged `side` and + // linked to its parent so the GUI can load it but the sidebar hides it. + const persisted = await threadStore.get('child_persisted') + expect(persisted).not.toBeNull() + expect(persisted?.relation).toBe('side') + expect(persisted?.parentThreadId).toBe('thr_parent') + expect(persisted?.title).toContain('explore') + + // The child's transcript persists too (loadable for the read-only viewer). + const items = await sessionStore.loadItems('child_persisted') + expect(items.some((item) => item.kind === 'assistant_text')).toBe(true) + }) + it('gives an inherit child the parent agent full tool set (no forced read-only allowlist)', async () => { const seen: ModelRequest[] = [] const registry = new CapabilityRegistry([{ @@ -340,4 +439,55 @@ describe('child agent executor', () => { expect(seen[0]?.systemPrompt).toBe('BASE PROMPT\n\nYou are a careful reviewer.') }) + + it('persists the child as a hidden side thread on the shared stores when provided', async () => { + const sessionStore = new InMemorySessionStore() + const threadStore = new InMemoryThreadStore() + const eventBus = new InMemoryEventBus() + const events = new RuntimeEventRecorder({ + eventBus, + sessionStore, + allocateSeq: (threadId) => eventBus.allocateSeq(threadId), + nowIso: () => '2026-06-03T00:00:00.000Z' + }) + const executor = createChildAgentExecutor({ + model: model([ + { kind: 'assistant_text_delta', text: 'persisted answer' }, + { kind: 'completed', stopReason: 'stop' } + ]), + toolHost: new LocalToolHost({ registry: new CapabilityRegistry([]) }), + prefix: createImmutablePrefix({ systemPrompt: 'child system' }), + defaultModel: 'child-test', + sessionStore, + threadStore, + events, + nowIso: () => '2026-06-03T00:00:00.000Z' + }) + + await executor({ + childId: 'child_persist', + parentThreadId: 'thr_parent', + parentTurnId: 'turn_parent', + profile: 'explore', + prompt: 'Investigate', + toolPolicy: 'readOnly', + signal: new AbortController().signal + }) + + // The child thread is persisted as a `side` branch of the parent. The + // `side` relation is what the thread store / ThreadService.list filter on + // to keep it out of the default (sidebar) list while leaving it loadable. + const thread = await threadStore.get('child_persist') + expect(thread).toMatchObject({ relation: 'side', parentThreadId: 'thr_parent' }) + + // The full session must live on the thread RECORD's turns/items — that is + // what `GET /threads/:id` (getThreadDetail → selectThread) reads to render + // the child's conversation when the user drills into it. + const recordItems = (thread?.turns ?? []).flatMap((turn) => turn.items) + const recordAssistantText = recordItems + .filter((item): item is Extract => item.kind === 'assistant_text') + .map((item) => item.text) + .join('') + expect(recordAssistantText).toContain('persisted answer') + }) }) diff --git a/kun/tests/delegation-runtime.test.ts b/kun/tests/delegation-runtime.test.ts index 967314195..1d46f6325 100644 --- a/kun/tests/delegation-runtime.test.ts +++ b/kun/tests/delegation-runtime.test.ts @@ -43,6 +43,20 @@ describe('DelegationRuntime', () => { expect(externalUsage[0]).toMatchObject({ totalTokens: 3 }) }) + it('fires onStart with the child id (so the tool can surface it mid-run)', async () => { + const runtime = createRuntime({}) + const started: Array<{ childId: string; profile?: string }> = [] + const result = await runtime.runChild({ + parentThreadId: 'thr_1', + parentTurnId: 'turn_1', + prompt: 'Research B', + onStart: (childId, profile) => started.push({ childId, profile }), + signal: new AbortController().signal + }) + expect(started).toHaveLength(1) + expect(started[0]?.childId).toBe(result.id) + }) + it('denies disabled delegation and exhausted child budgets', async () => { const disabled = createRuntime({ enabled: false }) await expect(disabled.runChild({ diff --git a/src/main/services/git-service.test.ts b/src/main/services/git-service.test.ts index 77998e12e..9407314dd 100644 --- a/src/main/services/git-service.test.ts +++ b/src/main/services/git-service.test.ts @@ -269,3 +269,37 @@ describe('worktree branch checkout — integration with real git', () => { expect(afterRemove.worktrees.map((item) => item.path)).not.toContain(result.worktreePath) }) }) + +describe('getGitBranches — worktree annotations', () => { + it('flags a branch checked out in another worktree and points back to the primary checkout', async () => { + // Park `main` in a linked worktree so the main repo and the worktree share + // a repository but hold different branches — the scenario that produced the + // "'develop' is already used by worktree" error. + execFileSync('git', ['-C', repoRoot, 'checkout', '-b', 'feature/parked'], { stdio: 'pipe' }) + const worktreePath = join(sandbox, 'linked', basename(repoRoot)) + await mkdir(join(worktreePath, '..'), { recursive: true }) + execFileSync('git', ['-C', repoRoot, 'worktree', 'add', worktreePath, 'main'], { stdio: 'pipe' }) + const worktreeReal = await realpath(worktreePath) + + // From the main repo: `main` lives in the linked (non-primary) worktree. + const fromMain = await getGitBranches(repoRoot) + expect(fromMain.ok).toBe(true) + if (!fromMain.ok) throw new Error('unreachable') + expect(fromMain.primaryRepositoryRoot).toBe(repoRoot) + const mainRow = fromMain.branches.find((b) => b.name === 'main') + expect(mainRow?.worktreePath).toBe(worktreeReal) + expect(mainRow?.worktreePrimary).toBe(false) + // The current branch is never flagged as living elsewhere. + expect(fromMain.branches.find((b) => b.name === 'feature/parked')?.worktreePath).toBeUndefined() + + // From the linked worktree: `feature/parked` lives in the primary checkout. + const fromWorktree = await getGitBranches(worktreeReal) + expect(fromWorktree.ok).toBe(true) + if (!fromWorktree.ok) throw new Error('unreachable') + expect(fromWorktree.primaryRepositoryRoot).toBe(repoRoot) + const parkedRow = fromWorktree.branches.find((b) => b.name === 'feature/parked') + expect(parkedRow?.worktreePath).toBe(repoRoot) + expect(parkedRow?.worktreePrimary).toBe(true) + expect(fromWorktree.branches.find((b) => b.name === 'main')?.worktreePath).toBeUndefined() + }) +}) diff --git a/src/main/services/git-service.ts b/src/main/services/git-service.ts index 862b648e4..77b0200de 100644 --- a/src/main/services/git-service.ts +++ b/src/main/services/git-service.ts @@ -180,14 +180,31 @@ export async function getGitBranches(workspaceRoot: string): Promise ({ - name, - current: currentBranch === name - })) + const worktreeRows = parseWorktreeListPorcelain( + (await runGit(cwd, ['worktree', 'list', '--porcelain'])).stdout + ) + const primaryRepositoryRoot = worktreeRows[0]?.path || repositoryRoot + const worktreeByBranch = new Map() + for (const row of worktreeRows) { + if (row.branch && !worktreeByBranch.has(row.branch)) { + worktreeByBranch.set(row.branch, { path: row.path, primary: row.path === primaryRepositoryRoot }) + } + } + const branches = [...branchSet].map((name) => { + // A branch checked out in *another* worktree cannot be switched to here. + // (The current branch lives in this worktree, so it's never "elsewhere".) + const elsewhere = name === currentBranch ? undefined : worktreeByBranch.get(name) + const offsite = elsewhere && elsewhere.path !== repositoryRoot ? elsewhere : undefined + return { + name, + current: currentBranch === name, + ...(offsite ? { worktreePath: offsite.path, worktreePrimary: offsite.primary } : {}) + } + }) const dirtyCount = (await runGit(cwd, ['status', '--porcelain=v1'])).stdout .split('\n') .filter((line) => line.trim().length > 0).length - return { ok: true, repositoryRoot, currentBranch, branches, dirtyCount } + return { ok: true, repositoryRoot, primaryRepositoryRoot, currentBranch, branches, dirtyCount } } catch (error) { return gitFailure(error) } diff --git a/src/renderer/src/agent/kun-mapper.ts b/src/renderer/src/agent/kun-mapper.ts index dd20c84f2..a6f321e5f 100644 --- a/src/renderer/src/agent/kun-mapper.ts +++ b/src/renderer/src/agent/kun-mapper.ts @@ -228,6 +228,9 @@ function normalizeChildMetadata( parentTurnId: child.parentTurnId, childId: child.childId, ...(child.childLabel ? { childLabel: child.childLabel } : {}), + ...(child.childProfile ? { childProfile: child.childProfile } : {}), + ...(child.childModel ? { childModel: child.childModel } : {}), + ...(child.childToolPolicy ? { childToolPolicy: child.childToolPolicy } : {}), childStatus: child.childStatus, childSeq: child.childSeq } diff --git a/src/renderer/src/agent/kun-runtime.ts b/src/renderer/src/agent/kun-runtime.ts index 0d63ac107..b853a3561 100644 --- a/src/renderer/src/agent/kun-runtime.ts +++ b/src/renderer/src/agent/kun-runtime.ts @@ -197,6 +197,8 @@ export class KunRuntimeProvider implements AgentProvider { latestUserMessageId?: string turnDurationByUserId?: Record usage?: ThreadUsageSnapshot + relation?: 'primary' | 'fork' | 'side' + parentThreadId?: string goal?: NormalizedThread['goal'] todos?: NormalizedThread['todos'] }> { @@ -231,6 +233,9 @@ export class KunRuntimeProvider implements AgentProvider { threadStatus: thread.status ?? latestTurn?.status, latestTurnId: latestTurn?.id, latestUserMessageId, + relation: thread.relation, + ...(thread.parentThreadId ? { parentThreadId: thread.parentThreadId } : {}), + ...(typeof thread.model === 'string' && thread.model.trim() ? { model: thread.model.trim() } : {}), goal: thread.goal ? goalFromCore(thread.goal) : null, todos: thread.todos ? todosFromCore(thread.todos) : null } diff --git a/src/renderer/src/agent/types.ts b/src/renderer/src/agent/types.ts index 943b58bfc..a7ba6808e 100644 --- a/src/renderer/src/agent/types.ts +++ b/src/renderer/src/agent/types.ts @@ -53,6 +53,12 @@ export type RuntimeChildMetadata = { parentTurnId: string childId: string childLabel?: string + /** Subagent profile id (e.g. `general`, `explore`) resolved by the runtime. */ + childProfile?: string + /** Model override the child ran under, when one was resolved. */ + childModel?: string + /** Tool policy applied to the child run. */ + childToolPolicy?: 'readOnly' | 'inherit' childStatus: 'queued' | 'running' | 'completed' | 'failed' | 'aborted' childSeq: number } @@ -450,6 +456,9 @@ export interface AgentProvider { latestUserMessageId?: string turnDurationByUserId?: Record usage?: ThreadUsageSnapshot + relation?: 'primary' | 'fork' | 'side' + parentThreadId?: string + model?: string goal?: ThreadGoal | null todos?: ThreadTodoList | null }> diff --git a/src/renderer/src/components/Workbench.tsx b/src/renderer/src/components/Workbench.tsx index 65e360696..0c9372fc3 100644 --- a/src/renderer/src/components/Workbench.tsx +++ b/src/renderer/src/components/Workbench.tsx @@ -42,6 +42,7 @@ import { import { Sidebar } from './chat/Sidebar' import { WorkbenchTopBar, type RightPanelMode } from './chat/WorkbenchTopBar' import { MessageTimeline } from './chat/MessageTimeline' +import { SubagentReturnBar } from './chat/message-timeline-empty' import { IkunCameoLayer, KunCelebrationLayer } from './chat/AnimatedWorkLogo' import { FloatingComposer, @@ -373,6 +374,8 @@ export function Workbench(): ReactElement { threadSearch, showArchivedThreads, activeThreadId, + activeThreadRelation, + activeThreadParentId, selectThread, createThread, blocks, @@ -435,6 +438,8 @@ export function Workbench(): ReactElement { threadSearch: s.threadSearch, showArchivedThreads: s.showArchivedThreads, activeThreadId: s.activeThreadId, + activeThreadRelation: s.activeThreadRelation, + activeThreadParentId: s.activeThreadParentId, selectThread: s.selectThread, createThread: s.createThread, blocks: s.blocks, @@ -2715,6 +2720,16 @@ export function Workbench(): ReactElement { {!focusModeEnabled ? : null}
+ {activeThreadRelation === 'side' && activeThreadParentId ? ( + thread.id === activeThreadParentId)?.title?.trim() ?? '' + } + onBack={() => { + if (activeThreadParentId) void selectThread(activeThreadParentId) + }} + /> + ) : ( + )}
{terminalOpen ? ( diff --git a/src/renderer/src/components/chat/GitBranchPicker.tsx b/src/renderer/src/components/chat/GitBranchPicker.tsx index 2a590fcea..bb311a2f6 100644 --- a/src/renderer/src/components/chat/GitBranchPicker.tsx +++ b/src/renderer/src/components/chat/GitBranchPicker.tsx @@ -1,11 +1,16 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactElement } from 'react' import { createPortal } from 'react-dom' -import { AlertCircle, Check, ChevronDown, GitBranch, Loader2, Plus, Search } from 'lucide-react' +import { AlertCircle, Check, ChevronDown, GitBranch, GitFork, Loader2, Plus, Search } from 'lucide-react' import { useTranslation } from 'react-i18next' -import type { GitBranchesResult } from '@shared/git-branches' +import type { GitBranchesResult, GitBranchRow } from '@shared/git-branches' import { getProvider } from '../../agent/registry' import { middleEllipsize } from '../../lib/middle-ellipsize' -import { markThreadWorktree, saveThreadWorktreeRegistry } from '../../lib/thread-worktree-registry' +import { + forgetThreadWorktree, + markThreadWorktree, + readThreadWorktreeRegistry, + saveThreadWorktreeRegistry +} from '../../lib/thread-worktree-registry' import { useChatStore } from '../../store/chat-store' import { rememberCodeWorkspaceRoots } from '../../store/chat-store-helpers' @@ -38,6 +43,7 @@ export function GitBranchPicker({ workspaceRoot }: Props): ReactElement | null { const [result, setResult] = useState(null) const [loading, setLoading] = useState(false) const [actingBranch, setActingBranch] = useState(null) + const [actingKind, setActingKind] = useState<'switch' | 'worktree' | null>(null) const [error, setError] = useState(null) const [tooltip, setTooltip] = useState(null) const wrapRef = useRef(null) @@ -64,6 +70,7 @@ export function GitBranchPicker({ workspaceRoot }: Props): ReactElement | null { setResult(null) setError(null) setActingBranch(null) + setActingKind(null) }, [root]) useEffect(() => { @@ -100,30 +107,16 @@ export function GitBranchPicker({ workspaceRoot }: Props): ReactElement | null { const trimmedQuery = query.trim() const exactBranchExists = branches.some((branch) => branch.name === trimmedQuery) - const selectedWorktreeBranch = trimmedQuery - ? exactBranchExists - ? trimmedQuery - : '' - : result?.ok - ? result.currentBranch ?? '' - : '' const canCreate = trimmedQuery.length > 0 && !exactBranchExists - const canCheckoutWorktree = selectedWorktreeBranch.length > 0 - const canRunFooterAction = canCreate || canCheckoutWorktree + const switchTargetRow = exactBranchExists + ? branches.find((branch) => branch.name === trimmedQuery) ?? null + : null const currentBranch = result?.ok ? result.currentBranch : null const label = currentBranch || (result?.ok ? t('gitDetached') : t('gitBranchUnavailable')) - const footerBranch = canCreate ? trimmedQuery : selectedWorktreeBranch - const footerBranchLabel = middleEllipsize(footerBranch, BRANCH_FOOTER_LABEL_MAX_LENGTH) - const footerActionLabel = canCreate - ? t('gitCreateNamedBranch', { branch: footerBranchLabel }) - : selectedWorktreeBranch - ? t('gitCheckoutNamedBranchWorktree', { branch: footerBranchLabel }) - : t('gitCreateBranch') - const footerActionTitle = canCreate - ? t('gitCreateNamedBranch', { branch: trimmedQuery }) - : selectedWorktreeBranch - ? t('gitCheckoutNamedBranchWorktree', { branch: selectedWorktreeBranch }) - : t('gitCreateBranch') + const footerBranchLabel = middleEllipsize(trimmedQuery, BRANCH_FOOTER_LABEL_MAX_LENGTH) + const footerCreateLabel = t('gitCreateNamedBranch', { branch: footerBranchLabel }) + const footerCreateTitle = t('gitCreateNamedBranch', { branch: trimmedQuery }) + const footerWorktreeTitle = t('gitNewBranchWorktree', { branch: trimmedQuery }) const showTooltip = useCallback((text: string, clientX: number, clientY: number): void => { if (!text.trim()) return setTooltip({ text, ...branchTooltipPosition(clientX, clientY) }) @@ -162,9 +155,106 @@ export function GitBranchPicker({ workspaceRoot }: Props): ReactElement | null { })) } + const switchBranch = async (branch: string): Promise => { + if (!root || !branch) return + setActingBranch(branch) + setActingKind('switch') + setError(null) + try { + const next = await window.kunGui.switchGitBranch(root, branch) + setResult(next) + if (!next.ok) { + setError(next.message) + return + } + setOpen(false) + setQuery('') + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setActingBranch(null) + setActingKind(null) + } + } + + // A branch already checked out in another worktree can't be switched to in + // place (git forbids the same branch in two worktrees). Navigate the active + // conversation to that checkout instead of running a doomed `git switch`. + const navigateToWorktree = async (branch: GitBranchRow): Promise => { + const worktreePath = branch.worktreePath + if (!worktreePath) return + const activeThreadId = useChatStore.getState().activeThreadId + if (!activeThreadId) return + setActingBranch(branch.name) + setActingKind('switch') + setError(null) + try { + const provider = getProvider() + if (typeof provider.updateThreadWorkspace === 'function') { + await provider.updateThreadWorkspace(activeThreadId, worktreePath) + } + const projectPath = result?.ok ? result.primaryRepositoryRoot : worktreePath + const registry = readThreadWorktreeRegistry() + saveThreadWorktreeRegistry( + branch.worktreePrimary + ? forgetThreadWorktree(activeThreadId, registry) + : markThreadWorktree( + activeThreadId, + { projectPath, worktreePath, branch: branch.name, createdAt: new Date().toISOString() }, + registry + ) + ) + useChatStore.setState((state) => ({ + codeWorkspaceRoots: rememberCodeWorkspaceRoots(state.codeWorkspaceRoots, [worktreePath]), + threads: state.threads.map((thread) => + thread.id === activeThreadId ? { ...thread, workspace: worktreePath } : thread + ) + })) + setOpen(false) + setQuery('') + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setActingBranch(null) + setActingKind(null) + } + } + + const selectBranch = (branch: GitBranchRow): void => { + if (branch.worktreePath) { + void navigateToWorktree(branch) + } else { + void switchBranch(branch.name) + } + } + + const createAndSwitchBranch = async (): Promise => { + const branch = query.trim() + if (!root || !branch) return + setActingBranch(branch) + setActingKind('switch') + setError(null) + try { + const next = await window.kunGui.createAndSwitchGitBranch(root, branch) + setResult(next) + if (!next.ok) { + setError(next.message) + return + } + setOpen(false) + setQuery('') + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setActingBranch(null) + setActingKind(null) + } + } + const checkoutBranchWorktree = async (branch: string): Promise => { if (!root || !branch) return setActingBranch(branch) + setActingKind('worktree') setError(null) try { const next = await window.kunGui.checkoutGitBranchWorktree(root, branch) @@ -184,13 +274,15 @@ export function GitBranchPicker({ workspaceRoot }: Props): ReactElement | null { setError(e instanceof Error ? e.message : String(e)) } finally { setActingBranch(null) + setActingKind(null) } } - const createBranch = async (): Promise => { + const createBranchWorktree = async (): Promise => { const branch = query.trim() if (!root || !branch) return setActingBranch(branch) + setActingKind('worktree') setError(null) try { const next = await window.kunGui.createGitBranchWorktree(root, branch) @@ -210,6 +302,7 @@ export function GitBranchPicker({ workspaceRoot }: Props): ReactElement | null { setError(e instanceof Error ? e.message : String(e)) } finally { setActingBranch(null) + setActingKind(null) } } @@ -245,12 +338,13 @@ export function GitBranchPicker({ workspaceRoot }: Props): ReactElement | null { e.preventDefault() setOpen(false) } - if (e.key === 'Enter' && canRunFooterAction) { - e.preventDefault() + if (e.key === 'Enter') { if (canCreate) { - void createBranch() - } else { - void checkoutBranchWorktree(selectedWorktreeBranch) + e.preventDefault() + void createAndSwitchBranch() + } else if (switchTargetRow) { + e.preventDefault() + selectBranch(switchTargetRow) } } }} @@ -278,72 +372,130 @@ export function GitBranchPicker({ workspaceRoot }: Props): ReactElement | null { ) : null} - {filteredBranches.map((branch) => ( + {filteredBranches.map((branch) => { + const isActing = actingBranch === branch.name + return ( +
+ + +
+ ) + })} + + {!loading && result?.ok && filteredBranches.length === 0 ? ( +
{t('gitNoBranches')}
+ ) : null} + + + {canCreate ? ( +
- ))} - - {!loading && result?.ok && filteredBranches.length === 0 ? ( -
{t('gitNoBranches')}
- ) : null} -
- -
- -
+ + + ) : null} ) : null} {tooltip ? createPortal( diff --git a/src/renderer/src/components/chat/SubagentCallCard.tsx b/src/renderer/src/components/chat/SubagentCallCard.tsx index 9f14ff02f..7ca6552d1 100644 --- a/src/renderer/src/components/chat/SubagentCallCard.tsx +++ b/src/renderer/src/components/chat/SubagentCallCard.tsx @@ -34,6 +34,8 @@ const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)' /** Parsed shape of the `delegate_task` tool `detail` JSON (all optional). */ type DelegateDetail = { + /** The child thread id — always present in the tool result, unlike `meta.child`. */ + childId?: string summary?: string error?: string profile?: string @@ -60,6 +62,7 @@ function parseDelegateDetail(detail: string | undefined): DelegateDetail { const num = (v: unknown): number | undefined => typeof v === 'number' && Number.isFinite(v) ? v : undefined return { + childId: str(obj.childId), summary: str(obj.summary), error: str(obj.error), profile: str(obj.profile), @@ -74,6 +77,7 @@ function parseDelegateDetail(detail: string | undefined): DelegateDetail { type ChildMeta = { childId?: string childLabel?: string + childProfile?: string childStatus?: string childSeq?: number parentTurnId?: string @@ -91,6 +95,7 @@ function readChildMeta(block: ChatBlock): ChildMeta { return { childId: str(child.childId), childLabel: str(child.childLabel), + childProfile: str(child.childProfile), childStatus: str(child.childStatus), childSeq: typeof child.childSeq === 'number' ? child.childSeq : undefined, parentTurnId: str(child.parentTurnId) @@ -374,8 +379,12 @@ export function SubagentCallCard({ const status = resolveStatus(block, child) const animate = !reducedMotion && onScreen && status === 'running' - // Pose key: detail.profile → childLabel → block toolName → 'custom'. - const poseId = detail.profile || child.childLabel || child.childId || 'custom' + // Profile id: prefer the live `childProfile` from the runtime metadata (set on + // the first queued/running event) so the agent type shows immediately; the + // result-JSON `profile` only arrives after the child completes. + const profileId = child.childProfile || detail.profile + // Pose key: profile → childLabel → block toolName → 'custom'. + const poseId = profileId || child.childLabel || child.childId || 'custom' const isKnownPose = KNOWN_POSE_IDS.has(poseId) const hue = isKnownPose ? null : hashHue(poseId) @@ -383,35 +392,30 @@ export function SubagentCallCard({ // → a custom profile's own name → a short name derived from the task → default. const taskText = block.kind === 'tool' ? splitTaskLine(block as ToolBlock) : undefined const roleName = - (detail.profile && KNOWN_POSE_IDS.has(detail.profile) - ? t(`subagentsPanel.role.${detail.profile}.name`, detail.profile) + (profileId && KNOWN_POSE_IDS.has(profileId) + ? t(`subagentsPanel.role.${profileId}.name`, profileId) : undefined) || child.childLabel?.trim() || - detail.profile?.trim() || + profileId?.trim() || taskText?.trim().split(/\s+/).slice(0, 6).join(' ').slice(0, 28) || t('subagentDefaultName') - const taskParts = [detail.profile || child.childLabel, detail.summary || (block.kind === 'tool' ? splitTaskLine(block as ToolBlock) : undefined)] + const taskParts = [child.childLabel, detail.summary || (block.kind === 'tool' ? splitTaskLine(block as ToolBlock) : undefined)] .filter((p): p is string => Boolean(p && p.trim())) const taskLine = taskParts.join(' · ') const elapsed = useElapsed(status, block.createdAt, detail.durationMs) const steps = detail.toolInvocations - // Auto-expand once on first terminal transition iff summary/error present. + // Always start collapsed — both while running and after it finishes. The card + // only opens when the user clicks it (no auto-expand on terminal transition). const hasBody = Boolean(detail.summary?.trim() || detail.error?.trim()) const [userToggled, setUserToggled] = useState(null) - const autoExpandedRef = useRef(false) - const [autoExpanded, setAutoExpanded] = useState(false) - useEffect(() => { - if (autoExpandedRef.current) return - if (isTerminal(status) && hasBody && !inGroup) { - autoExpandedRef.current = true - setAutoExpanded(true) - } - }, [status, hasBody, inGroup]) - const expanded = (userToggled ?? autoExpanded) && hasBody - - const childId = child.childId + const expanded = (userToggled ?? false) && hasBody + + // `meta.child` is only attached on the live child events (which the renderer + // currently drops), so for a completed delegation the reliable source of the + // child thread id is the tool result JSON (`detail.childId`). + const childId = child.childId || detail.childId const openChild = (): void => { if (!childId) return void selectThread(childId).catch(() => undefined) @@ -470,6 +474,20 @@ export function SubagentCallCard({ : ''} + {childId ? ( + + ) : null} {hasBody ? ( expanded ? ( @@ -503,20 +521,6 @@ export function SubagentCallCard({ {detail.toolPolicy === 'readOnly' ? t('subagentPolicyReadOnly') : t('subagentPolicyFull')} ) : null} - - {childId ? ( - - ) : null} ) : null} @@ -547,6 +551,8 @@ function splitTaskLine(block: ToolBlock): string | undefined { if (!raw) return undefined const stripped = raw.replace(/^delegate_task\s*:\s*/i, '').trim() if (!stripped || stripped.length > 160) return undefined + // Bare tool name (no task text yet, e.g. while running) — nothing useful. + if (/^delegate_task$/i.test(stripped)) return undefined return stripped } @@ -586,7 +592,7 @@ export function SubagentGroup({ blocks }: { blocks: ChatBlock[] }): ReactElement const clusterPoses = sorted.slice(0, 5).map((b) => { const c = readChildMeta(b) const d = parseDelegateDetail(b.kind === 'tool' ? (b as ToolBlock).detail : undefined) - return d.profile || c.childLabel || c.childId || 'custom' + return c.childProfile || d.profile || c.childLabel || c.childId || 'custom' }) const overflow = sorted.length - clusterPoses.length diff --git a/src/renderer/src/components/chat/message-timeline-bubbles.tsx b/src/renderer/src/components/chat/message-timeline-bubbles.tsx index 91c837177..bce34dbdc 100644 --- a/src/renderer/src/components/chat/message-timeline-bubbles.tsx +++ b/src/renderer/src/components/chat/message-timeline-bubbles.tsx @@ -865,9 +865,11 @@ function RuntimeMetaChips({ const childLabel = typeof child?.childLabel === 'string' && child.childLabel.trim() ? child.childLabel.trim() - : typeof child?.childId === 'string' - ? child.childId - : '' + : typeof child?.childProfile === 'string' && child.childProfile.trim() + ? child.childProfile.trim() + : typeof child?.childId === 'string' + ? child.childId + : '' if ( (hideAttachments || attachmentIds.length === 0) && activeSkillIds.length === 0 && diff --git a/src/renderer/src/components/chat/message-timeline-empty.tsx b/src/renderer/src/components/chat/message-timeline-empty.tsx index 52c56254e..e32368b15 100644 --- a/src/renderer/src/components/chat/message-timeline-empty.tsx +++ b/src/renderer/src/components/chat/message-timeline-empty.tsx @@ -1,6 +1,6 @@ import { Fragment, useState, type ReactElement } from 'react' import { useTranslation } from 'react-i18next' -import { GitFork, RefreshCw, Settings } from 'lucide-react' +import { Bot, CornerUpLeft, GitFork, RefreshCw, Settings } from 'lucide-react' import type { ClawImChannelV1 } from '@shared/app-settings' import { KunStateFigure } from './AnimatedWorkLogo' import { InitialSessionUsageHeatmap } from './InitialSessionUsageHeatmap' @@ -204,6 +204,47 @@ export function ThreadForkBanner({ parentTitle }: { parentTitle: string }): Reac ) } +/** + * Bar shown where the composer normally sits when viewing a subagent's own + * (`relation: 'side'`) session. The subagent runs autonomously, so there is no + * input box — only a way back to the parent conversation that delegated it. + * These threads are hidden from the sidebar, so this is the route home. + */ +export function SubagentReturnBar({ + parentTitle, + onBack +}: { + parentTitle: string + onBack: () => void +}): ReactElement { + const { t } = useTranslation('common') + return ( + + ) +} + export function ThreadForkPoint({ parentTitle }: { parentTitle: string }): ReactElement { const { t } = useTranslation('common') return ( diff --git a/src/renderer/src/components/chat/message-timeline-process.tsx b/src/renderer/src/components/chat/message-timeline-process.tsx index 5e4bf128a..2c5f8a5fd 100644 --- a/src/renderer/src/components/chat/message-timeline-process.tsx +++ b/src/renderer/src/components/chat/message-timeline-process.tsx @@ -954,9 +954,11 @@ function RuntimeMetaBadges({ const childLabel = typeof child?.childLabel === 'string' && child.childLabel.trim() ? child.childLabel.trim() - : typeof child?.childId === 'string' - ? child.childId - : '' + : typeof child?.childProfile === 'string' && child.childProfile.trim() + ? child.childProfile.trim() + : typeof child?.childId === 'string' + ? child.childId + : '' if ( sources.length === 0 && attachmentIds.length === 0 && diff --git a/src/renderer/src/components/settings-section-general.tsx b/src/renderer/src/components/settings-section-general.tsx index c48bb8017..66c75b140 100644 --- a/src/renderer/src/components/settings-section-general.tsx +++ b/src/renderer/src/components/settings-section-general.tsx @@ -8,7 +8,6 @@ import { DEFAULT_WRITE_INLINE_COMPLETION_MODEL, DEFAULT_WRITE_INLINE_LONG_COMPLETION_MAX_TOKENS, DEFAULT_KUN_DATA_DIR, - LEGACY_UI_FONT_SCALE_FACTORS, UI_FONT_SCALE_MAX, UI_FONT_SCALE_MIN, WRITE_INLINE_COMPLETION_MODEL_IDS, @@ -242,18 +241,6 @@ export function GeneralSettingsSection({ ctx }: { ctx: Record }): R const fontScale = normalizeUiFontScale(form.uiFontScale) const fontScalePercent = Math.round(fontScale * 100) const setFontScale = (value: number): void => update({ uiFontScale: normalizeUiFontScale(value) }) - const fontScalePresets: { value: number; label: string }[] = [ - { value: LEGACY_UI_FONT_SCALE_FACTORS.small, label: t('fontScaleSmall') }, - { value: LEGACY_UI_FONT_SCALE_FACTORS.medium, label: t('fontScaleMedium') }, - { value: LEGACY_UI_FONT_SCALE_FACTORS.large, label: t('fontScaleLarge') } - ] - const fontScaleChipClass = (active: boolean): string => - [ - 'inline-flex h-7 items-center rounded-full border px-3 text-[12px] font-medium transition', - active - ? 'border-accent/60 bg-accent/8 text-accent ring-1 ring-accent/30' - : 'border-ds-border bg-ds-card text-ds-muted hover:bg-ds-hover hover:text-ds-ink' - ].join(' ') const cursorSpotlightColor = normalizeHexColor(form.cursorSpotlightColor) return ( @@ -293,19 +280,6 @@ export function GeneralSettingsSection({ ctx }: { ctx: Record }): R description={t('fontScaleDesc')} control={
-
- {fontScalePresets.map((preset) => ( - - ))} -
} /> + + + updateProviderProxy({ url: e.target.value })} + /> +
+ } + /> {pendingImport && pendingImportProvider ? ( { return { activeThreadId: null, + activeThreadRelation: null, + activeThreadParentId: null, activeThreadGoal: null, activeThreadTodos: null, blocks: [], diff --git a/src/renderer/src/store/chat-store-thread-actions.ts b/src/renderer/src/store/chat-store-thread-actions.ts index ec5a1b13a..041977552 100644 --- a/src/renderer/src/store/chat-store-thread-actions.ts +++ b/src/renderer/src/store/chat-store-thread-actions.ts @@ -411,10 +411,25 @@ export function createThreadActions( latestUserMessageId, turnDurationByUserId = {}, usage: threadUsage, + relation: threadRelation, + parentThreadId: threadParentId, + model: threadModel, goal, todos } = await p.getThreadDetail(id) - const blocks = hydrateBlockModelLabels(id, rawBlocks) + // A subagent's `side` thread has no locally-stored per-turn model labels + // (it was never sent through the composer). Backfill the user blocks with + // the child thread's resolved model so the session shows "which model", + // matching the main conversation. Safe: a child runs on a single model. + const labeledBlocks = + threadRelation === 'side' && threadModel + ? rawBlocks.map((block) => + block.kind === 'user' && !block.modelLabel + ? { ...block, modelLabel: threadModel } + : block + ) + : rawBlocks + const blocks = hydrateBlockModelLabels(id, labeledBlocks) const busy = threadSnapshotLooksRunning(blocks, threadStatus) const currentTurnUserId = busy ? latestUserMessageId ?? findLatestUserBlockId(blocks) @@ -426,6 +441,8 @@ export function createThreadActions( watchTurnCompletion: nextWatch, unreadThreadIds: nextUnread, activeThreadId: id, + activeThreadRelation: threadRelation ?? 'primary', + activeThreadParentId: threadParentId ?? null, activeThreadGoal: goal ?? null, activeThreadTodos: todos ?? null, blocks, @@ -794,6 +811,10 @@ export function createThreadActions( } set((s) => ({ activeThreadId: threadId, + // Freshly created threads are always primary — clear any side-session + // relation carried over from the previously active thread. + activeThreadRelation: 'primary', + activeThreadParentId: null, codeWorkspaceRoots: rememberCodeWorkspaceRoots(s.codeWorkspaceRoots, [workspaceRoot, createdThread?.workspace]), lastSeq: 0, inspectorSelectedId: null, @@ -969,8 +990,19 @@ export function createThreadActions( }) } } + // Subscribe to the turn's event stream BEFORE the cosmetic title rename so + // a slow/blocked title write never delays the conversation. Title naming + // must not be a blocking point of the conversation flow. + set({ currentTurnId: turnId }) + const ac = new AbortController() + sseAbortRef.current = ac + const sink = buildThreadEventSink(set, get, { threadId: activeThreadId, signal: ac.signal, sinceSeq: seqAtSend }) + subscribeThreadEventsWithRecovery(p, activeThreadId, seqAtSend, sink, ac.signal, get) + armBusyWatchdog(set, get) if (shouldRenameThreadAfterSend) { - // Provisional first-message title; let the backend LLM titler upgrade it. + // Provisional first-message title; the backend LLM titler upgrades it + // later (fire-and-forget on the runtime). Awaited here only to land the + // title before refreshThreads re-reads the list — never blocks the stream. const renamed = await p.renameThread(activeThreadId, generatedTitle, true).then(() => true).catch(() => { /* keep message delivery successful even if auto-title update fails */ return false @@ -983,12 +1015,6 @@ export function createThreadActions( })) } } - set({ currentTurnId: turnId }) - const ac = new AbortController() - sseAbortRef.current = ac - const sink = buildThreadEventSink(set, get, { threadId: activeThreadId, signal: ac.signal, sinceSeq: seqAtSend }) - subscribeThreadEventsWithRecovery(p, activeThreadId, seqAtSend, sink, ac.signal, get) - armBusyWatchdog(set, get) await get().refreshThreads() return true } catch (e) { diff --git a/src/renderer/src/store/chat-store-types.ts b/src/renderer/src/store/chat-store-types.ts index 23ecba433..a2732cff2 100644 --- a/src/renderer/src/store/chat-store-types.ts +++ b/src/renderer/src/store/chat-store-types.ts @@ -149,6 +149,10 @@ export type ChatState = { threadSearch: string showArchivedThreads: boolean activeThreadId: string | null + /** Relationship of the active thread (e.g. `side` for a subagent's own session). */ + activeThreadRelation: 'primary' | 'fork' | 'side' | null + /** Parent thread of the active thread, when it is a `side`/`fork` branch. */ + activeThreadParentId: string | null activeThreadGoal: ThreadGoal | null activeThreadTodos: ThreadTodoList | null blocks: ChatBlock[] diff --git a/src/renderer/src/store/chat-store.ts b/src/renderer/src/store/chat-store.ts index 8828a74f2..771e0d0b9 100644 --- a/src/renderer/src/store/chat-store.ts +++ b/src/renderer/src/store/chat-store.ts @@ -143,6 +143,8 @@ export const useChatStore = create((set, get) => ({ threadSearch: '', showArchivedThreads: false, activeThreadId: null, + activeThreadRelation: null, + activeThreadParentId: null, activeThreadGoal: null, activeThreadTodos: null, blocks: [], diff --git a/src/shared/git-branches.ts b/src/shared/git-branches.ts index 6f23d2364..74ee1afdc 100644 --- a/src/shared/git-branches.ts +++ b/src/shared/git-branches.ts @@ -1,12 +1,23 @@ export type GitBranchRow = { name: string current: boolean + /** + * Absolute path of another worktree that already has this branch checked out. + * Git only allows a branch to live in one worktree at a time, so when this is + * set an in-place `git switch` would fail — the UI navigates to this path + * instead. Unset when the branch is free to be checked out here. + */ + worktreePath?: string + /** True when {@link worktreePath} is the repository's primary (main) worktree. */ + worktreePrimary?: boolean } export type GitBranchesResult = | { ok: true repositoryRoot: string + /** Absolute path of the repository's primary (main) worktree. */ + primaryRepositoryRoot: string currentBranch: string | null branches: GitBranchRow[] dirtyCount: number @@ -21,6 +32,7 @@ export type GitWorktreeCheckoutResult = | { ok: true repositoryRoot: string + primaryRepositoryRoot: string sourceRepositoryRoot: string worktreePath: string currentBranch: string | null