feat(web): show file/media preview as a split pane (peer of chat/files)

On desktop, opening a preview from a chat link/media now splits the layout and
shows it as a 'preview' view at the chat/files level (a transient tab in that
group, closeable via the group's close button) instead of a separate right-side
panel — matching the split buttons. Mobile keeps the full-screen side panel; the
preview view isn't persisted across reloads.
This commit is contained in:
qer 2026-06-14 16:25:29 +08:00
parent 3485e1037f
commit d9ba945fa9
9 changed files with 260 additions and 14 deletions

View file

@ -177,6 +177,8 @@ function normalizePreviewPath(inputPath: string): { path: string } | { error: st
async function openFilePreview(target: FilePreviewRequest): Promise<void> {
thinkingTarget.value = null; // shared right-side slot
compactionTarget.value = null;
// Desktop: surface the preview as a split pane (a peer of chat/files).
if (!isMobile.value) conversationPaneRef.value?.openPreviewPane();
const normalized = normalizePreviewPath(target.path);
previewTarget.value = target;
previewFile.value = null;
@ -213,6 +215,7 @@ function openMediaPreview(media: ToolMedia): void {
if (media.kind !== 'image') return;
thinkingTarget.value = null;
compactionTarget.value = null;
if (!isMobile.value) conversationPaneRef.value?.openPreviewPane();
previewRequestSeq++;
previewTarget.value = null;
previewError.value = null;
@ -303,8 +306,12 @@ function closeCompactionPanel(): void {
}
/** Any occupant of the shared right-side slot. */
// File/media preview lives in the split-pane system on desktop (a peer of
// chat/files); the right-side panel only hosts it on mobile, where there is no
// split layout. Thinking/compaction summaries stay in the side panel always.
const sidePreviewVisible = computed(() => previewVisible.value && isMobile.value);
const sidePanelVisible = computed(
() => previewVisible.value || thinkingVisible.value || compactionPanelVisible.value,
() => sidePreviewVisible.value || thinkingVisible.value || compactionPanelVisible.value,
);
/** True while the panel's resize handle is being dragged the width
@ -654,6 +661,15 @@ function openPr(url: string): void {
:active-workspace-id="client.activeWorkspaceId.value"
:session-title="activeSessionTitle"
:pr="null"
:preview-file="previewFile"
:preview-loading="previewLoading"
:preview-error="previewError"
:preview-line="previewTarget?.line"
:preview-download-url="previewDownloadUrl"
:preview-external-actions="previewExternalActions"
@close-preview="closeFilePreview"
@open-preview-external="openPreviewInEditor"
@reveal-preview="revealPreviewFile"
@select-workspace="handleCreateSessionInWorkspace($event)"
@open-in-app="(app) => client.openInApp(app)"
@add-workspace="showAddWorkspace = true"
@ -727,7 +743,7 @@ function openPr(url: string): void {
@close="closeCompactionPanel"
/>
<FilePreview
v-else-if="previewVisible"
v-else-if="sidePreviewVisible"
:file="previewFile"
:loading="previewLoading"
:error="previewError"

View file

@ -88,6 +88,15 @@ const props = defineProps<{
sessionTitle?: string;
/** GitHub PR for the current branch, when known (shown in the chat header). */
pr?: { number: number; state: string; url: string } | null;
// ---- Global preview pane (file/media opened from chat links) ------------
// Owned by App; rendered here as a split pane (a peer of chat/files) so the
// preview lives at the same level as the other views, not in a separate panel.
previewFile?: FileData | null;
previewLoading?: boolean;
previewError?: string | null;
previewLine?: number;
previewDownloadUrl?: string | null;
previewExternalActions?: boolean;
}>();
const emit = defineEmits<{
@ -116,6 +125,12 @@ const emit = defineEmits<{
openCompaction: [target: { turnId: string }];
/** Edit + resend the last user message (App undoes, then refills composer). */
editMessage: [text: string];
/** Preview pane: close it (App clears the preview state). */
closePreview: [];
/** Preview pane: open the previewed file in the external editor. */
openPreviewExternal: [];
/** Preview pane: reveal the previewed file in the OS file manager. */
revealPreview: [];
/** Empty-composer workspace picker: start a new conversation elsewhere. */
selectWorkspace: [workspaceId: string];
/** Empty-composer workspace picker: create a new workspace. */
@ -177,7 +192,29 @@ function switchTab(tab: PaneKey): void {
function loadComposerForEdit(value: string): void {
(dockedComposerRef.value ?? emptyComposerRef.value)?.loadForEdit(value);
}
defineExpose({ switchTab, loadComposerForEdit });
/** Open (or focus) the preview pane App calls this when a file/media preview
is requested, so the preview shows as a split pane at the chat/files level. */
function openPreviewPane(): void {
paneLayout.openPreview();
}
/** Close the preview pane and tell App to clear the preview state. */
function closePreviewPane(): void {
paneLayout.closePreview();
emit('closePreview');
}
// When App clears the preview (e.g. on a session switch), drop the now-empty
// preview pane. Watcher batching means the transient null state while a preview
// is opening (loading already true by then) never triggers this.
watch(
() => [props.previewFile, props.previewLoading, props.previewError] as const,
([file, loading, error]) => {
if (!file && !loading && !error) paneLayout.closePreview();
},
);
defineExpose({ switchTab, loadComposerForEdit, openPreviewPane });
function firstGroupId(node: PaneLayout): string | undefined {
if (node.type === 'group') return node.id;
@ -764,9 +801,10 @@ onUnmounted(() => {
:changes-count="changesCount"
:todos="todos ?? []"
:can-close="paneLayout.layout.value.type !== 'group'"
:has-preview="group.views.includes('preview')"
@select="selectGroupPane(group, $event)"
@split="paneLayout.split(group.id, $event)"
@close="paneLayout.close(group.id)"
@close="group.active === 'preview' ? closePreviewPane() : paneLayout.close(group.id)"
>
<div
:ref="group.active === 'chat' ? 'panesRef' : undefined"
@ -811,6 +849,21 @@ onUnmounted(() => {
v-else-if="group.active === 'terminal' && sessionId"
:session-id="sessionId"
/>
<!-- Preview pane: a file/media preview opened from a chat link, shown
here as a peer of chat/files instead of a separate side panel. -->
<FilePreview
v-else-if="group.active === 'preview'"
:file="previewFile ?? null"
:loading="previewLoading ?? false"
:error="previewError ?? null"
:line="previewLine"
:download-url="previewDownloadUrl ?? null"
closable
:external-actions="previewExternalActions"
@close="closePreviewPane"
@open-external="emit('openPreviewExternal')"
@reveal="emit('revealPreview')"
/>
<template v-else-if="group.active === 'files'">
<div v-show="!mobile || !filesShowPreview" class="files-nav">
<div v-if="hasGit" class="nav-seg">

View file

@ -1,20 +1,28 @@
<!-- apps/kimi-web/src/components/TabBar.vue -->
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import type { PaneKey, TodoView } from '../types';
defineProps<{ active: PaneKey; runningTasks: number; changesCount?: number; todos?: TodoView[]; mobile?: boolean }>();
const props = defineProps<{ active: PaneKey; runningTasks: number; changesCount?: number; todos?: TodoView[]; mobile?: boolean; hasPreview?: boolean }>();
const emit = defineEmits<{ select: [pane: PaneKey] }>();
const { t } = useI18n();
const tabs: { key: PaneKey; labelKey: string }[] = [
const BASE_TABS: { key: PaneKey; labelKey: string }[] = [
{ key: 'chat', labelKey: 'sidebar.tabChat' },
{ key: 'files', labelKey: 'sidebar.tabFiles' },
{ key: 'tasks', labelKey: 'sidebar.tabTasks' },
{ key: 'todo', labelKey: 'sidebar.tabTodo' },
{ key: 'terminal', labelKey: 'sidebar.tabTerminal' },
];
// 'preview' is a transient tab shown only while this group hosts a preview.
const tabs = computed(() =>
props.hasPreview
? [...BASE_TABS, { key: 'preview' as PaneKey, labelKey: 'sidebar.tabPreview' }]
: BASE_TABS,
);
</script>
<template>

View file

@ -8,6 +8,8 @@ defineProps<{
changesCount?: number;
todos?: TodoView[];
canClose?: boolean;
/** This group currently hosts a preview pane → show its 'preview' tab. */
hasPreview?: boolean;
}>();
const emit = defineEmits<{
@ -25,6 +27,7 @@ const emit = defineEmits<{
:running-tasks="runningTasks"
:changes-count="changesCount"
:todos="todos ?? []"
:has-preview="hasPreview"
@select="emit('select', $event)"
/>
<div class="view-actions">

View file

@ -19,6 +19,9 @@ export type PaneSplit = {
export type PaneLayout = PaneGroup | PaneSplit;
const STORAGE_KEY = 'kimi-web.layout';
// Default tab set for a group. 'preview' is intentionally NOT here — it's a
// transient view added to a group only while a file/media preview is open, so
// groups don't show an empty "Preview" tab the rest of the time.
const ALL_VIEWS: PaneKey[] = ['chat', 'files', 'tasks', 'todo', 'terminal'];
function nextId(prefix: string): string {
@ -30,17 +33,44 @@ function defaultGroup(active: PaneKey = 'chat'): PaneGroup {
}
function isPaneKey(value: unknown): value is PaneKey {
return value === 'chat' || value === 'files' || value === 'tasks' || value === 'todo' || value === 'terminal';
return (
value === 'chat' ||
value === 'files' ||
value === 'tasks' ||
value === 'todo' ||
value === 'terminal' ||
value === 'preview'
);
}
/** First group id in the tree (depth-first), or null for an empty tree. */
function firstGroupId(node: PaneLayout): string | null {
if (node.type === 'group') return node.id;
for (const child of node.children) {
const id = firstGroupId(child);
if (id) return id;
}
return null;
}
/** Whether any group already hosts the 'preview' view. */
function hasPreviewGroup(node: PaneLayout): boolean {
if (node.type === 'group') return node.views.includes('preview');
return node.children.some(hasPreviewGroup);
}
function normalizeLayout(raw: unknown): PaneLayout | null {
if (!raw || typeof raw !== 'object') return null;
const node = raw as Record<string, unknown>;
if (node['type'] === 'group') {
const active = isPaneKey(node['active']) ? node['active'] : 'chat';
const views = Array.isArray(node['views'])
? node['views'].filter(isPaneKey)
: ALL_VIEWS;
// Drop the transient 'preview' view on reload — its content isn't persisted,
// so a restored preview pane would be empty. A preview-only group collapses
// away entirely (return null) so the split that hosted it folds back.
const rawViews = Array.isArray(node['views']) ? node['views'].filter(isPaneKey) : ALL_VIEWS;
const views = rawViews.filter((v) => v !== 'preview');
if (rawViews.length > 0 && views.length === 0) return null;
const rawActive = isPaneKey(node['active']) ? node['active'] : 'chat';
const active = rawActive === 'preview' ? 'chat' : rawActive;
return {
type: 'group',
id: typeof node['id'] === 'string' ? node['id'] : nextId('group'),
@ -53,6 +83,7 @@ function normalizeLayout(raw: unknown): PaneLayout | null {
? node['children'].map(normalizeLayout).filter((item): item is PaneLayout => item !== null)
: [];
if (children.length === 0) return null;
if (children.length === 1) return children[0]!;
const sizes = Array.isArray(node['sizes']) && node['sizes'].length === children.length
? node['sizes'].map((size) => typeof size === 'number' && Number.isFinite(size) ? size : 1)
: children.map(() => 1);
@ -120,7 +151,11 @@ export function usePaneLayout() {
}
function setActive(groupId: string, active: PaneKey): void {
commit(updateGroup(layout.value, groupId, (group) => ({ ...group, active })));
commit(updateGroup(layout.value, groupId, (group) => ({
...group,
views: group.views.includes(active) ? group.views : [...group.views, active],
active,
})));
}
function split(groupId: string, dir: 'row' | 'col'): void {
@ -151,5 +186,68 @@ export function usePaneLayout() {
commit(defaultGroup());
}
return { layout, setActive, split, close, resize, reset };
/** Open (or focus) a 'preview' pane at the chat/files level. If no preview
group exists yet, split the first group and give the new one a 'preview'
view; otherwise just make the existing preview group active. */
function openPreview(): void {
if (hasPreviewGroup(layout.value)) {
commit(
mapGroups(layout.value, (group) =>
group.views.includes('preview') ? { ...group, active: 'preview' } : group,
),
);
return;
}
const targetId = firstGroupId(layout.value);
if (!targetId) {
commit({ type: 'group', id: nextId('group'), views: ['preview'], active: 'preview' });
return;
}
commit(
updateGroup(layout.value, targetId, (group) => ({
type: 'split',
id: nextId('split'),
dir: 'row',
children: [group, { type: 'group', id: nextId('group'), views: ['preview'], active: 'preview' }],
sizes: [1, 1],
})),
);
}
/** Close any preview pane: a preview-only group collapses its split; a group
that also holds other views just switches away from preview. */
function closePreview(): void {
if (!hasPreviewGroup(layout.value)) return;
let next = layout.value;
// Collapse preview-only groups (they exist only for the preview).
const previewOnlyIds: string[] = [];
function collect(node: PaneLayout): void {
if (node.type === 'group') {
if (node.views.length === 1 && node.views[0] === 'preview') previewOnlyIds.push(node.id);
} else {
node.children.forEach(collect);
}
}
collect(next);
for (const id of previewOnlyIds) next = removeGroup(next, id);
// Any remaining group still listing 'preview' (mixed group) drops it.
next = mapGroups(next, (group) =>
group.views.includes('preview')
? {
...group,
views: group.views.filter((v) => v !== 'preview'),
active: group.active === 'preview' ? 'chat' : group.active,
}
: group,
);
commit(next);
}
return { layout, setActive, split, close, resize, reset, openPreview, closePreview };
}
/** Apply `fn` to every group in the tree. */
function mapGroups(layout: PaneLayout, fn: (group: PaneGroup) => PaneGroup): PaneLayout {
if (layout.type === 'group') return fn(layout);
return { ...layout, children: layout.children.map((child) => mapGroups(child, fn)) };
}

View file

@ -26,6 +26,7 @@ export default {
tabTasks: 'tasks',
tabTodo: 'todo',
tabTerminal: 'terminal',
tabPreview: 'preview',
noSessions: 'No conversations yet',
showMore: 'Show more ({count})',
} as const;

View file

@ -26,6 +26,7 @@ export default {
tabTasks: '后台任务',
tabTodo: '待办',
tabTerminal: '终端',
tabPreview: '预览',
noSessions: '暂无对话',
showMore: '展开更多 ({count})',
};

View file

@ -206,7 +206,7 @@ export interface ConversationStatus {
// ~/diff and ~/files were merged into a single ~/files tab (changed-first list +
// a Changed|All toggle + an adaptive content pane: diff for changed files, content
// preview for unchanged ones). 'diff' is gone; 'files' is the merged key.
export type PaneKey = 'chat' | 'files' | 'tasks' | 'todo' | 'terminal';
export type PaneKey = 'chat' | 'files' | 'tasks' | 'todo' | 'terminal' | 'preview';
export interface ActivationBadges {
plan: boolean;

View file

@ -0,0 +1,66 @@
// apps/kimi-web/test/pane-layout-preview.test.ts
//
// The preview pane is a transient view that lives at the same level as
// chat/files: opening it splits the layout (like the split button); closing it
// collapses the split back.
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { usePaneLayout, type PaneLayout, type PaneGroup } from '../src/composables/usePaneLayout';
function groups(node: PaneLayout): PaneGroup[] {
return node.type === 'group' ? [node] : node.children.flatMap(groups);
}
beforeEach(() => {
try { localStorage.clear(); } catch { /* ignore */ }
});
afterEach(() => {
try { localStorage.clear(); } catch { /* ignore */ }
});
describe('usePaneLayout preview pane', () => {
it('opens the preview as a split peer and closes back to one group', () => {
const pl = usePaneLayout();
// Default: a single chat group.
expect(pl.layout.value.type).toBe('group');
pl.openPreview();
// Now a split with a dedicated preview group alongside the original.
expect(pl.layout.value.type).toBe('split');
const previewGroups = groups(pl.layout.value).filter((g) => g.views.includes('preview'));
expect(previewGroups).toHaveLength(1);
expect(previewGroups[0]!.active).toBe('preview');
pl.closePreview();
// Collapses back to a single (non-preview) group.
expect(pl.layout.value.type).toBe('group');
expect(groups(pl.layout.value).some((g) => g.views.includes('preview'))).toBe(false);
});
it('focuses the existing preview group instead of splitting again', () => {
const pl = usePaneLayout();
pl.openPreview();
const afterFirst = JSON.parse(JSON.stringify(pl.layout.value));
pl.openPreview();
// No new split — still exactly one preview group.
expect(groups(pl.layout.value).filter((g) => g.views.includes('preview'))).toHaveLength(1);
// Same split structure (id unchanged).
expect((pl.layout.value as { id: string }).id).toBe(afterFirst.id);
});
it('keeps a preview split group when the user switches it to another tab', () => {
const pl = usePaneLayout();
pl.openPreview();
const previewGroup = groups(pl.layout.value).find((g) => g.views.includes('preview'));
expect(previewGroup).toBeDefined();
pl.setActive(previewGroup!.id, 'files');
pl.closePreview();
const remainingGroups = groups(pl.layout.value);
expect(remainingGroups).toHaveLength(2);
expect(remainingGroups.some((g) => g.views.includes('preview'))).toBe(false);
expect(remainingGroups.some((g) => g.active === 'files')).toBe(true);
});
});