mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-09-02 03:25:44 +00:00
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:
parent
c7ae597e3e
commit
6720944aed
23 changed files with 391 additions and 26 deletions
5
.changeset/file-preview-not-found.md
Normal file
5
.changeset/file-preview-not-found.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"kimi-code-app": patch
|
||||
---
|
||||
|
||||
修复打开已被删除或移动的文件时提示「无法读取这个文件」的问题。
|
||||
5
.changeset/turn-diff-toggle-close.md
Normal file
5
.changeset/turn-diff-toggle-close.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"kimi-code-app": patch
|
||||
---
|
||||
|
||||
修复再次点击文件改动卡片里的文件时右侧改动面板无法收起的问题。
|
||||
5
.changeset/turn-files-more-no-press-scale.md
Normal file
5
.changeset/turn-files-more-no-press-scale.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"kimi-code-app": patch
|
||||
---
|
||||
|
||||
去掉文件改动卡片底部「还有 N 个文件」按钮按下时的压缩效果。
|
||||
5
.changeset/turn-files-summary-interactive.md
Normal file
5
.changeset/turn-files-summary-interactive.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"kimi-code-app": patch
|
||||
---
|
||||
|
||||
修复一轮对话末尾文件改动卡片里的文件无法点击打开的问题。
|
||||
5
.changeset/turn-files-underline-lighter.md
Normal file
5
.changeset/turn-files-underline-lighter.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"kimi-code-app": patch
|
||||
---
|
||||
|
||||
修复文件改动卡片里文件链接 hover 时下划线过深的问题。
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -139,6 +139,7 @@ const props = withDefaults(
|
|||
{
|
||||
approvals: () => [],
|
||||
questions: () => [],
|
||||
turnFilesInteractive: true,
|
||||
turnActive: false,
|
||||
working: false,
|
||||
compaction: null,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
96
apps/desktop/tests/renderer/detail-panel-toggle.test.ts
Normal file
96
apps/desktop/tests/renderer/detail-panel-toggle.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
71
apps/desktop/tests/renderer/turn-files-summary.test.ts
Normal file
71
apps/desktop/tests/renderer/turn-files-summary.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -139,6 +139,7 @@ const props = withDefaults(
|
|||
{
|
||||
approvals: () => [],
|
||||
questions: () => [],
|
||||
turnFilesInteractive: true,
|
||||
turnActive: false,
|
||||
working: false,
|
||||
compaction: null,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
96
apps/web/test/detail-panel-toggle.test.ts
Normal file
96
apps/web/test/detail-panel-toggle.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
25
apps/web/test/turn-files-summary.test.ts
Normal file
25
apps/web/test/turn-files-summary.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue