mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-10 01:39:25 +00:00
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:
parent
3485e1037f
commit
d9ba945fa9
9 changed files with 260 additions and 14 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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)) };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export default {
|
|||
tabTasks: 'tasks',
|
||||
tabTodo: 'todo',
|
||||
tabTerminal: 'terminal',
|
||||
tabPreview: 'preview',
|
||||
noSessions: 'No conversations yet',
|
||||
showMore: 'Show more ({count})',
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export default {
|
|||
tabTasks: '后台任务',
|
||||
tabTodo: '待办',
|
||||
tabTerminal: '终端',
|
||||
tabPreview: '预览',
|
||||
noSessions: '暂无对话',
|
||||
showMore: '展开更多 ({count})',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
66
apps/kimi-web/test/pane-layout-preview.test.ts
Normal file
66
apps/kimi-web/test/pane-layout-preview.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue