fix(web): don't raise a global error toast when a file diff is unavailable

Clicking a changed file the daemon can't diff (new/untracked/binary/deleted)
made loadFileDiff push a session-level 'kimi server api' error toast on a
routine click. The DiffView already shows a graceful 'no diff' state when the
lines are empty, so demote the failure to a console.warn (captured by the trace
export) and let the pane handle it locally.
This commit is contained in:
qer 2026-06-13 12:35:32 +08:00
parent da18be330c
commit 90b1108701
12 changed files with 424 additions and 17 deletions

View file

@ -1460,6 +1460,36 @@ const server = http.createServer((req, res) => {
return res.end(ok(gs));
}
// ---- fs:diff ----
// POST /api/v1/sessions/{id}/fs:diff body: { path }
if (seg[0] === 'sessions' && seg[2] === 'fs:diff' && seg.length === 3 && method === 'POST') {
const session = sessions.find((s) => s.id === sid);
if (!session) return res.end(fail(40401, `session ${sid} does not exist`));
const b = json();
if (!b.path) return res.end(fail(40001, 'path: required'));
// Deterministic fake unified diff so the Changed → diff flow is demoable.
const diff = [
`diff --git a/${b.path} b/${b.path}`,
'index 1111111..2222222 100644',
`--- a/${b.path}`,
`+++ b/${b.path}`,
'@@ -1,7 +1,9 @@',
` // ${b.path}`,
' ',
'-export function oldImpl(input) {',
'- return null;',
'+export function newImpl(input) {',
'+ // handle the empty-state branch the old impl missed',
'+ if (!input) return fallback();',
'+ return compute(input);',
' }',
' ',
' export const VERSION = 2;',
'',
].join('\n');
return res.end(ok({ path: b.path, diff, truncated: false }));
}
// ---- fs:list ----
// POST /api/v1/sessions/{id}/fs:list body: { path, depth?, include_git_status? }
if (seg[0] === 'sessions' && seg[2] === 'fs:list' && seg.length === 3 && method === 'POST') {

View file

@ -734,13 +734,11 @@ export class DaemonKimiWebApi implements KimiWebApi {
async getFileDiff(
sessionId: string,
path?: string,
path: string,
): Promise<{ path: string; diff: string }> {
const body: Record<string, unknown> = {};
if (path !== undefined) body['path'] = path;
const data = await this.http.post<WireDiffResult>(
`/sessions/${encodeURIComponent(sessionId)}/fs:diff`,
body,
{ path },
);
return { path: data.path, diff: data.diff };
}

View file

@ -493,7 +493,7 @@ export interface KimiWebApi {
searchFiles(sessionId: string, input: { query: string; limit?: number }): Promise<{ items: Array<{ path: string; name: string; kind: FsKind; score: number; matchPositions: number[] }>; truncated: boolean }>;
grepFiles(sessionId: string, input: { pattern: string; regex?: boolean; caseSensitive?: boolean }): Promise<{ files: Array<{ path: string; matches: Array<{ line: number; col: number; text: string; before: string[]; after: string[] }> }>; filesScanned: number; truncated: boolean; elapsedMs: number }>;
getGitStatus(sessionId: string, paths?: string[]): Promise<{ branch: string; ahead: number; behind: number; entries: Record<string, string> }>;
getFileDiff(sessionId: string, path?: string): Promise<{ path: string; diff: string }>;
getFileDiff(sessionId: string, path: string): Promise<{ path: string; diff: string }>;
getFileDownloadUrl(sessionId: string, path: string): string;
openFile(sessionId: string, input: { path: string; line?: number }): Promise<{ opened: true }>;
revealFile(sessionId: string, input: { path: string }): Promise<{ revealed: true }>;

View file

@ -185,6 +185,11 @@ async function handleFileSelect(entry: FsEntry): Promise<void> {
// ---------------------------------------------------------------------------
const changedView = ref<'changed' | 'all'>('changed');
// Non-git workspace (gitInfo is null): there is no Changed view to offer
// the Changed|All toggle is hidden and the full file tree shows directly.
const hasGit = computed(() => (props.gitInfo ?? null) !== null);
const filesView = computed<'changed' | 'all'>(() => (hasGit.value ? changedView.value : 'all'));
// The "Changed" navigator can show a flat list or a directory tree (persisted).
const CHANGED_LAYOUT_KEY = 'kimi-web.changed-layout';
function loadChangedLayout(): 'list' | 'tree' {
@ -680,7 +685,7 @@ onUnmounted(() => {
half is visible; the divider only exists on desktop). -->
<template v-else-if="active === 'files'">
<div v-show="!mobile || !filesShowPreview" class="files-nav">
<div class="nav-seg">
<div v-if="hasGit" class="nav-seg">
<div class="seg-group" role="group" :aria-label="t('fileTree.segLabel')">
<button
type="button"
@ -702,7 +707,7 @@ onUnmounted(() => {
</div>
</div>
<!-- list/tree layout toggle for the Changed view -->
<div v-if="changedView === 'changed'" class="nav-tools">
<div v-if="filesView === 'changed'" class="nav-tools">
<button
type="button"
class="layout-toggle"
@ -715,12 +720,12 @@ onUnmounted(() => {
</button>
</div>
<div class="files-nav-body">
<template v-if="changedView === 'changed'">
<template v-if="filesView === 'changed'">
<DiffView
v-if="changedLayout === 'list'"
mode="list"
:changes="changes ?? []"
:git-info="null"
:git-info="gitInfo ?? null"
@open="pickChanged"
/>
<ChangedTree v-else :changes="changes ?? []" @open="pickChanged" />
@ -758,7 +763,7 @@ onUnmounted(() => {
:loading="previewLoading"
/>
<div v-else class="files-empty">
{{ changedView === 'changed' ? t('fileTree.selectChanged') : t('fileTree.selectFile') }}
{{ filesView === 'changed' ? t('fileTree.selectChanged') : t('fileTree.selectFile') }}
</div>
</div>
</template>

View file

@ -10,8 +10,7 @@ const { t } = useI18n();
const tabs: { key: PaneKey; labelKey: string }[] = [
{ key: 'chat', labelKey: 'sidebar.tabChat' },
// TODO: temporarily hide the files tab until the feature is ready
// { key: 'files', labelKey: 'sidebar.tabFiles' },
{ key: 'files', labelKey: 'sidebar.tabFiles' },
{ key: 'tasks', labelKey: 'sidebar.tabTasks' },
{ key: 'todo', labelKey: 'sidebar.tabTodo' },
];
@ -28,8 +27,7 @@ const tabs: { key: PaneKey; labelKey: string }[] = [
@click="emit('select', tab.key)"
>
{{ t(tab.labelKey) }}
<!-- TODO: restore when files tab is re-enabled -->
<!-- <span v-if="tab.key === 'files' && (changesCount ?? 0) > 0" class="d"></span> -->
<span v-if="tab.key === 'files' && (changesCount ?? 0) > 0" class="d"></span>
<span v-if="tab.key === 'tasks' && runningTasks > 0" class="cnt">{{ runningTasks }}</span>
<span v-if="tab.key === 'todo' && (todos?.length ?? 0) > 0" class="cnt">{{ (todos?.filter((t) => t.status === 'done').length ?? 0) }}/{{ todos!.length }}</span>
</div>

View file

@ -1504,8 +1504,13 @@ async function loadFileDiff(path: string): Promise<void> {
if (selectedDiffPath.value !== path) return;
fileDiffLines.value = parseDiff(result.diff);
} catch (err) {
// A single file's diff failing (a new/untracked/binary/deleted file the
// daemon can't diff) is LOCAL to this pane, not a session-level fault — the
// DiffView already shows a graceful "no diff" state when the lines are
// empty. Surfacing it as a global "kimi server api" error toast on a routine
// file click is disproportionate, so log it for the trace export instead.
if (selectedDiffPath.value === path) fileDiffLines.value = [];
pushOperationFailure('loadFileDiff', err, { sessionId: sid });
console.warn('[loadFileDiff] diff unavailable for', path, err);
} finally {
if (selectedDiffPath.value === path) fileDiffLoading.value = false;
}

View file

@ -0,0 +1,84 @@
// apps/kimi-web/test/files-tab-no-git.test.ts
//
// Files tab without git: a workspace that is not a git repository (gitInfo is
// null) has no "Changed" view to offer — the Changed|All toggle must not
// render and the full file tree shows directly. With git info present the
// toggle renders and defaults to the Changed view.
import { mount } from '@vue/test-utils';
import { createI18n } from 'vue-i18n';
import { afterEach, describe, expect, it } from 'vitest';
import { nextTick } from 'vue';
import ConversationPane from '../src/components/ConversationPane.vue';
import type { ChatTurn, ConversationStatus } from '../src/types';
const status: ConversationStatus = {
model: 'kimi-test',
modelId: 'kimi-test',
ctxUsed: 0,
ctxMax: 0,
permission: 'manual',
branch: 'main',
cwd: '/repo',
isGitRepo: true,
};
const turns: ChatTurn[] = [{ id: 't1', role: 'user', no: 1, text: 'hi' }];
// Heavy children are irrelevant here — the toggle and navigator choice are
// ConversationPane's own template logic.
const stubs = {
TabBar: true,
ChatPane: true,
Composer: true,
TasksPane: true,
TasksCard: true,
TodoCard: true,
QuestionCard: true,
FileTree: true,
DiffView: true,
ChangedTree: true,
};
function mountFilesTab(gitInfo: { branch: string; ahead: number; behind: number } | null, changes: { path: string; status: string }[]) {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} },
missingWarn: false,
fallbackWarn: false,
});
const wrapper = mount(ConversationPane, {
props: { turns, tasks: [], status, gitInfo, changes },
global: { plugins: [i18n], stubs },
});
(wrapper.vm as unknown as { switchTab(tab: string): void }).switchTab('files');
return wrapper;
}
afterEach(() => {
document.body.innerHTML = '';
});
describe('files tab in a non-git workspace', () => {
it('hides the Changed|All toggle and shows the full file tree directly', async () => {
const wrapper = mountFilesTab(null, []);
await nextTick();
expect(wrapper.find('.seg-btn').exists()).toBe(false);
expect(wrapper.find('file-tree-stub').exists()).toBe(true);
expect(wrapper.find('changed-tree-stub').exists()).toBe(false);
});
it('with git info: shows the toggle and defaults to the Changed view', async () => {
const wrapper = mountFilesTab({ branch: 'main', ahead: 0, behind: 0 }, [
{ path: 'a.ts', status: 'modified' },
]);
await nextTick();
expect(wrapper.findAll('.seg-btn').length).toBe(2);
expect(wrapper.find('changed-tree-stub').exists() || wrapper.find('diff-view-stub').exists()).toBe(true);
expect(wrapper.find('file-tree-stub').exists()).toBe(false);
});
});

View file

@ -39,6 +39,11 @@
* Response data: FsGitStatusResponse { branch, ahead, behind, entries }
* Errors: 40401, 40908 (not a git repo), 41304
*
* POST /v1/sessions/{sid}/fs:diff
* Body: FsDiffRequest { path }
* Response data: FsDiffResponse { path, diff, truncated }
* Errors: 40401, 40409, 40908 (not a git repo), 41304
*
* GET /v1/sessions/{sid}/fs/{path}:download
* Response: binary stream or envelope (40401 / 40409 / 41304)
*
@ -228,6 +233,18 @@ export const fsGitStatusResponseSchema = z.object({
});
export type FsGitStatusResponse = z.infer<typeof fsGitStatusResponseSchema>;
export const fsDiffRequestSchema = z.object({
path: z.string().min(1),
});
export type FsDiffRequest = z.infer<typeof fsDiffRequestSchema>;
export const fsDiffResponseSchema = z.object({
path: z.string(),
diff: z.string(),
truncated: z.boolean(),
});
export type FsDiffResponse = z.infer<typeof fsDiffResponseSchema>;
export const fsDownloadParamsSchema = z.object({
path: z.string().min(1),
range: z.string().optional(),

View file

@ -4,6 +4,7 @@ import { createReadStream } from 'node:fs';
import {
ErrorCode,
fsDiffRequestSchema,
fsGitStatusRequestSchema,
fsGrepRequestSchema,
fsListManyRequestSchema,
@ -14,6 +15,7 @@ import {
fsSearchRequestSchema,
fsStatManyRequestSchema,
fsStatRequestSchema,
type FsDiffRequest,
type FsGitStatusRequest,
type FsGrepRequest,
type FsListManyRequest,
@ -101,6 +103,7 @@ const FS_ACTIONS = [
'search',
'grep',
'git_status',
'diff',
'open',
'reveal',
] as const;
@ -130,7 +133,7 @@ export function registerFsRoutes(
[ErrorCode.FS_GIT_UNAVAILABLE]: {},
},
description:
'Filesystem action dispatcher. Supported actions: list, read, list_many, stat, stat_many, search, grep, git_status, open, reveal.',
'Filesystem action dispatcher. Supported actions: list, read, list_many, stat, stat_many, search, grep, git_status, diff, open, reveal.',
tags: ['fs'],
operationId: 'fsAction',
},
@ -188,6 +191,9 @@ export function registerFsRoutes(
case 'git_status':
await handleGitStatus(ix, session_id, req, reply);
return;
case 'diff':
await handleDiff(ix, session_id, req, reply);
return;
case 'open':
await handleOpen(ix, session_id, req, reply);
return;
@ -463,6 +469,24 @@ async function handleGitStatus(
reply.send(okEnvelope(data, req.id));
}
async function handleDiff(
ix: IInstantiationService,
sessionId: string,
req: { id: string; body: unknown },
reply: { send(payload: unknown): unknown },
): Promise<void> {
const parsed = fsDiffRequestSchema.safeParse(req.body ?? {});
if (!parsed.success) {
reply.send(buildValidationEnvelope(parsed.error.issues, req.id));
return;
}
const body: FsDiffRequest = parsed.data;
const data = await ix.invokeFunction((a) =>
a.get(IFsGitService).diff(sessionId, body),
);
reply.send(okEnvelope(data, req.id));
}
async function handleOpen(
ix: IInstantiationService,
sessionId: string,

View file

@ -247,6 +247,158 @@ describe('POST /api/v1/sessions/{sid}/fs:git_status (W11.2)', () => {
});
});
describe('POST /api/v1/sessions/{sid}/fs:diff', () => {
it('modified file: unified diff with -old/+new lines', async () => {
initRepo();
writeFileSync(join(workspace, 'seed.txt'), 'changed\n');
const r = await bootDaemon();
const sid = await createSession(r);
const res = await appOf(r).inject({
method: 'POST',
url: `/api/v1/sessions/${sid}/fs:diff`,
payload: { path: 'seed.txt' },
});
const env = envelopeOf<{ path: string; diff: string; truncated: boolean }>(res.json());
expect(env.code).toBe(0);
expect(env.data!.path).toBe('seed.txt');
expect(env.data!.diff).toContain('-seed');
expect(env.data!.diff).toContain('+changed');
expect(env.data!.truncated).toBe(false);
});
it('untracked file: all-added diff against /dev/null', async () => {
initRepo();
writeFileSync(join(workspace, 'new.txt'), 'brand new\n');
const r = await bootDaemon();
const sid = await createSession(r);
const res = await appOf(r).inject({
method: 'POST',
url: `/api/v1/sessions/${sid}/fs:diff`,
payload: { path: 'new.txt' },
});
const env = envelopeOf<{ path: string; diff: string }>(res.json());
expect(env.code).toBe(0);
expect(env.data!.diff).toContain('+brand new');
expect(env.data!.diff).not.toContain('-brand new');
});
it('deleted file: all-removed diff', async () => {
initRepo();
rmSync(join(workspace, 'seed.txt'));
const r = await bootDaemon();
const sid = await createSession(r);
const res = await appOf(r).inject({
method: 'POST',
url: `/api/v1/sessions/${sid}/fs:diff`,
payload: { path: 'seed.txt' },
});
const env = envelopeOf<{ diff: string }>(res.json());
expect(env.code).toBe(0);
expect(env.data!.diff).toContain('-seed');
});
it('clean tracked file: empty diff', async () => {
initRepo();
const r = await bootDaemon();
const sid = await createSession(r);
const res = await appOf(r).inject({
method: 'POST',
url: `/api/v1/sessions/${sid}/fs:diff`,
payload: { path: 'seed.txt' },
});
const env = envelopeOf<{ diff: string }>(res.json());
expect(env.code).toBe(0);
expect(env.data!.diff).toBe('');
});
it('repo without commits: untracked file still diffs all-added', async () => {
git(['init', '-b', 'main']);
writeFileSync(join(workspace, 'first.txt'), 'first\n');
const r = await bootDaemon();
const sid = await createSession(r);
const res = await appOf(r).inject({
method: 'POST',
url: `/api/v1/sessions/${sid}/fs:diff`,
payload: { path: 'first.txt' },
});
const env = envelopeOf<{ diff: string }>(res.json());
expect(env.code).toBe(0);
expect(env.data!.diff).toContain('+first');
});
it('nonexistent path → 40409', async () => {
initRepo();
const r = await bootDaemon();
const sid = await createSession(r);
const res = await appOf(r).inject({
method: 'POST',
url: `/api/v1/sessions/${sid}/fs:diff`,
payload: { path: 'no-such-file.txt' },
});
const env = envelopeOf<null>(res.json());
expect(env.code).toBe(40409);
});
it('non-git workspace → 40908', async () => {
writeFileSync(join(workspace, 'plain.txt'), 'plain\n');
const r = await bootDaemon();
const sid = await createSession(r);
const res = await appOf(r).inject({
method: 'POST',
url: `/api/v1/sessions/${sid}/fs:diff`,
payload: { path: 'plain.txt' },
});
const env = envelopeOf<null>(res.json());
expect(env.code).toBe(40908);
});
it('path escaping cwd → 41304', async () => {
initRepo();
const r = await bootDaemon();
const sid = await createSession(r);
const res = await appOf(r).inject({
method: 'POST',
url: `/api/v1/sessions/${sid}/fs:diff`,
payload: { path: '../outside.txt' },
});
const env = envelopeOf<null>(res.json());
expect(env.code).toBe(41304);
});
it('missing path → 40001 validation', async () => {
initRepo();
const r = await bootDaemon();
const sid = await createSession(r);
const res = await appOf(r).inject({
method: 'POST',
url: `/api/v1/sessions/${sid}/fs:diff`,
payload: {},
});
const env = envelopeOf<null>(res.json());
expect(env.code).toBe(40001);
});
it('40401 unknown session', async () => {
const r = await bootDaemon();
const res = await appOf(r).inject({
method: 'POST',
url: '/api/v1/sessions/sess_does_not_exist/fs:diff',
payload: { path: 'seed.txt' },
});
const env = envelopeOf<null>(res.json());
expect(env.code).toBe(40401);
});
});
describe('parsePorcelain (W11.2)', () => {
it('parses a clean tree', () => {
const out = parsePorcelain('## main\n', undefined);

View file

@ -3,6 +3,8 @@ import path from 'node:path';
import { createDecorator, type IDisposable } from '@moonshot-ai/agent-core';
import type {
FsDiffRequest,
FsDiffResponse,
FsGitStatus,
FsGitStatusRequest,
FsGitStatusResponse,
@ -26,6 +28,8 @@ export interface IFsGitService extends IDisposable {
sessionId: string,
req: FsGitStatusRequest,
): Promise<FsGitStatusResponse>;
diff(sessionId: string, req: FsDiffRequest): Promise<FsDiffResponse>;
}
// eslint-disable-next-line @typescript-eslint/no-redeclare

View file

@ -4,12 +4,22 @@ import { spawn } from 'node:child_process';
import { promises as fs } from 'node:fs';
import { Disposable, InstantiationType, registerSingleton } from '@moonshot-ai/agent-core';
import type { FsGitStatusRequest, FsGitStatusResponse } from '@moonshot-ai/protocol';
import type {
FsDiffRequest,
FsDiffResponse,
FsGitStatusRequest,
FsGitStatusResponse,
} from '@moonshot-ai/protocol';
import { ISessionService } from '../session/session';
import { FsPathNotFoundError } from './fs';
import { IFsGitService, FsGitUnavailableError, parsePorcelain } from './fsGit';
import { resolveSafePath } from './fsPathSafety';
/** Cap a single file's unified diff (a runaway generated file should not blow
up the envelope); the response carries `truncated` so the UI can say so. */
const DIFF_MAX_BYTES = 1_048_576;
export class FsGitService extends Disposable implements IFsGitService {
readonly _serviceBrand: undefined;
@ -57,6 +67,86 @@ export class FsGitService extends Disposable implements IFsGitService {
return parsePorcelain(porcRes.stdout, filterSet);
}
async diff(sessionId: string, req: FsDiffRequest): Promise<FsDiffResponse> {
const session = await this.sessions.get(sessionId);
const cwd = session.metadata.cwd;
const realCwd = await fs.realpath(cwd);
const safe = await resolveSafePath(realCwd, req.path);
const rel = safe.relative;
const insideRes = await runCommand('git', ['rev-parse', '--is-inside-work-tree'], realCwd);
if (insideRes.exitCode !== 0 || insideRes.stdout.trim() !== 'true') {
throw new FsGitUnavailableError(
realCwd,
insideRes.stderr.trim() || `git rev-parse exit ${insideRes.exitCode}`,
);
}
const statusRes = await runCommand(
'git',
['status', '--porcelain=v1', '--', rel],
realCwd,
);
if (statusRes.exitCode !== 0) {
throw new FsGitUnavailableError(
realCwd,
statusRes.stderr.trim() || `git status exit ${statusRes.exitCode}`,
);
}
const untracked = statusRes.stdout.startsWith('??');
// A repo with no commits yet has no HEAD to diff against — every changed
// file is all-new there, same as the untracked case.
const headRes = await runCommand('git', ['rev-parse', '--verify', '--quiet', 'HEAD'], realCwd);
const hasHead = headRes.exitCode === 0;
// An untracked file has no HEAD side; diff it against /dev/null so the UI
// gets an all-added hunk. `git diff --no-index` exits 1 when files differ.
let diffRes: RunResult;
if (untracked || !hasHead) {
diffRes = await runCommand(
'git',
['diff', '--no-color', '--no-index', '--', '/dev/null', rel],
realCwd,
);
if (diffRes.exitCode !== 0 && diffRes.exitCode !== 1) {
throw new FsGitUnavailableError(
realCwd,
diffRes.stderr.trim() || `git diff exit ${diffRes.exitCode}`,
);
}
} else {
diffRes = await runCommand(
'git',
['diff', '--no-color', 'HEAD', '--', rel],
realCwd,
);
if (diffRes.exitCode !== 0) {
throw new FsGitUnavailableError(
realCwd,
diffRes.stderr.trim() || `git diff exit ${diffRes.exitCode}`,
);
}
if (diffRes.stdout.length === 0 && statusRes.stdout.length === 0) {
// Not changed at all — distinguish "clean file" (empty diff is fine)
// from a path that doesn't exist anywhere.
const exists = await fs
.stat(safe.absolute)
.then(() => true)
.catch(() => false);
if (!exists) throw new FsPathNotFoundError(req.path);
}
}
const full = diffRes.stdout;
const truncated = full.length > DIFF_MAX_BYTES;
return {
path: rel,
diff: truncated ? full.slice(0, DIFF_MAX_BYTES) : full,
truncated,
};
}
}
interface RunResult {