From 8d502ab939ada259c88cb518f60181fea7ae9dfc Mon Sep 17 00:00:00 2001 From: musnows Date: Fri, 26 Jun 2026 00:01:00 +0800 Subject: [PATCH 1/6] fix(settings): move proxy below providers and refine font-scale control --- .../components/settings-section-general.tsx | 32 ++------------ .../components/settings-section-providers.tsx | 44 +++++++++---------- src/renderer/src/index.css | 12 +++++ 3 files changed, 37 insertions(+), 51 deletions(-) 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 ? ( Date: Fri, 26 Jun 2026 00:23:19 +0800 Subject: [PATCH 2/6] feat: enhance delegation tool provider and runtime with subagent support - Updated `delegation-tool-provider.ts` to emit partial results on child start, allowing the GUI to display running status. - Modified `builtin-profiles.ts` to default child profiles to 'general' when not explicitly set, ensuring proper labeling in the GUI. - Enhanced `child-agent-executor.ts` to support persistent child sessions with shared stores, enabling live streaming of events and queryable sessions. - Updated `delegation-runtime.ts` to invoke `onStart` callback with child ID and profile, allowing mid-run visibility in the GUI. - Adjusted `runtime-factory.ts` to persist child sessions as hidden `side` threads on the shared event bus. - Enhanced `thread-service.ts` to support `side` thread relations and parent thread IDs for better session management. - Added tests to verify child session persistence and error handling in `child-agent-executor.test.ts` and `delegation-runtime.test.ts`. - Updated UI components to handle subagent sessions, including new `SubagentReturnBar` for navigation back to parent threads. - Localized new UI strings for subagent sessions in English and Chinese. --- .../adapters/tool/delegation-tool-provider.ts | 11 +- kun/src/delegation/builtin-profiles.ts | 7 +- kun/src/delegation/child-agent-executor.ts | 71 +++++++-- kun/src/delegation/delegation-runtime.ts | 15 ++ kun/src/server/runtime-factory.ts | 5 + kun/src/services/thread-service.ts | 12 +- kun/tests/child-agent-executor.test.ts | 150 ++++++++++++++++++ kun/tests/delegation-runtime.test.ts | 14 ++ src/renderer/src/agent/kun-mapper.ts | 3 + src/renderer/src/agent/kun-runtime.ts | 5 + src/renderer/src/agent/types.ts | 9 ++ src/renderer/src/components/Workbench.tsx | 16 ++ .../src/components/chat/SubagentCallCard.tsx | 58 ++++--- .../chat/message-timeline-bubbles.tsx | 8 +- .../chat/message-timeline-empty.tsx | 43 ++++- .../chat/message-timeline-process.tsx | 8 +- src/renderer/src/locales/en/common.json | 4 + src/renderer/src/locales/zh/common.json | 4 + .../src/store/chat-store-runtime-helpers.ts | 4 + .../src/store/chat-store-thread-actions.ts | 23 ++- src/renderer/src/store/chat-store-types.ts | 4 + src/renderer/src/store/chat-store.ts | 2 + 22 files changed, 429 insertions(+), 47 deletions(-) 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/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/SubagentCallCard.tsx b/src/renderer/src/components/chat/SubagentCallCard.tsx index 9f14ff02f..d63ca15a7 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,14 +392,14 @@ 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(' · ') @@ -411,7 +420,10 @@ export function SubagentCallCard({ }, [status, hasBody, inGroup]) const expanded = (userToggled ?? autoExpanded) && hasBody - const childId = child.childId + // `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 +482,20 @@ export function SubagentCallCard({ : ''} + {childId ? ( + + ) : null} {hasBody ? ( expanded ? ( @@ -503,20 +529,6 @@ export function SubagentCallCard({ {detail.toolPolicy === 'readOnly' ? t('subagentPolicyReadOnly') : t('subagentPolicyFull')} ) : null} - - {childId ? ( - - ) : null} ) : null} @@ -547,6 +559,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 +600,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/locales/en/common.json b/src/renderer/src/locales/en/common.json index 99344c5ae..57483f65c 100644 --- a/src/renderer/src/locales/en/common.json +++ b/src/renderer/src/locales/en/common.json @@ -2056,6 +2056,10 @@ "subagentPolicyReadOnly": "Read-only", "subagentPolicyFull": "Full access", "subagentOpenSession": "Open sub-session", + "subagentSessionBannerTitle": "Subagent session", + "subagentSessionBannerSub": "This is a subagent's own session, delegated from “{{title}}”.", + "subagentSessionBannerSubUnknown": "This is a subagent's own session, delegated from the parent conversation.", + "subagentSessionBannerBack": "Back to parent", "subagentSwarmTitle": "{{count}} subagents", "subagentSwarmRunning": "{{count}} running", "subagentSwarmQueued": "{{count}} queued", diff --git a/src/renderer/src/locales/zh/common.json b/src/renderer/src/locales/zh/common.json index a03a0f158..e4d1b6953 100644 --- a/src/renderer/src/locales/zh/common.json +++ b/src/renderer/src/locales/zh/common.json @@ -2056,6 +2056,10 @@ "subagentPolicyReadOnly": "只读", "subagentPolicyFull": "完整权限", "subagentOpenSession": "打开子会话", + "subagentSessionBannerTitle": "子代理会话", + "subagentSessionBannerSub": "这是子代理的独立会话,由「{{title}}」委派。", + "subagentSessionBannerSubUnknown": "这是子代理的独立会话,由上级会话委派。", + "subagentSessionBannerBack": "返回上级会话", "subagentSwarmTitle": "{{count}} 子代理", "subagentSwarmRunning": "{{count}} 运行", "subagentSwarmQueued": "{{count}} 排队", diff --git a/src/renderer/src/store/chat-store-runtime-helpers.ts b/src/renderer/src/store/chat-store-runtime-helpers.ts index 1de95cd18..375dae170 100644 --- a/src/renderer/src/store/chat-store-runtime-helpers.ts +++ b/src/renderer/src/store/chat-store-runtime-helpers.ts @@ -178,6 +178,8 @@ export function collectAssistantTextForTurn( export function clearedThreadSelection(): Pick< ChatState, | 'activeThreadId' + | 'activeThreadRelation' + | 'activeThreadParentId' | 'activeThreadGoal' | 'activeThreadTodos' | 'blocks' @@ -196,6 +198,8 @@ export function clearedThreadSelection(): Pick< > { 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..c00609a9b 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, 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: [], From a1d365bfd50a6cdd18ed63dd7a5e4f42b0abef41 Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Fri, 26 Jun 2026 00:26:47 +0800 Subject: [PATCH 3/6] feat: optimize thread event subscription to improve conversation flow --- .../src/store/chat-store-thread-actions.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/renderer/src/store/chat-store-thread-actions.ts b/src/renderer/src/store/chat-store-thread-actions.ts index c00609a9b..041977552 100644 --- a/src/renderer/src/store/chat-store-thread-actions.ts +++ b/src/renderer/src/store/chat-store-thread-actions.ts @@ -990,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 @@ -1004,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) { From 67f9fe09526c6d59494b9ea4921d16b4f6c8195c Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Fri, 26 Jun 2026 00:46:06 +0800 Subject: [PATCH 4/6] feat: update branch creation and switching labels in localization files --- .../src/components/chat/GitBranchPicker.tsx | 250 ++++++++++++------ src/renderer/src/locales/en/common.json | 7 +- src/renderer/src/locales/zh/common.json | 7 +- 3 files changed, 174 insertions(+), 90 deletions(-) diff --git a/src/renderer/src/components/chat/GitBranchPicker.tsx b/src/renderer/src/components/chat/GitBranchPicker.tsx index 2a590fcea..ad3b49459 100644 --- a/src/renderer/src/components/chat/GitBranchPicker.tsx +++ b/src/renderer/src/components/chat/GitBranchPicker.tsx @@ -1,6 +1,6 @@ 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 { getProvider } from '../../agent/registry' @@ -38,6 +38,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 +65,7 @@ export function GitBranchPicker({ workspaceRoot }: Props): ReactElement | null { setResult(null) setError(null) setActingBranch(null) + setActingKind(null) }, [root]) useEffect(() => { @@ -100,30 +102,14 @@ 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 switchTarget = exactBranchExists ? trimmedQuery : '' 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 +148,55 @@ 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) + } + } + + 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 +216,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 +244,7 @@ export function GitBranchPicker({ workspaceRoot }: Props): ReactElement | null { setError(e instanceof Error ? e.message : String(e)) } finally { setActingBranch(null) + setActingKind(null) } } @@ -245,12 +280,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 (switchTarget) { + e.preventDefault() + void switchBranch(switchTarget) } } }} @@ -278,72 +314,114 @@ 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/locales/en/common.json b/src/renderer/src/locales/en/common.json index 57483f65c..3b315a97c 100644 --- a/src/renderer/src/locales/en/common.json +++ b/src/renderer/src/locales/en/common.json @@ -1474,8 +1474,11 @@ "gitBranchLoading": "Loading branches…", "gitDirtyFiles": "Uncommitted: {{count}} files", "gitNoBranches": "No matching branches.", - "gitCreateBranch": "Create worktree…", - "gitCreateNamedBranch": "Create worktree on new branch {{branch}}", + "gitCreateBranch": "Create branch…", + "gitCreateNamedBranch": "Create and switch to {{branch}}", + "gitSwitchToNamedBranch": "Switch to {{branch}}", + "gitOpenBranchWorktree": "Open {{branch}} in a worktree", + "gitNewBranchWorktree": "Create worktree for new branch {{branch}}", "gitCheckoutNamedBranchWorktree": "Create worktree from {{branch}}", "sidebarThreadWorktreeBadge": "Worktree", "sidebarThreadWorktree": "Worktree: {{branch}}", diff --git a/src/renderer/src/locales/zh/common.json b/src/renderer/src/locales/zh/common.json index e4d1b6953..3038fb160 100644 --- a/src/renderer/src/locales/zh/common.json +++ b/src/renderer/src/locales/zh/common.json @@ -1474,8 +1474,11 @@ "gitBranchLoading": "正在读取分支…", "gitDirtyFiles": "未提交:{{count}} 个文件", "gitNoBranches": "没有匹配的分支。", - "gitCreateBranch": "创建 worktree…", - "gitCreateNamedBranch": "创建新分支 {{branch}} 的 worktree", + "gitCreateBranch": "创建分支…", + "gitCreateNamedBranch": "创建并切换到 {{branch}}", + "gitSwitchToNamedBranch": "切换到 {{branch}}", + "gitOpenBranchWorktree": "在 worktree 中打开 {{branch}}", + "gitNewBranchWorktree": "为新分支 {{branch}} 创建 worktree", "gitCheckoutNamedBranchWorktree": "从 {{branch}} 创建 worktree", "sidebarThreadWorktreeBadge": "工作树", "sidebarThreadWorktree": "工作树:{{branch}}", From 6b744253e44befd9eb92817d89b186bd7626b8b4 Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Fri, 26 Jun 2026 00:49:36 +0800 Subject: [PATCH 5/6] feat: update SubagentCallCard to always start collapsed and remove auto-expand logic --- .../src/components/chat/SubagentCallCard.tsx | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/src/renderer/src/components/chat/SubagentCallCard.tsx b/src/renderer/src/components/chat/SubagentCallCard.tsx index d63ca15a7..7ca6552d1 100644 --- a/src/renderer/src/components/chat/SubagentCallCard.tsx +++ b/src/renderer/src/components/chat/SubagentCallCard.tsx @@ -406,19 +406,11 @@ export function SubagentCallCard({ 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 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 From 3369dd4330feb295e7405b953580513a8d80e129 Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Fri, 26 Jun 2026 00:57:33 +0800 Subject: [PATCH 6/6] feat: enhance git branch management with worktree support and localization updates --- src/main/services/git-service.test.ts | 34 +++++++ src/main/services/git-service.ts | 27 ++++-- .../src/components/chat/GitBranchPicker.tsx | 90 +++++++++++++++++-- src/renderer/src/locales/en/common.json | 2 + src/renderer/src/locales/zh/common.json | 2 + src/shared/git-branches.ts | 12 +++ 6 files changed, 154 insertions(+), 13 deletions(-) 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/components/chat/GitBranchPicker.tsx b/src/renderer/src/components/chat/GitBranchPicker.tsx index ad3b49459..bb311a2f6 100644 --- a/src/renderer/src/components/chat/GitBranchPicker.tsx +++ b/src/renderer/src/components/chat/GitBranchPicker.tsx @@ -2,10 +2,15 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactElement } import { createPortal } from 'react-dom' 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' @@ -103,7 +108,9 @@ export function GitBranchPicker({ workspaceRoot }: Props): ReactElement | null { const trimmedQuery = query.trim() const exactBranchExists = branches.some((branch) => branch.name === trimmedQuery) const canCreate = trimmedQuery.length > 0 && !exactBranchExists - const switchTarget = exactBranchExists ? trimmedQuery : '' + 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 footerBranchLabel = middleEllipsize(trimmedQuery, BRANCH_FOOTER_LABEL_MAX_LENGTH) @@ -170,6 +177,57 @@ export function GitBranchPicker({ workspaceRoot }: Props): ReactElement | 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 @@ -284,9 +342,9 @@ export function GitBranchPicker({ workspaceRoot }: Props): ReactElement | null { if (canCreate) { e.preventDefault() void createAndSwitchBranch() - } else if (switchTarget) { + } else if (switchTargetRow) { e.preventDefault() - void switchBranch(switchTarget) + selectBranch(switchTargetRow) } } }} @@ -324,10 +382,22 @@ export function GitBranchPicker({ workspaceRoot }: Props): ReactElement | null {