diff --git a/.changeset/file-preview-not-found.md b/.changeset/file-preview-not-found.md new file mode 100644 index 000000000..e04615410 --- /dev/null +++ b/.changeset/file-preview-not-found.md @@ -0,0 +1,5 @@ +--- +"kimi-code-app": patch +--- + +修复打开已被删除或移动的文件时提示「无法读取这个文件」的问题。 diff --git a/.changeset/turn-diff-toggle-close.md b/.changeset/turn-diff-toggle-close.md new file mode 100644 index 000000000..26f0c74d1 --- /dev/null +++ b/.changeset/turn-diff-toggle-close.md @@ -0,0 +1,5 @@ +--- +"kimi-code-app": patch +--- + +修复再次点击文件改动卡片里的文件时右侧改动面板无法收起的问题。 diff --git a/.changeset/turn-files-more-no-press-scale.md b/.changeset/turn-files-more-no-press-scale.md new file mode 100644 index 000000000..6af0ff2ff --- /dev/null +++ b/.changeset/turn-files-more-no-press-scale.md @@ -0,0 +1,5 @@ +--- +"kimi-code-app": patch +--- + +去掉文件改动卡片底部「还有 N 个文件」按钮按下时的压缩效果。 diff --git a/.changeset/turn-files-summary-interactive.md b/.changeset/turn-files-summary-interactive.md new file mode 100644 index 000000000..16df3f6c7 --- /dev/null +++ b/.changeset/turn-files-summary-interactive.md @@ -0,0 +1,5 @@ +--- +"kimi-code-app": patch +--- + +修复一轮对话末尾文件改动卡片里的文件无法点击打开的问题。 diff --git a/.changeset/turn-files-underline-lighter.md b/.changeset/turn-files-underline-lighter.md new file mode 100644 index 000000000..0583ba44e --- /dev/null +++ b/.changeset/turn-files-underline-lighter.md @@ -0,0 +1,5 @@ +--- +"kimi-code-app": patch +--- + +修复文件改动卡片里文件链接 hover 时下划线过深的问题。 diff --git a/apps/desktop/src/renderer/App.vue b/apps/desktop/src/renderer/App.vue index 02367c485..2f8db9e30 100644 --- a/apps/desktop/src/renderer/App.vue +++ b/apps/desktop/src/renderer/App.vue @@ -1534,6 +1534,7 @@ function openPr(url: string): void { @open-agent="openAgentPanel" @open-file="openFilePreview" @open-media="openMediaPreview" + @open-turn-diff="openTurnDiff($event)" /> (); const { t } = useI18n(); @@ -130,6 +132,7 @@ function phaseLabel(phase: AgentMember['phase']): string { @open-agent="emit('openAgent', $event)" @open-file="emit('openFile', $event)" @open-media="emit('openMedia', $event)" + @open-turn-diff="emit('openTurnDiff', $event)" /> diff --git a/apps/desktop/src/renderer/components/chat/ChatPane.vue b/apps/desktop/src/renderer/components/chat/ChatPane.vue index ac3f3d65e..07b15f8e0 100644 --- a/apps/desktop/src/renderer/components/chat/ChatPane.vue +++ b/apps/desktop/src/renderer/components/chat/ChatPane.vue @@ -139,6 +139,7 @@ const props = withDefaults( { approvals: () => [], questions: () => [], + turnFilesInteractive: true, turnActive: false, working: false, compaction: null, diff --git a/apps/desktop/src/renderer/components/chat/TurnFilesSummary.vue b/apps/desktop/src/renderer/components/chat/TurnFilesSummary.vue index 6bf0db82e..38a0af1f7 100644 --- a/apps/desktop/src/renderer/components/chat/TurnFilesSummary.vue +++ b/apps/desktop/src/renderer/components/chat/TurnFilesSummary.vue @@ -9,13 +9,16 @@ import type { FilePreviewRequest } from '../../types'; import { basename } from '../../lib/pathBasename'; import { pathRelativeTo } from '../../lib/pathRelativeTo'; -const props = defineProps<{ - changes: TurnFileChange[]; - cwd?: string; - /** False where nothing handles the row action (e.g. the BTW side chat) — - file rows then render as plain text instead of links. */ - interactive?: boolean; -}>(); +const props = withDefaults( + defineProps<{ + changes: TurnFileChange[]; + cwd?: string; + /** False where nothing handles the row action (e.g. the BTW side chat) — + file rows then render as plain text instead of links. */ + interactive?: boolean; + }>(), + { interactive: true }, +); const emit = defineEmits<{ openDiff: [change: TurnFileChange]; @@ -252,6 +255,9 @@ button.tf-file { } button.tf-file:hover { text-decoration: underline; + /* The spec's "lightly": the line rides the faint text token, not the + full-strength label colour. */ + text-decoration-color: var(--color-text-faint); text-underline-offset: 3px; } .tf-file:focus-visible { @@ -279,6 +285,9 @@ button.tf-file:hover { justify-content: flex-start; border-radius: 0; } +.turn-files .tf-more:not(:disabled):active { + transform: none; +} .tf-more-car { color: var(--color-text-faint); transition: transform var(--duration-base) var(--ease-out); diff --git a/apps/desktop/src/renderer/composables/client/useWorkspaceState.ts b/apps/desktop/src/renderer/composables/client/useWorkspaceState.ts index 1a00caf86..34ca58c0c 100644 --- a/apps/desktop/src/renderer/composables/client/useWorkspaceState.ts +++ b/apps/desktop/src/renderer/composables/client/useWorkspaceState.ts @@ -63,6 +63,7 @@ export const SESSIONS_INITIAL_PAGE_SIZE = 5; const SESSION_NOT_FOUND_CODE = 40401; const PROMPT_NOT_FOUND_CODE = 40402; const WORKSPACE_NOT_FOUND_CODE = 40410; +const FS_PATH_NOT_FOUND_CODE = 40409; // Shared "already resolved" conflict (40902). The daemon reuses it for both // approvals and questions when a second client races the resolve, so a // duplicate submit is reported as a conflict even though the desired end @@ -444,7 +445,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta // An empty (0-byte) new file has no line diff — git has nothing to // add — and the generic "no line changes" state would wrongly read // as "nothing changed". Read the file to tell the two apart. - const file = await readFileContent(path); + const file = await readFileContent(path).catch(() => null); if (selectedDiffPath.value !== path || rawState.activeSessionId !== sid) return; fileDiffEmptyFile.value = file !== null && file.size === 0; return; @@ -456,7 +457,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta const texts = await buildFullDiffTexts(rows, { truncated: result.truncated, readNewText: async () => { - const file = await readFileContent(path); + const file = await readFileContent(path).catch(() => null); if (!file || file.isBinary || file.encoding !== 'utf-8') return null; return file.content; }, @@ -2872,7 +2873,10 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta /** * Read file content for the active session. - * Returns the file metadata + content (including path), or null on error or no active session. + * Returns the file metadata + content (including path), or null on error or + * no active session. A genuinely-absent path (fs.path_not_found) is RETHROWN + * instead of nulled — the file preview maps it to a dedicated not-found + * state, which a shared "read failed" can't express. */ async function readFileContent(path: string): Promise<{ path: string; @@ -2901,6 +2905,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta }; } catch (err) { logWarn('[kimi-code] readFileContent failed for', path, err); + if (isDaemonApiError(err) && err.code === FS_PATH_NOT_FOUND_CODE) throw err; return null; } } diff --git a/apps/desktop/src/renderer/composables/useDetailPanel.ts b/apps/desktop/src/renderer/composables/useDetailPanel.ts index 245d40e77..da589b9b0 100644 --- a/apps/desktop/src/renderer/composables/useDetailPanel.ts +++ b/apps/desktop/src/renderer/composables/useDetailPanel.ts @@ -1,7 +1,7 @@ // apps/web/src/composables/useDetailPanel.ts // Unified right-side detail layer. Only one detail is open at a time. -import { computed, ref, watch, type Ref } from 'vue'; +import { computed, ref, shallowRef, watch, type Ref } from 'vue'; import type { AgentMember } from '../types'; import type { TurnFileChange } from '../components/chatTurnRendering'; import type { DetailTarget } from './useFilePreview'; @@ -287,7 +287,8 @@ export function useDetailPanel({ // --------------------------------------------------------------------------- // Unlike the git 'diff' slot (workspace vs HEAD), this shows ONE turn's edit to // ONE file — the DiffViewLine[] the summary derived alongside its stats. - const turnDiffChange = ref(null); + // shallowRef: openTurnDiff toggles on raw-object identity. + const turnDiffChange = shallowRef(null); function openTurnDiff(change: TurnFileChange): void { // Toggle only on the SAME change object: two turns may touch one path, and diff --git a/apps/desktop/src/renderer/composables/useFilePreview.ts b/apps/desktop/src/renderer/composables/useFilePreview.ts index 98d8e4a39..c6ae66787 100644 --- a/apps/desktop/src/renderer/composables/useFilePreview.ts +++ b/apps/desktop/src/renderer/composables/useFilePreview.ts @@ -247,7 +247,13 @@ export function useFilePreview({ client, detailTarget }: UseFilePreviewOptions) } } catch (err) { if (requestSeq !== previewRequestSeq) return; - previewError.value = err instanceof Error ? err.message : t('filePreview.errors.loadFailed'); + // readFileContent rethrows fs.path_not_found — the file was renamed or + // deleted after the turn touched it, which is not a read failure. + previewError.value = isNotFoundError(err) + ? t('filePreview.errors.notFound') + : err instanceof Error + ? err.message + : t('filePreview.errors.loadFailed'); } finally { if (requestSeq === previewRequestSeq) { previewLoading.value = false; diff --git a/apps/desktop/tests/renderer/detail-panel-toggle.test.ts b/apps/desktop/tests/renderer/detail-panel-toggle.test.ts new file mode 100644 index 000000000..9268bdc60 --- /dev/null +++ b/apps/desktop/tests/renderer/detail-panel-toggle.test.ts @@ -0,0 +1,96 @@ +import { ref } from 'vue'; +import { describe, expect, it, vi } from 'vitest'; + +// useFilePreview only needs a `t` pass-through; everything else (createI18n +// for the transitive web-i18n import) stays real. +vi.mock('vue-i18n', async (importActual) => { + const actual = await importActual(); + return { ...actual, useI18n: () => ({ t: (key: string) => key }) }; +}); + +import { useDetailPanel } from '../../src/renderer/composables/useDetailPanel'; +import { useFilePreview, type DetailTarget } from '../../src/renderer/composables/useFilePreview'; +import type { TurnFileChange } from '../../src/renderer/components/chatTurnRendering'; + +const editChange: TurnFileChange = { + path: '/repo/src/a.ts', + added: 3, + removed: 1, + hasWrite: false, + statsIncomplete: false, + diff: null, +}; + +describe('detail panel toggle', () => { + it('second openTurnDiff with the same change object closes the panel', () => { + const detailTarget = ref(null); + const client = { + activeSessionId: ref('session-1'), + activeAppTasks: ref([]), + turns: ref([]), + sideChatVisible: ref(false), + auxiliaryTranscripts: { getEntry: vi.fn(), activate: vi.fn(), deactivate: vi.fn() }, + loadGitStatus: vi.fn(), + clearFileDiff: vi.fn(), + loadFileDiff: vi.fn(), + }; + const panel = useDetailPanel({ + client: client as never, + sideWidth: ref(280), + detailTarget, + closeFilePreview: vi.fn(), + }); + + panel.openTurnDiff(editChange); + expect(detailTarget.value).toBe('turn-diff'); + panel.openTurnDiff(editChange); + expect(detailTarget.value).toBe(null); + }); + + it('second openFilePreview with the same target closes the panel', async () => { + const detailTarget = ref(null); + const client = { + status: ref({ cwd: '/repo' }), + readFileContent: vi.fn(async () => ({ + path: 'src/a.ts', + content: 'x', + encoding: 'utf-8', + mime: 'text/plain', + isBinary: false, + size: 1, + })), + readHostFileContent: vi.fn(), + getFileDownloadUrl: vi.fn(() => 'url'), + openWorkspaceFile: vi.fn(), + revealWorkspaceFile: vi.fn(), + }; + const preview = useFilePreview({ client: client as never, detailTarget }); + + await preview.openFilePreview({ path: '/repo/src/a.ts', allowHostRead: true }); + expect(detailTarget.value).toBe('file'); + await preview.openFilePreview({ path: '/repo/src/a.ts', allowHostRead: true }); + expect(detailTarget.value).toBe(null); + }); + + it('maps a daemon path-not-found to the dedicated not-found error state', async () => { + const detailTarget = ref(null); + const client = { + status: ref({ cwd: '/repo' }), + // DaemonApiError-shaped: the guard keys on name + numeric code. + readFileContent: vi.fn(async () => { + throw Object.assign(new Error('path not found: src/gone.ts'), { + name: 'DaemonApiError', + code: 40409, + }); + }), + readHostFileContent: vi.fn(), + getFileDownloadUrl: vi.fn(() => 'url'), + openWorkspaceFile: vi.fn(), + revealWorkspaceFile: vi.fn(), + }; + const preview = useFilePreview({ client: client as never, detailTarget }); + + await preview.openFilePreview({ path: '/repo/src/gone.ts', allowHostRead: true }); + expect(preview.previewError.value).toBe('filePreview.errors.notFound'); + }); +}); diff --git a/apps/desktop/tests/renderer/turn-files-summary.test.ts b/apps/desktop/tests/renderer/turn-files-summary.test.ts new file mode 100644 index 000000000..505279ddd --- /dev/null +++ b/apps/desktop/tests/renderer/turn-files-summary.test.ts @@ -0,0 +1,71 @@ +import { defineComponent, h } from 'vue'; +import { renderToString } from 'vue/server-renderer'; +import { describe, expect, it, vi } from 'vitest'; + +// ChatPane's import graph reaches markstream (KaTeX worker), which can't load +// under node — only its props metadata is needed here, so stub Markdown out. +vi.mock('@moonshot-ai/web-markdown', async () => { + const vue = await import('vue'); + return { + Markdown: vue.defineComponent({ name: 'Markdown', setup: () => () => vue.h('div') }), + }; +}); + +// The summary only needs a `t` pass-through; everything else (createI18n for +// the transitive web-i18n import) stays real. +vi.mock('vue-i18n', async (importActual) => { + const actual = await importActual(); + return { ...actual, useI18n: () => ({ t: (key: string) => key }) }; +}); + +import ChatPane from '../../src/renderer/components/chat/ChatPane.vue'; +import TurnFilesSummary from '../../src/renderer/components/chat/TurnFilesSummary.vue'; +import type { TurnFileChange } from '../../src/renderer/components/chatTurnRendering'; + +const editChange: TurnFileChange = { + path: '/repo/src/a.ts', + added: 3, + removed: 1, + hasWrite: false, + statsIncomplete: false, + diff: null, +}; +const writeChange: TurnFileChange = { + path: '/repo/src/b.ts', + added: 0, + removed: 0, + hasWrite: true, + statsIncomplete: true, + diff: null, +}; + +function propDefault(component: unknown, name: string): unknown { + const props = (component as { props?: Record }).props; + return props?.[name]?.default; +} + +function renderSummary(props: Record): Promise { + // Untyped on purpose: spread test props don't satisfy the SFC's vnode-prop + // generics, and the assertion targets rendered HTML, not the prop types. + const Host = defineComponent(() => () => h(TurnFilesSummary as never, { cwd: '/repo', ...props })); + return renderToString(h(Host)); +} + +describe('TurnFilesSummary interactivity', () => { + it('defaults the interactive flag to true at both boundaries', () => { + expect(propDefault(TurnFilesSummary, 'interactive')).toBe(true); + expect(propDefault(ChatPane, 'turnFilesInteractive')).toBe(true); + }); + + it('renders rows as buttons when interactive is not passed', async () => { + const html = await renderSummary({ changes: [editChange, writeChange] }); + expect(html).toMatch(/]*class="[^"]*tf-file/); + expect(html).not.toMatch(/]*class="[^"]*tf-file/); + }); + + it('renders rows as plain text only when interactive is explicitly false', async () => { + const html = await renderSummary({ changes: [editChange], interactive: false }); + expect(html).toMatch(/]*class="[^"]*tf-file/); + expect(html).not.toMatch(/]*class="[^"]*tf-file/); + }); +}); diff --git a/apps/web/src/App.vue b/apps/web/src/App.vue index 297f33e41..8eee04dc8 100644 --- a/apps/web/src/App.vue +++ b/apps/web/src/App.vue @@ -1082,6 +1082,7 @@ function openPr(url: string): void { @open-agent="openAgentPanel" @open-file="openFilePreview" @open-media="openMediaPreview" + @open-turn-diff="openTurnDiff($event)" /> (); const { t } = useI18n(); const identity = computed(() => props.member.id); @@ -127,6 +129,7 @@ function phaseLabel(phase: AgentMember['phase']): string { @open-agent="emit('openAgent', $event)" @open-file="emit('openFile', $event)" @open-media="emit('openMedia', $event)" + @open-turn-diff="emit('openTurnDiff', $event)" /> diff --git a/apps/web/src/components/chat/ChatPane.vue b/apps/web/src/components/chat/ChatPane.vue index 25ef16bd5..94c506e4f 100644 --- a/apps/web/src/components/chat/ChatPane.vue +++ b/apps/web/src/components/chat/ChatPane.vue @@ -139,6 +139,7 @@ const props = withDefaults( { approvals: () => [], questions: () => [], + turnFilesInteractive: true, turnActive: false, working: false, compaction: null, diff --git a/apps/web/src/components/chat/TurnFilesSummary.vue b/apps/web/src/components/chat/TurnFilesSummary.vue index f35074866..8deeafcfb 100644 --- a/apps/web/src/components/chat/TurnFilesSummary.vue +++ b/apps/web/src/components/chat/TurnFilesSummary.vue @@ -9,13 +9,16 @@ import type { FilePreviewRequest } from '../../types'; import { basename } from '../../lib/pathBasename'; import { pathRelativeTo } from '../../lib/pathRelativeTo'; -const props = defineProps<{ - changes: TurnFileChange[]; - cwd?: string; - /** False where nothing handles the row action (e.g. the BTW side chat) — - file rows then render as plain text instead of links. */ - interactive?: boolean; -}>(); +const props = withDefaults( + defineProps<{ + changes: TurnFileChange[]; + cwd?: string; + /** False where nothing handles the row action (e.g. the BTW side chat) — + file rows then render as plain text instead of links. */ + interactive?: boolean; + }>(), + { interactive: true }, +); const emit = defineEmits<{ openDiff: [change: TurnFileChange]; @@ -252,6 +255,9 @@ button.tf-file { } button.tf-file:hover { text-decoration: underline; + /* The spec's "lightly": the line rides the faint text token, not the + full-strength label colour. */ + text-decoration-color: var(--color-text-faint); text-underline-offset: 3px; } .tf-file:focus-visible { @@ -279,6 +285,9 @@ button.tf-file:hover { justify-content: flex-start; border-radius: 0; } +.turn-files .tf-more:not(:disabled):active { + transform: none; +} .tf-more-car { color: var(--color-text-faint); transition: transform var(--duration-base) var(--ease-out); diff --git a/apps/web/src/composables/client/useWorkspaceState.ts b/apps/web/src/composables/client/useWorkspaceState.ts index a7552ff59..9fcf85bf5 100644 --- a/apps/web/src/composables/client/useWorkspaceState.ts +++ b/apps/web/src/composables/client/useWorkspaceState.ts @@ -60,6 +60,7 @@ export const SESSIONS_INITIAL_PAGE_SIZE = 5; const SESSION_NOT_FOUND_CODE = 40401; const PROMPT_NOT_FOUND_CODE = 40402; const WORKSPACE_NOT_FOUND_CODE = 40410; +const FS_PATH_NOT_FOUND_CODE = 40409; // Shared "already resolved" conflict (40902). The daemon reuses it for both // approvals and questions when a second client races the resolve, so a // duplicate submit is reported as a conflict even though the desired end @@ -441,7 +442,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta // An empty (0-byte) new file has no line diff — git has nothing to // add — and the generic "no line changes" state would wrongly read // as "nothing changed". Read the file to tell the two apart. - const file = await readFileContent(path); + const file = await readFileContent(path).catch(() => null); if (selectedDiffPath.value !== path || rawState.activeSessionId !== sid) return; fileDiffEmptyFile.value = file !== null && file.size === 0; return; @@ -453,7 +454,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta const texts = await buildFullDiffTexts(rows, { truncated: result.truncated, readNewText: async () => { - const file = await readFileContent(path); + const file = await readFileContent(path).catch(() => null); if (!file || file.isBinary || file.encoding !== 'utf-8') return null; return file.content; }, @@ -2846,7 +2847,10 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta /** * Read file content for the active session. - * Returns the file metadata + content (including path), or null on error or no active session. + * Returns the file metadata + content (including path), or null on error or + * no active session. A genuinely-absent path (fs.path_not_found) is RETHROWN + * instead of nulled — the file preview maps it to a dedicated not-found + * state, which a shared "read failed" can't express. */ async function readFileContent(path: string): Promise<{ path: string; @@ -2875,6 +2879,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta }; } catch (err) { logWarn('[kimi-web] readFileContent failed for', path, err); + if (isDaemonApiError(err) && err.code === FS_PATH_NOT_FOUND_CODE) throw err; return null; } } diff --git a/apps/web/src/composables/useDetailPanel.ts b/apps/web/src/composables/useDetailPanel.ts index fa859b83e..16ce8a873 100644 --- a/apps/web/src/composables/useDetailPanel.ts +++ b/apps/web/src/composables/useDetailPanel.ts @@ -1,7 +1,7 @@ // apps/kimi-web/src/composables/useDetailPanel.ts // Unified right-side detail layer. Only one detail is open at a time. -import { computed, ref, watch, type Ref } from 'vue'; +import { computed, ref, shallowRef, watch, type Ref } from 'vue'; import type { AgentMember } from '../types'; import type { TurnFileChange } from '../components/chatTurnRendering'; import type { DetailTarget } from './useFilePreview'; @@ -282,7 +282,8 @@ export function useDetailPanel({ // --------------------------------------------------------------------------- // Unlike the git 'diff' slot (workspace vs HEAD), this shows ONE turn's edit to // ONE file — the DiffViewLine[] the summary derived alongside its stats. - const turnDiffChange = ref(null); + // shallowRef: openTurnDiff toggles on raw-object identity. + const turnDiffChange = shallowRef(null); function openTurnDiff(change: TurnFileChange): void { // Toggle only on the SAME change object: two turns may touch one path, and diff --git a/apps/web/src/composables/useFilePreview.ts b/apps/web/src/composables/useFilePreview.ts index 84b5bf545..53da7db1b 100644 --- a/apps/web/src/composables/useFilePreview.ts +++ b/apps/web/src/composables/useFilePreview.ts @@ -247,7 +247,13 @@ export function useFilePreview({ client, detailTarget }: UseFilePreviewOptions) } } catch (err) { if (requestSeq !== previewRequestSeq) return; - previewError.value = err instanceof Error ? err.message : t('filePreview.errors.loadFailed'); + // readFileContent rethrows fs.path_not_found — the file was renamed or + // deleted after the turn touched it, which is not a read failure. + previewError.value = isNotFoundError(err) + ? t('filePreview.errors.notFound') + : err instanceof Error + ? err.message + : t('filePreview.errors.loadFailed'); } finally { if (requestSeq === previewRequestSeq) { previewLoading.value = false; diff --git a/apps/web/test/detail-panel-toggle.test.ts b/apps/web/test/detail-panel-toggle.test.ts new file mode 100644 index 000000000..ce67cb70a --- /dev/null +++ b/apps/web/test/detail-panel-toggle.test.ts @@ -0,0 +1,96 @@ +import { ref } from 'vue'; +import { describe, expect, it, vi } from 'vitest'; + +// useFilePreview only needs a `t` pass-through; everything else (createI18n +// for the transitive web-i18n import) stays real. +vi.mock('vue-i18n', async (importActual) => { + const actual = await importActual(); + return { ...actual, useI18n: () => ({ t: (key: string) => key }) }; +}); + +import { useDetailPanel } from '../src/composables/useDetailPanel'; +import { useFilePreview, type DetailTarget } from '../src/composables/useFilePreview'; +import type { TurnFileChange } from '../src/components/chatTurnRendering'; + +const editChange: TurnFileChange = { + path: '/repo/src/a.ts', + added: 3, + removed: 1, + hasWrite: false, + statsIncomplete: false, + diff: null, +}; + +describe('detail panel toggle', () => { + it('second openTurnDiff with the same change object closes the panel', () => { + const detailTarget = ref(null); + const client = { + activeSessionId: ref('session-1'), + activeAppTasks: ref([]), + turns: ref([]), + sideChatVisible: ref(false), + auxiliaryTranscripts: { getEntry: vi.fn(), activate: vi.fn(), deactivate: vi.fn() }, + loadGitStatus: vi.fn(), + clearFileDiff: vi.fn(), + loadFileDiff: vi.fn(), + }; + const panel = useDetailPanel({ + client: client as never, + sideWidth: ref(280), + detailTarget, + closeFilePreview: vi.fn(), + }); + + panel.openTurnDiff(editChange); + expect(detailTarget.value).toBe('turn-diff'); + panel.openTurnDiff(editChange); + expect(detailTarget.value).toBe(null); + }); + + it('second openFilePreview with the same target closes the panel', async () => { + const detailTarget = ref(null); + const client = { + status: ref({ cwd: '/repo' }), + readFileContent: vi.fn(async () => ({ + path: 'src/a.ts', + content: 'x', + encoding: 'utf-8', + mime: 'text/plain', + isBinary: false, + size: 1, + })), + readHostFileContent: vi.fn(), + getFileDownloadUrl: vi.fn(() => 'url'), + openWorkspaceFile: vi.fn(), + revealWorkspaceFile: vi.fn(), + }; + const preview = useFilePreview({ client: client as never, detailTarget }); + + await preview.openFilePreview({ path: '/repo/src/a.ts', allowHostRead: true }); + expect(detailTarget.value).toBe('file'); + await preview.openFilePreview({ path: '/repo/src/a.ts', allowHostRead: true }); + expect(detailTarget.value).toBe(null); + }); + + it('maps a daemon path-not-found to the dedicated not-found error state', async () => { + const detailTarget = ref(null); + const client = { + status: ref({ cwd: '/repo' }), + // DaemonApiError-shaped: the guard keys on name + numeric code. + readFileContent: vi.fn(async () => { + throw Object.assign(new Error('path not found: src/gone.ts'), { + name: 'DaemonApiError', + code: 40409, + }); + }), + readHostFileContent: vi.fn(), + getFileDownloadUrl: vi.fn(() => 'url'), + openWorkspaceFile: vi.fn(), + revealWorkspaceFile: vi.fn(), + }; + const preview = useFilePreview({ client: client as never, detailTarget }); + + await preview.openFilePreview({ path: '/repo/src/gone.ts', allowHostRead: true }); + expect(preview.previewError.value).toBe('filePreview.errors.notFound'); + }); +}); diff --git a/apps/web/test/turn-files-summary.test.ts b/apps/web/test/turn-files-summary.test.ts new file mode 100644 index 000000000..21c29bd04 --- /dev/null +++ b/apps/web/test/turn-files-summary.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, vi } from 'vitest'; + +// ChatPane's import graph reaches markstream (KaTeX worker), which can't load +// under node — only its props metadata is needed here, so stub Markdown out. +vi.mock('@moonshot-ai/web-markdown', async () => { + const vue = await import('vue'); + return { + Markdown: vue.defineComponent({ name: 'Markdown', setup: () => () => vue.h('div') }), + }; +}); + +import ChatPane from '../src/components/chat/ChatPane.vue'; +import TurnFilesSummary from '../src/components/chat/TurnFilesSummary.vue'; + +function propDefault(component: unknown, name: string): unknown { + const props = (component as { props?: Record }).props; + return props?.[name]?.default; +} + +describe('TurnFilesSummary interactivity', () => { + it('defaults the interactive flag to true at both boundaries', () => { + expect(propDefault(TurnFilesSummary, 'interactive')).toBe(true); + expect(propDefault(ChatPane, 'turnFilesInteractive')).toBe(true); + }); +});