fix(chat): revive the turn file-change summary's dead file rows (#148)

* fix(chat): revive the turn file-change summary's dead file rows

The summary card's file rows rendered as inert plain-text spans in the
main conversation, so they had no hover feedback and ignored every click:

- An optional boolean prop without a declared default casts ABSENT to
  false, so "interactive unless explicitly false" silently resolved to
  false wherever the flag wasn't passed (ChatPane → TurnFilesSummary).
  Both boundaries now declare the default true explicitly; only the BTW
  side chat keeps the plain-text rows, by design.

With the rows alive again, two latent defects on the never-exercised
interactive path surfaced and are fixed here:

- The turn-diff panel could not be toggled closed by tapping the same
  row again: openTurnDiff compares the stored change by identity, but a
  deep ref reads back a reactive proxy that never matches the raw
  object. turnDiffChange is now a shallowRef.
- AgentDetailPanel's embedded ChatPane never forwarded open-turn-diff,
  so an Edit row there would have been a dead button; the event now
  flows through to the shared detail layer.

Plus two style corrections on the card:

- The row hover underline rode the full-strength text colour; it now
  uses the faint text token, matching the spec's "lightly".
- The foot "N more files" toggle no longer inherits the shared Button's
  press-scale, which read as the whole strip denting inward on a
  full-width square-cornered row.

Tests: SSR-render assertions pin the interactive defaults and the
button-vs-span rows (turn-files-summary.test.ts), and composable tests
cover the second-tap toggle of both the turn-diff and file-preview
panels (detail-panel-toggle.test.ts) — mirrored desktop/web.

* refactor(chat): trim invariant comments, keep web tests pure-logic

Review feedback on the turn file-change summary fix:

- The shallowRef / prop-default notes carried bug-narrative detail that
  repo rules keep out of source comments; each is now a one-line
  invariant hint (root causes live in the earlier commit message).
- apps/web tests are pure logic only (apps/web/AGENTS.md), so the web
  copies no longer render components: turn-files-summary pins just the
  compiled prop defaults (rendered button-vs-span coverage stays in the
  desktop renderer tests), and detail-panel-toggle calls the composables
  directly instead of mounting a host. Same coverage, no renderer.

* fix(chat): show a real not-found state for moved or deleted preview files

Clicking a file the turn touched after it was renamed or deleted showed the generic unable-to-read message — the daemon precise fs.path_not_found (40409) was swallowed into null by readFileContent, collapsing every failure into one misleading message. readFileContent now rethrows the not-found code (its two loadFileDiff call sites keep their null-on-error behavior via .catch), and openFilePreview maps it to the dedicated not-found state that only the host-read branch used before.

* chore(chat): drop the css rationale note, trim the not-found changeset

Review feedback: the press-scale override carries its reason in the commit message rather than a source comment, and the changeset stops at the fixed problem per the changeset skill (no behavior supplement).

* chore(chat): drop the remaining prop-default rationale notes

Review feedback: the one-line boolean-cast note is still regression rationale; the defaults are guarded by the turn-files-summary tests, so source stays bare per the comment-restraint rule.

* chore(chat): drop regression narrative from the new tests

Review feedback: test names and assertions already carry the regression intent; setup-constraint notes (module mocks) stay, bug root causes live in the commit history.
This commit is contained in:
qer 2026-07-29 16:01:35 +08:00 committed by GitHub
parent c7ae597e3e
commit 6720944aed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 391 additions and 26 deletions

View file

@ -0,0 +1,5 @@
---
"kimi-code-app": patch
---
修复打开已被删除或移动的文件时提示「无法读取这个文件」的问题。

View file

@ -0,0 +1,5 @@
---
"kimi-code-app": patch
---
修复再次点击文件改动卡片里的文件时右侧改动面板无法收起的问题。

View file

@ -0,0 +1,5 @@
---
"kimi-code-app": patch
---
去掉文件改动卡片底部「还有 N 个文件」按钮按下时的压缩效果。

View file

@ -0,0 +1,5 @@
---
"kimi-code-app": patch
---
修复一轮对话末尾文件改动卡片里的文件无法点击打开的问题。

View file

@ -0,0 +1,5 @@
---
"kimi-code-app": patch
---
修复文件改动卡片里文件链接 hover 时下划线过深的问题。

