From 660a056b8743ecdca083b56d66655036122d9235 Mon Sep 17 00:00:00 2001 From: xingyu Date: Sun, 12 Jul 2026 17:07:28 +0800 Subject: [PATCH 001/110] fix(extensions): make docs containment cross-platform (#874) --- kun/src/extensions/host-process.ts | 44 ++++++++++++------- .../scripts/generate-manifest-schema.mjs | 2 +- scripts/check-extension-docs.test.mjs | 24 +++++++++- scripts/check-extension-release-gate.mjs | 2 + scripts/lib/extension-docs-validation.mjs | 13 +++++- 5 files changed, 66 insertions(+), 19 deletions(-) diff --git a/kun/src/extensions/host-process.ts b/kun/src/extensions/host-process.ts index 60a2f9a71..33c10f2db 100644 --- a/kun/src/extensions/host-process.ts +++ b/kun/src/extensions/host-process.ts @@ -370,11 +370,22 @@ export class ExtensionHostProcess { state: this._state }) } - return this.peer!.request('extension.invoke', { method, params }, { - signal: options.signal, - timeoutMs: options.timeoutMs ?? this.limits.operationTimeoutMs, - resetTimeoutOnStream: options.resetTimeoutOnStream - }) + try { + return await this.peer!.request('extension.invoke', { method, params }, { + signal: options.signal, + timeoutMs: options.timeoutMs ?? this.limits.operationTimeoutMs, + resetTimeoutOnStream: options.resetTimeoutOnStream + }) + } catch (error) { + if ( + this.exitPromise !== undefined && + this.child !== undefined && + (this.child.exitCode !== null || this.child.signalCode !== null) + ) { + await this.exitPromise + } + throw error + } } async notify(method: string, params: JsonValue): Promise { @@ -539,16 +550,19 @@ export class ExtensionHostProcess { await this.log.write('lifecycle', `exited expected=${expected} code=${code} signal=${signal}`) .catch(() => undefined) await this.log.flush().catch(() => undefined) - this.resolveExit?.() - this.resolveExit = undefined - await this.options.onExit?.({ - extensionId: this.principal.extensionId, - lifecycleNonce: this.lifecycleNonce, - expected, - code, - signal, - ...(this._lastError === undefined ? {} : { error: this._lastError }) - }) + try { + await this.options.onExit?.({ + extensionId: this.principal.extensionId, + lifecycleNonce: this.lifecycleNonce, + expected, + code, + signal, + ...(this._lastError === undefined ? {} : { error: this._lastError }) + }) + } finally { + this.resolveExit?.() + this.resolveExit = undefined + } } private send(envelope: RpcEnvelope): Promise { diff --git a/packages/extension-api/scripts/generate-manifest-schema.mjs b/packages/extension-api/scripts/generate-manifest-schema.mjs index 3fe7f36f1..286d5c0f9 100644 --- a/packages/extension-api/scripts/generate-manifest-schema.mjs +++ b/packages/extension-api/scripts/generate-manifest-schema.mjs @@ -29,7 +29,7 @@ const output = `${JSON.stringify(schema, null, 2)}\n` if (process.argv.includes('--check')) { const current = await readFile(outputPath, 'utf8').catch(() => '') - if (current !== output) { + if (current.replace(/\r\n/gu, '\n') !== output) { console.error('EXT_SCHEMA_STALE: schema/kun-extension.schema.json is not generated from ExtensionManifestSchema') process.exitCode = 1 } diff --git a/scripts/check-extension-docs.test.mjs b/scripts/check-extension-docs.test.mjs index b04e5bee0..0252bc66d 100644 --- a/scripts/check-extension-docs.test.mjs +++ b/scripts/check-extension-docs.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' -import { dirname, join } from 'node:path' +import { dirname, join, posix, win32 } from 'node:path' import test from 'node:test' import { fileURLToPath } from 'node:url' import { @@ -9,6 +9,7 @@ import { SDK_SNAPSHOTS_BEGIN, SDK_SNAPSHOTS_END, githubHeadingSlug, + isPathWithinRoot, renderApiExportsRegion, renderSdkSnapshotsRegion, validateBilingualPair, @@ -110,6 +111,27 @@ test('detects generated API inventory and Changelog public-surface drift', () => )[0].includes('drifted')) }) +test('contains public SDK declarations across native and mixed Windows separators', () => { + assert.equal( + isPathWithinRoot( + 'D:\\a\\Kun\\Kun\\packages\\extension-api\\src', + 'D:/a/Kun/Kun/packages/extension-api/src/accounts.ts', + win32 + ), + true + ) + assert.equal( + isPathWithinRoot( + 'D:\\a\\Kun\\Kun\\packages\\extension-api\\src', + 'D:/a/Kun/Kun/packages/extension-api/src-escape/accounts.ts', + win32 + ), + false + ) + assert.equal(isPathWithinRoot('/repo/packages/api/src', '/repo/packages/api/src/index.ts', posix), true) + assert.equal(isPathWithinRoot('/repo/packages/api/src', '/repo/packages/other/index.ts', posix), false) +}) + function fixture(name) { return readFile(join(fixtures, name), 'utf8') } diff --git a/scripts/check-extension-release-gate.mjs b/scripts/check-extension-release-gate.mjs index bd8884bdb..9a5b4e41f 100644 --- a/scripts/check-extension-release-gate.mjs +++ b/scripts/check-extension-release-gate.mjs @@ -193,6 +193,7 @@ function requirePublishDependencies(document, workflowLabel) { } function requireOrderedSourceMarkers(source, label, markers) { + source = source.replace(/\r\n/gu, '\n') let priorIndex = -1 for (const marker of markers) { const index = source.indexOf(marker, priorIndex + 1) @@ -202,6 +203,7 @@ function requireOrderedSourceMarkers(source, label, markers) { } function requireSourceMarkersAfter(source, label, priorMarker, markers) { + source = source.replace(/\r\n/gu, '\n') const priorIndex = source.indexOf(priorMarker) check(priorIndex >= 0, `${label} is missing required gate marker: ${priorMarker}`) for (const marker of markers) { diff --git a/scripts/lib/extension-docs-validation.mjs b/scripts/lib/extension-docs-validation.mjs index 37a8cc4db..9b45d8ec9 100644 --- a/scripts/lib/extension-docs-validation.mjs +++ b/scripts/lib/extension-docs-validation.mjs @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto' import { access, readFile, readdir } from 'node:fs/promises' -import { dirname, extname, join, relative, resolve, sep } from 'node:path' +import { dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path' import ts from 'typescript' export const API_EXPORTS_BEGIN = '' @@ -244,7 +244,7 @@ export async function inspectPublicSdkPackages(root) { const declaration = symbol.valueDeclaration ?? symbol.declarations?.[0] if (!declaration) throw new Error(`Public export ${exportSymbol.name} has no declaration`) const sourceFile = declaration.getSourceFile().fileName - if (!sourceFile.startsWith(`${join(packageRoot, 'src')}${sep}`) && sourceFile !== entryPath) { + if (!isPathWithinRoot(join(packageRoot, 'src'), sourceFile)) { throw new Error(`Public export ${exportSymbol.name} escapes ${definition.name}: ${sourceFile}`) } return { @@ -271,6 +271,15 @@ export async function inspectPublicSdkPackages(root) { return result } +export function isPathWithinRoot(root, candidate, pathApi = { isAbsolute, relative, sep }) { + const relativePath = pathApi.relative(root, candidate) + return relativePath === '' || ( + relativePath !== '..' && + !relativePath.startsWith(`..${pathApi.sep}`) && + !pathApi.isAbsolute(relativePath) + ) +} + export function renderApiExportsRegion(sdkPackages, locale) { const summaryLabels = locale === 'zh' ? ['SDK 包', '版本', '公开入口', '公开导出数', '公开 surface SHA-256'] From 64c6ccfae1e42a991ce3597602db8a805c32512b Mon Sep 17 00:00:00 2001 From: luoye520ww <100058663+luoye520ww@users.noreply.github.com> Date: Sat, 11 Jul 2026 01:39:23 +0800 Subject: [PATCH 002/110] fix(write): preserve completion word boundaries --- .../write/inline-completion/feedback.test.ts | 40 +++++++++++++++++++ .../src/write/inline-completion/feedback.ts | 28 ++++++++++++- 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/write/inline-completion/feedback.test.ts b/src/renderer/src/write/inline-completion/feedback.test.ts index cacde3683..5097b56f3 100644 --- a/src/renderer/src/write/inline-completion/feedback.test.ts +++ b/src/renderer/src/write/inline-completion/feedback.test.ts @@ -110,4 +110,44 @@ describe('evaluateInlineCompletionCandidate', () => { }) expect(decision.feedback.reason).toBe('model-selected-edit') }) + + it('adds a missing space between adjacent Latin words', () => { + const decision = evaluateInlineCompletionCandidate( + context({ + currentLinePrefix: 'hello', + prefixWindow: 'hello', + endsWithWordChar: true + }), + { text: 'world', action: { kind: 'short', text: 'world' } }, + { minAcceptScore: 0.52, mode: 'short' } + ) + + expect(decision.accepted).toBe(true) + expect(decision.text).toBe(' world') + expect(decision.action).toEqual({ kind: 'short', text: ' world' }) + }) + + it.each([ + ['existing whitespace', 'hello', ' world', false, false, ' world'], + ['punctuation continuation', 'hello', ', world', false, false, ', world'], + ['cursor inside a word', 'hello', 'world', true, false, 'world'], + ['URL continuation', 'https://example.com', 'path', false, true, 'path'], + ['CJK continuation', '你好', '世界', false, false, '世界'] + ])('keeps %s unchanged', (_label, prefix, suggestion, nextCharIsWord, looksLikeUrlTail, expected) => { + const decision = evaluateInlineCompletionCandidate( + context({ + currentLinePrefix: prefix, + prefixWindow: prefix, + endsWithWordChar: true, + nextCharIsWord, + looksLikeUrlTail + }), + { text: suggestion, action: { kind: 'short', text: suggestion } }, + { minAcceptScore: 0, mode: 'short' } + ) + + expect(decision.accepted).toBe(true) + expect(decision.text).toBe(expected) + expect(decision.action).toEqual({ kind: 'short', text: expected }) + }) }) diff --git a/src/renderer/src/write/inline-completion/feedback.ts b/src/renderer/src/write/inline-completion/feedback.ts index b332cbcc7..eb1829e26 100644 --- a/src/renderer/src/write/inline-completion/feedback.ts +++ b/src/renderer/src/write/inline-completion/feedback.ts @@ -33,6 +33,29 @@ function compactText(text = ''): string { return sanitizeText(text).replace(/\s+/g, ' ').trim() } +const SPACE_SEPARATED_WORD_CHAR = /[\p{Script=Latin}\p{Number}_]/u + +function normalizeCompletionBoundary( + context: InlineCompletionRequestContext, + text: string +): string { + if ( + !context.endsWithWordChar || + context.nextCharIsWord || + context.looksLikeUrlTail || + !text || + /^\s/u.test(text) + ) { + return text + } + + const previous = context.currentLinePrefix.at(-1) ?? '' + const next = text.at(0) ?? '' + return SPACE_SEPARATED_WORD_CHAR.test(previous) && SPACE_SEPARATED_WORD_CHAR.test(next) + ? ` ${text}` + : text +} + function clipPreview(text = '', maxChars = 100): string { const normalized = compactText(text) if (normalized.length <= maxChars) return normalized @@ -193,7 +216,10 @@ export function evaluateInlineCompletionCandidate( const rawAction = actionFromSuggestion(suggestion, requestedMode) const isEditAction = rawAction?.kind === 'edit' const structuredModelAction = hasStructuredModelAction(suggestion) - const text = sanitizeText(rawAction ? actionText(rawAction) : '') + const sanitizedText = sanitizeText(rawAction ? actionText(rawAction) : '') + const text = isEditAction + ? sanitizedText + : normalizeCompletionBoundary(context, sanitizedText) const mode = rawAction?.kind ?? requestedMode const minAcceptScore = Number.isFinite(options.minAcceptScore) ? Number(options.minAcceptScore) From 1ccc05054a63d59d7fea0382686f48b1fed13904 Mon Sep 17 00:00:00 2001 From: luoye520ww <100058663+luoye520ww@users.noreply.github.com> Date: Sat, 11 Jul 2026 01:58:07 +0800 Subject: [PATCH 003/110] fix(write): isolate assistant context by file --- .../useWorkbenchNavigationController.ts | 2 +- .../useWorkbenchWriteAssistantRuntime.ts | 44 +++++- .../chat-store-navigation-actions.test.ts | 86 +++++++++++ .../store/chat-store-navigation-actions.ts | 32 +++- src/renderer/src/store/chat-store-types.ts | 4 +- .../src/write/write-thread-registry.test.ts | 138 +++++++++++++++++- .../src/write/write-thread-registry.ts | 128 ++++++++++++++-- .../write-workspace-file-actions.test.ts | 122 +++++++++++++--- .../src/write/write-workspace-file-actions.ts | 11 ++ 9 files changed, 526 insertions(+), 41 deletions(-) diff --git a/src/renderer/src/components/workbench/useWorkbenchNavigationController.ts b/src/renderer/src/components/workbench/useWorkbenchNavigationController.ts index 8a61855dc..db9794ab0 100644 --- a/src/renderer/src/components/workbench/useWorkbenchNavigationController.ts +++ b/src/renderer/src/components/workbench/useWorkbenchNavigationController.ts @@ -274,7 +274,7 @@ export function useWorkbenchNavigationController({ const writeWorkspaceRoot = writeState.workspaceRoot || workspaceRoot setInput('') writeState.clearQuotedSelections() - void createWriteThread(writeWorkspaceRoot) + void createWriteThread(writeWorkspaceRoot, writeState.activeFilePath ?? undefined) }, [createWriteThread, setInput, workspaceRoot]) const pickWriteAssistantWorkspace = useCallback(async (): Promise => { diff --git a/src/renderer/src/components/workbench/useWorkbenchWriteAssistantRuntime.ts b/src/renderer/src/components/workbench/useWorkbenchWriteAssistantRuntime.ts index b3efee88e..c1bc1a925 100644 --- a/src/renderer/src/components/workbench/useWorkbenchWriteAssistantRuntime.ts +++ b/src/renderer/src/components/workbench/useWorkbenchWriteAssistantRuntime.ts @@ -1,10 +1,15 @@ -import { useMemo } from 'react' +import { useEffect, useMemo, useRef } from 'react' import type { ModelProviderModelGroup } from '@shared/kun-gui-api' import { buildComposerAssistantPickList, resolveComposerAssistantProviderId } from '../chat/composer-model-selection' import { useWriteWorkspaceStore } from '../../write/write-workspace-store' +import { useChatStore } from '../../store/chat-store' +import { + activeWriteThreadForWorkspace, + readWriteThreadRegistry +} from '../../write/write-thread-registry' type WorkbenchWriteAssistantRuntimeOptions = { composerPickList: string[] @@ -19,7 +24,14 @@ export function useWorkbenchWriteAssistantRuntime({ const setWriteAssistantOpen = useWriteWorkspaceStore((s) => s.setAssistantOpen) const writeAssistantModel = useWriteWorkspaceStore((s) => s.assistantModel) const writeAssistantProviderId = useWriteWorkspaceStore((s) => s.assistantProviderId) + const writeWorkspaceRoot = useWriteWorkspaceStore((s) => s.workspaceRoot) + const activeWriteFilePath = useWriteWorkspaceStore((s) => s.activeFilePath) const setWriteAssistantModel = useWriteWorkspaceStore((s) => s.setAssistantModel) + const route = useChatStore((s) => s.route) + const runtimeConnection = useChatStore((s) => s.runtimeConnection) + const activeThreadId = useChatStore((s) => s.activeThreadId) + const threads = useChatStore((s) => s.threads) + const pendingThreadIdRef = useRef(null) const writeAssistantPickList = useMemo(() => { return buildComposerAssistantPickList({ composerPickList @@ -33,6 +45,36 @@ export function useWorkbenchWriteAssistantRuntime({ }) }, [composerModelGroups, writeAssistantModel, writeAssistantProviderId]) + useEffect(() => { + if (route !== 'write' || !writeWorkspaceRoot) return + const chatState = useChatStore.getState() + if (!activeWriteFilePath) { + if (activeThreadId) chatState.clearActiveThreadSelection() + return + } + if (runtimeConnection !== 'ready') { + if (activeThreadId) chatState.clearActiveThreadSelection() + return + } + + const target = activeWriteThreadForWorkspace( + writeWorkspaceRoot, + threads, + readWriteThreadRegistry(), + activeWriteFilePath + ) + if (target?.id === activeThreadId) return + if (target) { + if (pendingThreadIdRef.current === target.id) return + pendingThreadIdRef.current = target.id + void chatState.selectWriteThread(target.id, writeWorkspaceRoot).finally(() => { + if (pendingThreadIdRef.current === target.id) pendingThreadIdRef.current = null + }) + } else if (activeThreadId) { + chatState.clearActiveThreadSelection() + } + }, [activeThreadId, activeWriteFilePath, route, runtimeConnection, threads, writeWorkspaceRoot]) + return { resolvedWriteAssistantProviderId, setWriteAssistantModel, diff --git a/src/renderer/src/store/chat-store-navigation-actions.test.ts b/src/renderer/src/store/chat-store-navigation-actions.test.ts index 289386ddf..6d78f0fc4 100644 --- a/src/renderer/src/store/chat-store-navigation-actions.test.ts +++ b/src/renderer/src/store/chat-store-navigation-actions.test.ts @@ -8,6 +8,14 @@ import { markDesignThread, saveDesignThreadRegistry } from '../design/design-thread-registry' +import { + activeWriteThreadForWorkspace, + emptyWriteThreadRegistry, + markWriteThread, + readWriteThreadRegistry, + saveWriteThreadRegistry +} from '../write/write-thread-registry' +import { useWriteWorkspaceStore } from '../write/write-workspace-store' const registryMock = vi.hoisted(() => ({ getProvider: vi.fn() @@ -343,6 +351,84 @@ describe('chat-store navigation workspace selection', () => { }) }) +describe('write assistant file conversation selection', () => { + beforeEach(() => { + rendererRuntimeClient.invalidateSettings() + registryMock.getProvider.mockReset() + }) + + afterEach(() => { + useWriteWorkspaceStore.getState().resetWorkspace() + rendererRuntimeClient.invalidateSettings() + vi.unstubAllGlobals() + }) + + it('selects the conversation mapped to the active file', async () => { + const storage = new MemoryStorage() + const workspace = '/Users/zxy/write' + const registry = markWriteThread( + workspace, + 'thr_b', + markWriteThread(workspace, 'thr_a', emptyWriteThreadRegistry(), `${workspace}/a.md`), + `${workspace}/b.md` + ) + saveWriteThreadRegistry(registry, storage) + vi.stubGlobal('window', { localStorage: storage }) + useWriteWorkspaceStore.setState({ + workspaceRoot: workspace, + activeFilePath: `${workspace}/b.md`, + activeFileKind: 'text' + }) + const harness = buildHarness() + Object.assign(harness.state, harness.actions) + harness.state.activeThreadId = 'thr_a' + harness.state.workspaceRoot = workspace + harness.state.threads = [ + thread({ id: 'thr_a', workspace }), + thread({ id: 'thr_b', workspace }) + ] + + await expect(harness.actions.ensureWriteThreadForWorkspace(workspace)).resolves.toBe('thr_b') + expect(harness.selectThread).toHaveBeenCalledWith('thr_b') + }) + + it('creates and records a fresh conversation for an unmapped file', async () => { + const storage = new MemoryStorage() + const workspace = '/Users/zxy/write' + const activeFilePath = `${workspace}/new.md` + vi.stubGlobal('window', { localStorage: storage }) + useWriteWorkspaceStore.setState({ + workspaceRoot: workspace, + activeFilePath, + activeFileKind: 'text' + }) + const created = thread({ id: 'thr_new', workspace, title: 'Write Assistant' }) + const createThread = vi.fn(async () => created) + registryMock.getProvider.mockReturnValue({ createThread }) + const harness = buildHarness() + Object.assign(harness.state, harness.actions) + harness.state.activeThreadId = null + harness.state.workspaceRoot = workspace + harness.state.threads = [] + + await expect(harness.actions.ensureWriteThreadForWorkspace(workspace)).resolves.toBe('thr_new') + + const registry = readWriteThreadRegistry(storage) + expect(createThread).toHaveBeenCalledWith({ + workspace, + title: 'Write Assistant', + mode: 'agent' + }) + expect(activeWriteThreadForWorkspace( + workspace, + [created], + registry, + activeFilePath + )?.id).toBe('thr_new') + expect(harness.selectThread).toHaveBeenCalledWith('thr_new') + }) +}) + describe('onClawChannelActivity routes through subscribeThreadEventsLive (not selectThread)', () => { beforeEach(() => { rendererRuntimeClient.invalidateSettings() diff --git a/src/renderer/src/store/chat-store-navigation-actions.ts b/src/renderer/src/store/chat-store-navigation-actions.ts index 1e9177557..e790655b7 100644 --- a/src/renderer/src/store/chat-store-navigation-actions.ts +++ b/src/renderer/src/store/chat-store-navigation-actions.ts @@ -31,7 +31,8 @@ import { isConversationWorkspacePath, isInternalDeepSeekGuiWorkspace, isInternalTemporaryWorkspace, - normalizeWorkspaceRoot + normalizeWorkspaceRoot, + workspaceRootIdentityKey } from '../lib/workspace-path' import { resolveProjectWorkspacePath } from '../lib/worktree-project-path' import { readThreadWorktreeRegistry } from '../lib/thread-worktree-registry' @@ -72,6 +73,7 @@ import { writeThreadBelongsToWorkspace, writeWorkspaceForThreadId } from '../write/write-thread-registry' +import { useWriteWorkspaceStore } from '../write/write-workspace-store' import { DESIGN_ASSISTANT_THREAD_TITLE, activeDesignThreadForWorkspace, @@ -264,13 +266,19 @@ export function createNavigationActions( syncTurnCompletionPoll(set, get) }, - ensureWriteThreadForWorkspace: async (workspaceRoot) => { + ensureWriteThreadForWorkspace: async (workspaceRoot, activeFilePath) => { const state = get() const targetWorkspace = normalizeWorkspaceRoot(workspaceRoot) || (await readActiveWriteWorkspace(state.workspaceRoot)) if (!targetWorkspace) { set({ error: i18n.t('common:workspaceRequiredToCreateThread') }) return null } + const writeState = useWriteWorkspaceStore.getState() + const targetFilePath = activeFilePath?.trim() || ( + workspaceRootIdentityKey(writeState.workspaceRoot) === workspaceRootIdentityKey(targetWorkspace) + ? writeState.activeFilePath?.trim() || undefined + : undefined + ) if (state.runtimeConnection !== 'ready') { set({ error: i18n.t('common:runtimeActionNeedsConnection') }) return null @@ -285,22 +293,27 @@ export function createNavigationActions( const activeThread = state.activeThreadId ? state.threads.find((thread) => thread.id === state.activeThreadId) ?? null : null - if (activeThread && writeThreadBelongsToWorkspace(activeThread, targetWorkspace, registry)) { + const existing = activeWriteThreadForWorkspace( + targetWorkspace, + state.threads, + registry, + targetFilePath + ) + if (activeThread && existing?.id === activeThread.id) { set({ route: 'write', error: null }) return activeThread.id } - const existing = activeWriteThreadForWorkspace(targetWorkspace, state.threads, registry) if (existing) { set({ route: 'write' }) await get().selectThread(existing.id) return existing.id } - return get().createWriteThread(targetWorkspace) + return get().createWriteThread(targetWorkspace, targetFilePath) }, - createWriteThread: async (workspaceRoot) => { + createWriteThread: async (workspaceRoot, activeFilePath) => { const targetWorkspace = normalizeWorkspaceRoot(workspaceRoot) || (await readActiveWriteWorkspace(get().workspaceRoot)) if (!targetWorkspace) { set({ error: i18n.t('common:workspaceRequiredToCreateThread') }) @@ -317,7 +330,12 @@ export function createNavigationActions( title: WRITE_ASSISTANT_THREAD_TITLE, mode: 'agent' }) - saveWriteThreadRegistry(markWriteThread(targetWorkspace, thread.id)) + saveWriteThreadRegistry(markWriteThread( + targetWorkspace, + thread.id, + readWriteThreadRegistry(), + activeFilePath + )) set((s) => ({ route: 'write', threads: s.threads.some((item) => item.id === thread.id) ? s.threads : [thread, ...s.threads], diff --git a/src/renderer/src/store/chat-store-types.ts b/src/renderer/src/store/chat-store-types.ts index 019df2de5..bf98f8adb 100644 --- a/src/renderer/src/store/chat-store-types.ts +++ b/src/renderer/src/store/chat-store-types.ts @@ -241,8 +241,8 @@ export type ChatState = { setRoute: (r: AppRoute) => void openWrite: () => Promise openCode: () => Promise - ensureWriteThreadForWorkspace: (workspaceRoot?: string) => Promise - createWriteThread: (workspaceRoot?: string) => Promise + ensureWriteThreadForWorkspace: (workspaceRoot?: string, activeFilePath?: string) => Promise + createWriteThread: (workspaceRoot?: string, activeFilePath?: string) => Promise ensureDesignThreadForWorkspace: (workspaceRoot?: string, docId?: string) => Promise createDesignThread: (workspaceRoot?: string, docId?: string) => Promise selectWriteThread: (threadId: string, workspaceRoot?: string) => Promise diff --git a/src/renderer/src/write/write-thread-registry.test.ts b/src/renderer/src/write/write-thread-registry.test.ts index 808ede36a..908ef8051 100644 --- a/src/renderer/src/write/write-thread-registry.test.ts +++ b/src/renderer/src/write/write-thread-registry.test.ts @@ -6,10 +6,12 @@ import { WRITE_ASSISTANT_THREAD_TITLE, activeWriteThreadForWorkspace, emptyWriteThreadRegistry, + forgetWriteFileThreads, forgetWriteThread, hydrateWriteThreadRegistry, isWriteThreadId, markWriteThread, + moveWriteFileThreads, pruneWriteThreadRegistry, readWriteThreadRegistry, saveWriteThreadRegistry, @@ -58,6 +60,130 @@ describe('write-thread-registry', () => { expect(second.workspaces['/Users/zxy/workspace'].threadIds).toEqual(['thread-2', 'thread-1']) }) + it('keeps independent conversations for files in the same workspace', () => { + const workspace = '/Users/zxy/workspace' + const first = markWriteThread( + workspace, + 'thread-a', + emptyWriteThreadRegistry(), + `${workspace}/draft-a.md` + ) + const registry = markWriteThread( + workspace, + 'thread-b', + first, + `${workspace}/draft-b.md` + ) + const threads = [ + thread('thread-a', workspace), + thread('thread-b', workspace) + ] + + expect(activeWriteThreadForWorkspace( + workspace, + threads, + registry, + `${workspace}/draft-a.md` + )?.id).toBe('thread-a') + expect(activeWriteThreadForWorkspace( + workspace, + threads, + registry, + `${workspace}/draft-b.md` + )?.id).toBe('thread-b') + expect(activeWriteThreadForWorkspace( + workspace, + threads, + registry, + `${workspace}/new-file.md` + )).toBeNull() + }) + + it('does not assign a legacy workspace conversation to an arbitrary file', () => { + const workspace = '/Users/zxy/workspace' + const registry = markWriteThread(workspace, 'legacy-thread', emptyWriteThreadRegistry()) + const threads = [thread('legacy-thread', workspace)] + + expect(activeWriteThreadForWorkspace(workspace, threads, registry)?.id).toBe('legacy-thread') + expect(activeWriteThreadForWorkspace( + workspace, + threads, + registry, + `${workspace}/draft.md` + )).toBeNull() + }) + + it('keeps case-sensitive POSIX file paths separate', () => { + const workspace = '/Users/zxy/workspace' + const registry = markWriteThread( + workspace, + 'thread-lower', + markWriteThread( + workspace, + 'thread-upper', + emptyWriteThreadRegistry(), + `${workspace}/Foo.md` + ), + `${workspace}/foo.md` + ) + const threads = [ + thread('thread-upper', workspace), + thread('thread-lower', workspace) + ] + + expect(activeWriteThreadForWorkspace( + workspace, + threads, + registry, + `${workspace}/Foo.md` + )?.id).toBe('thread-upper') + expect(activeWriteThreadForWorkspace( + workspace, + threads, + registry, + `${workspace}/foo.md` + )?.id).toBe('thread-lower') + }) + + it('moves directory mappings on rename and removes them on delete', () => { + const workspace = '/Users/zxy/workspace' + const original = markWriteThread( + workspace, + 'thread-a', + emptyWriteThreadRegistry(), + `${workspace}/drafts/chapter.md` + ) + const moved = moveWriteFileThreads( + workspace, + `${workspace}/drafts`, + `${workspace}/archive`, + original + ) + const threads = [thread('thread-a', workspace)] + + expect(activeWriteThreadForWorkspace( + workspace, + threads, + moved, + `${workspace}/archive/chapter.md` + )?.id).toBe('thread-a') + expect(activeWriteThreadForWorkspace( + workspace, + threads, + moved, + `${workspace}/drafts/chapter.md` + )).toBeNull() + + const removed = forgetWriteFileThreads(workspace, `${workspace}/archive`, moved) + expect(activeWriteThreadForWorkspace( + workspace, + threads, + removed, + `${workspace}/archive/chapter.md` + )).toBeNull() + expect(isWriteThreadId('thread-a', removed)).toBe(true) + }) + it('caps remembered write thread ids per workspace', () => { let registry = emptyWriteThreadRegistry() for (let index = 0; index < MAX_WRITE_THREAD_IDS_PER_WORKSPACE + 5; index += 1) { @@ -94,12 +220,20 @@ describe('write-thread-registry', () => { }) it('prunes missing runtime threads and forgets deleted threads', () => { - const registry = markWriteThread('/Users/zxy/workspace', 'thread-2', - markWriteThread('/Users/zxy/workspace', 'thread-1', emptyWriteThreadRegistry())) + const workspace = '/Users/zxy/workspace' + const registry = markWriteThread( + workspace, + 'thread-2', + markWriteThread(workspace, 'thread-1', emptyWriteThreadRegistry(), `${workspace}/a.md`), + `${workspace}/b.md` + ) const pruned = pruneWriteThreadRegistry([thread('thread-1', '/Users/zxy/workspace')], registry) expect(isWriteThreadId('thread-2', pruned)).toBe(false) expect(pruned.workspaces['/Users/zxy/workspace'].activeThreadId).toBe('thread-1') + expect(pruned.workspaces[workspace].fileThreadIds).toEqual({ + '/Users/zxy/workspace/a.md': 'thread-1' + }) expect(forgetWriteThread('thread-1', pruned).workspaces['/Users/zxy/workspace']).toBeUndefined() }) diff --git a/src/renderer/src/write/write-thread-registry.ts b/src/renderer/src/write/write-thread-registry.ts index b579f8326..8295a7dd2 100644 --- a/src/renderer/src/write/write-thread-registry.ts +++ b/src/renderer/src/write/write-thread-registry.ts @@ -10,6 +10,7 @@ export const MAX_WRITE_THREAD_REGISTRY_WORKSPACES = 80 export type WriteThreadWorkspaceRecord = { activeThreadId: string threadIds: string[] + fileThreadIds: Record } export type WriteThreadRegistry = { @@ -30,6 +31,15 @@ export function writeWorkspaceKey(workspaceRoot: string | undefined | null): str return normalizeWorkspaceRoot(workspaceRoot ?? '') } +export function writeFileKey(filePath: string | undefined | null): string { + const normalized = (filePath ?? '').trim().replace(/\\/g, '/').replace(/\/+$/, '') + if (!normalized) return '' + const platform = typeof window !== 'undefined' ? window.kunGui?.platform : undefined + return platform === 'win32' || /^[A-Za-z]:\//.test(normalized) + ? normalized.toLowerCase() + : normalized +} + function normalizeWriteWorkspacePathForMatch(workspaceRoot: string | undefined | null): string { return writeWorkspaceKey(workspaceRoot) .replace(/\\/g, '/') @@ -94,6 +104,22 @@ function normalizeThreadIds(ids: unknown): string[] { return [...ordered].slice(0, MAX_WRITE_THREAD_IDS_PER_WORKSPACE) } +function normalizeFileThreadIds( + value: unknown, + threadIds: readonly string[] +): Record { + if (!value || typeof value !== 'object') return {} + const knownThreadIds = new Set(threadIds) + const entries: Array<[string, string]> = [] + for (const [filePath, threadId] of Object.entries(value as Record)) { + const key = writeFileKey(filePath) + const id = typeof threadId === 'string' ? threadId.trim() : '' + if (!key || !knownThreadIds.has(id)) continue + entries.push([key, id]) + } + return Object.fromEntries(entries.slice(-MAX_WRITE_THREAD_IDS_PER_WORKSPACE)) +} + function trimRegistryWorkspaces( workspaces: WriteThreadRegistry['workspaces'] ): WriteThreadRegistry['workspaces'] { @@ -111,7 +137,11 @@ export function normalizeWriteThreadRegistry(raw: unknown): WriteThreadRegistry for (const [workspaceRoot, value] of Object.entries(source.workspaces as Record)) { const key = writeWorkspaceKey(workspaceRoot) if (!key || !value || typeof value !== 'object') continue - const record = value as { activeThreadId?: unknown; threadIds?: unknown } + const record = value as { + activeThreadId?: unknown + threadIds?: unknown + fileThreadIds?: unknown + } const threadIds = normalizeThreadIds(record.threadIds) const activeThreadId = typeof record.activeThreadId === 'string' && record.activeThreadId.trim() @@ -124,7 +154,8 @@ export function normalizeWriteThreadRegistry(raw: unknown): WriteThreadRegistry if (cappedIds.length > 0) { workspaces[key] = { activeThreadId: cappedIds[0], - threadIds: cappedIds + threadIds: cappedIds, + fileThreadIds: normalizeFileThreadIds(record.fileThreadIds, cappedIds) } } } @@ -245,7 +276,8 @@ export function hydrateWriteThreadRegistry( current?.activeThreadId && threadIds.includes(current.activeThreadId) ? current.activeThreadId : threadIds[0], - threadIds + threadIds, + fileThreadIds: current?.fileThreadIds ?? {} } } @@ -255,20 +287,29 @@ export function hydrateWriteThreadRegistry( export function markWriteThread( workspaceRoot: string, threadId: string, - registry: WriteThreadRegistry = readWriteThreadRegistry() + registry: WriteThreadRegistry = readWriteThreadRegistry(), + filePath?: string ): WriteThreadRegistry { const key = writeWorkspaceKey(workspaceRoot) const id = threadId.trim() if (!key || !id) return registry - const record = registry.workspaces[key] ?? { activeThreadId: '', threadIds: [] } + const record = registry.workspaces[key] ?? { + activeThreadId: '', + threadIds: [], + fileThreadIds: {} + } const threadIds = [id, ...record.threadIds.filter((item) => item !== id)] + const fileKey = writeFileKey(filePath) + const fileThreadIds = fileKey + ? { ...record.fileThreadIds, [fileKey]: id } + : record.fileThreadIds const workspaces = { ...registry.workspaces } delete workspaces[key] return normalizeWriteThreadRegistry({ ...registry, workspaces: { ...workspaces, - [key]: { activeThreadId: id, threadIds } + [key]: { activeThreadId: id, threadIds, fileThreadIds } } }) } @@ -285,12 +326,71 @@ export function forgetWriteThread( if (threadIds.length === 0) continue workspaces[workspaceRoot] = { activeThreadId: record.activeThreadId === id ? threadIds[0] : record.activeThreadId, - threadIds + threadIds, + fileThreadIds: Object.fromEntries( + Object.entries(record.fileThreadIds).filter(([, mappedId]) => mappedId !== id) + ) } } return normalizeWriteThreadRegistry({ version: 1, workspaces }) } +export function moveWriteFileThreads( + workspaceRoot: string, + previousPath: string, + nextPath: string, + registry: WriteThreadRegistry = readWriteThreadRegistry() +): WriteThreadRegistry { + const workspaceKey = writeWorkspaceKey(workspaceRoot) + const previousKey = writeFileKey(previousPath) + const nextKey = writeFileKey(nextPath) + const record = registry.workspaces[workspaceKey] + if (!record || !previousKey || !nextKey || previousKey === nextKey) return registry + + let changed = false + const fileThreadIds: Record = {} + for (const [fileKey, threadId] of Object.entries(record.fileThreadIds)) { + if (fileKey === previousKey || fileKey.startsWith(`${previousKey}/`)) { + fileThreadIds[`${nextKey}${fileKey.slice(previousKey.length)}`] = threadId + changed = true + } else { + fileThreadIds[fileKey] = threadId + } + } + if (!changed) return registry + return normalizeWriteThreadRegistry({ + ...registry, + workspaces: { + ...registry.workspaces, + [workspaceKey]: { ...record, fileThreadIds } + } + }) +} + +export function forgetWriteFileThreads( + workspaceRoot: string, + path: string, + registry: WriteThreadRegistry = readWriteThreadRegistry() +): WriteThreadRegistry { + const workspaceKey = writeWorkspaceKey(workspaceRoot) + const fileKey = writeFileKey(path) + const record = registry.workspaces[workspaceKey] + if (!record || !fileKey) return registry + const fileThreadIds = Object.fromEntries( + Object.entries(record.fileThreadIds).filter(([candidate]) => + candidate !== fileKey && !candidate.startsWith(`${fileKey}/`) + ) + ) + if (Object.keys(fileThreadIds).length === Object.keys(record.fileThreadIds).length) return registry + return normalizeWriteThreadRegistry({ + ...registry, + workspaces: { + ...registry.workspaces, + [workspaceKey]: { ...record, fileThreadIds } + } + }) +} + export function pruneWriteThreadRegistry( threads: Pick[], registry: WriteThreadRegistry = readWriteThreadRegistry() @@ -303,7 +403,11 @@ export function pruneWriteThreadRegistry( const activeThreadId = threadIds.includes(record.activeThreadId) ? record.activeThreadId : threadIds[0] - workspaces[workspaceRoot] = { activeThreadId, threadIds } + workspaces[workspaceRoot] = { + activeThreadId, + threadIds, + fileThreadIds: normalizeFileThreadIds(record.fileThreadIds, threadIds) + } } return normalizeWriteThreadRegistry({ version: 1, workspaces }) } @@ -311,12 +415,16 @@ export function pruneWriteThreadRegistry( export function activeWriteThreadForWorkspace( workspaceRoot: string, threads: NormalizedThread[], - registry: WriteThreadRegistry = readWriteThreadRegistry() + registry: WriteThreadRegistry = readWriteThreadRegistry(), + filePath?: string ): NormalizedThread | null { const key = writeWorkspaceKey(workspaceRoot) if (!key) return null const record = registry.workspaces[key] if (!record) return null + const fileKey = writeFileKey(filePath) + const targetThreadId = fileKey ? record.fileThreadIds[fileKey] : record.activeThreadId + if (fileKey && !targetThreadId) return null const candidates = record.threadIds .map((id) => threads.find((thread) => thread.id === id) ?? null) .filter((thread): thread is NormalizedThread => Boolean(thread)) @@ -324,5 +432,5 @@ export function activeWriteThreadForWorkspace( .filter((thread) => writeWorkspacePathsMatch(writeWorkspaceForThreadId(thread.id, registry) || thread.workspace, key) ) - return candidates.find((thread) => thread.id === record.activeThreadId) ?? candidates[0] ?? null + return candidates.find((thread) => thread.id === targetThreadId) ?? (fileKey ? null : candidates[0] ?? null) } diff --git a/src/renderer/src/write/write-workspace-file-actions.test.ts b/src/renderer/src/write/write-workspace-file-actions.test.ts index 3ce056f86..2372b0017 100644 --- a/src/renderer/src/write/write-workspace-file-actions.test.ts +++ b/src/renderer/src/write/write-workspace-file-actions.test.ts @@ -3,6 +3,37 @@ import { defaultWriteSettings } from '@shared/app-settings' import { createWriteFileActions } from './write-workspace-file-actions' import { initialState } from './write-workspace-store-helpers' import type { WriteWorkspaceGet, WriteWorkspaceSet, WriteWorkspaceState } from './write-workspace-store-types' +import { + activeWriteThreadForWorkspace, + emptyWriteThreadRegistry, + markWriteThread, + readWriteThreadRegistry, + saveWriteThreadRegistry +} from './write-thread-registry' +import type { NormalizedThread } from '../agent/types' + +class MemoryStorage { + private values = new Map() + + getItem(key: string): string | null { + return this.values.get(key) ?? null + } + + setItem(key: string, value: string): void { + this.values.set(key, value) + } +} + +function writeThread(id: string, workspace: string): NormalizedThread { + return { + id, + title: 'Write Assistant', + updatedAt: '2026-07-11T00:00:00.000Z', + model: 'auto', + mode: 'agent', + workspace + } +} function deferred(): { promise: Promise; resolve: (value: T) => void } { let resolve!: (value: T) => void @@ -284,35 +315,52 @@ describe('write workspace file actions', () => { }) it('keeps markdown files visible when renaming without an extension', async () => { + const workspace = '/Users/zxy/write' + const storage = new MemoryStorage() + saveWriteThreadRegistry(markWriteThread( + workspace, + 'thread-draft', + emptyWriteThreadRegistry(), + `${workspace}/draft.md` + ), storage) const renameWorkspaceEntry = vi.fn(async () => ({ ok: true as const, - path: '/tmp/write/final.md', - previousPath: '/tmp/write/draft.md', + path: `${workspace}/final.md`, + previousPath: `${workspace}/draft.md`, renamedAt: '2026-06-21T00:00:00.000Z' })) - installDsGui({ - renameWorkspaceEntry, - listWorkspaceDirectory: vi.fn(async () => ({ - ok: true as const, - root: '/tmp/write', - entries: [{ - name: 'final.md', - path: '/tmp/write/final.md', - type: 'file' as const, - ext: '.md' - }] - })) + vi.stubGlobal('window', { + localStorage: storage, + kunGui: { + renameWorkspaceEntry, + listWorkspaceDirectory: vi.fn(async () => ({ + ok: true as const, + root: workspace, + entries: [{ + name: 'final.md', + path: `${workspace}/final.md`, + type: 'file' as const, + ext: '.md' + }] + })) + } }) const { actions } = createHarness() - const result = await actions.renameEntry('/tmp/write', '/tmp/write/draft.md', 'final') + const result = await actions.renameEntry(workspace, `${workspace}/draft.md`, 'final') - expect(result).toBe('/tmp/write/final.md') + expect(result).toBe(`${workspace}/final.md`) expect(renameWorkspaceEntry).toHaveBeenCalledWith({ - workspaceRoot: '/tmp/write', - path: '/tmp/write/draft.md', + workspaceRoot: workspace, + path: `${workspace}/draft.md`, newName: 'final.md' }) + expect(activeWriteThreadForWorkspace( + workspace, + [writeThread('thread-draft', workspace)], + readWriteThreadRegistry(storage), + `${workspace}/final.md` + )?.id).toBe('thread-draft') }) it('returns false and reports file errors when delete IPC throws', async () => { @@ -329,6 +377,44 @@ describe('write workspace file actions', () => { expect(get().fileError).toBe('delete failed') }) + it('removes deleted file conversation mappings without deleting thread history', async () => { + const workspace = '/Users/zxy/write' + const storage = new MemoryStorage() + saveWriteThreadRegistry(markWriteThread( + workspace, + 'thread-draft', + emptyWriteThreadRegistry(), + `${workspace}/drafts/chapter.md` + ), storage) + vi.stubGlobal('window', { + localStorage: storage, + kunGui: { + deleteWorkspaceEntry: vi.fn(async () => ({ + ok: true as const, + path: `${workspace}/drafts`, + deletedAt: '2026-07-11T00:00:00.000Z' + })), + listWorkspaceDirectory: vi.fn(async () => ({ + ok: true as const, + root: workspace, + entries: [] + })) + } + }) + const { actions } = createHarness() + + await expect(actions.deleteEntry(workspace, `${workspace}/drafts`)).resolves.toBe(true) + + const registry = readWriteThreadRegistry(storage) + expect(activeWriteThreadForWorkspace( + workspace, + [writeThread('thread-draft', workspace)], + registry, + `${workspace}/drafts/chapter.md` + )).toBeNull() + expect(registry.workspaces[workspace].threadIds).toContain('thread-draft') + }) + it('opens PDF files through the read-only PDF preview state', async () => { const readWorkspacePdf = vi.fn(async () => ({ ok: true as const, diff --git a/src/renderer/src/write/write-workspace-file-actions.ts b/src/renderer/src/write/write-workspace-file-actions.ts index e7b85b6fd..03addce54 100644 --- a/src/renderer/src/write/write-workspace-file-actions.ts +++ b/src/renderer/src/write/write-workspace-file-actions.ts @@ -15,6 +15,11 @@ import { rememberActiveFile, writeDirnameFromPath } from './write-workspace-store-helpers' +import { + forgetWriteFileThreads, + moveWriteFileThreads, + saveWriteThreadRegistry +} from './write-thread-registry' type WriteFileActions = Pick< WriteWorkspaceState, @@ -422,6 +427,11 @@ export function createWriteFileActions({ set({ fileError: result.message }) return null } + saveWriteThreadRegistry(moveWriteFileThreads( + workspaceRoot, + result.previousPath, + result.path + )) const previousPrefix = `${normalizePath(result.previousPath)}/` set((state) => { const nextActiveFilePath = state.activeFilePath === result.previousPath @@ -500,6 +510,7 @@ export function createWriteFileActions({ set({ fileError: result.message }) return false } + saveWriteThreadRegistry(forgetWriteFileThreads(workspaceRoot, result.path)) const deletedPath = normalizePath(result.path) const currentActiveFilePath = get().activeFilePath const activePath = currentActiveFilePath ? normalizePath(currentActiveFilePath) : '' From fd4f92c5835e938e9019d8936b4a5e4d802ec4ca Mon Sep 17 00:00:00 2001 From: luoye520ww <100058663+luoye520ww@users.noreply.github.com> Date: Sat, 11 Jul 2026 02:09:09 +0800 Subject: [PATCH 004/110] fix(write): keep selection actions visible --- .../src/components/write/WriteInlineAgent.tsx | 67 ++++++++--- .../write/write-workspace-view-utils.test.ts | 81 ++++++++++++- .../write/write-workspace-view-utils.ts | 106 +++++++++++++++++- 3 files changed, 234 insertions(+), 20 deletions(-) diff --git a/src/renderer/src/components/write/WriteInlineAgent.tsx b/src/renderer/src/components/write/WriteInlineAgent.tsx index 9ddc0770f..d9f934a63 100644 --- a/src/renderer/src/components/write/WriteInlineAgent.tsx +++ b/src/renderer/src/components/write/WriteInlineAgent.tsx @@ -41,7 +41,11 @@ import { WRITE_BLOCK_TYPES, type WriteBlockType } from '../../write/block-type' import type { WriteInlineFormatKind } from '../../write/inline-format' import type { ResolvedWriteQuickAction } from '../../write/quick-actions' import type { ResolvedWriteAgentPreset } from '../../write/agent-presets' -import { clamp, INLINE_AGENT_GAP, type WriteInlineAgentPosition } from './write-workspace-view-utils' +import { + inlineAgentPlacement, + type WriteInlineAgentPlacement, + type WriteInlineAgentPosition +} from './write-workspace-view-utils' type Props = { action: WriteInlineAgentPosition @@ -196,7 +200,11 @@ export function WriteInlineAgent({ }: Props): ReactElement { const { t } = useTranslation('common') const menuRef = useRef(null) - const [placement, setPlacement] = useState<{ top: number; origin: 'top-center' | 'bottom-center' } | null>(null) + const [placement, setPlacement] = useState(null) + const [viewport, setViewport] = useState(() => ({ + width: window.innerWidth, + height: window.innerHeight + })) const [blockMenuOpen, setBlockMenuOpen] = useState(false) const showBlockSelector = !imageMode && formattingEnabled && Boolean(onSetBlockType) @@ -212,24 +220,36 @@ export function WriteInlineAgent({ const activeBlock = BLOCK_TYPE_META[blockType] ?? BLOCK_TYPE_META.paragraph const ActiveBlockIcon = activeBlock.icon - // Measure the rendered menu and place it below the selection, flipping above - // when there isn't enough room. Runs before paint so there is no flash. + useLayoutEffect(() => { + const updateViewport = (): void => { + setViewport({ width: window.innerWidth, height: window.innerHeight }) + } + window.addEventListener('resize', updateViewport) + return () => window.removeEventListener('resize', updateViewport) + }, []) + + // Measure before paint, then choose a non-overlapping side of the selection. useLayoutEffect(() => { const el = menuRef.current if (!el) return - const height = el.offsetHeight - const viewportHeight = window.innerHeight - const below = action.anchorBottom + INLINE_AGENT_GAP - const above = action.anchorTop - height - INLINE_AGENT_GAP - const canPlaceAbove = above >= 16 - const placeAbove = preferAbove - ? canPlaceAbove - : below + height > viewportHeight - 16 && canPlaceAbove - const top = clamp(placeAbove ? above : below, 16, Math.max(16, viewportHeight - height - 16)) - setPlacement({ top, origin: placeAbove ? 'bottom-center' : 'top-center' }) + setPlacement(inlineAgentPlacement({ + left: action.left, + width: action.width, + anchorLeft: action.anchorLeft, + anchorRight: action.anchorRight, + anchorTop: action.anchorTop, + anchorBottom: action.anchorBottom + }, { + menuHeight: el.scrollHeight, + viewportWidth: viewport.width, + viewportHeight: viewport.height, + preferAbove + })) }, [ action.anchorTop, action.anchorBottom, + action.anchorLeft, + action.anchorRight, action.left, action.width, value, @@ -243,7 +263,9 @@ export function WriteInlineAgent({ showComposer, blockMenuOpen, quickActions.length, - preferAbove + preferAbove, + viewport.height, + viewport.width ]) const handleKeyDown = (event: ReactKeyboardEvent): void => { @@ -274,13 +296,22 @@ export function WriteInlineAgent({ data-origin={placement?.origin ?? 'top-center'} data-selection-ignore="true" style={{ - left: action.left, - top: placement?.top ?? action.anchorBottom + INLINE_AGENT_GAP, + left: placement?.left ?? action.left, + top: placement?.top ?? action.anchorBottom, width: action.width, + maxHeight: placement?.maxHeight, + transformOrigin: placement?.origin.replace('-', ' '), visibility: placement ? 'visible' : 'hidden' }} > -
+
{showBlockSelector ? (
{onCloseTarget ? (
) })} - {!openTargets.length && !target ? ( + {!visibleTargets.length ? (
- {badge} - {currentFileName} +
) : null}
+ {onTogglePreserveAcrossThreads ? ( + + ) : null}
) : imageResult?.ok ? ( -
+
{currentFileName} ) : null} {isSvgFile && svgRendered && !result.truncated ? ( -
+
{currentFileName}
) : isMarkdownFile && markdownRendered ? ( -
+
+ {tabMenu && typeof document !== 'undefined' ? createPortal( +
{ + if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return + event.preventDefault() + const items = Array.from( + event.currentTarget.querySelectorAll('[role^="menuitem"]:not(:disabled)') + ) + if (items.length === 0) return + const currentIndex = items.indexOf(document.activeElement as HTMLButtonElement) + const direction = event.key === 'ArrowDown' ? 1 : -1 + items[(currentIndex + direction + items.length) % items.length]?.focus() + }} + > + {onTogglePinnedTarget ? ( + + ) : null} + {onCloseOtherTargets ? ( + + ) : null} +
, + document.body + ) : null} ) } diff --git a/src/renderer/src/components/workbench-layout.ts b/src/renderer/src/components/workbench-layout.ts index f6f356c39..a6ee7f1ae 100644 --- a/src/renderer/src/components/workbench-layout.ts +++ b/src/renderer/src/components/workbench-layout.ts @@ -286,10 +286,6 @@ export function useWorkbenchLayout({ previewThreadId.current = activeThreadId autoOpenedPreviewUrlRef.current = null if (rightPanelMode === BUILTIN_RIGHT_PANEL_IDS.browser) setRightPanelMode(null) - if (rightPanelMode === BUILTIN_RIGHT_PANEL_IDS.file) { - setRightPanelMode(null) - setFilePreviewTarget(null) - } }, [activeThreadId, rightPanelMode]) useEffect(() => { diff --git a/src/renderer/src/components/workbench/useWorkbenchFileTreeController.test.ts b/src/renderer/src/components/workbench/useWorkbenchFileTreeController.test.ts new file mode 100644 index 000000000..bb38a5838 --- /dev/null +++ b/src/renderer/src/components/workbench/useWorkbenchFileTreeController.test.ts @@ -0,0 +1,261 @@ +import { createElement, useState } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import type { WorkspaceFileTarget } from '@shared/workspace-file' +import { BUILTIN_RIGHT_PANEL_IDS } from '../../extensions/contribution-ids' +import type { RightPanelMode } from '../chat/WorkbenchTopBar' +import { + closeFilePreviewTarget, + closeOtherFilePreviewTargets, + migrateLegacyPinnedFilePreviewTargetKeys, + parsePinnedFilePreviewTargetKeys, + retainFilePreviewTargets, + useWorkbenchFileTreeController, + workspaceFileTargetKey +} from './useWorkbenchFileTreeController' + +class MemoryStorage { + private readonly values = new Map() + + getItem(key: string): string | null { + return this.values.get(key) ?? null + } + + setItem(key: string, value: string): void { + this.values.set(key, value) + } + + removeItem(key: string): void { + this.values.delete(key) + } +} + +const targets: WorkspaceFileTarget[] = [ + { path: '/repo/docs/One.md', workspaceRoot: '/repo' }, + { path: '/repo/docs/two.md', workspaceRoot: '/repo' }, + { path: '/repo/docs/three.md', workspaceRoot: '/repo' } +] + +describe('file preview tab lifecycle helpers', () => { + it('preserves POSIX case while folding Windows drive and UNC targets', () => { + expect(workspaceFileTargetKey(targets[0], 'linux')).not.toBe( + workspaceFileTargetKey({ ...targets[0], path: '/repo/docs/one.md' }, 'linux') + ) + expect(workspaceFileTargetKey( + { path: 'C:\\Repo\\Docs\\One.md', workspaceRoot: 'C:\\Repo' }, + 'linux' + )).toBe('c:/repo\nc:/repo/docs/one.md') + expect(workspaceFileTargetKey( + { path: '\\\\Server\\Share\\One.md', workspaceRoot: '\\\\Server\\Share' }, + 'linux' + )).toBe('//server/share\n//server/share/one.md') + expect(workspaceFileTargetKey( + { path: ' /repo//docs/One.md ', workspaceRoot: '/repo///' }, + 'linux' + )).toBe('/repo\n/repo/docs/One.md') + }) + + it('keeps only pinned tabs when preservation is disabled', () => { + const pinned = new Set([workspaceFileTargetKey(targets[1], 'linux')]) + expect(retainFilePreviewTargets(targets, pinned, false)).toEqual([targets[1]]) + expect(retainFilePreviewTargets(targets, pinned, true)).toEqual(targets) + }) + + it('keeps other pinned tabs for close-others but lets an explicit close unpin them', () => { + const pinnedKey = workspaceFileTargetKey(targets[1], 'linux') + expect(closeOtherFilePreviewTargets(targets, targets[0], new Set([pinnedKey]))).toEqual([ + targets[0], + targets[1] + ]) + + expect(closeFilePreviewTarget(targets, [pinnedKey], targets[1], targets[1])).toEqual({ + targets: [targets[0], targets[2]], + pinnedTargetKeys: [], + activeTarget: targets[0] + }) + }) + + it('bounds and validates persisted keys and migrates only reversible Windows legacy keys', () => { + const values = Array.from({ length: 205 }, (_, index) => `/repo\n/repo/${index}.md`) + expect(parsePinnedFilePreviewTargetKeys(JSON.stringify(values), 'linux')).toHaveLength(200) + expect(parsePinnedFilePreviewTargetKeys('{broken', 'linux')).toEqual([]) + expect(parsePinnedFilePreviewTargetKeys(JSON.stringify(['title-only', 4]), 'linux')).toEqual([]) + + const legacy = JSON.stringify([ + 'c:/repo\nc:/repo\nc:/repo/docs/one.md', + 'c:/repo\nd:/other\nd:/other/two.md', + 'malformed' + ]) + expect(migrateLegacyPinnedFilePreviewTargetKeys(legacy, 'win32')).toEqual([ + 'c:/repo\nc:/repo/docs/one.md', + 'd:/other\nd:/other/two.md' + ]) + expect(migrateLegacyPinnedFilePreviewTargetKeys(legacy, 'linux')).toEqual([]) + }) +}) + +type HarnessProps = { + activeThreadId: string | null + rightPanelMode: RightPanelMode + onSetRightPanelMode: (mode: RightPanelMode) => void +} + +let latestController: ReturnType + +function ControllerHarness({ activeThreadId, rightPanelMode, onSetRightPanelMode }: HarnessProps) { + const [filePreviewTarget, setFilePreviewTarget] = useState(null) + latestController = useWorkbenchFileTreeController({ + route: 'chat', + threads: [], + activeThreadId, + workspaceRoot: '/repo', + activeSkillWorkspace: '/repo', + rightPanelMode, + filePreviewTarget, + setFilePreviewTarget, + setRightPanelMode: onSetRightPanelMode, + setRightSidebarWidth: () => undefined + }) + return null +} + +describe('useWorkbenchFileTreeController thread transitions', () => { + let renderer: ReactTestRenderer + let setRightPanelMode: Mock<(mode: RightPanelMode) => void> + let storage: MemoryStorage + + beforeEach(async () => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + storage = new MemoryStorage() + vi.stubGlobal('window', { + kunGui: { platform: 'linux' }, + localStorage: storage + }) + setRightPanelMode = vi.fn<(mode: RightPanelMode) => void>() + await act(async () => { + renderer = create(createElement(ControllerHarness, { + activeThreadId: 'thread-a', + rightPanelMode: 'extension:example/panel', + onSetRightPanelMode: setRightPanelMode + })) + }) + }) + + afterEach(async () => { + await act(async () => renderer.unmount()) + vi.unstubAllGlobals() + }) + + it('prunes hidden unpinned tabs across A -> null -> B without closing another panel', async () => { + await act(async () => { + latestController.openWorkspaceFilePreviewTarget(targets[0]) + latestController.openWorkspaceFilePreviewTarget(targets[1]) + latestController.togglePinnedFilePreviewTarget(targets[1]) + }) + setRightPanelMode.mockClear() + + await act(async () => { + renderer.update(createElement(ControllerHarness, { + activeThreadId: null, + rightPanelMode: 'extension:example/panel', + onSetRightPanelMode: setRightPanelMode + })) + }) + expect(latestController.openFilePreviewTargets).toEqual([targets[1]]) + + await act(async () => { + renderer.update(createElement(ControllerHarness, { + activeThreadId: 'thread-b', + rightPanelMode: 'extension:example/panel', + onSetRightPanelMode: setRightPanelMode + })) + }) + expect(latestController.openFilePreviewTargets).toEqual([targets[1]]) + expect(setRightPanelMode).not.toHaveBeenCalledWith(null) + }) + + it('applies a disabled preservation setting on the next thread switch', async () => { + await act(async () => { + latestController.openWorkspaceFilePreviewTarget(targets[0]) + latestController.togglePinnedFilePreviewTarget(targets[0]) + latestController.openWorkspaceFilePreviewTarget(targets[1]) + latestController.togglePreserveFilePreviewTargets() + }) + expect(latestController.preserveFilePreviewTargets).toBe(true) + + await act(async () => latestController.togglePreserveFilePreviewTargets()) + expect(latestController.preserveFilePreviewTargets).toBe(false) + expect(latestController.openFilePreviewTargets).toEqual([targets[0], targets[1]]) + + await act(async () => { + renderer.update(createElement(ControllerHarness, { + activeThreadId: 'thread-b', + rightPanelMode: 'extension:example/panel', + onSetRightPanelMode: setRightPanelMode + })) + }) + expect(latestController.openFilePreviewTargets).toEqual([targets[0]]) + expect(setRightPanelMode).not.toHaveBeenCalledWith(null) + }) + + it('retains every tab across thread switches while preservation is enabled', async () => { + await act(async () => { + latestController.openWorkspaceFilePreviewTarget(targets[0]) + latestController.openWorkspaceFilePreviewTarget(targets[1]) + latestController.togglePreserveFilePreviewTargets() + }) + await act(async () => { + renderer.update(createElement(ControllerHarness, { + activeThreadId: 'thread-b', + rightPanelMode: 'extension:example/panel', + onSetRightPanelMode: setRightPanelMode + })) + }) + expect(latestController.openFilePreviewTargets).toEqual([targets[0], targets[1]]) + }) + + it('restores persisted pin and preservation preferences after remounting', async () => { + await act(async () => { + latestController.openWorkspaceFilePreviewTarget(targets[0]) + latestController.togglePinnedFilePreviewTarget(targets[0]) + latestController.togglePreserveFilePreviewTargets() + renderer.unmount() + }) + await act(async () => { + renderer = create(createElement(ControllerHarness, { + activeThreadId: 'thread-a', + rightPanelMode: 'extension:example/panel', + onSetRightPanelMode: setRightPanelMode + })) + }) + expect(latestController.preserveFilePreviewTargets).toBe(true) + expect(latestController.pinnedFilePreviewTargetKeys).toEqual([ + workspaceFileTargetKey(targets[0], 'linux') + ]) + }) + + it('treats an explicit collapse as authoritative for tabs and persisted pins', async () => { + await act(async () => { + latestController.openWorkspaceFilePreviewTarget(targets[0]) + latestController.togglePinnedFilePreviewTarget(targets[0]) + latestController.clearFilePreviewTargets() + }) + expect(latestController.openFilePreviewTargets).toEqual([]) + expect(latestController.pinnedFilePreviewTargetKeys).toEqual([]) + expect(storage.getItem('kun.filePreview.pinnedTargets')).toBe('[]') + }) + + it('closes the file panel only when a switch leaves no retained file tabs', async () => { + await act(async () => latestController.openWorkspaceFilePreviewTarget(targets[0])) + setRightPanelMode.mockClear() + await act(async () => { + renderer.update(createElement(ControllerHarness, { + activeThreadId: 'thread-b', + rightPanelMode: BUILTIN_RIGHT_PANEL_IDS.file, + onSetRightPanelMode: setRightPanelMode + })) + }) + expect(latestController.openFilePreviewTargets).toEqual([]) + expect(setRightPanelMode).toHaveBeenCalledWith(null) + }) +}) diff --git a/src/renderer/src/components/workbench/useWorkbenchFileTreeController.ts b/src/renderer/src/components/workbench/useWorkbenchFileTreeController.ts index b72230abe..5f91c6619 100644 --- a/src/renderer/src/components/workbench/useWorkbenchFileTreeController.ts +++ b/src/renderer/src/components/workbench/useWorkbenchFileTreeController.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { WorkspaceFileTarget } from '@shared/workspace-file' import type { NormalizedThread } from '../../agent/types' import { @@ -7,6 +7,12 @@ import { type ComposerFileReference } from '../../lib/composer-file-references' import { normalizeWorkspaceRoot } from '../../lib/workspace-path' +import { workspaceFileTargetKey } from '../../lib/workspace-file-target-key' +import { + readBrowserStorageItem, + removeBrowserStorageItem, + writeBrowserStorageItem +} from '../../lib/browser-storage' import type { ChatFileTreeReference } from '../chat/ChatFileTreePanel' import type { RightPanelMode } from '../chat/WorkbenchTopBar' import { BUILTIN_RIGHT_PANEL_IDS } from '../../extensions/contribution-ids' @@ -27,9 +33,122 @@ export type WorkbenchFileTreeControllerOptions = { setRightSidebarWidth: (updater: (width: number) => number) => void } -function workspaceFileTargetKey(target: WorkspaceFileTarget | null | undefined): string { - if (!target?.path) return '' - return `${target.workspaceRoot ?? ''}\n${target.path}`.replaceAll('\\', '/').toLowerCase() +export const PINNED_FILE_PREVIEW_TARGETS_KEY = 'kun.filePreview.pinnedTargets' +export const PRESERVE_FILE_PREVIEW_TARGETS_KEY = 'kun.filePreview.preserveAcrossThreads' +export const LEGACY_PINNED_FILE_PREVIEW_TARGETS_KEY = 'kun.issue781.pinnedPreviewTabs' +const MAX_PINNED_FILE_PREVIEW_TARGETS = 200 + +export { workspaceFileTargetKey } from '../../lib/workspace-file-target-key' + +export function retainFilePreviewTargets( + targets: WorkspaceFileTarget[], + pinnedTargetKeys: ReadonlySet, + preserveAcrossThreads: boolean +): WorkspaceFileTarget[] { + if (preserveAcrossThreads) return targets + return targets.filter((target) => pinnedTargetKeys.has(workspaceFileTargetKey(target))) +} + +export function closeOtherFilePreviewTargets( + targets: WorkspaceFileTarget[], + targetToKeep: WorkspaceFileTarget, + pinnedTargetKeys: ReadonlySet +): WorkspaceFileTarget[] { + const keepKey = workspaceFileTargetKey(targetToKeep) + return targets.filter((target) => { + const key = workspaceFileTargetKey(target) + return key === keepKey || pinnedTargetKeys.has(key) + }) +} + +export function closeFilePreviewTarget( + targets: WorkspaceFileTarget[], + pinnedTargetKeys: string[], + targetToClose: WorkspaceFileTarget, + activeTarget: WorkspaceFileTarget | null +): { + targets: WorkspaceFileTarget[] + pinnedTargetKeys: string[] + activeTarget: WorkspaceFileTarget | null +} { + const closingKey = workspaceFileTargetKey(targetToClose) + const index = targets.findIndex((item) => workspaceFileTargetKey(item) === closingKey) + const nextPinnedTargetKeys = pinnedTargetKeys.filter((key) => key !== closingKey) + if (index < 0) { + return { targets, pinnedTargetKeys: nextPinnedTargetKeys, activeTarget } + } + const nextTargets = targets.filter((_, itemIndex) => itemIndex !== index) + if (workspaceFileTargetKey(activeTarget) !== closingKey) { + return { targets: nextTargets, pinnedTargetKeys: nextPinnedTargetKeys, activeTarget } + } + return { + targets: nextTargets, + pinnedTargetKeys: nextPinnedTargetKeys, + activeTarget: nextTargets[Math.max(0, index - 1)] ?? nextTargets[0] ?? null + } +} + +export function parsePinnedFilePreviewTargetKeys(raw: string | null, platform = ''): string[] { + if (!raw) return [] + try { + const value: unknown = JSON.parse(raw) + if (!Array.isArray(value)) return [] + const keys = value.flatMap((item): string[] => { + if (typeof item !== 'string') return [] + const parts = item.replaceAll('\\', '/').split('\n') + if (parts.length !== 2 || !parts[1]) return [] + return [workspaceFileTargetKey({ workspaceRoot: parts[0], path: parts[1] }, platform)] + }) + return Array.from(new Set(keys)).slice(-MAX_PINNED_FILE_PREVIEW_TARGETS) + } catch { + return [] + } +} + +export function migrateLegacyPinnedFilePreviewTargetKeys(raw: string | null, platform = ''): string[] { + if (platform !== 'win32' || !raw) return [] + try { + const value: unknown = JSON.parse(raw) + if (!Array.isArray(value)) return [] + const keys = value.flatMap((item): string[] => { + if (typeof item !== 'string') return [] + const parts = item.replaceAll('\\', '/').split('\n') + if (parts.length !== 3 || !parts[2]) return [] + return [workspaceFileTargetKey({ workspaceRoot: parts[1], path: parts[2] }, platform)] + }) + return Array.from(new Set(keys)).slice(-MAX_PINNED_FILE_PREVIEW_TARGETS) + } catch { + return [] + } +} + +function readStoredPinnedTargetKeys(): string[] { + const platform = typeof window !== 'undefined' ? window.kunGui?.platform ?? '' : '' + const stored = readBrowserStorageItem(PINNED_FILE_PREVIEW_TARGETS_KEY) + if (stored !== null) return parsePinnedFilePreviewTargetKeys(stored, platform) + + const legacy = readBrowserStorageItem(LEGACY_PINNED_FILE_PREVIEW_TARGETS_KEY) + const migrated = migrateLegacyPinnedFilePreviewTargetKeys(legacy, platform) + if (platform === 'win32') removeBrowserStorageItem(LEGACY_PINNED_FILE_PREVIEW_TARGETS_KEY) + if (migrated.length > 0) { + writeBrowserStorageItem(PINNED_FILE_PREVIEW_TARGETS_KEY, JSON.stringify(migrated)) + } + return migrated +} + +function readStoredPreserveAcrossThreads(): boolean { + return readBrowserStorageItem(PRESERVE_FILE_PREVIEW_TARGETS_KEY) === 'true' +} + +function storePinnedTargetKeys(keys: string[]): void { + writeBrowserStorageItem( + PINNED_FILE_PREVIEW_TARGETS_KEY, + JSON.stringify(keys.slice(-MAX_PINNED_FILE_PREVIEW_TARGETS)) + ) +} + +function storePreserveAcrossThreads(value: boolean): void { + writeBrowserStorageItem(PRESERVE_FILE_PREVIEW_TARGETS_KEY, String(value)) } export function useWorkbenchFileTreeController({ @@ -49,6 +168,18 @@ export function useWorkbenchFileTreeController({ const [fileTreeSidePanelView, setFileTreeSidePanelView] = useState('workspace') const [openFilePreviewTargets, setOpenFilePreviewTargets] = useState([]) + const [pinnedFilePreviewTargetKeys, setPinnedFilePreviewTargetKeys] = useState( + readStoredPinnedTargetKeys + ) + const [preserveFilePreviewTargets, setPreserveFilePreviewTargets] = useState( + readStoredPreserveAcrossThreads + ) + const openFilePreviewTargetsRef = useRef(openFilePreviewTargets) + const pinnedFilePreviewTargetKeysRef = useRef(pinnedFilePreviewTargetKeys) + const preserveFilePreviewTargetsRef = useRef(preserveFilePreviewTargets) + const filePreviewTargetRef = useRef(filePreviewTarget) + const previousActiveThreadIdRef = useRef(activeThreadId) + filePreviewTargetRef.current = filePreviewTarget const fileTreeWorkspaceRoot = useMemo( () => normalizeWorkspaceRoot(threads.find((thread) => thread.id === activeThreadId)?.workspace || workspaceRoot), [activeThreadId, threads, workspaceRoot] @@ -79,18 +210,37 @@ export function useWorkbenchFileTreeController({ ) } + function updateOpenFilePreviewTargets(next: WorkspaceFileTarget[]): void { + openFilePreviewTargetsRef.current = next + setOpenFilePreviewTargets(next) + } + + function updatePinnedFilePreviewTargetKeys(next: string[]): void { + const normalized = Array.from(new Set(next.filter(Boolean))).slice(-MAX_PINNED_FILE_PREVIEW_TARGETS) + pinnedFilePreviewTargetKeysRef.current = normalized + setPinnedFilePreviewTargetKeys(normalized) + storePinnedTargetKeys(normalized) + } + + const selectFilePreviewTarget = useCallback((target: WorkspaceFileTarget | null): void => { + filePreviewTargetRef.current = target + setFilePreviewTarget(target) + }, [setFilePreviewTarget]) + function openWorkspaceFilePreviewTarget(target: WorkspaceFileTarget): void { const nextTarget = { ...target, workspaceRoot: target.workspaceRoot ?? fileTreeWorkspaceRoot } if (!nextTarget.workspaceRoot) return - setOpenFilePreviewTargets((current) => { - const key = workspaceFileTargetKey(nextTarget) - if (current.some((item) => workspaceFileTargetKey(item) === key)) return current - return [...current, nextTarget] - }) - setFilePreviewTarget(nextTarget) + const key = workspaceFileTargetKey(nextTarget) + const current = openFilePreviewTargetsRef.current + const existingIndex = current.findIndex((item) => workspaceFileTargetKey(item) === key) + const next = existingIndex >= 0 + ? current.map((item, index) => index === existingIndex ? nextTarget : item) + : [...current, nextTarget] + updateOpenFilePreviewTargets(next) + selectFilePreviewTarget(nextTarget) setRightSidebarWidth((width) => Math.max(width, CODE_PANEL_PREFERRED)) setRightPanelMode(BUILTIN_RIGHT_PANEL_IDS.file) } @@ -102,18 +252,43 @@ export function useWorkbenchFileTreeController({ } function closeWorkspaceFilePreviewTarget(target: WorkspaceFileTarget): void { - const closingKey = workspaceFileTargetKey(target) - setOpenFilePreviewTargets((current) => { - const index = current.findIndex((item) => workspaceFileTargetKey(item) === closingKey) - if (index < 0) return current - const next = current.filter((_, itemIndex) => itemIndex !== index) - if (workspaceFileTargetKey(filePreviewTarget) === closingKey) { - const fallback = next[Math.max(0, index - 1)] ?? next[0] ?? null - setFilePreviewTarget(fallback) - if (!fallback) setRightPanelMode(null) - } - return next - }) + const next = closeFilePreviewTarget( + openFilePreviewTargetsRef.current, + pinnedFilePreviewTargetKeysRef.current, + target, + filePreviewTargetRef.current + ) + updatePinnedFilePreviewTargetKeys(next.pinnedTargetKeys) + updateOpenFilePreviewTargets(next.targets) + if (next.activeTarget === filePreviewTargetRef.current) return + selectFilePreviewTarget(next.activeTarget) + if (!next.activeTarget) setRightPanelMode(null) + } + + function togglePinnedFilePreviewTarget(target: WorkspaceFileTarget): void { + const key = workspaceFileTargetKey(target) + if (!key) return + const current = pinnedFilePreviewTargetKeysRef.current + updatePinnedFilePreviewTargetKeys( + current.includes(key) ? current.filter((item) => item !== key) : [...current, key] + ) + } + + function closeOtherWorkspaceFilePreviewTargets(target: WorkspaceFileTarget): void { + const next = closeOtherFilePreviewTargets( + openFilePreviewTargetsRef.current, + target, + new Set(pinnedFilePreviewTargetKeysRef.current) + ) + updateOpenFilePreviewTargets(next) + selectFilePreviewTarget(target) + } + + function togglePreserveFilePreviewTargets(): void { + const nextPreserve = !preserveFilePreviewTargetsRef.current + preserveFilePreviewTargetsRef.current = nextPreserve + setPreserveFilePreviewTargets(nextPreserve) + storePreserveAcrossThreads(nextPreserve) } function addWorkspaceReferenceFromSidebar(reference: ChatFileTreeReference): void { @@ -135,19 +310,44 @@ export function useWorkbenchFileTreeController({ } function clearFilePreviewTargets(): void { - setOpenFilePreviewTargets([]) - setFilePreviewTarget(null) + updateOpenFilePreviewTargets([]) + updatePinnedFilePreviewTargetKeys([]) + selectFilePreviewTarget(null) } useEffect(() => { if (rightPanelMode !== BUILTIN_RIGHT_PANEL_IDS.file || !filePreviewTarget) return - setOpenFilePreviewTargets((current) => { - const key = workspaceFileTargetKey(filePreviewTarget) - if (current.some((item) => workspaceFileTargetKey(item) === key)) return current - return [...current, filePreviewTarget] - }) + const current = openFilePreviewTargetsRef.current + const key = workspaceFileTargetKey(filePreviewTarget) + const existingIndex = current.findIndex((item) => workspaceFileTargetKey(item) === key) + if (existingIndex < 0) { + updateOpenFilePreviewTargets([...current, filePreviewTarget]) + } else if (current[existingIndex] !== filePreviewTarget) { + updateOpenFilePreviewTargets( + current.map((item, index) => index === existingIndex ? filePreviewTarget : item) + ) + } }, [filePreviewTarget, rightPanelMode]) + useEffect(() => { + const previousThreadId = previousActiveThreadIdRef.current + previousActiveThreadIdRef.current = activeThreadId + if (previousThreadId === activeThreadId) return + + const retained = retainFilePreviewTargets( + openFilePreviewTargetsRef.current, + new Set(pinnedFilePreviewTargetKeysRef.current), + preserveFilePreviewTargetsRef.current + ) + updateOpenFilePreviewTargets(retained) + const activeKey = workspaceFileTargetKey(filePreviewTargetRef.current) + const nextTarget = retained.find((item) => workspaceFileTargetKey(item) === activeKey) + ?? retained[0] + ?? null + selectFilePreviewTarget(nextTarget) + if (!nextTarget && rightPanelMode === BUILTIN_RIGHT_PANEL_IDS.file) setRightPanelMode(null) + }, [activeThreadId, rightPanelMode, selectFilePreviewTarget, setRightPanelMode]) + useEffect(() => { if (route !== 'chat') setComposerFileReferences([]) }, [route]) @@ -157,6 +357,8 @@ export function useWorkbenchFileTreeController({ fileTreeSidePanelOpen, fileTreeSidePanelView, openFilePreviewTargets, + pinnedFilePreviewTargetKeys, + preserveFilePreviewTargets, fileTreeWorkspaceRoot, clearComposerFileReferences, addComposerFileReference, @@ -165,6 +367,9 @@ export function useWorkbenchFileTreeController({ openWorkspaceFilePreviewTarget, previewWorkspaceFileFromSidebar, closeWorkspaceFilePreviewTarget, + togglePinnedFilePreviewTarget, + closeOtherFilePreviewTargets: closeOtherWorkspaceFilePreviewTargets, + togglePreserveFilePreviewTargets, addWorkspaceReferenceFromSidebar, toggleFileTreeSidePanel, openFileTreeSidePanel, diff --git a/src/renderer/src/components/workbench/useWorkbenchRightPanelElement.tsx b/src/renderer/src/components/workbench/useWorkbenchRightPanelElement.tsx index 4f25fef93..631d89149 100644 --- a/src/renderer/src/components/workbench/useWorkbenchRightPanelElement.tsx +++ b/src/renderer/src/components/workbench/useWorkbenchRightPanelElement.tsx @@ -81,6 +81,11 @@ type WorkbenchRightPanelElementOptions = Pick< | 'workspaceRoot' | 'onSelectTarget' | 'onCloseTarget' + | 'pinnedTargetKeys' + | 'preserveAcrossThreads' + | 'onTogglePinnedTarget' + | 'onCloseOtherTargets' + | 'onTogglePreserveAcrossThreads' > extensionView?: RightPanelHostProps['extensionView'] workspaceRoot?: string diff --git a/src/renderer/src/lib/issue-781-document-usability.ts b/src/renderer/src/lib/issue-781-document-usability.ts index 7eea97cd8..7b4859a00 100644 --- a/src/renderer/src/lib/issue-781-document-usability.ts +++ b/src/renderer/src/lib/issue-781-document-usability.ts @@ -6,55 +6,14 @@ const LINKIFIED_ATTR = 'data-kun-issue781-linkified' const FILE_PATH_ATTR = 'data-kun-issue781-file-path' const FILE_LINE_ATTR = 'data-kun-issue781-file-line' const FILE_COLUMN_ATTR = 'data-kun-issue781-file-column' -const ENHANCED_ATTR = 'data-kun-issue781-enhanced' const STYLE_ID = 'kun-issue-781-document-usability-style' -const PINNED_TABS_KEY = 'kun.issue781.pinnedPreviewTabs' -const SCROLL_POSITIONS_KEY = 'kun.issue781.previewScrollPositions' - -const LABELS = { - zh: { - pinTab: '固定标签', - unpinTab: '取消固定标签', - closeOtherTabs: '关闭其他标签页' - }, - en: { - pinTab: 'Pin tab', - unpinTab: 'Unpin tab', - closeOtherTabs: 'Close other tabs' - } -} as const +const OUTPUT_CONTAINER_SELECTOR = '.ds-markdown, .ds-code-block-html, .ds-file-preview-code-html' let installed = false let observer: MutationObserver | null = null let scanTimer: number | null = null -let menuEl: HTMLDivElement | null = null -let cleanups: Array<() => void> = [] - -function trackCleanup(cleanup: () => void): void { - cleanups.push(cleanup) -} - -function label(key: keyof typeof LABELS.en): string { - const language = document.documentElement.lang || navigator.language || '' - return language.toLowerCase().startsWith('zh') ? LABELS.zh[key] : LABELS.en[key] -} - -function readJson(key: string, fallback: T): T { - try { - const raw = window.localStorage.getItem(key) - return raw ? JSON.parse(raw) as T : fallback - } catch { - return fallback - } -} - -function writeJson(key: string, value: T): void { - try { - window.localStorage.setItem(key, JSON.stringify(value)) - } catch { - // Ignore storage quota / private-mode errors. The feature remains usable in-memory. - } -} +let styleElement: HTMLStyleElement | null = null +const pendingContainers = new Set() function injectStyle(): void { if (document.getElementById(STYLE_ID)) return @@ -80,41 +39,9 @@ function injectStyle(): void { background: color-mix(in srgb, var(--ds-accent) 17%, transparent); text-decoration-color: var(--ds-accent); } - .ds-code-sidebar-tab.kun-issue781-pinned::before { - content: '📌'; - margin-right: 2px; - font-size: 10px; - opacity: 0.78; - } - .kun-issue781-menu { - position: fixed; - z-index: 9999; - min-width: 172px; - border: 1px solid var(--ds-border); - border-radius: 10px; - background: var(--ds-card); - box-shadow: 0 18px 50px rgba(15, 23, 42, 0.22); - padding: 5px; - } - .kun-issue781-menu button { - display: block; - width: 100%; - border: 0; - border-radius: 8px; - background: transparent; - color: var(--ds-ink); - cursor: pointer; - font: inherit; - font-size: 12px; - padding: 7px 9px; - text-align: left; - } - .kun-issue781-menu button:hover { background: var(--ds-hover); } ` document.head.appendChild(style) - trackCleanup(() => { - style.remove() - }) + styleElement = style } function isBlockedTextNode(node: Text): boolean { @@ -135,48 +62,6 @@ function targetFromDataset(element: HTMLElement): WorkspaceFileTarget | null { } } -function tabKey(tab: Element | null): string { - return tab instanceof HTMLElement ? (tab.title || tab.textContent || '').trim() : '' -} - -function tabScopeKey(tab: Element | null): string { - if (!(tab instanceof HTMLElement)) return '' - const rawKey = tabKey(tab) - if (!rawKey) return '' - const sidebar = tab.closest('.ds-code-sidebar') - const explicitWorkspaceRoot = sidebar instanceof HTMLElement - ? sidebar.getAttribute('data-kun-workspace-root') - : '' - const explicitPreviewKey = tab.getAttribute('data-kun-preview-key') - const fallbackPageScope = `${window.location.origin}${window.location.pathname}` - return `${explicitWorkspaceRoot || fallbackPageScope}\n${explicitPreviewKey || rawKey}` - .replaceAll('\\', '/') - .toLowerCase() -} - -function activeTabKey(): string { - return tabScopeKey(document.querySelector('.ds-code-sidebar-tab.is-active')) -} - -function pinnedTabs(): string[] { - return readJson(PINNED_TABS_KEY, []) -} - -function setPinnedTabs(next: string[]): void { - writeJson(PINNED_TABS_KEY, Array.from(new Set(next.filter(Boolean)))) -} - -function scrollPositions(): Record { - return readJson>(SCROLL_POSITIONS_KEY, {}) -} - -function setScrollPosition(key: string, value: number): void { - if (!key) return - const next = scrollPositions() - next[key] = value - writeJson(SCROLL_POSITIONS_KEY, next) -} - function linkifyTextNode(node: Text): void { if (isBlockedTextNode(node)) return const text = node.nodeValue ?? '' @@ -186,9 +71,7 @@ function linkifyTextNode(node: Text): void { const fragment = document.createDocumentFragment() let cursor = 0 for (const match of matches) { - if (match.start > cursor) { - fragment.appendChild(document.createTextNode(text.slice(cursor, match.start))) - } + if (match.start > cursor) fragment.appendChild(document.createTextNode(text.slice(cursor, match.start))) const button = document.createElement('button') button.type = 'button' button.className = 'ds-issue781-file-link ds-file-reference-link' @@ -201,200 +84,86 @@ function linkifyTextNode(node: Text): void { fragment.appendChild(button) cursor = match.end } - if (cursor < text.length) { - fragment.appendChild(document.createTextNode(text.slice(cursor))) - } + if (cursor < text.length) fragment.appendChild(document.createTextNode(text.slice(cursor))) node.replaceWith(fragment) } function linkifyContainer(container: ParentNode): void { - const walker = document.createTreeWalker( - container, - NodeFilter.SHOW_TEXT, - { - acceptNode(node) { - if (!(node instanceof Text)) return NodeFilter.FILTER_REJECT - if (!node.nodeValue?.trim()) return NodeFilter.FILTER_REJECT - return isBlockedTextNode(node) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT + const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, { + acceptNode(node) { + if (!(node instanceof Text) || !node.nodeValue?.trim() || isBlockedTextNode(node)) { + return NodeFilter.FILTER_REJECT } + return NodeFilter.FILTER_ACCEPT } - ) + }) const nodes: Text[] = [] while (walker.nextNode()) nodes.push(walker.currentNode as Text) for (const node of nodes) linkifyTextNode(node) } -function scanRenderedOutput(): void { - const containers = document.querySelectorAll('.ds-markdown, .ds-code-block-html, .ds-file-preview-code-html') - for (const container of containers) linkifyContainer(container) -} - -function applyPinnedClasses(): void { - const pinned = new Set(pinnedTabs()) - document.querySelectorAll('.ds-code-sidebar-tab').forEach((tab) => { - tab.classList.toggle('kun-issue781-pinned', pinned.has(tabScopeKey(tab))) - }) -} - -function closeIssue781Menu(): void { - menuEl?.remove() - menuEl = null -} - -function showTabMenu(tab: HTMLElement, x: number, y: number): void { - closeIssue781Menu() - const key = tabScopeKey(tab) - if (!key) return - const pinned = new Set(pinnedTabs()) - const menu = document.createElement('div') - menu.className = 'kun-issue781-menu' - menu.style.left = `${x}px` - menu.style.top = `${y}px` - const pinButton = document.createElement('button') - pinButton.type = 'button' - pinButton.textContent = pinned.has(key) ? label('unpinTab') : label('pinTab') - const closeOthersButton = document.createElement('button') - closeOthersButton.type = 'button' - closeOthersButton.textContent = label('closeOtherTabs') - pinButton.addEventListener('click', () => { - if (pinned.has(key)) pinned.delete(key) - else pinned.add(key) - setPinnedTabs([...pinned]) - applyPinnedClasses() - closeIssue781Menu() - }) - closeOthersButton.addEventListener('click', () => { - const pinnedNow = new Set(pinnedTabs()) - document.querySelectorAll('.ds-code-sidebar-tab').forEach((item) => { - const itemKey = tabScopeKey(item) - if (item === tab || pinnedNow.has(itemKey)) return - const close = item.querySelector('.ds-code-sidebar-tab-close') - if (close instanceof HTMLButtonElement) close.click() - }) - closeIssue781Menu() - }) - menu.append(pinButton, closeOthersButton) - document.body.appendChild(menu) - menuEl = menu -} - -function enhancePreviewTabs(): void { - applyPinnedClasses() - const tabs = document.querySelector('.ds-code-sidebar-tabs') - if (!(tabs instanceof HTMLElement) || tabs.getAttribute(ENHANCED_ATTR) === 'tabs') return - tabs.setAttribute(ENHANCED_ATTR, 'tabs') - const onWheel = (event: WheelEvent): void => { - const tabList = Array.from(tabs.querySelectorAll('.ds-code-sidebar-tab')) as HTMLElement[] - if (tabList.length < 2) return - event.preventDefault() - const activeIndex = Math.max(0, tabList.findIndex((tab) => tab.classList.contains('is-active'))) - const nextIndex = (activeIndex + (event.deltaY > 0 ? 1 : -1) + tabList.length) % tabList.length - tabList[nextIndex]?.click() - } - const onContextMenu = (event: MouseEvent): void => { - const target = event.target - if (!(target instanceof HTMLElement)) return - const tab = target.closest('.ds-code-sidebar-tab') - if (!(tab instanceof HTMLElement)) return - event.preventDefault() - showTabMenu(tab, event.clientX, event.clientY) - } - tabs.addEventListener('wheel', onWheel, { passive: false }) - tabs.addEventListener('contextmenu', onContextMenu) - trackCleanup(() => { - tabs.removeEventListener('wheel', onWheel) - tabs.removeEventListener('contextmenu', onContextMenu) - if (tabs.getAttribute(ENHANCED_ATTR) === 'tabs') tabs.removeAttribute(ENHANCED_ATTR) - }) +function scanRenderedOutput(root: ParentNode = document): void { + root.querySelectorAll(OUTPUT_CONTAINER_SELECTOR).forEach(linkifyContainer) } -function enhanceScrollMemory(): void { - const scrollers = document.querySelectorAll('.ds-file-preview-scroll, .ds-file-preview-markdown') - scrollers.forEach((element) => { - if (!(element instanceof HTMLElement)) return - if (element.getAttribute(ENHANCED_ATTR) !== 'scroll') { - element.setAttribute(ENHANCED_ATTR, 'scroll') - const onScroll = (): void => setScrollPosition(activeTabKey(), element.scrollTop) - element.addEventListener('scroll', onScroll, { passive: true }) - trackCleanup(() => { - element.removeEventListener('scroll', onScroll) - if (element.getAttribute(ENHANCED_ATTR) === 'scroll') element.removeAttribute(ENHANCED_ATTR) - }) - } - const key = activeTabKey() - const stored = scrollPositions()[key] - if (key && typeof stored === 'number' && Math.abs(element.scrollTop - stored) > 4) { - window.requestAnimationFrame(() => { - element.scrollTop = stored - }) - } - }) +function collectOutputContainers(node: Node): void { + const element = node instanceof Element ? node : node.parentElement + if (!element) return + const containing = element.closest(OUTPUT_CONTAINER_SELECTOR) + if (containing) pendingContainers.add(containing) + if (!(node instanceof Element)) return + if (node.matches(OUTPUT_CONTAINER_SELECTOR)) pendingContainers.add(node) + node.querySelectorAll(OUTPUT_CONTAINER_SELECTOR).forEach((container) => pendingContainers.add(container)) } function scheduleScan(): void { if (scanTimer !== null) return scanTimer = window.setTimeout(() => { scanTimer = null - scanRenderedOutput() - enhancePreviewTabs() - enhanceScrollMemory() + const containers = [...pendingContainers] + pendingContainers.clear() + containers.forEach(linkifyContainer) }, 120) } +function onRenderedOutputMutations(mutations: MutationRecord[]): void { + for (const mutation of mutations) mutation.addedNodes.forEach(collectOutputContainers) + if (pendingContainers.size > 0) scheduleScan() +} + function onDocumentClick(event: MouseEvent): void { const target = event.target if (!(target instanceof HTMLElement)) return - const fileLink = target.closest(`[${LINKIFIED_ATTR}]`) - if (fileLink instanceof HTMLElement) { - const fileTarget = targetFromDataset(fileLink) - if (!fileTarget) return - event.preventDefault() - event.stopPropagation() - previewWorkspaceFile(fileTarget) - } -} - -function onDocumentPointerDown(event: PointerEvent): void { - if (menuEl && event.target instanceof Node && !menuEl.contains(event.target)) closeIssue781Menu() + if (!(fileLink instanceof HTMLElement)) return + const fileTarget = targetFromDataset(fileLink) + if (!fileTarget) return + event.preventDefault() + event.stopPropagation() + previewWorkspaceFile(fileTarget) } export function uninstallIssue781DocumentUsability(): void { if (!installed || typeof window === 'undefined' || typeof document === 'undefined') return installed = false - if (scanTimer !== null) { - window.clearTimeout(scanTimer) - scanTimer = null - } + if (scanTimer !== null) window.clearTimeout(scanTimer) + scanTimer = null observer?.disconnect() observer = null - closeIssue781Menu() - document.querySelectorAll('.kun-issue781-pinned').forEach((element) => { - element.classList.remove('kun-issue781-pinned') - }) - for (const cleanup of cleanups.splice(0).reverse()) { - cleanup() - } + pendingContainers.clear() + document.removeEventListener('click', onDocumentClick, true) + styleElement?.remove() + styleElement = null } export function installIssue781DocumentUsability(): () => void { if (typeof window === 'undefined' || typeof document === 'undefined') return () => {} if (installed) return uninstallIssue781DocumentUsability installed = true - cleanups = [] injectStyle() scanRenderedOutput() - enhancePreviewTabs() - enhanceScrollMemory() document.addEventListener('click', onDocumentClick, true) - document.addEventListener('pointerdown', onDocumentPointerDown, true) - trackCleanup(() => { - document.removeEventListener('click', onDocumentClick, true) - document.removeEventListener('pointerdown', onDocumentPointerDown, true) - }) - observer = new MutationObserver(() => { - scheduleScan() - }) + observer = new MutationObserver(onRenderedOutputMutations) observer.observe(document.body, { childList: true, subtree: true }) return uninstallIssue781DocumentUsability } diff --git a/src/renderer/src/lib/workspace-file-target-key.ts b/src/renderer/src/lib/workspace-file-target-key.ts new file mode 100644 index 000000000..10cdacaa3 --- /dev/null +++ b/src/renderer/src/lib/workspace-file-target-key.ts @@ -0,0 +1,36 @@ +import type { WorkspaceFileTarget } from '@shared/workspace-file' + +function rendererPlatform(): string { + return typeof window !== 'undefined' ? window.kunGui?.platform ?? '' : '' +} + +function isWindowsStylePath(path: string): boolean { + return /^[a-z]:[/\\]/i.test(path) || /^[/\\]{2}[^/\\]/.test(path) +} + +function normalizeKeyPath(path: string): string { + const value = path.trim().replaceAll('\\', '/') + if (!value) return '' + if (/^[a-z]:\/+$/i.test(value)) return `${value.slice(0, 2)}/` + const prefix = value.startsWith('//') ? '//' : value.startsWith('/') ? '/' : '' + const rest = value + .slice(prefix.length) + .replace(/\/{2,}/g, '/') + .replace(/\/+$/, '') + return prefix + rest +} + +export function workspaceFileTargetKey( + target: WorkspaceFileTarget | null | undefined, + platform = rendererPlatform() +): string { + if (!target?.path) return '' + const normalizedRoot = normalizeKeyPath(target.workspaceRoot ?? '') + const normalizedPath = normalizeKeyPath(target.path) + if (!normalizedPath) return '' + const key = `${normalizedRoot}\n${normalizedPath}` + const caseInsensitive = platform === 'win32' || + isWindowsStylePath(normalizedPath) || + isWindowsStylePath(normalizedRoot) + return caseInsensitive ? key.toLowerCase() : key +} diff --git a/src/renderer/src/locales/en/common.json b/src/renderer/src/locales/en/common.json index 603f54614..82764bd71 100644 --- a/src/renderer/src/locales/en/common.json +++ b/src/renderer/src/locales/en/common.json @@ -2480,6 +2480,12 @@ "filePreviewClose": "Close file preview", "filePreviewOpenFiles": "Open files", "filePreviewCloseTab": "Close {{file}}", + "filePreviewPinnedTab": "Pinned tab: {{file}}", + "filePreviewPinTab": "Pin tab", + "filePreviewUnpinTab": "Unpin tab", + "filePreviewCloseOtherTabs": "Close other tabs", + "filePreviewTabActions": "File tab actions", + "filePreviewPreserveAcrossThreads": "Keep open files when switching conversations", "filePreviewEnterReadingMode": "Expand reading view", "filePreviewExitReadingMode": "Exit reading view", "filePreviewRenderMarkdown": "Render Markdown", diff --git a/src/renderer/src/locales/zh/common.json b/src/renderer/src/locales/zh/common.json index b1a0fc10f..bc1d6d298 100644 --- a/src/renderer/src/locales/zh/common.json +++ b/src/renderer/src/locales/zh/common.json @@ -2480,6 +2480,12 @@ "filePreviewClose": "关闭文件预览", "filePreviewOpenFiles": "已打开文件", "filePreviewCloseTab": "关闭 {{file}}", + "filePreviewPinnedTab": "已固定标签:{{file}}", + "filePreviewPinTab": "固定标签", + "filePreviewUnpinTab": "取消固定标签", + "filePreviewCloseOtherTabs": "关闭其他标签页", + "filePreviewTabActions": "文件标签操作", + "filePreviewPreserveAcrossThreads": "切换会话时保留已打开文件", "filePreviewEnterReadingMode": "展开阅读", "filePreviewExitReadingMode": "退出阅读", "filePreviewRenderMarkdown": "渲染 Markdown", diff --git a/src/renderer/src/styles/markdown-code.css b/src/renderer/src/styles/markdown-code.css index 5b201d1a7..236276136 100644 --- a/src/renderer/src/styles/markdown-code.css +++ b/src/renderer/src/styles/markdown-code.css @@ -480,6 +480,34 @@ html[data-theme='dark'] .ds-code-sidebar { background: var(--ds-card-strong); } +.ds-code-sidebar-tab:focus-within { + outline: 2px solid color-mix(in srgb, var(--ds-accent) 58%, transparent); + outline-offset: 1px; +} + +.ds-code-sidebar-tab-selector { + display: inline-flex; + min-width: 0; + flex: 1 1 auto; + align-items: center; + gap: 0.55rem; + border: 0; + background: transparent; + padding: 0; + color: inherit; + cursor: pointer; + font: inherit; + text-align: left; +} + +.ds-code-sidebar-tab-selector:focus { + outline: none; +} + +.ds-code-sidebar-tab-selector:disabled { + cursor: default; +} + .ds-code-sidebar-tab:disabled { cursor: default; opacity: 0.68; From 07d1bde1d6f5bf4671981cd5b59eb1342ad1823f Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Sun, 12 Jul 2026 20:13:34 +0800 Subject: [PATCH 022/110] fix(write): bind sends to ensured thread --- .../store/chat-store-thread-actions.test.ts | 37 +++++++++++++++++++ .../src/store/chat-store-thread-actions.ts | 4 ++ 2 files changed, 41 insertions(+) diff --git a/src/renderer/src/store/chat-store-thread-actions.test.ts b/src/renderer/src/store/chat-store-thread-actions.test.ts index caea2fb0c..4cb419fdf 100644 --- a/src/renderer/src/store/chat-store-thread-actions.test.ts +++ b/src/renderer/src/store/chat-store-thread-actions.test.ts @@ -228,6 +228,43 @@ describe('chat-store-thread-actions queued messages', () => { ) }) + it('fails closed when another thread becomes active while the Write ensure resolves', async () => { + const provider = { sendUserMessage: vi.fn() } + registryMock.getProvider.mockReturnValue(provider) + vi.stubGlobal('window', { kunGui: {} }) + useWriteWorkspaceStore.setState({ + workspaceRoot: '/workspace/deepseek-gui', + activeFilePath: '/workspace/deepseek-gui/draft.md', + activeFileKind: 'text', + documentEpoch: 4, + contentRevision: 2, + fileContent: 'saved draft', + persistedContent: 'saved draft', + saveStatus: 'saved' + }) + const { actions, state } = buildHarness() + state.route = 'write' + state.busy = false + const ensureWriteThreadForWorkspace = vi.fn(async () => { + state.activeThreadId = 'thr_selected_elsewhere' + return 'thr_existing' + }) + state.ensureWriteThreadForWorkspace = ensureWriteThreadForWorkspace as ChatState['ensureWriteThreadForWorkspace'] + + await expect(actions.sendMessage('revise this', 'agent', { + writeContext: { + workspaceRoot: '/workspace/deepseek-gui', + activeFilePath: '/workspace/deepseek-gui/draft.md', + documentEpoch: 4, + contentRevision: 2 + } + })).resolves.toBe(false) + + expect(ensureWriteThreadForWorkspace).toHaveBeenCalledTimes(1) + expect(provider.sendUserMessage).not.toHaveBeenCalled() + expect(state.blocks).toEqual([]) + }) + it.each([ ['route', { route: 'chat' as const }], ['file', { activeFilePath: '/workspace/deepseek-gui/other.md' }], diff --git a/src/renderer/src/store/chat-store-thread-actions.ts b/src/renderer/src/store/chat-store-thread-actions.ts index 7fde7cb4f..e6789064c 100644 --- a/src/renderer/src/store/chat-store-thread-actions.ts +++ b/src/renderer/src/store/chat-store-thread-actions.ts @@ -683,6 +683,10 @@ export function createThreadActions( ) if (!writeThreadId) return false if (writeContext?.threadId && writeThreadId !== writeContext.threadId) return false + // ensureWriteThreadForWorkspace may await selectThread. If the user + // selects another conversation before it resolves, never fall through to + // the provider with that newer activeThreadId. + if (get().activeThreadId !== writeThreadId) return false if (writeContext && !writeContext.threadId) { writeContext = { ...writeContext, threadId: writeThreadId } } From 7ed115f642e8a3038c830429d5173a2db2290471 Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Sun, 12 Jul 2026 23:25:23 +0800 Subject: [PATCH 023/110] feat(design): enhance design guidance in prompts for better user experience --- .../design/design-turn-prompt.design-mode-context.test.ts | 7 +++++++ .../src/design/design-turn-prompt/html-and-canvas.ts | 8 +++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/design/design-turn-prompt.design-mode-context.test.ts b/src/renderer/src/design/design-turn-prompt.design-mode-context.test.ts index a026447be..5cb29589f 100644 --- a/src/renderer/src/design/design-turn-prompt.design-mode-context.test.ts +++ b/src/renderer/src/design/design-turn-prompt.design-mode-context.test.ts @@ -14,6 +14,11 @@ describe('design turn prompt design mode context', () => { expect(prompt).toContain('BUILD A SINGLE SCREEN') expect(prompt).toContain('BUILD A COMPLETE MULTI-SCREEN EXPERIENCE') expect(prompt).toContain('one `design_create_screen` call with a `screens` array') + expect(prompt).toContain('DESIGN FOUNDATION GUIDANCE') + expect(prompt).toContain('normally call `design_system` with `operation: "create"` before `design_create_screen`') + expect(prompt).toContain('This is a preferred sequence, not a hard gate') + expect(prompt).toContain('DESIGN-SYSTEM CLAIMS MUST BE FACTUAL') + expect(prompt).toContain('Per-screen `.kun-design/.../DESIGN.md` notes') expect(prompt).toContain('ask one concise question with `user_input`') expect(prompt).toContain('prefer the fewest calls') expect(prompt).not.toContain('Design mode workflow contract:') @@ -35,6 +40,8 @@ describe('design turn prompt design mode context', () => { expect(prompt).not.toContain('Design mode workflow contract:') expect(prompt).not.toContain('BUILD A COMPLETE MULTI-SCREEN EXPERIENCE') + expect(prompt).not.toContain('DESIGN FOUNDATION GUIDANCE') + expect(prompt).not.toContain('DESIGN-SYSTEM CLAIMS MUST BE FACTUAL') expect(prompt).toContain('Code sidebar whiteboard') }) diff --git a/src/renderer/src/design/design-turn-prompt/html-and-canvas.ts b/src/renderer/src/design/design-turn-prompt/html-and-canvas.ts index 1b460e62c..3c96d806a 100644 --- a/src/renderer/src/design/design-turn-prompt/html-and-canvas.ts +++ b/src/renderer/src/design/design-turn-prompt/html-and-canvas.ts @@ -347,6 +347,12 @@ export function buildCanvasTurnPrompt(options: DesignTurnOptions): string { : '- NEVER paste raw HTML into assistant text and NEVER put HTML/`write`/`content` payloads inside design tools. Screen HTML is written by the system via Write/Edit tools after a screen frame is created.', '- The renderer validates each tool call, applies it atomically (one undo entry per call), and visually highlights the affected shapes for ~1s.', '- Keep tool use bounded: prefer the fewest calls that complete the requested visible outcome. Batch related screens in one `design_create_screen.screens` call and related shape operations in one focused `design_update_shapes.ops` call. Do not emit one call per shape.', + ...(codeCanvasMode + ? [] + : [ + '- DESIGN FOUNDATION GUIDANCE — before building a complete product or multi-screen experience, check whether a valid root `DESIGN.md` source is listed above. If it exists, read and follow it before designing. If it is absent, normally call `design_system` with `operation: "create"` before `design_create_screen` so the screens share a real project-level foundation. This is a preferred sequence, not a hard gate: a quick exploration or an explicit user request to skip the design system may proceed directly to screens.', + '- DESIGN-SYSTEM CLAIMS MUST BE FACTUAL — say that screens share a unified design system only when a valid root `DESIGN.md` was already listed or a `design_system` call succeeded in this turn. Per-screen `.kun-design/.../DESIGN.md` notes and visually similar page CSS do not count as the project design system.' + ]), '', 'FIRST classify the request and commit to ONE primary lane:', '- EDIT AN EXISTING IMAGE — the user wants to change/edit/restyle/redo/recolor/fix/transform a picture that is ALREADY on the canvas, and the snapshot has a SELECTED `image` shape carrying an `imageUrl`. Phrasings like "change X into Y", "把这张图改成…", "改成 X", or "改一下这张图" all land here when the selected picture is the thing being changed. → call `generate_image` with `reference_image_paths` set to that `imageUrl`, then `design_update_shapes` that same shape (full rules under "Editing or restyling an EXISTING image" below). In this lane you MUST NOT use `design_create_screen` / `add-screen` and MUST NOT write or edit any HTML file.', @@ -366,7 +372,7 @@ export function buildCanvasTurnPrompt(options: DesignTurnOptions): string { ...(codeCanvasMode ? [] : [ - '- BUILD A COMPLETE MULTI-SCREEN EXPERIENCE — the user explicitly asks for a complete product, a set of pages, an end-to-end flow, multiple named screens, or wording such as "整套", "完整", "多页面", or "全套". → make one `design_create_screen` call with a `screens` array containing all necessary named screens and a self-contained brief for each. If pages are not named, choose the smallest coherent set that covers the main product flow.', + '- BUILD A COMPLETE MULTI-SCREEN EXPERIENCE — the user explicitly asks for a complete product, a set of pages, an end-to-end flow, multiple named screens, or wording such as "整套", "完整", "多页面", or "全套". → follow the DESIGN FOUNDATION GUIDANCE above, then make one `design_create_screen` call with a `screens` array containing all necessary named screens and a self-contained brief for each. If pages are not named, choose the smallest coherent set that covers the main product flow.', '- SCOPE AMBIGUITY — if it is genuinely unclear whether the user wants one screen or a complete multi-screen experience and that choice materially changes the work, ask one concise question with `user_input` and wait. Otherwise choose the narrowest reasonable scope and act.' ]), codeCanvasMode From cc8434581c0271f12aacea7f6e97dd75d5b938a0 Mon Sep 17 00:00:00 2001 From: luoye520ww <100058663+luoye520ww@users.noreply.github.com> Date: Sat, 11 Jul 2026 16:52:41 +0800 Subject: [PATCH 024/110] feat(replay): add model-aware comparison policy --- .github/workflows/replay-benchmark.yml | 8 ++ kun/README.md | 27 +++++ kun/src/benchmark/replay-benchmark.test.ts | 64 +++++++++++ kun/src/benchmark/replay-benchmark.ts | 121 ++++++++++++++++++++- kun/src/cli/replay-entry.ts | 13 ++- 5 files changed, 226 insertions(+), 7 deletions(-) diff --git a/.github/workflows/replay-benchmark.yml b/.github/workflows/replay-benchmark.yml index 0ac919de1..1c0063e56 100644 --- a/.github/workflows/replay-benchmark.yml +++ b/.github/workflows/replay-benchmark.yml @@ -30,6 +30,10 @@ on: description: Optional baseline report JSON path required: false type: string + comparison_policy: + description: Optional default and per-model comparison policy JSON path + required: false + type: string budget: description: Optional replay budget JSON path required: false @@ -81,6 +85,7 @@ jobs: REPEAT_COUNT: ${{ inputs.repeat }} CONCURRENCY: ${{ inputs.concurrency }} BASELINE_PATH: ${{ inputs.baseline }} + COMPARISON_POLICY_PATH: ${{ inputs.comparison_policy }} BUDGET_PATH: ${{ inputs.budget }} FAIL_ON_REGRESSION: ${{ inputs.fail_on_regression }} FAIL_ON_BUDGET: ${{ inputs.fail_on_budget }} @@ -101,6 +106,9 @@ jobs: fi if [[ -n "${BASELINE_PATH}" ]]; then args+=(--baseline "${BASELINE_PATH}") + if [[ -n "${COMPARISON_POLICY_PATH}" ]]; then + args+=(--comparison-policy "${COMPARISON_POLICY_PATH}") + fi if [[ "${FAIL_ON_REGRESSION}" == "true" ]]; then args+=(--fail-on-regression) fi diff --git a/kun/README.md b/kun/README.md index 8d28fab86..438aab61a 100644 --- a/kun/README.md +++ b/kun/README.md @@ -68,6 +68,33 @@ npm run benchmark:replay -- --suite benchmarks/agent-core.json --repeat 2 \ --baseline replay-baseline.json --output replay-current.json --fail-on-regression ``` +Use a comparison policy when providers or local models need different variance tolerances: + +```bash +npm run benchmark:replay -- --suite benchmarks/agent-core.json \ + --baseline replay-baseline.json --comparison-policy replay-policy.json \ + --output replay-current.json --fail-on-regression +``` + +```json +{ + "defaults": { + "maxSuccessRateDrop": 0, + "maxTtftRelativeIncrease": 0.2, + "maxTtftAbsoluteIncreaseMs": 300 + }, + "models": { + "local-model": { + "maxTtftRelativeIncrease": 0.5, + "maxTtftAbsoluteIncreaseMs": 800 + } + } +} +``` + +Reports must use the same suite, task count, repeat count, and tag. Model changes are rejected unless the policy +sets `allowModelChange` to `true`; per-model thresholds are resolved against the current report model. + Replay threads always use the `read-only` sandbox and disable interactive input. Reports include success rate, TTFT, full latency, tool time, SSE delivery delay, token/cache/cost counters, and Kun process peak RSS. The runtime token is accepted only through `KUN_RUNTIME_TOKEN`, so it does not leak through process arguments. diff --git a/kun/src/benchmark/replay-benchmark.test.ts b/kun/src/benchmark/replay-benchmark.test.ts index 937ba2a9e..d917f3c92 100644 --- a/kun/src/benchmark/replay-benchmark.test.ts +++ b/kun/src/benchmark/replay-benchmark.test.ts @@ -173,6 +173,70 @@ describe('replay benchmark', () => { ])) }) + it('uses per-model comparison tolerances without changing global defaults', () => { + const baseline = report([replayRun('passed', 100, 1_000, 0.8)], '2026-06-28T00:00:00.000Z') + const current = report([replayRun('passed', 450, 1_900, 0.8)], '2026-06-29T00:00:00.000Z') + current.runtime.model = 'local-model' + + const comparison = compareReplayReports(current, baseline, { + allowModelChange: true, + defaults: {}, + models: { + 'local-model': { + maxTtftRelativeIncrease: 5, + maxTotalRelativeIncrease: 5 + } + } + }) + + expect(comparison.model).toBe('local-model') + expect(comparison.policy.maxTtftRelativeIncrease).toBe(5) + expect(comparison.regressions).not.toEqual(expect.arrayContaining([ + expect.stringContaining('TTFT'), + expect.stringContaining('total latency') + ])) + }) + + it('supports explicit token and memory regression thresholds', () => { + const baseline = report([replayRun('passed', 100, 1_000, 0.8)], '2026-06-28T00:00:00.000Z') + const current = report([replayRun('passed', 100, 1_000, 0.8)], '2026-06-29T00:00:00.000Z') + baseline.summary.promptTokens = 1_000 + current.summary.promptTokens = 1_500 + baseline.summary.peakRssBytes = 100_000 + current.summary.peakRssBytes = 200_000 + + const comparison = compareReplayReports(current, baseline, { + defaults: { + maxPromptTokensRelativeIncrease: 0.1, + maxPromptTokensAbsoluteIncrease: 100, + maxPeakRssRelativeIncrease: 0.1, + maxPeakRssAbsoluteIncreaseBytes: 10_000 + } + }) + + expect(comparison.regressions).toEqual(expect.arrayContaining([ + expect.stringContaining('prompt tokens'), + expect.stringContaining('peak RSS') + ])) + }) + + it('rejects an incompatible baseline unless model changes are explicit', () => { + const baseline = report([replayRun('passed', 100, 1_000, 0.8)], '2026-06-28T00:00:00.000Z') + const current = report([replayRun('passed', 100, 1_000, 0.8)], '2026-06-29T00:00:00.000Z') + baseline.runtime.model = 'model-a' + current.runtime.model = 'model-b' + + expect(() => compareReplayReports(current, baseline)).toThrow('runtime model') + expect(() => compareReplayReports(current, baseline, { + allowModelChange: true + })).not.toThrow() + + current.suite.taskCount += 1 + expect(() => compareReplayReports(current, baseline, { + allowModelChange: true + })).toThrow('task count') + }) + it('evaluates explicit replay budget gates', () => { const passing = report([ replayRun('passed', 100, 1_000, 0.8), diff --git a/kun/src/benchmark/replay-benchmark.ts b/kun/src/benchmark/replay-benchmark.ts index 9ca31cbeb..eb082b831 100644 --- a/kun/src/benchmark/replay-benchmark.ts +++ b/kun/src/benchmark/replay-benchmark.ts @@ -138,6 +138,8 @@ export type ReplayReportSummary = { export type ReplayComparison = { baselineGeneratedAt: string + model?: string + policy: ReplayComparisonThresholds successRateDelta: number ttftP95MsDelta: number | null totalP95MsDelta: number | null @@ -148,6 +150,30 @@ export type ReplayComparison = { regressions: string[] } +const ReplayComparisonThresholdsSchema = z.object({ + maxSuccessRateDrop: z.number().min(0).max(1).default(0), + maxTtftRelativeIncrease: z.number().nonnegative().default(0.2), + maxTtftAbsoluteIncreaseMs: z.number().nonnegative().default(300), + maxTotalRelativeIncrease: z.number().nonnegative().default(0.2), + maxTotalAbsoluteIncreaseMs: z.number().nonnegative().default(500), + maxCacheHitRateDrop: z.number().min(0).max(1).default(0.05), + maxCostRelativeIncrease: z.number().nonnegative().default(0.1), + maxCostAbsoluteIncreaseUsd: z.number().nonnegative().optional(), + maxPromptTokensRelativeIncrease: z.number().nonnegative().optional(), + maxPromptTokensAbsoluteIncrease: z.number().int().nonnegative().optional(), + maxPeakRssRelativeIncrease: z.number().nonnegative().optional(), + maxPeakRssAbsoluteIncreaseBytes: z.number().int().nonnegative().optional() +}).strict() + +export const ReplayComparisonPolicySchema = z.object({ + defaults: ReplayComparisonThresholdsSchema.default(() => ReplayComparisonThresholdsSchema.parse({})), + models: z.record(z.string().min(1), ReplayComparisonThresholdsSchema.partial()).default({}), + allowModelChange: z.boolean().default(false) +}).strict() + +export type ReplayComparisonThresholds = z.infer +export type ReplayComparisonPolicy = z.infer + const ReplayBudgetSchema = z.object({ minSuccessRate: z.number().min(0).max(1).optional(), maxTtftP95Ms: z.number().nonnegative().optional(), @@ -532,28 +558,92 @@ export function summarizeReplayRuns(runs: ReplayRunResult[]): ReplayReportSummar } } -export function compareReplayReports(current: ReplayReport, baseline: ReplayReport): ReplayComparison { +export function parseReplayComparisonPolicy(input: unknown): ReplayComparisonPolicy { + return ReplayComparisonPolicySchema.parse(input ?? {}) +} + +export function compareReplayReports( + current: ReplayReport, + baseline: ReplayReport, + policyInput: unknown = {} +): ReplayComparison { + const configuredPolicy = parseReplayComparisonPolicy(policyInput) + assertReplayReportsComparable(current, baseline, configuredPolicy) + const model = current.runtime.model + const policy = ReplayComparisonThresholdsSchema.parse({ + ...configuredPolicy.defaults, + ...(model ? configuredPolicy.models[model] : {}) + }) const successRateDelta = current.summary.successRate - baseline.summary.successRate const ttftP95MsDelta = nullableDelta(current.summary.ttftP95Ms, baseline.summary.ttftP95Ms) const totalP95MsDelta = nullableDelta(current.summary.totalP95Ms, baseline.summary.totalP95Ms) const cacheHitRateDelta = nullableDelta(current.summary.cacheHitRate, baseline.summary.cacheHitRate) const peakRssBytesDelta = nullableDelta(current.summary.peakRssBytes, baseline.summary.peakRssBytes) const regressions: string[] = [] - if (successRateDelta < 0) regressions.push(`success rate dropped by ${formatPercent(-successRateDelta)}`) - if (isRelativeRegression(current.summary.ttftP95Ms, baseline.summary.ttftP95Ms, 0.2, 300)) { + if (successRateDelta < -policy.maxSuccessRateDrop) { + regressions.push(`success rate dropped by ${formatPercent(-successRateDelta)}`) + } + if (isRelativeRegression( + current.summary.ttftP95Ms, + baseline.summary.ttftP95Ms, + policy.maxTtftRelativeIncrease, + policy.maxTtftAbsoluteIncreaseMs + )) { regressions.push(`TTFT p95 increased by ${ttftP95MsDelta}ms`) } - if (isRelativeRegression(current.summary.totalP95Ms, baseline.summary.totalP95Ms, 0.2, 500)) { + if (isRelativeRegression( + current.summary.totalP95Ms, + baseline.summary.totalP95Ms, + policy.maxTotalRelativeIncrease, + policy.maxTotalAbsoluteIncreaseMs + )) { regressions.push(`total latency p95 increased by ${totalP95MsDelta}ms`) } - if (cacheHitRateDelta !== null && cacheHitRateDelta < -0.05) { + if (cacheHitRateDelta !== null && cacheHitRateDelta < -policy.maxCacheHitRateDrop) { regressions.push(`cache hit rate dropped by ${formatPercent(-cacheHitRateDelta)}`) } - if (baseline.summary.costUsd > 0 && current.summary.costUsd > baseline.summary.costUsd * 1.1) { + if (isRelativeRegression( + current.summary.costUsd, + baseline.summary.costUsd, + policy.maxCostRelativeIncrease, + policy.maxCostAbsoluteIncreaseUsd ?? 0 + )) { + regressions.push(`cost increased by $${(current.summary.costUsd - baseline.summary.costUsd).toFixed(6)}`) + } else if ( + baseline.summary.costUsd <= 0 && + policy.maxCostAbsoluteIncreaseUsd !== undefined && + current.summary.costUsd - baseline.summary.costUsd > policy.maxCostAbsoluteIncreaseUsd + ) { regressions.push(`cost increased by $${(current.summary.costUsd - baseline.summary.costUsd).toFixed(6)}`) } + if ( + (policy.maxPromptTokensRelativeIncrease !== undefined || + policy.maxPromptTokensAbsoluteIncrease !== undefined) && + isRelativeRegression( + current.summary.promptTokens, + baseline.summary.promptTokens, + policy.maxPromptTokensRelativeIncrease ?? 0, + policy.maxPromptTokensAbsoluteIncrease ?? 0 + ) + ) { + regressions.push(`prompt tokens increased by ${current.summary.promptTokens - baseline.summary.promptTokens}`) + } + if ( + (policy.maxPeakRssRelativeIncrease !== undefined || + policy.maxPeakRssAbsoluteIncreaseBytes !== undefined) && + isRelativeRegression( + current.summary.peakRssBytes, + baseline.summary.peakRssBytes, + policy.maxPeakRssRelativeIncrease ?? 0, + policy.maxPeakRssAbsoluteIncreaseBytes ?? 0 + ) + ) { + regressions.push(`peak RSS increased by ${formatBytes(peakRssBytesDelta ?? 0)}`) + } return { baselineGeneratedAt: baseline.generatedAt, + ...(model ? { model } : {}), + policy, successRateDelta, ttftP95MsDelta, totalP95MsDelta, @@ -565,6 +655,24 @@ export function compareReplayReports(current: ReplayReport, baseline: ReplayRepo } } +function assertReplayReportsComparable( + current: ReplayReport, + baseline: ReplayReport, + policy: ReplayComparisonPolicy +): void { + const mismatches: string[] = [] + if (current.suite.name !== baseline.suite.name) mismatches.push('suite name') + if (current.suite.taskCount !== baseline.suite.taskCount) mismatches.push('task count') + if (current.suite.repeat !== baseline.suite.repeat) mismatches.push('repeat count') + if ((current.suite.tag ?? '') !== (baseline.suite.tag ?? '')) mismatches.push('tag filter') + if (!policy.allowModelChange && (current.runtime.model ?? '') !== (baseline.runtime.model ?? '')) { + mismatches.push('runtime model') + } + if (mismatches.length > 0) { + throw new Error(`replay baseline is not comparable: ${mismatches.join(', ')} differ`) + } +} + export function parseReplayBudget(input: unknown): ReplayBudget { return ReplayBudgetSchema.parse(input) } @@ -612,6 +720,7 @@ export function formatReplayReportMarkdown(report: ReplayReport): string { if (report.comparison) { lines.push('## Baseline Comparison', '') lines.push(`- Baseline: ${report.comparison.baselineGeneratedAt}`) + lines.push(`- Comparison model: ${report.comparison.model ?? 'n/a'}`) lines.push(`- Success rate delta: ${formatSignedPercent(report.comparison.successRateDelta)}`) lines.push(`- TTFT p95 delta: ${formatSignedOptionalMs(report.comparison.ttftP95MsDelta)}`) lines.push(`- Total p95 delta: ${formatSignedOptionalMs(report.comparison.totalP95MsDelta)}`) diff --git a/kun/src/cli/replay-entry.ts b/kun/src/cli/replay-entry.ts index aa2715a82..9468763d8 100644 --- a/kun/src/cli/replay-entry.ts +++ b/kun/src/cli/replay-entry.ts @@ -19,6 +19,7 @@ type CliOptions = { outputPath?: string summaryPath?: string baselinePath?: string + comparisonPolicyPath?: string budgetPath?: string repeat: number concurrency: number @@ -38,6 +39,9 @@ if (!options.suitePath) { printUsage() process.exit(2) } +if (options.comparisonPolicyPath && !options.baselinePath) { + throw new Error('--comparison-policy requires --baseline') +} const suitePath = resolve(options.suitePath) const suite = JSON.parse(await readFile(suitePath, 'utf8')) as unknown @@ -60,7 +64,10 @@ const report = await runReplaySuite(suite, { if (options.baselinePath) { const baseline = JSON.parse(await readFile(resolve(options.baselinePath), 'utf8')) as ReplayReport - report.comparison = compareReplayReports(report, baseline) + const comparisonPolicy = options.comparisonPolicyPath + ? JSON.parse(await readFile(resolve(options.comparisonPolicyPath), 'utf8')) as unknown + : undefined + report.comparison = compareReplayReports(report, baseline, comparisonPolicy) } if (options.budgetPath) { const budget = JSON.parse(await readFile(resolve(options.budgetPath), 'utf8')) as unknown @@ -122,6 +129,9 @@ function parseArgs(args: string[]): CliOptions { case '--baseline': options.baselinePath = requiredValue(args, ++index, arg) break + case '--comparison-policy': + options.comparisonPolicyPath = requiredValue(args, ++index, arg) + break case '--budget': options.budgetPath = requiredValue(args, ++index, arg) break @@ -177,6 +187,7 @@ function printUsage(): void { console.log(' --repeat Repeat each selected task (default 1)') console.log(' --concurrency Parallel tasks, capped at 8 (default 1)') console.log(' --baseline Compare against an earlier report') + console.log(' --comparison-policy Configure default and per-model regression tolerances') console.log(' --budget Evaluate explicit CI budget thresholds') console.log(' --output Write the full machine-readable report') console.log(' --summary-output Write a Markdown summary report') From 0628d55f14d7f170c8b9c67ec5b1111cb436e716 Mon Sep 17 00:00:00 2001 From: luoye520ww <100058663+luoye520ww@users.noreply.github.com> Date: Sat, 11 Jul 2026 17:08:03 +0800 Subject: [PATCH 025/110] feat(telemetry): export sanitized spans over OTLP --- kun/README.md | 21 ++ kun/src/cli/serve.ts | 44 +++- kun/src/config/kun-config.ts | 9 +- kun/src/telemetry/agent-observability.test.ts | 23 +- kun/src/telemetry/agent-observability.ts | 29 ++- kun/src/telemetry/index.ts | 1 + kun/src/telemetry/otlp-http-json-sink.test.ts | 89 +++++++ kun/src/telemetry/otlp-http-json-sink.ts | 226 ++++++++++++++++++ kun/tests/contracts.test.ts | 18 ++ 9 files changed, 448 insertions(+), 12 deletions(-) create mode 100644 kun/src/telemetry/otlp-http-json-sink.test.ts create mode 100644 kun/src/telemetry/otlp-http-json-sink.ts diff --git a/kun/README.md b/kun/README.md index 438aab61a..11ebbcb40 100644 --- a/kun/README.md +++ b/kun/README.md @@ -503,6 +503,27 @@ cross-thread recall, create an explicit memory record through the GUI memory review surface or the `memory_create` tool. If it should stay local to one thread, leave it as a pinned constraint. +## Agent observability + +Sanitized agent spans can stay in the default local JSONL file or be +exported to an OpenTelemetry collector with OTLP/HTTP JSON. The OTLP +exporter is opt-in, bounded, batched, and runs outside the runtime event +persistence path. Set the standard variables below before starting Kun: + +```sh +OTEL_TRACES_EXPORTER=otlp +OTEL_EXPORTER_OTLP_PROTOCOL=http/json +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 +``` + +`OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` is used as-is when set. Otherwise +Kun appends `/v1/traces` to `OTEL_EXPORTER_OTLP_ENDPOINT`. Standard +`OTEL_EXPORTER_OTLP_HEADERS` and `OTEL_EXPORTER_OTLP_TIMEOUT` values are +also supported, including their trace-specific variants. Prompts, +assistant text, tool arguments, tool output, commands, and arbitrary +error messages are excluded by default. `includeSensitiveContent` must +be explicitly enabled before arbitrary error messages are exported. + ## Troubleshooting - MCP server does not appear: check `capabilities.mcp.enabled`, the diff --git a/kun/src/cli/serve.ts b/kun/src/cli/serve.ts index 39ef091af..3d65d27e9 100644 --- a/kun/src/cli/serve.ts +++ b/kun/src/cli/serve.ts @@ -11,6 +11,7 @@ import { readOptionalKunConfigFile, type LoadedKunConfig } from '../config/kun-config.js' +import { parseOtlpHeaders, resolveOtlpTracesEndpoint } from '../telemetry/otlp-http-json-sink.js' /** * Parse the `kun serve` command line into validated options. @@ -58,6 +59,31 @@ export function parseServeOptions( stringFlag(raw, 'observabilityOutput') ?? env.KUN_OBSERVABILITY_OUTPUT_PATH ?? configServe.observability?.outputPath + const observabilityExporterValue = + stringFlag(raw, 'observability-exporter') ?? + env.KUN_OBSERVABILITY_EXPORTER ?? + configServe.observability?.exporter + const observabilityExporter = observabilityExporterValue as + | 'jsonl' + | 'otlp-http-json' + | undefined + const otlpProtocol = env.OTEL_EXPORTER_OTLP_TRACES_PROTOCOL ?? env.OTEL_EXPORTER_OTLP_PROTOCOL + const standardOtlpEnabled = env.OTEL_TRACES_EXPORTER + ?.split(',') + .map((entry) => entry.trim()) + .includes('otlp') && otlpProtocol === 'http/json' + const resolvedObservabilityExporter = observabilityExporter ?? (standardOtlpEnabled ? 'otlp-http-json' : undefined) + const observabilityEndpoint = + env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT ?? + (standardOtlpEnabled + ? resolveOtlpTracesEndpoint({ commonEndpoint: env.OTEL_EXPORTER_OTLP_ENDPOINT }) + : configServe.observability?.endpoint) + const observabilityHeaders = parseOtlpHeaders( + env.OTEL_EXPORTER_OTLP_TRACES_HEADERS ?? env.OTEL_EXPORTER_OTLP_HEADERS + ) ?? configServe.observability?.headers + const observabilityTimeoutMs = numberEnv( + env.OTEL_EXPORTER_OTLP_TRACES_TIMEOUT ?? env.OTEL_EXPORTER_OTLP_TIMEOUT + ) ?? configServe.observability?.timeoutMs const merged: ServeOptions = { ...DEFAULT_SERVE_OPTIONS, ...(loadedConfig ? { configPath: loadedConfig.path } : {}), @@ -152,11 +178,15 @@ export function parseServeOptions( : {}) }, observability: - observabilityEnabled !== undefined || observabilityOutputPath + observabilityEnabled !== undefined || observabilityOutputPath || resolvedObservabilityExporter ? { ...(configServe.observability ?? {}), - ...(observabilityEnabled !== undefined ? { enabled: observabilityEnabled } : {}), - ...(observabilityOutputPath ? { outputPath: observabilityOutputPath } : {}) + enabled: observabilityEnabled ?? Boolean(standardOtlpEnabled), + ...(observabilityOutputPath ? { outputPath: observabilityOutputPath } : {}), + ...(resolvedObservabilityExporter ? { exporter: resolvedObservabilityExporter } : {}), + ...(observabilityEndpoint ? { endpoint: observabilityEndpoint } : {}), + ...(observabilityHeaders ? { headers: observabilityHeaders } : {}), + ...(observabilityTimeoutMs ? { timeoutMs: observabilityTimeoutMs } : {}) } : configServe.observability, headers: configServe.headers, @@ -172,6 +202,12 @@ export function parseServeOptions( return ServeOptionsSchema.parse(merged) } +function numberEnv(value: string | undefined): number | undefined { + if (!value?.trim()) return undefined + const parsed = Number(value) + return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined +} + /** * Validate a pre-constructed options object. Used by tests and by the * main process when Kun is started programmatically. @@ -202,6 +238,8 @@ Options: --observability Write sanitized OpenTelemetry-style agent spans --observability-output JSONL span output path (default {data-dir}/observability/agent-spans.jsonl) + --observability-exporter + jsonl | otlp-http-json ` export const ServeExitCode = { diff --git a/kun/src/config/kun-config.ts b/kun/src/config/kun-config.ts index a3a8030a3..c237edd02 100644 --- a/kun/src/config/kun-config.ts +++ b/kun/src/config/kun-config.ts @@ -255,8 +255,13 @@ export const ObservabilityConfigSchema = z .object({ enabled: z.boolean().default(false).optional(), outputPath: z.string().min(1).optional(), - // Reserved for future trace payload sampling. The current exporter never - // records prompts, tool arguments, tool output, command text, or secrets. + exporter: z.enum(['jsonl', 'otlp-http-json']).optional(), + endpoint: z.string().url().optional(), + headers: z.record(z.string(), z.string()).optional(), + timeoutMs: z.number().int().min(1).max(300_000).optional(), + batchSize: z.number().int().min(1).max(512).optional(), + maxQueueSize: z.number().int().min(1).max(16_384).optional(), + // Prompt/tool payloads remain excluded unless this explicit opt-in is set. includeSensitiveContent: z.boolean().default(false).optional() }) .strict() diff --git a/kun/src/telemetry/agent-observability.test.ts b/kun/src/telemetry/agent-observability.test.ts index 7f146c083..bb9439418 100644 --- a/kun/src/telemetry/agent-observability.test.ts +++ b/kun/src/telemetry/agent-observability.test.ts @@ -212,7 +212,28 @@ describe('AgentObservabilityRecorder', () => { })) expect(sink.spans.map((span) => span.name)).toEqual(['kun.tool bash', 'kun.turn']) - expect(sink.spans[0].status).toEqual({ code: 'ERROR', message: 'interrupted' }) + expect(sink.spans[0].status).toEqual({ code: 'ERROR' }) + }) + + it('exports arbitrary error messages only after explicit opt-in', async () => { + const sink = new CaptureSink() + const recorder = new AgentObservabilityRecorder(sink, { includeSensitiveContent: true }) + + await recorder.record(event({ + kind: 'turn_started', + threadId: 'thread-1', + turnId: 'turn-1', + timestamp: '2026-07-09T00:00:00.000Z' + })) + await recorder.record(event({ + kind: 'turn_failed', + threadId: 'thread-1', + turnId: 'turn-1', + timestamp: '2026-07-09T00:00:00.400Z', + message: 'provider response may contain sensitive content' + })) + + expect(sink.spans[0].status.message).toBe('provider response may contain sensitive content') }) }) diff --git a/kun/src/telemetry/agent-observability.ts b/kun/src/telemetry/agent-observability.ts index 4f170a49a..ad4d9a5f6 100644 --- a/kun/src/telemetry/agent-observability.ts +++ b/kun/src/telemetry/agent-observability.ts @@ -5,6 +5,7 @@ import type { RuntimeEvent } from '../contracts/events.js' import type { UsageSnapshot } from '../contracts/usage.js' import type { ObservabilityConfig } from '../config/kun-config.js' import type { RuntimeEventObserver } from '../services/runtime-event-recorder.js' +import { OtlpHttpJsonAgentObservabilitySink } from './otlp-http-json-sink.js' export type AgentObservabilityAttributeValue = string | number | boolean | string[] @@ -71,7 +72,10 @@ export class AgentObservabilityRecorder implements RuntimeEventObserver { private readonly turns = new Map() private readonly tools = new Map() - constructor(private readonly sink: AgentObservabilitySink) {} + constructor( + private readonly sink: AgentObservabilitySink, + private readonly options: { includeSensitiveContent?: boolean } = {} + ) {} async record(event: RuntimeEvent): Promise { switch (event.kind) { @@ -112,8 +116,8 @@ export class AgentObservabilityRecorder implements RuntimeEventObserver { return case 'error': this.addTurnEvent(event.threadId, event.turnId, 'exception', { - 'exception.message': event.message, - ...(event.code ? { 'exception.type': event.code } : {}), + 'exception.type': event.code ?? 'runtime_error', + ...(this.options.includeSensitiveContent ? { 'exception.message': event.message } : {}), ...(event.severity ? { 'kun.error.severity': event.severity } : {}) }, event.timestamp) return @@ -213,9 +217,10 @@ export class AgentObservabilityRecorder implements RuntimeEventObserver { if (!event.turnId) return const key = turnKey(event.threadId, event.turnId) const span = this.turns.get(key) - await this.finishDanglingToolSpans(event.threadId, event.turnId, event.timestamp, code === 'OK' ? 'UNSET' : 'ERROR', message) + const safeMessage = this.options.includeSensitiveContent ? message : undefined + await this.finishDanglingToolSpans(event.threadId, event.turnId, event.timestamp, code === 'OK' ? 'UNSET' : 'ERROR', safeMessage) if (!span) return - await this.emitSpan(span, event.timestamp, code, message) + await this.emitSpan(span, event.timestamp, code, safeMessage) this.turns.delete(key) } @@ -279,10 +284,22 @@ export function createAgentObservabilityRecorder(input: { dataDir: string }): AgentObservabilityRecorder | undefined { if (!input.config?.enabled) return undefined + if (input.config.exporter === 'otlp-http-json') { + return new AgentObservabilityRecorder(new OtlpHttpJsonAgentObservabilitySink({ + endpoint: input.config.endpoint, + headers: input.config.headers, + timeoutMs: input.config.timeoutMs, + batchSize: input.config.batchSize, + maxQueueSize: input.config.maxQueueSize + }), { includeSensitiveContent: input.config.includeSensitiveContent }) + } const outputPath = input.config.outputPath ? resolveOutputPath(input.config.outputPath, input.dataDir) : join(input.dataDir, 'observability', 'agent-spans.jsonl') - return new AgentObservabilityRecorder(new JsonlAgentObservabilitySink(outputPath)) + return new AgentObservabilityRecorder( + new JsonlAgentObservabilitySink(outputPath), + { includeSensitiveContent: input.config.includeSensitiveContent } + ) } function usageAttributes(usage: UsageSnapshot, model: string | undefined): Record { diff --git a/kun/src/telemetry/index.ts b/kun/src/telemetry/index.ts index 9757a26ed..a00dd7d29 100644 --- a/kun/src/telemetry/index.ts +++ b/kun/src/telemetry/index.ts @@ -1,3 +1,4 @@ export * from './usage-counter.js' export * from './cache-telemetry.js' export * from './agent-observability.js' +export * from './otlp-http-json-sink.js' diff --git a/kun/src/telemetry/otlp-http-json-sink.test.ts b/kun/src/telemetry/otlp-http-json-sink.test.ts new file mode 100644 index 000000000..808776ecb --- /dev/null +++ b/kun/src/telemetry/otlp-http-json-sink.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentObservabilitySpan } from './agent-observability.js' +import { + OtlpHttpJsonAgentObservabilitySink, + parseOtlpHeaders, + resolveOtlpTracesEndpoint +} from './otlp-http-json-sink.js' + +describe('OtlpHttpJsonAgentObservabilitySink', () => { + it('exports valid OTLP JSON without adding content fields', async () => { + const calls: Array> = [] + const fetch: typeof globalThis.fetch = vi.fn(async (input, init) => { + calls.push([input, init]) + return new Response(null, { status: 200 }) + }) + const sink = new OtlpHttpJsonAgentObservabilitySink({ + endpoint: 'https://collector.example/v1/traces', + headers: { authorization: 'Bearer token' }, + fetch + }) + + sink.emit(span()) + await sink.flush() + + expect(fetch).toHaveBeenCalledOnce() + const [url, init] = calls[0]! + expect(url).toBe('https://collector.example/v1/traces') + expect(init?.headers).toMatchObject({ + 'content-type': 'application/json', + authorization: 'Bearer token' + }) + const payload = JSON.parse(String(init?.body)) + const exported = payload.resourceSpans[0].scopeSpans[0].spans[0] + expect(exported).toMatchObject({ + traceId: '1'.repeat(32), + spanId: '2'.repeat(16), + kind: 1, + status: { code: 1 } + }) + expect(exported.attributes).toContainEqual({ + key: 'gen_ai.usage.input_tokens', + value: { intValue: '42' } + }) + expect(JSON.stringify(payload)).not.toContain('prompt') + expect(JSON.stringify(payload)).not.toContain('secret') + }) + + it('uses signal endpoint as-is and appends the traces path to common endpoints', () => { + expect(resolveOtlpTracesEndpoint({ tracesEndpoint: 'https://a.test/custom' })).toBe('https://a.test/custom') + expect(resolveOtlpTracesEndpoint({ commonEndpoint: 'https://a.test/otel/' })).toBe('https://a.test/otel/v1/traces') + expect(resolveOtlpTracesEndpoint({})).toBe('http://localhost:4318/v1/traces') + }) + + it('parses percent-encoded standard OTLP headers', () => { + expect(parseOtlpHeaders('api-key=hello%20world,x-tenant=kun')).toEqual({ + 'api-key': 'hello world', + 'x-tenant': 'kun' + }) + }) + + it('does not retry a permanently rejected batch', async () => { + const fetch: typeof globalThis.fetch = vi.fn(async () => new Response(null, { status: 400 })) + const sink = new OtlpHttpJsonAgentObservabilitySink({ fetch }) + + sink.emit(span()) + await sink.flush() + await sink.flush() + + expect(fetch).toHaveBeenCalledOnce() + }) +}) + +function span(): AgentObservabilitySpan { + return { + schemaUrl: 'https://opentelemetry.io/schemas/1.37.0', + traceId: '1'.repeat(32), + spanId: '2'.repeat(16), + name: 'kun.turn', + kind: 'internal', + startTimeUnixNano: '1000000', + endTimeUnixNano: '2000000', + durationMs: 1, + status: { code: 'OK' }, + attributes: { + 'gen_ai.usage.input_tokens': 42, + 'kun.cache.hit_rate': 0.5 + } + } +} diff --git a/kun/src/telemetry/otlp-http-json-sink.ts b/kun/src/telemetry/otlp-http-json-sink.ts new file mode 100644 index 000000000..c90eac935 --- /dev/null +++ b/kun/src/telemetry/otlp-http-json-sink.ts @@ -0,0 +1,226 @@ +import type { + AgentObservabilityAttributeValue, + AgentObservabilitySink, + AgentObservabilitySpan +} from './agent-observability.js' + +const DEFAULT_OTLP_ENDPOINT = 'http://localhost:4318/v1/traces' +const DEFAULT_TIMEOUT_MS = 10_000 +const DEFAULT_BATCH_SIZE = 64 +const DEFAULT_MAX_QUEUE_SIZE = 2_048 +const MAX_RETRY_DELAY_MS = 30_000 + +class PermanentOtlpExportError extends Error {} + +export type OtlpHttpJsonSinkOptions = { + endpoint?: string + headers?: Record + timeoutMs?: number + batchSize?: number + maxQueueSize?: number + fetch?: typeof globalThis.fetch +} + +/** + * A bounded, non-blocking OTLP/HTTP JSON exporter. Runtime event persistence + * must never wait for an external collector, so emit only queues work and a + * single background worker owns delivery and retry ordering. + */ +export class OtlpHttpJsonAgentObservabilitySink implements AgentObservabilitySink { + private readonly endpoint: string + private readonly headers: Record + private readonly timeoutMs: number + private readonly batchSize: number + private readonly maxQueueSize: number + private readonly fetchImpl: typeof globalThis.fetch + private queue: AgentObservabilitySpan[] = [] + private scheduled = false + private inFlight: Promise | undefined + private retryAttempt = 0 + private retryTimer: ReturnType | undefined + private warnedAboutOverflow = false + + constructor(options: OtlpHttpJsonSinkOptions = {}) { + this.endpoint = options.endpoint ?? DEFAULT_OTLP_ENDPOINT + this.headers = options.headers ?? {} + this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS + this.batchSize = options.batchSize ?? DEFAULT_BATCH_SIZE + this.maxQueueSize = options.maxQueueSize ?? DEFAULT_MAX_QUEUE_SIZE + this.fetchImpl = options.fetch ?? globalThis.fetch + } + + emit(span: AgentObservabilitySpan): void { + if (this.queue.length >= this.maxQueueSize) { + this.queue.shift() + if (!this.warnedAboutOverflow) { + this.warnedAboutOverflow = true + console.warn('[kun] OTLP trace queue full; dropping oldest spans') + } + } + this.queue.push(span) + this.scheduleFlush() + } + + async flush(): Promise { + if (this.inFlight) return this.inFlight + if (this.retryTimer) { + clearTimeout(this.retryTimer) + this.retryTimer = undefined + } + if (this.queue.length === 0) return + + const batch = this.queue.splice(0, this.batchSize) + this.inFlight = this.exportBatch(batch) + .then(() => { + this.retryAttempt = 0 + this.warnedAboutOverflow = false + }) + .catch((error: unknown) => { + if (error instanceof PermanentOtlpExportError) { + console.warn(`[kun] OTLP trace export rejected; dropping batch: ${error.message}`) + return + } + this.requeue(batch) + const delayMs = Math.min(1_000 * 2 ** this.retryAttempt, MAX_RETRY_DELAY_MS) + this.retryAttempt += 1 + const message = error instanceof Error ? error.message : String(error) + console.warn(`[kun] OTLP trace export failed; retrying in ${delayMs}ms: ${message}`) + this.retryTimer = setTimeout(() => { + this.retryTimer = undefined + this.scheduleFlush() + }, delayMs) + this.retryTimer.unref?.() + }) + .finally(() => { + this.inFlight = undefined + if (!this.retryTimer && this.queue.length > 0) this.scheduleFlush() + }) + return this.inFlight + } + + private scheduleFlush(): void { + if (this.scheduled || this.inFlight || this.retryTimer) return + this.scheduled = true + queueMicrotask(() => { + this.scheduled = false + void this.flush() + }) + } + + private requeue(batch: AgentObservabilitySpan[]): void { + const available = Math.max(0, this.maxQueueSize - this.queue.length) + this.queue = [...batch.slice(Math.max(0, batch.length - available)), ...this.queue] + } + + private async exportBatch(batch: AgentObservabilitySpan[]): Promise { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), this.timeoutMs) + timeout.unref?.() + try { + const response = await this.fetchImpl(this.endpoint, { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...this.headers + }, + body: JSON.stringify(toExportTraceServiceRequest(batch)), + signal: controller.signal + }) + if (!response.ok) { + const message = `collector returned HTTP ${response.status}` + if (response.status !== 408 && response.status !== 429 && response.status < 500) { + throw new PermanentOtlpExportError(message) + } + throw new Error(message) + } + } finally { + clearTimeout(timeout) + } + } +} + +export function resolveOtlpTracesEndpoint(input: { + tracesEndpoint?: string + commonEndpoint?: string +}): string { + if (input.tracesEndpoint) return input.tracesEndpoint + if (!input.commonEndpoint) return DEFAULT_OTLP_ENDPOINT + return input.commonEndpoint.replace(/\/$/, '') + '/v1/traces' +} + +export function parseOtlpHeaders(value: string | undefined): Record | undefined { + if (!value?.trim()) return undefined + const headers: Record = {} + for (const entry of value.split(',')) { + const separator = entry.indexOf('=') + if (separator <= 0) continue + const key = safeDecodeURIComponent(entry.slice(0, separator).trim()) + const headerValue = safeDecodeURIComponent(entry.slice(separator + 1).trim()) + if (key) headers[key] = headerValue + } + return Object.keys(headers).length > 0 ? headers : undefined +} + +function safeDecodeURIComponent(value: string): string { + try { + return decodeURIComponent(value) + } catch { + return value + } +} + +function toExportTraceServiceRequest(spans: AgentObservabilitySpan[]): Record { + return { + resourceSpans: [{ + resource: { + attributes: [attribute('service.name', 'kun-runtime')] + }, + scopeSpans: [{ + scope: { name: 'kun.agent-observability' }, + spans: spans.map(toOtlpSpan), + schemaUrl: spans[0]?.schemaUrl + }] + }] + } +} + +function toOtlpSpan(span: AgentObservabilitySpan): Record { + return { + traceId: span.traceId, + spanId: span.spanId, + ...(span.parentSpanId ? { parentSpanId: span.parentSpanId } : {}), + name: span.name, + kind: span.kind === 'client' ? 3 : 1, + startTimeUnixNano: span.startTimeUnixNano, + endTimeUnixNano: span.endTimeUnixNano, + attributes: Object.entries(span.attributes).map(([key, value]) => attribute(key, value)), + status: { + code: span.status.code === 'OK' ? 1 : span.status.code === 'ERROR' ? 2 : 0, + ...(span.status.message ? { message: span.status.message } : {}) + }, + ...(span.events?.length + ? { + events: span.events.map((event) => ({ + name: event.name, + timeUnixNano: event.timeUnixNano, + attributes: Object.entries(event.attributes ?? {}).map(([key, value]) => attribute(key, value)) + })) + } + : {}) + } +} + +function attribute(key: string, value: AgentObservabilityAttributeValue): Record { + return { key, value: anyValue(value) } +} + +function anyValue(value: AgentObservabilityAttributeValue): Record { + if (Array.isArray(value)) { + return { arrayValue: { values: value.map((entry) => ({ stringValue: entry })) } } + } + if (typeof value === 'boolean') return { boolValue: value } + if (typeof value === 'number') { + return Number.isInteger(value) ? { intValue: String(value) } : { doubleValue: value } + } + return { stringValue: value } +} diff --git a/kun/tests/contracts.test.ts b/kun/tests/contracts.test.ts index 195f9d588..4fbaa8ec8 100644 --- a/kun/tests/contracts.test.ts +++ b/kun/tests/contracts.test.ts @@ -406,6 +406,24 @@ describe('cli', () => { }) }) + it('enables the OTLP HTTP JSON exporter from standard environment variables', () => { + const parsed = parseServeOptions(['--data-dir=/srv/ca'], { + OTEL_TRACES_EXPORTER: 'otlp', + OTEL_EXPORTER_OTLP_PROTOCOL: 'http/json', + OTEL_EXPORTER_OTLP_ENDPOINT: 'https://collector.example/otel', + OTEL_EXPORTER_OTLP_HEADERS: 'api-key=hello%20world', + OTEL_EXPORTER_OTLP_TIMEOUT: '2500' + }) + expect(parsed.observability).toEqual({ + enabled: true, + exporter: 'otlp-http-json', + endpoint: 'https://collector.example/otel/v1/traces', + headers: { 'api-key': 'hello world' }, + timeoutMs: 2500, + includeSensitiveContent: false + }) + }) + it('loads serve and context compaction settings from an explicit config file', async () => { const dir = await mkdtemp(join(tmpdir(), 'kun-config-')) try { From 06cb93a2e180deb81cfa428cd4960a7a56792e54 Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Sun, 12 Jul 2026 23:41:58 +0800 Subject: [PATCH 026/110] fix(runtime): harden replay and telemetry exports --- kun/README.md | 8 +- kun/src/benchmark/replay-benchmark.test.ts | 65 +++++ kun/src/benchmark/replay-benchmark.ts | 243 ++++++++++++++---- kun/src/cli/serve.ts | 30 ++- kun/src/server/runtime-factory.ts | 6 +- kun/src/telemetry/agent-observability.test.ts | 11 +- kun/src/telemetry/agent-observability.ts | 6 + kun/src/telemetry/otlp-http-json-sink.test.ts | 12 + kun/src/telemetry/otlp-http-json-sink.ts | 58 ++++- kun/tests/contracts.test.ts | 38 +++ 10 files changed, 400 insertions(+), 77 deletions(-) diff --git a/kun/README.md b/kun/README.md index 11ebbcb40..f29e2369f 100644 --- a/kun/README.md +++ b/kun/README.md @@ -92,8 +92,9 @@ npm run benchmark:replay -- --suite benchmarks/agent-core.json \ } ``` -Reports must use the same suite, task count, repeat count, and tag. Model changes are rejected unless the policy -sets `allowModelChange` to `true`; per-model thresholds are resolved against the current report model. +Reports must use the same suite, task iterations, repeat count, concurrency, and tag. Model changes are rejected +unless the policy sets `allowModelChange` to `true`. Each run records its effective task/suite/runtime model, so +mixed-model suites resolve and evaluate thresholds separately for every current model. Replay threads always use the `read-only` sandbox and disable interactive input. Reports include success rate, TTFT, full latency, tool time, SSE delivery delay, token/cache/cost counters, and Kun process peak RSS. The runtime @@ -508,7 +509,8 @@ stay local to one thread, leave it as a pinned constraint. Sanitized agent spans can stay in the default local JSONL file or be exported to an OpenTelemetry collector with OTLP/HTTP JSON. The OTLP exporter is opt-in, bounded, batched, and runs outside the runtime event -persistence path. Set the standard variables below before starting Kun: +persistence path. Runtime shutdown drains queued batches within the configured +export timeout and leaves no background retry timer behind. Set the standard variables below before starting Kun: ```sh OTEL_TRACES_EXPORTER=otlp diff --git a/kun/src/benchmark/replay-benchmark.test.ts b/kun/src/benchmark/replay-benchmark.test.ts index d917f3c92..9582c7b12 100644 --- a/kun/src/benchmark/replay-benchmark.test.ts +++ b/kun/src/benchmark/replay-benchmark.test.ts @@ -197,6 +197,41 @@ describe('replay benchmark', () => { ])) }) + it('applies comparison policies to each effective model in a mixed-model suite', () => { + const baselineFast = replayRun('passed', 100, 1_000, 0.8) + Object.assign(baselineFast, { id: 'fast#1', taskId: 'fast', model: 'fast-model' }) + const baselineAccurate = replayRun('passed', 100, 1_000, 0.8) + Object.assign(baselineAccurate, { id: 'accurate#1', taskId: 'accurate', model: 'accurate-model' }) + const currentFast = replayRun('passed', 450, 1_900, 0.8) + Object.assign(currentFast, { id: 'fast#1', taskId: 'fast', model: 'fast-model' }) + const currentAccurate = replayRun('passed', 500, 2_000, 0.8) + Object.assign(currentAccurate, { id: 'accurate#1', taskId: 'accurate', model: 'accurate-model' }) + + const baseline = report([baselineFast, baselineAccurate], '2026-06-28T00:00:00.000Z') + const current = report([currentFast, currentAccurate], '2026-06-29T00:00:00.000Z') + const comparison = compareReplayReports(current, baseline, { + models: { + 'fast-model': { + maxTtftRelativeIncrease: 5, + maxTotalRelativeIncrease: 5 + } + } + }) + + expect(comparison.model).toBeUndefined() + expect(comparison.modelComparisons.map((entry) => entry.model)).toEqual([ + 'accurate-model', + 'fast-model' + ]) + expect(comparison.regressions).toEqual(expect.arrayContaining([ + expect.stringContaining('[model accurate-model] TTFT'), + expect.stringContaining('[model accurate-model] total latency') + ])) + expect(comparison.regressions).not.toEqual(expect.arrayContaining([ + expect.stringContaining('[model fast-model]') + ])) + }) + it('supports explicit token and memory regression thresholds', () => { const baseline = report([replayRun('passed', 100, 1_000, 0.8)], '2026-06-28T00:00:00.000Z') const current = report([replayRun('passed', 100, 1_000, 0.8)], '2026-06-29T00:00:00.000Z') @@ -220,6 +255,27 @@ describe('replay benchmark', () => { ])) }) + it('enforces absolute token and memory thresholds when the baseline is zero', () => { + const baseline = report([replayRun('passed', 100, 1_000, 0.8)], '2026-06-28T00:00:00.000Z') + const current = report([replayRun('passed', 100, 1_000, 0.8)], '2026-06-29T00:00:00.000Z') + baseline.summary.promptTokens = 0 + current.summary.promptTokens = 150 + baseline.summary.peakRssBytes = 0 + current.summary.peakRssBytes = 20_000 + + const comparison = compareReplayReports(current, baseline, { + defaults: { + maxPromptTokensAbsoluteIncrease: 100, + maxPeakRssAbsoluteIncreaseBytes: 10_000 + } + }) + + expect(comparison.regressions).toEqual(expect.arrayContaining([ + expect.stringContaining('prompt tokens'), + expect.stringContaining('peak RSS') + ])) + }) + it('rejects an incompatible baseline unless model changes are explicit', () => { const baseline = report([replayRun('passed', 100, 1_000, 0.8)], '2026-06-28T00:00:00.000Z') const current = report([replayRun('passed', 100, 1_000, 0.8)], '2026-06-29T00:00:00.000Z') @@ -237,6 +293,15 @@ describe('replay benchmark', () => { })).toThrow('task count') }) + it('rejects comparisons recorded with different concurrency', () => { + const baseline = report([replayRun('passed', 100, 1_000, 0.8)], '2026-06-28T00:00:00.000Z') + const current = report([replayRun('passed', 100, 1_000, 0.8)], '2026-06-29T00:00:00.000Z') + baseline.suite.concurrency = 1 + current.suite.concurrency = 2 + + expect(() => compareReplayReports(current, baseline)).toThrow('concurrency') + }) + it('evaluates explicit replay budget gates', () => { const passing = report([ replayRun('passed', 100, 1_000, 0.8), diff --git a/kun/src/benchmark/replay-benchmark.ts b/kun/src/benchmark/replay-benchmark.ts index eb082b831..b12b6b58a 100644 --- a/kun/src/benchmark/replay-benchmark.ts +++ b/kun/src/benchmark/replay-benchmark.ts @@ -90,6 +90,8 @@ export type ReplayRunResult = { taskId: string iteration: number tags: string[] + /** Effective model used for this run, including task/suite overrides. */ + model?: string threadId?: string turnId?: string status: 'passed' | 'failed' | 'timeout' | 'error' @@ -140,6 +142,22 @@ export type ReplayComparison = { baselineGeneratedAt: string model?: string policy: ReplayComparisonThresholds + modelComparisons: ReplayModelComparison[] + successRateDelta: number + ttftP95MsDelta: number | null + totalP95MsDelta: number | null + promptTokensDelta: number + cacheHitRateDelta: number | null + costUsdDelta: number + peakRssBytesDelta: number | null + regressions: string[] +} + +export type ReplayModelComparison = { + model?: string + baselineModels: string[] + runCount: number + policy: ReplayComparisonThresholds successRateDelta: number ttftP95MsDelta: number | null totalP95MsDelta: number | null @@ -206,7 +224,7 @@ export type ReplayBudgetEvaluation = { export type ReplayReport = { version: 1 generatedAt: string - suite: { name: string; taskCount: number; repeat: number; tag?: string } + suite: { name: string; taskCount: number; repeat: number; concurrency?: number; tag?: string } runtime: { baseUrl: string model?: string @@ -290,6 +308,7 @@ export async function runReplaySuite( name: suite.name, taskCount: selectedTasks.length, repeat, + concurrency, ...(options.tag ? { tag: options.tag } : {}) }, runtime: { @@ -369,6 +388,7 @@ async function runReplayTask(input: { taskId: task.id, iteration, tags: task.tags, + model, threadId, turnId, status: collected.timedOut ? 'timeout' : failureReasons.length > 0 ? 'failed' : 'passed', @@ -379,7 +399,7 @@ async function runReplayTask(input: { } catch (error) { shouldInterrupt = turnId !== undefined return { - ...errorReplayRun(runId, task, iteration, errorMessage(error)), + ...errorReplayRun(runId, task, iteration, errorMessage(error), model), ...(threadId ? { threadId } : {}), ...(turnId ? { turnId } : {}) } @@ -568,32 +588,70 @@ export function compareReplayReports( policyInput: unknown = {} ): ReplayComparison { const configuredPolicy = parseReplayComparisonPolicy(policyInput) - assertReplayReportsComparable(current, baseline, configuredPolicy) - const model = current.runtime.model - const policy = ReplayComparisonThresholdsSchema.parse({ - ...configuredPolicy.defaults, - ...(model ? configuredPolicy.models[model] : {}) - }) - const successRateDelta = current.summary.successRate - baseline.summary.successRate - const ttftP95MsDelta = nullableDelta(current.summary.ttftP95Ms, baseline.summary.ttftP95Ms) - const totalP95MsDelta = nullableDelta(current.summary.totalP95Ms, baseline.summary.totalP95Ms) - const cacheHitRateDelta = nullableDelta(current.summary.cacheHitRate, baseline.summary.cacheHitRate) - const peakRssBytesDelta = nullableDelta(current.summary.peakRssBytes, baseline.summary.peakRssBytes) + const pairs = assertReplayReportsComparable(current, baseline, configuredPolicy) + const groupedPairs = groupReplayRunPairsByCurrentModel(pairs, current, baseline) + const modelComparisons = [...groupedPairs.values()] + .sort((left, right) => (left.model ?? '').localeCompare(right.model ?? '')) + .map((group): ReplayModelComparison => { + const policy = replayComparisonThresholdsForModel(configuredPolicy, group.model) + const metrics = compareReplaySummaries( + groupedPairs.size === 1 ? current.summary : summarizeReplayRuns(group.currentRuns), + groupedPairs.size === 1 ? baseline.summary : summarizeReplayRuns(group.baselineRuns), + policy + ) + return { + ...(group.model ? { model: group.model } : {}), + baselineModels: [...group.baselineModels].sort(), + runCount: group.currentRuns.length, + policy, + ...metrics + } + }) + const onlyModel = modelComparisons.length === 1 ? modelComparisons[0] : undefined + const policy = onlyModel?.policy ?? ReplayComparisonThresholdsSchema.parse(configuredPolicy.defaults) + const metrics = compareReplaySummaries(current.summary, baseline.summary, policy) + const regressions = modelComparisons.flatMap((comparison) => + comparison.regressions.map((regression) => + modelComparisons.length === 1 + ? regression + : `[model ${comparison.model ?? 'unknown'}] ${regression}` + ) + ) + return { + baselineGeneratedAt: baseline.generatedAt, + ...(onlyModel?.model ? { model: onlyModel.model } : {}), + policy, + modelComparisons, + ...metrics, + regressions + } +} + +function compareReplaySummaries( + current: ReplayReportSummary, + baseline: ReplayReportSummary, + policy: ReplayComparisonThresholds +): Omit { + const successRateDelta = current.successRate - baseline.successRate + const ttftP95MsDelta = nullableDelta(current.ttftP95Ms, baseline.ttftP95Ms) + const totalP95MsDelta = nullableDelta(current.totalP95Ms, baseline.totalP95Ms) + const cacheHitRateDelta = nullableDelta(current.cacheHitRate, baseline.cacheHitRate) + const peakRssBytesDelta = nullableDelta(current.peakRssBytes, baseline.peakRssBytes) const regressions: string[] = [] if (successRateDelta < -policy.maxSuccessRateDrop) { regressions.push(`success rate dropped by ${formatPercent(-successRateDelta)}`) } - if (isRelativeRegression( - current.summary.ttftP95Ms, - baseline.summary.ttftP95Ms, + if (isIncreaseRegression( + current.ttftP95Ms, + baseline.ttftP95Ms, policy.maxTtftRelativeIncrease, policy.maxTtftAbsoluteIncreaseMs )) { regressions.push(`TTFT p95 increased by ${ttftP95MsDelta}ms`) } - if (isRelativeRegression( - current.summary.totalP95Ms, - baseline.summary.totalP95Ms, + if (isIncreaseRegression( + current.totalP95Ms, + baseline.totalP95Ms, policy.maxTotalRelativeIncrease, policy.maxTotalAbsoluteIncreaseMs )) { @@ -602,75 +660,139 @@ export function compareReplayReports( if (cacheHitRateDelta !== null && cacheHitRateDelta < -policy.maxCacheHitRateDrop) { regressions.push(`cache hit rate dropped by ${formatPercent(-cacheHitRateDelta)}`) } - if (isRelativeRegression( - current.summary.costUsd, - baseline.summary.costUsd, + if (isIncreaseRegression( + current.costUsd, + baseline.costUsd, policy.maxCostRelativeIncrease, - policy.maxCostAbsoluteIncreaseUsd ?? 0 + policy.maxCostAbsoluteIncreaseUsd )) { - regressions.push(`cost increased by $${(current.summary.costUsd - baseline.summary.costUsd).toFixed(6)}`) - } else if ( - baseline.summary.costUsd <= 0 && - policy.maxCostAbsoluteIncreaseUsd !== undefined && - current.summary.costUsd - baseline.summary.costUsd > policy.maxCostAbsoluteIncreaseUsd - ) { - regressions.push(`cost increased by $${(current.summary.costUsd - baseline.summary.costUsd).toFixed(6)}`) + regressions.push(`cost increased by $${(current.costUsd - baseline.costUsd).toFixed(6)}`) } if ( (policy.maxPromptTokensRelativeIncrease !== undefined || policy.maxPromptTokensAbsoluteIncrease !== undefined) && - isRelativeRegression( - current.summary.promptTokens, - baseline.summary.promptTokens, - policy.maxPromptTokensRelativeIncrease ?? 0, - policy.maxPromptTokensAbsoluteIncrease ?? 0 + isIncreaseRegression( + current.promptTokens, + baseline.promptTokens, + policy.maxPromptTokensRelativeIncrease, + policy.maxPromptTokensAbsoluteIncrease ) ) { - regressions.push(`prompt tokens increased by ${current.summary.promptTokens - baseline.summary.promptTokens}`) + regressions.push(`prompt tokens increased by ${current.promptTokens - baseline.promptTokens}`) } if ( (policy.maxPeakRssRelativeIncrease !== undefined || policy.maxPeakRssAbsoluteIncreaseBytes !== undefined) && - isRelativeRegression( - current.summary.peakRssBytes, - baseline.summary.peakRssBytes, - policy.maxPeakRssRelativeIncrease ?? 0, - policy.maxPeakRssAbsoluteIncreaseBytes ?? 0 + isIncreaseRegression( + current.peakRssBytes, + baseline.peakRssBytes, + policy.maxPeakRssRelativeIncrease, + policy.maxPeakRssAbsoluteIncreaseBytes ) ) { regressions.push(`peak RSS increased by ${formatBytes(peakRssBytesDelta ?? 0)}`) } return { - baselineGeneratedAt: baseline.generatedAt, - ...(model ? { model } : {}), - policy, successRateDelta, ttftP95MsDelta, totalP95MsDelta, - promptTokensDelta: current.summary.promptTokens - baseline.summary.promptTokens, + promptTokensDelta: current.promptTokens - baseline.promptTokens, cacheHitRateDelta, - costUsdDelta: current.summary.costUsd - baseline.summary.costUsd, + costUsdDelta: current.costUsd - baseline.costUsd, peakRssBytesDelta, regressions } } +type ReplayRunPair = { current: ReplayRunResult; baseline: ReplayRunResult } + function assertReplayReportsComparable( current: ReplayReport, baseline: ReplayReport, policy: ReplayComparisonPolicy -): void { +): ReplayRunPair[] { const mismatches: string[] = [] if (current.suite.name !== baseline.suite.name) mismatches.push('suite name') if (current.suite.taskCount !== baseline.suite.taskCount) mismatches.push('task count') if (current.suite.repeat !== baseline.suite.repeat) mismatches.push('repeat count') + if ((current.suite.concurrency ?? 1) !== (baseline.suite.concurrency ?? 1)) mismatches.push('concurrency') if ((current.suite.tag ?? '') !== (baseline.suite.tag ?? '')) mismatches.push('tag filter') - if (!policy.allowModelChange && (current.runtime.model ?? '') !== (baseline.runtime.model ?? '')) { - mismatches.push('runtime model') + + const currentByKey = replayRunsByIdentity(current.runs) + const baselineByKey = replayRunsByIdentity(baseline.runs) + const currentKeys = [...currentByKey.keys()].sort() + const baselineKeys = [...baselineByKey.keys()].sort() + if (currentByKey.size !== current.runs.length || baselineByKey.size !== baseline.runs.length) { + mismatches.push('duplicate task iterations') + } else if (currentKeys.length !== baselineKeys.length || currentKeys.some((key, index) => key !== baselineKeys[index])) { + mismatches.push('task iterations') + } + + const pairs = currentKeys.flatMap((key): ReplayRunPair[] => { + const currentRun = currentByKey.get(key) + const baselineRun = baselineByKey.get(key) + return currentRun && baselineRun ? [{ current: currentRun, baseline: baselineRun }] : [] + }) + if (!policy.allowModelChange && pairs.some((pair) => + effectiveReplayRunModel(pair.current, current) !== effectiveReplayRunModel(pair.baseline, baseline) + )) { + mismatches.push('runtime model selection') } if (mismatches.length > 0) { throw new Error(`replay baseline is not comparable: ${mismatches.join(', ')} differ`) } + return pairs +} + +function replayRunsByIdentity(runs: ReplayRunResult[]): Map { + return new Map(runs.map((run) => [`${run.taskId}\u0000${run.iteration}`, run])) +} + +function effectiveReplayRunModel(run: ReplayRunResult, report: ReplayReport): string | undefined { + return run.model ?? report.runtime.model +} + +function replayComparisonThresholdsForModel( + policy: ReplayComparisonPolicy, + model: string | undefined +): ReplayComparisonThresholds { + return ReplayComparisonThresholdsSchema.parse({ + ...policy.defaults, + ...(model ? policy.models[model] : {}) + }) +} + +function groupReplayRunPairsByCurrentModel( + pairs: ReplayRunPair[], + currentReport: ReplayReport, + baselineReport: ReplayReport +): Map + currentRuns: ReplayRunResult[] + baselineRuns: ReplayRunResult[] + }> { + const groups = new Map + currentRuns: ReplayRunResult[] + baselineRuns: ReplayRunResult[] + }>() + for (const pair of pairs) { + const model = effectiveReplayRunModel(pair.current, currentReport) + const key = model ?? '' + const group = groups.get(key) ?? { + ...(model ? { model } : {}), + baselineModels: new Set(), + currentRuns: [], + baselineRuns: [] + } + group.currentRuns.push(pair.current) + group.baselineRuns.push(pair.baseline) + group.baselineModels.add(effectiveReplayRunModel(pair.baseline, baselineReport) ?? 'unknown') + groups.set(key, group) + } + return groups } export function parseReplayBudget(input: unknown): ReplayBudget { @@ -1023,12 +1145,19 @@ function jaccard(expected: readonly string[], actual: readonly string[]): number return union === 0 ? 1 : intersection / union } -function errorReplayRun(id: string, task: ReplayTask, iteration: number, error: string): ReplayRunResult { +function errorReplayRun( + id: string, + task: ReplayTask, + iteration: number, + error: string, + model?: string +): ReplayRunResult { return { id, taskId: task.id, iteration, tags: task.tags, + ...(model ? { model } : {}), status: 'error', failureReasons: [error], metrics: emptyReplayMetrics(), @@ -1092,14 +1221,18 @@ function nullableDelta(current: number | null, baseline: number | null): number return current === null || baseline === null ? null : current - baseline } -function isRelativeRegression( +function isIncreaseRegression( current: number | null, baseline: number | null, - ratio: number, - minimumDelta: number + ratio: number | undefined, + minimumDelta: number | undefined ): boolean { - if (current === null || baseline === null || baseline <= 0) return false - return current - baseline >= minimumDelta && current > baseline * (1 + ratio) + if (current === null || baseline === null) return false + const delta = current - baseline + if (delta <= 0) return false + if (minimumDelta !== undefined && delta <= minimumDelta) return false + if (baseline <= 0) return minimumDelta !== undefined + return ratio === undefined || current > baseline * (1 + ratio) } function roundMetric(value: number): number { diff --git a/kun/src/cli/serve.ts b/kun/src/cli/serve.ts index 3d65d27e9..5d2987847 100644 --- a/kun/src/cli/serve.ts +++ b/kun/src/cli/serve.ts @@ -50,34 +50,36 @@ export function parseServeOptions( configServe.tokenEconomy?.enabled ?? configServe.tokenEconomyMode ?? DEFAULT_SERVE_OPTIONS.tokenEconomyMode - const observabilityEnabled = + const explicitObservabilityEnabled = booleanFlag(raw, 'observability') ?? - envBoolean(env.KUN_OBSERVABILITY) ?? - configServe.observability?.enabled + envBoolean(env.KUN_OBSERVABILITY) const observabilityOutputPath = stringFlag(raw, 'observability-output') ?? stringFlag(raw, 'observabilityOutput') ?? env.KUN_OBSERVABILITY_OUTPUT_PATH ?? configServe.observability?.outputPath + const otlpProtocol = env.OTEL_EXPORTER_OTLP_TRACES_PROTOCOL ?? env.OTEL_EXPORTER_OTLP_PROTOCOL + const standardOtlpEnabled = env.OTEL_TRACES_EXPORTER + ?.split(',') + .map((entry) => entry.trim()) + .includes('otlp') && otlpProtocol === 'http/json' + const observabilityEnabled = explicitObservabilityEnabled ?? + (standardOtlpEnabled ? true : configServe.observability?.enabled) const observabilityExporterValue = stringFlag(raw, 'observability-exporter') ?? env.KUN_OBSERVABILITY_EXPORTER ?? + (standardOtlpEnabled ? 'otlp-http-json' : undefined) ?? configServe.observability?.exporter const observabilityExporter = observabilityExporterValue as | 'jsonl' | 'otlp-http-json' | undefined - const otlpProtocol = env.OTEL_EXPORTER_OTLP_TRACES_PROTOCOL ?? env.OTEL_EXPORTER_OTLP_PROTOCOL - const standardOtlpEnabled = env.OTEL_TRACES_EXPORTER - ?.split(',') - .map((entry) => entry.trim()) - .includes('otlp') && otlpProtocol === 'http/json' - const resolvedObservabilityExporter = observabilityExporter ?? (standardOtlpEnabled ? 'otlp-http-json' : undefined) const observabilityEndpoint = env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT ?? - (standardOtlpEnabled + (env.OTEL_EXPORTER_OTLP_ENDPOINT ? resolveOtlpTracesEndpoint({ commonEndpoint: env.OTEL_EXPORTER_OTLP_ENDPOINT }) - : configServe.observability?.endpoint) + : undefined) ?? + configServe.observability?.endpoint const observabilityHeaders = parseOtlpHeaders( env.OTEL_EXPORTER_OTLP_TRACES_HEADERS ?? env.OTEL_EXPORTER_OTLP_HEADERS ) ?? configServe.observability?.headers @@ -178,12 +180,12 @@ export function parseServeOptions( : {}) }, observability: - observabilityEnabled !== undefined || observabilityOutputPath || resolvedObservabilityExporter + observabilityEnabled !== undefined || observabilityOutputPath || observabilityExporter ? { ...(configServe.observability ?? {}), - enabled: observabilityEnabled ?? Boolean(standardOtlpEnabled), + enabled: observabilityEnabled ?? false, ...(observabilityOutputPath ? { outputPath: observabilityOutputPath } : {}), - ...(resolvedObservabilityExporter ? { exporter: resolvedObservabilityExporter } : {}), + ...(observabilityExporter ? { exporter: observabilityExporter } : {}), ...(observabilityEndpoint ? { endpoint: observabilityEndpoint } : {}), ...(observabilityHeaders ? { headers: observabilityHeaders } : {}), ...(observabilityTimeoutMs ? { timeoutMs: observabilityTimeoutMs } : {}) diff --git a/kun/src/server/runtime-factory.ts b/kun/src/server/runtime-factory.ts index 7ecb99b11..1a214c872 100644 --- a/kun/src/server/runtime-factory.ts +++ b/kun/src/server/runtime-factory.ts @@ -1486,7 +1486,11 @@ export async function createKunServeRuntime( shutdownAllLspSessions() await mcpProviders.close() } finally { - await stores.shutdown?.() + try { + await agentObservability?.shutdown() + } finally { + await stores.shutdown?.() + } } } } diff --git a/kun/src/telemetry/agent-observability.test.ts b/kun/src/telemetry/agent-observability.test.ts index bb9439418..a5ae81339 100644 --- a/kun/src/telemetry/agent-observability.test.ts +++ b/kun/src/telemetry/agent-observability.test.ts @@ -1,7 +1,7 @@ import { mkdtemp, rm, stat } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { RuntimeEvent } from '../contracts/events.js' import { AgentObservabilityRecorder, @@ -24,6 +24,15 @@ describe('AgentObservabilityRecorder', () => { await Promise.all(cleanup.splice(0).map((path) => rm(path, { recursive: true, force: true }))) }) + it('shuts down its exporter so queued spans can flush before process exit', async () => { + const shutdown = vi.fn(async () => undefined) + const recorder = new AgentObservabilityRecorder({ emit: () => undefined, shutdown }) + + await recorder.shutdown() + + expect(shutdown).toHaveBeenCalledOnce() + }) + it('writes observability JSONL with private filesystem permissions', async () => { const root = await mkdtemp(join(tmpdir(), 'kun-observability-')) cleanup.push(root) diff --git a/kun/src/telemetry/agent-observability.ts b/kun/src/telemetry/agent-observability.ts index ad4d9a5f6..c507e1add 100644 --- a/kun/src/telemetry/agent-observability.ts +++ b/kun/src/telemetry/agent-observability.ts @@ -33,6 +33,7 @@ export type AgentObservabilitySpan = { export type AgentObservabilitySink = { emit(span: AgentObservabilitySpan): Promise | void + shutdown?(): Promise | void } type PendingSpan = { @@ -135,6 +136,11 @@ export class AgentObservabilityRecorder implements RuntimeEventObserver { } } + /** Flush queued exporters after active turns have been settled. */ + async shutdown(): Promise { + await this.sink.shutdown?.() + } + private startTurn(event: RuntimeEvent): void { if (!event.turnId) return const key = turnKey(event.threadId, event.turnId) diff --git a/kun/src/telemetry/otlp-http-json-sink.test.ts b/kun/src/telemetry/otlp-http-json-sink.test.ts index 808776ecb..1b796c00d 100644 --- a/kun/src/telemetry/otlp-http-json-sink.test.ts +++ b/kun/src/telemetry/otlp-http-json-sink.test.ts @@ -68,6 +68,18 @@ describe('OtlpHttpJsonAgentObservabilitySink', () => { expect(fetch).toHaveBeenCalledOnce() }) + + it('drains every queued batch during shutdown', async () => { + const fetch: typeof globalThis.fetch = vi.fn(async () => new Response(null, { status: 200 })) + const sink = new OtlpHttpJsonAgentObservabilitySink({ batchSize: 1, fetch }) + + sink.emit(span()) + sink.emit({ ...span(), spanId: '3'.repeat(16) }) + sink.emit({ ...span(), spanId: '4'.repeat(16) }) + await sink.shutdown() + + expect(fetch).toHaveBeenCalledTimes(3) + }) }) function span(): AgentObservabilitySpan { diff --git a/kun/src/telemetry/otlp-http-json-sink.ts b/kun/src/telemetry/otlp-http-json-sink.ts index c90eac935..5dc8f3f42 100644 --- a/kun/src/telemetry/otlp-http-json-sink.ts +++ b/kun/src/telemetry/otlp-http-json-sink.ts @@ -39,6 +39,9 @@ export class OtlpHttpJsonAgentObservabilitySink implements AgentObservabilitySin private retryAttempt = 0 private retryTimer: ReturnType | undefined private warnedAboutOverflow = false + private warnedAfterShutdown = false + private closed = false + private shutdownPromise: Promise | undefined constructor(options: OtlpHttpJsonSinkOptions = {}) { this.endpoint = options.endpoint ?? DEFAULT_OTLP_ENDPOINT @@ -50,6 +53,13 @@ export class OtlpHttpJsonAgentObservabilitySink implements AgentObservabilitySin } emit(span: AgentObservabilitySpan): void { + if (this.closed) { + if (!this.warnedAfterShutdown) { + this.warnedAfterShutdown = true + console.warn('[kun] OTLP trace exporter is closed; dropping late spans') + } + return + } if (this.queue.length >= this.maxQueueSize) { this.queue.shift() if (!this.warnedAboutOverflow) { @@ -62,6 +72,7 @@ export class OtlpHttpJsonAgentObservabilitySink implements AgentObservabilitySin } async flush(): Promise { + if (this.closed) return this.shutdownPromise ?? this.inFlight if (this.inFlight) return this.inFlight if (this.retryTimer) { clearTimeout(this.retryTimer) @@ -80,6 +91,11 @@ export class OtlpHttpJsonAgentObservabilitySink implements AgentObservabilitySin console.warn(`[kun] OTLP trace export rejected; dropping batch: ${error.message}`) return } + if (this.closed) { + const message = error instanceof Error ? error.message : String(error) + console.warn(`[kun] OTLP trace export failed during shutdown; dropping batch: ${message}`) + return + } this.requeue(batch) const delayMs = Math.min(1_000 * 2 ** this.retryAttempt, MAX_RETRY_DELAY_MS) this.retryAttempt += 1 @@ -98,8 +114,18 @@ export class OtlpHttpJsonAgentObservabilitySink implements AgentObservabilitySin return this.inFlight } + /** + * Stop accepting spans and drain every queued batch within one exporter + * timeout window. Shutdown never leaves retry timers behind and never makes + * process exit wait through an unbounded retry sequence. + */ + shutdown(): Promise { + this.shutdownPromise ??= this.drainAndClose() + return this.shutdownPromise + } + private scheduleFlush(): void { - if (this.scheduled || this.inFlight || this.retryTimer) return + if (this.closed || this.scheduled || this.inFlight || this.retryTimer) return this.scheduled = true queueMicrotask(() => { this.scheduled = false @@ -112,9 +138,35 @@ export class OtlpHttpJsonAgentObservabilitySink implements AgentObservabilitySin this.queue = [...batch.slice(Math.max(0, batch.length - available)), ...this.queue] } - private async exportBatch(batch: AgentObservabilitySpan[]): Promise { + private async drainAndClose(): Promise { + this.closed = true + const deadline = Date.now() + this.timeoutMs + if (this.retryTimer) { + clearTimeout(this.retryTimer) + this.retryTimer = undefined + } + if (this.inFlight) await this.inFlight + + while (this.queue.length > 0) { + const remainingMs = deadline - Date.now() + if (remainingMs <= 0) { + console.warn(`[kun] OTLP trace shutdown timed out; dropping ${this.queue.length} queued span(s)`) + this.queue = [] + return + } + const batch = this.queue.splice(0, this.batchSize) + try { + await this.exportBatch(batch, remainingMs) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.warn(`[kun] OTLP trace export failed during shutdown; dropping batch: ${message}`) + } + } + } + + private async exportBatch(batch: AgentObservabilitySpan[], timeoutMs = this.timeoutMs): Promise { const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(), this.timeoutMs) + const timeout = setTimeout(() => controller.abort(), Math.max(1, timeoutMs)) timeout.unref?.() try { const response = await this.fetchImpl(this.endpoint, { diff --git a/kun/tests/contracts.test.ts b/kun/tests/contracts.test.ts index 4fbaa8ec8..bc9f3fb53 100644 --- a/kun/tests/contracts.test.ts +++ b/kun/tests/contracts.test.ts @@ -424,6 +424,44 @@ describe('cli', () => { }) }) + it('applies CLI and standard OTLP environment precedence over config', async () => { + const dir = await mkdtemp(join(tmpdir(), 'kun-observability-config-')) + try { + const configPath = join(dir, 'kun.config.json') + await writeFile(configPath, JSON.stringify({ + serve: { + dataDir: join(dir, 'data'), + observability: { + enabled: false, + exporter: 'jsonl', + endpoint: 'https://config.example/v1/traces' + } + } + })) + const env = { + OTEL_TRACES_EXPORTER: 'otlp', + OTEL_EXPORTER_OTLP_PROTOCOL: 'http/json', + OTEL_EXPORTER_OTLP_ENDPOINT: 'https://env.example/otel' + } + + const standard = parseServeOptions(['--config', configPath], env) + expect(standard.observability).toMatchObject({ + enabled: true, + exporter: 'otlp-http-json', + endpoint: 'https://env.example/otel/v1/traces' + }) + + const cli = parseServeOptions([ + '--config', configPath, + '--observability', + '--observability-exporter=jsonl' + ], env) + expect(cli.observability).toMatchObject({ enabled: true, exporter: 'jsonl' }) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + it('loads serve and context compaction settings from an explicit config file', async () => { const dir = await mkdtemp(join(tmpdir(), 'kun-config-')) try { From eab97b832e6e02be2a552d6293e24cfd956c7ea6 Mon Sep 17 00:00:00 2001 From: luoye520ww <100058663+luoye520ww@users.noreply.github.com> Date: Sat, 11 Jul 2026 03:03:55 +0800 Subject: [PATCH 027/110] feat(write): show document word counts --- .../src/components/write/WriteWorkspaceView.tsx | 7 ++++++- .../write/write-workspace-view-utils.test.ts | 10 ++++++++-- .../write/write-workspace-view-utils.ts | 15 +++++++++++++-- src/renderer/src/locales/en/common.json | 1 + src/renderer/src/locales/zh/common.json | 1 + 5 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/components/write/WriteWorkspaceView.tsx b/src/renderer/src/components/write/WriteWorkspaceView.tsx index 8f894ed20..cbfc07bcb 100644 --- a/src/renderer/src/components/write/WriteWorkspaceView.tsx +++ b/src/renderer/src/components/write/WriteWorkspaceView.tsx @@ -242,7 +242,12 @@ export function WriteWorkspaceView({ () => (activeFileIsText ? computeWriteDocumentStats(fileContent, isMarkdown) : null), [activeFileIsText, fileContent, isMarkdown], ) - const documentStatsLabel = documentStats ? t('writeCharacterCount', { count: documentStats.characterCount }) : null + const documentStatsLabel = documentStats + ? t('writeDocumentStats', { + words: documentStats.wordCount, + characters: documentStats.characterCount + }) + : null const workspacePathLabel = rootDirectory || workspaceRoot const workspaceName = workspacePathLabel ? writeBasenameFromPath(workspacePathLabel) : t('writeWorkspace') const exportInFlight = exportingFormat !== null diff --git a/src/renderer/src/components/write/write-workspace-view-utils.test.ts b/src/renderer/src/components/write/write-workspace-view-utils.test.ts index cf2ba7375..33cc7e299 100644 --- a/src/renderer/src/components/write/write-workspace-view-utils.test.ts +++ b/src/renderer/src/components/write/write-workspace-view-utils.test.ts @@ -9,13 +9,19 @@ describe('computeWriteDocumentStats', () => { it('counts visible markdown text instead of syntax markers', () => { const stats = computeWriteDocumentStats('# 标题\n\n- 第一项\n- 第二项 **加粗**\n', true) - expect(stats).toEqual({ characterCount: 10 }) + expect(stats).toEqual({ characterCount: 10, wordCount: 6 }) }) it('counts non-whitespace characters for plain text files', () => { const stats = computeWriteDocumentStats('Hello world\n 2026 ', false) - expect(stats).toEqual({ characterCount: 14 }) + expect(stats).toEqual({ characterCount: 14, wordCount: 3 }) + }) + + it('does not merge words across Markdown node boundaries', () => { + const stats = computeWriteDocumentStats('first paragraph\n\nsecond paragraph', true) + + expect(stats.wordCount).toBe(4) }) }) diff --git a/src/renderer/src/components/write/write-workspace-view-utils.ts b/src/renderer/src/components/write/write-workspace-view-utils.ts index 2c75b324d..400fded79 100644 --- a/src/renderer/src/components/write/write-workspace-view-utils.ts +++ b/src/renderer/src/components/write/write-workspace-view-utils.ts @@ -32,6 +32,7 @@ export type WriteNotice = { export type WriteDocumentStats = { characterCount: number + wordCount: number } export type WriteModeMenuItem = { @@ -87,16 +88,26 @@ function collectVisibleText(node: { type?: string; text?: string; content?: unkn function visibleTextFromMarkdown(markdown: string): string { try { - return collectVisibleText(parseWriteMarkdown(markdown), []).join('') + // Preserve block boundaries so adjacent Markdown nodes cannot become one + // word when document statistics are calculated. + return collectVisibleText(parseWriteMarkdown(markdown), []).join(' ') } catch { return markdown } } +function countWords(text: string): number { + if (typeof Intl.Segmenter === 'function') { + const segmenter = new Intl.Segmenter(undefined, { granularity: 'word' }) + return [...segmenter.segment(text)].filter((segment) => segment.isWordLike).length + } + return text.match(/[\p{L}\p{N}]+(?:['’][\p{L}\p{N}]+)*/gu)?.length ?? 0 +} + export function computeWriteDocumentStats(content: string, isMarkdown: boolean): WriteDocumentStats { const visibleText = isMarkdown ? visibleTextFromMarkdown(content) : content const characterCount = Array.from(visibleText.replace(/\s+/g, '')).length - return { characterCount } + return { characterCount, wordCount: countWords(visibleText) } } export function clamp(value: number, min: number, max: number): number { diff --git a/src/renderer/src/locales/en/common.json b/src/renderer/src/locales/en/common.json index 82764bd71..219c6aef2 100644 --- a/src/renderer/src/locales/en/common.json +++ b/src/renderer/src/locales/en/common.json @@ -2257,6 +2257,7 @@ "writeModeSplit": "Split preview", "writeModePreview": "Preview only", "writeCharacterCount": "{{count}} chars", + "writeDocumentStats": "{{words}} words · {{characters}} chars", "writePreviewErrorFallback": "Markdown preview failed, showing source text instead.", "writeUnsupportedFileType": "Write currently opens only Markdown, TXT, PDF, and common image files.", "writeImagePreview": "Image preview", diff --git a/src/renderer/src/locales/zh/common.json b/src/renderer/src/locales/zh/common.json index bc1d6d298..ef6f5f383 100644 --- a/src/renderer/src/locales/zh/common.json +++ b/src/renderer/src/locales/zh/common.json @@ -2257,6 +2257,7 @@ "writeModeSplit": "分栏预览", "writeModePreview": "仅预览", "writeCharacterCount": "字数 {{count}}", + "writeDocumentStats": "{{words}} 词 · {{characters}} 字", "writePreviewErrorFallback": "Markdown 预览渲染失败,已临时显示源码文本。", "writeUnsupportedFileType": "Write 模式目前只打开 Markdown、TXT、PDF 和常见图片文件。", "writeImagePreview": "图片预览", From 465fa032c5b1fa178b48d4aaa00de377548287f6 Mon Sep 17 00:00:00 2001 From: luoye520ww <100058663+luoye520ww@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:16:30 +0800 Subject: [PATCH 028/110] feat(write): reveal files in system manager --- .../src/components/write/WriteFileTree.tsx | 12 +++++- .../src/components/write/WriteSidebar.tsx | 15 +++++++ .../src/lib/open-workspace-path.test.ts | 40 ++++++++++++++++++- src/renderer/src/lib/open-workspace-path.ts | 39 ++++++++++++------ 4 files changed, 92 insertions(+), 14 deletions(-) diff --git a/src/renderer/src/components/write/WriteFileTree.tsx b/src/renderer/src/components/write/WriteFileTree.tsx index 2975fcb07..09c05e349 100644 --- a/src/renderer/src/components/write/WriteFileTree.tsx +++ b/src/renderer/src/components/write/WriteFileTree.tsx @@ -1,5 +1,5 @@ import type { ReactElement, ReactNode } from 'react' -import { ChevronDown, ChevronRight, FileText, FilePlus2, Folder, FolderPlus, Image, Pencil, RefreshCw, Trash2 } from 'lucide-react' +import { ChevronDown, ChevronRight, FileText, FilePlus2, Folder, FolderPlus, FolderSearch, Image, Pencil, RefreshCw, Trash2 } from 'lucide-react' import { useTranslation } from 'react-i18next' import type { WorkspaceEntry } from '@shared/workspace-file' import { isWriteImageFileExtension, isWriteWorkspaceEntry } from '@shared/write-text-file' @@ -23,6 +23,7 @@ type Props = { onCreateDirectory: (directoryPath?: string) => void onRenameEntry: (entry: WorkspaceEntry) => void onDeleteEntry: (entry: WorkspaceEntry) => void + onRevealEntry: (entry: WorkspaceEntry) => void onRefresh: () => void showHeader?: boolean showRootLabel?: boolean @@ -100,6 +101,7 @@ export function WriteFileTree({ onCreateDirectory, onRenameEntry, onDeleteEntry, + onRevealEntry, onRefresh, showHeader = true, showRootLabel = true @@ -143,6 +145,14 @@ export function WriteFileTree({ ) : null} + onRevealEntry(entry)} + > + + onRenameEntry(entry)} diff --git a/src/renderer/src/components/write/WriteSidebar.tsx b/src/renderer/src/components/write/WriteSidebar.tsx index 6caa4e5fc..40639cdeb 100644 --- a/src/renderer/src/components/write/WriteSidebar.tsx +++ b/src/renderer/src/components/write/WriteSidebar.tsx @@ -8,6 +8,7 @@ import { Folder, FolderOpen, FolderPlus, + FolderSearch, Plus, RefreshCw, Settings, @@ -18,6 +19,7 @@ import { useTranslation } from 'react-i18next' import type { WorkspaceEntry } from '@shared/workspace-file' import { confirmDialog } from '../../lib/confirm-dialog' import { formatWorkspacePickerError } from '../../lib/format-workspace-picker-error' +import { revealWorkspacePathInFileManager } from '../../lib/open-workspace-path' import { useChatStore, type SettingsRouteSection } from '../../store/chat-store' import { useWriteWorkspaceStore, @@ -363,6 +365,18 @@ export function WriteSidebar({ actions={ active || removable ? ( <> + void revealWorkspacePathInFileManager(workspacePath, workspacePath)} + title={window.kunGui?.platform === 'darwin' + ? t('fileTreeRevealInFinder') + : t('fileTreeRevealInFileManager')} + ariaLabel={window.kunGui?.platform === 'darwin' + ? t('fileTreeRevealInFinder') + : t('fileTreeRevealInFileManager')} + stopPropagation + > + + {active ? ( <> void openCreateDirectoryDialog(directoryPath)} onRenameEntry={openRenameEntryDialog} onDeleteEntry={openDeleteEntryDialog} + onRevealEntry={(entry) => void revealWorkspacePathInFileManager(entry.path, workspaceRoot)} onRefresh={() => void refreshWorkspace(workspaceRoot)} showHeader={false} showRootLabel={false} diff --git a/src/renderer/src/lib/open-workspace-path.test.ts b/src/renderer/src/lib/open-workspace-path.test.ts index 948d5ad46..4e06e9b0c 100644 --- a/src/renderer/src/lib/open-workspace-path.test.ts +++ b/src/renderer/src/lib/open-workspace-path.test.ts @@ -1,5 +1,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { openWorkspacePathInEditor } from './open-workspace-path' +import { + openWorkspacePathInEditor, + revealWorkspacePathInFileManager +} from './open-workspace-path' afterEach(() => { vi.unstubAllGlobals() @@ -27,3 +30,38 @@ describe('openWorkspacePathInEditor', () => { }) }) }) + +describe('revealWorkspacePathInFileManager', () => { + it('opens the requested path with the platform file manager', async () => { + const openEditorPath = vi.fn(async () => ({ + ok: true as const, + path: '/tmp/workspace/notes.md', + editorId: 'file-manager' + })) + vi.stubGlobal('window', { kunGui: { openEditorPath } }) + + await expect( + revealWorkspacePathInFileManager('/tmp/workspace/notes.md', '/tmp/workspace') + ).resolves.toMatchObject({ ok: true }) + expect(openEditorPath).toHaveBeenCalledWith({ + path: '/tmp/workspace/notes.md', + workspaceRoot: '/tmp/workspace', + editorId: 'file-manager' + }) + }) + + it('returns a failed result when the bridge rejects the request', async () => { + vi.stubGlobal('window', { + kunGui: { + openEditorPath: vi.fn(async () => { + throw new Error('reveal failed') + }) + } + }) + + await expect(revealWorkspacePathInFileManager('/tmp/workspace')).resolves.toEqual({ + ok: false, + message: 'reveal failed' + }) + }) +}) diff --git a/src/renderer/src/lib/open-workspace-path.ts b/src/renderer/src/lib/open-workspace-path.ts index 02394647f..ea7f63cc4 100644 --- a/src/renderer/src/lib/open-workspace-path.ts +++ b/src/renderer/src/lib/open-workspace-path.ts @@ -1,4 +1,4 @@ -import type { EditorOpenResult } from '@shared/editor' +import type { EditorOpenResult, OpenEditorPathOptions } from '@shared/editor' import { readPreferredEditorId } from './editor-preferences' export type WorkspacePathTarget = { @@ -7,25 +7,40 @@ export type WorkspacePathTarget = { column?: number } -export async function openWorkspacePathInEditor( - target: WorkspacePathTarget, - workspaceRoot?: string -): Promise { +async function invokeOpenEditorPath(options: OpenEditorPathOptions): Promise { if (typeof window === 'undefined' || typeof window.kunGui?.openEditorPath !== 'function') { return { ok: false, message: 'Editor bridge is unavailable.' } } try { - return await window.kunGui.openEditorPath({ - path: target.path, - line: target.line, - column: target.column, - workspaceRoot, - editorId: readPreferredEditorId() - }) + return await window.kunGui.openEditorPath(options) } catch (error) { return { ok: false, message: error instanceof Error ? error.message : String(error) } } } +export async function openWorkspacePathInEditor( + target: WorkspacePathTarget, + workspaceRoot?: string +): Promise { + return invokeOpenEditorPath({ + path: target.path, + line: target.line, + column: target.column, + workspaceRoot, + editorId: readPreferredEditorId() + }) +} + export const openWorkspacePath = openWorkspacePathInEditor + +export async function revealWorkspacePathInFileManager( + targetPath: string, + workspaceRoot?: string +): Promise { + return invokeOpenEditorPath({ + path: targetPath, + workspaceRoot, + editorId: 'file-manager' + }) +} From 5d5eb80010ebc283c2942805a82e255cf248cf7c Mon Sep 17 00:00:00 2001 From: luoye520ww <100058663+luoye520ww@users.noreply.github.com> Date: Sat, 11 Jul 2026 08:39:17 +0800 Subject: [PATCH 029/110] feat(write): add distraction-free focus mode --- .../write/WriteWorkspaceDocumentPane.tsx | 38 ++++++++++++++++++- src/renderer/src/locales/en/common.json | 3 ++ src/renderer/src/locales/zh/common.json | 3 ++ .../src/write/write-focus-mode.test.ts | 27 +++++++++++++ src/renderer/src/write/write-focus-mode.ts | 10 +++++ 5 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 src/renderer/src/write/write-focus-mode.test.ts create mode 100644 src/renderer/src/write/write-focus-mode.ts diff --git a/src/renderer/src/components/write/WriteWorkspaceDocumentPane.tsx b/src/renderer/src/components/write/WriteWorkspaceDocumentPane.tsx index 6990b91ba..5ee13ef4b 100644 --- a/src/renderer/src/components/write/WriteWorkspaceDocumentPane.tsx +++ b/src/renderer/src/components/write/WriteWorkspaceDocumentPane.tsx @@ -1,4 +1,5 @@ -import { type MutableRefObject, type ReactElement, type RefObject } from 'react' +import { useEffect, useState, type MutableRefObject, type ReactElement, type RefObject } from 'react' +import { Maximize2, Minimize2 } from 'lucide-react' import { useTranslation } from 'react-i18next' import type { WriteInlineCompletionSettingsV1 } from '@shared/app-settings' import type { WriteRenderSafety } from '../../write/write-render-safety' @@ -13,6 +14,7 @@ import { WriteMarkdownPreview } from './WriteMarkdownPreview' import { WriteWorkspaceStart } from './WriteWorkspaceStart' import { WriteImagePreview } from './WriteImagePreview' import { WritePdfViewer } from './WritePdfViewer' +import { isWriteFocusModeShortcut } from '../../write/write-focus-mode' type Props = { activeFilePath: string | null @@ -110,6 +112,26 @@ export function WriteWorkspaceDocumentPane({ onMarkdownReviewStateChange }: Props): ReactElement { const { t } = useTranslation('common') + const [focusMode, setFocusMode] = useState(false) + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent): void => { + if (activeFileIsText && isWriteFocusModeShortcut(event)) { + event.preventDefault() + setFocusMode((active) => !active) + return + } + if (focusMode && event.key === 'Escape' && !event.defaultPrevented) { + setFocusMode(false) + } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [activeFileIsText, focusMode]) + + useEffect(() => { + if (!activeFileIsText && focusMode) setFocusMode(false) + }, [activeFileIsText, focusMode]) if (!activeFilePath) { return ( @@ -167,7 +189,19 @@ export function WriteWorkspaceDocumentPane({ } return ( -
+
+ {renderSafety.notice !== 'none' ? (
{fileGuardMessage}
diff --git a/src/renderer/src/locales/en/common.json b/src/renderer/src/locales/en/common.json index 219c6aef2..058928e08 100644 --- a/src/renderer/src/locales/en/common.json +++ b/src/renderer/src/locales/en/common.json @@ -2817,6 +2817,9 @@ "description": "Description", "color": "Color", "mode": "Mode", + "writeFocusModeEnter": "Enter focus mode", + "writeFocusModeExit": "Exit focus mode", + "writeFocusModeShortcut": "⌘/Ctrl + Shift + F", "systemPrompt": "System prompt", "toolPolicy": "Tool access" } diff --git a/src/renderer/src/locales/zh/common.json b/src/renderer/src/locales/zh/common.json index ef6f5f383..08316edce 100644 --- a/src/renderer/src/locales/zh/common.json +++ b/src/renderer/src/locales/zh/common.json @@ -2817,6 +2817,9 @@ "description": "描述", "color": "颜色", "mode": "模式", + "writeFocusModeEnter": "进入专注模式", + "writeFocusModeExit": "退出专注模式", + "writeFocusModeShortcut": "⌘/Ctrl + Shift + F", "systemPrompt": "系统提示词", "toolPolicy": "工具权限" } diff --git a/src/renderer/src/write/write-focus-mode.test.ts b/src/renderer/src/write/write-focus-mode.test.ts new file mode 100644 index 000000000..0c2ed8d4c --- /dev/null +++ b/src/renderer/src/write/write-focus-mode.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { isWriteFocusModeShortcut } from './write-focus-mode' + +describe('isWriteFocusModeShortcut', () => { + const event = (overrides: Partial = {}) => ({ + code: 'KeyF', + ctrlKey: true, + metaKey: false, + shiftKey: true, + altKey: false, + repeat: false, + isComposing: false, + ...overrides + }) as KeyboardEvent + + it('accepts Ctrl/Command + Shift + F once', () => { + expect(isWriteFocusModeShortcut(event())).toBe(true) + expect(isWriteFocusModeShortcut(event({ ctrlKey: false, metaKey: true }))).toBe(true) + }) + + it('rejects incomplete, repeated, composing, and Alt-modified shortcuts', () => { + expect(isWriteFocusModeShortcut(event({ shiftKey: false }))).toBe(false) + expect(isWriteFocusModeShortcut(event({ repeat: true }))).toBe(false) + expect(isWriteFocusModeShortcut(event({ isComposing: true }))).toBe(false) + expect(isWriteFocusModeShortcut(event({ altKey: true }))).toBe(false) + }) +}) diff --git a/src/renderer/src/write/write-focus-mode.ts b/src/renderer/src/write/write-focus-mode.ts new file mode 100644 index 000000000..e0852d9ea --- /dev/null +++ b/src/renderer/src/write/write-focus-mode.ts @@ -0,0 +1,10 @@ +export function isWriteFocusModeShortcut( + event: Pick +): boolean { + return event.code === 'KeyF' && + event.shiftKey && + (event.ctrlKey || event.metaKey) && + !event.altKey && + !event.repeat && + !event.isComposing +} From ba7fbc6f3b7523f1c3e088fd63ebbf1fd5a13092 Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Sun, 12 Jul 2026 23:39:09 +0800 Subject: [PATCH 030/110] fix(write): harden document workspace controls --- .../components/write/WriteFileTree.test.ts | 69 +++++++ .../src/components/write/WriteSidebar.tsx | 115 ++++++------ .../write/WriteWorkspaceDocumentPane.test.ts | 170 ++++++++++++++++++ .../write/WriteWorkspaceDocumentPane.tsx | 37 ++-- .../components/write/WriteWorkspaceView.tsx | 8 +- .../write/write-workspace-view-utils.test.ts | 6 + .../write/write-workspace-view-utils.ts | 45 ++++- src/renderer/src/styles/surfaces-write.css | 15 ++ .../src/write/write-focus-mode.test.ts | 22 ++- src/renderer/src/write/write-focus-mode.ts | 18 +- 10 files changed, 423 insertions(+), 82 deletions(-) create mode 100644 src/renderer/src/components/write/WriteFileTree.test.ts create mode 100644 src/renderer/src/components/write/WriteWorkspaceDocumentPane.test.ts diff --git a/src/renderer/src/components/write/WriteFileTree.test.ts b/src/renderer/src/components/write/WriteFileTree.test.ts new file mode 100644 index 000000000..3ed7e1b96 --- /dev/null +++ b/src/renderer/src/components/write/WriteFileTree.test.ts @@ -0,0 +1,69 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { WorkspaceEntry } from '@shared/workspace-file' +import { WriteFileTree } from './WriteFileTree' + +vi.mock('react-i18next', () => { + const labels: Record = { + fileTreeRevealInFinder: 'Reveal in Finder', + fileTreeRevealInFileManager: 'Reveal in file manager', + writeRenameEntry: 'Rename', + writeDeleteFile: 'Delete file' + } + const t = (key: string) => labels[key] ?? key + return { useTranslation: () => ({ t }) } +}) + +const entry: WorkspaceEntry = { + name: 'draft.md', + path: '/repo/draft.md', + type: 'file', + ext: '.md' +} + +describe('WriteFileTree reveal action', () => { + let renderer: ReactTestRenderer + const onRevealEntry = vi.fn() + + beforeEach(async () => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + vi.stubGlobal('window', { kunGui: { platform: 'linux' } }) + onRevealEntry.mockClear() + await act(async () => { + renderer = create(createElement(WriteFileTree, { + rootDirectory: '/repo', + entriesByDir: { '/repo': [entry] }, + expandedDirs: new Set(), + loadingDirs: {}, + selectedFilePath: null, + error: null, + onToggleDir: () => undefined, + onSelectFile: () => undefined, + onCreateFile: () => undefined, + onCreateDirectory: () => undefined, + onRenameEntry: () => undefined, + onDeleteEntry: () => undefined, + onRevealEntry, + onRefresh: () => undefined, + showHeader: false, + showRootLabel: false + })) + }) + }) + + afterEach(async () => { + await act(async () => renderer.unmount()) + vi.unstubAllGlobals() + }) + + it('exposes an accessible action and reveals the selected entry', async () => { + const revealButton = renderer.root.findByProps({ 'aria-label': 'Reveal in file manager' }) + const stopPropagation = vi.fn() + + await act(async () => revealButton.props.onClick({ stopPropagation })) + + expect(stopPropagation).toHaveBeenCalled() + expect(onRevealEntry).toHaveBeenCalledWith(entry) + }) +}) diff --git a/src/renderer/src/components/write/WriteSidebar.tsx b/src/renderer/src/components/write/WriteSidebar.tsx index 40639cdeb..78cb14046 100644 --- a/src/renderer/src/components/write/WriteSidebar.tsx +++ b/src/renderer/src/components/write/WriteSidebar.tsx @@ -137,6 +137,11 @@ export function WriteSidebar({ || (workspaceRoot.trim() && !entriesByDir[root]) ) + const revealWritePath = async (targetPath: string, boundaryRoot: string): Promise => { + const result = await revealWorkspacePathInFileManager(targetPath, boundaryRoot) + if (!result.ok) setFileError(result.message) + } + const defaultParentDirectory = (): string => { if (!root) return workspaceRoot if (activeFilePath && activeFilePath.startsWith(root)) return writeDirnameFromPath(activeFilePath) @@ -362,65 +367,63 @@ export function WriteSidebar({ onClick={() => void toggleWorkspaceGroup(workspacePath)} className="min-h-[36px]" buttonClassName="items-center gap-2 px-2.5 py-2" - actions={ - active || removable ? ( - <> - void revealWorkspacePathInFileManager(workspacePath, workspacePath)} - title={window.kunGui?.platform === 'darwin' - ? t('fileTreeRevealInFinder') - : t('fileTreeRevealInFileManager')} - ariaLabel={window.kunGui?.platform === 'darwin' - ? t('fileTreeRevealInFinder') - : t('fileTreeRevealInFileManager')} - stopPropagation - > - - - {active ? ( - <> - void openCreateFileDialog(root)} - title={t('writeCreateFile')} - ariaLabel={t('writeCreateFile')} - tone="accent" - stopPropagation - > - - - void openCreateDirectoryDialog(root)} - title={t('writeCreateFolder')} - ariaLabel={t('writeCreateFolder')} - stopPropagation - > - - - void refreshWorkspace(workspaceRoot)} - title={t('writeRefreshWorkspace')} - ariaLabel={t('writeRefreshWorkspace')} - stopPropagation - > - - - - ) : null} - - {removable ? ( + actions={( + <> + void revealWritePath(workspacePath, workspacePath)} + title={window.kunGui?.platform === 'darwin' + ? t('fileTreeRevealInFinder') + : t('fileTreeRevealInFileManager')} + ariaLabel={window.kunGui?.platform === 'darwin' + ? t('fileTreeRevealInFinder') + : t('fileTreeRevealInFileManager')} + stopPropagation + > + + + {active ? ( + <> + void openCreateFileDialog(root)} + title={t('writeCreateFile')} + ariaLabel={t('writeCreateFile')} + tone="accent" + stopPropagation + > + + void removeWorkspaceFromList(workspacePath)} - title={t('writeRemoveWorkspace')} - ariaLabel={t('writeRemoveWorkspace')} - tone="danger" + onClick={() => void openCreateDirectoryDialog(root)} + title={t('writeCreateFolder')} + ariaLabel={t('writeCreateFolder')} stopPropagation > - + - ) : null} - - ) : undefined - } + void refreshWorkspace(workspaceRoot)} + title={t('writeRefreshWorkspace')} + ariaLabel={t('writeRefreshWorkspace')} + stopPropagation + > + + + + ) : null} + + {removable ? ( + void removeWorkspaceFromList(workspacePath)} + title={t('writeRemoveWorkspace')} + ariaLabel={t('writeRemoveWorkspace')} + tone="danger" + stopPropagation + > + + + ) : null} + + )} > {collapsed ? ( @@ -456,7 +459,7 @@ export function WriteSidebar({ onCreateDirectory={(directoryPath) => void openCreateDirectoryDialog(directoryPath)} onRenameEntry={openRenameEntryDialog} onDeleteEntry={openDeleteEntryDialog} - onRevealEntry={(entry) => void revealWorkspacePathInFileManager(entry.path, workspaceRoot)} + onRevealEntry={(entry) => void revealWritePath(entry.path, workspaceRoot)} onRefresh={() => void refreshWorkspace(workspaceRoot)} showHeader={false} showRootLabel={false} diff --git a/src/renderer/src/components/write/WriteWorkspaceDocumentPane.test.ts b/src/renderer/src/components/write/WriteWorkspaceDocumentPane.test.ts new file mode 100644 index 000000000..9354f1593 --- /dev/null +++ b/src/renderer/src/components/write/WriteWorkspaceDocumentPane.test.ts @@ -0,0 +1,170 @@ +import { createElement, createRef } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { WriteWorkspaceDocumentPane } from './WriteWorkspaceDocumentPane' + +vi.mock('react-i18next', () => { + const t = (key: string) => key + return { useTranslation: () => ({ t }) } +}) +vi.mock('../../write/tiptap/WriteRichEditor', () => ({ WriteRichEditor: () => null })) +vi.mock('./WriteMarkdownEditor', () => ({ WriteMarkdownEditor: () => null })) +vi.mock('./WriteMarkdownPreview', () => ({ WriteMarkdownPreview: () => null })) +vi.mock('./WriteWorkspaceStart', () => ({ WriteWorkspaceStart: () => null })) +vi.mock('./WriteImagePreview', () => ({ WriteImagePreview: () => null })) +vi.mock('./WritePdfViewer', () => ({ WritePdfViewer: () => null })) + +const noop = (): void => undefined + +function paneProps(focusMode: boolean, onFocusModeChange: (active: boolean) => void) { + return { + activeFilePath: '/repo/draft.md', + documentEpoch: 1, + activeFileIsImage: false, + activeFileIsPdf: false, + activeFileIsText: true, + fileLoading: false, + fileContent: 'Draft', + imageDataUrl: '', + imageMimeType: '', + pdfDataBase64: '', + pdfMimeType: '', + pdfMtimeMs: 0, + fileSize: 5, + workspaceRoot: '/repo', + workspaceName: 'repo', + workspacePathLabel: '/repo', + renderSafety: { + livePreviewEnabled: true, + markdownPreviewEnabled: true, + readOnly: false, + notice: 'none' as const + }, + fileGuardMessage: '', + fileGuardDetail: '', + editorVisible: true, + previewVisible: false, + editorWidth: 'w-full', + previewWidth: 'w-0', + editorAppearance: 'source' as const, + richModeActive: false, + richHandleRef: { current: null }, + debouncedPreviewContent: 'Draft', + isMarkdown: true, + inlineCompletion: { + enabled: false, + retrievalEnabled: false, + longCompletionEnabled: false, + inheritProvider: true, + providerId: '', + apiKey: '', + baseUrl: '', + inheritModel: true, + model: '', + debounceMs: 100, + longDebounceMs: 200, + minAcceptScore: 0, + longMinAcceptScore: 0, + maxTokens: 32, + longMaxTokens: 64 + }, + inlineCompletionApiReady: false, + recentEdits: [], + editorPaneRef: createRef(), + previewPaneRef: createRef(), + onAskAssistant: noop, + onCreateDraft: noop, + onPickWorkspace: noop, + onRefreshWorkspace: noop, + onContentChange: noop, + onDocumentEdit: noop, + onSelectionChange: noop, + onSaveShortcut: noop, + onImagePasteSaved: noop, + onImagePasteError: noop, + focusMode, + onFocusModeChange + } +} + +describe('WriteWorkspaceDocumentPane focus mode', () => { + let renderer: ReactTestRenderer + let keydown: ((event: KeyboardEvent) => void) | undefined + const onFocusModeChange = vi.fn() + + beforeEach(async () => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + keydown = undefined + onFocusModeChange.mockClear() + vi.stubGlobal('window', { + addEventListener: vi.fn((type: string, listener: (event: KeyboardEvent) => void) => { + if (type === 'keydown') keydown = listener + }), + removeEventListener: vi.fn() + }) + await act(async () => { + renderer = create(createElement( + WriteWorkspaceDocumentPane, + paneProps(false, onFocusModeChange) + )) + }) + }) + + afterEach(async () => { + await act(async () => renderer.unmount()) + vi.unstubAllGlobals() + }) + + it('toggles from the accessible button and the non-repeating keyboard shortcut', async () => { + const button = renderer.root.findByProps({ 'aria-label': 'writeFocusModeEnter' }) + expect(button.props['aria-keyshortcuts']).toBe('Meta+Shift+F Control+Shift+F') + await act(async () => button.props.onClick()) + expect(onFocusModeChange).toHaveBeenCalledWith(true) + + const preventDefault = vi.fn() + await act(async () => keydown?.({ + code: 'KeyF', + key: 'F', + ctrlKey: true, + metaKey: false, + shiftKey: true, + altKey: false, + repeat: false, + isComposing: false, + defaultPrevented: false, + target: { tagName: 'DIV' }, + preventDefault + } as unknown as KeyboardEvent)) + expect(preventDefault).toHaveBeenCalled() + expect(onFocusModeChange).toHaveBeenLastCalledWith(true) + }) + + it('does not steal the shortcut from a form control and exits with Escape', async () => { + await act(async () => keydown?.({ + code: 'KeyF', + key: 'F', + ctrlKey: true, + metaKey: false, + shiftKey: true, + altKey: false, + repeat: false, + isComposing: false, + defaultPrevented: false, + target: { tagName: 'INPUT' }, + preventDefault: vi.fn() + } as unknown as KeyboardEvent)) + expect(onFocusModeChange).not.toHaveBeenCalled() + + await act(async () => { + renderer.update(createElement( + WriteWorkspaceDocumentPane, + paneProps(true, onFocusModeChange) + )) + }) + await act(async () => keydown?.({ + key: 'Escape', + defaultPrevented: false + } as KeyboardEvent)) + expect(onFocusModeChange).toHaveBeenCalledWith(false) + }) +}) diff --git a/src/renderer/src/components/write/WriteWorkspaceDocumentPane.tsx b/src/renderer/src/components/write/WriteWorkspaceDocumentPane.tsx index 5ee13ef4b..c8a573289 100644 --- a/src/renderer/src/components/write/WriteWorkspaceDocumentPane.tsx +++ b/src/renderer/src/components/write/WriteWorkspaceDocumentPane.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, type MutableRefObject, type ReactElement, type RefObject } from 'react' +import { useEffect, type MutableRefObject, type ReactElement, type RefObject } from 'react' import { Maximize2, Minimize2 } from 'lucide-react' import { useTranslation } from 'react-i18next' import type { WriteInlineCompletionSettingsV1 } from '@shared/app-settings' @@ -14,7 +14,10 @@ import { WriteMarkdownPreview } from './WriteMarkdownPreview' import { WriteWorkspaceStart } from './WriteWorkspaceStart' import { WriteImagePreview } from './WriteImagePreview' import { WritePdfViewer } from './WritePdfViewer' -import { isWriteFocusModeShortcut } from '../../write/write-focus-mode' +import { + isWriteFocusModeFormControl, + isWriteFocusModeShortcut +} from '../../write/write-focus-mode' type Props = { activeFilePath: string | null @@ -62,6 +65,8 @@ type Props = { onImagePasteSaved: () => void onImagePasteError: (message: string) => void onMarkdownReviewStateChange?: (active: boolean) => void + focusMode: boolean + onFocusModeChange: (active: boolean) => void } export function WriteWorkspaceDocumentPane({ @@ -109,29 +114,34 @@ export function WriteWorkspaceDocumentPane({ onSaveShortcut, onImagePasteSaved, onImagePasteError, - onMarkdownReviewStateChange + onMarkdownReviewStateChange, + focusMode, + onFocusModeChange }: Props): ReactElement { const { t } = useTranslation('common') - const [focusMode, setFocusMode] = useState(false) useEffect(() => { const handleKeyDown = (event: KeyboardEvent): void => { - if (activeFileIsText && isWriteFocusModeShortcut(event)) { + if ( + activeFileIsText && + !isWriteFocusModeFormControl(event.target) && + isWriteFocusModeShortcut(event) + ) { event.preventDefault() - setFocusMode((active) => !active) + onFocusModeChange(!focusMode) return } if (focusMode && event.key === 'Escape' && !event.defaultPrevented) { - setFocusMode(false) + onFocusModeChange(false) } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) - }, [activeFileIsText, focusMode]) + }, [activeFileIsText, focusMode, onFocusModeChange]) useEffect(() => { - if (!activeFileIsText && focusMode) setFocusMode(false) - }, [activeFileIsText, focusMode]) + if (!activeFileIsText && focusMode) onFocusModeChange(false) + }, [activeFileIsText, focusMode, onFocusModeChange]) if (!activeFilePath) { return ( @@ -189,14 +199,15 @@ export function WriteWorkspaceDocumentPane({ } return ( -
+
+ +
+
+ ) } diff --git a/src/renderer/src/components/write/WriteWorkspaceDocumentPane.tsx b/src/renderer/src/components/write/WriteWorkspaceDocumentPane.tsx index c8a573289..839d4a71f 100644 --- a/src/renderer/src/components/write/WriteWorkspaceDocumentPane.tsx +++ b/src/renderer/src/components/write/WriteWorkspaceDocumentPane.tsx @@ -67,6 +67,8 @@ type Props = { onMarkdownReviewStateChange?: (active: boolean) => void focusMode: boolean onFocusModeChange: (active: boolean) => void + onboarding?: boolean + workspaceLoading?: boolean } export function WriteWorkspaceDocumentPane({ @@ -116,7 +118,9 @@ export function WriteWorkspaceDocumentPane({ onImagePasteError, onMarkdownReviewStateChange, focusMode, - onFocusModeChange + onFocusModeChange, + onboarding = false, + workspaceLoading = false }: Props): ReactElement { const { t } = useTranslation('common') @@ -144,6 +148,13 @@ export function WriteWorkspaceDocumentPane({ }, [activeFileIsText, focusMode, onFocusModeChange]) if (!activeFilePath) { + if (workspaceLoading) { + return ( +
+ {t('writeWorkspaceLoading')} +
+ ) + } return ( ) } diff --git a/src/renderer/src/components/write/WriteWorkspaceStart.test.ts b/src/renderer/src/components/write/WriteWorkspaceStart.test.ts new file mode 100644 index 000000000..69ed2c49f --- /dev/null +++ b/src/renderer/src/components/write/WriteWorkspaceStart.test.ts @@ -0,0 +1,36 @@ +import { createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import '../../i18n' +import { WriteWorkspaceStart } from './WriteWorkspaceStart' + +const baseProps = { + onAskAssistant: () => undefined, + onCreateDraft: () => undefined, + onPickWorkspace: () => undefined, + onRefreshWorkspace: () => undefined, + workspaceName: 'write_workspace', + workspacePathLabel: '/home/user/.kun/write_workspace' +} + +describe('WriteWorkspaceStart', () => { + it('explains writing-space setup only after onboarding is confirmed', () => { + const html = renderToStaticMarkup(createElement(WriteWorkspaceStart, { + ...baseProps, + onboarding: true + })) + + expect(html).toContain('Create your first writing space') + expect(html).toContain('Create writing space') + expect(html).toContain('Use Kun default space') + expect(html).toContain('separately from code projects') + }) + + it('keeps the regular empty-workspace actions after onboarding', () => { + const html = renderToStaticMarkup(createElement(WriteWorkspaceStart, baseProps)) + + expect(html).toContain('New draft') + expect(html).toContain('Ask AI for an outline') + expect(html).not.toContain('Use Kun default space') + }) +}) diff --git a/src/renderer/src/components/write/WriteWorkspaceStart.tsx b/src/renderer/src/components/write/WriteWorkspaceStart.tsx index 99dbd99e0..a0f19c931 100644 --- a/src/renderer/src/components/write/WriteWorkspaceStart.tsx +++ b/src/renderer/src/components/write/WriteWorkspaceStart.tsx @@ -1,5 +1,5 @@ import type { ReactElement } from 'react' -import { FilePenLine, FilePlus2, FolderOpen, ListTodo, RefreshCw, Sparkles } from 'lucide-react' +import { FilePenLine, FilePlus2, FolderOpen, FolderPlus, ListTodo, RefreshCw, Sparkles } from 'lucide-react' import { useTranslation } from 'react-i18next' export function WriteWorkspaceStart({ @@ -8,7 +8,8 @@ export function WriteWorkspaceStart({ onPickWorkspace, onRefreshWorkspace, workspaceName, - workspacePathLabel + workspacePathLabel, + onboarding = false }: { onAskAssistant: () => void onCreateDraft: () => void @@ -16,6 +17,7 @@ export function WriteWorkspaceStart({ onRefreshWorkspace: () => void workspaceName: string workspacePathLabel: string + onboarding?: boolean }): ReactElement { const { t } = useTranslation('common') return ( @@ -27,31 +29,45 @@ export function WriteWorkspaceStart({ {t('writeStudio')}

- {t('writeStartTitle')} + {t(onboarding ? 'writeOnboardingTitle' : 'writeStartTitle')}

- {t('writeStartSub')} + {t(onboarding ? 'writeOnboardingSub' : 'writeStartSub')}

+ {onboarding ? ( +

+ {t('writeOnboardingSeparationNote')} +

+ ) : null} +
- {t('writeStartReadyLabel')} + {t(onboarding ? 'writeOnboardingSetupLabel' : 'writeStartReadyLabel')}
diff --git a/src/renderer/src/components/write/WriteWorkspaceToolbar.tsx b/src/renderer/src/components/write/WriteWorkspaceToolbar.tsx index d7e6ba560..a85098ed6 100644 --- a/src/renderer/src/components/write/WriteWorkspaceToolbar.tsx +++ b/src/renderer/src/components/write/WriteWorkspaceToolbar.tsx @@ -10,7 +10,8 @@ import { Loader2, Presentation, Save, - Sparkles + Sparkles, + WandSparkles } from 'lucide-react' import { useTranslation } from 'react-i18next' import type { WriteExportFormat } from '@shared/write-export' @@ -34,6 +35,7 @@ type Props = { activeFileName: string activeFilePath: string documentStatsLabel: string | null + inlineCompletionEnabled: boolean assistantOpen: boolean exportInFlight: boolean exportMenuOpen: boolean @@ -47,6 +49,7 @@ type Props = { onExportFile: (format: WriteExportFormat) => void onGeneratePresentation: () => void onSave: () => void + onToggleInlineCompletion: () => void onToggleLeftSidebar: () => void previewMode: WritePreviewMode presentationEnabled: boolean @@ -69,6 +72,7 @@ export function WriteWorkspaceToolbar({ activeFileName, activeFilePath, documentStatsLabel, + inlineCompletionEnabled, assistantOpen, exportInFlight, exportMenuOpen, @@ -82,6 +86,7 @@ export function WriteWorkspaceToolbar({ onExportFile, onGeneratePresentation, onSave, + onToggleInlineCompletion, onToggleLeftSidebar, previewMode, presentationEnabled, @@ -248,6 +253,17 @@ export function WriteWorkspaceToolbar({
{activeFileIsText ? : null} + + + + {Math.round(currentTimeMs)} / {Math.round(durationMs)} ms + + { + useCanvasMotionStore.getState().setPlaying(false) + useCanvasMotionStore.getState().setCurrentTimeMs(Number(event.target.value)) + }} + className="canvas-inspector-range min-w-[120px] flex-1" + aria-label={t('canvasMotionPlayhead', 'Motion playhead')} + /> + + + + + + + + + {reducedMotion ? ( +
+ {t('canvasMotionReduced', 'Reduced motion is enabled. Scrubbing remains available; automatic playback is paused.')} +
+ ) : null} + +
+ + +
+
+
+ {[0, 0.25, 0.5, 0.75, 1].map((ratio) => ( + + {Math.round(durationMs * ratio)} + + ))} + 0 ? currentTimeMs / durationMs * 100 : 0}%` }} + /> +
+ {timeline?.tracks.length ? timeline.tracks.map((track, trackIndex) => { + const selected = selectedTrackId === track.id + const startsLayer = trackIndex === 0 || timeline.tracks[trackIndex - 1]?.targetShapeId !== track.targetShapeId + return ( +
+ {startsLayer ? ( +
+ {shapeLabel(document.objects[track.targetShapeId], track.targetShapeId)} +
+ ) : null} +
useCanvasMotionStore.getState().selectKeyframe(track.id, null)} + > + +
+ + {track.keyframes.map((keyframe) => { + const displayedTime = drag?.trackId === track.id && drag.keyframeId === keyframe.id + ? trackTime(track, { ...keyframe, timeMs: drag.timeMs }) + : trackTime(track, keyframe) + const keyframeSelected = selectedKeyframeId === keyframe.id && selected + return ( + +
+
+
+ ) + }) : ( +
+ {selectedShapeIds.length > 0 + ? t('canvasMotionEmptySelected', 'Apply a preset or add a property to start animating the selected layer.') + : t('canvasMotionEmpty', 'Select a layer or frame, then add a Motion preset.')} +
+ )} +
+
+ + {selectedTrack ? ( + { + const next = removeTrack(document.motion, frameId, selectedTrack.id) + commitMotion(next, 'motion-delete-track') + useCanvasMotionStore.getState().selectKeyframe(null) + }} + onUpdateKeyframe={(patch) => { + if (selectedKeyframe) updateKeyframe(selectedTrack, selectedKeyframe, patch) + }} + onDeleteKeyframe={removeSelectedKeyframe} + onAddKeyframe={() => addKeyframeAtPlayhead(selectedTrack)} + /> + ) : null} +
+ + ) +} diff --git a/src/renderer/src/components/design/canvas/CanvasMotionInspector.test.ts b/src/renderer/src/components/design/canvas/CanvasMotionInspector.test.ts new file mode 100644 index 000000000..f6f6b12bb --- /dev/null +++ b/src/renderer/src/components/design/canvas/CanvasMotionInspector.test.ts @@ -0,0 +1,191 @@ +import { createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { useCanvasShapeStore } from '../../../design/canvas/canvas-shape-store' +import { useCanvasSelectionStore } from '../../../design/canvas/canvas-selection-store' +import { + createDefaultShape, + createEmptyDocument, + type CanvasDocument +} from '../../../design/canvas/canvas-types' +import { useCanvasUndoStore } from '../../../design/canvas/canvas-undo-store' +import { + commitAutoKeyCanvasGesture, + shouldAutoKeyCanvasGesture +} from '../../../design/motion/canvas-motion-auto-key' +import { applyMotionPreset } from '../../../design/motion/canvas-motion-mutations' +import { evaluateMotionTrack } from '../../../design/motion/evaluator' +import { useCanvasMotionStore } from '../../../design/motion/canvas-motion-store' +import { commitInspectorUpdate } from './PropertiesPanel' +import { MotionKeyframeControls } from './properties-panel/MotionKeyframeControls' + +function installDocument(): { document: CanvasDocument; shapeId: string; frameId: string } { + const document = createEmptyDocument() + const frame = { + ...createDefaultShape('frame', 0, 0), + id: 'inspector-frame', + parentId: document.rootId, + children: ['inspector-card'] + } + const shape = { + ...createDefaultShape('rect', 10, 20), + id: 'inspector-card', + parentId: frame.id, + frameId: frame.id + } + document.objects[document.rootId] = { + ...document.objects[document.rootId], + children: [frame.id] + } + document.objects[frame.id] = frame + document.objects[shape.id] = shape + useCanvasShapeStore.getState().loadDocument(document, 'motion-inspector-test') + useCanvasSelectionStore.setState({ selectedIds: new Set([shape.id]) }) + useCanvasMotionStore.setState({ + open: true, + activeFrameId: frame.id, + currentTimeMs: 500, + playing: false + }) + return { document, shapeId: shape.id, frameId: frame.id } +} + +beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + useCanvasMotionStore.getState().reset() + useCanvasShapeStore.getState().loadDocument(createEmptyDocument(), 'motion-inspector-empty') + useCanvasSelectionStore.setState({ selectedIds: new Set() }) + useCanvasUndoStore.getState().clear() +}) + +afterEach(() => { + useCanvasMotionStore.getState().reset() +}) + +describe('Motion inspector keyframes', () => { + it('stays hidden outside Motion mode', () => { + const shape = createDefaultShape('rect', 0, 0) + useCanvasMotionStore.getState().reset() + + expect(renderToStaticMarkup(createElement(MotionKeyframeControls, { shape }))).toBe('') + }) + + it('adds and removes a property keyframe at the current playhead', async () => { + const { shapeId, frameId } = installDocument() + let renderer!: ReactTestRenderer + await act(async () => { + renderer = create(createElement(MotionKeyframeControls, { + shape: useCanvasShapeStore.getState().document.objects[shapeId] + })) + }) + + const addX = renderer.root.findByProps({ 'aria-label': 'Add x keyframe' }) + expect(addX.props['aria-pressed']).toBe(false) + await act(async () => { + addX.props.onClick() + renderer.update(createElement(MotionKeyframeControls, { + shape: useCanvasShapeStore.getState().document.objects[shapeId] + })) + }) + + const track = useCanvasShapeStore.getState().document.motion!.timelines[frameId].tracks.find( + (candidate) => candidate.targetShapeId === shapeId && candidate.property === 'x' + )! + expect(track.keyframes.map((keyframe) => [keyframe.timeMs, keyframe.value])).toEqual([ + [0, 10], + [500, 10] + ]) + expect(renderer.root.findByProps({ 'aria-label': 'Remove x keyframe' }).props['aria-pressed']).toBe(true) + + await act(async () => { + renderer.root.findByProps({ 'aria-label': 'Remove x keyframe' }).props.onClick() + }) + expect( + useCanvasShapeStore.getState().document.motion!.timelines[frameId].tracks[0].keyframes + ).toHaveLength(1) + + await act(async () => renderer.unmount()) + }) + + it('adds an offset keyframe relative to the current shape base', async () => { + const { shapeId, frameId } = installDocument() + let document = useCanvasShapeStore.getState().document + document.motion = applyMotionPreset(document.motion, document, frameId, [shapeId], 'move', { + direction: 'right', + distance: 20, + durationMs: 100 + }) + document = { + ...document, + objects: { + ...document.objects, + [shapeId]: { ...document.objects[shapeId], x: 50 } + } + } + useCanvasShapeStore.getState().loadDocument(document, 'motion-inspector-offset-base') + useCanvasMotionStore.setState({ open: true, activeFrameId: frameId, currentTimeMs: 50 }) + const beforeTrack = useCanvasShapeStore.getState().document.motion!.timelines[frameId].tracks.find( + (candidate) => candidate.targetShapeId === shapeId && candidate.property === 'x' + )! + const expectedRaw = evaluateMotionTrack(beforeTrack, 50, 50) - 50 + + let renderer!: ReactTestRenderer + await act(async () => { + renderer = create(createElement(MotionKeyframeControls, { + shape: useCanvasShapeStore.getState().document.objects[shapeId] + })) + }) + await act(async () => { + renderer.root.findByProps({ 'aria-label': 'Add x keyframe' }).props.onClick() + }) + + const afterTrack = useCanvasShapeStore.getState().document.motion!.timelines[frameId].tracks.find( + (candidate) => candidate.targetShapeId === shapeId && candidate.property === 'x' + )! + expect(afterTrack.keyframes.find((keyframe) => keyframe.timeMs === 50)?.value).toBeCloseTo(expectedRaw) + await act(async () => renderer.unmount()) + }) + + it('routes supported PropertiesPanel edits through Auto-key without rewriting base geometry', () => { + const { shapeId, frameId } = installDocument() + useCanvasMotionStore.getState().setAutoKey(true) + + commitInspectorUpdate('design', 'set-x', [shapeId], { x: 42 }) + + const state = useCanvasShapeStore.getState() + expect(state.document.objects[shapeId].x).toBe(10) + expect(state.document.motion!.timelines[frameId].tracks[0]).toMatchObject({ + targetShapeId: shapeId, + property: 'x', + baseValue: 10 + }) + expect( + state.document.motion!.timelines[frameId].tracks[0].keyframes.map((keyframe) => [ + keyframe.timeMs, + keyframe.value + ]) + ).toEqual([ + [0, 10], + [500, 42] + ]) + }) + + it('turns one transient canvas gesture into one Auto-key mutation', () => { + const { shapeId, frameId } = installDocument() + useCanvasMotionStore.getState().setAutoKey(true) + const patches = [{ id: shapeId, before: { x: 10 }, after: { x: 64 } }] + + useCanvasShapeStore.getState().updateShape(shapeId, { x: 64 }, true) + expect(shouldAutoKeyCanvasGesture(patches)).toBe(true) + expect(commitAutoKeyCanvasGesture(patches, 'move')).toBe(true) + + const state = useCanvasShapeStore.getState() + expect(state.document.objects[shapeId].x).toBe(10) + expect(state.document.motion!.timelines[frameId].tracks[0].keyframes.at(-1)).toMatchObject({ + timeMs: 500, + value: 64 + }) + expect(useCanvasUndoStore.getState().undoStack).toHaveLength(1) + }) +}) diff --git a/src/renderer/src/components/design/canvas/CanvasMotionKeyframeInspector.tsx b/src/renderer/src/components/design/canvas/CanvasMotionKeyframeInspector.tsx new file mode 100644 index 000000000..954a1879d --- /dev/null +++ b/src/renderer/src/components/design/canvas/CanvasMotionKeyframeInspector.tsx @@ -0,0 +1,164 @@ +import { Plus, Trash2 } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import type { + CanvasMotionEasing, + CanvasMotionKeyframe, + CanvasMotionTrack +} from '../../../design/motion' + +type KeyframePatch = Partial> + +type Props = { + track: CanvasMotionTrack + keyframe: CanvasMotionKeyframe | undefined + targetLabel: string + onDeleteTrack: () => void + onUpdateKeyframe: (patch: KeyframePatch) => void + onDeleteKeyframe: () => void + onAddKeyframe: () => void +} + +function easingFromType(type: string): CanvasMotionEasing { + switch (type) { + case 'ease-in': return { type: 'ease-in' } + case 'ease-out': return { type: 'ease-out' } + case 'ease-in-out': return { type: 'ease-in-out' } + case 'hold': return { type: 'hold' } + case 'cubic-bezier': return { type: 'cubic-bezier', x1: 0.25, y1: 0.1, x2: 0.25, y2: 1 } + case 'spring': return { type: 'spring', mass: 1, stiffness: 100, damping: 10 } + default: return { type: 'linear' } + } +} + +export function CanvasMotionKeyframeInspector({ + track, + keyframe, + targetLabel, + onDeleteTrack, + onUpdateKeyframe, + onDeleteKeyframe, + onAddKeyframe +}: Props) { + const { t } = useTranslation('common') + const easing = keyframe?.easing + return ( + + ) +} diff --git a/src/renderer/src/components/design/canvas/CanvasMotionToolbar.test.ts b/src/renderer/src/components/design/canvas/CanvasMotionToolbar.test.ts new file mode 100644 index 000000000..1ecde6dc7 --- /dev/null +++ b/src/renderer/src/components/design/canvas/CanvasMotionToolbar.test.ts @@ -0,0 +1,43 @@ +import { createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { beforeEach, describe, expect, it } from 'vitest' +import { useCanvasMotionStore } from '../../../design/motion/canvas-motion-store' +import { CanvasToolbar } from './CanvasToolbar' + +beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + useCanvasMotionStore.getState().reset() +}) + +describe('CanvasToolbar Motion mode', () => { + it('exposes Motion only on the Design canvas', () => { + const designHtml = renderToStaticMarkup(createElement(CanvasToolbar, { + workspaceRoot: '/workspace', + surface: 'design' + })) + const codeHtml = renderToStaticMarkup(createElement(CanvasToolbar, { + workspaceRoot: '/workspace', + surface: 'code', + onExportCanvas: async () => undefined + })) + + expect(designHtml).toContain('aria-label="Motion"') + expect(designHtml).toContain('aria-pressed="false"') + expect(codeHtml).not.toContain('aria-label="Motion"') + }) + + it('reports the active Motion toggle state accessibly', async () => { + useCanvasMotionStore.getState().setOpen(true) + let renderer!: ReactTestRenderer + await act(async () => { + renderer = create(createElement(CanvasToolbar, { + workspaceRoot: '/workspace', + surface: 'design' + })) + }) + + expect(renderer.root.findByProps({ 'aria-label': 'Motion' }).props['aria-pressed']).toBe(true) + await act(async () => renderer.unmount()) + }) +}) diff --git a/src/renderer/src/components/design/canvas/CanvasMotionWrappers.test.ts b/src/renderer/src/components/design/canvas/CanvasMotionWrappers.test.ts new file mode 100644 index 000000000..8c0c0a89e --- /dev/null +++ b/src/renderer/src/components/design/canvas/CanvasMotionWrappers.test.ts @@ -0,0 +1,170 @@ +import { createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { act, create as createRenderer, type ReactTestRenderer } from 'react-test-renderer' +import { beforeEach, describe, expect, it } from 'vitest' +import { useCanvasSelectionStore } from '../../../design/canvas/canvas-selection-store' +import { useCanvasShapeStore } from '../../../design/canvas/canvas-shape-store' +import { + createDefaultShape, + createEmptyDocument, + createHtmlFrameShape, + createSvgFrameShape +} from '../../../design/canvas/canvas-types' +import { createRunningAppFrameShape } from '../../../design/canvas/running-app-frame' +import { useCanvasViewportStore } from '../../../design/canvas/canvas-viewport-store' +import { useDesignWorkspaceStore } from '../../../design/design-workspace-store' +import type { DesignArtifact } from '../../../design/design-types' +import { RunningAppFrameOverlay } from './RunningAppFrameOverlay' +import { SvgFrameOverlay } from './SvgFrameOverlay' +import { ScreenOverlay } from './html-frame/HtmlFrameScreenOverlay' +import { ShapeDispatcher } from './shapes/ShapeDispatcher' + +const createdAt = '2026-07-13T00:00:00.000Z' + +function artifact(id: string, kind: 'html' | 'svg'): DesignArtifact { + const extension = kind === 'svg' ? 'svg' : 'html' + const relativePath = `.kun-design/doc/${id}/v1.${extension}` + return { + id, + kind, + title: `${id} artifact`, + relativePath, + createdAt, + updatedAt: createdAt, + versions: [{ id: `${id}-v1`, relativePath, createdAt, summary: '' }] + } +} + +beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + useCanvasShapeStore.getState().loadDocument(createEmptyDocument()) + useCanvasSelectionStore.setState({ selectedIds: new Set() }) + useCanvasViewportStore.setState({ + vbox: { x: 0, y: 0, width: 1200, height: 800 }, + containerWidth: 1200, + containerHeight: 800, + activeTool: 'select' + }) + useDesignWorkspaceStore.setState({ + workspaceRoot: '/workspace', + artifacts: [], + activeArtifactId: null, + parallelPageStates: {}, + pagesRun: null + }) +}) + +describe('canvas motion preview wrappers', () => { + it('keeps native SVG geometry on an inner static transform group', () => { + const shape = { + ...createDefaultShape('rect', 12, 24), + id: 'native-card', + rotation: 15, + opacity: 0.6 + } + + const html = renderToStaticMarkup(createElement(ShapeDispatcher, { + shapeId: shape.id, + objects: { [shape.id]: shape } + })) + + expect(html).toContain('id="shape-native-card"') + expect(html).toContain('data-canvas-motion-target="native-card"') + expect(html).toContain('data-canvas-motion-kind="svg"') + expect(html).toContain('opacity="0.6"') + expect(html).toContain('transform="translate(12, 24) rotate(15, 50, 50)"') + }) + + it('wraps a running app as one portal target without changing iframe scale', () => { + const shape = createRunningAppFrameShape({ + x: 20, + y: 40, + url: 'localhost:5173/dashboard', + title: 'Live dashboard' + })! + shape.id = 'running-app' + shape.rotation = 12 + shape.opacity = 0.7 + + const html = renderToStaticMarkup(createElement(RunningAppFrameOverlay, { + shape, + screenX: 30, + screenY: 60, + screenWidth: 640, + screenHeight: 400, + zIndex: 3, + zoom: 0.5, + active: true, + interactive: false, + panning: false, + editing: false, + onDoubleClick: () => undefined + })) + + expect(html).toContain('data-canvas-motion-target="running-app"') + expect(html).toContain('data-canvas-motion-kind="portal"') + expect(html).toContain('opacity:0.7') + expect(html).toContain('transform:rotate(12deg)') + expect(html).toContain('transform:scale(0.5)') + }) + + it('wraps an HTML artifact frame as one portal target', () => { + const screen = artifact('home', 'html') + const shape = createHtmlFrameShape('Home', 10, 20, screen.id, 'mobile') + shape.id = 'html-frame' + shape.rotation = 8 + shape.opacity = 0.8 + useDesignWorkspaceStore.setState({ artifacts: [screen], activeArtifactId: screen.id }) + + const html = renderToStaticMarkup(createElement(ScreenOverlay, { + shape, + workspaceRoot: '/workspace', + screenX: 15, + screenY: 30, + screenWidth: 195, + screenHeight: 422, + zIndex: 2, + zoom: 0.5, + active: true, + interactive: false, + panning: false, + editing: false, + onDoubleClick: () => undefined, + onToggleModify: () => undefined + })) + + expect(html).toContain('data-canvas-motion-target="html-frame"') + expect(html).toContain('data-canvas-motion-kind="portal"') + expect(html).toContain('opacity:0.8') + expect(html).toContain('transform:rotate(8deg)') + }) + + it('wraps an SVG artifact frame as one outer portal target', async () => { + const svg = artifact('logo-loop', 'svg') + const shape = createSvgFrameShape('Logo loop', 50, 60, svg.id, 320, 240) + shape.id = 'svg-frame' + shape.parentId = '__root__' + shape.rotation = 5 + shape.opacity = 0.65 + const document = createEmptyDocument() + document.objects[shape.id] = shape + document.objects[document.rootId] = { + ...document.objects[document.rootId], + children: [shape.id] + } + useCanvasShapeStore.getState().loadDocument(document) + useDesignWorkspaceStore.setState({ artifacts: [svg], activeArtifactId: svg.id }) + + let renderer!: ReactTestRenderer + await act(async () => { + renderer = createRenderer(createElement(SvgFrameOverlay, { + workspaceRoot: '/workspace' + })) + }) + const target = renderer.root.findByProps({ 'data-canvas-motion-target': 'svg-frame' }) + expect(target.props['data-canvas-motion-kind']).toBe('portal') + expect(target.props['data-svg-artifact-id']).toBe('logo-loop') + expect(target.props.style).toMatchObject({ opacity: 0.65, transform: 'rotate(5deg)' }) + await act(async () => renderer.unmount()) + }) +}) diff --git a/src/renderer/src/components/design/canvas/CanvasToolbar.tsx b/src/renderer/src/components/design/canvas/CanvasToolbar.tsx index c143bd651..d904a1148 100644 --- a/src/renderer/src/components/design/canvas/CanvasToolbar.tsx +++ b/src/renderer/src/components/design/canvas/CanvasToolbar.tsx @@ -5,6 +5,7 @@ import { Download, FileCode2, FileImage, + Film, Frame, Hand, ImagePlus, @@ -29,6 +30,7 @@ import { useDesignSystemStore } from '../../../design/canvas/design-system-store import type { CanvasTool } from '../../../design/canvas/canvas-types' import type { CanvasExportFormat } from '../../../design/canvas/canvas-export' import { useCanvasViewportStore } from '../../../design/canvas/canvas-viewport-store' +import { useCanvasMotionStore } from '../../../design/motion/canvas-motion-store' import { useDesignWorkspaceStore } from '../../../design/design-workspace-store' import { buildRecommendedDesignWorkflowAction, @@ -96,6 +98,8 @@ function CanvasToolbarInner({ const artifacts = useDesignWorkspaceStore((s) => s.artifacts) const activeTool = useCanvasViewportStore((s) => s.activeTool) const setActiveTool = useCanvasViewportStore((s) => s.setActiveTool) + const motionOpen = useCanvasMotionStore((s) => s.open) + const toggleMotionOpen = useCanvasMotionStore((s) => s.toggleOpen) const vbox = useCanvasViewportStore((s) => s.vbox) const selectedIds = useCanvasSelectionStore((s) => s.selectedIds) const setFileError = useDesignWorkspaceStore((s) => s.setFileError) @@ -247,6 +251,20 @@ function CanvasToolbarInner({ <>
+ + + ) + })} +
+ ) +} diff --git a/src/renderer/src/components/design/canvas/properties-panel/primitives.tsx b/src/renderer/src/components/design/canvas/properties-panel/primitives.tsx index ebe6b5154..bdb9056b7 100644 --- a/src/renderer/src/components/design/canvas/properties-panel/primitives.tsx +++ b/src/renderer/src/components/design/canvas/properties-panel/primitives.tsx @@ -1,4 +1,4 @@ -import { useState, type ReactElement, type ReactNode } from 'react' +import { useEffect, useState, type ReactElement, type ReactNode } from 'react' import { useCanvasShapeStore } from '../../../../design/canvas/canvas-shape-store' import { useCanvasUndoStore } from '../../../../design/canvas/canvas-undo-store' import { filterEditableShapeIds } from '../../../../design/canvas/canvas-editability' @@ -77,6 +77,13 @@ export function NumberBox({ }): ReactElement { const display = value === MIXED ? '' : value === undefined ? '' : String(Math.round((value as number) * 100) / 100) + const [draft, setDraft] = useState(display) + useEffect(() => setDraft(display), [display]) + const commit = (): void => { + const next = parseFloat(draft) + if (Number.isFinite(next)) onCommit(next) + else setDraft(display) + } return (
-
Add property
+
+ {t('canvasMotionAddProperty', 'Add property')} +
- {PROPERTY_LABELS.map(({ property, label }) => ( + {PROPERTY_LABELS.map(({ property, labelKey, fallback }) => ( ))}
@@ -535,6 +587,9 @@ export function CanvasMotionDock(): ReactElement | null { style={{ left: `${durationMs > 0 ? currentTimeMs / durationMs * 100 : 0}%` }} />
+ {svgPreview ? ( + + ) : null} {timeline?.tracks.length ? timeline.tracks.map((track, trackIndex) => { const selected = selectedTrackId === track.id const startsLayer = trackIndex === 0 || timeline.tracks[trackIndex - 1]?.targetShapeId !== track.targetShapeId @@ -604,7 +659,7 @@ export function CanvasMotionDock(): ReactElement | null {
) - }) : ( + }) : svgPreview ? null : (
{selectedShapeIds.length > 0 ? t('canvasMotionEmptySelected', 'Apply a preset or add a property to start animating the selected layer.') diff --git a/src/renderer/src/components/design/canvas/CanvasMotionSvgPreview.tsx b/src/renderer/src/components/design/canvas/CanvasMotionSvgPreview.tsx new file mode 100644 index 000000000..5172b2783 --- /dev/null +++ b/src/renderer/src/components/design/canvas/CanvasMotionSvgPreview.tsx @@ -0,0 +1,142 @@ +import { FileCode2, Pause, Play, Repeat2, RotateCcw } from 'lucide-react' +import type { ReactElement } from 'react' +import { useTranslation } from 'react-i18next' +import { + controlSvgAnimationPreview, + type SvgAnimationPreviewState +} from '../../../design/svg/svg-animation-preview-store' + +function formatTime(timeMs: number): string { + if (timeMs >= 1_000) return `${(timeMs / 1_000).toFixed(1)}s` + return `${Math.round(timeMs)}ms` +} + +export function CanvasMotionSvgPreview({ + preview, + reducedMotion +}: { + preview: SvgAnimationPreviewState + reducedMotion: boolean +}): ReactElement { + const { t } = useTranslation('common') + const hasAnimations = preview.status === 'ready' && preview.animationCount > 0 + const displayedTime = preview.loopsIndefinitely + ? preview.currentTimeMs % preview.durationMs + : Math.min(preview.currentTimeMs, preview.durationMs) + const status = preview.status === 'loading' + ? t('canvasMotionSvgInspecting', 'Inspecting SVG animation…') + : preview.status === 'missing' + ? t('canvasMotionSvgMissing', 'The SVG source is missing.') + : preview.status === 'invalid' + ? t('canvasMotionSvgInvalid', 'The SVG animation could not be inspected.') + : preview.animationCount === 0 + ? t('canvasMotionSvgNone', 'No internal SVG animation was detected.') + : '' + + return ( +
+
+
+ +
+
+
+ + {t('canvasMotionSvgLane', 'SVG internal animation')} + + {hasAnimations ? ( + <> + + {t('canvasMotionSvgCount', '{{count}} animations', { count: preview.animationCount })} + + {preview.loopsIndefinitely ? ( + + + {t('canvasMotionSvgLooping', 'Looping')} + + ) : null} + + {t('canvasMotionSvgCycle', '{{duration}} representative cycle', { + duration: formatTime(preview.durationMs) + })} + + + ) : null} +
+

+ {hasAnimations + ? t( + 'canvasMotionSvgGuidance', + 'Preview-only content animation. Container Motion presets move, scale, rotate, or fade the whole SVG.' + ) + : status} +

+ {hasAnimations ? ( +
+ + + + {formatTime(displayedTime)} / {formatTime(preview.durationMs)} + + controlSvgAnimationPreview(preview.shapeId, { + type: 'seek', + timeMs: Number(event.target.value) + })} + className="canvas-inspector-range min-w-[120px] flex-1" + aria-label={t('canvasMotionSvgPlayhead', 'SVG internal animation playhead')} + /> + +
+ ) : null} +
+
+
+ ) +} diff --git a/src/renderer/src/components/design/canvas/CanvasMotionWrappers.test.ts b/src/renderer/src/components/design/canvas/CanvasMotionWrappers.test.ts index 8c0c0a89e..a4316377f 100644 --- a/src/renderer/src/components/design/canvas/CanvasMotionWrappers.test.ts +++ b/src/renderer/src/components/design/canvas/CanvasMotionWrappers.test.ts @@ -14,6 +14,11 @@ import { createRunningAppFrameShape } from '../../../design/canvas/running-app-f import { useCanvasViewportStore } from '../../../design/canvas/canvas-viewport-store' import { useDesignWorkspaceStore } from '../../../design/design-workspace-store' import type { DesignArtifact } from '../../../design/design-types' +import { useCanvasMotionStore } from '../../../design/motion/canvas-motion-store' +import { + resetSvgAnimationPreviewStore, + useSvgAnimationPreviewStore +} from '../../../design/svg/svg-animation-preview-store' import { RunningAppFrameOverlay } from './RunningAppFrameOverlay' import { SvgFrameOverlay } from './SvgFrameOverlay' import { ScreenOverlay } from './html-frame/HtmlFrameScreenOverlay' @@ -52,6 +57,8 @@ beforeEach(() => { parallelPageStates: {}, pagesRun: null }) + useCanvasMotionStore.getState().reset() + resetSvgAnimationPreviewStore() }) describe('canvas motion preview wrappers', () => { @@ -153,6 +160,13 @@ describe('canvas motion preview wrappers', () => { children: [shape.id] } useCanvasShapeStore.getState().loadDocument(document) + useCanvasSelectionStore.setState({ selectedIds: new Set([shape.id]) }) + useCanvasMotionStore.setState({ open: true, activeFrameId: document.rootId }) + useCanvasViewportStore.setState({ + vbox: { x: 10_000, y: 10_000, width: 120_000, height: 80_000 }, + containerWidth: 1_200, + containerHeight: 800 + }) useDesignWorkspaceStore.setState({ artifacts: [svg], activeArtifactId: svg.id }) let renderer!: ReactTestRenderer @@ -165,6 +179,48 @@ describe('canvas motion preview wrappers', () => { expect(target.props['data-canvas-motion-kind']).toBe('portal') expect(target.props['data-svg-artifact-id']).toBe('logo-loop') expect(target.props.style).toMatchObject({ opacity: 0.65, transform: 'rotate(5deg)' }) + expect(useSvgAnimationPreviewStore.getState().previews[shape.id]).toMatchObject({ + shapeId: shape.id, + artifactId: svg.id, + title: svg.title, + status: 'loading', + animationCount: 0 + }) + await act(async () => useCanvasMotionStore.getState().setOpen(false)) + expect(useSvgAnimationPreviewStore.getState().previews[shape.id]).toBeUndefined() + await act(async () => useCanvasMotionStore.getState().setOpen(true)) + expect(useSvgAnimationPreviewStore.getState().previews[shape.id]).toMatchObject({ + artifactId: svg.id, + status: 'loading' + }) + await act(async () => renderer.unmount()) + expect(useSvgAnimationPreviewStore.getState().previews[shape.id]).toBeUndefined() + }) + + it('reports missing SVG artifact metadata instead of leaving Motion inspection pending', async () => { + const shape = createSvgFrameShape('Missing loop', 50, 60, 'missing-svg', 320, 240) + shape.id = 'missing-svg-frame' + shape.parentId = '__root__' + const document = createEmptyDocument() + document.objects[shape.id] = shape + document.objects[document.rootId] = { + ...document.objects[document.rootId], + children: [shape.id] + } + useCanvasShapeStore.getState().loadDocument(document) + useCanvasSelectionStore.setState({ selectedIds: new Set([shape.id]) }) + useCanvasMotionStore.setState({ open: true, activeFrameId: shape.id }) + + let renderer!: ReactTestRenderer + await act(async () => { + renderer = createRenderer(createElement(SvgFrameOverlay, { workspaceRoot: '/workspace' })) + }) + + expect(useSvgAnimationPreviewStore.getState().previews[shape.id]).toMatchObject({ + artifactId: 'missing-svg', + title: 'Missing loop', + status: 'missing' + }) await act(async () => renderer.unmount()) }) }) diff --git a/src/renderer/src/components/design/canvas/SvgFrameOverlay.tsx b/src/renderer/src/components/design/canvas/SvgFrameOverlay.tsx index 51eb2773d..5fc793157 100644 --- a/src/renderer/src/components/design/canvas/SvgFrameOverlay.tsx +++ b/src/renderer/src/components/design/canvas/SvgFrameOverlay.tsx @@ -12,6 +12,11 @@ import { useDesignWorkspaceStore } from '../../../design/design-workspace-store' import { useCanvasMotionStore } from '../../../design/motion/canvas-motion-store' import { useCanvasMotionPortalStyle } from '../../../design/motion/canvas-motion-preview' import { useSvgArtifactPreview } from '../../../design/svg/use-svg-artifact-preview' +import { + publishSvgAnimationPreview, + registerSvgAnimationPreviewController, + type SvgAnimationPreviewController +} from '../../../design/svg/svg-animation-preview-store' import { htmlFrameCanvasRectToScreenRect, htmlFrameCanvasScreenTransform @@ -146,6 +151,7 @@ function SvgArtifactFrame({ const tickRef = useRef(null) const lastTickRef = useRef(null) const lastUiTickRef = useRef(0) + const controllerRef = useRef(null) const preview = useSvgArtifactPreview(workspaceRoot, artifact?.relativePath ?? '', background) const hasAnimations = preview.animationCount + cssTimeline.animationCount > 0 const durationMs = hasAnimations @@ -161,6 +167,87 @@ function SvgArtifactFrame({ controlTimeline(iframeRef.current, bounded, rate) }, [durationMs, rate]) + controllerRef.current = { + play: () => { + if (!hasAnimations) return + if (designMotionOpen) resumeAfterDesignMotionRef.current = true + if (currentMsRef.current >= durationMs) seek(0) + setPlaying(true) + }, + pause: () => { + if (designMotionOpen) resumeAfterDesignMotionRef.current = false + setPlaying(false) + }, + restart: () => { + if (designMotionOpen) resumeAfterDesignMotionRef.current = true + seek(0) + setPlaying(true) + }, + seek: (timeMs) => { + if (designMotionOpen) resumeAfterDesignMotionRef.current = false + setPlaying(false) + seek(timeMs) + }, + setRate: (nextRate) => setRate(Math.max(0.1, Math.min(4, nextRate))) + } + + useEffect(() => { + if (!designMotionOpen || !selected) return + return registerSvgAnimationPreviewController(shape.id, { + play: () => controllerRef.current?.play(), + pause: () => controllerRef.current?.pause(), + restart: () => controllerRef.current?.restart(), + seek: (timeMs) => controllerRef.current?.seek(timeMs), + setRate: (nextRate) => controllerRef.current?.setRate(nextRate) + }) + }, [designMotionOpen, selected, shape.id]) + + useEffect(() => { + if (!designMotionOpen || !selected) return + if (!artifact) { + publishSvgAnimationPreview({ + shapeId: shape.id, + artifactId: reference?.id ?? '', + title: shape.name?.trim() || 'SVG', + status: 'missing', + animationCount: 0, + durationMs: 1_000, + loopsIndefinitely: false, + currentTimeMs: 0, + playing: false, + rate + }) + return + } + publishSvgAnimationPreview({ + shapeId: shape.id, + artifactId: artifact.id, + title: artifact.title, + status: preview.status, + animationCount: preview.animationCount + cssTimeline.animationCount, + durationMs, + loopsIndefinitely, + currentTimeMs: currentMs, + playing, + rate + }) + }, [ + artifact, + cssTimeline.animationCount, + currentMs, + designMotionOpen, + durationMs, + loopsIndefinitely, + playing, + preview.animationCount, + preview.status, + rate, + reference?.id, + selected, + shape.name, + shape.id + ]) + useEffect(() => { if (!playing || preview.status !== 'ready' || !hasAnimations) { if (tickRef.current !== null) cancelAnimationFrame(tickRef.current) @@ -236,7 +323,6 @@ function SvgArtifactFrame({ }, [artifact, preview.status, preview.visualElementCount]) if (!artifact || !reference) return null - if (screenWidth < 8 || screenHeight < 8) return null const diagnostics = preview.diagnostics.length const label = preview.status === 'invalid' ? preview.diagnostics[0]?.message ?? 'Invalid SVG' @@ -399,10 +485,11 @@ export function SvgFrameOverlay({ workspaceRoot }: { workspaceRoot: string }): R ) const priorityIds = new Set(selectedIds) const candidates = svgFramesInCanvasPaintOrder(document).filter((shape) => { + const selected = selectedIds.has(shape.id) const motionRelevant = motionOpen && hasMotionTargetAncestor(document, shape.id, motionTargets) if (motionRelevant) priorityIds.add(shape.id) - return shape.width * zoom >= 8 && shape.height * zoom >= 8 && - (motionRelevant || frameIntersectsViewport(shape, vbox)) + if (selected || motionRelevant) return true + return shape.width * zoom >= 8 && shape.height * zoom >= 8 && frameIntersectsViewport(shape, vbox) }) return selectSvgFramesForOverlay(candidates, priorityIds) }, [document, motionFrameId, motionOpen, selectedIds, vbox, zoom]) diff --git a/src/renderer/src/design/svg/svg-animation-preview-store.test.ts b/src/renderer/src/design/svg/svg-animation-preview-store.test.ts new file mode 100644 index 000000000..6a6e598e2 --- /dev/null +++ b/src/renderer/src/design/svg/svg-animation-preview-store.test.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + controlSvgAnimationPreview, + publishSvgAnimationPreview, + registerSvgAnimationPreviewController, + resetSvgAnimationPreviewStore, + useSvgAnimationPreviewStore +} from './svg-animation-preview-store' + +beforeEach(() => resetSvgAnimationPreviewStore()) + +describe('SVG animation preview bridge', () => { + it('publishes bounded transient state and routes player commands', () => { + const play = vi.fn() + const pause = vi.fn() + const restart = vi.fn() + const seek = vi.fn() + const setRate = vi.fn() + const unregister = registerSvgAnimationPreviewController('svg-frame', { + play, + pause, + restart, + seek, + setRate + }) + + publishSvgAnimationPreview({ + shapeId: 'svg-frame', + artifactId: 'animated-logo', + title: 'Animated logo', + status: 'ready', + animationCount: 24, + durationMs: 5_000, + loopsIndefinitely: true, + currentTimeMs: 1_250, + playing: false, + rate: 1 + }) + + expect(useSvgAnimationPreviewStore.getState().previews['svg-frame']).toMatchObject({ + animationCount: 24, + durationMs: 5_000, + currentTimeMs: 1_250 + }) + expect(controlSvgAnimationPreview('svg-frame', { type: 'play' })).toBe(true) + expect(controlSvgAnimationPreview('svg-frame', { type: 'pause' })).toBe(true) + expect(controlSvgAnimationPreview('svg-frame', { type: 'restart' })).toBe(true) + expect(controlSvgAnimationPreview('svg-frame', { type: 'seek', timeMs: 2_000 })).toBe(true) + expect(controlSvgAnimationPreview('svg-frame', { type: 'set-rate', rate: 2 })).toBe(true) + expect(play).toHaveBeenCalledOnce() + expect(pause).toHaveBeenCalledOnce() + expect(restart).toHaveBeenCalledOnce() + expect(seek).toHaveBeenCalledWith(2_000) + expect(setRate).toHaveBeenCalledWith(2) + + unregister() + expect(useSvgAnimationPreviewStore.getState().previews['svg-frame']).toBeUndefined() + expect(controlSvgAnimationPreview('svg-frame', { type: 'play' })).toBe(false) + }) + + it('publishes an indefinite player time within its representative cycle', () => { + publishSvgAnimationPreview({ + shapeId: 'long-running-svg', + artifactId: 'clock', + title: 'Clock', + status: 'ready', + animationCount: 1, + durationMs: 5_000, + loopsIndefinitely: true, + currentTimeMs: 612_345, + playing: true, + rate: 1 + }) + + expect(useSvgAnimationPreviewStore.getState().previews['long-running-svg']?.currentTimeMs).toBe(2_345) + }) + + it('ignores stale unregister callbacks after a portal remount', () => { + const firstCleanup = registerSvgAnimationPreviewController('svg-frame', { + play: vi.fn(), pause: vi.fn(), restart: vi.fn(), seek: vi.fn(), setRate: vi.fn() + }) + const secondPlay = vi.fn() + const secondCleanup = registerSvgAnimationPreviewController('svg-frame', { + play: secondPlay, pause: vi.fn(), restart: vi.fn(), seek: vi.fn(), setRate: vi.fn() + }) + publishSvgAnimationPreview({ + shapeId: 'svg-frame', artifactId: 'new', title: 'New', status: 'ready', + animationCount: 1, durationMs: 1_000, loopsIndefinitely: true, + currentTimeMs: 0, playing: false, rate: 1 + }) + + firstCleanup() + expect(controlSvgAnimationPreview('svg-frame', { type: 'play' })).toBe(true) + expect(secondPlay).toHaveBeenCalledOnce() + expect(useSvgAnimationPreviewStore.getState().previews['svg-frame']).toBeDefined() + secondCleanup() + }) +}) diff --git a/src/renderer/src/design/svg/svg-animation-preview-store.ts b/src/renderer/src/design/svg/svg-animation-preview-store.ts new file mode 100644 index 000000000..f93cfca38 --- /dev/null +++ b/src/renderer/src/design/svg/svg-animation-preview-store.ts @@ -0,0 +1,106 @@ +import { create } from 'zustand' + +export type SvgAnimationPreviewStatus = 'loading' | 'ready' | 'invalid' | 'missing' + +export type SvgAnimationPreviewState = { + shapeId: string + artifactId: string + title: string + status: SvgAnimationPreviewStatus + animationCount: number + durationMs: number + loopsIndefinitely: boolean + currentTimeMs: number + playing: boolean + rate: number +} + +export type SvgAnimationPreviewController = { + play: () => void + pause: () => void + restart: () => void + seek: (timeMs: number) => void + setRate: (rate: number) => void +} + +type RegisteredController = { + owner: symbol + controller: SvgAnimationPreviewController +} + +type SvgAnimationPreviewStore = { + previews: Readonly> +} + +const controllers = new Map() + +export const useSvgAnimationPreviewStore = create(() => ({ + previews: {} +})) + +function bounded(value: number, minimum: number, maximum: number, fallback: number): number { + return Number.isFinite(value) ? Math.max(minimum, Math.min(maximum, value)) : fallback +} + +export function publishSvgAnimationPreview(preview: SvgAnimationPreviewState): void { + const durationMs = bounded(preview.durationMs, 1, 600_000, 1_000) + const rawCurrentTimeMs = Number.isFinite(preview.currentTimeMs) + ? Math.max(0, preview.currentTimeMs) + : 0 + const normalized: SvgAnimationPreviewState = { + ...preview, + animationCount: Math.round(bounded(preview.animationCount, 0, 5_000, 0)), + durationMs, + currentTimeMs: preview.loopsIndefinitely + ? rawCurrentTimeMs % durationMs + : Math.min(rawCurrentTimeMs, durationMs), + rate: bounded(preview.rate, 0.1, 4, 1) + } + useSvgAnimationPreviewStore.setState((state) => ({ + previews: { ...state.previews, [preview.shapeId]: normalized } + })) +} + +export function registerSvgAnimationPreviewController( + shapeId: string, + controller: SvgAnimationPreviewController +): () => void { + const owner = Symbol(shapeId) + controllers.set(shapeId, { owner, controller }) + return () => { + if (controllers.get(shapeId)?.owner !== owner) return + controllers.delete(shapeId) + useSvgAnimationPreviewStore.setState((state) => { + if (!state.previews[shapeId]) return state + const previews = { ...state.previews } + delete previews[shapeId] + return { previews } + }) + } +} + +export function controlSvgAnimationPreview( + shapeId: string, + action: + | { type: 'play' } + | { type: 'pause' } + | { type: 'restart' } + | { type: 'seek'; timeMs: number } + | { type: 'set-rate'; rate: number } +): boolean { + const controller = controllers.get(shapeId)?.controller + if (!controller) return false + switch (action.type) { + case 'play': controller.play(); break + case 'pause': controller.pause(); break + case 'restart': controller.restart(); break + case 'seek': controller.seek(action.timeMs); break + case 'set-rate': controller.setRate(action.rate); break + } + return true +} + +export function resetSvgAnimationPreviewStore(): void { + controllers.clear() + useSvgAnimationPreviewStore.setState({ previews: {} }) +} diff --git a/src/renderer/src/design/svg/svg-document.test.ts b/src/renderer/src/design/svg/svg-document.test.ts index 1c100cc4a..c168b50e2 100644 --- a/src/renderer/src/design/svg/svg-document.test.ts +++ b/src/renderer/src/design/svg/svg-document.test.ts @@ -4,6 +4,7 @@ import { SVG_NAMESPACE, isVisualSvgElement, parseAndSanitizeSvgDocument, + summarizeSvgAnimationTiming, svgAnimationTiming, validSvgRootNamespace } from './svg-document' @@ -35,11 +36,25 @@ describe('SVG document safety helpers', () => { }) it('keeps the complete static SMIL timing contract instead of guessing one second', () => { - expect(svgAnimationTiming({ dur: '2min' })).toEqual({ endMs: 120_000, mayContinue: false }) - expect(svgAnimationTiming({ dur: '1s', repeatDur: '10s' })).toEqual({ endMs: 10_000, mayContinue: false }) + expect(svgAnimationTiming({ dur: '2min' })).toEqual({ endMs: 120_000, mayContinue: false, cycleMs: 120_000 }) + expect(svgAnimationTiming({ dur: '1s', repeatDur: '10s' })).toEqual({ endMs: 10_000, mayContinue: false, cycleMs: 1_000 }) expect(svgAnimationTiming({ dur: '1s', repeatCount: 'indefinite', repeatDur: '10s' })) - .toEqual({ endMs: 10_000, mayContinue: false }) - expect(svgAnimationTiming({ dur: '1s', repeatCount: 'indefinite' })).toEqual({ endMs: 0, mayContinue: true }) - expect(svgAnimationTiming({ dur: '1s', begin: 'click' })).toEqual({ endMs: 0, mayContinue: true }) + .toEqual({ endMs: 10_000, mayContinue: false, cycleMs: 1_000 }) + expect(svgAnimationTiming({ dur: '1s', repeatCount: 'indefinite' })) + .toEqual({ endMs: 0, mayContinue: true, cycleMs: 1_000 }) + expect(svgAnimationTiming({ dur: '1s', begin: 'click' })) + .toEqual({ endMs: 0, mayContinue: true, cycleMs: 1_000 }) + }) + + it('uses the longest simple duration as the representative cycle for indefinite SMIL', () => { + const timing = [ + svgAnimationTiming({ dur: '5s', repeatCount: 'indefinite' }), + svgAnimationTiming({ dur: '2s', begin: '250ms', repeatCount: 'indefinite' }) + ] + + expect(summarizeSvgAnimationTiming(timing)).toEqual({ + durationMs: 5_000, + loopsIndefinitely: true + }) }) }) diff --git a/src/renderer/src/design/svg/svg-document.ts b/src/renderer/src/design/svg/svg-document.ts index c6154ecb6..e13788c1d 100644 --- a/src/renderer/src/design/svg/svg-document.ts +++ b/src/renderer/src/design/svg/svg-document.ts @@ -58,6 +58,12 @@ export type InvalidSvgDocument = { export type SvgDocumentResult = SanitizedSvgDocument | InvalidSvgDocument +export type SvgAnimationTiming = { + endMs: number + mayContinue: boolean + cycleMs: number +} + function diagnostic( severity: SvgDiagnostic['severity'], code: string, @@ -108,16 +114,17 @@ export function svgAnimationTiming(attributes: { begin?: string | null repeatCount?: string | null repeatDur?: string | null -}): { endMs: number; mayContinue: boolean } { +}): SvgAnimationTiming { const durationText = attributes.dur?.trim().toLowerCase() ?? '' const simpleDuration = durationMs(durationText) + const cycleMs = Math.max(0, simpleDuration ?? 0) const beginText = attributes.begin?.trim() ?? '' const beginEntries = beginText ? beginText.split(';').map((entry) => entry.trim()).filter(Boolean) : ['0s'] const beginTimes = beginEntries.map((entry) => durationMs(entry)) // Syncbase/event begins cannot be resolved without running the SVG. Never // hard-stop the whiteboard clock based on an invented one-second duration. const dynamicBegin = beginTimes.some((value) => value === null) - if (durationText === 'indefinite' || dynamicBegin) return { endMs: 0, mayContinue: true } + if (durationText === 'indefinite' || dynamicBegin) return { endMs: 0, mayContinue: true, cycleMs } const repeatDurationText = attributes.repeatDur?.trim().toLowerCase() ?? '' const repeatDuration = repeatDurationText === 'indefinite' ? Infinity : durationMs(repeatDurationText) @@ -132,12 +139,28 @@ export function svgAnimationTiming(attributes: { const simple = simpleDuration ?? 0 const repeated = Number.isFinite(repeatCount) && repeatCount > 0 ? simple * repeatCount : Infinity const activeDuration = repeatDuration === null ? repeated : Math.min(repeated, repeatDuration) - if (!Number.isFinite(activeDuration)) return { endMs: 0, mayContinue: true } + if (!Number.isFinite(activeDuration)) return { endMs: 0, mayContinue: true, cycleMs } const lastBegin = Math.max(0, ...beginTimes.map((value) => Math.max(0, value ?? 0))) - return { endMs: lastBegin + activeDuration, mayContinue: false } + return { endMs: lastBegin + activeDuration, mayContinue: false, cycleMs } +} + +export function summarizeSvgAnimationTiming(timing: readonly SvgAnimationTiming[]): { + durationMs: number + loopsIndefinitely: boolean +} { + const loopsIndefinitely = timing.some((item) => item.mayContinue) + const maxDuration = timing.reduce((max, item) => Math.max(max, item.endMs), 0) + // An indefinitely repeating SMIL element has no finite end time, but its + // simple `dur` is still the useful representative cycle for scrubbing. Keep + // the longest detected cycle instead of collapsing every loop to 1000 ms. + const maxCycleDuration = timing.reduce((max, item) => Math.max(max, item.cycleMs), 0) + return { + durationMs: timing.length > 0 ? Math.max(1, maxDuration, maxCycleDuration || 1000) : 4000, + loopsIndefinitely + } } -function animationTiming(element: Element): { endMs: number; mayContinue: boolean } { +function animationTiming(element: Element): SvgAnimationTiming { return svgAnimationTiming({ dur: element.getAttribute('dur'), begin: element.getAttribute('begin'), @@ -350,8 +373,7 @@ export function parseAndSanitizeSvgDocument(raw: string): SvgDocumentResult { // `loopsIndefinitely` also includes event/syncbase starts whose end cannot // be computed statically. Keeping the clock monotonic is safer than freezing // a valid interactive animation after a guessed finite duration. - const loopsIndefinitely = timing.some((item) => item.mayContinue) - const maxDuration = timing.reduce((max, item) => Math.max(max, item.endMs), 0) + const animationSummary = summarizeSvgAnimationTiming(timing) root.setAttribute('width', '100%') root.setAttribute('height', '100%') root.setAttribute('preserveAspectRatio', root.getAttribute('preserveAspectRatio') || 'xMidYMid meet') @@ -361,8 +383,8 @@ export function parseAndSanitizeSvgDocument(raw: string): SvgDocumentResult { diagnostics, animationCount: animations.length, visualElementCount, - durationMs: animations.length > 0 ? Math.max(1, maxDuration || 1000) : 4000, - loopsIndefinitely, + durationMs: animationSummary.durationMs, + loopsIndefinitely: animationSummary.loopsIndefinitely, ...(viewBox ? { viewBox } : {}) } } diff --git a/src/renderer/src/locales/en/common.json b/src/renderer/src/locales/en/common.json index 1cb682242..a10182abc 100644 --- a/src/renderer/src/locales/en/common.json +++ b/src/renderer/src/locales/en/common.json @@ -394,6 +394,57 @@ "canvasCritiquePromptWithFindings": "Fix the {{count}} canvas critique finding(s). Keep the current direction, but repair design-system binding, contrast, tap target, spacing, hierarchy, and alignment issues with focused canvas operations.", "canvasCritiquePromptClean": "Critique the current canvas and improve visual hierarchy, alignment, spacing, contrast, and design-system consistency. Keep the current direction and use focused canvas operations.", "canvasRotateHandle": "Rotate selection", + "canvasMotionMode": "Motion", + "canvasMotionDock": "Motion timeline", + "canvasMotionCanvasTimeline": "Canvas timeline", + "canvasMotionFrameTimeline": "Frame timeline", + "canvasMotionContainer": "Container Motion", + "canvasMotionContainerHint": "Animate the selected layer as one canvas object", + "canvasMotionPlay": "Play", + "canvasMotionPause": "Pause", + "canvasMotionReset": "Reset", + "canvasMotionPlayhead": "Motion playhead", + "canvasMotionDuration": "Duration", + "canvasMotionPlaybackMode": "Playback mode", + "canvasMotionOnce": "Once", + "canvasMotionLoop": "Loop", + "canvasMotionPingPong": "Ping-pong", + "canvasMotionPlaybackRate": "Playback rate", + "canvasMotionZoom": "Zoom", + "canvasMotionTimelineZoom": "Timeline zoom", + "canvasMotionAutoKey": "Auto-key", + "canvasMotionClose": "Close Motion", + "canvasMotionReduced": "Reduced motion is enabled. Scrubbing remains available; automatic playback is paused.", + "canvasMotionReducedShort": "Automatic playback is disabled by reduced motion.", + "canvasMotionPresetFade": "Fade", + "canvasMotionPresetMove": "Move", + "canvasMotionPresetScale": "Scale", + "canvasMotionPresetRotate": "Rotate", + "canvasMotionAddProperty": "Add property", + "canvasMotionPropertyX": "X", + "canvasMotionPropertyY": "Y", + "canvasMotionPropertyRotate": "Rotate", + "canvasMotionPropertyScaleX": "Scale X", + "canvasMotionPropertyScaleY": "Scale Y", + "canvasMotionPropertyOpacity": "Opacity", + "canvasMotionAddKeyframe": "Add keyframe at playhead", + "canvasMotionDeleteTrack": "Delete track", + "canvasMotionEmptySelected": "Apply a preset or add a property to start animating the selected layer.", + "canvasMotionEmpty": "Select a layer or frame, then add a Motion preset.", + "canvasMotionSvgLane": "SVG internal animation", + "canvasMotionSvgInspecting": "Inspecting SVG animation…", + "canvasMotionSvgMissing": "The SVG source is missing.", + "canvasMotionSvgInvalid": "The SVG animation could not be inspected.", + "canvasMotionSvgNone": "No internal SVG animation was detected.", + "canvasMotionSvgCount": "{{count}} animations", + "canvasMotionSvgLooping": "Looping", + "canvasMotionSvgCycle": "{{duration}} representative cycle", + "canvasMotionSvgGuidance": "Preview-only content animation. Container Motion presets move, scale, rotate, or fade the whole SVG.", + "canvasMotionSvgPlay": "Play SVG internal animation", + "canvasMotionSvgPause": "Pause SVG internal animation", + "canvasMotionSvgRestart": "Restart SVG internal animation", + "canvasMotionSvgPlayhead": "SVG internal animation playhead", + "canvasMotionSvgRate": "SVG internal animation rate", "designPrototypePlay": "Play prototype", "designPrototypePlayUnavailable": "Create at least one screen before playing the prototype", "designPrototypeBack": "Back", diff --git a/src/renderer/src/locales/zh/common.json b/src/renderer/src/locales/zh/common.json index 0b654957d..7ed937d6b 100644 --- a/src/renderer/src/locales/zh/common.json +++ b/src/renderer/src/locales/zh/common.json @@ -394,6 +394,57 @@ "canvasCritiquePromptWithFindings": "修复刚刚发现的 {{count}} 个画布审查问题。保持当前设计方向,用聚焦的画布操作修复设计系统绑定、对比度、点击热区、间距、层级和对齐问题。", "canvasCritiquePromptClean": "审查当前画布,并优化视觉层级、对齐、间距、对比度和设计系统一致性。保持当前设计方向,用聚焦的画布操作完成。", "canvasRotateHandle": "旋转选区", + "canvasMotionMode": "Motion", + "canvasMotionDock": "Motion 时间线", + "canvasMotionCanvasTimeline": "画布时间线", + "canvasMotionFrameTimeline": "画板时间线", + "canvasMotionContainer": "整体 Motion", + "canvasMotionContainerHint": "把所选图层作为一个整体添加动画", + "canvasMotionPlay": "播放", + "canvasMotionPause": "暂停", + "canvasMotionReset": "重置", + "canvasMotionPlayhead": "Motion 播放头", + "canvasMotionDuration": "时长", + "canvasMotionPlaybackMode": "播放模式", + "canvasMotionOnce": "播放一次", + "canvasMotionLoop": "循环", + "canvasMotionPingPong": "往返", + "canvasMotionPlaybackRate": "播放速度", + "canvasMotionZoom": "缩放", + "canvasMotionTimelineZoom": "时间线缩放", + "canvasMotionAutoKey": "自动关键帧", + "canvasMotionClose": "关闭 Motion", + "canvasMotionReduced": "已启用减少动态效果。仍可拖动播放头预览,但自动播放已暂停。", + "canvasMotionReducedShort": "减少动态效果已关闭自动播放。", + "canvasMotionPresetFade": "淡入淡出", + "canvasMotionPresetMove": "移动", + "canvasMotionPresetScale": "缩放", + "canvasMotionPresetRotate": "旋转", + "canvasMotionAddProperty": "添加属性", + "canvasMotionPropertyX": "X", + "canvasMotionPropertyY": "Y", + "canvasMotionPropertyRotate": "旋转", + "canvasMotionPropertyScaleX": "水平缩放", + "canvasMotionPropertyScaleY": "垂直缩放", + "canvasMotionPropertyOpacity": "不透明度", + "canvasMotionAddKeyframe": "在播放头处添加关键帧", + "canvasMotionDeleteTrack": "删除轨道", + "canvasMotionEmptySelected": "应用预设或添加属性,开始为所选图层制作动画。", + "canvasMotionEmpty": "先选择一个图层或画板,再添加 Motion 预设。", + "canvasMotionSvgLane": "SVG 内容动画", + "canvasMotionSvgInspecting": "正在读取 SVG 内部动画…", + "canvasMotionSvgMissing": "找不到 SVG 源文件。", + "canvasMotionSvgInvalid": "无法读取 SVG 内部动画。", + "canvasMotionSvgNone": "未检测到 SVG 内部动画。", + "canvasMotionSvgCount": "{{count}} 条动画", + "canvasMotionSvgLooping": "循环播放", + "canvasMotionSvgCycle": "代表周期 {{duration}}", + "canvasMotionSvgGuidance": "这里只预览 SVG 内容自身的动画;“整体 Motion”控制整个 SVG 的移动、缩放、旋转或淡入淡出。", + "canvasMotionSvgPlay": "播放 SVG 内容动画", + "canvasMotionSvgPause": "暂停 SVG 内容动画", + "canvasMotionSvgRestart": "重新播放 SVG 内容动画", + "canvasMotionSvgPlayhead": "SVG 内容动画播放头", + "canvasMotionSvgRate": "SVG 内容动画速度", "designPrototypePlay": "播放原型", "designPrototypePlayUnavailable": "先创建至少一个 screen,才能播放原型", "designPrototypeBack": "返回", From de37d0f7aa658aee9389f7b92491d2b718a74e87 Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Tue, 14 Jul 2026 02:51:41 +0800 Subject: [PATCH 038/110] fix(chat): prevent repeated compaction projections Avoid no-progress re-compaction, coalesce automatic markers per turn, and reconcile delayed stable user events with optimistic bubbles.\n\nFixes #881 --- kun/src/loop/compaction-history.test.ts | 37 ++++++++- kun/src/loop/compaction-history.ts | 38 +++++++-- kun/src/loop/context-compactor.test.ts | 34 +++++++- kun/src/loop/context-compactor.ts | 19 +++++ .../loop/history-compaction-service.test.ts | 39 ++++++++++ kun/src/server/routes/threads.ts | 3 +- src/renderer/src/agent/kun-mapper.ts | 3 + src/renderer/src/agent/types.ts | 2 + .../src/store/chat-projection-reducer.test.ts | 78 +++++++++++++++++++ .../src/store/chat-projection-reducer.ts | 24 +++++- .../store/chat-store-runtime-helpers.test.ts | 1 + .../src/store/chat-store-runtime-helpers.ts | 42 +++++++++- 12 files changed, 307 insertions(+), 13 deletions(-) diff --git a/kun/src/loop/compaction-history.test.ts b/kun/src/loop/compaction-history.test.ts index cb41df093..c86d118ad 100644 --- a/kun/src/loop/compaction-history.test.ts +++ b/kun/src/loop/compaction-history.test.ts @@ -52,7 +52,6 @@ describe('compaction history projection', () => { expect(visible.map((item) => item.id)).toEqual([ 'item_head_a', 'item_head_b', - 'compaction_previous', 'compaction_next', 'item_tail_a', 'item_tail_b' @@ -64,6 +63,42 @@ describe('compaction history projection', () => { ]) }) + it('preserves manual compaction markers when coalescing automatic markers', () => { + const threadId = 'thread_1' + const turnId = 'turn_1' + const manualSummary = makeCompactionItem({ + id: 'compaction_manual', + threadId, + turnId, + summary: 'manual summary', + replacedTokens: 100, + pinnedConstraints: [], + auto: false + }) + const automaticSummary = makeCompactionItem({ + id: 'compaction_auto', + threadId, + turnId, + summary: 'automatic summary', + replacedTokens: 200, + pinnedConstraints: [], + auto: true + }) + const tail = makeUserItem({ id: 'item_tail', threadId, turnId, text: 'recent' }) + + const visible = insertCompactionIntoVisibleHistory({ + visibleItems: [manualSummary, tail], + compactedItems: [automaticSummary, tail], + summaryItem: automaticSummary + }) + + expect(visible.map((item) => item.id)).toEqual([ + 'compaction_manual', + 'compaction_auto', + 'item_tail' + ]) + }) + it('moves a turn-bucket compaction summary to the end so the UI renders it inside the latest turn', () => { const threadId = 'thread_1' const turnId = 'turn_3' diff --git a/kun/src/loop/compaction-history.ts b/kun/src/loop/compaction-history.ts index 094bb6ca5..566fb0de8 100644 --- a/kun/src/loop/compaction-history.ts +++ b/kun/src/loop/compaction-history.ts @@ -16,14 +16,22 @@ export function insertCompactionIntoVisibleHistory(input: { summaryItem: TurnItem }): TurnItem[] { const summaryIndex = input.compactedItems.findIndex((item) => item.id === input.summaryItem.id) - if (summaryIndex < 0) return replaceOrAppendItem(input.visibleItems, input.summaryItem) + if (summaryIndex < 0) { + return replaceOrAppendItem( + coalesceAutomaticCompactions(input.visibleItems, input.summaryItem), + input.summaryItem + ) + } const tailIds = new Set( input.compactedItems .slice(summaryIndex + 1) .map((item) => item.id) ) - const withoutSummary = input.visibleItems.filter((item) => item.id !== input.summaryItem.id) + const withoutSummary = coalesceAutomaticCompactions( + input.visibleItems, + input.summaryItem + ).filter((item) => item.id !== input.summaryItem.id) if (tailIds.size === 0) return [...withoutSummary, input.summaryItem] const insertIndex = withoutSummary.findIndex((item) => tailIds.has(item.id)) @@ -55,17 +63,18 @@ function replaceOrAppendItem(items: readonly TurnItem[], item: TurnItem): TurnIt * shows it inside the turn where the compaction actually happened. */ export function placeCompactionsAtTurnEnd(items: readonly TurnItem[]): TurnItem[] { + const coalesced = coalesceAutomaticCompactions(items) let hasTrailingCompaction = false - for (const item of items) { + for (const item of coalesced) { if (item.kind === 'compaction' && item.replacedTokens > 0) { hasTrailingCompaction = true break } } - if (!hasTrailingCompaction) return [...items] + if (!hasTrailingCompaction) return coalesced const rest: TurnItem[] = [] const trailing: TurnItem[] = [] - for (const item of items) { + for (const item of coalesced) { if (item.kind === 'compaction' && item.replacedTokens > 0) { trailing.push(item) } else { @@ -74,3 +83,22 @@ export function placeCompactionsAtTurnEnd(items: readonly TurnItem[]): TurnItem[ } return [...rest, ...trailing] } + +/** Keep manual markers and only the newest automatic marker for each turn. */ +function coalesceAutomaticCompactions( + items: readonly TurnItem[], + incoming?: TurnItem +): TurnItem[] { + const latestAutoByTurn = new Map() + for (const item of [...items, ...(incoming ? [incoming] : [])]) { + if (isAutomaticCompaction(item)) latestAutoByTurn.set(item.turnId, item.id) + } + if (latestAutoByTurn.size === 0) return [...items] + return items.filter((item) => + !isAutomaticCompaction(item) || latestAutoByTurn.get(item.turnId) === item.id + ) +} + +function isAutomaticCompaction(item: TurnItem): boolean { + return item.kind === 'compaction' && item.replacedTokens > 0 && item.auto !== false +} diff --git a/kun/src/loop/context-compactor.test.ts b/kun/src/loop/context-compactor.test.ts index b93b638b3..7b77def71 100644 --- a/kun/src/loop/context-compactor.test.ts +++ b/kun/src/loop/context-compactor.test.ts @@ -1,10 +1,42 @@ import { describe, expect, it } from 'vitest' import { createImmutablePrefix } from '../cache/immutable-prefix.js' import type { TurnItem } from '../contracts/items.js' -import { makeAssistantTextItem, makeUserItem } from '../domain/item.js' +import { makeAssistantTextItem, makeCompactionItem, makeUserItem } from '../domain/item.js' import { ContextCompactor } from './context-compactor.js' describe('ContextCompactor', () => { + it('does not replace an existing summary when no new history can be folded', () => { + const threadId = 'thr_compaction_no_progress' + const turnId = 'turn_compaction_no_progress' + const previousSummary = makeCompactionItem({ + id: 'compaction_previous', + threadId, + turnId, + summary: 'Existing handoff summary', + replacedTokens: 50_000, + pinnedConstraints: [], + auto: true + }) + const recent = makeUserItem({ + id: 'item_recent', + threadId, + turnId, + text: 'Keep this recent request verbatim.' + }) + + const result = new ContextCompactor().compact({ + threadId, + turnId, + history: [previousSummary, recent], + prefix: createImmutablePrefix(), + keepRecent: 1, + mode: 'force' + }) + + expect(result.replacedTokens).toBe(0) + expect(result.next).toEqual([previousSummary, recent]) + }) + it('preserves numbered problem outlines when heuristic compaction is the fallback', () => { const threadId = 'thr_compaction_outline' const turnId = 'turn_compaction_outline' diff --git a/kun/src/loop/context-compactor.ts b/kun/src/loop/context-compactor.ts index da9f56115..5d285c779 100644 --- a/kun/src/loop/context-compactor.ts +++ b/kun/src/loop/context-compactor.ts @@ -187,6 +187,25 @@ export class ContextCompactor { : repairTailStartForToolResults(history, history.length - keepRecent) const head = history.slice(0, tailStart) const tail = history.slice(tailStart) + // Re-summarizing only the previous summary cannot reclaim any conversation + // history. Provider usage counters can remain above a threshold after a + // successful compaction (notably when cached tokens are cumulative), which + // used to create a fresh compaction item on every following model step. + if (head.length > 0 && head.every((item) => item.kind === 'compaction')) { + return { + next: [...frozen, ...history], + summaryItem: makeCompactionItem({ + id: `compaction_${input.turnId}_noop`, + turnId: input.turnId, + threadId: input.threadId, + summary: 'no new history to compact', + replacedTokens: 0, + pinnedConstraints: input.prefix.pinnedConstraints, + auto: input.auto + }), + replacedTokens: 0 + } + } const replacedTokens = this.estimator.estimateItems(head) const sourceDigest = computeShortHash(compactedItemsDigestSource(head)) const digestMarker = createToolDigestMarker(sourceDigest) diff --git a/kun/src/loop/history-compaction-service.test.ts b/kun/src/loop/history-compaction-service.test.ts index b36b61afb..a98efba26 100644 --- a/kun/src/loop/history-compaction-service.test.ts +++ b/kun/src/loop/history-compaction-service.test.ts @@ -150,6 +150,45 @@ describe('HistoryCompactionService', () => { ]) }) + it('does not emit another automatic marker when only the prior summary is foldable', async () => { + const sessionStore = new InMemorySessionStore() + await seedLongHistory(sessionStore, 'repeat_guard') + const rewriteThreadItemsFromSession = vi.fn(async () => undefined) + const service = new HistoryCompactionService({ + sessionStore, + compactor: new ContextCompactor({ softThreshold: 1, hardThreshold: 2 }), + prefix: createImmutablePrefix({ systemPrompt: 'stable prefix' }), + model: silentModel(), + usage: new UsageService(), + events: createEvents(sessionStore), + ids: new SequentialIdGenerator(), + telemetry: { + hydratePromptPressureIfCold: async () => undefined, + consumePromptPressure: () => undefined + }, + recordGoalUsage: async () => undefined, + rewriteThreadItemsFromSession + }) + const request = { + model: 'test-model', + signal: new AbortController().signal, + threadId, + turnId + } + + const compacted = await service.compactIfNeeded({ + ...request, + items: await sessionStore.loadItems(threadId) + }) + const unchanged = await service.compactIfNeeded({ ...request, items: compacted }) + + expect(unchanged).toEqual(compacted) + expect(rewriteThreadItemsFromSession).toHaveBeenCalledTimes(1) + const events = await sessionStore.loadEventsSince(threadId, 0) + expect(events.filter((event) => event.kind === 'compaction_completed')).toHaveLength(1) + expect((await sessionStore.loadItems(threadId)).filter((item) => item.kind === 'compaction')).toHaveLength(1) + }) + it('only consumes the pending prompt-pressure signal when no compaction is needed', async () => { const sessionStore = new InMemorySessionStore() const item = makeUserItem({ id: 'item_only', threadId, turnId, text: 'short' }) diff --git a/kun/src/server/routes/threads.ts b/kun/src/server/routes/threads.ts index bb8783c04..c30fe10f1 100644 --- a/kun/src/server/routes/threads.ts +++ b/kun/src/server/routes/threads.ts @@ -22,6 +22,7 @@ import type { SessionStore } from '../../ports/session-store.js' import type { UserInputGate } from '../../ports/user-input-gate.js' import type { Turn } from '../../contracts/turns.js' import type { TurnItem } from '../../contracts/items.js' +import { placeCompactionsAtTurnEnd } from '../../loop/compaction-history.js' /** * Handlers for the thread CRUD endpoints. The handlers accept a @@ -181,7 +182,7 @@ function hydrateThreadItemsFromSession(thread: ThreadRecord, items: TurnItem[]): const sessionTurnItems = itemsByTurn.get(turn.id) if (!sessionTurnItems) return turn changed = true - return { ...turn, items: sessionTurnItems } + return { ...turn, items: placeCompactionsAtTurnEnd(sessionTurnItems) } }) return changed ? { ...thread, turns } : thread } diff --git a/src/renderer/src/agent/kun-mapper.ts b/src/renderer/src/agent/kun-mapper.ts index f878c4d8e..80919367b 100644 --- a/src/renderer/src/agent/kun-mapper.ts +++ b/src/renderer/src/agent/kun-mapper.ts @@ -904,6 +904,7 @@ function compactionBlockFromItem(item: CoreTurnItemJson): ChatBlock { return { kind: 'compaction', id: item.id, + turnId: item.turnId, createdAt: itemCreatedAt(item), summary: item.summary?.trim() || 'Context compacted', status: item.status === 'failed' ? 'error' : 'success', @@ -1130,6 +1131,7 @@ function childLifecycleToolEventFromRuntimeEvent(event: CoreRuntimeEventJson): T function compactionFromItem(item: CoreTurnItemJson): CompactionEventPayload { return { itemId: item.id, + turnId: item.turnId, summary: item.summary?.trim() || 'Context compacted', status: item.status === 'failed' ? 'error' : item.status === 'running' ? 'running' : 'success', createdAt: itemCreatedAt(item), @@ -1165,6 +1167,7 @@ function compactionFromEvent( ): CompactionEventPayload { return { itemId: event.itemId ?? `compaction_${event.seq ?? Date.now()}`, + turnId: event.turnId, summary: event.summary ?? 'Context compacted', status, createdAt: event.timestamp, diff --git a/src/renderer/src/agent/types.ts b/src/renderer/src/agent/types.ts index c117e3365..1e1395e4a 100644 --- a/src/renderer/src/agent/types.ts +++ b/src/renderer/src/agent/types.ts @@ -234,6 +234,7 @@ export type ToolBlock = { export type CompactionBlock = { kind: 'compaction' id: string + turnId?: string createdAt?: string summary: string status: 'running' | 'success' | 'error' @@ -389,6 +390,7 @@ export type RuntimeErrorEventPayload = { export type CompactionEventPayload = { itemId: string + turnId?: string summary: string status: 'running' | 'success' | 'error' detail?: string diff --git a/src/renderer/src/store/chat-projection-reducer.test.ts b/src/renderer/src/store/chat-projection-reducer.test.ts index 6f994f33f..f56db2794 100644 --- a/src/renderer/src/store/chat-projection-reducer.test.ts +++ b/src/renderer/src/store/chat-projection-reducer.test.ts @@ -101,6 +101,84 @@ describe('chat projection reducer', () => { expect(projected.blocks).toHaveLength(2) }) + it('reconciles a delayed stable user event with its optimistic bubble', () => { + const createdAt = '2026-07-11T00:00:00.000Z' + const initial = { + ...state(), + busy: false, + currentTurnId: null, + currentTurnUserId: null, + turnStartedAtByUserId: {}, + blocks: [ + { + kind: 'user' as const, + id: 'u-optimistic', + createdAt, + text: '检查一下脚本并优化执行进度' + }, + { + kind: 'compaction' as const, + id: 'compaction_1', + status: 'success' as const, + summary: 'Existing summary' + } + ] + } + + const projected = project(initial, [{ + type: 'user_message_received', + payload: { + itemId: 'item_turn_1_user', + turnId: 'turn_1', + createdAt, + text: '分析脚本是否存在问题,并优化执行过程和进度。', + meta: { displayText: '检查一下脚本并优化执行进度' } + } + }]) + + expect(projected.blocks).toHaveLength(2) + expect(projected.blocks[0]).toMatchObject({ + kind: 'user', + id: 'item_turn_1_user', + meta: { displayText: '检查一下脚本并优化执行进度' } + }) + expect(projected.blocks[1]).toMatchObject({ kind: 'compaction', id: 'compaction_1' }) + }) + + it('keeps only the latest automatic compaction marker for a turn', () => { + const projected = project(state(), [ + { + type: 'compaction_updated', + payload: { + itemId: 'compaction_1', + turnId: 'turn_1', + summary: 'first summary', + status: 'success', + auto: true + } + }, + { + type: 'compaction_updated', + payload: { + itemId: 'compaction_2', + turnId: 'turn_1', + summary: 'new summary', + status: 'success', + auto: true + } + } + ]) + + expect(projected.blocks).toEqual([ + expect.objectContaining({ + kind: 'compaction', + id: 'compaction_2', + turnId: 'turn_1', + summary: 'new summary' + }) + ]) + }) + it('retires a pending approval after its runtime resolution is projected', () => { const projected = project(state(), [ { diff --git a/src/renderer/src/store/chat-projection-reducer.ts b/src/renderer/src/store/chat-projection-reducer.ts index e133b3881..bcfa910c5 100644 --- a/src/renderer/src/store/chat-projection-reducer.ts +++ b/src/renderer/src/store/chat-projection-reducer.ts @@ -10,6 +10,7 @@ import { isBackgroundShellNoticeUserMessage } from '@shared/background-shell-not import type { ChatState } from './chat-store-types' import { isOptimisticUserBlockId, + matchingOptimisticUserBlockId, reconcileOptimisticUserBlock, upsertUserBlock } from './chat-store-runtime-helpers' @@ -70,17 +71,22 @@ export function reduceChatProjection( const baseBlocks = flushed.blocks ?? state.blocks const optimisticUserId = state.currentTurnUserId const backgroundNotice = isBackgroundShellNoticeUserMessage({ text: event.text, meta: event.meta }) - const reconcileOptimistic = Boolean( + const currentOptimisticUserId = !backgroundNotice && optimisticUserId && optimisticUserId !== event.itemId && isOptimisticUserBlockId(optimisticUserId) && baseBlocks.some((block) => block.kind === 'user' && block.id === optimisticUserId) + ? optimisticUserId + : null + const optimisticMatchId = currentOptimisticUserId ?? ( + backgroundNotice ? null : matchingOptimisticUserBlockId(baseBlocks, event) ) - const reconciledBlocks = reconcileOptimistic && optimisticUserId + const reconcileOptimistic = Boolean(optimisticMatchId && optimisticMatchId !== event.itemId) + const reconciledBlocks = reconcileOptimistic && optimisticMatchId ? reconcileOptimisticUserBlock( baseBlocks, - optimisticUserId, + optimisticMatchId, event.itemId, event.text, event.modelLabel @@ -348,6 +354,7 @@ export function reduceChatProjection( const blocks = [...state.blocks] blocks[index] = { ...current, + turnId: event.turnId ?? current.turnId, summary: event.summary || current.summary, status: event.status, detail: event.detail ?? current.detail, @@ -360,12 +367,21 @@ export function reduceChatProjection( } const flushed = flushLiveProjection(state, context.now) const baseBlocks = flushed.blocks ?? state.blocks + const visibleBlocks = event.auto !== false && event.turnId + ? baseBlocks.filter((block) => !( + block.kind === 'compaction' && + block.id !== event.itemId && + block.auto !== false && + block.turnId === event.turnId + )) + : baseBlocks return { ...base, ...flushed, - blocks: [...baseBlocks, { + blocks: [...visibleBlocks, { kind: 'compaction', id: event.itemId, + turnId: event.turnId, createdAt: event.createdAt ?? new Date(context.now).toISOString(), summary: event.summary, status: event.status, diff --git a/src/renderer/src/store/chat-store-runtime-helpers.test.ts b/src/renderer/src/store/chat-store-runtime-helpers.test.ts index c3c82ac29..b3adc8001 100644 --- a/src/renderer/src/store/chat-store-runtime-helpers.test.ts +++ b/src/renderer/src/store/chat-store-runtime-helpers.test.ts @@ -16,6 +16,7 @@ import { describe('chat store runtime helpers', () => { it('detects optimistic user block ids', () => { expect(isOptimisticUserBlockId('u-123')).toBe(true) + expect(isOptimisticUserBlockId('q-123-0')).toBe(true) expect(isOptimisticUserBlockId('item_turn_abc_user')).toBe(false) }) diff --git a/src/renderer/src/store/chat-store-runtime-helpers.ts b/src/renderer/src/store/chat-store-runtime-helpers.ts index 9bfb8289b..fcbbfd03f 100644 --- a/src/renderer/src/store/chat-store-runtime-helpers.ts +++ b/src/renderer/src/store/chat-store-runtime-helpers.ts @@ -166,7 +166,47 @@ function mergeRuntimeDisclosureMeta( } export function isOptimisticUserBlockId(id: string): boolean { - return id.startsWith('u-') + return id.startsWith('u-') || id.startsWith('q-') +} + +/** + * Match a late/replayed stable user item to the client bubble created when the + * request was sent. Stable SSE events may arrive after turn settlement, when + * `currentTurnUserId` has already been cleared, so identity alone is not + * available. Display text plus near-identical creation time keeps the fallback + * scoped to the same submission and still allows intentionally repeated text. + */ +export function matchingOptimisticUserBlockId( + blocks: ChatBlock[], + event: UserMessageEventPayload +): string | null { + const expectedTexts = new Set( + [event.text, event.meta?.displayText] + .map((text) => text?.trim()) + .filter((text): text is string => Boolean(text)) + ) + const eventTime = timestampMs(event.createdAt) + for (let index = blocks.length - 1; index >= 0; index -= 1) { + const block = blocks[index] + if (block.kind !== 'user' || !isOptimisticUserBlockId(block.id)) continue + if (!expectedTexts.has(block.text.trim())) continue + const blockTurnId = block.turnId?.trim() || block.meta?.turnId?.trim() + if (event.turnId && blockTurnId) { + if (event.turnId !== blockTurnId) continue + return block.id + } + const blockTime = timestampMs(block.createdAt) + if (eventTime !== null && blockTime !== null && Math.abs(eventTime - blockTime) <= 60_000) { + return block.id + } + } + return null +} + +function timestampMs(value: string | undefined): number | null { + if (!value) return null + const parsed = Date.parse(value) + return Number.isFinite(parsed) ? parsed : null } export function reconcileOptimisticUserBlock( From 28ea984d5a7f580c496fd1bced63647f131bcf40 Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Tue, 14 Jul 2026 02:55:07 +0800 Subject: [PATCH 039/110] fix(design): clarify motion track hierarchy --- .../design/canvas/CanvasMotionDock.test.ts | 7 + .../design/canvas/CanvasMotionDock.tsx | 299 +++++++++++------- .../design/canvas/CanvasMotionSvgPreview.tsx | 106 ++++--- .../design/canvas/CanvasViewport.tsx | 13 +- src/renderer/src/locales/en/common.json | 3 + src/renderer/src/locales/zh/common.json | 3 + 6 files changed, 273 insertions(+), 158 deletions(-) diff --git a/src/renderer/src/components/design/canvas/CanvasMotionDock.test.ts b/src/renderer/src/components/design/canvas/CanvasMotionDock.test.ts index 3a7867340..49ca00992 100644 --- a/src/renderer/src/components/design/canvas/CanvasMotionDock.test.ts +++ b/src/renderer/src/components/design/canvas/CanvasMotionDock.test.ts @@ -271,6 +271,13 @@ describe('CanvasMotionDock', () => { expect(json).toContain('24 animations') expect(json).toContain('Looping') expect(json).toMatch(/(?:5000ms|5\.0s) representative cycle/) + expect(json).toContain('Preview only') + expect(renderer.root.findByProps({ + 'aria-label': 'Preview-only content animation. Container Motion presets move, scale, rotate, or fade the whole SVG.' + })).toBeDefined() + expect(renderer.root.findByProps({ 'data-motion-transport': 'container' })).toBeDefined() + expect(renderer.root.findByProps({ 'data-motion-track-grid': true })).toBeDefined() + expect(renderer.root.findByProps({ 'data-motion-track-kind': 'svg-content' })).toBeDefined() expect(json).not.toContain('Apply a preset or add a property to start animating the selected layer.') expect(json).not.toContain('Select a layer or frame, then add a Motion preset.') expect(renderer.root.findByProps({ 'aria-label': 'Play' }).props.disabled).toBe(true) diff --git a/src/renderer/src/components/design/canvas/CanvasMotionDock.tsx b/src/renderer/src/components/design/canvas/CanvasMotionDock.tsx index 12f5bc839..c91f0807c 100644 --- a/src/renderer/src/components/design/canvas/CanvasMotionDock.tsx +++ b/src/renderer/src/components/design/canvas/CanvasMotionDock.tsx @@ -68,6 +68,8 @@ const PRESETS: Array<{ { preset: 'rotate', labelKey: 'canvasMotionPresetRotate', fallback: 'Rotate' } ] +const TIMELINE_TICKS = [0, 0.25, 0.5, 0.75, 1] as const + type DragState = { trackId: string keyframeId: string @@ -183,6 +185,7 @@ export function CanvasMotionDock(): ReactElement | null { const frameName = frameId === document.rootId ? t('canvasMotionCanvasTimeline', 'Canvas timeline') : shapeLabel(document.objects[frameId], t('canvasMotionFrameTimeline', 'Frame timeline')) + const playheadPercent = `${durationMs > 0 ? currentTimeMs / durationMs * 100 : 0}%` const lastFrameRef = useRef(null) useEffect(() => { @@ -386,7 +389,8 @@ export function CanvasMotionDock(): ReactElement | null {
{ const target = event.target as HTMLElement const editing = target.matches('input, textarea, select, button, [contenteditable="true"]') @@ -413,13 +417,23 @@ export function CanvasMotionDock(): ReactElement | null { }} tabIndex={-1} > -
- - {t('canvasMotionContainer', 'Container Motion')} - +
+
+ + + +
+
{t('canvasMotionMode', 'Motion')}
+
{frameName}
+
+
+ - + {Math.round(currentTimeMs)} / {Math.round(durationMs)} ms -
-
-
-
- {t('canvasMotionAddProperty', 'Add property')} -
-
+ +
+ + {t('canvasMotionAddProperty', 'Add property')} + {PROPERTY_LABELS.map(({ property, labelKey, fallback }) => ( ))}
- +
+
+
-
-
- {[0, 0.25, 0.5, 0.75, 1].map((ratio) => ( - - {Math.round(durationMs * ratio)} +
+
+
+ + {t('canvasMotionTracks', 'Tracks')} - ))} - 0 ? currentTimeMs / durationMs * 100 : 0}%` }} - /> + + {timeline?.tracks.length ?? 0} + +
+
+ {TIMELINE_TICKS.map((ratio) => ( + + {Math.round(durationMs * ratio)} + + ))} + +
{svgPreview ? ( @@ -593,77 +640,101 @@ export function CanvasMotionDock(): ReactElement | null { {timeline?.tracks.length ? timeline.tracks.map((track, trackIndex) => { const selected = selectedTrackId === track.id const startsLayer = trackIndex === 0 || timeline.tracks[trackIndex - 1]?.targetShapeId !== track.targetShapeId + const property = PROPERTY_LABELS.find((item) => item.property === track.property) + const propertyLabel = t(property?.labelKey ?? track.property, property?.fallback ?? track.property) return (
{startsLayer ? ( -
- {shapeLabel(document.objects[track.targetShapeId], track.targetShapeId)} +
+
+ + + {shapeLabel(document.objects[track.targetShapeId], track.targetShapeId)} + +
+
+ {t('canvasMotionContainer', 'Container Motion')} +
) : null}
useCanvasMotionStore.getState().selectKeyframe(track.id, null)} > - -
- - {track.keyframes.map((keyframe) => { - const displayedTime = drag?.trackId === track.id && drag.keyframeId === keyframe.id - ? trackTime(track, { ...keyframe, timeMs: drag.timeMs }) - : trackTime(track, keyframe) - const keyframeSelected = selectedKeyframeId === keyframe.id && selected - return ( - -
+
+ {TIMELINE_TICKS.map((ratio) => ( + + ))} + + + {track.keyframes.map((keyframe) => { + const displayedTime = drag?.trackId === track.id && drag.keyframeId === keyframe.id + ? trackTime(track, { ...keyframe, timeMs: drag.timeMs }) + : trackTime(track, keyframe) + const keyframeSelected = selectedKeyframeId === keyframe.id && selected + return ( + +
) }) : svgPreview ? null : ( -
- {selectedShapeIds.length > 0 - ? t('canvasMotionEmptySelected', 'Apply a preset or add a property to start animating the selected layer.') - : t('canvasMotionEmpty', 'Select a layer or frame, then add a Motion preset.')} +
+
+
+ {selectedShapeIds.length > 0 + ? t('canvasMotionEmptySelected', 'Apply a preset or add a property to start animating the selected layer.') + : t('canvasMotionEmpty', 'Select a layer or frame, then add a Motion preset.')} +
)}
diff --git a/src/renderer/src/components/design/canvas/CanvasMotionSvgPreview.tsx b/src/renderer/src/components/design/canvas/CanvasMotionSvgPreview.tsx index 5172b2783..5de21c56d 100644 --- a/src/renderer/src/components/design/canvas/CanvasMotionSvgPreview.tsx +++ b/src/renderer/src/components/design/canvas/CanvasMotionSvgPreview.tsx @@ -32,53 +32,55 @@ export function CanvasMotionSvgPreview({ : preview.animationCount === 0 ? t('canvasMotionSvgNone', 'No internal SVG animation was detected.') : '' + const guidance = t( + 'canvasMotionSvgGuidance', + 'Preview-only content animation. Container Motion presets move, scale, rotate, or fade the whole SVG.' + ) return (
-
-
- +
+
+ + + {t('canvasMotionSvgLane', 'SVG internal animation')} +
-
-
- - {t('canvasMotionSvgLane', 'SVG internal animation')} - - {hasAnimations ? ( - <> - - {t('canvasMotionSvgCount', '{{count}} animations', { count: preview.animationCount })} - - {preview.loopsIndefinitely ? ( - - - {t('canvasMotionSvgLooping', 'Looping')} - - ) : null} - - {t('canvasMotionSvgCycle', '{{duration}} representative cycle', { - duration: formatTime(preview.durationMs) - })} +
+ {hasAnimations ? ( + <> + + {t('canvasMotionSvgCount', '{{count}} animations', { count: preview.animationCount })} + + {preview.loopsIndefinitely ? ( + + + {t('canvasMotionSvgLooping', 'Looping')} - - ) : null} -
-

- {hasAnimations - ? t( - 'canvasMotionSvgGuidance', - 'Preview-only content animation. Container Motion presets move, scale, rotate, or fade the whole SVG.' - ) - : status} -

+ ) : null} + + {t('canvasMotionSvgCycle', '{{duration}} representative cycle', { + duration: formatTime(preview.durationMs) + })} + + + ) : ( + {status} + )} +
+
+ +
+
{hasAnimations ? ( -
+ <> - + + {preview.title} + + + {t('canvasMotionSvgPreviewOnly', 'Preview only')} + + + ) : ( + {preview.title} + )} +
+
+ {hasAnimations ? ( + <> + {formatTime(displayedTime)} / {formatTime(preview.durationMs)} -
- ) : null} + + ) : ( + {status} + )}
diff --git a/src/renderer/src/components/design/canvas/CanvasViewport.tsx b/src/renderer/src/components/design/canvas/CanvasViewport.tsx index 0ec586d72..6b75149d5 100644 --- a/src/renderer/src/components/design/canvas/CanvasViewport.tsx +++ b/src/renderer/src/components/design/canvas/CanvasViewport.tsx @@ -513,7 +513,12 @@ export function CanvasViewport({ ref={rootRef} tabIndex={surface === 'code' ? -1 : undefined} className="ds-no-drag relative h-full w-full overflow-hidden bg-[#f8fafc] text-[#1e1e1e] outline-none dark:bg-[#111318] dark:text-[#e9ecef]" - style={{ '--canvas-bottom-ui-inset': designMotionOpen ? '262px' : '16px' } as React.CSSProperties} + style={{ + '--canvas-motion-dock-height': '264px', + '--canvas-bottom-ui-inset': designMotionOpen + ? 'calc(var(--canvas-motion-dock-height) + 16px)' + : '16px' + } as React.CSSProperties} >
{surface === 'design' ? : null} diff --git a/src/renderer/src/locales/en/common.json b/src/renderer/src/locales/en/common.json index a10182abc..885fc40bb 100644 --- a/src/renderer/src/locales/en/common.json +++ b/src/renderer/src/locales/en/common.json @@ -400,6 +400,8 @@ "canvasMotionFrameTimeline": "Frame timeline", "canvasMotionContainer": "Container Motion", "canvasMotionContainerHint": "Animate the selected layer as one canvas object", + "canvasMotionPresets": "Presets", + "canvasMotionTracks": "Tracks", "canvasMotionPlay": "Play", "canvasMotionPause": "Pause", "canvasMotionReset": "Reset", @@ -445,6 +447,7 @@ "canvasMotionSvgRestart": "Restart SVG internal animation", "canvasMotionSvgPlayhead": "SVG internal animation playhead", "canvasMotionSvgRate": "SVG internal animation rate", + "canvasMotionSvgPreviewOnly": "Preview only", "designPrototypePlay": "Play prototype", "designPrototypePlayUnavailable": "Create at least one screen before playing the prototype", "designPrototypeBack": "Back", diff --git a/src/renderer/src/locales/zh/common.json b/src/renderer/src/locales/zh/common.json index 7ed937d6b..69f850de8 100644 --- a/src/renderer/src/locales/zh/common.json +++ b/src/renderer/src/locales/zh/common.json @@ -400,6 +400,8 @@ "canvasMotionFrameTimeline": "画板时间线", "canvasMotionContainer": "整体 Motion", "canvasMotionContainerHint": "把所选图层作为一个整体添加动画", + "canvasMotionPresets": "预设", + "canvasMotionTracks": "轨道", "canvasMotionPlay": "播放", "canvasMotionPause": "暂停", "canvasMotionReset": "重置", @@ -445,6 +447,7 @@ "canvasMotionSvgRestart": "重新播放 SVG 内容动画", "canvasMotionSvgPlayhead": "SVG 内容动画播放头", "canvasMotionSvgRate": "SVG 内容动画速度", + "canvasMotionSvgPreviewOnly": "仅预览", "designPrototypePlay": "播放原型", "designPrototypePlayUnavailable": "先创建至少一个 screen,才能播放原型", "designPrototypeBack": "返回", From d843b91f56481dd3380b950c3a56edcc7dd89e3e Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Tue, 14 Jul 2026 03:12:36 +0800 Subject: [PATCH 040/110] fix(loop): force final answer after empty tool retries --- kun/src/loop/continuation-instructions.ts | 14 +++- kun/src/loop/model-step-service.ts | 23 ++++--- .../loop/round-outcome-coordinator.test.ts | 5 +- kun/src/loop/round-outcome-coordinator.ts | 6 +- kun/tests/loop.test.ts | 67 ++++++++++++++++++- 5 files changed, 100 insertions(+), 15 deletions(-) diff --git a/kun/src/loop/continuation-instructions.ts b/kun/src/loop/continuation-instructions.ts index 3343bd0b3..70bcb66d3 100644 --- a/kun/src/loop/continuation-instructions.ts +++ b/kun/src/loop/continuation-instructions.ts @@ -45,7 +45,8 @@ export function goalContinuationInstruction(goal: ThreadGoal | undefined): strin const GOAL_NO_TOOL_REPEAT_SIMILARITY = 0.85 const GOAL_NO_TOOL_REPEAT_MIN_LENGTH = 12 export const GOAL_NO_TOOL_REPEAT_MAX_RECOVERY_STEPS = 3 -export const EMPTY_POST_TOOL_MAX_RECOVERY_STEPS = 1 +export const EMPTY_POST_TOOL_FINAL_ANSWER_RECOVERY_STEP = 2 +export const EMPTY_POST_TOOL_MAX_RECOVERY_STEPS = EMPTY_POST_TOOL_FINAL_ANSWER_RECOVERY_STEP export function goalNoToolRecoveryInstruction(recoveryStep: number): string { return [ @@ -58,7 +59,16 @@ export function goalNoToolRecoveryInstruction(recoveryStep: number): string { ].join('\n') } -export function emptyPostToolRecoveryInstruction(): string { +export function emptyPostToolRecoveryInstruction(recoveryStep: number): string { + if (recoveryStep >= EMPTY_POST_TOOL_FINAL_ANSWER_RECOVERY_STEP) { + return [ + 'Tool final-answer recovery:', + '- The model has repeatedly ended with an empty response after tool execution.', + '- Tool calling is disabled for this recovery request.', + '- Inspect the completed tool results and provide a clear, non-empty final answer now.', + '- Summarize what succeeded, what failed, and any next step the user needs to take.' + ].join('\n') + } return [ 'Tool continuation recovery:', '- The previous model response ended without a final answer after tool execution.', diff --git a/kun/src/loop/model-step-service.ts b/kun/src/loop/model-step-service.ts index dc8e66071..372ebb475 100644 --- a/kun/src/loop/model-step-service.ts +++ b/kun/src/loop/model-step-service.ts @@ -29,6 +29,7 @@ import { buildToolPreferenceInstruction } from '../prompt/kun-system-prompt.js' import { effectiveHistoryAfterLatestCompaction } from './compaction-history.js' import { resolveCoherentProviderAccount } from './compaction-summary.js' import { + EMPTY_POST_TOOL_FINAL_ANSWER_RECOVERY_STEP, emptyPostToolRecoveryInstruction, hasSuccessfulCreatePlanResult, userInputUnavailableInstruction @@ -411,6 +412,10 @@ export class ModelStepService { createPlanSatisfied, stepIndex }) + const emptyPostToolRecoveryStep = this.deps.roundOutcome.emptyPostToolRecoverySteps(turnId) + const forceFinalAnswerRecovery = + emptyPostToolRecoveryStep >= EMPTY_POST_TOOL_FINAL_ANSWER_RECOVERY_STEP + const requestToolSpecs = forceFinalAnswerRecovery ? [] : effectiveToolSpecs const history = await this.deps.historyCompaction.compactIfNeeded({ items, model, @@ -419,7 +424,7 @@ export class ModelStepService { signal, threadId, turnId, - toolSpecs: effectiveToolSpecs, + toolSpecs: requestToolSpecs, reserveModelRequest: () => this.deps.budgetGate.reserveAdditionalModelRequest(threadId, turnId) }) if (signal.aborted) return 'aborted' @@ -466,7 +471,7 @@ export class ModelStepService { nowIso: this.deps.nowIso() }) : null - const toolPreferenceInstruction = buildToolPreferenceInstruction(tools) + const toolPreferenceInstruction = buildToolPreferenceInstruction(requestToolSpecs) const contextInstructions = [ ...(runtimeContextInstruction ? [runtimeContextInstruction] : []), ...(thread.extensionProfile?.instructionOverlay?.trim() @@ -482,22 +487,22 @@ export class ModelStepService { ? [goalRecoveryInstruction] : []), ...(activeTodoInstruction ? [activeTodoInstruction] : []), - ...(this.deps.roundOutcome.hasEmptyPostToolRecovery(turnId) - ? [emptyPostToolRecoveryInstruction()] + ...(emptyPostToolRecoveryStep > 0 + ? [emptyPostToolRecoveryInstruction(emptyPostToolRecoveryStep)] : []), ...imageGenerationReferenceInstructions({ imageAttachments: attachments.imageAttachments, textFallbacks: attachments.textFallbacks, workspace: thread?.workspace ?? '', - tools: effectiveToolSpecs + tools: requestToolSpecs }), ...memoryInstructions(memories), ...(skillResolution.catalogInstruction ? [skillResolution.catalogInstruction] : []), ...skillResolution.instructions, ...(userInputDisabled ? [userInputUnavailableInstruction()] : []), ...(toolPreferenceInstruction ? [toolPreferenceInstruction] : []), - ...(effectiveToolSpecs.some((tool) => tool.name === 'bash') ? [shellRuntimeInstruction()] : []), - ...(suggestVerification ? [verificationSuggestionInstruction()] : []), + ...(requestToolSpecs.some((tool) => tool.name === 'bash') ? [shellRuntimeInstruction()] : []), + ...(!forceFinalAnswerRecovery && suggestVerification ? [verificationSuggestionInstruction()] : []), ...(toolCatalogDriftMessage ? [toolCatalogDriftMessage] : []) ] await this.deps.recordPipelineStage(threadId, turnId, 'input_remembered', { @@ -525,8 +530,8 @@ export class ModelStepService { contextInstructions, history: forwardHistory, attachments, - tools: effectiveToolSpecs, - ...(requiredToolName ? { requiredToolName } : {}), + tools: requestToolSpecs, + ...(!forceFinalAnswerRecovery && requiredToolName ? { requiredToolName } : {}), ...(this.deps.tokenEconomy ? { tokenEconomy: this.deps.tokenEconomy } : {}), signal }) diff --git a/kun/src/loop/round-outcome-coordinator.test.ts b/kun/src/loop/round-outcome-coordinator.test.ts index f3b16e08c..e8d2104f5 100644 --- a/kun/src/loop/round-outcome-coordinator.test.ts +++ b/kun/src/loop/round-outcome-coordinator.test.ts @@ -212,7 +212,7 @@ describe('RoundOutcomeCoordinator', () => { expect(h.items[0]).toMatchObject({ kind: 'error', code: 'required_tool_missing' }) }) - it('allows one empty post-tool recovery before failing in event-then-item order', async () => { + it('allows continuation and final-answer recovery before failing in event-then-item order', async () => { const fileChange = makeToolCallItem({ id: 'file_change', threadId, @@ -227,6 +227,9 @@ describe('RoundOutcomeCoordinator', () => { await expect(h.coordinator.resolve(round)).resolves.toBe('continue') expect(h.coordinator.hasEmptyPostToolRecovery(turnId)).toBe(true) + expect(h.coordinator.emptyPostToolRecoverySteps(turnId)).toBe(1) + await expect(h.coordinator.resolve(round)).resolves.toBe('continue') + expect(h.coordinator.emptyPostToolRecoverySteps(turnId)).toBe(2) await expect(h.coordinator.resolve(round)).resolves.toBe('failed') expect(h.failures).toEqual([ expect.objectContaining({ code: 'empty_post_tool_continuation' }) diff --git a/kun/src/loop/round-outcome-coordinator.ts b/kun/src/loop/round-outcome-coordinator.ts index d37c6fbf3..abadde17d 100644 --- a/kun/src/loop/round-outcome-coordinator.ts +++ b/kun/src/loop/round-outcome-coordinator.ts @@ -84,6 +84,10 @@ export class RoundOutcomeCoordinator { return (this.emptyPostToolRecoveryStepsByTurn.get(turnId) ?? 0) > 0 } + emptyPostToolRecoverySteps(turnId: string): number { + return this.emptyPostToolRecoveryStepsByTurn.get(turnId) ?? 0 + } + clearTurn(turnId: string): void { this.lastNoToolTextByTurn.delete(turnId) this.goalNoToolRecoveryStepsByTurn.delete(turnId) @@ -265,7 +269,7 @@ export class RoundOutcomeCoordinator { } const message = - 'Model stopped without a final answer after tool execution, including after a recovery retry.' + 'Model stopped without a final answer after tool execution, including after continuation and final-answer recovery attempts.' this.deps.rememberFailure(input.turnId, { error: message, code: 'empty_post_tool_continuation', diff --git a/kun/tests/loop.test.ts b/kun/tests/loop.test.ts index 9c9b02ca3..390e831c3 100644 --- a/kun/tests/loop.test.ts +++ b/kun/tests/loop.test.ts @@ -570,6 +570,7 @@ describe('AgentLoop', () => { it('fails visibly when the model repeats an empty post-tool continuation', async () => { let calls = 0 + const requests: ModelRequest[] = [] const writeHelper = LocalToolHost.defineTool({ name: 'write_helper', description: 'Write a helper script.', @@ -582,7 +583,8 @@ describe('AgentLoop', () => { { provider: 'repeated-empty-after-tool', model: 'repeated-empty-after-tool', - async *stream(): AsyncIterable { + async *stream(request): AsyncIterable { + requests.push(request) calls += 1 if (calls === 1) { yield { @@ -605,7 +607,9 @@ describe('AgentLoop', () => { const items = await h.sessionStore.loadItems(h.threadId) expect(status).toBe('failed') - expect(calls).toBe(3) + expect(calls).toBe(4) + expect(requests[3]?.tools).toEqual([]) + expect(requests[3]?.contextInstructions?.join('\n')).toContain('Tool final-answer recovery') expect(items).toEqual(expect.arrayContaining([ expect.objectContaining({ kind: 'error', @@ -614,6 +618,65 @@ describe('AgentLoop', () => { ])) }) + it('forces a tool-free final answer after two empty post-tool continuations', async () => { + let calls = 0 + const requests: ModelRequest[] = [] + const writeHelper = LocalToolHost.defineTool({ + name: 'write_helper', + description: 'Write a helper script.', + inputSchema: { type: 'object', properties: {} }, + policy: 'auto', + toolKind: 'file_change', + execute: async () => ({ output: { ok: true } }) + }) + const h = makeHarness( + { + provider: 'final-answer-after-repeated-empty', + model: 'final-answer-after-repeated-empty', + async *stream(request): AsyncIterable { + requests.push(request) + calls += 1 + if (calls === 1) { + yield { + kind: 'tool_call_complete', + callId: 'call_write_helper', + toolName: 'write_helper', + arguments: {} + } + yield { kind: 'completed', stopReason: 'tool_calls' } + return + } + if (calls < 4) { + yield { kind: 'completed', stopReason: 'stop' } + return + } + yield { kind: 'assistant_text_delta', text: 'The helper was written successfully.' } + yield { kind: 'completed', stopReason: 'stop' } + } + }, + { tools: [writeHelper] } + ) + await bootstrapThread(h) + + const status = await h.loop.runTurn(h.threadId, h.turnId) + const items = await h.sessionStore.loadItems(h.threadId) + + expect(status).toBe('completed') + expect(calls).toBe(4) + expect(requests[2]?.tools.map((tool) => tool.name)).toContain('write_helper') + expect(requests[3]?.tools).toEqual([]) + expect(requests[3]?.contextInstructions?.join('\n')).toContain('Tool final-answer recovery') + expect(items).toEqual(expect.arrayContaining([ + expect.objectContaining({ + kind: 'assistant_text', + text: 'The helper was written successfully.' + }) + ])) + expect(items).not.toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'empty_post_tool_continuation' }) + ])) + }) + it('keeps running past the legacy eight-step ceiling until the model stops', async () => { let calls = 0 const noop = LocalToolHost.defineTool({ From 5c7488ef1c4e8f58ceb0ddb28511fceb415898d2 Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Tue, 14 Jul 2026 03:13:20 +0800 Subject: [PATCH 041/110] feat(renderer): implement main window recovery logic and tests --- src/main/index.ts | 137 +++++++++++++++--- .../main-window-renderer-recovery.test.ts | 32 ++++ src/main/main-window-renderer-recovery.ts | 39 +++++ .../src/components/chat/MessageTimeline.tsx | 7 +- .../chat/use-timeline-scroll.test.ts | 31 +++- .../components/chat/use-timeline-scroll.ts | 55 ++++--- 6 files changed, 256 insertions(+), 45 deletions(-) create mode 100644 src/main/main-window-renderer-recovery.test.ts create mode 100644 src/main/main-window-renderer-recovery.ts diff --git a/src/main/index.ts b/src/main/index.ts index 9cfaeffdf..6bf0fe343 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -83,9 +83,17 @@ import { } from './kun-process' import { expandHomePath } from './settings-store' import { KunRuntimeSupervisor, type KunRuntimeStatus } from './kun-runtime-supervisor' -import { configureLogger, logError, logWarn, pruneOnStartup } from './logger' +import { configureLogger, logError, logInfo, logWarn, pruneOnStartup } from './logger' import { cleanupUnusedGitCheckpointsIfDue } from './services/git-checkpoint-service' import { resolveMainWindowCloseDecision } from './window-close-behavior' +import { + MAIN_WINDOW_RENDERER_RECOVERY_DELAY_MS, + MAIN_WINDOW_RENDERER_RECOVERY_MAX_ATTEMPTS, + MAIN_WINDOW_RENDERER_RECOVERY_WINDOW_MS, + MainWindowRendererRecoveryBudget, + shouldRecoverMainFrameLoad, + shouldRecoverRendererProcess +} from './main-window-renderer-recovery' import { createClawRuntime, type ClawRuntime } from './claw-runtime' import { createScheduleRuntime, type ScheduleRuntime } from './schedule-runtime' import { createWorkflowRuntime, type WorkflowRuntime } from './workflow-runtime' @@ -1076,7 +1084,7 @@ function createWindow(options: { suppressInitialShow?: boolean } = {}): void { traceStartup('createWindow:start') const preloadPath = resolvePreloadPath(__dirname) const usesDesktopTitleBar = process.platform === 'win32' || process.platform === 'linux' - mainWindow = new BrowserWindow({ + const window = new BrowserWindow({ width: 1280, height: 840, minWidth: 960, @@ -1095,49 +1103,134 @@ function createWindow(options: { suppressInitialShow?: boolean } = {}): void { additionalArguments: [`--kun-home-dir=${homedir()}`] } }) - bindExtensionMainWindow?.(mainWindow) + mainWindow = window + bindExtensionMainWindow?.(window) if (usesDesktopTitleBar) { - mainWindow.setMenu(null) - mainWindow.setMenuBarVisibility(false) + window.setMenu(null) + window.setMenuBarVisibility(false) + } + const recoveryBudget = new MainWindowRendererRecoveryBudget() + let recoveryTimer: ReturnType | null = null + let rendererProcessId = 0 + const scheduleRendererRecovery = (trigger: string, detail: unknown): void => { + if ( + recoveryTimer || + isAppQuitInProgress() || + window.isDestroyed() || + window.webContents.isDestroyed() + ) return + + const attempt = recoveryBudget.reserve() + if (attempt === null) { + logError('renderer', 'Automatic main-window recovery stopped after repeated failures.', { + trigger, + detail, + maxAttempts: MAIN_WINDOW_RENDERER_RECOVERY_MAX_ATTEMPTS, + windowMs: MAIN_WINDOW_RENDERER_RECOVERY_WINDOW_MS + }) + return + } + + logWarn('renderer', 'Scheduling a main-window reload after renderer failure.', { + trigger, + detail, + attempt, + maxAttempts: MAIN_WINDOW_RENDERER_RECOVERY_MAX_ATTEMPTS + }) + recoveryTimer = setTimeout(() => { + recoveryTimer = null + if ( + isAppQuitInProgress() || + window.isDestroyed() || + window.webContents.isDestroyed() + ) return + logWarn('renderer', 'Reloading the main window after renderer failure.', { + trigger, + attempt + }) + window.webContents.reload() + }, MAIN_WINDOW_RENDERER_RECOVERY_DELAY_MS) + recoveryTimer.unref?.() } - mainWindow.webContents.on('preload-error', (_event, preloadPath, error) => { + + window.webContents.on('preload-error', (_event, preloadPath, error) => { const message = error instanceof Error ? error.message : String(error) console.error(`[kun-gui] failed to load preload ${preloadPath}:`, error) logError('preload', 'Failed to load preload script', { preloadPath, message }) }) - mainWindow.webContents.on('context-menu', (event, params) => { + window.webContents.on('render-process-gone', (_event, details) => { + if (isAppQuitInProgress() || !shouldRecoverRendererProcess(details.reason)) return + const detail = { + reason: details.reason, + exitCode: details.exitCode, + rendererProcessId + } + console.error('[kun-gui] main renderer process exited unexpectedly:', detail) + logError('renderer', 'Main renderer process exited unexpectedly.', detail) + scheduleRendererRecovery('render-process-gone', detail) + }) + window.webContents.on( + 'did-fail-load', + (_event, errorCode, errorDescription, validatedURL, isMainFrame, frameProcessId) => { + if ( + isAppQuitInProgress() || + !shouldRecoverMainFrameLoad(errorCode, isMainFrame) + ) return + const detail = { + errorCode, + errorDescription, + validatedURL, + frameProcessId + } + console.error('[kun-gui] main renderer failed to load:', detail) + logError('renderer', 'Main renderer failed to load.', detail) + scheduleRendererRecovery('did-fail-load', detail) + } + ) + window.webContents.on('unresponsive', () => { + if (isAppQuitInProgress()) return + logWarn('renderer', 'Main renderer became unresponsive.', { rendererProcessId }) + }) + window.webContents.on('responsive', () => { + logInfo('renderer', `Main renderer became responsive again (pid=${rendererProcessId}).`) + }) + window.webContents.on('context-menu', (event, params) => { event.preventDefault() - const window = mainWindow - if (!window || window.isDestroyed()) return + if (window.isDestroyed()) return showRendererContextMenu(window, params) }) const showWindow = (): void => { if (options.suppressInitialShow) return - if (!mainWindow || mainWindow.isDestroyed() || mainWindow.isVisible()) return - mainWindow.show() + if (window.isDestroyed() || window.isVisible()) return + window.show() } - mainWindow.on('close', (event) => { - if (!mainWindow || mainWindow.isDestroyed()) return - handleMainWindowClose(mainWindow, event) + window.on('close', (event) => { + if (window.isDestroyed()) return + handleMainWindowClose(window, event) }) - mainWindow.on('closed', () => { - mainWindow = null + window.on('closed', () => { + if (recoveryTimer) { + clearTimeout(recoveryTimer) + recoveryTimer = null + } + if (mainWindow === window) mainWindow = null }) const devUrl = devServerHintUrl() traceStartup('createWindow:load', { devUrl: devUrl ?? 'file' }) if (devUrl) { - mainWindow.loadURL(devUrl) + void window.loadURL(devUrl) } else { - mainWindow.loadFile(join(__dirname, '../renderer/index.html')) + void window.loadFile(join(__dirname, '../renderer/index.html')) } - mainWindow.once('ready-to-show', () => { + window.once('ready-to-show', () => { traceStartup('window:ready-to-show') showWindow() }) - mainWindow.webContents.once('did-finish-load', () => { + window.webContents.on('did-finish-load', () => { traceStartup('window:did-finish-load') - if (runtimeSupervisor.lastStatus && mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('runtime:status', runtimeSupervisor.lastStatus) + rendererProcessId = window.webContents.getOSProcessId() + if (runtimeSupervisor.lastStatus && !window.isDestroyed()) { + window.webContents.send('runtime:status', runtimeSupervisor.lastStatus) } showWindow() }) diff --git a/src/main/main-window-renderer-recovery.test.ts b/src/main/main-window-renderer-recovery.test.ts new file mode 100644 index 000000000..edede9a7f --- /dev/null +++ b/src/main/main-window-renderer-recovery.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { + MainWindowRendererRecoveryBudget, + shouldRecoverMainFrameLoad, + shouldRecoverRendererProcess +} from './main-window-renderer-recovery' + +describe('main window renderer recovery', () => { + it('ignores subframe failures and Chromium navigation cancellation', () => { + expect(shouldRecoverMainFrameLoad(-105, false)).toBe(false) + expect(shouldRecoverMainFrameLoad(-3, true)).toBe(false) + expect(shouldRecoverMainFrameLoad(-105, true)).toBe(true) + }) + + it('recovers unexpected renderer exits but ignores a normal clean exit', () => { + expect(shouldRecoverRendererProcess('clean-exit')).toBe(false) + expect(shouldRecoverRendererProcess('crashed')).toBe(true) + expect(shouldRecoverRendererProcess('oom')).toBe(true) + expect(shouldRecoverRendererProcess('memory-eviction')).toBe(true) + }) + + it('bounds automatic reloads inside a sliding time window', () => { + const budget = new MainWindowRendererRecoveryBudget(2, 60_000) + + expect(budget.reserve(1_000)).toBe(1) + expect(budget.reserve(2_000)).toBe(2) + expect(budget.reserve(3_000)).toBeNull() + expect(budget.reserve(61_000)).toBe(2) + expect(budget.reserve(62_000)).toBe(2) + expect(budget.reserve(63_000)).toBeNull() + }) +}) diff --git a/src/main/main-window-renderer-recovery.ts b/src/main/main-window-renderer-recovery.ts new file mode 100644 index 000000000..65aaf8bbc --- /dev/null +++ b/src/main/main-window-renderer-recovery.ts @@ -0,0 +1,39 @@ +import type { RenderProcessGoneDetails } from 'electron' + +export const MAIN_WINDOW_RENDERER_RECOVERY_DELAY_MS = 250 +export const MAIN_WINDOW_RENDERER_RECOVERY_MAX_ATTEMPTS = 2 +export const MAIN_WINDOW_RENDERER_RECOVERY_WINDOW_MS = 60_000 + +// Chromium reports ERR_ABORTED while replacing an in-flight navigation. That +// is expected during reloads and must not start another recovery cycle. +const ERR_ABORTED = -3 + +export function shouldRecoverRendererProcess( + reason: RenderProcessGoneDetails['reason'] +): boolean { + return reason !== 'clean-exit' +} + +export function shouldRecoverMainFrameLoad( + errorCode: number, + isMainFrame: boolean +): boolean { + return isMainFrame && errorCode !== ERR_ABORTED +} + +/** Sliding-window guard that prevents a persistently crashing page from looping forever. */ +export class MainWindowRendererRecoveryBudget { + private attempts: number[] = [] + + constructor( + private readonly maxAttempts = MAIN_WINDOW_RENDERER_RECOVERY_MAX_ATTEMPTS, + private readonly windowMs = MAIN_WINDOW_RENDERER_RECOVERY_WINDOW_MS + ) {} + + reserve(now = Date.now()): number | null { + this.attempts = this.attempts.filter((startedAt) => now - startedAt < this.windowMs) + if (this.attempts.length >= this.maxAttempts) return null + this.attempts.push(now) + return this.attempts.length + } +} diff --git a/src/renderer/src/components/chat/MessageTimeline.tsx b/src/renderer/src/components/chat/MessageTimeline.tsx index 6e6c005a9..3164df080 100644 --- a/src/renderer/src/components/chat/MessageTimeline.tsx +++ b/src/renderer/src/components/chat/MessageTimeline.tsx @@ -84,7 +84,6 @@ type Props = { type CompactionTimelineBlock = Extract const TURN_PAGE_SIZE = 18 -const AUTO_COLLAPSE_THRESHOLD = 24 const TIMELINE_JUMP_RAIL_FALLBACK_LEFT_PX = 16 const TIMELINE_JUMP_RAIL_STAGE_INSET_PX = 16 const TIMELINE_JUMP_RAIL_WIDTH_PX = 30 @@ -346,7 +345,6 @@ export function MessageTimeline({ liveReasoning.length ].join(':') const { - visibleTurnCount, hiddenTurnCount, loadEarlierTurns, collapseEarlierTurns @@ -355,7 +353,6 @@ export function MessageTimeline({ endRef, activeThreadId, pageSize: TURN_PAGE_SIZE, - autoCollapseThreshold: AUTO_COLLAPSE_THRESHOLD, totalTurns: turns.length, busy, scrollDeps: { @@ -548,7 +545,7 @@ export function MessageTimeline({ ) : null} - {hiddenTurnCount > 0 ? ( + {hiddenTurnCount > 0 && !busy ? (
+
+ ))} +
+ + {!state.initialized ? ( +
Loading editor…
+ ) : !project ? ( + + ) : ( +
+ + +
+ {formatTime(frameToSeconds(project, state.playheadFrame))} / {formatTime(frameToSeconds(project, project.durationFrames))} + }> + playing !== state.playing && controller.togglePlaying()} + onResourceError={() => void controller.refreshActiveLease()} + /> + + + +
+ + + +
+ + + + +
+
+ )} + +
+ {messages.localOnly} + {messages.keyboardHelp} +
+
+ ) +} + +function ProjectBar({ controller, messages }: { controller: EditorController; messages: Messages }): React.JSX.Element { + const { state } = controller + const [name, setName] = useState('Untitled interview') + const [preset, setPreset] = useState<'16:9' | '9:16' | '1:1'>('16:9') + const create = (event: FormEvent): void => { + event.preventDefault() + void controller.createProject(name, preset) + } + return ( +
+
+ +
{messages.appName}Transcript-first workbench
+
+ +
+ {state.connection === 'online' ? messages.connected : state.connection} + {state.project && r{state.project.currentRevision}} + + + + +
+
+ ) +} + +function EmptyProject({ controller, messages }: { controller: EditorController; messages: Messages }): React.JSX.Element { + return ( +
+ +
+

Local-first editing

+

Shape the story, keep every cut editable.

+

{messages.noProject}

+

{messages.unsupported}

+
+ {controller.state.projects.slice(0, 3).map((project) => ( + + ))} +
+
+
+ ) +} + +function MediaLibrary({ controller, messages }: { controller: EditorController; messages: Messages }): React.JSX.Element { + const project = controller.state.project! + const assets = project.assets.slice(0, VIEW_LIMITS.virtualWindow) + return ( + void controller.importMedia()}>{messages.importMedia}}> + {assets.length === 0 ? {messages.noMedia} : ( +
    + {assets.map((asset) => { + const revoked = Boolean(asset.mediaHandleId && controller.state.revokedHandles.includes(asset.mediaHandleId)) + return ( +
  • + +
  • + ) + })} +
+ )} + {project.assets.length > assets.length &&

Showing the first {assets.length} of {project.assets.length} bounded assets.

} +
+ ) +} + +function TranscriptPanel({ controller, messages }: { controller: EditorController; messages: Messages }): React.JSX.Element { + const { state } = controller + const project = state.project! + const transcripts = state.selectedAssetId + ? project.transcripts.filter(({ assetId }) => assetId === state.selectedAssetId) + : project.transcripts + const segments = transcripts.flatMap((transcript) => transcript.segments.map((segment) => ({ ...segment, assetId: transcript.assetId }))) + const start = Math.min(state.transcriptWindowStart, Math.max(0, segments.length - 1)) + const visible = segments.slice(start, start + VIEW_LIMITS.virtualWindow) + const active = activeTranscriptSegment(project, state.selectedAssetId, state.playheadFrame) + return ( + }> + {segments.length === 0 ? {messages.noTranscript} : ( +
    + {visible.map((segment) => ( +
  1. + +
  2. + ))} +
+ )} +

Transcript and timing evidence do not prove unseen visual events.

+ +
+ ) +} + +function ScriptReview({ controller, messages }: { controller: EditorController; messages: Messages }): React.JSX.Element { + const script = controller.state.script + const [ranges, setRanges] = useState('[]') + const apply = (): void => { + try { + const parsed: unknown = JSON.parse(ranges) + if (!Array.isArray(parsed)) throw new Error('Ranges must be a JSON array.') + void controller.applyScript(parsed as Array<{ assetId: string; startUs: number; endUs: number; reason?: 'filler' | 'silence' | 'selection' }>) + } catch { + // Keep validation local and non-destructive; the button is paired with + // explanatory helper text and Host validation remains authoritative. + } + } + return ( +
+ {messages.readScript} + {!script ? ( + + ) : ( +
+ Revision {script.revision} · digest {script.digest.slice(0, 12) || 'unavailable'}{script.dirty ? ' · edited' : ''} +
+ + +
+ +
+ 16:9 · 1600 × 900 + Nothing selected +
+ +
+
+
+ Private extension run + Presentation Agent +
+
Idle
+
+
    +
    + + +
    + + +
    +
    +
    + + + + + + + + + + + + + + +
    +
    + Safe structured preview + Presentation preview +
    +
    + + 1 / 1 + + +
    +
    +
    + + + + +
    +
    +
    + + + diff --git a/examples/extensions/presentation-studio/src/webview/main.ts b/examples/extensions/presentation-studio/src/webview/main.ts new file mode 100644 index 000000000..c09ae4821 --- /dev/null +++ b/examples/extensions/presentation-studio/src/webview/main.ts @@ -0,0 +1,1578 @@ +import { + ExtensionHostClient, + type AgentRunEvent, + type AgentRunSubscription, + type HostMessage, + type HostTransport, + type JsonObject, + type JsonValue, + type Theme +} from '@kun/extension-api' +import { + MAX_PRESENTATION_OPERATIONS, + applyPresentationOperations, + createImageElement, + createPresentationSlide, + createShapeElement, + createTextElement, + type PresentationElement, + type PresentationFontFamily, + type PresentationImageElement, + type PresentationOperation, + type PresentationProject, + type PresentationShapeElement, + type PresentationSlide, + type PresentationTextElement +} from '../shared/presentation.js' + +declare global { + interface Window { + readonly kunExtension: HostTransport + } +} + +type SaveTone = 'idle' | 'saving' | 'saved' | 'error' +type CommandResponse = { path: string; project: PresentationProject } +type SaveResponse = CommandResponse & { + resultingRevision: number + currentRevision: number + changedIds: string[] + warnings: Array<{ code: string; path: string; message: string }> + idempotentReplay: boolean +} +type ExportResponse = { + sourcePath: string + destinationPath: string + revision: number + bytes: number +} +type PresentationChangedPayload = { + path: string + revision: number + source: 'command' | 'tool' + changedIds: string[] +} +type HistoryEntry = { + forward: PresentationOperation[] + inverse: PresentationOperation[] + label: string +} +type PointerSession = { + pointerId: number + slideId: string + elementId: string + mode: 'move' | 'resize' + handle?: 'nw' | 'ne' | 'se' | 'sw' + startClientX: number + startClientY: number + original: PresentationElement + preview: PresentationElement +} +type ImageCacheEntry = + | { state: 'loading' } + | { state: 'ready'; url: string } + | { state: 'error'; message: string } +type PersistedViewState = { + path?: string + selectedSlideId?: string + lastRunId?: string + lastAgentSequence?: number +} + +const SVG_NS = 'http://www.w3.org/2000/svg' +const SAVE_DEBOUNCE_MS = 450 +const MAX_IMAGE_BASE64_CHARS = 8 * 1024 * 1024 +const TERMINAL_RUN_STATES = new Set(['completed', 'failed', 'cancelled', 'budget-exhausted']) + +const client = new ExtensionHostClient(window.kunExtension) + +function required(selector: string): T { + const node = document.querySelector(selector) + if (!node) throw new Error(`Presentation Studio is missing ${selector}`) + return node +} + +const ui = { + studio: required('#studio'), + path: required('#deck-path'), + newDeck: required('#new-deck'), + loadDeck: required('#load-deck'), + openExport: required('#open-copy'), + saveState: required('#save-state'), + conflictBanner: required('#conflict-banner'), + conflictDetail: required('#conflict-detail'), + reloadConflict: required('#reload-conflict'), + deckTitle: required('#deck-title'), + slideList: required('#slide-list'), + addSlide: required('#add-slide'), + duplicateSlide: required('#duplicate-slide'), + deleteSlide: required('#delete-slide'), + undo: required('#undo'), + redo: required('#redo'), + addText: required('#add-text'), + addShape: required('#add-shape'), + openImage: required('#open-image'), + openPreview: required('#open-preview'), + canvasEmpty: required('#canvas-empty'), + canvas: required('#slide-canvas'), + canvasBackground: required('#slide-canvas > .canvas-background'), + canvasElements: required('#canvas-elements'), + canvasSelection: required('#canvas-selection'), + inlineHost: required('#inline-editor-host'), + inlineText: required('#inline-text-editor'), + canvasCaption: required('#canvas-caption'), + selectionCaption: required('#selection-caption'), + inspectorTitle: required('#inspector-title'), + inspectorBody: required('#inspector-body'), + imageDialog: required('#image-dialog'), + imageForm: required('#image-form'), + imagePath: required('#image-path'), + imageError: required('#image-dialog-error'), + exportDialog: required('#export-dialog'), + exportForm: required('#export-form'), + exportPath: required('#export-path'), + exportError: required('#export-dialog-error'), + previewDialog: required('#preview-dialog'), + previewCanvas: required('#preview-canvas'), + previewBackground: required('#preview-canvas > .canvas-background'), + previewElements: required('#preview-elements'), + previewPrev: required('#preview-prev'), + previewNext: required('#preview-next'), + previewPosition: required('#preview-position'), + agentForm: required('#agent-form'), + agentPrompt: required('#agent-prompt'), + sendAgent: required('#send-agent'), + cancelAgent: required('#cancel-agent'), + agentState: required('#agent-state'), + agentEvents: required('#agent-events') +} + +let project: PresentationProject | null = null +let activePath = '' +let selectedSlideId: string | null = null +let selectedElementId: string | null = null +let pendingOperations: PresentationOperation[] = [] +let undoStack: HistoryEntry[] = [] +let redoStack: HistoryEntry[] = [] +let saveTimer = 0 +let savePromise: Promise | null = null +let ownSaveTargetRevision: number | null = null +let conflicted = false +let pointerSession: PointerSession | null = null +let inlineEditingId: string | null = null +let previewIndex = 0 +let idCounter = 0 +let viewStateTimer = 0 +let activeRunId: string | null = null +let lastRunId: string | null = null +let lastAgentSequence = 0 +let agentSubscription: AgentRunSubscription | null = null +const imageCache = new Map() + +function svg(tag: K): SVGElementTagNameMap[K] { + return document.createElementNS(SVG_NS, tag) +} + +function html(tag: K): HTMLElementTagNameMap[K] { + return document.createElement(tag) +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)) +} + +function finite(value: unknown, fallback: number): number { + return typeof value === 'number' && Number.isFinite(value) ? value : fallback +} + +function makeId(prefix: string): string { + idCounter += 1 + const random = globalThis.crypto?.randomUUID?.().replaceAll('-', '').slice(0, 12) + ?? Math.random().toString(36).slice(2, 14) + return `${prefix}-${Date.now().toString(36)}-${idCounter.toString(36)}-${random}` +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function normalizePath(value: string): string { + const path = value.trim() + if ( + !path || + path.length > 240 || + !/^[A-Za-z0-9][A-Za-z0-9._ -]*\.kun-ppt\.html$/u.test(path) + ) { + throw new Error('Use a root-level filename ending in .kun-ppt.html.') + } + if (path.includes('/') || path.includes('\\') || path === '.' || path === '..') { + throw new Error('Presentation files must use a root-level workspace filename.') + } + return path +} + +function setSaveStatus(message: string, tone: SaveTone = 'idle'): void { + ui.saveState.textContent = message + ui.saveState.dataset.tone = tone +} + +function setAgentStatus(message: string, tone: SaveTone = 'idle'): void { + ui.agentState.textContent = message + ui.agentState.dataset.tone = tone +} + +function setConflict(message: string): void { + conflicted = true + ui.conflictDetail.textContent = message + ui.conflictBanner.hidden = false + setSaveStatus('Revision conflict — reload required', 'error') + renderControls() + renderInspector() +} + +function clearConflict(): void { + conflicted = false + ui.conflictBanner.hidden = true + ui.conflictDetail.textContent = 'Reload before making more edits.' +} + +function currentSlide(): PresentationSlide | null { + if (!project) return null + return project.slides.find((slide) => slide.id === selectedSlideId) ?? project.slides[0] ?? null +} + +function currentElement(): PresentationElement | null { + const slide = currentSlide() + if (!slide || !selectedElementId) return null + return slide.elements.find((element) => element.id === selectedElementId) ?? null +} + +function executeCommand(id: string, args: JsonObject): Promise { + return client.commands.executeCommand(id, args).then((value) => value as unknown as T) +} + +function scheduleViewState(): void { + if (viewStateTimer) window.clearTimeout(viewStateTimer) + viewStateTimer = window.setTimeout(() => { + viewStateTimer = 0 + const state: PersistedViewState = { + ...(activePath ? { path: activePath } : {}), + ...(selectedSlideId ? { selectedSlideId } : {}), + ...(lastRunId ? { lastRunId } : {}), + lastAgentSequence + } + void client.ui.setViewState(state as unknown as JsonValue).catch(() => undefined) + }, 150) +} + +function renderControls(): void { + const loaded = project !== null + const editable = loaded && !conflicted + const slide = currentSlide() + ui.openExport.disabled = !loaded || conflicted + ui.addSlide.disabled = !editable + ui.duplicateSlide.disabled = !editable || !slide + ui.deleteSlide.disabled = !editable || !slide || (project?.slides.length ?? 0) <= 1 + ui.undo.disabled = !editable || undoStack.length === 0 + ui.redo.disabled = !editable || redoStack.length === 0 + ui.addText.disabled = !editable || !slide + ui.addShape.disabled = !editable || !slide + ui.openImage.disabled = !editable || !slide + ui.openPreview.disabled = !loaded || !slide + ui.agentPrompt.disabled = !editable + ui.sendAgent.disabled = !editable + ui.cancelAgent.disabled = activeRunId === null +} + +function fontFamily(token: PresentationFontFamily): string { + if (token === 'serif') return 'Georgia, Times New Roman, serif' + if (token === 'mono') return 'SFMono-Regular, Consolas, Liberation Mono, monospace' + return 'Inter, Arial, Helvetica, sans-serif' +} + +function geometry(element: PresentationElement): { x: number; y: number; width: number; height: number } { + return { + x: finite(element.x, 0) * 16, + y: finite(element.y, 0) * 9, + width: Math.max(1, finite(element.width, 1) * 16), + height: Math.max(1, finite(element.height, 1) * 9) + } +} + +function wrapText(text: string, width: number, fontSize: number, maxLines: number): string[] { + const normalized = text.replaceAll('\r\n', '\n').replaceAll('\r', '\n') + const maxChars = Math.max(1, Math.floor(width / Math.max(5, fontSize * 0.56))) + const lines: string[] = [] + for (const paragraph of normalized.split('\n')) { + if (!paragraph) { + lines.push('') + continue + } + let current = '' + for (const word of paragraph.split(/\s+/u)) { + if (!current) { + current = word + } else if (`${current} ${word}`.length <= maxChars) { + current = `${current} ${word}` + } else { + lines.push(current) + current = word + } + while (current.length > maxChars) { + lines.push(current.slice(0, maxChars)) + current = current.slice(maxChars) + } + } + lines.push(current) + } + return lines.slice(0, Math.max(1, maxLines)) +} + +function setTransform(node: SVGElement, element: PresentationElement): void { + const box = geometry(element) + const rotation = finite(element.rotation, 0) + if (rotation !== 0) { + node.setAttribute( + 'transform', + `rotate(${rotation} ${box.x + box.width / 2} ${box.y + box.height / 2})` + ) + } + node.setAttribute('opacity', String(clamp(finite(element.opacity, 1), 0, 1))) +} + +function renderTextElement(group: SVGGElement, element: PresentationTextElement): void { + const box = geometry(element) + const hit = svg('rect') + hit.setAttribute('x', String(box.x)) + hit.setAttribute('y', String(box.y)) + hit.setAttribute('width', String(box.width)) + hit.setAttribute('height', String(box.height)) + hit.setAttribute('fill', 'transparent') + group.append(hit) + + const text = svg('text') + const fontSize = clamp(finite(element.fontSize, 48), 8, 240) + const lineHeight = fontSize * 1.18 + const lines = wrapText(element.text, box.width, fontSize, Math.floor(box.height / lineHeight)) + const contentHeight = Math.max(lineHeight, lines.length * lineHeight) + const baseY = element.verticalAlign === 'bottom' + ? box.y + box.height - contentHeight + fontSize + : element.verticalAlign === 'middle' + ? box.y + (box.height - contentHeight) / 2 + fontSize + : box.y + fontSize + const textX = element.align === 'right' + ? box.x + box.width + : element.align === 'center' + ? box.x + box.width / 2 + : box.x + text.setAttribute('x', String(textX)) + text.setAttribute('y', String(baseY)) + text.setAttribute('fill', element.color) + text.setAttribute('font-size', String(fontSize)) + text.setAttribute('font-weight', String(element.fontWeight)) + text.setAttribute('font-family', fontFamily(project?.theme.fontFamily ?? 'sans')) + text.setAttribute('text-anchor', element.align === 'right' ? 'end' : element.align === 'center' ? 'middle' : 'start') + text.setAttribute('pointer-events', 'none') + lines.forEach((line, index) => { + const span = svg('tspan') + span.setAttribute('x', String(textX)) + span.setAttribute('dy', index === 0 ? '0' : String(lineHeight)) + span.textContent = line + text.append(span) + }) + group.append(text) +} + +function renderShapeElement(group: SVGGElement, element: PresentationShapeElement): void { + const box = geometry(element) + if (element.shape === 'ellipse') { + const ellipse = svg('ellipse') + ellipse.setAttribute('cx', String(box.x + box.width / 2)) + ellipse.setAttribute('cy', String(box.y + box.height / 2)) + ellipse.setAttribute('rx', String(box.width / 2)) + ellipse.setAttribute('ry', String(box.height / 2)) + ellipse.setAttribute('fill', element.fillColor) + ellipse.setAttribute('stroke', element.strokeColor) + ellipse.setAttribute('stroke-width', String(element.strokeWidth)) + group.append(ellipse) + return + } + if (element.shape === 'line') { + const line = svg('line') + line.setAttribute('x1', String(box.x)) + line.setAttribute('y1', String(box.y + box.height / 2)) + line.setAttribute('x2', String(box.x + box.width)) + line.setAttribute('y2', String(box.y + box.height / 2)) + line.setAttribute('stroke', element.strokeColor) + line.setAttribute('stroke-width', String(Math.max(1, element.strokeWidth))) + line.setAttribute('stroke-linecap', 'round') + group.append(line) + return + } + const rect = svg('rect') + rect.setAttribute('x', String(box.x)) + rect.setAttribute('y', String(box.y)) + rect.setAttribute('width', String(box.width)) + rect.setAttribute('height', String(box.height)) + rect.setAttribute('rx', String(Math.max(0, element.cornerRadius))) + rect.setAttribute('fill', element.fillColor) + rect.setAttribute('stroke', element.strokeColor) + rect.setAttribute('stroke-width', String(element.strokeWidth)) + group.append(rect) +} + +function imageMime(path: string): string | null { + const lower = path.toLowerCase() + if (lower.endsWith('.png')) return 'image/png' + if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg' + if (lower.endsWith('.gif')) return 'image/gif' + if (lower.endsWith('.webp')) return 'image/webp' + return null +} + +function assertImagePath(path: string): string { + const normalized = path.trim().replaceAll('\\', '/') + const segments = normalized.split('/') + if ( + !normalized || + normalized.length > 260 || + normalized.startsWith('/') || + /^[A-Za-z]:/u.test(normalized) || + /^[A-Za-z][A-Za-z0-9+.-]*:/u.test(normalized) || + // eslint-disable-next-line no-control-regex -- path validation intentionally matches ASCII controls + /[\u0000-\u001F\u007F%:*?"<>|#]/u.test(normalized) || + segments.some((part) => part === '' || part === '.' || part === '..') || + imageMime(normalized) === null + ) { + throw new Error('Use a workspace-relative PNG, JPEG, GIF, or WebP path.') + } + return normalized +} + +async function resolveImage(path: string): Promise { + const normalized = assertImagePath(path) + const current = imageCache.get(normalized) + if (current?.state === 'ready') return current.url + if (current?.state === 'error') throw new Error(current.message) + imageCache.set(normalized, { state: 'loading' }) + try { + const file = await client.workspace.readFile(normalized, 'base64') + if (file.encoding !== 'base64' || file.content.length > MAX_IMAGE_BASE64_CHARS) { + throw new Error('The image is too large for the editor preview.') + } + const mime = imageMime(normalized) + if (!mime) throw new Error('Unsupported image format.') + const url = `data:${mime};base64,${file.content}` + imageCache.set(normalized, { state: 'ready', url }) + return url + } catch (error) { + const message = errorMessage(error) + imageCache.set(normalized, { state: 'error', message }) + throw error + } +} + +function requestImage(path: string): ImageCacheEntry | undefined { + const normalized = path.trim().replaceAll('\\', '/') + const cached = imageCache.get(normalized) + if (!cached) { + void resolveImage(normalized) + .then(() => renderAllVisuals()) + .catch(() => renderAllVisuals()) + return { state: 'loading' } + } + return cached +} + +function renderImageElement(group: SVGGElement, element: PresentationImageElement): void { + const box = geometry(element) + let cache: ImageCacheEntry | undefined + try { + cache = requestImage(assertImagePath(element.src)) + } catch (error) { + cache = { state: 'error', message: errorMessage(error) } + } + if (cache?.state === 'ready') { + const image = svg('image') + image.setAttribute('x', String(box.x)) + image.setAttribute('y', String(box.y)) + image.setAttribute('width', String(box.width)) + image.setAttribute('height', String(box.height)) + image.setAttribute('href', cache.url) + image.setAttribute('preserveAspectRatio', element.fit === 'cover' ? 'xMidYMid slice' : 'xMidYMid meet') + image.setAttribute('aria-label', element.alt || element.src) + group.append(image) + return + } + const placeholder = svg('rect') + placeholder.classList.add('image-placeholder') + placeholder.setAttribute('x', String(box.x)) + placeholder.setAttribute('y', String(box.y)) + placeholder.setAttribute('width', String(box.width)) + placeholder.setAttribute('height', String(box.height)) + group.append(placeholder) + const mark = svg('text') + mark.classList.add('image-placeholder-mark') + mark.setAttribute('x', String(box.x + box.width / 2)) + mark.setAttribute('y', String(box.y + box.height / 2)) + mark.textContent = cache?.state === 'error' ? '!' : '…' + group.append(mark) +} + +function renderElement(element: PresentationElement, interactive: boolean): SVGGElement { + const group = svg('g') + group.classList.add('canvas-item') + group.dataset.elementId = element.id + group.dataset.kind = element.type + if (interactive) { + group.setAttribute('role', 'button') + group.setAttribute('tabindex', '0') + group.setAttribute('aria-label', `${element.type} element ${element.id}`) + } + setTransform(group, element) + if (element.type === 'text') renderTextElement(group, element) + else if (element.type === 'shape') renderShapeElement(group, element) + else renderImageElement(group, element) + return group +} + +function projectElementForRender(element: PresentationElement): PresentationElement { + if (pointerSession?.elementId === element.id) return pointerSession.preview + return element +} + +function renderSelection(element: PresentationElement | null): void { + ui.canvasSelection.replaceChildren() + if (!element || inlineEditingId === element.id) return + const box = geometry(projectElementForRender(element)) + const outline = svg('rect') + outline.classList.add('selection-outline') + outline.setAttribute('x', String(box.x)) + outline.setAttribute('y', String(box.y)) + outline.setAttribute('width', String(box.width)) + outline.setAttribute('height', String(box.height)) + ui.canvasSelection.append(outline) + const handles: Array<['nw' | 'ne' | 'se' | 'sw', number, number]> = [ + ['nw', box.x, box.y], + ['ne', box.x + box.width, box.y], + ['se', box.x + box.width, box.y + box.height], + ['sw', box.x, box.y + box.height] + ] + for (const [name, x, y] of handles) { + const handle = svg('rect') + handle.classList.add('selection-handle') + handle.dataset.handle = name + handle.setAttribute('x', String(x - 9)) + handle.setAttribute('y', String(y - 9)) + handle.setAttribute('width', '18') + handle.setAttribute('height', '18') + handle.setAttribute('rx', '3') + ui.canvasSelection.append(handle) + } +} + +function configureInlineEditor(element: PresentationTextElement | null): void { + if (!element || inlineEditingId !== element.id) { + ui.inlineHost.setAttribute('hidden', '') + return + } + const box = geometry(element) + ui.inlineHost.setAttribute('x', String(box.x)) + ui.inlineHost.setAttribute('y', String(box.y)) + ui.inlineHost.setAttribute('width', String(box.width)) + ui.inlineHost.setAttribute('height', String(box.height)) + ui.inlineHost.removeAttribute('hidden') +} + +function renderCanvas(): void { + const slide = currentSlide() + if (!project || !slide) { + ui.canvas.setAttribute('hidden', '') + ui.canvasEmpty.hidden = false + ui.canvasElements.replaceChildren() + ui.canvasSelection.replaceChildren() + return + } + ui.canvas.removeAttribute('hidden') + ui.canvasEmpty.hidden = true + ui.canvasBackground.setAttribute('fill', slide.backgroundColor ?? project.theme.backgroundColor) + ui.canvasElements.replaceChildren( + ...slide.elements.map((element) => renderElement(projectElementForRender(element), true)) + ) + const selected = currentElement() + renderSelection(selected) + configureInlineEditor(selected?.type === 'text' ? selected : null) + ui.canvasCaption.textContent = `16:9 · ${slide.title} · revision ${project.revision}` + ui.selectionCaption.textContent = selected + ? `${selected.type} · ${selected.id}` + : 'Nothing selected' +} + +function slideTitle(slide: PresentationSlide, index: number): string { + return slide.title.trim() || `Slide ${index + 1}` +} + +function renderSlideList(): void { + if (!project) { + ui.slideList.replaceChildren() + ui.deckTitle.textContent = 'Untitled presentation' + return + } + ui.deckTitle.textContent = project.title + const cards = project.slides.map((slide, index) => { + const item = html('li') + const card = html('button') + card.type = 'button' + card.className = 'slide-card' + card.dataset.slideId = slide.id + card.setAttribute('role', 'option') + card.setAttribute('aria-selected', String(slide.id === selectedSlideId)) + card.draggable = !conflicted + + const number = html('span') + number.className = 'slide-card-number' + number.textContent = String(index + 1) + const shell = html('span') + shell.className = 'slide-thumbnail-shell' + const thumbnail = html('span') + thumbnail.className = 'slide-thumbnail' + const preview = svg('svg') + preview.setAttribute('viewBox', '0 0 1600 900') + preview.setAttribute('aria-hidden', 'true') + const background = svg('rect') + background.setAttribute('x', '0') + background.setAttribute('y', '0') + background.setAttribute('width', '1600') + background.setAttribute('height', '900') + background.setAttribute('fill', slide.backgroundColor ?? project!.theme.backgroundColor) + preview.append(background, ...slide.elements.map((element) => renderElement(element, false))) + thumbnail.append(preview) + const title = html('span') + title.className = 'slide-thumbnail-title' + title.textContent = slideTitle(slide, index) + shell.append(thumbnail, title) + card.append(number, shell) + card.addEventListener('click', () => selectSlide(slide.id)) + card.addEventListener('keydown', (event) => { + if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') { + event.preventDefault() + reorderSlide(slide.id, Math.max(0, index - 1)) + } else if (event.key === 'ArrowDown' || event.key === 'ArrowRight') { + event.preventDefault() + reorderSlide(slide.id, Math.min(project!.slides.length - 1, index + 1)) + } + }) + card.addEventListener('dragstart', (event) => { + event.dataTransfer?.setData('text/plain', slide.id) + card.dataset.dragging = 'true' + }) + card.addEventListener('dragend', () => { + delete card.dataset.dragging + for (const node of ui.slideList.querySelectorAll('[data-drop-target]')) { + delete node.dataset.dropTarget + } + }) + card.addEventListener('dragover', (event) => { + if (conflicted) return + event.preventDefault() + const before = event.clientY < card.getBoundingClientRect().top + card.getBoundingClientRect().height / 2 + card.dataset.dropTarget = before ? 'before' : 'after' + }) + card.addEventListener('dragleave', () => delete card.dataset.dropTarget) + card.addEventListener('drop', (event) => { + event.preventDefault() + const draggedId = event.dataTransfer?.getData('text/plain') + if (!draggedId || draggedId === slide.id) return + const remaining = project!.slides.filter((candidate) => candidate.id !== draggedId) + const target = remaining.findIndex((candidate) => candidate.id === slide.id) + const insertAfter = card.dataset.dropTarget === 'after' + reorderSlide(draggedId, clamp(target + (insertAfter ? 1 : 0), 0, remaining.length)) + }) + item.append(card) + return item + }) + ui.slideList.replaceChildren(...cards) +} + +function renderPreview(): void { + if (!project || project.slides.length === 0) return + previewIndex = clamp(previewIndex, 0, project.slides.length - 1) + const slide = project.slides[previewIndex] + ui.previewBackground.setAttribute('fill', slide.backgroundColor ?? project.theme.backgroundColor) + ui.previewElements.replaceChildren(...slide.elements.map((element) => renderElement(element, false))) + ui.previewPosition.textContent = `${previewIndex + 1} / ${project.slides.length}` + ui.previewPrev.disabled = previewIndex === 0 + ui.previewNext.disabled = previewIndex === project.slides.length - 1 + ui.previewCanvas.setAttribute('aria-label', `Preview: ${slideTitle(slide, previewIndex)}`) +} + +function renderAllVisuals(): void { + renderSlideList() + renderCanvas() + if (ui.previewDialog.open) renderPreview() +} + +function commitProject(next: PresentationProject, responsePath: string, selectedId?: string): void { + project = next + activePath = responsePath + ui.path.value = responsePath + selectedSlideId = next.slides.some((slide) => slide.id === selectedId) + ? selectedId! + : next.slides[0]?.id ?? null + selectedElementId = null + pendingOperations = [] + undoStack = [] + redoStack = [] + pointerSession = null + inlineEditingId = null + imageCache.clear() + clearConflict() + setSaveStatus(`Loaded revision ${next.revision}`, 'saved') + renderAll() + scheduleViewState() +} + +function renderAll(): void { + renderAllVisuals() + renderInspector() + renderControls() +} + +function selectSlide(slideId: string): void { + if (!project?.slides.some((slide) => slide.id === slideId)) return + commitInlineEdit() + selectedSlideId = slideId + selectedElementId = null + previewIndex = project.slides.findIndex((slide) => slide.id === slideId) + renderAll() + scheduleViewState() +} + +function selectElement(elementId: string | null): void { + if (inlineEditingId && inlineEditingId !== elementId) commitInlineEdit() + selectedElementId = elementId + renderCanvas() + renderInspector() + renderControls() +} + +function localApply( + operations: PresentationOperation[], + label: string, + options: { recordHistory?: boolean } = {} +): boolean { + if (!project || conflicted || operations.length === 0) return false + try { + const result = applyPresentationOperations(project, operations) + project = result.project + pendingOperations.push(...operations) + if (options.recordHistory !== false) { + undoStack.push({ forward: operations, inverse: result.inverseOperations, label }) + if (undoStack.length > 100) undoStack.shift() + redoStack = [] + } + if (pendingOperations.length >= MAX_PRESENTATION_OPERATIONS) scheduleSave(0) + else scheduleSave() + setSaveStatus(`Unsaved · ${label}`, 'saving') + renderAll() + return true + } catch (error) { + setSaveStatus(errorMessage(error), 'error') + return false + } +} + +function scheduleSave(delay = SAVE_DEBOUNCE_MS): void { + if (saveTimer) window.clearTimeout(saveTimer) + saveTimer = window.setTimeout(() => { + saveTimer = 0 + void flushPending('autosave').catch(() => undefined) + }, delay) +} + +async function flushPending(reason: string): Promise { + if (saveTimer) { + window.clearTimeout(saveTimer) + saveTimer = 0 + } + if (savePromise) { + await savePromise + if (pendingOperations.length > 0 && !conflicted) await flushPending(reason) + return + } + if (!project || !activePath || pendingOperations.length === 0) { + if (conflicted) throw new Error('Reload the conflicted deck before continuing.') + return + } + if (conflicted) throw new Error('Reload the conflicted deck before continuing.') + + const batch = pendingOperations.splice(0, MAX_PRESENTATION_OPERATIONS) + const expectedRevision = project.revision + ownSaveTargetRevision = expectedRevision + 1 + setSaveStatus(`Saving ${batch.length} edit${batch.length === 1 ? '' : 's'}…`, 'saving') + savePromise = (async () => { + try { + const response = await executeCommand('presentation-save', { + path: activePath, + expectedRevision, + operations: batch, + operationId: makeId(`ui-${reason}`) + } as unknown as JsonObject) + if (!project) return + if (pendingOperations.length === 0) { + project = response.project + } else { + project = { + ...project, + revision: response.currentRevision, + operationReceipts: response.project.operationReceipts + } + } + setSaveStatus( + response.warnings.length > 0 + ? `Saved revision ${response.currentRevision} · ${response.warnings.length} warning(s)` + : `Saved revision ${response.currentRevision}`, + 'saved' + ) + renderAll() + scheduleViewState() + } catch (error) { + pendingOperations.unshift(...batch) + const message = errorMessage(error) + if (/revision|conflict|stale/i.test(message)) setConflict(message) + else setSaveStatus(`Save failed: ${message}`, 'error') + throw error + } finally { + ownSaveTargetRevision = null + savePromise = null + } + })() + await savePromise + if (pendingOperations.length > 0 && !conflicted) await flushPending(reason) +} + +function undo(): void { + const entry = undoStack.pop() + if (!entry || !project || conflicted) return + try { + const result = applyPresentationOperations(project, entry.inverse) + project = result.project + pendingOperations.push(...entry.inverse) + redoStack.push(entry) + scheduleSave() + setSaveStatus(`Unsaved · undo ${entry.label}`, 'saving') + renderAll() + } catch (error) { + undoStack.push(entry) + setSaveStatus(errorMessage(error), 'error') + } +} + +function redo(): void { + const entry = redoStack.pop() + if (!entry || !project || conflicted) return + try { + const result = applyPresentationOperations(project, entry.forward) + project = result.project + pendingOperations.push(...entry.forward) + undoStack.push(entry) + scheduleSave() + setSaveStatus(`Unsaved · redo ${entry.label}`, 'saving') + renderAll() + } catch (error) { + redoStack.push(entry) + setSaveStatus(errorMessage(error), 'error') + } +} + +function addSlide(): void { + if (!project) return + const slide = createPresentationSlide(makeId('slide'), `Slide ${project.slides.length + 1}`) + const index = Math.max(0, project.slides.findIndex((candidate) => candidate.id === selectedSlideId) + 1) + if (localApply([{ kind: 'slide.insert', slide, index }], 'add slide')) selectSlide(slide.id) +} + +function duplicateSlide(): void { + const source = currentSlide() + if (!source || !project) return + const copy: PresentationSlide = { + ...structuredClone(source), + id: makeId('slide'), + title: `${source.title.slice(0, 115)} copy`, + elements: source.elements.map((element) => ({ ...element, id: makeId(element.type) })) + } + const index = project.slides.findIndex((slide) => slide.id === source.id) + 1 + if (localApply([{ kind: 'slide.insert', slide: copy, index }], 'duplicate slide')) selectSlide(copy.id) +} + +function deleteSlide(): void { + const slide = currentSlide() + if (!slide || !project || project.slides.length <= 1) return + const index = project.slides.findIndex((candidate) => candidate.id === slide.id) + const nextId = project.slides[index + 1]?.id ?? project.slides[index - 1]?.id ?? null + if (localApply([{ kind: 'slide.delete', slideId: slide.id }], 'delete slide')) { + selectedSlideId = nextId + selectedElementId = null + renderAll() + } +} + +function reorderSlide(slideId: string, index: number): void { + if (!project || conflicted) return + const current = project.slides.findIndex((slide) => slide.id === slideId) + if (current < 0 || current === index) return + localApply([{ kind: 'slide.reorder', slideId, index }], 'reorder slide') + selectedSlideId = slideId + renderAll() +} + +function insertElement(element: PresentationElement, label: string): void { + const slide = currentSlide() + if (!slide) return + if (localApply([{ kind: 'element.upsert', slideId: slide.id, element }], label)) { + selectedElementId = element.id + renderAll() + } +} + +function upsertElement(element: PresentationElement, label: string): void { + const slide = currentSlide() + if (!slide) return + localApply([{ kind: 'element.upsert', slideId: slide.id, element }], label) +} + +function deleteSelectedElement(): void { + const slide = currentSlide() + const element = currentElement() + if (!slide || !element) return + if (localApply([{ kind: 'element.delete', slideId: slide.id, elementId: element.id }], 'delete element')) { + selectedElementId = null + renderAll() + } +} + +function beginInlineEdit(element: PresentationTextElement): void { + inlineEditingId = element.id + selectedElementId = element.id + ui.inlineText.value = element.text + renderCanvas() + window.setTimeout(() => { + ui.inlineText.focus() + ui.inlineText.select() + }, 0) +} + +function commitInlineEdit(cancel = false): void { + if (!inlineEditingId) return + const element = currentElement() + inlineEditingId = null + ui.inlineHost.setAttribute('hidden', '') + if (!cancel && element?.type === 'text' && ui.inlineText.value !== element.text) { + upsertElement({ ...element, text: ui.inlineText.value }, 'edit text') + } else { + renderCanvas() + } +} + +function beginPointer(event: PointerEvent): void { + if (event.button !== 0 || conflicted) return + if (event.target instanceof Element && event.target.closest('.inline-editor-shell')) return + const slide = currentSlide() + if (!slide) return + const target = event.target instanceof Element ? event.target : null + const handle = target?.closest('[data-handle]')?.dataset.handle as PointerSession['handle'] + const elementNode = target?.closest('[data-element-id]') + const elementId = handle ? selectedElementId : elementNode?.dataset.elementId + const element = slide.elements.find((candidate) => candidate.id === elementId) + if (!element) { + if (!handle) selectElement(null) + return + } + event.preventDefault() + selectedElementId = element.id + pointerSession = { + pointerId: event.pointerId, + slideId: slide.id, + elementId: element.id, + mode: handle ? 'resize' : 'move', + ...(handle ? { handle } : {}), + startClientX: event.clientX, + startClientY: event.clientY, + original: structuredClone(element), + preview: structuredClone(element) + } + ui.canvas.setPointerCapture(event.pointerId) + renderCanvas() + renderInspector() +} + +function updatePointer(event: PointerEvent): void { + const session = pointerSession + if (!session || session.pointerId !== event.pointerId) return + event.preventDefault() + const rect = ui.canvas.getBoundingClientRect() + const dx = ((event.clientX - session.startClientX) / Math.max(1, rect.width)) * 100 + const dy = ((event.clientY - session.startClientY) / Math.max(1, rect.height)) * 100 + const original = session.original + let x = original.x + let y = original.y + let width = original.width + let height = original.height + if (session.mode === 'move') { + x = clamp(original.x + dx, 0, 100 - original.width) + y = clamp(original.y + dy, 0, 100 - original.height) + } else { + const min = 2 + if (session.handle === 'nw' || session.handle === 'sw') { + x = clamp(original.x + dx, 0, original.x + original.width - min) + width = original.width + (original.x - x) + } else { + width = clamp(original.width + dx, min, 100 - original.x) + } + if (session.handle === 'nw' || session.handle === 'ne') { + y = clamp(original.y + dy, 0, original.y + original.height - min) + height = original.height + (original.y - y) + } else { + height = clamp(original.height + dy, min, 100 - original.y) + } + } + session.preview = { ...original, x, y, width, height } + renderCanvas() +} + +function endPointer(event: PointerEvent): void { + const session = pointerSession + if (!session || session.pointerId !== event.pointerId) return + pointerSession = null + if (ui.canvas.hasPointerCapture(event.pointerId)) ui.canvas.releasePointerCapture(event.pointerId) + const changed = ['x', 'y', 'width', 'height'].some( + (key) => session.preview[key as 'x'] !== session.original[key as 'x'] + ) + if (changed) upsertElement(session.preview, session.mode === 'move' ? 'move element' : 'resize element') + else renderAll() +} + +function field( + labelText: string, + value: string, + onChange: (value: string) => void, + options: { type?: string; min?: string; max?: string; step?: string; multiline?: boolean } = {} +): HTMLLabelElement { + const label = html('label') + label.className = 'field' + const caption = html('span') + caption.textContent = labelText + const control = options.multiline ? html('textarea') : html('input') + if (control instanceof HTMLInputElement) { + control.type = options.type ?? 'text' + if (options.min) control.min = options.min + if (options.max) control.max = options.max + if (options.step) control.step = options.step + } + control.value = value + control.disabled = conflicted + if (options.multiline) { + control.addEventListener('blur', () => { + if (control.value !== value) onChange(control.value) + }) + } else { + control.addEventListener('change', () => onChange(control.value)) + } + label.append(caption, control) + return label +} + +function selectField( + labelText: string, + value: T, + choices: readonly T[], + onChange: (value: T) => void +): HTMLLabelElement { + const label = html('label') + label.className = 'field' + const caption = html('span') + caption.textContent = labelText + const select = html('select') + select.disabled = conflicted + for (const choice of choices) { + const option = html('option') + option.value = choice + option.textContent = choice + option.selected = choice === value + select.append(option) + } + select.addEventListener('change', () => onChange(select.value as T)) + label.append(caption, select) + return label +} + +function section(titleText: string, ...children: HTMLElement[]): HTMLElement { + const node = html('section') + node.className = 'inspector-section' + const title = html('h3') + title.textContent = titleText + node.append(title, ...children) + return node +} + +function geometryFields(element: PresentationElement): HTMLElement { + const grid = html('div') + grid.className = 'inspector-grid' + const number = (name: 'x' | 'y' | 'width' | 'height', label: string): HTMLLabelElement => { + const min = name === 'width' || name === 'height' ? 0.1 : 0 + const max = name === 'x' + ? 100 - element.width + : name === 'y' + ? 100 - element.height + : name === 'width' + ? 100 - element.x + : 100 - element.y + return field( + label, + String(element[name]), + (value) => upsertElement({ ...element, [name]: clamp(Number(value), min, max) }, `change ${label}`), + { type: 'number', min: String(min), max: String(max), step: '0.1' } + ) + } + grid.append(number('x', 'X %'), number('y', 'Y %'), number('width', 'Width %'), number('height', 'Height %')) + return grid +} + +function renderInspector(): void { + ui.inspectorBody.replaceChildren() + const slide = currentSlide() + const element = currentElement() + if (!project || !slide) { + ui.inspectorTitle.textContent = 'No selection' + const message = html('p') + message.className = 'muted' + message.textContent = 'Open or create a deck to edit its properties.' + ui.inspectorBody.append(message) + return + } + if (!element) { + ui.inspectorTitle.textContent = slide.title + ui.inspectorBody.append( + section( + 'Document', + field('Deck title', project.title, (title) => localApply([{ kind: 'document.update', patch: { title } }], 'rename deck')), + selectField('Typeface', project.theme.fontFamily, ['sans', 'serif', 'mono'] as const, (fontFamily) => + localApply([{ kind: 'document.update', patch: { theme: { fontFamily } } }], 'change typeface')), + field('Deck background', project.theme.backgroundColor, (backgroundColor) => + localApply([{ kind: 'document.update', patch: { theme: { backgroundColor } } }], 'change deck background'), { type: 'color' }), + field('Text color', project.theme.textColor, (textColor) => + localApply([{ kind: 'document.update', patch: { theme: { textColor } } }], 'change text color'), { type: 'color' }), + field('Accent color', project.theme.accentColor, (accentColor) => + localApply([{ kind: 'document.update', patch: { theme: { accentColor } } }], 'change accent'), { type: 'color' }) + ), + section( + 'Slide', + field('Slide title', slide.title, (title) => + localApply([{ kind: 'slide.update', slideId: slide.id, patch: { title } }], 'rename slide')), + field('Background', slide.backgroundColor ?? project.theme.backgroundColor, (backgroundColor) => + localApply([{ kind: 'slide.update', slideId: slide.id, patch: { backgroundColor } }], 'change slide background'), { type: 'color' }) + ) + ) + return + } + + ui.inspectorTitle.textContent = `${element.type} · ${element.id}` + const common = section( + 'Layout', + geometryFields(element), + field('Rotation', String(element.rotation), (value) => + upsertElement({ ...element, rotation: clamp(Number(value), -180, 180) }, 'rotate element'), + { type: 'number', min: '-180', max: '180', step: '1' }), + field('Opacity', String(element.opacity), (value) => + upsertElement({ ...element, opacity: clamp(Number(value), 0, 1) }, 'change opacity'), + { type: 'number', min: '0', max: '1', step: '0.05' }) + ) + ui.inspectorBody.append(common) + + if (element.type === 'text') { + ui.inspectorBody.append(section( + 'Text', + field('Content', element.text, (text) => upsertElement({ ...element, text }, 'edit text'), { multiline: true }), + field('Font size', String(element.fontSize), (value) => + upsertElement({ ...element, fontSize: clamp(Number(value), 8, 240) }, 'change font size'), + { type: 'number', min: '8', max: '240', step: '1' }), + selectField('Weight', String(element.fontWeight), ['400', '500', '600', '700'] as const, (weight) => + upsertElement({ ...element, fontWeight: Number(weight) as 400 | 500 | 600 | 700 }, 'change weight')), + field('Color', element.color, (color) => upsertElement({ ...element, color }, 'change text color'), { type: 'color' }), + selectField('Align', element.align, ['left', 'center', 'right'] as const, (align) => + upsertElement({ ...element, align }, 'change text align')), + selectField('Vertical', element.verticalAlign, ['top', 'middle', 'bottom'] as const, (verticalAlign) => + upsertElement({ ...element, verticalAlign }, 'change vertical align')) + )) + } else if (element.type === 'shape') { + ui.inspectorBody.append(section( + 'Shape', + selectField('Kind', element.shape, ['rectangle', 'ellipse', 'line'] as const, (shape) => + upsertElement({ ...element, shape }, 'change shape')), + field('Fill', element.fillColor, (fillColor) => upsertElement({ ...element, fillColor }, 'change fill'), { type: 'color' }), + field('Stroke', element.strokeColor, (strokeColor) => upsertElement({ ...element, strokeColor }, 'change stroke'), { type: 'color' }), + field('Stroke width', String(element.strokeWidth), (value) => + upsertElement({ ...element, strokeWidth: clamp(Number(value), 0, 32) }, 'change stroke width'), + { type: 'number', min: '0', max: '32', step: '1' }), + field('Corner radius', String(element.cornerRadius), (value) => + upsertElement({ ...element, cornerRadius: clamp(Number(value), 0, 100) }, 'change corner radius'), + { type: 'number', min: '0', max: '100', step: '1' }) + )) + } else { + ui.inspectorBody.append(section( + 'Image', + field('Workspace path', element.src, (src) => { + try { + upsertElement({ ...element, src: assertImagePath(src) }, 'change image') + } catch (error) { + setSaveStatus(errorMessage(error), 'error') + } + }), + field('Alt text', element.alt, (alt) => upsertElement({ ...element, alt }, 'change alt text')), + selectField('Fit', element.fit, ['contain', 'cover'] as const, (fit) => + upsertElement({ ...element, fit }, 'change image fit')) + )) + } + + const remove = html('button') + remove.type = 'button' + remove.className = 'button button-danger' + remove.textContent = 'Delete element' + remove.disabled = conflicted + remove.addEventListener('click', deleteSelectedElement) + ui.inspectorBody.append(remove) +} + +async function loadDeck(path: string, preferredSlideId?: string): Promise { + if (pendingOperations.length > 0) await flushPending('before-load') + setSaveStatus('Loading presentation…', 'saving') + const response = await executeCommand('presentation-load', { path }) + commitProject(response.project, response.path, preferredSlideId) +} + +async function createDeck(path: string): Promise { + if (pendingOperations.length > 0) await flushPending('before-create') + setSaveStatus('Creating presentation…', 'saving') + const response = await executeCommand('presentation-create', { + path, + title: path.replace(/\.kun-ppt\.html$/u, '').replaceAll('-', ' ') + }) + commitProject(response.project, response.path) +} + +function agentEventDetail(event: AgentRunEvent): string { + if (event.type === 'state' || event.type === 'terminal') return event.state + if (event.type === 'progress') return event.message + if (event.type === 'steering-accepted') return event.steeringId + if (event.type === 'usage') return JSON.stringify(event.usage).slice(0, 4000) + const content = typeof event.content === 'string' ? event.content : JSON.stringify(event.content) + return (content || event.role).slice(0, 4000) +} + +function appendAgentEvent(event: AgentRunEvent): void { + if (event.sequence <= lastAgentSequence && ui.agentEvents.childElementCount > 0) return + lastAgentSequence = Math.max(lastAgentSequence, event.sequence) + const item = html('li') + item.className = 'agent-event' + const kind = html('span') + kind.className = 'agent-event-kind' + kind.textContent = `${event.sequence} · ${event.type}` + const detail = html('span') + detail.className = 'agent-event-detail' + detail.textContent = agentEventDetail(event) + item.append(kind, detail) + ui.agentEvents.append(item) + item.scrollIntoView({ block: 'nearest' }) + if (event.type === 'state') setAgentStatus(event.state, event.state === 'running' ? 'saving' : 'idle') + if (event.type === 'terminal') { + setAgentStatus(event.state, event.state === 'completed' ? 'saved' : 'error') + activeRunId = null + renderControls() + } + scheduleViewState() +} + +async function observeRun(runId: string, afterSequence = 0): Promise { + await agentSubscription?.dispose() + agentSubscription = await client.agent.subscribe({ runId, afterSequence }) + agentSubscription.onEvent(appendAgentEvent) +} + +async function sendAgent(input: string): Promise { + if (!project || !activePath || conflicted) return + await flushPending('before-agent') + if (!project || pendingOperations.length > 0 || conflicted) { + throw new Error('The deck must be saved before the Agent can run.') + } + const contextualInput = [ + `Presentation file: ${activePath}`, + `Current revision: ${project.revision}`, + `Selected slide ID: ${selectedSlideId ?? 'none'}`, + '', + input + ].join('\n') + if (activeRunId) { + setAgentStatus('Sending steering…', 'saving') + const result = await client.agent.steer({ runId: activeRunId, input: contextualInput }) + if (!result.accepted) throw new Error('The running Agent did not accept steering.') + return + } + + ui.agentEvents.replaceChildren() + lastAgentSequence = 0 + setAgentStatus('Creating Agent run…', 'saving') + const { run } = await client.agent.createRun({ + input: contextualInput, + profileId: 'presentation-designer', + visibility: 'private', + metadata: { path: activePath, revision: project.revision, selectedSlideId: selectedSlideId ?? null }, + budget: { + maxTokens: 12_000, + maxElapsedMs: 900_000, + maxModelRequests: 24, + maxToolInvocations: 48, + maxEvents: 2_000 + } + }) + activeRunId = run.id + lastRunId = run.id + setAgentStatus(run.state, run.state === 'running' ? 'saving' : 'idle') + renderControls() + scheduleViewState() + await observeRun(run.id, 0) +} + +function isChangedPayload(value: JsonValue): value is JsonObject & PresentationChangedPayload { + if (value === null || Array.isArray(value) || typeof value !== 'object') return false + return typeof value.path === 'string' && + typeof value.revision === 'number' && + (value.source === 'command' || value.source === 'tool') && + Array.isArray(value.changedIds) +} + +async function handleHostMessage(message: HostMessage): Promise { + if (message.channel !== 'presentation.changed' || !isChangedPayload(message.payload)) return + const change = message.payload + if (!project || change.path !== activePath || change.revision <= project.revision) return + if (change.source === 'command' && ownSaveTargetRevision === change.revision) return + if (pendingOperations.length > 0 || savePromise) { + setConflict(`Revision ${change.revision} arrived while local edits were pending.`) + return + } + try { + const slideId = selectedSlideId ?? undefined + await loadDeck(activePath, slideId) + setSaveStatus(`Refreshed after ${change.source} change · revision ${change.revision}`, 'saved') + } catch (error) { + setConflict(`Could not refresh revision ${change.revision}: ${errorMessage(error)}`) + } +} + +function applyTheme(theme: Theme): void { + ui.studio.dataset.theme = theme.kind + document.documentElement.dataset.reducedMotion = String(theme.reducedMotion) +} + +function bindEvents(): void { + ui.newDeck.addEventListener('click', () => { + void createDeck(normalizePath(ui.path.value)).catch((error) => setSaveStatus(errorMessage(error), 'error')) + }) + ui.loadDeck.addEventListener('click', () => { + void loadDeck(normalizePath(ui.path.value)).catch((error) => setSaveStatus(errorMessage(error), 'error')) + }) + ui.reloadConflict.addEventListener('click', () => { + pendingOperations = [] + void loadDeck(activePath, selectedSlideId ?? undefined).catch((error) => setSaveStatus(errorMessage(error), 'error')) + }) + ui.addSlide.addEventListener('click', addSlide) + ui.duplicateSlide.addEventListener('click', duplicateSlide) + ui.deleteSlide.addEventListener('click', deleteSlide) + ui.undo.addEventListener('click', undo) + ui.redo.addEventListener('click', redo) + ui.addText.addEventListener('click', () => { + if (!project) return + insertElement(createTextElement(makeId('text'), { color: project.theme.textColor }), 'add text') + }) + ui.addShape.addEventListener('click', () => { + if (!project) return + insertElement(createShapeElement(makeId('shape'), { + fillColor: project.theme.accentColor, + strokeColor: project.theme.accentColor + }), 'add shape') + }) + ui.openImage.addEventListener('click', () => { + ui.imagePath.value = '' + ui.imageError.textContent = '' + ui.imageDialog.showModal() + ui.imagePath.focus() + }) + ui.imageForm.addEventListener('submit', (event) => { + event.preventDefault() + const path = ui.imagePath.value + ui.imageError.textContent = 'Loading image…' + void resolveImage(path) + .then(() => { + insertElement(createImageElement(makeId('image'), assertImagePath(path), { alt: path }), 'add image') + ui.imageDialog.close() + }) + .catch((error) => { ui.imageError.textContent = errorMessage(error) }) + }) + ui.openExport.addEventListener('click', () => { + ui.exportError.textContent = '' + ui.exportPath.value = activePath.replace(/\.kun-ppt\.html$/u, '-copy.kun-ppt.html') + ui.exportDialog.showModal() + ui.exportPath.focus() + }) + ui.exportForm.addEventListener('submit', (event) => { + event.preventDefault() + void (async () => { + if (!project) return + await flushPending('before-copy') + const destinationPath = normalizePath(ui.exportPath.value) + if (destinationPath === activePath) throw new Error('Choose a different destination filename.') + const response = await executeCommand('presentation-export-copy', { + path: activePath, + destinationPath, + expectedRevision: project.revision + }) + ui.exportDialog.close() + setSaveStatus(`Exported ${response.destinationPath} · ${response.bytes} bytes`, 'saved') + })().catch((error) => { ui.exportError.textContent = errorMessage(error) }) + }) + for (const close of document.querySelectorAll('[data-close-dialog]')) { + close.addEventListener('click', () => { + const dialog = document.getElementById(close.dataset.closeDialog ?? '') + if (dialog instanceof HTMLDialogElement) dialog.close() + }) + } + ui.openPreview.addEventListener('click', () => { + if (!project) return + previewIndex = Math.max(0, project.slides.findIndex((slide) => slide.id === selectedSlideId)) + renderPreview() + ui.previewDialog.showModal() + }) + ui.previewPrev.addEventListener('click', () => { previewIndex -= 1; renderPreview() }) + ui.previewNext.addEventListener('click', () => { previewIndex += 1; renderPreview() }) + ui.previewDialog.addEventListener('keydown', (event) => { + if (event.key === 'ArrowLeft') { event.preventDefault(); previewIndex -= 1; renderPreview() } + if (event.key === 'ArrowRight') { event.preventDefault(); previewIndex += 1; renderPreview() } + }) + + ui.canvas.addEventListener('pointerdown', beginPointer) + ui.canvas.addEventListener('pointermove', updatePointer) + ui.canvas.addEventListener('pointerup', endPointer) + ui.canvas.addEventListener('pointercancel', endPointer) + ui.canvas.addEventListener('dblclick', (event) => { + const target = event.target instanceof Element ? event.target.closest('[data-element-id]') : null + const element = currentSlide()?.elements.find((candidate) => candidate.id === target?.dataset.elementId) + if (element?.type === 'text') beginInlineEdit(element) + }) + ui.canvas.addEventListener('keydown', (event) => { + if (inlineEditingId) return + const element = currentElement() + if ((event.key === 'Delete' || event.key === 'Backspace') && element) { + event.preventDefault() + deleteSelectedElement() + return + } + if (event.key === 'Enter' && element?.type === 'text') { + event.preventDefault() + beginInlineEdit(element) + return + } + if (!element || !['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) return + event.preventDefault() + const step = event.shiftKey ? 1 : 0.25 + const next = { + ...element, + x: clamp(element.x + (event.key === 'ArrowLeft' ? -step : event.key === 'ArrowRight' ? step : 0), 0, 100 - element.width), + y: clamp(element.y + (event.key === 'ArrowUp' ? -step : event.key === 'ArrowDown' ? step : 0), 0, 100 - element.height) + } + upsertElement(next, 'nudge element') + }) + ui.inlineText.addEventListener('blur', () => commitInlineEdit()) + ui.inlineText.addEventListener('keydown', (event) => { + if (event.key === 'Escape') { event.preventDefault(); commitInlineEdit(true) } + if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { event.preventDefault(); commitInlineEdit() } + }) + document.addEventListener('keydown', (event) => { + if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement || event.target instanceof HTMLSelectElement) return + const modifier = event.metaKey || event.ctrlKey + if (modifier && event.key.toLowerCase() === 'z') { + event.preventDefault() + if (event.shiftKey) redo() + else undo() + } + }) + + ui.agentForm.addEventListener('submit', (event) => { + event.preventDefault() + const input = ui.agentPrompt.value.trim() + if (!input) return + ui.agentPrompt.value = '' + void sendAgent(input).catch((error) => setAgentStatus(errorMessage(error), 'error')) + }) + ui.cancelAgent.addEventListener('click', () => { + if (!activeRunId) return + setAgentStatus('Cancelling…', 'saving') + void client.agent.cancel({ runId: activeRunId, reason: 'Cancelled from Presentation Studio' }) + .catch((error) => setAgentStatus(errorMessage(error), 'error')) + }) + client.ui.onDidReceiveMessage((message) => void handleHostMessage(message)) + client.ui.onDidChangeTheme(applyTheme) + client.ui.onDidChangeLocale((locale) => { + document.documentElement.lang = locale.language + document.documentElement.dir = locale.direction + }) +} + +async function restoreAgent(runId: string): Promise { + try { + const run = await client.agent.getRun(runId) + lastRunId = run.id + activeRunId = TERMINAL_RUN_STATES.has(run.state) ? null : run.id + setAgentStatus(run.state, run.state === 'completed' ? 'saved' : TERMINAL_RUN_STATES.has(run.state) ? 'error' : 'saving') + renderControls() + await observeRun(run.id, 0) + } catch { + lastRunId = null + activeRunId = null + setAgentStatus('Previous run unavailable') + } +} + +async function initialize(): Promise { + bindEvents() + const [theme, locale, restored] = await Promise.all([ + client.ui.getTheme(), + client.ui.getLocale(), + client.ui.getViewState() + ]) + applyTheme(theme) + document.documentElement.lang = locale.language + document.documentElement.dir = locale.direction + renderAll() + if (restored?.path) { + ui.path.value = restored.path + try { + await loadDeck(normalizePath(restored.path), restored.selectedSlideId) + } catch (error) { + setSaveStatus(`Could not restore deck: ${errorMessage(error)}`, 'error') + } + } + if (restored?.lastRunId) await restoreAgent(restored.lastRunId) +} + +window.addEventListener('pagehide', () => { + void (async () => { + try { + await flushPending('pagehide') + } catch { + // The visible conflict/error state already explains why the save was not completed. + } + await agentSubscription?.dispose() + await client.dispose() + })() +}, { once: true }) + +await initialize() diff --git a/examples/extensions/presentation-studio/src/webview/styles.css b/examples/extensions/presentation-studio/src/webview/styles.css new file mode 100644 index 000000000..bcb618261 --- /dev/null +++ b/examples/extensions/presentation-studio/src/webview/styles.css @@ -0,0 +1,1052 @@ +:root { + color-scheme: dark; + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-synthesis: none; + text-rendering: optimizeLegibility; +} + +* { + box-sizing: border-box; +} + +html, +body { + min-width: 320px; + min-height: 100%; + margin: 0; +} + +body { + overflow: hidden; + background: #0a0c11; +} + +button, +input, +textarea, +select { + color: inherit; + font: inherit; +} + +button, +input, +textarea { + border: 0; +} + +button { + cursor: pointer; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.46; +} + +button:focus-visible, +input:focus-visible, +textarea:focus-visible, +select:focus-visible, +[tabindex]:focus-visible { + outline: 2px solid var(--focus); + outline-offset: 2px; +} + +[hidden] { + display: none !important; +} + +.studio { + --bg: #0a0c11; + --panel: #10131a; + --panel-raised: #171b25; + --panel-hover: #1d2230; + --border: #2a3040; + --border-strong: #3d465c; + --text: #f4f6fb; + --muted: #959db0; + --faint: #6c7487; + --accent: #7c6cff; + --accent-strong: #958aff; + --accent-soft: rgba(124, 108, 255, 0.16); + --danger: #ff707d; + --danger-soft: rgba(255, 112, 125, 0.12); + --warning: #f4bd5b; + --success: #61d095; + --focus: #a99fff; + --shadow: 0 18px 60px rgba(0, 0, 0, 0.38); + --slide-background: #ffffff; + display: grid; + grid-template-rows: auto auto minmax(0, 1fr); + height: 100vh; + min-height: 560px; + overflow: hidden; + color: var(--text); + background: var(--bg); +} + +.studio[data-theme="light"] { + --bg: #edf0f5; + --panel: #f9fafc; + --panel-raised: #ffffff; + --panel-hover: #eef1f7; + --border: #d6dbe5; + --border-strong: #b9c0cf; + --text: #181b23; + --muted: #60697b; + --faint: #7e8798; + --accent: #5b49e8; + --accent-strong: #4938ca; + --accent-soft: rgba(91, 73, 232, 0.11); + --danger: #c93d4c; + --danger-soft: rgba(201, 61, 76, 0.1); + --warning: #9b6813; + --success: #198754; + --focus: #5b49e8; + --shadow: 0 18px 60px rgba(34, 40, 58, 0.2); + color-scheme: light; +} + +.studio[data-theme="high-contrast"] { + --bg: #000000; + --panel: #000000; + --panel-raised: #080808; + --panel-hover: #171717; + --border: #ffffff; + --border-strong: #ffffff; + --text: #ffffff; + --muted: #ffffff; + --faint: #d7d7d7; + --accent: #00e5ff; + --accent-strong: #ffffff; + --accent-soft: rgba(0, 229, 255, 0.2); + --danger: #ff5e6c; + --danger-soft: rgba(255, 94, 108, 0.2); + --warning: #ffe45c; + --success: #5cff9b; + --focus: #ffe45c; + --shadow: none; +} + +.topbar { + z-index: 5; + display: grid; + grid-template-columns: auto minmax(260px, 1fr) auto minmax(120px, auto); + gap: 16px; + align-items: center; + min-height: 66px; + padding: 10px 16px; + border-bottom: 1px solid var(--border); + background: color-mix(in srgb, var(--panel) 95%, transparent); +} + +.brand { + display: flex; + gap: 10px; + align-items: center; + min-width: 188px; +} + +.brand-mark { + display: grid; + width: 34px; + height: 34px; + place-items: center; + border-radius: 10px; + color: #ffffff; + font-weight: 800; + background: linear-gradient(145deg, #958aff, #5344d1); + box-shadow: 0 7px 18px rgba(91, 73, 232, 0.3); +} + +.brand-copy { + display: grid; + gap: 1px; +} + +.brand-copy strong { + font-size: 13px; + letter-spacing: 0.01em; +} + +.brand-copy small { + color: var(--muted); + font-size: 10px; +} + +.path-field { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 9px; + align-items: center; + min-width: 0; +} + +.path-field > span, +.field > span { + color: var(--muted); + font-size: 11px; + font-weight: 650; + letter-spacing: 0.03em; +} + +.path-field input, +.field input, +.field textarea, +.field select, +.agent-composer textarea, +.inspector-body input, +.inspector-body textarea, +.inspector-body select { + width: 100%; + min-width: 0; + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text); + background: var(--panel-raised); +} + +.path-field input, +.field input, +.field select, +.inspector-body input, +.inspector-body select { + height: 34px; + padding: 0 10px; +} + +.path-field input:focus, +.field input:focus, +.field textarea:focus, +.field select:focus, +.agent-composer textarea:focus, +.inspector-body input:focus, +.inspector-body textarea:focus, +.inspector-body select:focus { + border-color: var(--accent); +} + +.topbar-actions, +.toolbar-group, +.preview-actions, +.agent-actions, +.rail-actions { + display: flex; + gap: 7px; + align-items: center; +} + +.save-state, +.agent-state { + max-width: 210px; + overflow: hidden; + color: var(--muted); + font-size: 11px; + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; +} + +.save-state[data-tone="saving"], +.agent-state[data-tone="running"] { + color: var(--warning); +} + +.save-state[data-tone="saved"], +.agent-state[data-tone="completed"] { + color: var(--success); +} + +.save-state[data-tone="error"], +.agent-state[data-tone="failed"] { + color: var(--danger); +} + +.button, +.icon-button { + display: inline-grid; + place-items: center; + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text); + background: var(--panel-raised); + transition: + border-color 120ms ease, + background-color 120ms ease, + color 120ms ease, + transform 120ms ease; +} + +.button { + min-height: 34px; + padding: 0 12px; + font-size: 12px; + font-weight: 650; +} + +.button-compact { + min-height: 29px; + padding: 0 9px; + font-size: 11px; +} + +.icon-button { + width: 32px; + height: 32px; + padding: 0; + font-size: 17px; +} + +.button:not(:disabled):hover, +.icon-button:not(:disabled):hover { + border-color: var(--border-strong); + background: var(--panel-hover); +} + +.button:not(:disabled):active, +.icon-button:not(:disabled):active { + transform: translateY(1px); +} + +.button-primary { + border-color: var(--accent); + color: #ffffff; + background: var(--accent); +} + +.button-primary:not(:disabled):hover { + border-color: var(--accent-strong); + background: var(--accent-strong); +} + +.button-danger { + color: var(--danger); +} + +.button-danger:not(:disabled):hover { + border-color: var(--danger); + background: var(--danger-soft); +} + +.conflict-banner { + z-index: 4; + display: flex; + gap: 14px; + align-items: center; + justify-content: space-between; + padding: 9px 16px; + border-bottom: 1px solid var(--danger); + background: var(--danger-soft); +} + +.conflict-banner > div { + display: flex; + gap: 9px; + align-items: baseline; + min-width: 0; + font-size: 12px; +} + +.conflict-banner span { + overflow: hidden; + color: var(--muted); + text-overflow: ellipsis; + white-space: nowrap; +} + +.workspace-grid { + display: grid; + grid-template-columns: minmax(188px, 228px) minmax(420px, 1fr) minmax(248px, 300px); + min-height: 0; + overflow: hidden; +} + +.slide-rail, +.inspector, +.editor-column { + min-width: 0; + min-height: 0; +} + +.slide-rail, +.inspector { + display: grid; + background: var(--panel); +} + +.slide-rail { + grid-template-rows: auto minmax(0, 1fr) auto; + border-right: 1px solid var(--border); +} + +.inspector { + grid-template-rows: auto minmax(0, 1fr); + border-left: 1px solid var(--border); +} + +.panel-header { + display: flex; + gap: 10px; + align-items: center; + justify-content: space-between; + min-height: 59px; + padding: 12px 13px; + border-bottom: 1px solid var(--border); +} + +.panel-header > div, +.agent-header > div { + display: grid; + gap: 3px; + min-width: 0; +} + +.panel-header strong, +.agent-header strong { + overflow: hidden; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.eyebrow { + color: var(--faint); + font-size: 9px; + font-weight: 750; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.slide-list { + display: flex; + flex-direction: column; + gap: 10px; + min-height: 0; + margin: 0; + padding: 12px; + overflow: auto; + list-style: none; +} + +.slide-card { + display: grid; + grid-template-columns: 22px minmax(0, 1fr); + gap: 7px; + align-items: start; + width: 100%; + padding: 0; + border: 0; + color: var(--text); + text-align: left; + background: transparent; +} + +.slide-card-number { + padding-top: 4px; + color: var(--faint); + font-size: 10px; + text-align: right; +} + +.slide-thumbnail-shell { + display: grid; + gap: 6px; +} + +.slide-thumbnail { + position: relative; + width: 100%; + overflow: hidden; + border: 2px solid transparent; + border-radius: 7px; + background: #ffffff; + box-shadow: 0 5px 18px rgba(0, 0, 0, 0.2); + aspect-ratio: 16 / 9; +} + +.slide-thumbnail svg { + display: block; + width: 100%; + height: 100%; +} + +.slide-card[aria-selected="true"] .slide-thumbnail { + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-soft), 0 5px 18px rgba(0, 0, 0, 0.22); +} + +.slide-card:hover .slide-thumbnail { + border-color: var(--border-strong); +} + +.slide-card[aria-selected="true"]:hover .slide-thumbnail { + border-color: var(--accent-strong); +} + +.slide-thumbnail-title { + overflow: hidden; + color: var(--muted); + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.slide-card[aria-selected="true"] .slide-thumbnail-title { + color: var(--text); +} + +.slide-card[data-dragging="true"] { + opacity: 0.45; +} + +.slide-card[data-drop-target="before"] { + border-top: 2px solid var(--accent); +} + +.slide-card[data-drop-target="after"] { + border-bottom: 2px solid var(--accent); +} + +.rail-actions { + justify-content: stretch; + padding: 10px 12px; + border-top: 1px solid var(--border); +} + +.rail-actions .button { + flex: 1; +} + +.editor-column { + display: grid; + grid-template-rows: auto minmax(260px, 1fr) auto minmax(174px, 28vh); + overflow: hidden; + background: + radial-gradient(circle at 50% 35%, var(--panel-hover), transparent 48%), + var(--bg); +} + +.canvas-toolbar { + display: flex; + gap: 8px; + align-items: center; + min-height: 48px; + padding: 8px 12px; + border-bottom: 1px solid var(--border); + background: var(--panel); +} + +.toolbar-divider { + width: 1px; + height: 22px; + background: var(--border); +} + +.toolbar-spacer { + flex: 1; +} + +.canvas-viewport { + display: grid; + min-height: 0; + padding: clamp(14px, 3vw, 42px); + overflow: auto; + place-items: center; +} + +.slide-canvas, +.preview-canvas { + display: block; + width: min(100%, calc((100vh - 390px) * 16 / 9)); + min-width: 280px; + max-width: 1280px; + overflow: visible; + border: 1px solid rgba(0, 0, 0, 0.23); + border-radius: 3px; + background: #ffffff; + box-shadow: 0 24px 70px rgba(0, 0, 0, 0.35); + aspect-ratio: 16 / 9; + touch-action: none; +} + +.canvas-background { + fill: var(--slide-background); +} + +.canvas-item { + cursor: move; +} + +.canvas-item[data-kind="text"] { + cursor: text; +} + +.canvas-item:focus { + outline: none; +} + +.selection-outline { + fill: none; + stroke: #7c6cff; + stroke-width: 3; + vector-effect: non-scaling-stroke; + pointer-events: none; +} + +.selection-handle { + fill: #ffffff; + stroke: #5b49e8; + stroke-width: 2; + vector-effect: non-scaling-stroke; + cursor: nwse-resize; +} + +.selection-handle[data-handle="ne"], +.selection-handle[data-handle="sw"] { + cursor: nesw-resize; +} + +.inline-editor-shell { + width: 100%; + height: 100%; + padding: 3px; + background: rgba(124, 108, 255, 0.16); +} + +.inline-editor-shell textarea { + width: 100%; + height: 100%; + resize: none; + border: 2px solid #7c6cff; + border-radius: 2px; + padding: 5px; + color: #11131a; + background: #ffffff; + outline: none; +} + +.image-placeholder { + fill: #e6e8ee; + stroke: #9ca3b2; + stroke-dasharray: 12 8; +} + +.image-placeholder-mark { + fill: #737b8c; + font-family: ui-sans-serif, system-ui, sans-serif; + font-size: 44px; + font-weight: 700; + text-anchor: middle; + dominant-baseline: central; + pointer-events: none; +} + +.canvas-footer { + display: flex; + gap: 12px; + align-items: center; + justify-content: space-between; + min-height: 30px; + padding: 4px 13px; + border-top: 1px solid var(--border); + color: var(--faint); + font-size: 10px; + background: var(--panel); +} + +.empty-state { + display: grid; + max-width: 380px; + place-items: center; + color: var(--muted); + text-align: center; +} + +.empty-state h1 { + margin: 12px 0 6px; + color: var(--text); + font-size: 20px; +} + +.empty-state p { + margin: 0; + font-size: 12px; + line-height: 1.6; +} + +.empty-state code { + color: var(--accent-strong); +} + +.empty-icon { + display: grid; + width: 58px; + height: 58px; + place-items: center; + border: 1px solid var(--border-strong); + border-radius: 18px; + color: var(--accent-strong); + font-size: 28px; + background: var(--accent-soft); +} + +.inspector-body { + display: flex; + flex-direction: column; + gap: 15px; + min-height: 0; + padding: 14px; + overflow: auto; +} + +.inspector-section { + display: grid; + gap: 10px; + padding-bottom: 14px; + border-bottom: 1px solid var(--border); +} + +.inspector-section:last-child { + border-bottom: 0; +} + +.inspector-section h3 { + margin: 0; + color: var(--muted); + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.inspector-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 9px; +} + +.field { + display: grid; + gap: 6px; +} + +.field textarea, +.inspector-body textarea { + min-height: 76px; + padding: 8px 10px; + resize: vertical; + line-height: 1.45; +} + +.field small, +.muted { + color: var(--muted); + font-size: 11px; + line-height: 1.5; +} + +.field-error { + min-height: 18px; + margin: 0; + color: var(--danger); + font-size: 11px; +} + +.color-control { + display: grid; + grid-template-columns: 38px minmax(0, 1fr); + gap: 7px; + align-items: center; +} + +.color-control input[type="color"] { + width: 38px; + padding: 3px; +} + +.agent-dock { + display: grid; + grid-template-rows: auto minmax(55px, 1fr) auto; + min-height: 0; + border-top: 1px solid var(--border); + background: var(--panel); +} + +.agent-header { + display: flex; + gap: 12px; + align-items: center; + justify-content: space-between; + padding: 9px 13px; + border-bottom: 1px solid var(--border); +} + +.agent-events { + display: flex; + flex-direction: column; + gap: 7px; + min-height: 0; + margin: 0; + padding: 9px 13px; + overflow: auto; + list-style: none; +} + +.agent-event { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 8px; + align-items: start; + font-size: 11px; + line-height: 1.45; +} + +.agent-event-kind { + min-width: 54px; + color: var(--accent-strong); + font-weight: 700; +} + +.agent-event-detail { + overflow-wrap: anywhere; + color: var(--muted); + white-space: pre-wrap; +} + +.agent-composer { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 9px; + align-items: end; + padding: 9px 13px 11px; + border-top: 1px solid var(--border); +} + +.agent-composer textarea { + min-height: 48px; + max-height: 100px; + padding: 8px 10px; + resize: vertical; + line-height: 1.4; +} + +.modal, +.preview-dialog { + padding: 0; + border: 1px solid var(--border-strong); + color: var(--text); + background: var(--panel-raised); + box-shadow: var(--shadow); +} + +.modal { + width: min(460px, calc(100vw - 32px)); + border-radius: 14px; +} + +.modal::backdrop, +.preview-dialog::backdrop { + background: rgba(2, 4, 9, 0.78); +} + +.modal-card { + display: grid; + gap: 18px; + padding: 18px; +} + +.modal-card > header, +.modal-card > footer, +.preview-header { + display: flex; + gap: 12px; + align-items: center; + justify-content: space-between; +} + +.modal-card h2 { + margin: 4px 0 0; + font-size: 17px; +} + +.modal-card footer { + justify-content: flex-end; +} + +.preview-dialog { + width: 100vw; + max-width: none; + height: 100vh; + max-height: none; + margin: 0; + border: 0; + background: #07090d; +} + +.preview-header { + min-height: 58px; + padding: 10px 16px; + border-bottom: 1px solid #282d39; + color: #f5f6fa; + background: #10131a; +} + +.preview-header > div:first-child { + display: grid; + gap: 3px; +} + +.preview-stage { + display: grid; + height: calc(100vh - 58px); + padding: clamp(12px, 3vw, 46px); + overflow: auto; + place-items: center; +} + +.preview-canvas { + width: min(100%, calc((100vh - 120px) * 16 / 9)); + max-width: none; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +@media (max-width: 1180px) { + .topbar { + grid-template-columns: auto minmax(220px, 1fr) auto; + } + + .save-state { + display: none; + } + + .workspace-grid { + grid-template-columns: 180px minmax(380px, 1fr) 245px; + } + + .brand-copy small { + display: none; + } +} + +@media (max-width: 900px) { + body { + overflow: auto; + } + + .studio { + height: auto; + min-height: 100vh; + overflow: visible; + } + + .topbar { + grid-template-columns: auto minmax(180px, 1fr); + } + + .topbar-actions { + grid-column: 1 / -1; + } + + .workspace-grid { + grid-template-columns: 1fr; + grid-template-rows: auto minmax(620px, 1fr) auto; + overflow: visible; + } + + .slide-rail { + grid-template-rows: auto auto auto; + border-right: 0; + border-bottom: 1px solid var(--border); + } + + .slide-list { + flex-direction: row; + overflow-x: auto; + } + + .slide-card { + flex: 0 0 150px; + } + + .editor-column { + min-height: 620px; + } + + .inspector { + min-height: 320px; + border-top: 1px solid var(--border); + border-left: 0; + } +} + +@media (max-width: 560px) { + .topbar { + grid-template-columns: 1fr; + } + + .brand, + .path-field, + .topbar-actions { + grid-column: 1; + } + + .path-field { + grid-template-columns: 1fr; + } + + .topbar-actions .button { + flex: 1; + } + + .canvas-toolbar { + flex-wrap: wrap; + } + + .toolbar-spacer { + display: none; + } + + .canvas-viewport { + padding: 12px; + } + + .slide-canvas { + min-width: 260px; + } + + .agent-composer { + grid-template-columns: 1fr; + } + + .agent-actions { + justify-content: flex-end; + } + + .conflict-banner, + .conflict-banner > div { + align-items: stretch; + flex-direction: column; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + } +} diff --git a/examples/extensions/presentation-studio/tsconfig.host.json b/examples/extensions/presentation-studio/tsconfig.host.json new file mode 100644 index 000000000..de6221bb5 --- /dev/null +++ b/examples/extensions/presentation-studio/tsconfig.host.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src/host/**/*.ts", "src/shared/**/*.ts"] +} diff --git a/examples/extensions/presentation-studio/tsconfig.webview.json b/examples/extensions/presentation-studio/tsconfig.webview.json new file mode 100644 index 000000000..3f69f8756 --- /dev/null +++ b/examples/extensions/presentation-studio/tsconfig.webview.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "rootDir": "src", + "outDir": "dist/webview", + "noEmit": true, + "skipLibCheck": true + }, + "include": ["src/webview/**/*.ts", "src/shared/**/*.ts"] +} diff --git a/examples/extensions/presentation-studio/vite.config.ts b/examples/extensions/presentation-studio/vite.config.ts new file mode 100644 index 000000000..109b03dca --- /dev/null +++ b/examples/extensions/presentation-studio/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite' + +export default defineConfig({ + root: 'src/webview', + base: './', + css: { postcss: { plugins: [] } }, + build: { + target: 'es2022', + outDir: '../../dist/webview', + emptyOutDir: true + } +}) diff --git a/examples/extensions/presentation-studio/vite.host.config.ts b/examples/extensions/presentation-studio/vite.host.config.ts new file mode 100644 index 000000000..e3889ce52 --- /dev/null +++ b/examples/extensions/presentation-studio/vite.host.config.ts @@ -0,0 +1,22 @@ +import { fileURLToPath } from 'node:url' +import { defineConfig } from 'vite' + +export default defineConfig({ + build: { + target: 'node20', + outDir: 'dist/host', + emptyOutDir: true, + sourcemap: false, + minify: false, + lib: { + entry: fileURLToPath(new URL('src/host/extension.ts', import.meta.url)), + formats: ['es'], + fileName: () => 'extension.js' + }, + rollupOptions: { + output: { + inlineDynamicImports: true + } + } + } +}) diff --git a/kun/src/adapters/tool/extension-tool-provider.test.ts b/kun/src/adapters/tool/extension-tool-provider.test.ts index 8822fb1de..852659bd5 100644 --- a/kun/src/adapters/tool/extension-tool-provider.test.ts +++ b/kun/src/adapters/tool/extension-tool-provider.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { ExtensionApiError } from '@kun/extension-api' import type { ToolHostContext } from '../../ports/tool-host.js' import type { ExtensionPrincipal } from '../../services/extension-agent-service.js' import { CapabilityRegistry } from './capability-registry.js' @@ -45,6 +46,13 @@ const echoDeclaration = { } describe('ExtensionToolRegistry', () => { + it('keeps the Presentation Studio direct-tool namespace stable for GUI provenance', () => { + expect(extensionToolModelAlias( + 'kun-examples.presentation-studio', + 'presentation-create' + )).toBe('ext_e1d66f1c97_presentation-create') + }) + it('derives collision-free identities and executes through LocalToolHost', async () => { const capabilities = new CapabilityRegistry() const tools = new ExtensionToolRegistry({ registry: capabilities }) @@ -164,6 +172,73 @@ describe('ExtensionToolRegistry', () => { }) }) + it('keeps deterministic public API rejections retryable without relaxing unknown outcomes', async () => { + const capabilities = new CapabilityRegistry() + const tools = new ExtensionToolRegistry({ registry: capabilities }) + let conflictCalls = 0 + const conflict = await tools.register(principal('com.example.conflict'), { + ...echoDeclaration, + name: 'save', + sideEffect: 'workspace-write' + }, async () => { + conflictCalls += 1 + throw new ExtensionApiError({ + code: 'CONFLICT', + message: 'The expected revision is stale.', + retryable: true, + details: { expectedRevision: 2, actualRevision: 3 } + }) + }) + let unavailableCalls = 0 + const unavailable = await tools.register(principal('com.example.unavailable'), { + ...echoDeclaration, + name: 'send', + sideEffect: 'external' + }, async () => { + unavailableCalls += 1 + throw new ExtensionApiError({ + code: 'HOST_UNAVAILABLE', + message: 'The extension host disconnected.', + retryable: true + }) + }) + const host = new LocalToolHost({ registry: capabilities }) + const awaitApproval = vi.fn(async () => 'allow' as const) + const conflictCall = { + callId: 'call_conflict', toolName: conflict.modelAlias, arguments: { text: 'save' } + } + const firstConflict = await host.execute(conflictCall, context({ awaitApproval })) + const secondConflict = await host.execute(conflictCall, context({ awaitApproval })) + + expect(conflictCalls).toBe(2) + expect(firstConflict.item).toMatchObject({ + isError: true, + output: { + code: 'tool_execution_failed', + error: 'The expected revision is stale.' + } + }) + expect(secondConflict.item).toMatchObject({ + isError: true, + output: { code: 'tool_execution_failed' } + }) + + const unavailableCall = { + callId: 'call_unavailable', toolName: unavailable.modelAlias, arguments: { text: 'send' } + } + const firstUnavailable = await host.execute(unavailableCall, context({ awaitApproval })) + const secondUnavailable = await host.execute(unavailableCall, context({ awaitApproval })) + expect(unavailableCalls).toBe(1) + expect(firstUnavailable.item).toMatchObject({ + isError: true, + output: { error: expect.stringContaining('outcome is unknown') } + }) + expect(secondUnavailable.item).toMatchObject({ + isError: true, + output: { code: 'tool_outcome_unknown' } + }) + }) + it('bounds output and cancels in-flight handlers when disposed', async () => { const capabilities = new CapabilityRegistry() const tools = new ExtensionToolRegistry({ registry: capabilities }) @@ -244,7 +319,8 @@ describe('ExtensionToolRegistry', () => { await tools.register(principal('com.example.many'), { ...echoDeclaration, name: `tool_${index}`, - description: `Catalog utility number ${index}` + description: `Catalog utility number ${index}`, + sideEffect: index === MAX_DIRECT_EXTENSION_TOOLS ? 'workspace-write' : 'none' }, async ({ arguments: args }) => ({ output: { echoed: args.text, index } })) } const epoch = tools.createCatalogEpoch({ id: 'epoch_many', createdAt: '2026-07-11T00:00:00.000Z' }) @@ -278,6 +354,7 @@ describe('ExtensionToolRegistry', () => { expect(called.item).toMatchObject({ output: { canonicalToolId: `extension:com.example.many/tool_${MAX_DIRECT_EXTENSION_TOOLS}`, + sideEffect: 'workspace-write', result: { echoed: 'hello', index: MAX_DIRECT_EXTENSION_TOOLS } } }) diff --git a/kun/src/adapters/tool/extension-tool-provider.ts b/kun/src/adapters/tool/extension-tool-provider.ts index be5e99654..98a9bf00d 100644 --- a/kun/src/adapters/tool/extension-tool-provider.ts +++ b/kun/src/adapters/tool/extension-tool-provider.ts @@ -1,4 +1,5 @@ import { createHash, randomUUID } from 'node:crypto' +import { ExtensionApiError, type ExtensionErrorCode } from '@kun/extension-api' import type { ExtensionPrincipal } from '../../services/extension-agent-service.js' import type { ExtensionToolCatalogEntry, @@ -101,6 +102,21 @@ const ABSOLUTE_MAX_OUTPUT_BYTES = 1024 * 1024 const MAX_PROGRESS_UPDATES = 64 const MAX_PROGRESS_BYTES = 64 * 1024 export const MAX_DIRECT_EXTENSION_TOOLS = 16 +const KNOWN_PRE_COMMIT_EXTENSION_API_ERROR_CODES: ReadonlySet = new Set([ + 'INVALID_ARGUMENT', + 'VALIDATION_FAILED', + 'PERMISSION_DENIED', + 'NOT_FOUND', + 'CONFLICT', + 'UNSUPPORTED_CAPABILITY', + 'INCOMPATIBLE_API', + 'INCOMPATIBLE_MANIFEST', + 'INCOMPATIBLE_ENGINE', + 'INCOMPATIBLE_RPC', + 'INTERACTION_REQUIRED', + 'ACCOUNT_REQUIRED', + 'RESOURCE_LIMIT' +]) /** * Dynamic Extension Tool Provider. It adapts host-process handlers into @@ -498,6 +514,7 @@ export class ExtensionToolRegistry { return { output: { canonicalToolId, + sideEffect: registration.declaration.sideEffect, result: result.item.output }, ...(result.item.isError ? { isError: true } : {}) @@ -651,6 +668,9 @@ function hasUnknownSideEffect(sideEffect: ExtensionToolSideEffect): boolean { } function isKnownFailure(error: unknown): boolean { + if (error instanceof ExtensionApiError) { + return KNOWN_PRE_COMMIT_EXTENSION_API_ERROR_CODES.has(error.code) + } return Boolean(error && typeof error === 'object' && 'knownFailure' in error && error.knownFailure === true) } diff --git a/kun/src/adapters/tool/ppt-master-tool.test.ts b/kun/src/adapters/tool/ppt-master-tool.test.ts index 72a3f2fe1..b6224a2b3 100644 --- a/kun/src/adapters/tool/ppt-master-tool.test.ts +++ b/kun/src/adapters/tool/ppt-master-tool.test.ts @@ -136,7 +136,13 @@ describe('PPT Master local tool', () => { expect(exported.isError).toBeUndefined() expect(exported.output).toMatchObject({ action: 'export', - output_path: join(workspace, 'presentations', 'brief.pptx') + output_path: join(workspace, 'presentations', 'brief.pptx'), + generatedFiles: [{ + name: 'brief.pptx', + relativePath: 'presentations/brief.pptx', + mimeType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + byteSize: 4 + }] }) expect(calls[3]).toEqual([ join(skillDir, 'scripts', 'svg_to_pptx.py'), diff --git a/kun/src/adapters/tool/ppt-master-tool.ts b/kun/src/adapters/tool/ppt-master-tool.ts index c2a1643f4..729493e91 100644 --- a/kun/src/adapters/tool/ppt-master-tool.ts +++ b/kun/src/adapters/tool/ppt-master-tool.ts @@ -19,6 +19,7 @@ const MAX_GUIDE_BYTES = 512 * 1024 const MAX_GUIDE_OUTPUT_BYTES = 24_000 const DEFAULT_GUIDE_LINES = 180 const MAX_GUIDE_LINES = 400 +const PPTX_MIME_TYPE = 'application/vnd.openxmlformats-officedocument.presentationml.presentation' const MANAGED_SKILL_DIR = join(homedir(), '.kun', 'skills', 'ppt-master') const INSTALL_METADATA_FILE = '.kun-ppt-master.json' const MANAGED_BY = 'kun-gui' @@ -322,6 +323,15 @@ export function createPptMasterRunTool( isError: true } } + const generatedPresentation = + result.exitCode === 0 && action === 'export' && command.outputPath && command.outputRelativePath + ? { + name: basename(command.outputPath), + relativePath: command.outputRelativePath, + mimeType: PPTX_MIME_TYPE, + byteSize: (await stat(command.outputPath)).size + } + : undefined return result.exitCode === 0 ? { output: { @@ -330,6 +340,7 @@ export function createPptMasterRunTool( ? { project_path: createdProjectPath } : command.projectPath ? { project_path: command.projectPath } : {}), ...(command.outputPath ? { output_path: command.outputPath } : {}), + ...(generatedPresentation ? { generatedFiles: [generatedPresentation] } : {}), output: result.output } } @@ -354,6 +365,7 @@ async function commandForAction( args: string[] projectPath?: string outputPath?: string + outputRelativePath?: string temporaryOutputPath?: string } | { error: string }> { const script = (name: string): string => join(skillDir, 'scripts', name) @@ -425,6 +437,7 @@ async function commandForAction( args: [script('svg_to_pptx.py'), project.absolutePath, '--output', temporaryOutputPath, '--quiet'], projectPath: project.absolutePath, outputPath: output.absolutePath, + outputRelativePath: output.relativePath, temporaryOutputPath } } diff --git a/kun/src/extensions/host-protocol.ts b/kun/src/extensions/host-protocol.ts index 45e81616a..6bedde86d 100644 --- a/kun/src/extensions/host-protocol.ts +++ b/kun/src/extensions/host-protocol.ts @@ -1,4 +1,9 @@ import { randomUUID } from 'node:crypto' +import { + ExtensionApiError, + ExtensionErrorSchema, + type ExtensionErrorData +} from '@kun/extension-api' import { z } from 'zod' import { asExtensionError, extensionError, type ExtensionErrorDetails } from './errors.js' import { redactSecrets, redactSecretText } from '../config/secret-redaction.js' @@ -19,6 +24,7 @@ const CorrelationId = z.string().regex(/^[a-zA-Z0-9_-]{1,128}$/) const ErrorPayloadSchema = z.object({ code: z.string().min(1).max(200), message: z.string().max(4_000), + retryable: z.boolean().optional(), details: z.record(z.string(), JsonValueSchema).optional() }).strict() @@ -306,7 +312,7 @@ export class JsonRpcPeer { this.settlePending( envelope.id, undefined, - extensionError(envelope.error.code, envelope.error.message, envelope.error.details ?? {}) + errorFromPayload(envelope.error) ) } else { this.settlePending(envelope.id, envelope.result ?? null) @@ -554,6 +560,17 @@ function envelopeBytes(envelope: RpcEnvelope): number { } function errorPayload(error: unknown): RpcErrorPayload { + const publicError = publicExtensionError(error) + if (publicError !== undefined) { + return { + code: publicError.code, + message: redactSecretText(publicError.message).slice(0, 4_000), + retryable: publicError.retryable, + ...(publicError.details === undefined + ? {} + : { details: jsonSafeDetails(redactSecrets(publicError.details)) }) + } + } const normalized = asExtensionError(error) return { code: normalized.code, @@ -562,6 +579,42 @@ function errorPayload(error: unknown): RpcErrorPayload { } } +function publicExtensionError(error: unknown): ExtensionErrorData | undefined { + try { + if (!error || typeof error !== 'object') return undefined + const candidate = error as Record + // Node extensions are expected to bundle their runtime dependencies, so + // their ExtensionApiError constructor may not be referentially equal to + // the Host's SDK constructor. The public name plus the strict schema is + // the cross-package boundary; no stack, cause, or extra field is copied. + if (candidate.name !== 'ExtensionApiError' || typeof candidate.retryable !== 'boolean') { + return undefined + } + const parsed = ExtensionErrorSchema.safeParse({ + code: candidate.code, + message: candidate.message, + retryable: candidate.retryable, + ...(candidate.details === undefined ? {} : { details: candidate.details }) + }) + return parsed.success ? parsed.data : undefined + } catch { + return undefined + } +} + +function errorFromPayload(payload: RpcErrorPayload): Error { + if (payload.retryable !== undefined) { + const parsed = ExtensionErrorSchema.safeParse({ + code: payload.code, + message: payload.message, + retryable: payload.retryable, + ...(payload.details === undefined ? {} : { details: payload.details }) + }) + if (parsed.success) return new ExtensionApiError(parsed.data) + } + return extensionError(payload.code, payload.message, payload.details ?? {}) +} + function jsonSafeDetails(details: ExtensionErrorDetails): Record { try { const parsed = JsonValueSchema.parse(details) diff --git a/kun/tests/extension-host.test.ts b/kun/tests/extension-host.test.ts index 416805ac7..0e0061e8f 100644 --- a/kun/tests/extension-host.test.ts +++ b/kun/tests/extension-host.test.ts @@ -3,6 +3,7 @@ import { execFile } from 'node:child_process' import { tmpdir } from 'node:os' import { join } from 'node:path' import { promisify } from 'node:util' +import { ExtensionApiError } from '@kun/extension-api' import { beforeAll, describe, expect, it } from 'vitest' import { ExtensionHostProcess, @@ -104,6 +105,58 @@ describe('extension host protocol', () => { left.close() right.close() }) + + it('round-trips bundled public API errors without trusting unbranded error-shaped objects', async () => { + let left!: JsonRpcPeer + let right!: JsonRpcPeer + right = new JsonRpcPeer({ + send: async (envelope) => left.receive(structuredClone(envelope)), + onRequest: async (method) => { + if (method === 'public-error') { + const error = new ExtensionApiError({ + code: 'CONFLICT', + message: 'The expected revision is stale.', + retryable: true, + details: { + expectedRevision: 7, + actualRevision: 8, + authToken: 'must-not-cross-the-rpc-boundary' + } + }) + Object.setPrototypeOf(error, Error.prototype) + throw error + } + throw Object.assign(new Error('untrusted implementation detail'), { + code: 'CONFLICT', + retryable: true, + details: { leaked: true } + }) + } + }) + left = new JsonRpcPeer({ + send: async (envelope) => right.receive(structuredClone(envelope)) + }) + + const publicError = await left.request('public-error', null).catch((error: unknown) => error) + expect(publicError).toBeInstanceOf(ExtensionApiError) + expect(publicError).toMatchObject({ + code: 'CONFLICT', + message: 'The expected revision is stale.', + retryable: true, + details: { + expectedRevision: 7, + actualRevision: 8, + authToken: '' + } + }) + await expect(left.request('error-shaped-object', null)).rejects.toMatchObject({ + code: 'EXTENSION_INTERNAL_ERROR', + message: 'Extension operation failed', + details: {} + }) + left.close() + right.close() + }) }) describe('extension host processes', () => { @@ -250,6 +303,97 @@ export async function migrateState(state, context) { } }, 15_000) + it('preserves public API failures through a real Node Host broker round trip', async () => { + const root = await mkdtemp(join(tmpdir(), 'kun-extension-api-error-')) + try { + const packagePath = join(root, 'extension') + await mkdir(packagePath, { recursive: true }) + await writeFile(join(packagePath, 'main.mjs'), ` +export async function activate(context) { + context.subscriptions.add(await context.commands.registerCommand('save', async () => { + await context.workspace.writeFile({ + path: 'deck.kun-ppt.html', + content: '', + encoding: 'utf8' + }); + return null; + })); +} +`) + const manifest = parseExtensionManifest({ + publisher: 'acme', + name: 'api-error', + version: '1.0.0', + manifestVersion: 1, + apiVersion: '1.0.0', + engines: { kun: '*' }, + main: 'main.mjs', + activationEvents: ['onCommand:save'], + contributes: { commands: [{ id: 'save', title: 'Save' }] }, + permissions: ['commands.register', 'workspace.write'], + stateSchemaVersion: 0 + }) + const extension: ResolvedExtension = { + id: 'acme.api-error', + version: '1.0.0', + packagePath, + manifest, + requestedPermissions: [...manifest.permissions], + grantedPermissions: [...manifest.permissions], + source: { type: 'development', locator: packagePath }, + development: true, + generation: 1 + } + const host = new ExtensionHostProcess({ + extension, + compatibilityReport: manifestCompatibilityReport(manifest, { + kunVersion: '0.1.0', + supportedManifestVersions: [1], + supportedApiVersions: ['1.1.0'] + }), + paths: new ExtensionPaths({ + packageRoot: join(root, 'packages'), + dataRoot: join(root, 'data') + }), + runnerPath: builtinRunnerPath, + limits: { + activationTimeoutMs: 4_000, + operationTimeoutMs: 4_000, + shutdownTimeoutMs: 2_000 + }, + requiredPermission: (method) => method.startsWith('commands.') + ? 'commands.register' + : method === 'workspace.writeFile' ? 'workspace.write' : undefined, + broker: async ({ method }) => { + if (method === 'commands.register') return { registrationId: 'command-save' } + if (method === 'commands.unregister') return null + if (method === 'workspace.writeFile') { + throw new ExtensionApiError({ + code: 'CONFLICT', + message: 'The presentation revision changed before commit.', + retryable: true, + details: { expectedRevision: 3, actualRevision: 4 } + }) + } + throw new Error(`unexpected broker method: ${method}`) + } + }) + + await host.activate('onCommand:save') + const error = await host.invoke('commands.invoke:command-save', null).catch((value: unknown) => value) + expect(error).toBeInstanceOf(ExtensionApiError) + expect(error).toMatchObject({ + code: 'CONFLICT', + message: 'The presentation revision changed before commit.', + retryable: true, + details: { expectedRevision: 3, actualRevision: 4 } + }) + await host.deactivate() + } finally { + await rm(root, { recursive: true, force: true }) + } + }, 15_000) + it('isolates processes, binds identity, minimizes environment, cancels calls, and shuts down', async () => { const root = await mkdtemp(join(tmpdir(), 'kun-extension-host-')) const previousSecret = process.env.KUN_EXTENSION_TEST_SECRET diff --git a/openspec/changes/add-agent-ppt-extension/design.md b/openspec/changes/add-agent-ppt-extension/design.md new file mode 100644 index 000000000..a0d368450 --- /dev/null +++ b/openspec/changes/add-agent-ppt-extension/design.md @@ -0,0 +1,81 @@ +## Context + +The current `origin/develop` baseline exposes Extension API v1.0 with full-page Webviews, commands, workspace read/write, Agent runs, Agent profiles, and extension tools. It does not expose workspace directory creation or atomic rename. PPT Master remains the managed Markdown-to-PPTX path and is intentionally separate from this HTML-first editing experience. + +The NQ reference project edits arbitrary HTML inside an unsandboxed iframe and exports a full runtime DOM snapshot. Its generated element IDs do not survive reopen, its change map is not a real operation log, and arbitrary slide scripts would share an unsafe execution surface in an Electron renderer. The new extension therefore uses a constrained presentation model rather than importing that runtime. + +## Goals / Non-Goals + +**Goals:** + +- Make one presentation editable by both a person and an extension-owned Kun Agent without last-writer-wins data loss. +- Keep the canonical artifact directly openable as a standalone HTML presentation. +- Keep slide and element identity stable across sessions and exports. +- Use only public Extension API v1.0 surfaces and minimum required permissions. +- Keep every tool schema, file, message, operation batch, and retained idempotency record bounded. +- Preserve Kun's existing tool approval, sandbox, cancellation, catalog, and single-runtime behavior. + +**Non-Goals:** + +- Importing arbitrary HTML or executing user/Agent-authored JavaScript in the extension Webview. +- PPTX/PDF import or export, animation timelines, charts, multiplayer CRDT, or pixel-perfect PowerPoint compatibility. +- Replacing or weakening managed PPT Master. +- Cherry-picking the legacy PPTist bridge or the local-only Extension API v1.1/video-editor stack. +- Adding PPT-specific private IPC or relaxing the extension CSP. + +## Decisions + +### 1. A constrained AST is embedded in the standalone HTML file + +A deck is saved as `.kun-ppt.html`. A non-executable `application/json` marker contains a schema-versioned model with document metadata, theme, slides, elements, revision, and a bounded operation receipt log. The rest of the file is a deterministic HTML/CSS projection with persistent `data-kun-slide-id` and `data-kun-element-id` attributes. + +Using one root-level file works within the v1.0 Workspace Broker, which cannot create directories. It is also directly previewable outside Kun and avoids a JSON/HTML pair becoming inconsistent. The parser reads only the exact JSON marker and never treats arbitrary surrounding HTML as editor authority. + +### 2. Manual and Agent edits share one typed reducer + +The shared engine accepts bounded operations for document metadata, slide insert/update/delete/reorder, and element upsert/delete. It validates the resulting model and returns changed IDs, warnings, and inverse operations. The Webview uses those inverse operations for undo/redo; the Agent tool uses the same reducer for batch edits. + +Elements are a discriminated union of text, shape, and workspace-relative image blocks. Geometry uses percentages on a fixed 16:9 canvas. Colors, fonts, paths, text lengths, slide counts, element counts, and total serialized bytes are constrained before projection. + +### 3. Persistence uses revision checks, receipts, and a per-path queue + +Every mutation supplies `expectedRevision`. The host rereads the file, compares revisions, applies the batch, increments once, renders the complete canonical HTML, rechecks the prior content immediately before `context.workspace.writeFile`, then rereads it for verification. Calls for paths that differ only by ASCII case are serialized inside the extension host. + +Agent mutations also supply an `operationId`. A digest and resulting revision are retained in a bounded receipt list, making a same-input retry return the prior success while rejecting reuse with different input. Extension API v1.0 offers neither atomic rename nor atomic conditional/create-only writes. Revision checks, immediate pre-write rechecks, per-path serialization, and post-write verification cover normal UI/Agent races inside one Extension Host, while cross-process atomicity is explicitly deferred. + +### 4. The Webview is a trusted renderer for untrusted structured data + +The full-page Webview builds slide DOM with `createElement`, `textContent`, validated style values, and broker-loaded workspace images. It never injects presentation HTML with `innerHTML`, never creates a nested iframe, and never enables remote network access. The file projection escapes all text and attributes and includes a restrictive standalone CSP. + +The editor offers a slide rail, responsive 16:9 canvas, drag/resize, inline text editing, property inspector, slide operations, undo/redo, preview, and debounced save. Before starting or steering the Agent, it flushes pending edits so the Agent reads the current revision. A revision conflict never overwrites; the UI asks for reload. + +### 5. A dedicated Agent profile uses five narrow tools + +The private `presentation-designer` profile may use only `presentation-create`, `presentation-read`, `presentation-apply`, `presentation-validate`, and `presentation-export-copy`. Its instructions require reading the current revision before edits, using stable IDs, applying bounded batches, refreshing on conflicts, validating before completion, and treating PPTX as a separate PPT Master workflow. + +The same tools remain available through Kun's normal extension ToolHost path. Declarations are defined once in TypeScript and regression-tested against the Manifest, so side-effect classification, input/output schemas, and output limits cannot silently drift. + +### 6. Completed turns surface presentation artifacts through the existing system opener + +Successful workspace-write tool results already carry a resolved `filePath`, but PPT Master names its final path as `output_path` and the chat timeline currently surfaces only file changes with unified diffs. The mapper therefore recognizes bounded output/destination path aliases and unwraps the progressive extension gateway's `result.content` envelope. A pure turn-level collector selects only `.ppt`/`.pptx` outputs plus `.kun-ppt.html` outputs whose provenance was derived from the real Presentation Studio tool identity and whose tool result carries the extension's verified content SHA-256. It rejects traversal and lexically external paths, applies platform-aware path identity, and renders the cards only after the turn finishes. + +The primary card action calls the existing `editor:open-path` bridge with `editorId: "system"` and the main-owned `presentation-artifact` open policy. The main process resolves and confines the path to the active workspace, verifies that the canonical target is a regular file ending in `.ppt`, `.pptx`, or `.kun-ppt.html`, and for HTML recomputes the current bytes' SHA-256 against the trusted tool result before using Electron `shell.openPath`. This prevents a presentation-looking symlink or a post-generation HTML overwrite from launching different content while still letting WPS, PowerPoint, or the browser be selected by the operating system. A second action reuses the existing file-manager reveal path and the same fixed type policy. No new IPC channel or application-specific executable discovery is introduced. + +## Risks / Trade-offs + +- [Risk] A single HTML file can grow large. -> Cap the model and rendered file below the public 1 MiB tool/message budget and reject oversized edits before writing. +- [Risk] Extension API v1.0 writes are not rename-atomic or conditionally atomic. -> Serialize case-folded paths, recheck directly before persistence, verify the post-write document, and document cross-process atomic storage as a future platform improvement. +- [Risk] Workspace images may be missing or too large. -> Validate relative paths, use bounded broker reads, show a non-fatal placeholder, and report validation warnings. +- [Risk] Agent and user edit concurrently. -> Flush UI edits before Agent input and fail closed on revision mismatch rather than automatically merging. +- [Risk] HTML projection could become an XSS surface. -> Project only validated AST fields, escape every text/attribute value, forbid arbitrary CSS/script, and keep the bridge-bearing Webview independent of the exported markup. +- [Risk] A tool may report a presentation-looking path that no longer exists. -> Keep path resolution in the main process, show a bounded open failure in the card, and never attempt a shell-command fallback. +- [Risk] A generic writer or symlink may disguise executable content as a presentation path. -> Require runtime-derived Presentation Studio provenance and a verified write-time digest for standalone HTML, then revalidate the canonical target's regular-file type, suffix, and current content digest in the main process. + +## Migration Plan + +No persisted Kun data migration is required. Install the example as a development extension, create a new `.kun-ppt.html` deck, and edit it through the contributed full-page View or tools. Future schema versions must add explicit model migration before accepting older files. Removing the extension leaves standalone presentation files intact. + +## Open Questions + +- A future Extension API revision may add atomic workspace transactions and directory creation; the project service should adopt those without changing the deck operation contract. +- Native PPTX/PDF export can be added later as an explicitly version-pinned, non-destructive background job rather than importing the legacy PPTist bridge. diff --git a/openspec/changes/add-agent-ppt-extension/proposal.md b/openspec/changes/add-agent-ppt-extension/proposal.md new file mode 100644 index 000000000..58d44fc93 --- /dev/null +++ b/openspec/changes/add-agent-ppt-extension/proposal.md @@ -0,0 +1,31 @@ +## Why + +Kun can generate native PPTX files through the managed PPT Master workflow, but it does not yet have an extension-owned slide workspace where a user and an Agent can repeatedly edit the same presentation. The NQ PPT HTML Editor demonstrates that a 16:9 HTML canvas, slide rail, direct text editing, and property inspector can make this workflow approachable. Its temporary DOM identifiers, unsandboxed arbitrary HTML, and full-document snapshot export are not suitable as a stable Kun extension contract. + +## What Changes + +- Add a complete `presentation-studio` Kun extension example with a full-page Webview, a private presentation Agent profile, and typed presentation tools. +- Store each deck as a standalone `.kun-ppt.html` file whose embedded, versioned presentation model is the source of truth and whose visible HTML is a deterministic projection. +- Give slides and elements stable IDs and route both visual edits and Agent edits through one revision-aware typed-operation reducer. +- Provide create, read, apply, validate, and copy/export operations with bounded schemas, optimistic revision checks, idempotency records, and serialized writes. +- Provide slide thumbnails, a 16:9 canvas, text/shape/image elements, property editing, drag/resize, slide ordering, undo/redo, preview, autosave, and an embedded extension-owned Agent run. +- Surface completed Agent-generated presentation artifacts below the final reply and open them through the operating system's default application association, with a safe file-manager fallback. +- Render only the structured presentation model inside the Webview. Agent-authored arbitrary HTML or scripts never execute in the bridge-bearing extension page. +- Document the extension and add it to the repository extension-example validation gate. + +## Capabilities + +### New Capabilities + +- `agent-html-presentation-extension`: Defines a stable, visually editable HTML presentation format and the extension APIs used by people and Kun Agent runs to edit it safely. + +### Modified Capabilities + +None. + +## Impact + +- Adds one public Extension API v1 example under `examples/extensions/presentation-studio`. +- Updates extension example documentation and validation enumeration, plus the existing chat artifact presentation and PPT Master result metadata. +- Adds no private renderer IPC, runtime route, second Agent runtime, provider surface, or PPT generation-pipeline change. +- Does not copy the NQ editor implementation; it clean-room reuses the interaction ideas while preserving Kun's Webview and tool security boundaries. diff --git a/openspec/changes/add-agent-ppt-extension/specs/agent-html-presentation-extension/spec.md b/openspec/changes/add-agent-ppt-extension/specs/agent-html-presentation-extension/spec.md new file mode 100644 index 000000000..e553e66ec --- /dev/null +++ b/openspec/changes/add-agent-ppt-extension/specs/agent-html-presentation-extension/spec.md @@ -0,0 +1,102 @@ +## ADDED Requirements + +### Requirement: Presentation files are stable standalone HTML artifacts +The extension SHALL store each presentation as a standalone `.kun-ppt.html` file containing a schema-versioned structured model, stable document/slide/element IDs, a positive revision, and a deterministic visible HTML projection. + +#### Scenario: Reopen a saved presentation +- **WHEN** the extension opens a presentation that it previously saved +- **THEN** every slide and element retains its ID, geometry, content, order, theme, and revision + +#### Scenario: Open the file outside Kun +- **WHEN** a user opens the saved file in a regular browser +- **THEN** the file presents the slides in 16:9 playback and print layouts without depending on Kun private APIs + +### Requirement: Human and Agent edits use the same operation semantics +The extension MUST route both Webview edits and Agent tool edits through the same validated typed-operation reducer and MUST produce inverse operations for reversible UI edits. + +#### Scenario: Edit text visually +- **WHEN** the user changes a text element in the canvas or inspector +- **THEN** the change is represented as an element operation, saved with a new revision, and is visible to the next Agent read + +#### Scenario: Apply an Agent batch +- **WHEN** the Agent applies multiple valid operations against the current revision +- **THEN** all operations commit as one revision and the open View refreshes to the same resulting model + +### Requirement: Concurrent edits fail closed +Every persisted mutation SHALL require an expected revision, SHALL serialize calls for the same case-folded workspace path inside one Extension Host, and SHALL reject a stale revision observed before persistence without overwriting the newer presentation. Cross-process atomic conditional writes are outside this requirement because Extension API v1 does not expose them. + +#### Scenario: Agent uses a stale revision +- **WHEN** the user saves revision 7 after the Agent read revision 6 and the Agent submits an edit for revision 6 +- **THEN** the edit fails with a revision-conflict result and revision 7 remains unchanged + +#### Scenario: Retry a completed Agent operation +- **WHEN** the same operation ID and payload are retried after their response was lost +- **THEN** the extension returns the recorded resulting revision without applying the batch twice + +### Requirement: The editor provides a complete bounded visual workflow +The full-page View SHALL support creating and loading a deck, slide navigation and ordering, text/shape/image elements, selection, drag, resize, inline text changes, property editing, undo/redo, preview, debounced save, and presentation-specific Agent runs. + +#### Scenario: Create and revise a deck +- **WHEN** a user creates a deck, adds slides and elements, moves and styles them, undoes one edit, and previews the result +- **THEN** the canvas, slide rail, inspector, preview, and saved standalone file show the same revision + +#### Scenario: Ask the presentation Agent to revise the open deck +- **WHEN** the user submits a revision request from the View +- **THEN** pending visual edits are saved first, a private extension-owned run uses the presentation profile/tools, and accepted file changes refresh the editor + +### Requirement: Presentation content cannot control the extension Webview +The extension MUST render validated structured fields with safe DOM APIs and MUST NOT execute or inject arbitrary presentation HTML, CSS, JavaScript, remote resources, or event handlers into the bridge-bearing Webview. + +#### Scenario: Text contains HTML and script syntax +- **WHEN** a text element contains tags, event-handler strings, or a closing script marker +- **THEN** the editor and standalone projection display it as text and the embedded model remains parseable + +#### Scenario: Invalid image path +- **WHEN** an image element uses an absolute path, traversal, unsupported type, or unavailable file +- **THEN** validation rejects it or renders a bounded placeholder without exposing files outside the workspace + +### Requirement: Completed presentation artifacts are directly openable +After an Agent turn completes, the GUI SHALL surface every successful, workspace-confined `.ppt`, `.pptx`, or trusted Presentation Studio `.kun-ppt.html` output as a deduplicated presentation file card. Native PowerPoint files SHALL open through the operating system's default application association, while `.kun-ppt.html` SHALL open through the same association as a standalone browser presentation. The card SHALL also allow revealing the file in the platform file manager. Before either action, the main process SHALL verify that the canonical target is a regular file inside the owning workspace and still has an allowed presentation suffix. Before system-opening `.kun-ppt.html`, it SHALL additionally recompute and match the trusted write-time SHA-256 digest. + +#### Scenario: PPT Master exports a deck +- **WHEN** PPT Master successfully exports `presentations/brief.pptx` and the Agent turn completes +- **THEN** the final reply shows one presentation card whose primary action asks the operating system to open that file with its configured default application such as WPS or PowerPoint + +#### Scenario: Presentation Studio writes an HTML deck +- **WHEN** a successful presentation write tool reports `brief.kun-ppt.html` +- **THEN** the final reply shows one presentation card that can open the standalone HTML projection with the system default application + +#### Scenario: Generic tool reports presentation-looking HTML +- **WHEN** a tool without trusted Presentation Studio provenance reports `evil.kun-ppt.html` +- **THEN** the GUI does not surface it as an executable standalone presentation card + +#### Scenario: A trusted HTML deck changes before it is opened +- **WHEN** Presentation Studio reports a verified HTML deck but its bytes no longer match the write-time digest when the user clicks Open +- **THEN** the main process refuses to launch the browser and the card shows a bounded failure state + +#### Scenario: Presentation tool runs through the progressive gateway +- **WHEN** a Presentation Studio write is wrapped by `extension_tool_call` as `result.content` +- **THEN** the GUI preserves its canonical tool provenance and workspace-write semantics and surfaces the completed deck normally + +#### Scenario: Presentation path disguises another target type +- **WHEN** a reported `.pptx` path resolves to a directory or to a symlink target with a different suffix +- **THEN** the main process rejects the open or reveal action without launching an application + +#### Scenario: Opening fails or the file moved +- **WHEN** the operating system cannot open a surfaced presentation path +- **THEN** the card remains visible, shows a bounded failure state, logs diagnostic detail, and does not fall back to an arbitrary command + +#### Scenario: Repeated tools report the same deck +- **WHEN** multiple successful tool results in one turn refer to the same presentation path +- **THEN** the final reply shows that presentation only once + +#### Scenario: Distinct case-sensitive paths are reported +- **WHEN** a case-sensitive workspace contains both `Deck.pptx` and `deck.pptx` +- **THEN** the final reply keeps both presentation cards rather than case-folding them into one + +### Requirement: Extension declarations remain public and verifiable +The implementation SHALL use only public Extension API v1 surfaces, minimum Manifest permissions, bounded strict tool schemas, and declarations that exactly match runtime registration. + +#### Scenario: Validate and pack the extension +- **WHEN** repository extension checks build, validate, and pack all examples +- **THEN** Presentation Studio passes without unresolved browser imports, undeclared resources, private Kun imports, or tool declaration drift diff --git a/openspec/changes/add-agent-ppt-extension/tasks.md b/openspec/changes/add-agent-ppt-extension/tasks.md new file mode 100644 index 000000000..4cd684c62 --- /dev/null +++ b/openspec/changes/add-agent-ppt-extension/tasks.md @@ -0,0 +1,34 @@ +## 1. Presentation Model And Projection + +- [x] 1.1 Define the bounded schema-versioned presentation model, stable IDs, theme, text/shape/image elements, and canonical parser/serializer. +- [x] 1.2 Implement the typed operation reducer with changed IDs, inverse operations, validation warnings, revision-independent deterministic behavior, and unit tests. +- [x] 1.3 Implement safe standalone HTML projection and embedded-model extraction tests covering escaping, script markers, deterministic output, and invalid files. + +## 2. Extension Host And Agent Tools + +- [x] 2.1 Implement the revision-aware project service with per-path serialization, size limits, idempotency receipts, post-write verification, and conflict errors. +- [x] 2.2 Register create/read/apply/validate/export-copy tools and View commands through public Extension API v1, with progress, cancellation, bounded outputs, and change notifications. +- [x] 2.3 Add the private presentation Agent profile and test exact Manifest/runtime declaration parity. + +## 3. Visual Presentation Studio + +- [x] 3.1 Build the full-page Webview shell with deck path controls, slide rail, 16:9 canvas, inspector, status, and responsive/themed styling. +- [x] 3.2 Implement slide and element creation, selection, ordering, drag/resize, inline text editing, property controls, undo/redo, preview, image resolution, and debounced revision-aware save. +- [x] 3.3 Implement extension-owned Agent run create/steer/cancel/replay, flush-before-run behavior, and automatic refresh after tool mutations. + +## 4. Packaging And Documentation + +- [x] 4.1 Add the Manifest, package scripts, TypeScript/Vite configuration, README, license, and clean-room reference notes. +- [x] 4.2 Add Presentation Studio to the extension examples index and validation enumeration. + +## 5. Verification + +- [x] 5.1 Run the extension's typecheck, build, unit tests, Manifest validation, and package validation. +- [x] 5.2 Run the repository extension example gate plus relevant root typecheck/build checks and diff hygiene. +- [x] 5.3 Exercise the built Webview in a browser harness and visually verify canvas, inspector, drag/resize, preview, and Agent panel layout. + +## 6. Presentation Artifact Handoff + +- [x] 6.1 Surface PPT Master and extension presentation output paths as bounded successful presentation artifacts, including mapper coverage for output/destination path aliases. +- [x] 6.2 Render deduplicated post-turn presentation cards with system-default open, file-manager reveal, loading, and bounded failure states. +- [x] 6.3 Add focused artifact-derivation, mapper, PPT Master, and system-opener tests and run the relevant renderer/Kun/typecheck/build checks. diff --git a/scripts/check-extension-examples.mjs b/scripts/check-extension-examples.mjs index d15a64346..8bac387b8 100644 --- a/scripts/check-extension-examples.mjs +++ b/scripts/check-extension-examples.mjs @@ -14,6 +14,7 @@ const expected = [ 'agent-assistant', 'direct-dom', 'hello-sidebar', + 'presentation-studio', 'streaming-model-provider', 'tool-provider', 'workspace-dashboard' diff --git a/src/main/ipc/app-ipc-schemas/workspace.ts b/src/main/ipc/app-ipc-schemas/workspace.ts index dd8b83c7c..3277fb23a 100644 --- a/src/main/ipc/app-ipc-schemas/workspace.ts +++ b/src/main/ipc/app-ipc-schemas/workspace.ts @@ -139,7 +139,9 @@ export const openEditorPathPayloadSchema = z workspaceRoot: optionalTrimmedString(MAX_PATH_LENGTH), editorId: optionalTrimmedString(MAX_EDITOR_ID_LENGTH), line: z.number().int().positive().max(1_000_000).optional(), - column: z.number().int().positive().max(1_000_000).optional() + column: z.number().int().positive().max(1_000_000).optional(), + openPolicy: z.enum(['presentation-artifact']).optional(), + expectedSha256: z.string().trim().regex(/^[a-f0-9]{64}$/i).optional() }) .strict() diff --git a/src/main/services/workspace-editors.test.ts b/src/main/services/workspace-editors.test.ts index b5eeb8e04..448ee8e70 100644 --- a/src/main/services/workspace-editors.test.ts +++ b/src/main/services/workspace-editors.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createHash } from 'node:crypto' const imageBytes = Buffer.alloc(80, 0xab) @@ -156,4 +157,131 @@ describe('workspace editor icons', () => { expect(electronMock.createFromBuffer).toHaveBeenCalledTimes(1) expect(electronMock.getFileIcon).not.toHaveBeenCalled() }) + + it('opens a workspace file through the operating system association', async () => { + fsPromisesMock.stat.mockResolvedValueOnce({ isFile: () => true }) + const { openEditorPath } = await import('./workspace-editors') + const result = await openEditorPath({ + path: 'presentations/brief.pptx', + workspaceRoot: '/tmp/workspace', + editorId: 'system', + openPolicy: 'presentation-artifact' + }) + + expect(result).toMatchObject({ + ok: true, + path: 'presentations/brief.pptx', + editorId: 'system' + }) + expect(workspacePathsMock.resolveOpenTargetPath).toHaveBeenCalledWith( + 'presentations/brief.pptx', + '/tmp/workspace', + { allowBasenameFallback: false } + ) + expect(electronMock.openPath).toHaveBeenCalledWith('presentations/brief.pptx') + }) + + it('reveals a workspace file without launching an editor', async () => { + fsPromisesMock.stat.mockResolvedValueOnce({ isFile: () => true }) + const { openEditorPath } = await import('./workspace-editors') + const result = await openEditorPath({ + path: 'brief.kun-ppt.html', + workspaceRoot: '/tmp/workspace', + editorId: 'file-manager', + openPolicy: 'presentation-artifact' + }) + + expect(result).toMatchObject({ ok: true, editorId: 'file-manager' }) + expect(electronMock.showItemInFolder).toHaveBeenCalledWith('brief.kun-ppt.html') + expect(electronMock.openPath).not.toHaveBeenCalled() + }) + + it('returns the system opener error without trying an arbitrary command', async () => { + electronMock.openPath.mockResolvedValueOnce('No application is associated with this file') + const { openEditorPath } = await import('./workspace-editors') + + await expect(openEditorPath({ + path: 'presentations/brief.pptx', + workspaceRoot: '/tmp/workspace', + editorId: 'system' + })).resolves.toEqual({ + ok: false, + message: 'No application is associated with this file' + }) + expect(electronMock.openPath).toHaveBeenCalledTimes(1) + }) + + it('rejects a presentation alias whose canonical target has another suffix', async () => { + workspacePathsMock.resolveOpenTargetPath.mockResolvedValueOnce('/tmp/workspace/payload.exe') + fsPromisesMock.stat.mockResolvedValueOnce({ isFile: () => true }) + const { openEditorPath } = await import('./workspace-editors') + + await expect(openEditorPath({ + path: '/tmp/workspace/deck.pptx', + workspaceRoot: '/tmp/workspace', + editorId: 'system', + openPolicy: 'presentation-artifact' + })).resolves.toEqual({ + ok: false, + message: 'Resolved file type is not allowed for this action.' + }) + expect(workspacePathsMock.resolveOpenTargetPath).toHaveBeenCalledWith( + '/tmp/workspace/deck.pptx', + '/tmp/workspace', + { allowBasenameFallback: false } + ) + expect(electronMock.openPath).not.toHaveBeenCalled() + }) + + it('rejects presentation-looking directories before opening or revealing them', async () => { + workspacePathsMock.resolveOpenTargetPath.mockResolvedValueOnce('/tmp/workspace/folder.pptx') + fsPromisesMock.stat.mockResolvedValueOnce({ isFile: () => false }) + const { openEditorPath } = await import('./workspace-editors') + + await expect(openEditorPath({ + path: '/tmp/workspace/folder.pptx', + workspaceRoot: '/tmp/workspace', + editorId: 'file-manager', + openPolicy: 'presentation-artifact' + })).resolves.toEqual({ + ok: false, + message: 'Path must point to a regular file.' + }) + expect(electronMock.showItemInFolder).not.toHaveBeenCalled() + }) + + it('system-opens a Kun HTML deck only while its trusted content digest still matches', async () => { + const expectedSha256 = createHash('sha256').update(imageBytes).digest('hex') + workspacePathsMock.resolveOpenTargetPath.mockResolvedValueOnce('/tmp/workspace/deck.kun-ppt.html') + fsPromisesMock.stat.mockResolvedValueOnce({ isFile: () => true, size: imageBytes.byteLength }) + const { openEditorPath } = await import('./workspace-editors') + + await expect(openEditorPath({ + path: '/tmp/workspace/deck.kun-ppt.html', + workspaceRoot: '/tmp/workspace', + editorId: 'system', + openPolicy: 'presentation-artifact', + expectedSha256 + })).resolves.toMatchObject({ ok: true, editorId: 'system' }) + expect(fsPromisesMock.readFile).toHaveBeenCalledWith('/tmp/workspace/deck.kun-ppt.html') + expect(electronMock.openPath).toHaveBeenCalledWith('/tmp/workspace/deck.kun-ppt.html') + }) + + it('rejects a Kun HTML deck that changed after the trusted write', async () => { + workspacePathsMock.resolveOpenTargetPath.mockResolvedValueOnce('/tmp/workspace/deck.kun-ppt.html') + fsPromisesMock.stat.mockResolvedValueOnce({ isFile: () => true, size: imageBytes.byteLength }) + const { openEditorPath } = await import('./workspace-editors') + + await expect(openEditorPath({ + path: '/tmp/workspace/deck.kun-ppt.html', + workspaceRoot: '/tmp/workspace', + editorId: 'system', + openPolicy: 'presentation-artifact', + expectedSha256: '0'.repeat(64) + })).resolves.toEqual({ + ok: false, + message: 'Presentation changed after it was generated. Save it again in Presentation Studio before opening.' + }) + expect(electronMock.openPath).not.toHaveBeenCalled() + }) }) diff --git a/src/main/services/workspace-editors.ts b/src/main/services/workspace-editors.ts index 98466f877..e1aa72d25 100644 --- a/src/main/services/workspace-editors.ts +++ b/src/main/services/workspace-editors.ts @@ -3,7 +3,7 @@ import { execFile } from 'node:child_process' import { readFile, stat, unlink } from 'node:fs/promises' import { homedir, tmpdir } from 'node:os' import { basename, dirname, extname, isAbsolute, join, posix } from 'node:path' -import { randomUUID } from 'node:crypto' +import { createHash, randomUUID } from 'node:crypto' import { promisify } from 'node:util' import type { EditorInfo, @@ -45,6 +45,8 @@ type ResolvedEditor = EditorInfo & { } const DEFAULT_EDITOR_ID = 'system' +const PRESENTATION_FILE_SUFFIXES = ['.ppt', '.pptx', '.kun-ppt.html'] as const +const MAX_KUN_PRESENTATION_HTML_BYTES = 900_000 const EDITOR_ICON_SOURCE_PX = 64 const LINUX_ICON_SIZES = ['512x512', '256x256', '128x128', '64x64', '48x48', '32x32', '24x24', '16x16'] const ICON_IMAGE_EXTENSIONS = ['.png', '.ico', '.jpg', '.jpeg', '.webp', '.svg'] @@ -703,7 +705,29 @@ export async function openEditorPath(payload: OpenEditorPathOptions): Promise item.id === DEFAULT_EDITOR_ID) if (!editor) throw new Error('No editor or system opener is available.') - const targetPath = await resolveOpenTargetPath(payload.path, payload.workspaceRoot) + const targetPath = payload.openPolicy + ? await resolveOpenTargetPath(payload.path, payload.workspaceRoot, { allowBasenameFallback: false }) + : await resolveOpenTargetPath(payload.path, payload.workspaceRoot) + if (payload.openPolicy === 'presentation-artifact') { + const info = await stat(targetPath) + if (!info.isFile()) throw new Error('Path must point to a regular file.') + const normalizedTarget = targetPath.toLowerCase() + if (!PRESENTATION_FILE_SUFFIXES.some((suffix) => normalizedTarget.endsWith(suffix))) { + throw new Error('Resolved file type is not allowed for this action.') + } + if (editor.id === 'system' && normalizedTarget.endsWith('.kun-ppt.html')) { + const expectedSha256 = payload.expectedSha256?.toLowerCase() + if (!expectedSha256) throw new Error('Verified presentation digest is required.') + if (info.size > MAX_KUN_PRESENTATION_HTML_BYTES) { + throw new Error('Presentation HTML exceeds the verified open limit.') + } + const content = await readFile(targetPath) + const actualSha256 = createHash('sha256').update(content).digest('hex') + if (actualSha256 !== expectedSha256) { + throw new Error('Presentation changed after it was generated. Save it again in Presentation Studio before opening.') + } + } + } await openWithResolvedEditor(editor, targetPath, payload.line, payload.column) return { ok: true, path: targetPath, editorId: editor.id } } catch (error) { diff --git a/src/main/services/workspace-files.ts b/src/main/services/workspace-files.ts index a5a0bc113..6c42514da 100644 --- a/src/main/services/workspace-files.ts +++ b/src/main/services/workspace-files.ts @@ -673,6 +673,10 @@ export async function resolveWorkspaceFile( const targetPath = await resolveOpenTargetPath(payload.path, payload.workspaceRoot, { allowBasenameFallback: false }) + const info = await stat(targetPath) + if (!info.isFile()) { + return { ok: false, message: 'Path must point to a regular workspace file.' } + } return { ok: true, path: targetPath } } catch (error) { return { diff --git a/src/main/services/workspace-service.test.ts b/src/main/services/workspace-service.test.ts index ad92adf18..fa6863b02 100644 --- a/src/main/services/workspace-service.test.ts +++ b/src/main/services/workspace-service.test.ts @@ -67,6 +67,34 @@ describe('workspace-service boundary checks', () => { } }) + it('does not resolve a presentation-looking directory as a file', async () => { + await mkdir(join(workspaceRoot, 'not-a-deck.pptx')) + + const result = await resolveWorkspaceFile({ + path: 'not-a-deck.pptx', + workspaceRoot + }) + + expect(result).toEqual({ + ok: false, + message: 'Path must point to a regular workspace file.' + }) + }) + + it('returns a presentation symlink canonical target for main-process type policy checks', async () => { + if (process.platform === 'win32') return + const payloadPath = join(workspaceRoot, 'payload.exe') + await writeFile(payloadPath, 'payload', 'utf8') + await symlink('payload.exe', join(workspaceRoot, 'deck.pptx')) + + const result = await resolveWorkspaceFile({ + path: 'deck.pptx', + workspaceRoot + }) + + expect(result).toEqual({ ok: true, path: await realpath(payloadPath) }) + }) + it('rejects relative paths that escape the selected workspace', async () => { const result = await readWorkspaceFile({ path: '../outside.txt', diff --git a/src/renderer/src/agent/kun-mapper.test.ts b/src/renderer/src/agent/kun-mapper.test.ts index 7ab9326b0..45c058600 100644 --- a/src/renderer/src/agent/kun-mapper.test.ts +++ b/src/renderer/src/agent/kun-mapper.test.ts @@ -7,6 +7,11 @@ import { } from './kun-mapper' import type { CoreRuntimeEventJson, CoreTurnItemJson } from './kun-contract' import type { ThreadErrorOptions, ThreadEventSink } from './types' +import { + PRESENTATION_STUDIO_EXTENSION_ID, + presentationStudioCanonicalToolId, + presentationStudioModelAlias +} from '@shared/presentation-artifact' function makeSink(): ThreadEventSink { return { @@ -1486,6 +1491,127 @@ describe('tool presentation inference', () => { }) }) + it('surfaces final output and destination path aliases for generated artifacts', () => { + const ppt = chatBlockFromItem({ + id: 'item_ppt', + turnId: 'turn_1', + threadId: 'thr_1', + role: 'tool', + status: 'completed', + createdAt: '2024-01-01T00:00:00.000Z', + kind: 'tool_result', + toolName: 'ppt_master_run', + toolKind: 'file_change', + callId: 'call_ppt', + output: { + output_path: '/tmp/presentations/brief.pptx', + generatedFiles: [{ + relativePath: 'presentations/brief.pptx', + mimeType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' + }] + } + }) + const htmlCopy = chatBlockFromItem({ + id: 'item_html_copy', + turnId: 'turn_1', + threadId: 'thr_1', + role: 'tool', + status: 'completed', + createdAt: '2024-01-01T00:00:00.000Z', + kind: 'tool_result', + toolName: presentationStudioModelAlias('presentation-export-copy'), + toolKind: 'file_change', + callId: 'call_html_copy', + output: { + content: { + sourcePath: 'brief.kun-ppt.html', + destinationPath: 'brief-copy.kun-ppt.html', + contentSha256: 'a'.repeat(64) + }, + summary: 'Exported copy' + } + }) + + expect(ppt).toMatchObject({ + filePath: '/tmp/presentations/brief.pptx', + meta: { + generatedFiles: [{ + relativePath: 'presentations/brief.pptx', + mimeType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' + }] + } + }) + expect(htmlCopy).toMatchObject({ + filePath: 'brief-copy.kun-ppt.html', + meta: { + canonicalToolId: presentationStudioCanonicalToolId('presentation-export-copy'), + presentationArtifactProducer: PRESENTATION_STUDIO_EXTENSION_ID, + presentationArtifactSha256: 'a'.repeat(64) + } + }) + }) + + it('unwraps progressive extension gateway presentation writes with trusted provenance', () => { + const block = chatBlockFromItem({ + id: 'item_gateway_html', + turnId: 'turn_1', + threadId: 'thr_1', + role: 'tool', + status: 'completed', + createdAt: '2024-01-01T00:00:00.000Z', + kind: 'tool_result', + toolName: 'extension_tool_call', + toolKind: 'tool_call', + callId: 'call_gateway_html', + output: { + canonicalToolId: presentationStudioCanonicalToolId('presentation-apply'), + result: { + content: { + path: 'brief.kun-ppt.html', + resultingRevision: 2, + contentSha256: 'b'.repeat(64) + }, + summary: 'Applied operations' + } + } + }) + + expect(block).toMatchObject({ + toolKind: 'file_change', + filePath: 'brief.kun-ppt.html', + meta: { + canonicalToolId: presentationStudioCanonicalToolId('presentation-apply'), + presentationArtifactProducer: PRESENTATION_STUDIO_EXTENSION_ID, + presentationArtifactSha256: 'b'.repeat(64) + } + }) + }) + + it('preserves workspace-write semantics from a generic progressive extension gateway', () => { + const block = chatBlockFromItem({ + id: 'item_gateway_ppt', + turnId: 'turn_1', + threadId: 'thr_1', + role: 'tool', + status: 'completed', + createdAt: '2024-01-01T00:00:00.000Z', + kind: 'tool_result', + toolName: 'extension_tool_call', + toolKind: 'tool_call', + callId: 'call_gateway_ppt', + output: { + canonicalToolId: 'extension:example.exporter/export-ppt', + sideEffect: 'workspace-write', + result: { content: { destinationPath: 'presentations/brief.pptx' } } + } + }) + + expect(block).toMatchObject({ + toolKind: 'file_change', + filePath: 'presentations/brief.pptx' + }) + }) + it('classifies built-in write/edit tools as file_change by name when toolKind is omitted', () => { const block = chatBlockFromItem({ id: 'item_write_builtin', diff --git a/src/renderer/src/agent/kun-mapper.ts b/src/renderer/src/agent/kun-mapper.ts index f878c4d8e..b0a090014 100644 --- a/src/renderer/src/agent/kun-mapper.ts +++ b/src/renderer/src/agent/kun-mapper.ts @@ -26,6 +26,12 @@ import { normalizeKunRuntimeEvent, type KunEventNormalizerDeps } from './kun-eve import type { RuntimeProjectionAction } from './runtime-projection-actions' import { redactSecrets, redactSecretText } from '@shared/secret-redaction' import { applyClientUserMessageSourceMeta } from '@shared/background-shell-notice' +import { + PRESENTATION_STUDIO_EXTENSION_ID, + PRESENTATION_STUDIO_WRITE_TOOL_NAMES, + presentationStudioCanonicalToolId, + presentationStudioModelAlias +} from '@shared/presentation-artifact' import type { CoreChildRuntimeMetadataJson, CoreRuntimeEventJson, @@ -173,12 +179,16 @@ function readStructuredString(record: Record, ...keys: string[] const FILE_PATH_KEYS = [ 'absolute_path', + 'output_path', + 'outputPath', + 'destination_path', + 'destinationPath', 'path', 'file_path', 'file', 'relative_path', 'target_path', - 'destination_path' + 'targetPath' ] as const const COMMAND_KEYS = ['command', 'cmd', 'script'] as const @@ -224,6 +234,62 @@ function payloadFor(item: CoreTurnItemJson): Record { return (item.arguments ?? {}) as Record } +function structuredPayloadsFor(item: CoreTurnItemJson): Record[] { + const payloads: Record[] = [] + const seen = new Set>() + const visit = (value: unknown, depth: number): void => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return + const record = value as Record + if (seen.has(record)) return + seen.add(record) + payloads.push(record) + if (depth >= 2) return + visit(record.result, depth + 1) + visit(record.content, depth + 1) + } + visit(payloadFor(item), 0) + return payloads +} + +const PRESENTATION_STUDIO_WRITE_TOOL_IDS = new Set( + PRESENTATION_STUDIO_WRITE_TOOL_NAMES.map(presentationStudioCanonicalToolId) +) +const PRESENTATION_STUDIO_DIRECT_TOOL_IDS = new Map( + PRESENTATION_STUDIO_WRITE_TOOL_NAMES.map((name) => [ + presentationStudioModelAlias(name), + presentationStudioCanonicalToolId(name) + ]) +) + +function gatewayPayloadFor(item: CoreTurnItemJson): Record | null { + if (item.kind !== 'tool_result' || item.toolName !== 'extension_tool_call') return null + return payloadFor(item) +} + +function presentationStudioWriteToolId(item: CoreTurnItemJson): string | undefined { + const direct = item.toolName ? PRESENTATION_STUDIO_DIRECT_TOOL_IDS.get(item.toolName) : undefined + if (direct) return direct + const canonicalToolId = gatewayPayloadFor(item)?.canonicalToolId + return typeof canonicalToolId === 'string' && PRESENTATION_STUDIO_WRITE_TOOL_IDS.has(canonicalToolId) + ? canonicalToolId + : undefined +} + +function gatewayHasWorkspaceWriteSideEffect(item: CoreTurnItemJson): boolean { + return gatewayPayloadFor(item)?.sideEffect === 'workspace-write' +} + +function readItemStructuredString( + item: CoreTurnItemJson, + ...keys: readonly string[] +): string | undefined { + for (const payload of structuredPayloadsFor(item)) { + const value = readStructuredString(payload, ...keys) + if (value) return value + } + return undefined +} + function normalizeChildMetadata( child: CoreChildRuntimeMetadataJson | undefined ): CoreChildRuntimeMetadataJson | undefined { @@ -468,10 +534,14 @@ function isGeneratedFileToolName(toolName: string | undefined): boolean { function extractToolGeneratedFiles(item: CoreTurnItemJson): GeneratedFileReference[] | undefined { if (item.kind !== 'tool_result') return undefined - const payload = payloadFor(item) + const payloads = structuredPayloadsFor(item) const candidates = [ - ...(Array.isArray(payload.generatedFiles) ? payload.generatedFiles : []), - ...(isGeneratedFileToolName(item.toolName) && Array.isArray(payload.files) ? payload.files : []) + ...payloads.flatMap((payload) => + Array.isArray(payload.generatedFiles) ? payload.generatedFiles : [] + ), + ...(isGeneratedFileToolName(item.toolName) + ? payloads.flatMap((payload) => Array.isArray(payload.files) ? payload.files : []) + : []) ] const generatedFiles: GeneratedFileReference[] = [] const seen = new Set() @@ -507,9 +577,16 @@ function inferToolPresentation(item: CoreTurnItemJson): { filePath?: string command?: string } { - const payload = payloadFor(item) - const filePath = readStructuredString(payload, ...FILE_PATH_KEYS) - const command = readStructuredString(payload, ...COMMAND_KEYS) + const filePath = readItemStructuredString(item, ...FILE_PATH_KEYS) + const command = readItemStructuredString(item, ...COMMAND_KEYS) + + if (presentationStudioWriteToolId(item) || gatewayHasWorkspaceWriteSideEffect(item)) { + return { + toolKind: 'file_change', + ...(filePath ? { filePath } : {}), + ...(command ? { command } : {}) + } + } if ( item.toolKind === 'tool_call' || @@ -606,6 +683,15 @@ function toolBlockFromItem(item: CoreTurnItemJson, child?: CoreChildRuntimeMetad if (attachments) meta.attachments = attachments const generatedFiles = extractToolGeneratedFiles(item) if (generatedFiles) meta.generatedFiles = generatedFiles + const presentationStudioToolId = presentationStudioWriteToolId(item) + if (presentationStudioToolId) { + meta.canonicalToolId = presentationStudioToolId + meta.presentationArtifactProducer = PRESENTATION_STUDIO_EXTENSION_ID + const contentSha256 = readItemStructuredString(item, 'contentSha256') + if (contentSha256 && /^[a-f0-9]{64}$/i.test(contentSha256)) { + meta.presentationArtifactSha256 = contentSha256.toLowerCase() + } + } const presentation = inferToolPresentation(item) const payload = payloadFor(item) if (presentation.command) meta.command = presentation.command diff --git a/src/renderer/src/components/chat/MessageTimeline.tool-summary.test.ts b/src/renderer/src/components/chat/MessageTimeline.tool-summary.test.ts index 846443b59..4659d828d 100644 --- a/src/renderer/src/components/chat/MessageTimeline.tool-summary.test.ts +++ b/src/renderer/src/components/chat/MessageTimeline.tool-summary.test.ts @@ -316,6 +316,21 @@ describe('MessageTimeline Kun runtime metadata smoke', () => { expect((html.match(/type="button"/g) ?? []).length).toBe(2) }) + it('leaves supported presentation outputs to the dedicated presentation panel', () => { + const block: ToolBlock = toolBlock({ + id: 'tool_presentations', + summary: 'presentation export', + meta: { + generatedFiles: [ + { relativePath: 'presentations/brief.pptx' }, + { relativePath: 'brief.kun-ppt.html' } + ] + } + }) + + expect(renderToStaticMarkup(createElement(GeneratedFilesPanel, { blocks: [block] }))).toBe('') + }) + it('projects only bounded non-secret generated-file metadata to result preview Views', () => { const sources = resultPreviewSourcesForTurn({ user: { kind: 'user', id: 'user_1', text: 'make report' }, diff --git a/src/renderer/src/components/chat/MessageTimeline.tsx b/src/renderer/src/components/chat/MessageTimeline.tsx index 6e6c005a9..ce4b10868 100644 --- a/src/renderer/src/components/chat/MessageTimeline.tsx +++ b/src/renderer/src/components/chat/MessageTimeline.tsx @@ -9,6 +9,8 @@ import { useTimelineScroll } from './use-timeline-scroll' import { deriveTurnSections } from './derive-turn-sections' import { MessageTimelineEmptyHero, ThreadForkBanner, ThreadForkPoint } from './message-timeline-empty' import { GeneratedFilesPanel, MessageBubble } from './message-timeline-bubbles' +import { PresentationFilesPanel } from './PresentationFilesPanel' +import { presentationFileArtifactsForTurn } from './presentation-file-artifacts' import { ReviewPlanCard, ReviewSummaryCard, TurnChangeSummary, WorkMetaRow } from './message-timeline-cards' import { ProcessSectionRow, groupProcessSections } from './message-timeline-process' import type { OpenChildThreadHandler } from './SubagentCallCard' @@ -802,6 +804,15 @@ function MessageTurn({ }), [turn, isProcessing, liveProcessText, liveContent, filePreviewWorkspaceRoot] ) + const presentationFiles = useMemo( + () => presentationFileArtifactsForTurn( + turn.blocks, + filePreviewWorkspaceRoot, + isProcessing, + typeof window === 'undefined' ? '' : window.kunGui?.platform ?? '' + ), + [turn.blocks, filePreviewWorkspaceRoot, isProcessing] + ) const compactionBlocks = useMemo( () => processBlocks.filter((block): block is CompactionTimelineBlock => block.kind === 'compaction'), [processBlocks] @@ -946,6 +957,8 @@ function MessageTurn({ + + {reviewBlocks.map((review) => ( ))} diff --git a/src/renderer/src/components/chat/PresentationFilesPanel.test.ts b/src/renderer/src/components/chat/PresentationFilesPanel.test.ts new file mode 100644 index 000000000..8ffa5ec25 --- /dev/null +++ b/src/renderer/src/components/chat/PresentationFilesPanel.test.ts @@ -0,0 +1,150 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { PresentationFilesPanel } from './PresentationFilesPanel' +import type { PresentationFileArtifact } from './presentation-file-artifacts' + +const actionMocks = vi.hoisted(() => ({ + open: vi.fn(), + reveal: vi.fn() +})) + +vi.mock('../../lib/open-workspace-path', () => ({ + openWorkspaceFileWithSystemDefault: actionMocks.open, + revealWorkspaceFileInFileManager: actionMocks.reveal +})) + +vi.mock('react-i18next', () => { + const labels: Record = { + presentationFilesTitle: 'Presentations', + presentationKindPowerPoint: 'PowerPoint presentation', + presentationOpen: 'Open', + presentationOpenOptions: 'Open options', + presentationOpenSystem: 'Open with system default app', + presentationOpenFailed: 'Open failed', + presentationRevealFailed: 'Reveal failed', + fileTreeRevealInFileManager: 'Reveal in file manager' + } + return { useTranslation: () => ({ t: (key: string) => labels[key] ?? key }) } +}) + +const file: PresentationFileArtifact = { + path: 'presentations/brief.pptx', + name: 'brief.pptx', + kind: 'powerpoint', + extension: 'PPTX' +} + +describe('PresentationFilesPanel', () => { + let renderer: ReactTestRenderer + const logError = vi.fn(async () => undefined) + + beforeEach(async () => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + actionMocks.open.mockReset().mockResolvedValue({ + ok: true, + path: '/workspace/presentations/brief.pptx', + editorId: 'system' + }) + actionMocks.reveal.mockReset().mockResolvedValue({ + ok: true, + path: '/workspace/presentations/brief.pptx', + editorId: 'file-manager' + }) + logError.mockClear() + vi.stubGlobal('window', { kunGui: { logError } }) + vi.stubGlobal('document', { + addEventListener: vi.fn(), + removeEventListener: vi.fn() + }) + await act(async () => { + renderer = create(createElement(PresentationFilesPanel, { + files: [file], + workspaceRoot: '/workspace' + })) + }) + }) + + afterEach(async () => { + await act(async () => renderer.unmount()) + vi.unstubAllGlobals() + }) + + it('opens the presentation with the thread workspace and system association', async () => { + const openButton = renderer.root.findByProps({ + 'aria-label': 'Open with system default app' + }) + + await act(async () => openButton.props.onClick()) + + expect(actionMocks.open).toHaveBeenCalledWith('presentations/brief.pptx', '/workspace', undefined) + expect(actionMocks.reveal).not.toHaveBeenCalled() + }) + + it('offers a file-manager reveal action', async () => { + const menuButton = renderer.root.findByProps({ 'aria-label': 'Open options' }) + await act(async () => menuButton.props.onClick()) + const menuItems = renderer.root.findAllByProps({ role: 'menuitem' }) + + await act(async () => menuItems[1].props.onClick()) + + expect(actionMocks.reveal).toHaveBeenCalledWith('presentations/brief.pptx', '/workspace', undefined) + }) + + it('forwards the trusted content digest for Kun HTML open verification', async () => { + const htmlFile: PresentationFileArtifact = { + path: 'brief.kun-ppt.html', + name: 'brief.kun-ppt.html', + kind: 'kun-html', + extension: 'HTML', + contentSha256: 'a'.repeat(64) + } + await act(async () => renderer.update(createElement(PresentationFilesPanel, { + files: [htmlFile], + workspaceRoot: '/workspace' + }))) + + const openButton = renderer.root.findByProps({ + 'aria-label': 'Open with system default app' + }) + await act(async () => openButton.props.onClick()) + + expect(actionMocks.open).toHaveBeenCalledWith( + 'brief.kun-ppt.html', + '/workspace', + 'a'.repeat(64) + ) + }) + + it('keeps the card visible and shows a bounded failure state', async () => { + actionMocks.open.mockResolvedValueOnce({ ok: false, message: 'No associated application' }) + const openButton = renderer.root.findByProps({ + 'aria-label': 'Open with system default app' + }) + + await act(async () => openButton.props.onClick()) + + expect(renderer.root.findByProps({ children: 'Open failed' })).toBeTruthy() + expect(logError).toHaveBeenCalledWith( + 'presentation-open', + 'Failed to open presentation artifact', + expect.objectContaining({ action: 'open', message: 'No associated application' }) + ) + }) + + it('reports a reveal failure separately from an association failure', async () => { + actionMocks.reveal.mockResolvedValueOnce({ ok: false, message: 'Finder unavailable' }) + const menuButton = renderer.root.findByProps({ 'aria-label': 'Open options' }) + await act(async () => menuButton.props.onClick()) + const menuItems = renderer.root.findAllByProps({ role: 'menuitem' }) + + await act(async () => menuItems[1].props.onClick()) + + expect(renderer.root.findByProps({ children: 'Reveal failed' })).toBeTruthy() + expect(logError).toHaveBeenCalledWith( + 'presentation-open', + 'Failed to open presentation artifact', + expect.objectContaining({ action: 'reveal', message: 'Finder unavailable' }) + ) + }) +}) diff --git a/src/renderer/src/components/chat/PresentationFilesPanel.tsx b/src/renderer/src/components/chat/PresentationFilesPanel.tsx new file mode 100644 index 000000000..e68422244 --- /dev/null +++ b/src/renderer/src/components/chat/PresentationFilesPanel.tsx @@ -0,0 +1,184 @@ +import type { ReactElement } from 'react' +import { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { ChevronDown, ExternalLink, FolderOpen, Loader2, Presentation as PresentationIcon } from 'lucide-react' +import { + openWorkspaceFileWithSystemDefault, + revealWorkspaceFileInFileManager +} from '../../lib/open-workspace-path' +import type { PresentationFileArtifact } from './presentation-file-artifacts' + +type PresentationAction = 'open' | 'reveal' + +function formatByteSize(byteSize: number | undefined): string { + if (typeof byteSize !== 'number' || !Number.isFinite(byteSize) || byteSize <= 0) return '' + const units = ['B', 'KB', 'MB', 'GB'] + let value = byteSize + let unit = 0 + while (value >= 1024 && unit < units.length - 1) { + value /= 1024 + unit += 1 + } + return `${value.toFixed(value >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}` +} + +function PresentationFileCard({ + file, + workspaceRoot +}: { + file: PresentationFileArtifact + workspaceRoot: string +}): ReactElement { + const { t } = useTranslation('common') + const [menuOpen, setMenuOpen] = useState(false) + const [busyAction, setBusyAction] = useState(null) + const [failedAction, setFailedAction] = useState(null) + const menuRef = useRef(null) + + useEffect(() => { + if (!menuOpen) return + const closeIfOutside = (event: PointerEvent): void => { + if (!menuRef.current?.contains(event.target as Node)) setMenuOpen(false) + } + const closeOnEscape = (event: KeyboardEvent): void => { + if (event.key === 'Escape') setMenuOpen(false) + } + document.addEventListener('pointerdown', closeIfOutside) + document.addEventListener('keydown', closeOnEscape) + return () => { + document.removeEventListener('pointerdown', closeIfOutside) + document.removeEventListener('keydown', closeOnEscape) + } + }, [menuOpen]) + + const runAction = async (action: PresentationAction): Promise => { + if (busyAction) return + setMenuOpen(false) + setBusyAction(action) + setFailedAction(null) + try { + const result = action === 'open' + ? await openWorkspaceFileWithSystemDefault(file.path, workspaceRoot, file.contentSha256) + : await revealWorkspaceFileInFileManager(file.path, workspaceRoot, file.contentSha256) + if (!result.ok) { + setFailedAction(action) + void window.kunGui?.logError?.('presentation-open', 'Failed to open presentation artifact', { + action, + message: result.message.slice(0, 1000), + path: file.path.slice(0, 1000) + })?.catch(() => undefined) + } + } catch (error) { + setFailedAction(action) + void window.kunGui?.logError?.('presentation-open', 'Failed to open presentation artifact', { + action, + message: error instanceof Error ? error.message.slice(0, 1000) : String(error).slice(0, 1000), + path: file.path.slice(0, 1000) + })?.catch(() => undefined) + } finally { + setBusyAction(null) + } + } + + const kindLabel = file.kind === 'kun-html' + ? t('presentationKindKunHtml') + : t('presentationKindPowerPoint') + const details = [kindLabel, file.extension, formatByteSize(file.byteSize)].filter(Boolean).join(' · ') + const busy = busyAction !== null + + return ( +
    + + + + + {file.name} + {details} + {failedAction ? ( + + {t(failedAction === 'reveal' ? 'presentationRevealFailed' : 'presentationOpenFailed')} + + ) : null} + + +
    + + + + {menuOpen ? ( +
    + + +
    + ) : null} +
    +
    + ) +} + +export function PresentationFilesPanel({ + files, + workspaceRoot +}: { + files: readonly PresentationFileArtifact[] + workspaceRoot: string +}): ReactElement | null { + const { t } = useTranslation('common') + if (files.length === 0) return null + + return ( +
    +
    {t('presentationFilesTitle')}
    +
    + {files.map((file) => ( + + ))} +
    +
    + ) +} diff --git a/src/renderer/src/components/chat/message-timeline-bubbles.tsx b/src/renderer/src/components/chat/message-timeline-bubbles.tsx index 5218b0918..687321b91 100644 --- a/src/renderer/src/components/chat/message-timeline-bubbles.tsx +++ b/src/renderer/src/components/chat/message-timeline-bubbles.tsx @@ -25,6 +25,7 @@ import { shouldShowQuestionHeader } from './user-input-panel-logic' import { InjectedMemoryMetaChip } from './injected-memory-meta-chip' +import { isPresentationArtifactPath } from './presentation-file-artifacts' const COPY_FEEDBACK_RESET_MS = 1600 const ASSISTANT_EXPORT_FORMATS: WriteExportFormat[] = ['pdf', 'docx', 'png', 'html'] @@ -985,7 +986,9 @@ export function GeneratedFilesPanel({ blocks }: { blocks: ToolBlock[] }): ReactE attachments.push(...metaAttachmentReferences(block.meta as RuntimeDisclosureMetadata | undefined)) generatedFiles.push(...metaGeneratedFileReferences(block.meta)) } - return mergeMediaReferences(attachments, generatedFiles) + return mergeMediaReferences(attachments, generatedFiles).filter( + (file) => !isPresentationArtifactPath(mediaPath(file)) + ) }, [blocks]) if (media.length === 0) return null diff --git a/src/renderer/src/components/chat/presentation-file-artifacts.test.ts b/src/renderer/src/components/chat/presentation-file-artifacts.test.ts new file mode 100644 index 000000000..ad989b463 --- /dev/null +++ b/src/renderer/src/components/chat/presentation-file-artifacts.test.ts @@ -0,0 +1,232 @@ +import { describe, expect, it } from 'vitest' +import type { ChatBlock } from '../../agent/types' +import { + derivePresentationFileArtifacts, + isPresentationArtifactPath, + MAX_PRESENTATION_ARTIFACTS_PER_TURN, + PRESENTATION_STUDIO_ARTIFACT_PRODUCER, + presentationArtifactKindForPath, + presentationFileArtifactsForTurn +} from './presentation-file-artifacts' + +const HTML_SHA256 = 'a'.repeat(64) + +describe('presentation file artifacts', () => { + it('recognizes supported presentation paths without accepting suffix tricks', () => { + expect(presentationArtifactKindForPath('slides/brief.PPTX')).toEqual({ + kind: 'powerpoint', + extension: 'PPTX' + }) + expect(presentationArtifactKindForPath('brief.kun-ppt.HTML')).toEqual({ + kind: 'kun-html', + extension: 'HTML' + }) + expect(presentationArtifactKindForPath('slides/brief.ppt')).toEqual({ + kind: 'powerpoint', + extension: 'PPT' + }) + expect(isPresentationArtifactPath('brief.pptx.exe')).toBe(false) + expect(isPresentationArtifactPath('brief.pptm')).toBe(false) + expect(isPresentationArtifactPath('brief.odp')).toBe(false) + expect(isPresentationArtifactPath('brief.html')).toBe(false) + expect(isPresentationArtifactPath(`${'a'.repeat(4097)}.pptx`)).toBe(false) + }) + + it('collects only successful write outputs and explicit generated presentation files', () => { + const blocks: ChatBlock[] = [ + { + kind: 'tool', + id: 'ppt', + summary: 'ppt_master_run', + status: 'success', + toolKind: 'file_change', + filePath: '/workspace/presentations/brief.pptx', + meta: { + generatedFiles: [{ + name: 'Leadership brief.pptx', + relativePath: 'presentations/brief.pptx', + mimeType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + byteSize: 4096 + }] + } + }, + { + kind: 'tool', + id: 'html', + summary: 'presentation-apply', + status: 'success', + toolKind: 'file_change', + filePath: 'brief.kun-ppt.html', + meta: { + presentationArtifactProducer: PRESENTATION_STUDIO_ARTIFACT_PRODUCER, + presentationArtifactSha256: HTML_SHA256 + } + }, + { + kind: 'tool', + id: 'failed', + summary: 'ppt_master_run', + status: 'error', + toolKind: 'file_change', + filePath: 'presentations/failed.pptx' + }, + { + kind: 'tool', + id: 'read', + summary: 'read', + status: 'success', + toolKind: 'tool_call', + filePath: 'presentations/not-generated.pptx' + } + ] + + expect(derivePresentationFileArtifacts(blocks, '/workspace')).toEqual([ + { + path: 'presentations/brief.pptx', + name: 'Leadership brief.pptx', + kind: 'powerpoint', + extension: 'PPTX', + mimeType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + byteSize: 4096 + }, + { + path: 'brief.kun-ppt.html', + name: 'brief.kun-ppt.html', + kind: 'kun-html', + extension: 'HTML', + contentSha256: HTML_SHA256 + } + ]) + }) + + it('deduplicates path aliases and bounds a turn', () => { + const blocks: ChatBlock[] = Array.from( + { length: MAX_PRESENTATION_ARTIFACTS_PER_TURN + 4 }, + (_, index) => ({ + kind: 'tool' as const, + id: `ppt-${index}`, + summary: 'export', + status: 'success' as const, + toolKind: 'file_change' as const, + filePath: `presentations/deck-${index}.pptx` + }) + ) + blocks.unshift({ + kind: 'tool', + id: 'alias', + summary: 'export', + status: 'success', + toolKind: 'file_change', + filePath: '/workspace/presentations/deck-0.pptx' + }) + blocks.unshift({ + kind: 'tool', + id: 'dot-alias', + summary: 'export', + status: 'success', + toolKind: 'file_change', + filePath: 'presentations/./deck-0.pptx' + }) + + const artifacts = derivePresentationFileArtifacts(blocks, '/workspace') + expect(artifacts).toHaveLength(MAX_PRESENTATION_ARTIFACTS_PER_TURN) + expect(artifacts.filter((artifact) => artifact.name.toLowerCase() === 'deck-0.pptx')).toHaveLength(1) + }) + + it('uses platform-aware case semantics for distinct presentation files', () => { + const blocks: ChatBlock[] = ['Deck.pptx', 'deck.pptx'].map((filePath, index) => ({ + kind: 'tool', + id: `ppt-${index}`, + summary: 'export', + status: 'success', + toolKind: 'file_change', + filePath + })) + + expect(derivePresentationFileArtifacts(blocks, '/workspace', 'linux')).toHaveLength(2) + expect(derivePresentationFileArtifacts(blocks, 'C:/workspace', 'win32')).toHaveLength(1) + }) + + it('rejects paths outside the owning workspace and parent traversal', () => { + const blocks: ChatBlock[] = [ + '/outside/leak.pptx', + '../leak.pptx', + '~/leak.pptx', + 'file:///outside/leak.pptx', + 'safe/deck.pptx' + ].map( + (filePath, index) => ({ + kind: 'tool', + id: `ppt-${index}`, + summary: 'export', + status: 'success', + toolKind: 'file_change', + filePath + }) + ) + + expect(derivePresentationFileArtifacts(blocks, '/workspace', 'linux').map(({ path }) => path)).toEqual([ + 'safe/deck.pptx' + ]) + expect(derivePresentationFileArtifacts(blocks, '', 'linux')).toEqual([]) + }) + + it('only accepts standalone HTML decks from trusted Presentation Studio writes', () => { + const untrusted: ChatBlock = { + kind: 'tool', + id: 'generic-write', + summary: 'write', + status: 'success', + toolKind: 'file_change', + filePath: 'evil.kun-ppt.html', + meta: { + generatedFiles: [{ relativePath: 'also-evil.kun-ppt.html' }] + } + } + const trusted: ChatBlock = { + ...untrusted, + id: 'presentation-studio', + filePath: 'deck.kun-ppt.html', + meta: { + presentationArtifactProducer: PRESENTATION_STUDIO_ARTIFACT_PRODUCER, + presentationArtifactSha256: HTML_SHA256 + } + } + + expect(derivePresentationFileArtifacts([untrusted, trusted], '/workspace')).toEqual([ + expect.objectContaining({ + path: 'deck.kun-ppt.html', + kind: 'kun-html', + contentSha256: HTML_SHA256 + }) + ]) + }) + + it('requires a valid write-time digest for trusted standalone HTML', () => { + const block: ChatBlock = { + kind: 'tool', + id: 'presentation-studio', + summary: 'presentation-create', + status: 'success', + toolKind: 'file_change', + filePath: 'deck.kun-ppt.html', + meta: { presentationArtifactProducer: PRESENTATION_STUDIO_ARTIFACT_PRODUCER } + } + + expect(derivePresentationFileArtifacts([block], '/workspace')).toEqual([]) + }) + + it('keeps presentation handoff hidden until the turn completes', () => { + const blocks: ChatBlock[] = [{ + kind: 'tool', + id: 'ppt', + summary: 'ppt_master_run', + status: 'success', + toolKind: 'file_change', + filePath: 'presentations/brief.pptx' + }] + + expect(presentationFileArtifactsForTurn(blocks, '/workspace', true)).toEqual([]) + expect(presentationFileArtifactsForTurn(blocks, '/workspace', false)).toHaveLength(1) + }) +}) diff --git a/src/renderer/src/components/chat/presentation-file-artifacts.ts b/src/renderer/src/components/chat/presentation-file-artifacts.ts new file mode 100644 index 000000000..b982725ad --- /dev/null +++ b/src/renderer/src/components/chat/presentation-file-artifacts.ts @@ -0,0 +1,233 @@ +import type { ChatBlock, GeneratedFileReference, ToolBlock } from '../../agent/types' +import { PRESENTATION_STUDIO_EXTENSION_ID } from '@shared/presentation-artifact' + +export type PresentationArtifactKind = 'powerpoint' | 'kun-html' + +export type PresentationFileArtifact = { + path: string + name: string + kind: PresentationArtifactKind + extension: string + mimeType?: string + byteSize?: number + contentSha256?: string +} + +export const MAX_PRESENTATION_ARTIFACTS_PER_TURN = 16 +export const PRESENTATION_STUDIO_ARTIFACT_PRODUCER = PRESENTATION_STUDIO_EXTENSION_ID +const MAX_PRESENTATION_ARTIFACT_PATH_LENGTH = 4096 +const MAX_PRESENTATION_ARTIFACT_NAME_LENGTH = 256 +const MAX_PRESENTATION_ARTIFACT_MIME_LENGTH = 128 + +const POWERPOINT_EXTENSIONS = new Set(['ppt', 'pptx']) + +function normalizeSlashes(value: string): string { + const normalized = value.trim().replace(/\\/g, '/').replace(/\/{2,}/g, '/') + if (normalized === '/' || /^[A-Za-z]:\/$/.test(normalized)) return normalized + return normalized.replace(/\/$/, '') +} + +function collapseCurrentDirectorySegments(value: string): string { + return normalizeSlashes(value).split('/').filter((segment) => segment !== '.').join('/') +} + +function isAbsolutePath(value: string): boolean { + return value.startsWith('/') || /^[A-Za-z]:\//.test(value) +} + +function containsParentTraversal(path: string): boolean { + return normalizeSlashes(path).split('/').includes('..') +} + +function hasUnsafePathPrefix(path: string): boolean { + if (path === '~' || path.startsWith('~/')) return true + return /^[a-z][a-z0-9+.-]*:/i.test(path) && !/^[A-Za-z]:\//.test(path) +} + +function hasControlCharacter(value: string): boolean { + for (const character of value) { + if ((character.codePointAt(0) ?? 0) <= 0x1f) return true + } + return false +} + +function caseComparablePath(path: string, platform: string): string { + return platform === 'win32' ? path.toLowerCase() : path +} + +function workspaceRelativeArtifactPath( + path: string, + workspaceRoot: string, + platform: string +): string | null { + let normalized = collapseCurrentDirectorySegments(path) + const root = collapseCurrentDirectorySegments(workspaceRoot) + if (!normalized || hasControlCharacter(normalized) || hasUnsafePathPrefix(normalized)) return null + if (containsParentTraversal(normalized)) return null + if (!root || !isAbsolutePath(root) || containsParentTraversal(root)) return null + if (!isAbsolutePath(normalized)) return normalized + + const comparablePath = caseComparablePath(normalized, platform) + const comparableRoot = caseComparablePath(root, platform) + const comparablePrefix = comparableRoot.endsWith('/') ? comparableRoot : `${comparableRoot}/` + if (!comparablePath.startsWith(comparablePrefix)) return null + const prefixLength = root.endsWith('/') ? root.length : root.length + 1 + normalized = normalized.slice(prefixLength) + return normalized || null +} + +function pathKey(path: string, workspaceRoot: string, platform: string): string | null { + const relative = workspaceRelativeArtifactPath(path, workspaceRoot, platform) + if (!relative) return null + return caseComparablePath(relative, platform) +} + +function isTrustedKunHtmlProducer(block: ToolBlock): boolean { + return block.meta?.presentationArtifactProducer === PRESENTATION_STUDIO_ARTIFACT_PRODUCER +} + +function trustedContentSha256(block: ToolBlock): string | undefined { + const value = block.meta?.presentationArtifactSha256 + return typeof value === 'string' && /^[a-f0-9]{64}$/i.test(value) + ? value.toLowerCase() + : undefined +} + +function canPublishArtifact(block: ToolBlock, kind: PresentationArtifactKind): boolean { + if (kind !== 'kun-html') return true + return isTrustedKunHtmlProducer(block) && Boolean(trustedContentSha256(block)) +} + +function preferArtifactPath(existing: string, candidate: string): string { + if (!isAbsolutePath(candidate) || isAbsolutePath(existing)) { + return candidate + } + return existing +} + +function nameFromPath(path: string): string { + return normalizeSlashes(path).split('/').filter(Boolean).at(-1) ?? path +} + +export function presentationArtifactKindForPath( + path: string +): { kind: PresentationArtifactKind; extension: string } | null { + if (!path.trim() || path.length > MAX_PRESENTATION_ARTIFACT_PATH_LENGTH) return null + const normalized = normalizeSlashes(path).toLowerCase() + if (normalized.endsWith('.kun-ppt.html')) { + return { kind: 'kun-html', extension: 'HTML' } + } + const name = nameFromPath(normalized) + const dot = name.lastIndexOf('.') + if (dot <= 0 || dot === name.length - 1) return null + const extension = name.slice(dot + 1) + if (POWERPOINT_EXTENSIONS.has(extension)) { + return { kind: 'powerpoint', extension: extension.toUpperCase() } + } + return null +} + +export function isPresentationArtifactPath(path: string | undefined): boolean { + return typeof path === 'string' && presentationArtifactKindForPath(path) !== null +} + +function generatedFilePath(file: GeneratedFileReference): string | undefined { + return file.relativePath || file.path || file.absolutePath +} + +function normalizeGeneratedFile(value: unknown): GeneratedFileReference | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + const raw = value as Record + const readString = (...keys: string[]): string | undefined => { + for (const key of keys) { + const entry = raw[key] + if (typeof entry === 'string' && entry.trim()) return entry.trim() + } + return undefined + } + const relativePath = readString('relativePath', 'relative_path') + const path = readString('path', 'file') + const absolutePath = readString('absolutePath', 'absolute_path') + const name = readString('name', 'fileName', 'filename')?.slice(0, MAX_PRESENTATION_ARTIFACT_NAME_LENGTH) + const mimeType = readString('mimeType', 'type', 'mediaType')?.slice(0, MAX_PRESENTATION_ARTIFACT_MIME_LENGTH) + const byteSize = raw.byteSize + return { + ...(relativePath ? { relativePath } : {}), + ...(path ? { path } : {}), + ...(absolutePath ? { absolutePath } : {}), + ...(name ? { name } : {}), + ...(mimeType ? { mimeType } : {}), + ...(typeof byteSize === 'number' && Number.isFinite(byteSize) && byteSize >= 0 + ? { byteSize } + : {}) + } +} + +function generatedFilesFrom(block: ToolBlock): GeneratedFileReference[] { + const value = block.meta?.generatedFiles + if (!Array.isArray(value)) return [] + return value + .map(normalizeGeneratedFile) + .filter((file): file is GeneratedFileReference => file !== null) +} + +export function derivePresentationFileArtifacts( + blocks: readonly ChatBlock[], + workspaceRoot: string, + platform = '' +): PresentationFileArtifact[] { + const artifacts: PresentationFileArtifact[] = [] + const indexByPath = new Map() + + const add = (block: ToolBlock, path: string, metadata?: GeneratedFileReference): void => { + if (path.length > MAX_PRESENTATION_ARTIFACT_PATH_LENGTH) return + const resolvedKind = presentationArtifactKindForPath(path) + if (!resolvedKind || !canPublishArtifact(block, resolvedKind.kind)) return + const key = pathKey(path, workspaceRoot, platform) + if (!key) return + const candidate: PresentationFileArtifact = { + path, + name: (metadata?.name?.trim() || nameFromPath(path)).slice(0, MAX_PRESENTATION_ARTIFACT_NAME_LENGTH), + kind: resolvedKind.kind, + extension: resolvedKind.extension, + ...(metadata?.mimeType?.trim() ? { mimeType: metadata.mimeType.trim() } : {}), + ...(typeof metadata?.byteSize === 'number' ? { byteSize: metadata.byteSize } : {}), + ...(resolvedKind.kind === 'kun-html' + ? { contentSha256: trustedContentSha256(block) } + : {}) + } + const existingIndex = indexByPath.get(key) + if (existingIndex !== undefined) { + const existing = artifacts[existingIndex] + artifacts[existingIndex] = { + ...existing, + ...candidate, + path: preferArtifactPath(existing.path, candidate.path) + } + return + } + if (artifacts.length >= MAX_PRESENTATION_ARTIFACTS_PER_TURN) return + indexByPath.set(key, artifacts.length) + artifacts.push(candidate) + } + + for (const block of blocks) { + if (block.kind !== 'tool' || block.status !== 'success') continue + if (block.toolKind === 'file_change' && block.filePath) add(block, block.filePath) + for (const file of generatedFilesFrom(block)) { + const path = generatedFilePath(file) + if (path) add(block, path, file) + } + } + + return artifacts +} + +export function presentationFileArtifactsForTurn( + blocks: readonly ChatBlock[], + workspaceRoot: string, + isProcessing: boolean, + platform = '' +): PresentationFileArtifact[] { + return isProcessing ? [] : derivePresentationFileArtifacts(blocks, workspaceRoot, platform) +} diff --git a/src/renderer/src/lib/open-workspace-path.test.ts b/src/renderer/src/lib/open-workspace-path.test.ts index 4e06e9b0c..95b037b9e 100644 --- a/src/renderer/src/lib/open-workspace-path.test.ts +++ b/src/renderer/src/lib/open-workspace-path.test.ts @@ -1,6 +1,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { + openWorkspaceFileWithSystemDefault, openWorkspacePathInEditor, + revealWorkspaceFileInFileManager, revealWorkspacePathInFileManager } from './open-workspace-path' @@ -65,3 +67,119 @@ describe('revealWorkspacePathInFileManager', () => { }) }) }) + +describe('exact workspace file actions', () => { + it('resolves the exact workspace file before using the system default application', async () => { + const resolveWorkspaceFile = vi.fn(async () => ({ + ok: true as const, + path: '/tmp/workspace/presentations/brief.pptx' + })) + const openEditorPath = vi.fn(async () => ({ + ok: true as const, + path: '/tmp/workspace/presentations/brief.pptx', + editorId: 'system' + })) + vi.stubGlobal('window', { kunGui: { resolveWorkspaceFile, openEditorPath } }) + + await expect( + openWorkspaceFileWithSystemDefault('presentations/brief.pptx', '/tmp/workspace') + ).resolves.toMatchObject({ ok: true, editorId: 'system' }) + expect(resolveWorkspaceFile).toHaveBeenCalledWith({ + path: 'presentations/brief.pptx', + workspaceRoot: '/tmp/workspace' + }) + expect(openEditorPath).toHaveBeenCalledWith({ + path: '/tmp/workspace/presentations/brief.pptx', + workspaceRoot: '/tmp/workspace', + editorId: 'system', + openPolicy: 'presentation-artifact' + }) + }) + + it('does not open a missing or ambiguous presentation', async () => { + const openEditorPath = vi.fn() + vi.stubGlobal('window', { + kunGui: { + resolveWorkspaceFile: vi.fn(async () => ({ ok: false as const, message: 'File not found.' })), + openEditorPath + } + }) + + await expect( + openWorkspaceFileWithSystemDefault('brief.pptx', '/tmp/workspace') + ).resolves.toEqual({ ok: false, message: 'File not found.' }) + expect(openEditorPath).not.toHaveBeenCalled() + }) + + it('resolves the same exact file before revealing it in the file manager', async () => { + const openEditorPath = vi.fn(async () => ({ + ok: true as const, + path: '/tmp/workspace/brief.kun-ppt.html', + editorId: 'file-manager' + })) + vi.stubGlobal('window', { + kunGui: { + resolveWorkspaceFile: vi.fn(async () => ({ + ok: true as const, + path: '/tmp/workspace/brief.kun-ppt.html' + })), + openEditorPath + } + }) + + await expect( + revealWorkspaceFileInFileManager('brief.kun-ppt.html', '/tmp/workspace') + ).resolves.toMatchObject({ ok: true, editorId: 'file-manager' }) + expect(openEditorPath).toHaveBeenCalledWith({ + path: '/tmp/workspace/brief.kun-ppt.html', + workspaceRoot: '/tmp/workspace', + editorId: 'file-manager', + openPolicy: 'presentation-artifact' + }) + }) + + it('carries the trusted HTML digest into the system-open policy', async () => { + const contentSha256 = 'a'.repeat(64) + const openEditorPath = vi.fn(async () => ({ + ok: true as const, + path: '/tmp/workspace/brief.kun-ppt.html', + editorId: 'system' + })) + vi.stubGlobal('window', { + kunGui: { + resolveWorkspaceFile: vi.fn(async () => ({ + ok: true as const, + path: '/tmp/workspace/brief.kun-ppt.html' + })), + openEditorPath + } + }) + + await expect(openWorkspaceFileWithSystemDefault( + 'brief.kun-ppt.html', + '/tmp/workspace', + contentSha256 + )).resolves.toMatchObject({ ok: true }) + expect(openEditorPath).toHaveBeenCalledWith({ + path: '/tmp/workspace/brief.kun-ppt.html', + workspaceRoot: '/tmp/workspace', + editorId: 'system', + openPolicy: 'presentation-artifact', + expectedSha256: contentSha256 + }) + }) + + it('requires the owning workspace root', async () => { + vi.stubGlobal('window', { + kunGui: { + resolveWorkspaceFile: vi.fn(), + openEditorPath: vi.fn() + } + }) + + await expect(openWorkspaceFileWithSystemDefault('/tmp/brief.pptx', '')).resolves.toEqual({ + ok: false, + message: 'Workspace root is required.' + }) + }) +}) diff --git a/src/renderer/src/lib/open-workspace-path.ts b/src/renderer/src/lib/open-workspace-path.ts index ea7f63cc4..f2588aea6 100644 --- a/src/renderer/src/lib/open-workspace-path.ts +++ b/src/renderer/src/lib/open-workspace-path.ts @@ -19,6 +19,22 @@ async function invokeOpenEditorPath(options: OpenEditorPathOptions): Promise { + const root = workspaceRoot.trim() + if (!root) return { ok: false, message: 'Workspace root is required.' } + if (typeof window === 'undefined' || typeof window.kunGui?.resolveWorkspaceFile !== 'function') { + return { ok: false, message: 'Workspace file bridge is unavailable.' } + } + try { + return await window.kunGui.resolveWorkspaceFile({ path: targetPath, workspaceRoot: root }) + } catch (error) { + return { ok: false, message: error instanceof Error ? error.message : String(error) } + } +} + export async function openWorkspacePathInEditor( target: WorkspacePathTarget, workspaceRoot?: string @@ -34,6 +50,38 @@ export async function openWorkspacePathInEditor( export const openWorkspacePath = openWorkspacePathInEditor +export async function openWorkspaceFileWithSystemDefault( + targetPath: string, + workspaceRoot: string, + expectedSha256?: string +): Promise { + const resolved = await resolveExactWorkspaceFile(targetPath, workspaceRoot) + if (!resolved.ok) return resolved + return invokeOpenEditorPath({ + path: resolved.path, + workspaceRoot: workspaceRoot.trim(), + editorId: 'system', + openPolicy: 'presentation-artifact', + ...(expectedSha256 ? { expectedSha256 } : {}) + }) +} + +export async function revealWorkspaceFileInFileManager( + targetPath: string, + workspaceRoot: string, + expectedSha256?: string +): Promise { + const resolved = await resolveExactWorkspaceFile(targetPath, workspaceRoot) + if (!resolved.ok) return resolved + return invokeOpenEditorPath({ + path: resolved.path, + workspaceRoot: workspaceRoot.trim(), + editorId: 'file-manager', + openPolicy: 'presentation-artifact', + ...(expectedSha256 ? { expectedSha256 } : {}) + }) +} + export async function revealWorkspacePathInFileManager( targetPath: string, workspaceRoot?: string diff --git a/src/renderer/src/locales/en/common.json b/src/renderer/src/locales/en/common.json index 1cb682242..7a9dda0af 100644 --- a/src/renderer/src/locales/en/common.json +++ b/src/renderer/src/locales/en/common.json @@ -2623,6 +2623,14 @@ "generatedFileSaving": "Saving…", "generatedFileSaved": "Saved", "generatedFileSaveFailed": "Save failed", + "presentationFilesTitle": "Presentations", + "presentationKindPowerPoint": "PowerPoint presentation", + "presentationKindKunHtml": "Kun HTML presentation", + "presentationOpen": "Open", + "presentationOpenOptions": "Open options", + "presentationOpenSystem": "Open with system default app", + "presentationOpenFailed": "Could not open this presentation. It may have moved or its default app may be unavailable.", + "presentationRevealFailed": "Could not reveal this presentation in the file manager.", "toolBuiltinRead": "Read", "toolBuiltinWrite": "Write", "toolBuiltinEdit": "Edit", diff --git a/src/renderer/src/locales/zh/common.json b/src/renderer/src/locales/zh/common.json index 0b654957d..234a597aa 100644 --- a/src/renderer/src/locales/zh/common.json +++ b/src/renderer/src/locales/zh/common.json @@ -2623,6 +2623,14 @@ "generatedFileSaving": "保存中…", "generatedFileSaved": "已保存", "generatedFileSaveFailed": "保存失败", + "presentationFilesTitle": "演示文稿", + "presentationKindPowerPoint": "PowerPoint 演示文稿", + "presentationKindKunHtml": "Kun HTML 演示文稿", + "presentationOpen": "打开", + "presentationOpenOptions": "打开方式", + "presentationOpenSystem": "使用系统默认应用打开", + "presentationOpenFailed": "无法打开此演示文稿;文件可能已移动,或系统尚未设置默认应用。", + "presentationRevealFailed": "无法在文件管理器中显示此演示文稿。", "toolBuiltinRead": "读取", "toolBuiltinWrite": "写入", "toolBuiltinEdit": "编辑", diff --git a/src/shared/editor.ts b/src/shared/editor.ts index d4e21acc1..eeb426726 100644 --- a/src/shared/editor.ts +++ b/src/shared/editor.ts @@ -21,6 +21,10 @@ export type OpenEditorPathOptions = { editorId?: string line?: number column?: number + /** Main-owned validation policy for generated artifact actions. */ + openPolicy?: 'presentation-artifact' + /** Trusted write-time digest required before system-opening a Kun HTML deck. */ + expectedSha256?: string } export type EditorOpenResult = diff --git a/src/shared/presentation-artifact.ts b/src/shared/presentation-artifact.ts new file mode 100644 index 000000000..199c5c3ce --- /dev/null +++ b/src/shared/presentation-artifact.ts @@ -0,0 +1,22 @@ +export const PRESENTATION_STUDIO_EXTENSION_ID = 'kun-examples.presentation-studio' + +export const PRESENTATION_STUDIO_WRITE_TOOL_NAMES = [ + 'presentation-create', + 'presentation-apply', + 'presentation-export-copy' +] as const + +export type PresentationStudioWriteToolName = typeof PRESENTATION_STUDIO_WRITE_TOOL_NAMES[number] + +export function presentationStudioCanonicalToolId(name: PresentationStudioWriteToolName): string { + return `extension:${PRESENTATION_STUDIO_EXTENSION_ID}/${name}` +} + +// extensionToolModelAlias hashes the stable extension id with SHA-256. Keeping +// the namespace here lets the renderer recognize direct calls without trusting +// a tool-controlled output field. Gateway calls carry the canonical id instead. +const PRESENTATION_STUDIO_MODEL_ALIAS_NAMESPACE = 'e1d66f1c97' + +export function presentationStudioModelAlias(name: PresentationStudioWriteToolName): string { + return `ext_${PRESENTATION_STUDIO_MODEL_ALIAS_NAMESPACE}_${name}` +} From a785e54e440cd68a72014a7b38922aa4d0f93871 Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Wed, 15 Jul 2026 03:07:17 +0800 Subject: [PATCH 056/110] fix(write): align PDF selection with UI scale --- .../src/components/write/WritePdfViewer.tsx | 29 +++++++++----- .../write-pdf-selection-geometry.test.ts | 20 ++++++++++ .../write/write-pdf-selection-geometry.ts | 39 +++++++++++++++++++ 3 files changed, 79 insertions(+), 9 deletions(-) create mode 100644 src/renderer/src/components/write/write-pdf-selection-geometry.test.ts create mode 100644 src/renderer/src/components/write/write-pdf-selection-geometry.ts diff --git a/src/renderer/src/components/write/WritePdfViewer.tsx b/src/renderer/src/components/write/WritePdfViewer.tsx index da6989992..275a0ef8e 100644 --- a/src/renderer/src/components/write/WritePdfViewer.tsx +++ b/src/renderer/src/components/write/WritePdfViewer.tsx @@ -15,6 +15,7 @@ import type { WriteSelectionAnchorRect, WriteSelectionPageRect } from './WriteMarkdownEditor' +import { viewportRectToPageLocalRect } from './write-pdf-selection-geometry' import { applyPdfTextLayerScale } from './write-pdf-text-layer' GlobalWorkerOptions.workerSrc = pdfWorkerUrl @@ -193,11 +194,19 @@ function mergeRectsIntoLineBars(rects: DOMRect[]): ViewportRect[] { } function pageRectsFromViewportRects(root: HTMLElement, rects: ViewportRect[]): WriteSelectionPageRect[] { - const pages = Array.from(root.querySelectorAll('[data-write-pdf-page]')).map((element) => ({ - element, - page: Number(element.dataset.writePdfPage ?? ''), - rect: element.getBoundingClientRect() - })).filter((page) => Number.isFinite(page.page) && page.page > 0) + const pages = Array.from(root.querySelectorAll('[data-write-pdf-page]')).map((element) => { + const rect = element.getBoundingClientRect() + const styleWidth = Number.parseFloat(element.style.width) + const styleHeight = Number.parseFloat(element.style.height) + return { + page: Number(element.dataset.writePdfPage ?? ''), + rect, + localSize: { + width: styleWidth > 0 ? styleWidth : element.offsetWidth || rect.width, + height: styleHeight > 0 ? styleHeight : element.offsetHeight || rect.height + } + } + }).filter((page) => Number.isFinite(page.page) && page.page > 0) const out: WriteSelectionPageRect[] = [] for (const rect of rects) { @@ -208,12 +217,14 @@ function pageRectsFromViewportRects(root: HTMLElement, rects: ViewportRect[]): W const top = Math.max(rect.top, page.rect.top) const bottom = Math.min(rect.bottom, page.rect.bottom) if (right <= left || bottom <= top) continue + const localRect = viewportRectToPageLocalRect( + { left, right, top, bottom }, + page.rect, + page.localSize + ) out.push({ page: page.page, - x: left - page.rect.left, - y: top - page.rect.top, - width: right - left, - height: bottom - top + ...localRect }) } return out diff --git a/src/renderer/src/components/write/write-pdf-selection-geometry.test.ts b/src/renderer/src/components/write/write-pdf-selection-geometry.test.ts new file mode 100644 index 000000000..2073b22f5 --- /dev/null +++ b/src/renderer/src/components/write/write-pdf-selection-geometry.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { viewportRectToPageLocalRect } from './write-pdf-selection-geometry' + +describe('Write PDF selection geometry', () => { + it('keeps viewport coordinates unchanged without UI scaling', () => { + expect(viewportRectToPageLocalRect( + { left: 180, top: 500, right: 540, bottom: 516 }, + { left: 10, top: 220, right: 714, bottom: 1100, width: 704, height: 880 }, + { width: 704, height: 880 } + )).toEqual({ x: 170, y: 280, width: 360, height: 16 }) + }) + + it('removes the app UI zoom before positioning the page overlay', () => { + expect(viewportRectToPageLocalRect( + { left: 281.25, top: 530, right: 617.5, bottom: 544.375 }, + { left: 10, top: 220, right: 890, bottom: 1320, width: 880, height: 1100 }, + { width: 704, height: 880 } + )).toEqual({ x: 217, y: 248, width: 269, height: 11.5 }) + }) +}) diff --git a/src/renderer/src/components/write/write-pdf-selection-geometry.ts b/src/renderer/src/components/write/write-pdf-selection-geometry.ts new file mode 100644 index 000000000..29c12f523 --- /dev/null +++ b/src/renderer/src/components/write/write-pdf-selection-geometry.ts @@ -0,0 +1,39 @@ +export type RectEdges = { + left: number + top: number + right: number + bottom: number +} + +export type RectSize = { + width: number + height: number +} + +export type PageLocalRect = { + x: number + y: number + width: number + height: number +} + +function renderedScale(renderedSize: number, localSize: number): number { + const scale = localSize > 0 ? renderedSize / localSize : 1 + return Number.isFinite(scale) && scale > 0 ? scale : 1 +} + +/** Convert viewport coordinates back into the page's pre-CSS-zoom space. */ +export function viewportRectToPageLocalRect( + rect: RectEdges, + renderedPage: RectEdges & RectSize, + localPage: RectSize +): PageLocalRect { + const scaleX = renderedScale(renderedPage.width, localPage.width) + const scaleY = renderedScale(renderedPage.height, localPage.height) + return { + x: (rect.left - renderedPage.left) / scaleX, + y: (rect.top - renderedPage.top) / scaleY, + width: (rect.right - rect.left) / scaleX, + height: (rect.bottom - rect.top) / scaleY + } +} From 94c2188765801fb025cc530b601d386a7c5af370 Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Wed, 15 Jul 2026 03:10:16 +0800 Subject: [PATCH 057/110] feat(chat): refine timeline jump rail --- .../src/components/chat/MessageTimeline.tsx | 172 ++++++++++++++---- .../chat/MessageTimeline.turn-rail.test.ts | 79 +++++++- src/renderer/src/styles/base-shell.css | 121 ++++++++---- 3 files changed, 301 insertions(+), 71 deletions(-) diff --git a/src/renderer/src/components/chat/MessageTimeline.tsx b/src/renderer/src/components/chat/MessageTimeline.tsx index 233726b76..4bf7c647f 100644 --- a/src/renderer/src/components/chat/MessageTimeline.tsx +++ b/src/renderer/src/components/chat/MessageTimeline.tsx @@ -1,6 +1,7 @@ import type { ReactElement, RefObject } from 'react' import { memo, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' +import { GitCommitHorizontal, Hash } from 'lucide-react' import type { ChatBlock, RuntimeConnectionStatus } from '../../agent/types' import { useChatStore } from '../../store/chat-store' import { threadHasPendingRuntimeWork } from '../../store/chat-store-runtime-helpers' @@ -53,6 +54,7 @@ import { type ExtensionResultPreviewSource } from '../../extensions/ControlledContributionSurfaces' import { resolveActiveExtensionWorkspaceRoot } from '../../extensions/active-extension-workspace' +import { extractDiffFilePath, extractUnifiedDiffText } from '../../lib/diff-stats' export { summarizeToolBlock } from './message-timeline-process' @@ -89,8 +91,8 @@ type CompactionTimelineBlock = Extract const TURN_PAGE_SIZE = 18 const TIMELINE_JUMP_RAIL_FALLBACK_LEFT_PX = 16 const TIMELINE_JUMP_RAIL_STAGE_INSET_PX = 16 -const TIMELINE_JUMP_RAIL_WIDTH_PX = 30 -const TIMELINE_JUMP_RAIL_PREVIEW_OFFSET_PX = 34 +const TIMELINE_JUMP_RAIL_WIDTH_PX = 62 +const TIMELINE_JUMP_RAIL_PREVIEW_OFFSET_PX = 68 const TIMELINE_JUMP_RAIL_PREVIEW_WIDTH_PX = 416 const TIMELINE_JUMP_RAIL_PREVIEW_MARGIN_PX = 16 const TIMELINE_JUMP_RAIL_PREVIEW_CONTAINER_GUTTER_PX = 88 @@ -173,18 +175,66 @@ function turnPreview(turn: Turn, fallback: string): string { return oneLine.length > 48 ? `${oneLine.slice(0, 47).trimEnd()}...` : oneLine } -function turnPromptPreview(turn: Turn, fallback: string): string { - if (turn.user && isBackgroundShellNoticeBlock(turn.user)) { - const display = turn.user.meta?.displayText?.trim() - if (display) return display.replace(/\s+/g, ' ') +function turnResponsePreview(turn: Turn, fallback: string): string { + for (let index = turn.blocks.length - 1; index >= 0; index -= 1) { + const block = turn.blocks[index] + if (block.kind !== 'assistant') continue + const content = splitThink(block.text).content.trim() + if (content) return content.replace(/\s+/g, ' ') } - const text = turn.user?.text.trim() ?? '' - if (!text) return fallback - return text.replace(/\s+/g, ' ') + return fallback +} + +export type TimelineJumpPreviewMetadata = { + fileLabels: string[] + hasCommit: boolean +} + +function timelineJumpPreviewFileLabel(filePath: string): string { + const normalized = filePath.trim().replace(/\\/g, '/').replace(/\/+$/, '') + return normalized.split('/').at(-1) ?? normalized +} + +export function timelineJumpPreviewMetadata(turn: Turn): TimelineJumpPreviewMetadata { + const fileLabels: string[] = [] + const seenFileLabels = new Set() + let hasCommit = false + + for (const block of turn.blocks) { + if (block.kind !== 'tool' || block.status !== 'success') continue + + if (block.toolKind === 'file_change') { + const filePath = extractDiffFilePath(extractUnifiedDiffText(block.detail), block.filePath) + if (filePath) { + const label = timelineJumpPreviewFileLabel(filePath) + const key = label.toLocaleLowerCase() + if (label && !seenFileLabels.has(key)) { + seenFileLabels.add(key) + fileLabels.push(label) + } + } + } + + const command = typeof block.meta?.command === 'string' ? block.meta.command : '' + if (/\bgit(?:\s+-C\s+(?:"[^"]*"|'[^']*'|\S+))?\s+commit\b/i.test(command)) { + hasCommit = true + } + } + + return { fileLabels: fileLabels.slice(0, 32), hasCommit } +} + +export function timelineJumpPreviewTop( + buttonTop: number, + buttonHeight: number, + railAnchorTop: number +): number { + return buttonTop + buttonHeight / 2 - railAnchorTop } -export function timelineJumpWaveLevel(index: number): number { - return [2, 4, 5, 3, 1][index % 5] ?? 3 +export function timelineJumpWaveDistance(index: number, hoveredIndex: number): number | null { + if (hoveredIndex < 0) return null + return Math.min(Math.abs(index - hoveredIndex), 3) } function processBlockHasError(block: ChatBlock): boolean { @@ -346,8 +396,12 @@ export function MessageTimeline({ previewLeft: number } | null>(null) const [jumpRailPreview, setJumpRailPreview] = useState<{ + key: string title: string prompt: string + fileLabels: string[] + hasCommit: boolean + top: number } | null>(null) const [messageContextMenu, setMessageContextMenu] = useState<{ position: { x: number; y: number } @@ -387,7 +441,13 @@ export function MessageTimeline({ ) const visibleTurnAnchors = useMemo( () => { - const anchors: { key: string; label: string; title: string; prompt: string; waveLevel: number }[] = [] + const anchors: Array<{ + key: string + title: string + prompt: string + fileLabels: string[] + hasCommit: boolean + }> = [] let questionIndex = turns .slice(0, hiddenTurnCount) .filter((turn) => turn.user) @@ -398,12 +458,12 @@ export function MessageTimeline({ questionIndex += 1 const absoluteTurnIndex = hiddenTurnCount + index const key = stableTurnKey(turn, absoluteTurnIndex) + const metadata = timelineJumpPreviewMetadata(turn) anchors.push({ key, - label: String(questionIndex), title: turnPreview(turn, t('timelineJumpTurn', { index: questionIndex })), - prompt: turnPromptPreview(turn, t('timelineJumpTurn', { index: questionIndex })), - waveLevel: timelineJumpWaveLevel(anchors.length) + prompt: turnResponsePreview(turn, t('timelineJumpTurn', { index: questionIndex })), + ...metadata }) }) return anchors @@ -491,14 +551,32 @@ export function MessageTimeline({ } const showJumpRailPreview = ( - anchor: { label: string; title: string; prompt: string } + anchor: { + key: string + title: string + prompt: string + fileLabels: string[] + hasCommit: boolean + }, + node: HTMLButtonElement ): void => { + const nodeRect = node.getBoundingClientRect() + const railAnchor = node.closest('.timeline-jump-rail-anchor') + const railAnchorTop = railAnchor?.getBoundingClientRect().top ?? nodeRect.top setJumpRailPreview({ - title: t('timelineJumpTurn', { index: anchor.label }), - prompt: anchor.prompt || anchor.title + key: anchor.key, + title: anchor.title, + prompt: anchor.prompt || anchor.title, + fileLabels: anchor.fileLabels, + hasCommit: anchor.hasCommit, + top: timelineJumpPreviewTop(nodeRect.top, nodeRect.height, railAnchorTop) }) } + const jumpRailHoveredIndex = jumpRailPreview + ? visibleTurnAnchors.findIndex((item) => item.key === jumpRailPreview.key) + : -1 + return ( @@ -511,34 +589,58 @@ export function MessageTimeline({ style={{ left: `${jumpRailLayout.railLeft}px` }} + onMouseLeave={() => setJumpRailPreview(null)} > - {visibleTurnAnchors.map((anchor) => ( -
    diff --git a/src/renderer/src/components/chat/MessageTimeline.turn-rail.test.ts b/src/renderer/src/components/chat/MessageTimeline.turn-rail.test.ts index 19c9285e3..6a8d9cf1d 100644 --- a/src/renderer/src/components/chat/MessageTimeline.turn-rail.test.ts +++ b/src/renderer/src/components/chat/MessageTimeline.turn-rail.test.ts @@ -3,7 +3,9 @@ import { activeTimelineTurnKey, timelineJumpRailLeft, timelineJumpRailPreviewLeft, - timelineJumpWaveLevel + timelineJumpPreviewMetadata, + timelineJumpPreviewTop, + timelineJumpWaveDistance } from './MessageTimeline' describe('activeTimelineTurnKey', () => { @@ -29,9 +31,76 @@ describe('activeTimelineTurnKey', () => { }) }) -describe('timelineJumpWaveLevel', () => { - it('cycles compact rail items through a wave pattern', () => { - expect(Array.from({ length: 7 }, (_, index) => timelineJumpWaveLevel(index))).toEqual([2, 4, 5, 3, 1, 2, 4]) +describe('timelineJumpWaveDistance', () => { + it('expands only the hovered turn and its nearby turns', () => { + expect(Array.from({ length: 7 }, (_, index) => timelineJumpWaveDistance(index, 3))).toEqual([3, 2, 1, 0, 1, 2, 3]) + }) + + it('keeps every turn compact when the rail is idle', () => { + expect(timelineJumpWaveDistance(3, -1)).toBeNull() + }) +}) + +describe('timelineJumpPreviewMetadata', () => { + it('collects unique edited file labels and detects a git commit command', () => { + expect(timelineJumpPreviewMetadata({ + user: { kind: 'user', id: 'user-1', text: 'Update the rail' }, + blocks: [ + { + kind: 'tool', + id: 'file-1', + summary: 'edit file', + status: 'success', + toolKind: 'file_change', + filePath: '/workspace/src/base-shell.css' + }, + { + kind: 'tool', + id: 'file-2', + summary: 'edit file again', + status: 'success', + toolKind: 'file_change', + filePath: 'src/base-shell.css' + }, + { + kind: 'tool', + id: 'commit-1', + summary: 'run command', + status: 'success', + toolKind: 'command_execution', + meta: { command: 'git add src/base-shell.css && git commit -m "fix rail"' } + } + ] + })).toEqual({ fileLabels: ['base-shell.css'], hasCommit: true }) + }) + + it('ignores failed file changes and non-commit git commands', () => { + expect(timelineJumpPreviewMetadata({ + blocks: [ + { + kind: 'tool', + id: 'file-failed', + summary: 'edit file', + status: 'error', + toolKind: 'file_change', + filePath: '/workspace/failed.ts' + }, + { + kind: 'tool', + id: 'status-1', + summary: 'run command', + status: 'success', + toolKind: 'command_execution', + meta: { command: 'git status --short' } + } + ] + })).toEqual({ fileLabels: [], hasCommit: false }) + }) +}) + +describe('timelineJumpPreviewTop', () => { + it('aligns the preview center with the hovered rail marker', () => { + expect(timelineJumpPreviewTop(210, 20, 180)).toBe(40) }) }) @@ -55,7 +124,7 @@ describe('timelineJumpRailLeft', () => { describe('timelineJumpRailPreviewLeft', () => { it('keeps the hover preview inside the conversation gutter', () => { - expect(timelineJumpRailPreviewLeft(-20, 520)).toBe(16) + expect(timelineJumpRailPreviewLeft(-20, 520)).toBe(48) }) it('keeps the hover preview inside the conversation right edge', () => { diff --git a/src/renderer/src/styles/base-shell.css b/src/renderer/src/styles/base-shell.css index 31106c6c6..a12ee1cb3 100644 --- a/src/renderer/src/styles/base-shell.css +++ b/src/renderer/src/styles/base-shell.css @@ -2800,6 +2800,13 @@ pre { isolation: isolate; } +/* Electron Webviews own a separate guest surface and can swallow pointer moves + after a sidebar divider enters their bounds. Disable guest hit testing only + for the lifetime of the Host resize gesture. */ +body.ds-workbench-resizing webview { + pointer-events: none; +} + .ds-workbench-divider::before { content: ''; position: absolute; @@ -2900,17 +2907,17 @@ pre { z-index: 15; display: flex; box-sizing: border-box; - width: 1.85rem; - min-width: 1.85rem; - max-height: min(54vh, 25rem); + width: 3.85rem; + min-width: 3.85rem; + max-height: min(64vh, 25rem); flex-direction: column; align-items: flex-start; - gap: 0.22rem; + gap: 0; overflow-y: auto; border: 0; - border-radius: 999px; + border-radius: 0; background: transparent; - padding: 0.28rem 0.18rem; + padding: 0.2rem 0.18rem; scrollbar-width: none; pointer-events: auto; transform: translateY(-50%); @@ -2921,56 +2928,71 @@ pre { } .timeline-jump-rail-button { + --timeline-wave-width: 0.75rem; display: block; + position: relative; box-sizing: border-box; - width: calc(0.22rem + var(--timeline-wave-width, 0.55rem)); - min-width: 0; - height: 0.18rem; - min-height: 0.18rem; + width: 100%; + min-width: 100%; + height: 1.25rem; + min-height: 1.25rem; margin-left: 0; padding: 0; border: 0; - border-radius: 999px; - background: color-mix(in srgb, var(--ds-text-muted) 52%, transparent); + background: transparent; + cursor: pointer; +} + +.timeline-jump-rail-button::before { + content: ''; + position: absolute; + top: 50%; + left: 0; + width: var(--timeline-wave-width); + height: 0.25rem; + transform: translateY(-50%); + border-radius: 1px; + background: color-mix(in srgb, var(--ds-text-muted) 44%, transparent); transition: background-color 140ms ease, box-shadow 140ms ease, opacity 140ms ease, - transform 140ms ease, width 140ms ease; } -.timeline-jump-rail-button[data-wave-level='1'] { - --timeline-wave-width: 0.32rem; +.timeline-jump-rail-button[data-wave-distance='0'] { + --timeline-wave-width: 3.2rem; } -.timeline-jump-rail-button[data-wave-level='2'] { - --timeline-wave-width: 0.5rem; +.timeline-jump-rail-button[data-wave-distance='1'] { + --timeline-wave-width: 2.45rem; } -.timeline-jump-rail-button[data-wave-level='3'] { - --timeline-wave-width: 0.72rem; +.timeline-jump-rail-button[data-wave-distance='2'] { + --timeline-wave-width: 1.45rem; } -.timeline-jump-rail-button[data-wave-level='4'] { - --timeline-wave-width: 0.95rem; +.timeline-jump-rail-button[data-wave-distance='3'] { + --timeline-wave-width: 0.75rem; } -.timeline-jump-rail-button[data-wave-level='5'] { - --timeline-wave-width: 1.18rem; +.timeline-jump-rail-button.is-active::before { + background: color-mix(in srgb, var(--ds-text) 68%, transparent); } -.timeline-jump-rail-button:hover, -.timeline-jump-rail-button:focus-visible, -.timeline-jump-rail-button.is-active { - width: 1.28rem; - background: var(--ds-accent); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--ds-accent) 12%, transparent); +.timeline-jump-rail-button[data-wave-distance='0']::before { + background: color-mix(in srgb, var(--ds-text) 84%, transparent); opacity: 1; - transform: none; +} + +.timeline-jump-rail-button:focus-visible { outline: none; } +.timeline-jump-rail-button:focus-visible::before { + box-shadow: 0 0 0 3px color-mix(in srgb, var(--ds-text) 10%, transparent); +} + .timeline-jump-rail-preview { position: absolute; top: 0; @@ -3007,7 +3029,7 @@ pre { margin-top: 0.5rem; overflow: hidden; -webkit-box-orient: vertical; - -webkit-line-clamp: 4; + -webkit-line-clamp: 3; color: var(--ds-text-muted); font-size: 0.86rem; font-weight: 600; @@ -3015,6 +3037,43 @@ pre { overflow-wrap: anywhere; } +.timeline-jump-rail-preview-meta { + display: flex; + min-width: 0; + align-items: center; + gap: 0.9rem; + margin-top: 0.7rem; + overflow: hidden; + color: var(--ds-text-muted); + font-size: 0.84rem; + font-weight: 600; + line-height: 1.25; + white-space: nowrap; +} + +.timeline-jump-rail-preview-meta-item { + display: inline-flex; + min-width: 0; + align-items: center; + gap: 0.35rem; +} + +.timeline-jump-rail-preview-meta-item > svg { + width: 1.05rem; + height: 1.05rem; + flex: 0 0 auto; + stroke-width: 1.8; +} + +.timeline-jump-rail-preview-file-label { + overflow: hidden; + text-overflow: ellipsis; +} + +.timeline-jump-rail-preview-meta-count { + flex: 0 0 auto; +} + .ds-plan-panel-overlay { position: fixed; inset: 0; From f46edbbc8d79f8871046a22fb3dfa5a88b6782ed Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Wed, 15 Jul 2026 03:11:41 +0800 Subject: [PATCH 058/110] fix(write): align inline agent under UI zoom --- .../src/components/write/WriteInlineAgent.tsx | 2 + .../write/write-workspace-view-utils.test.ts | 51 +++++++++++++++++++ .../write/write-workspace-view-utils.ts | 43 ++++++++++++---- 3 files changed, 85 insertions(+), 11 deletions(-) diff --git a/src/renderer/src/components/write/WriteInlineAgent.tsx b/src/renderer/src/components/write/WriteInlineAgent.tsx index b7db47b42..baf45ff46 100644 --- a/src/renderer/src/components/write/WriteInlineAgent.tsx +++ b/src/renderer/src/components/write/WriteInlineAgent.tsx @@ -241,6 +241,7 @@ export function WriteInlineAgent({ width: action.width, anchorLeft: action.anchorLeft, anchorRight: action.anchorRight, + coordinateScale: action.coordinateScale, anchorTop: action.anchorTop, anchorBottom: action.anchorBottom }, { @@ -254,6 +255,7 @@ export function WriteInlineAgent({ action.anchorBottom, action.anchorLeft, action.anchorRight, + action.coordinateScale, action.left, action.width, value, diff --git a/src/renderer/src/components/write/write-workspace-view-utils.test.ts b/src/renderer/src/components/write/write-workspace-view-utils.test.ts index be9a01c61..6029c0145 100644 --- a/src/renderer/src/components/write/write-workspace-view-utils.test.ts +++ b/src/renderer/src/components/write/write-workspace-view-utils.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { computeWriteDocumentStats, + inlineAgentPosition, inlineAgentPlacement, isInlineCompletionToggleShortcut, type WriteInlineAgentPosition @@ -65,11 +66,61 @@ const action: WriteInlineAgentPosition = { width: 300, anchorLeft: 400, anchorRight: 600, + coordinateScale: 1, anchorTop: 300, anchorBottom: 340 } +describe('inlineAgentPosition', () => { + it.each([ + { coordinateScale: 0.82, viewportWidth: 820 }, + { coordinateScale: 1, viewportWidth: 1000 }, + { coordinateScale: 1.25, viewportWidth: 1250 } + ])('normalizes selection coordinates at $coordinateScale UI scale', ({ coordinateScale, viewportWidth }) => { + const position = inlineAgentPosition({ + anchorRect: { + left: 400 * coordinateScale, + right: 600 * coordinateScale, + top: 300 * coordinateScale, + bottom: 340 * coordinateScale, + width: 200 * coordinateScale + } + }, { + compact: true, + coordinateScale, + viewportWidth + }) + + expect(position).toMatchObject({ + left: 380, + width: 240, + coordinateScale + }) + expect(position?.anchorLeft).toBeCloseTo(400) + expect(position?.anchorRight).toBeCloseTo(600) + expect(position?.anchorTop).toBeCloseTo(300) + expect(position?.anchorBottom).toBeCloseTo(340) + }) +}) + describe('inlineAgentPlacement', () => { + it.each([ + { coordinateScale: 0.82, viewportWidth: 820, viewportHeight: 656 }, + { coordinateScale: 1, viewportWidth: 1000, viewportHeight: 800 }, + { coordinateScale: 1.25, viewportWidth: 1250, viewportHeight: 1000 } + ])('keeps placement stable at $coordinateScale UI scale', ({ coordinateScale, viewportWidth, viewportHeight }) => { + expect(inlineAgentPlacement({ ...action, coordinateScale }, { + menuHeight: 200, + viewportWidth, + viewportHeight + })).toMatchObject({ + left: 320, + top: 348, + maxHeight: 200, + origin: 'top-center' + }) + }) + it('places the menu below a selection when it fits', () => { expect(inlineAgentPlacement(action, { menuHeight: 200, diff --git a/src/renderer/src/components/write/write-workspace-view-utils.ts b/src/renderer/src/components/write/write-workspace-view-utils.ts index 828dbc6c4..366d5a2cf 100644 --- a/src/renderer/src/components/write/write-workspace-view-utils.ts +++ b/src/renderer/src/components/write/write-workspace-view-utils.ts @@ -48,9 +48,11 @@ export type WriteInlineAgentPosition = { width: number anchorLeft: number anchorRight: number - /** Top of the selection rect in viewport coords; the menu measures itself and places above/below. */ + /** Body zoom used to convert viewport coordinates into fixed-position layout coordinates. */ + coordinateScale: number + /** Top of the selection rect in fixed-position layout coords; the menu measures itself and places above/below. */ anchorTop: number - /** Bottom of the selection rect in viewport coords. */ + /** Bottom of the selection rect in fixed-position layout coords. */ anchorBottom: number } @@ -181,21 +183,30 @@ export function useDebouncedValue(value: T, delayMs: number): T { export function inlineAgentPosition(selection: { anchorRect?: { left: number; right?: number; top: number; bottom: number; width: number } | null -}, options: { compact?: boolean } = {}): WriteInlineAgentPosition | null { +}, options: { + compact?: boolean + coordinateScale?: number + viewportWidth?: number +} = {}): WriteInlineAgentPosition | null { const rect = selection.anchorRect if (!rect) return null + const coordinateScale = validCoordinateScale(options.coordinateScale ?? currentBodyZoom()) + const viewportWidth = (options.viewportWidth ?? window.innerWidth) / coordinateScale + const anchorLeft = rect.left / coordinateScale + const anchorWidth = rect.width / coordinateScale const minWidth = options.compact ? 240 : INLINE_AGENT_MIN_WIDTH const maxWidth = options.compact ? 320 : INLINE_AGENT_MAX_WIDTH const targetRatio = options.compact ? 0.22 : 0.28 - const width = clamp(Math.round(window.innerWidth * targetRatio), minWidth, maxWidth) - const left = clamp(rect.left + rect.width / 2 - width / 2, 16, window.innerWidth - width - 16) + const width = clamp(Math.round(viewportWidth * targetRatio), minWidth, maxWidth) + const left = clamp(anchorLeft + anchorWidth / 2 - width / 2, 16, viewportWidth - width - 16) return { left, width, - anchorLeft: rect.left, - anchorRight: Number.isFinite(rect.right) ? Number(rect.right) : rect.left + rect.width, - anchorTop: rect.top, - anchorBottom: rect.bottom + anchorLeft, + anchorRight: (Number.isFinite(rect.right) ? Number(rect.right) : rect.left + rect.width) / coordinateScale, + coordinateScale, + anchorTop: rect.top / coordinateScale, + anchorBottom: rect.bottom / coordinateScale } } @@ -208,8 +219,9 @@ export function inlineAgentPlacement( preferAbove?: boolean } ): WriteInlineAgentPlacement { - const viewportWidth = Math.max(0, options.viewportWidth) - const viewportHeight = Math.max(0, options.viewportHeight) + const coordinateScale = validCoordinateScale(action.coordinateScale) + const viewportWidth = Math.max(0, options.viewportWidth / coordinateScale) + const viewportHeight = Math.max(0, options.viewportHeight / coordinateScale) const maxViewportHeight = Math.max(0, viewportHeight - INLINE_AGENT_VIEWPORT_MARGIN * 2) const naturalMenuHeight = Math.max(0, options.menuHeight) const menuHeight = Math.min(naturalMenuHeight, maxViewportHeight) @@ -290,6 +302,15 @@ export function inlineAgentPlacement( } } +function currentBodyZoom(): number { + if (typeof window === 'undefined' || typeof document === 'undefined') return 1 + return validCoordinateScale(Number.parseFloat(window.getComputedStyle(document.body).zoom)) +} + +function validCoordinateScale(value: number): number { + return Number.isFinite(value) && value > 0 ? value : 1 +} + export function modeButtonClass(active: boolean): string { return `inline-flex h-8 items-center justify-center rounded-lg px-2.5 text-[13px] transition ${ active From 012c47499fced04192c27da760465b98d63df0b8 Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Wed, 15 Jul 2026 03:18:36 +0800 Subject: [PATCH 059/110] fix(chat): tighten timeline rail spacing --- src/renderer/src/styles/base-shell.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/styles/base-shell.css b/src/renderer/src/styles/base-shell.css index a12ee1cb3..2317b1eb4 100644 --- a/src/renderer/src/styles/base-shell.css +++ b/src/renderer/src/styles/base-shell.css @@ -2934,8 +2934,8 @@ body.ds-workbench-resizing webview { box-sizing: border-box; width: 100%; min-width: 100%; - height: 1.25rem; - min-height: 1.25rem; + height: 1rem; + min-height: 1rem; margin-left: 0; padding: 0; border: 0; From 2e013754112d7e586bbd15430ff69aa8b047bd13 Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Wed, 15 Jul 2026 03:19:15 +0800 Subject: [PATCH 060/110] fix(extensions): bundle presentation studio by default --- .../extensions/presentation-studio/README.md | 5 + .../changes/add-agent-ppt-extension/design.md | 2 +- .../add-agent-ppt-extension/proposal.md | 1 + .../agent-html-presentation-extension/spec.md | 11 + .../changes/add-agent-ppt-extension/tasks.md | 1 + package.json | 4 +- scripts/after-pack.cjs | 5 +- scripts/check-extension-release-gate.mjs | 30 ++- scripts/pack-bundled-extensions.mjs | 246 ++++++++++++++++++ scripts/pack-bundled-extensions.test.mjs | 81 ++++++ scripts/smoke-packaged-extensions.cjs | 75 +++--- src/main/extension-packaging-release.test.ts | 36 ++- 12 files changed, 445 insertions(+), 52 deletions(-) create mode 100644 scripts/pack-bundled-extensions.mjs create mode 100644 scripts/pack-bundled-extensions.test.mjs diff --git a/examples/extensions/presentation-studio/README.md b/examples/extensions/presentation-studio/README.md index ae7ba0be1..86e2712e2 100644 --- a/examples/extensions/presentation-studio/README.md +++ b/examples/extensions/presentation-studio/README.md @@ -84,6 +84,11 @@ node examples/extensions/validate-manifest.mjs \ `npm run check:extension-examples` additionally validates and packs every example with the repository's Kun CLI. +`npm run dev` and production builds also package Presentation Studio into the +product-owned bundled extension catalog. On startup, Kun seeds it through the +normal extension registry beside Kun Video Editor. A user who explicitly +uninstalls it remains in control; later launches do not silently reinstall it. + ## Clean-room reference note The interaction vocabulary was informed by the separately inspected diff --git a/openspec/changes/add-agent-ppt-extension/design.md b/openspec/changes/add-agent-ppt-extension/design.md index a0d368450..429cdc173 100644 --- a/openspec/changes/add-agent-ppt-extension/design.md +++ b/openspec/changes/add-agent-ppt-extension/design.md @@ -73,7 +73,7 @@ The primary card action calls the existing `editor:open-path` bridge with `edito ## Migration Plan -No persisted Kun data migration is required. Install the example as a development extension, create a new `.kun-ppt.html` deck, and edit it through the contributed full-page View or tools. Future schema versions must add explicit model migration before accepting older files. Removing the extension leaves standalone presentation files intact. +No persisted Kun data migration is required. Development and production builds package the example into the product-owned bundled extension catalog, and the existing normal registry seeder installs it for clean profiles and profiles that have not explicitly removed it. Create a new `.kun-ppt.html` deck and edit it through the contributed full-page View or tools. Future schema versions must add explicit model migration before accepting older files. Explicitly uninstalling the extension remains durable, and removing the extension leaves standalone presentation files intact. ## Open Questions diff --git a/openspec/changes/add-agent-ppt-extension/proposal.md b/openspec/changes/add-agent-ppt-extension/proposal.md index 58d44fc93..a4f103a1e 100644 --- a/openspec/changes/add-agent-ppt-extension/proposal.md +++ b/openspec/changes/add-agent-ppt-extension/proposal.md @@ -12,6 +12,7 @@ Kun can generate native PPTX files through the managed PPT Master workflow, but - Surface completed Agent-generated presentation artifacts below the final reply and open them through the operating system's default application association, with a safe file-manager fallback. - Render only the structured presentation model inside the Webview. Agent-authored arbitrary HTML or scripts never execute in the bridge-bearing extension page. - Document the extension and add it to the repository extension-example validation gate. +- Package Presentation Studio in the product-owned bundled extension catalog so clean and existing profiles receive it through the normal default-extension seeder. ## Capabilities diff --git a/openspec/changes/add-agent-ppt-extension/specs/agent-html-presentation-extension/spec.md b/openspec/changes/add-agent-ppt-extension/specs/agent-html-presentation-extension/spec.md index e553e66ec..044aad3fa 100644 --- a/openspec/changes/add-agent-ppt-extension/specs/agent-html-presentation-extension/spec.md +++ b/openspec/changes/add-agent-ppt-extension/specs/agent-html-presentation-extension/spec.md @@ -100,3 +100,14 @@ The implementation SHALL use only public Extension API v1 surfaces, minimum Mani #### Scenario: Validate and pack the extension - **WHEN** repository extension checks build, validate, and pack all examples - **THEN** Presentation Studio passes without unresolved browser imports, undeclared resources, private Kun imports, or tool declaration drift + +### Requirement: Presentation Studio is bundled as a default extension +Development and production builds SHALL include Presentation Studio in the product-owned bundled extension catalog beside Kun Video Editor, and Kun SHALL seed it through the normal extension registry without overriding an explicit user uninstall. + +#### Scenario: Start with a clean profile +- **WHEN** Kun starts with a clean profile and the generated bundled extension catalog +- **THEN** both Presentation Studio and Kun Video Editor are installed and globally enabled through the normal registry + +#### Scenario: Start after explicitly uninstalling Presentation Studio +- **WHEN** a user uninstalls the seeded Presentation Studio extension and restarts Kun +- **THEN** the bundled-extension seeder preserves that removal instead of resurrecting the extension diff --git a/openspec/changes/add-agent-ppt-extension/tasks.md b/openspec/changes/add-agent-ppt-extension/tasks.md index 4cd684c62..9faf38025 100644 --- a/openspec/changes/add-agent-ppt-extension/tasks.md +++ b/openspec/changes/add-agent-ppt-extension/tasks.md @@ -20,6 +20,7 @@ - [x] 4.1 Add the Manifest, package scripts, TypeScript/Vite configuration, README, license, and clean-room reference notes. - [x] 4.2 Add Presentation Studio to the extension examples index and validation enumeration. +- [x] 4.3 Add Presentation Studio to the product-owned bundled extension catalog, packaged-resource validation, and default-seeding smoke coverage. ## 5. Verification diff --git a/package.json b/package.json index 1585b7ab8..dff4ca4b7 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "check:extension-docs": "node --test ./scripts/check-extension-docs.test.mjs && node ./scripts/check-extension-docs.mjs", "generate:extension-api-reference": "node ./scripts/generate-extension-api-reference.mjs", "check:extension-examples": "node ./scripts/check-extension-examples.mjs", - "check:extension-release-gate": "npm run build:extensions && npm run build:kun && node --test ./scripts/after-pack.test.cjs ./scripts/check-extension-release-execution.test.mjs ./scripts/pack-kun-video-editor.test.mjs ./scripts/smoke-packaged-extension-desktop.test.cjs ./scripts/smoke-packaged-extension-appimage.test.cjs ./scripts/smoke-packaged-video-editor-native.test.cjs ./scripts/smoke-packaged-video-editor-desktop.test.cjs ./scripts/verify-extension-native-evidence.test.mjs ./scripts/verify-manual-extension-release.test.mjs ./scripts/write-extension-native-evidence.test.mjs && node ./scripts/check-extension-release-gate.mjs", + "check:extension-release-gate": "npm run build:extensions && npm run build:kun && node --test ./scripts/after-pack.test.cjs ./scripts/check-extension-release-execution.test.mjs ./scripts/pack-bundled-extensions.test.mjs ./scripts/pack-kun-video-editor.test.mjs ./scripts/smoke-packaged-extension-desktop.test.cjs ./scripts/smoke-packaged-extension-appimage.test.cjs ./scripts/smoke-packaged-video-editor-native.test.cjs ./scripts/smoke-packaged-video-editor-desktop.test.cjs ./scripts/verify-extension-native-evidence.test.mjs ./scripts/verify-manual-extension-release.test.mjs ./scripts/write-extension-native-evidence.test.mjs && node ./scripts/check-extension-release-gate.mjs", "smoke:packaged-extensions": "node ./scripts/smoke-packaged-extensions.cjs", "smoke:packaged-extension-desktop": "node ./scripts/smoke-packaged-extension-desktop.cjs", "smoke:packaged-extension-appimage": "node ./scripts/smoke-packaged-extension-appimage.cjs", @@ -28,7 +28,7 @@ "evidence:extension-native": "node ./scripts/write-extension-native-evidence.mjs", "verify:extension-native-evidence": "node ./scripts/verify-extension-native-evidence.mjs", "verify:manual-extension-release": "node ./scripts/verify-manual-extension-release.mjs", - "build:bundled-extensions": "node ./scripts/pack-kun-video-editor.mjs --output ./resources/bundled-extensions --catalog", + "build:bundled-extensions": "node ./scripts/pack-bundled-extensions.mjs --output ./resources/bundled-extensions", "pack:kun-video-editor": "npm run build:kun && npm run build:bundled-extensions && node ./scripts/pack-kun-video-editor.mjs --require-bundled-identity", "verify:kun-video-editor-package": "npm run build:kun && npm run build:bundled-extensions && node ./scripts/pack-kun-video-editor.mjs --verify --require-bundled-identity", "check:extensions": "npm run check:extension-schema && npm run check:extension-docs && npm run check:extension-examples && npm run check:extension-release-gate", diff --git a/scripts/after-pack.cjs b/scripts/after-pack.cjs index 0c7c56122..8883b1eed 100644 --- a/scripts/after-pack.cjs +++ b/scripts/after-pack.cjs @@ -53,7 +53,10 @@ const LINUX_SANDBOX_LAUNCHER_FLAG = '--disable-setuid-sandbox' const LINUX_REAL_EXECUTABLE_SUFFIX = '.electron-bin' const BUNDLED_EXTENSIONS_DIR = 'bundled-extensions' const BUNDLED_EXTENSION_CATALOG_FILE = 'catalog.json' -const REQUIRED_BUNDLED_EXTENSION_IDS = ['kun-examples.kun-video-editor'] +const REQUIRED_BUNDLED_EXTENSION_IDS = [ + 'kun-examples.kun-video-editor', + 'kun-examples.presentation-studio' +] function normalizePlatform(platform) { return platform === 'win' ? 'win32' : platform diff --git a/scripts/check-extension-release-gate.mjs b/scripts/check-extension-release-gate.mjs index 031d712fc..8d44473b3 100644 --- a/scripts/check-extension-release-gate.mjs +++ b/scripts/check-extension-release-gate.mjs @@ -683,6 +683,15 @@ check( ), 'afterPack does not validate bundled .kunx catalog bytes before release artifacts are created' ) +for (const id of [ + 'kun-examples.kun-video-editor', + 'kun-examples.presentation-studio' +]) { + check( + afterPack.REQUIRED_BUNDLED_EXTENSION_IDS.includes(id), + `afterPack does not require bundled default extension: ${id}` + ) +} for (const pattern of [ 'packages/extension-api/package.json', 'packages/extension-api/dist/**/*', @@ -1004,24 +1013,33 @@ const manualReleaseVerifierSource = await text('scripts/verify-manual-extension- const nativeMediaSmokeSource = await text('scripts/run-extension-native-media-smoke.cjs') const packagedVideoNativeSource = await text('scripts/smoke-packaged-video-editor-native.cjs') const videoEditorPackSource = await text('scripts/pack-kun-video-editor.mjs') +const bundledExtensionsPackSource = await text('scripts/pack-bundled-extensions.mjs') check( rootPackage.scripts?.['build:bundled-extensions'] === - 'node ./scripts/pack-kun-video-editor.mjs --output ./resources/bundled-extensions --catalog' && + 'node ./scripts/pack-bundled-extensions.mjs --output ./resources/bundled-extensions' && rootPackage.scripts?.build?.includes('npm run build:bundled-extensions') && rootPackage.scripts?.dev?.includes('npm run build:bundled-extensions'), 'Kun build and dev must generate the canonical default extension catalog before launch' ) for (const marker of [ - 'videoEditorBundledCatalog', + 'BUNDLED_EXTENSION_DEFINITIONS', 'BUNDLED_EXTENSION_CATALOG_FILE', - 'catalog: process.argv.includes', - 'removeStaleVideoEditorArchives' + 'kun-examples.kun-video-editor', + 'kun-examples.presentation-studio', + 'bundledExtensionCatalog', + 'removeStaleBundledArchives' ]) { check( - videoEditorPackSource.includes(marker), - `Kun Video Editor packer omits bundled default invariant: ${marker}` + bundledExtensionsPackSource.includes(marker), + `Bundled Extension packer omits default invariant: ${marker}` ) } +check( + rootPackage.scripts?.['check:extension-release-gate']?.includes( + './scripts/pack-bundled-extensions.test.mjs' + ), + 'Extension release gate must execute bundled extension catalog tests' +) check( rootPackage.scripts?.['smoke:extension-native-media'] === 'node ./scripts/run-extension-native-media-smoke.cjs', diff --git a/scripts/pack-bundled-extensions.mjs b/scripts/pack-bundled-extensions.mjs new file mode 100644 index 000000000..e8904ddf2 --- /dev/null +++ b/scripts/pack-bundled-extensions.mjs @@ -0,0 +1,246 @@ +#!/usr/bin/env node + +import { createHash } from 'node:crypto' +import { createReadStream } from 'node:fs' +import { + lstat, + mkdir, + mkdtemp, + readFile, + readdir, + rename, + rm, + stat, + writeFile +} from 'node:fs/promises' +import { spawnSync } from 'node:child_process' +import { basename, dirname, join, relative, resolve, sep } from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const cliPath = join(root, 'kun', 'dist', 'cli', 'serve-entry.js') +const defaultOutput = join(root, 'resources', 'bundled-extensions') + +export const BUNDLED_EXTENSION_CATALOG_FILE = 'catalog.json' +export const BUNDLED_EXTENSION_DEFINITIONS = Object.freeze([ + Object.freeze({ + id: 'kun-examples.kun-video-editor', + name: 'kun-video-editor', + root: join(root, 'examples', 'extensions', 'kun-video-editor') + }), + Object.freeze({ + id: 'kun-examples.presentation-studio', + name: 'presentation-studio', + root: join(root, 'examples', 'extensions', 'presentation-studio') + }) +]) + +export function bundledArchiveName(manifest, expectedName) { + if (manifest?.name !== expectedName) { + throw new Error(`Expected the ${expectedName} manifest, got: ${String(manifest?.name)}`) + } + if ( + typeof manifest.version !== 'string' || + !/^[0-9A-Za-z][0-9A-Za-z._-]*$/u.test(manifest.version) + ) { + throw new Error(`Invalid ${expectedName} version: ${String(manifest?.version)}`) + } + return `${manifest.name}-${manifest.version}.kunx` +} + +export function bundledCatalogEntry(definition, manifest, archive, sha256) { + const id = `${String(manifest?.publisher ?? '')}.${String(manifest?.name ?? '')}` + if (id !== definition.id || manifest?.name !== definition.name) { + throw new Error(`Unexpected bundled extension id: ${id}`) + } + if ( + !Array.isArray(manifest.permissions) || + manifest.permissions.some((permission) => + typeof permission !== 'string' || permission.length === 0 + ) + ) { + throw new Error(`Bundled extension permissions are invalid: ${id}`) + } + if (typeof manifest.engines?.kun !== 'string' || typeof manifest.apiVersion !== 'string') { + throw new Error(`Bundled extension compatibility metadata is invalid: ${id}`) + } + if (basename(archive) !== archive || !/^[0-9A-Za-z][0-9A-Za-z._-]*\.kunx$/u.test(archive)) { + throw new Error(`Bundled extension archive name is invalid: ${archive}`) + } + if (!/^[a-f0-9]{64}$/u.test(sha256)) { + throw new Error(`Bundled extension archive digest is invalid: ${id}`) + } + return { + id, + version: manifest.version, + archive, + sha256, + enginesKun: manifest.engines.kun, + apiVersion: manifest.apiVersion, + permissions: [...new Set(manifest.permissions)].sort(), + ...(manifest.signature === undefined ? {} : { signature: manifest.signature }) + } +} + +export function bundledExtensionCatalog(entries) { + const sorted = [...entries].sort((left, right) => left.id.localeCompare(right.id)) + if (new Set(sorted.map((entry) => entry.id)).size !== sorted.length) { + throw new Error('Bundled extension catalog contains duplicate extension ids') + } + return { schemaVersion: 1, extensions: sorted } +} + +export async function packBundledExtensions({ output = defaultOutput } = {}) { + const directory = resolve(output) + await mkdir(directory, { recursive: true, mode: 0o700 }) + const packed = [] + for (const definition of BUNDLED_EXTENSION_DEFINITIONS) { + packed.push(await packBundledExtension(definition, directory)) + } + await removeStaleBundledArchives(directory, new Set(packed.map((entry) => entry.archive))) + const catalog = bundledExtensionCatalog(packed.map((entry) => entry.catalogEntry)) + const catalogPath = await writeBundledCatalog(directory, catalog) + return { directory, catalog: catalogPath, extensions: packed } +} + +async function packBundledExtension(definition, directory) { + const manifestPath = join(definition.root, 'kun-extension.json') + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) + const archiveName = bundledArchiveName(manifest, definition.name) + const archive = join(directory, archiveName) + const temporary = await mkdtemp(join(directory, `.${definition.name}-pack-`)) + const first = join(temporary, 'first.kunx') + const second = join(temporary, 'second.kunx') + try { + const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm' + runRequired(npm, ['--prefix', definition.root, 'run', 'build']) + runRequired(process.execPath, [ + cliPath, + 'extension', + 'validate', + definition.root, + '--json' + ]) + for (const target of [first, second]) { + runRequired(process.execPath, [ + cliPath, + 'extension', + 'pack', + definition.root, + '--output', + target, + '--overwrite', + '--json' + ]) + } + const identity = await assertDeterministicArchives(definition.id, first, second) + await rm(archive, { force: true }) + await rename(first, archive) + const details = await lstat(archive) + if (!details.isFile() || details.isSymbolicLink() || details.size <= 0) { + throw new Error(`Bundled extension archive is not a regular file: ${archive}`) + } + runRequired(process.execPath, [cliPath, 'extension', 'validate', archive, '--json']) + return { + id: definition.id, + archive: archiveName, + path: archive, + ...identity, + catalogEntry: bundledCatalogEntry(definition, manifest, archiveName, identity.sha256) + } + } finally { + await rm(temporary, { recursive: true, force: true }) + } +} + +async function assertDeterministicArchives(id, first, second) { + const [firstDetails, secondDetails, firstHash, secondHash] = await Promise.all([ + stat(first), + stat(second), + sha256File(first), + sha256File(second) + ]) + if ( + !firstDetails.isFile() || + !secondDetails.isFile() || + firstDetails.size <= 0 || + secondDetails.size <= 0 + ) { + throw new Error(`Bundled extension pack produced an empty archive: ${id}`) + } + if (firstDetails.size !== secondDetails.size || firstHash !== secondHash) { + throw new Error(`Bundled extension pack is not deterministic: ${id}`) + } + return { bytes: firstDetails.size, sha256: firstHash } +} + +async function sha256File(path) { + const hash = createHash('sha256') + for await (const chunk of createReadStream(path)) hash.update(chunk) + return hash.digest('hex') +} + +async function removeStaleBundledArchives(directory, expected) { + const names = BUNDLED_EXTENSION_DEFINITIONS.map((entry) => entry.name) + for (const entry of await readdir(directory, { withFileTypes: true })) { + if (expected.has(entry.name)) continue + if (!names.some((name) => entry.name.startsWith(`${name}-`) && entry.name.endsWith('.kunx'))) { + continue + } + await rm(join(directory, entry.name), { recursive: true, force: true }) + } +} + +async function writeBundledCatalog(directory, catalog) { + const path = join(directory, BUNDLED_EXTENSION_CATALOG_FILE) + const temporary = `${path}.${process.pid}.tmp` + try { + await writeFile(temporary, `${JSON.stringify(catalog, null, 2)}\n`, { + encoding: 'utf8', + mode: 0o600 + }) + await rename(temporary, path) + } finally { + await rm(temporary, { force: true }) + } + return path +} + +function runRequired(command, args) { + const result = spawnSync(command, args, { + cwd: root, + env: process.env, + stdio: 'inherit', + shell: false + }) + if (result.error) throw result.error + if (result.status !== 0) { + throw new Error(`${command} ${args.join(' ')} exited with ${String(result.status)}`) + } +} + +function argumentValue(name) { + const index = process.argv.indexOf(name) + if (index < 0) return undefined + const value = process.argv[index + 1] + if (!value || value.startsWith('--')) throw new Error(`${name} requires a value`) + return value +} + +async function main() { + const result = await packBundledExtensions({ output: argumentValue('--output') }) + const summary = result.extensions + .map((entry) => `${entry.id} ${entry.bytes} bytes sha256 ${entry.sha256}`) + .join('; ') + process.stdout.write( + `Bundled extensions deterministic pack OK: ${summary}; catalog ` + + `${relative(root, result.catalog).split(sep).join('/')}\n` + ) +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`) + process.exitCode = 1 + }) +} diff --git a/scripts/pack-bundled-extensions.test.mjs b/scripts/pack-bundled-extensions.test.mjs new file mode 100644 index 000000000..b2b57f7cc --- /dev/null +++ b/scripts/pack-bundled-extensions.test.mjs @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + BUNDLED_EXTENSION_DEFINITIONS, + bundledArchiveName, + bundledCatalogEntry, + bundledExtensionCatalog +} from './pack-bundled-extensions.mjs' + +const digest = 'a'.repeat(64) + +function manifest(name, overrides = {}) { + return { + publisher: 'kun-examples', + name, + version: '0.1.0', + apiVersion: '1.0.0', + engines: { kun: '>=0.1.0' }, + permissions: ['ui.views', 'workspace.read', 'ui.views'], + ...overrides + } +} + +test('declares both product-owned default extensions', () => { + assert.deepEqual( + BUNDLED_EXTENSION_DEFINITIONS.map((entry) => entry.id), + ['kun-examples.kun-video-editor', 'kun-examples.presentation-studio'] + ) +}) + +test('derives bounded catalog entries from canonical manifests', () => { + const definition = BUNDLED_EXTENSION_DEFINITIONS[1] + assert.equal( + bundledArchiveName(manifest('presentation-studio'), definition.name), + 'presentation-studio-0.1.0.kunx' + ) + assert.deepEqual( + bundledCatalogEntry( + definition, + manifest('presentation-studio'), + 'presentation-studio-0.1.0.kunx', + digest + ), + { + id: 'kun-examples.presentation-studio', + version: '0.1.0', + archive: 'presentation-studio-0.1.0.kunx', + sha256: digest, + enginesKun: '>=0.1.0', + apiVersion: '1.0.0', + permissions: ['ui.views', 'workspace.read'] + } + ) + assert.throws( + () => bundledCatalogEntry( + definition, + manifest('other'), + 'presentation-studio-0.1.0.kunx', + digest + ), + /Unexpected/ + ) +}) + +test('sorts catalog entries and rejects duplicate extension ids', () => { + const entries = BUNDLED_EXTENSION_DEFINITIONS.map((definition) => bundledCatalogEntry( + definition, + manifest(definition.name), + `${definition.name}-0.1.0.kunx`, + digest + )).reverse() + const catalog = bundledExtensionCatalog(entries) + assert.deepEqual( + catalog.extensions.map((entry) => entry.id), + ['kun-examples.kun-video-editor', 'kun-examples.presentation-studio'] + ) + assert.throws( + () => bundledExtensionCatalog([entries[0], entries[0]]), + /duplicate/ + ) +}) diff --git a/scripts/smoke-packaged-extensions.cjs b/scripts/smoke-packaged-extensions.cjs index bcd9beb8c..c8be2038f 100644 --- a/scripts/smoke-packaged-extensions.cjs +++ b/scripts/smoke-packaged-extensions.cjs @@ -35,7 +35,10 @@ const { pathToFileURL } = require('node:url') const { KUN_RUNTIME_REQUIRED_PATHS } = require('./after-pack.cjs') const EXTENSION_ID = 'kun-smoke.packaged' -const DEFAULT_EXTENSION_ID = 'kun-examples.kun-video-editor' +const DEFAULT_EXTENSION_IDS = [ + 'kun-examples.kun-video-editor', + 'kun-examples.presentation-studio' +] const RUNTIME_TOKEN = 'kun-packaged-extension-smoke-token' const PACKAGED_EXTENSION_SMOKE_SUCCESS_MARKER = 'Packaged Extension smoke OK (' @@ -129,17 +132,18 @@ async function main() { const listed = JSON.parse(runKun(runtimeEntry, [ 'extension', 'list', '--data-dir', profile, '--json' ])) - if ( - !Array.isArray(listed.extensions) || - listed.extensions.length !== 1 || - listed.extensions[0]?.id !== DEFAULT_EXTENSION_ID || - listed.extensions[0]?.globallyEnabled !== true - ) { - throw new Error('Packaged default extension was not seeded through the normal registry') + if (!Array.isArray(listed.extensions) || listed.extensions.length !== DEFAULT_EXTENSION_IDS.length) { + throw new Error('Packaged default extensions were not seeded through the normal registry') + } + for (const id of DEFAULT_EXTENSION_IDS) { + const installed = listed.extensions.find((extension) => extension?.id === id) + if (installed?.globallyEnabled !== true) { + throw new Error(`Packaged default extension was not enabled through the registry: ${id}`) + } + runKun(runtimeEntry, [ + 'extension', 'uninstall', id, '--data-dir', profile, '--json' + ]) } - runKun(runtimeEntry, [ - 'extension', 'uninstall', DEFAULT_EXTENSION_ID, '--data-dir', profile, '--json' - ]) server = await startKunServe(options) await server.close() server = undefined @@ -307,30 +311,33 @@ function validateBundledDefaultExtension(resourcesDir) { throw new Error('Packaged bundled extension catalog is not a regular file') } const catalog = JSON.parse(readFileSync(catalogPath, 'utf8')) - const matches = Array.isArray(catalog?.extensions) - ? catalog.extensions.filter((entry) => entry?.id === DEFAULT_EXTENSION_ID) - : [] - if (catalog?.schemaVersion !== 1 || matches.length !== 1) { - throw new Error('Packaged bundled extension catalog omits the default video editor') - } - const entry = matches[0] - if ( - typeof entry.archive !== 'string' || - !/^[0-9A-Za-z][0-9A-Za-z._-]*\.kunx$/u.test(entry.archive) || - typeof entry.sha256 !== 'string' || - !/^[a-f0-9]{64}$/u.test(entry.sha256) - ) { - throw new Error('Packaged bundled video editor catalog entry is invalid') + if (catalog?.schemaVersion !== 1 || !Array.isArray(catalog.extensions)) { + throw new Error('Packaged bundled extension catalog is invalid') } - const archivePath = join(root, entry.archive) - assertExists(archivePath, 'bundled video editor archive') - const archiveDetails = lstatSync(archivePath) - if (!archiveDetails.isFile() || archiveDetails.isSymbolicLink() || archiveDetails.size <= 0) { - throw new Error('Packaged bundled video editor archive is not a regular file') - } - const digest = createHash('sha256').update(readFileSync(archivePath)).digest('hex') - if (digest !== entry.sha256) { - throw new Error('Packaged bundled video editor archive digest does not match its catalog') + for (const id of DEFAULT_EXTENSION_IDS) { + const matches = catalog.extensions.filter((entry) => entry?.id === id) + if (matches.length !== 1) { + throw new Error(`Packaged bundled extension catalog omits a default extension: ${id}`) + } + const entry = matches[0] + if ( + typeof entry.archive !== 'string' || + !/^[0-9A-Za-z][0-9A-Za-z._-]*\.kunx$/u.test(entry.archive) || + typeof entry.sha256 !== 'string' || + !/^[a-f0-9]{64}$/u.test(entry.sha256) + ) { + throw new Error(`Packaged bundled extension catalog entry is invalid: ${id}`) + } + const archivePath = join(root, entry.archive) + assertExists(archivePath, `bundled extension archive ${id}`) + const archiveDetails = lstatSync(archivePath) + if (!archiveDetails.isFile() || archiveDetails.isSymbolicLink() || archiveDetails.size <= 0) { + throw new Error(`Packaged bundled extension archive is not a regular file: ${id}`) + } + const digest = createHash('sha256').update(readFileSync(archivePath)).digest('hex') + if (digest !== entry.sha256) { + throw new Error(`Packaged bundled extension archive digest does not match its catalog: ${id}`) + } } } diff --git a/src/main/extension-packaging-release.test.ts b/src/main/extension-packaging-release.test.ts index 893e4189d..23296663a 100644 --- a/src/main/extension-packaging-release.test.ts +++ b/src/main/extension-packaging-release.test.ts @@ -31,18 +31,35 @@ function packContext(root: string, platform: 'darwin' | 'win32' | 'linux') { function writeBundledExtensionResources(context: ReturnType): void { const root = join(afterPack._internals.packedResourcesDir(context), 'bundled-extensions') - const archive = Buffer.from('deterministic bundled extension archive') - const sha256 = createHash('sha256').update(archive).digest('hex') + const extensions = [ + { + id: 'kun-examples.kun-video-editor', + archive: 'kun-video-editor-0.1.0.kunx' + }, + { + id: 'kun-examples.presentation-studio', + archive: 'presentation-studio-0.1.0.kunx' + } + ].map((entry) => { + const bytes = Buffer.from(`deterministic bundled extension archive: ${entry.id}`) + return { + ...entry, + bytes, + sha256: createHash('sha256').update(bytes).digest('hex') + } + }) mkdirSync(root, { recursive: true }) - writeFileSync(join(root, 'kun-video-editor-0.1.0.kunx'), archive) + for (const extension of extensions) { + writeFileSync(join(root, extension.archive), extension.bytes) + } writeFileSync(join(root, 'catalog.json'), `${JSON.stringify({ schemaVersion: 1, - extensions: [{ - id: 'kun-examples.kun-video-editor', + extensions: extensions.map((extension) => ({ + id: extension.id, version: '0.1.0', - archive: 'kun-video-editor-0.1.0.kunx', - sha256 - }] + archive: extension.archive, + sha256: extension.sha256 + })) }, null, 2)}\n`) } @@ -83,6 +100,9 @@ describe('Extension Platform packaged release resources', () => { expect(afterPack.REQUIRED_BUNDLED_EXTENSION_IDS).toContain( 'kun-examples.kun-video-editor' ) + expect(afterPack.REQUIRED_BUNDLED_EXTENSION_IDS).toContain( + 'kun-examples.presentation-studio' + ) }) it.each(['darwin', 'win32', 'linux'] as const)( From 7dbdbc38d17eb0556dc9c79fba97f00db0fc8da9 Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Wed, 15 Jul 2026 03:20:38 +0800 Subject: [PATCH 061/110] fix(chat): compact timeline rail spacing --- src/renderer/src/styles/base-shell.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/styles/base-shell.css b/src/renderer/src/styles/base-shell.css index 2317b1eb4..4ebaff12b 100644 --- a/src/renderer/src/styles/base-shell.css +++ b/src/renderer/src/styles/base-shell.css @@ -2934,8 +2934,8 @@ body.ds-workbench-resizing webview { box-sizing: border-box; width: 100%; min-width: 100%; - height: 1rem; - min-height: 1rem; + height: 0.625rem; + min-height: 0.625rem; margin-left: 0; padding: 0; border: 0; From 3d7a4fbb8735b0cc4f22724995e9267c8f3216dc Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Wed, 15 Jul 2026 03:21:56 +0800 Subject: [PATCH 062/110] fix(write): stabilize PDF text selection --- .../src/components/write/WritePdfViewer.tsx | 49 +++++++++++++------ .../write/write-pdf-text-layer.test.ts | 29 ++++++++++- .../components/write/write-pdf-text-layer.ts | 20 ++++++++ src/renderer/src/styles/surfaces-write.css | 5 ++ src/shared/pdfjs-dist.d.ts | 14 ++++++ 5 files changed, 101 insertions(+), 16 deletions(-) diff --git a/src/renderer/src/components/write/WritePdfViewer.tsx b/src/renderer/src/components/write/WritePdfViewer.tsx index 275a0ef8e..69b5ce852 100644 --- a/src/renderer/src/components/write/WritePdfViewer.tsx +++ b/src/renderer/src/components/write/WritePdfViewer.tsx @@ -3,7 +3,6 @@ import { ChevronLeft, ChevronRight, Loader2, Minus, Plus, Search } from 'lucide- import { useTranslation } from 'react-i18next' import { GlobalWorkerOptions, - TextLayer, getDocument, type PDFDocumentProxy, type PDFPageProxy, @@ -16,7 +15,10 @@ import type { WriteSelectionPageRect } from './WriteMarkdownEditor' import { viewportRectToPageLocalRect } from './write-pdf-selection-geometry' -import { applyPdfTextLayerScale } from './write-pdf-text-layer' +import { + applyPdfTextLayerScale, + startPdfTextLayerRenderWithoutUiZoom +} from './write-pdf-text-layer' GlobalWorkerOptions.workerSrc = pdfWorkerUrl @@ -130,7 +132,11 @@ function collectRangeTextRects(range: Range): DOMRect[] { while (node && rects.length < MAX_SELECTION_FRAGMENT_RECTS) { if (range.comparePoint(node, 0) > 0) break const text = node as Text - if (text.data.trim() && range.intersectsNode(text)) { + if ( + text.data.trim() && + text.parentElement?.closest('.write-pdf-text-layer') && + range.intersectsNode(text) + ) { probe.selectNodeContents(text) if (text === range.startContainer) probe.setStart(text, range.startOffset) if (text === range.endContainer) probe.setEnd(text, range.endOffset) @@ -311,17 +317,18 @@ function WritePdfPage({ onPageText: (page: PageText) => void }): ReactElement { const canvasRef = useRef(null) - const textLayerRef = useRef(null) + const textLayerHostRef = useRef(null) const [pageSize, setPageSize] = useState<{ width: number; height: number } | null>(null) useEffect(() => { let cancelled = false let renderTask: { cancel: () => void; promise: Promise } | null = null + let textLayerBuilder: { cancel: () => void } | null = null const renderPage = async (): Promise => { const canvas = canvasRef.current - const textLayer = textLayerRef.current - if (!canvas || !textLayer) return + const textLayerHost = textLayerHostRef.current + if (!canvas || !textLayerHost) return const page: PDFPageProxy = await document.getPage(pageNumber) if (cancelled) return const viewport = page.getViewport({ scale }) @@ -340,15 +347,26 @@ function WritePdfPage({ await task.promise if (cancelled) return - textLayer.replaceChildren() - applyPdfTextLayerScale(textLayer.style, viewport) + textLayerHost.replaceChildren() const textContent = await page.getTextContent() - const textLayerRenderer = new TextLayer({ - textContentSource: textContent, - container: textLayer, - viewport + if (cancelled) return + // pdf_viewer.mjs reads the namespace that build/pdf.mjs installs on + // globalThis, so load the builder only after the core module is active. + const { TextLayerBuilder } = await import('pdfjs-dist/web/pdf_viewer.mjs') + if (cancelled) return + const builder = new TextLayerBuilder({ + pdfPage: page, + onAppend: (div) => { + if (!cancelled) textLayerHost.replaceChildren(div) + } }) - await textLayerRenderer.render() + textLayerBuilder = builder + builder.div.classList.add('write-pdf-text-layer') + applyPdfTextLayerScale(builder.div.style, viewport) + const textLayerRender = startPdfTextLayerRenderWithoutUiZoom( + () => builder.render({ viewport }) + ) + await textLayerRender if (!cancelled) { const text = textContent.items .map((item: TextContentItem) => (typeof item.str === 'string' ? item.str : '')) @@ -364,6 +382,7 @@ function WritePdfPage({ return () => { cancelled = true renderTask?.cancel() + textLayerBuilder?.cancel() } }, [document, onPageText, pageNumber, scale]) @@ -379,7 +398,7 @@ function WritePdfPage({ }} > -
    +