View file

@ -1534,6 +1534,7 @@ function openPr(url: string): void {
@open-agent="openAgentPanel"
@open-file="openFilePreview"
@open-media="openMediaPreview"
@open-turn-diff="openTurnDiff($event)"
/>
<SideChatPanel
v-else-if="detailTarget === 'btw' && btwVisible"

View file

@ -6,6 +6,7 @@ import { Badge, PanelHeader } from '@moonshot-ai/web-ui';
import { useFollowScroll } from '../../composables/useFollowScroll';
import type { AgentMember, ChatTurn, FilePreviewRequest, ToolMedia } from '../../types';
import type { TurnFileChange } from '../chatTurnRendering';
import ChatPane from './ChatPane.vue';
import OutputPanel from './tool-calls/OutputPanel.vue';
@ -26,6 +27,7 @@ const emit = defineEmits<{
openAgent: [agentId: string];
openFile: [target: FilePreviewRequest];
openMedia: [media: ToolMedia];
openTurnDiff: [change: TurnFileChange];
}>();
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)"
/>
</template>
</div>

View file

@ -139,6 +139,7 @@ const props = withDefaults(
{
approvals: () => [],
questions: () => [],
turnFilesInteractive: true,
turnActive: false,
working: false,
compaction: null,

View file

@ -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);

View file

@ -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;
}
}

View file

@ -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<TurnFileChange | null>(null);
// shallowRef: openTurnDiff toggles on raw-object identity.
const turnDiffChange = shallowRef<TurnFileChange | null>(null);
function openTurnDiff(change: TurnFileChange): void {
// Toggle only on the SAME change object: two turns may touch one path, and

View file

@ -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;

View file

@ -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<typeof import('vue-i18n')>();
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<DetailTarget | null>(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<DetailTarget | null>(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<DetailTarget | null>(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');
});
});

View file

@ -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<typeof import('vue-i18n')>();
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<string, { default?: unknown }> }).props;
return props?.[name]?.default;
}
function renderSummary(props: Record<string, unknown>): Promise<string> {
// 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(/<button[^>]*class="[^"]*tf-file/);
expect(html).not.toMatch(/<span[^>]*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(/<span[^>]*class="[^"]*tf-file/);
expect(html).not.toMatch(/<button[^>]*class="[^"]*tf-file/);
});
});

View file

@ -1082,6 +1082,7 @@ function openPr(url: string): void {
@open-agent="openAgentPanel"
@open-file="openFilePreview"
@open-media="openMediaPreview"
@open-turn-diff="openTurnDiff($event)"
/>
<SideChatPanel
v-else-if="detailTarget === 'btw' && btwVisible"

View file

@ -5,6 +5,7 @@ import { Badge, PanelHeader } from '@moonshot-ai/web-ui';
import { useFollowScroll } from '../../composables/useFollowScroll';
import type { AgentMember, ChatTurn, FilePreviewRequest, ToolMedia } from '../../types';
import type { TurnFileChange } from '../chatTurnRendering';
import ChatPane from './ChatPane.vue';
import OutputPanel from './tool-calls/OutputPanel.vue';
@ -24,6 +25,7 @@ const emit = defineEmits<{
openAgent: [agentId: string];
openFile: [target: FilePreviewRequest];
openMedia: [media: ToolMedia];
openTurnDiff: [change: TurnFileChange];
}>();
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)"
/>
</template>
</div>

View file

@ -139,6 +139,7 @@ const props = withDefaults(
{
approvals: () => [],
questions: () => [],
turnFilesInteractive: true,
turnActive: false,
working: false,
compaction: null,

View file

@ -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);

View file

@ -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;
}
}

View file

@ -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<TurnFileChange | null>(null);
// shallowRef: openTurnDiff toggles on raw-object identity.
const turnDiffChange = shallowRef<TurnFileChange | null>(null);
function openTurnDiff(change: TurnFileChange): void {
// Toggle only on the SAME change object: two turns may touch one path, and

View file

@ -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;

View file

@ -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<typeof import('vue-i18n')>();
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<DetailTarget | null>(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<DetailTarget | null>(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<DetailTarget | null>(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');
});
});

View file

@ -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<string, { default?: unknown }> }).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);
});
});