diff --git a/apps/kimi-web/src/App.vue b/apps/kimi-web/src/App.vue index 9c8a6cb5b..b17b8455d 100644 --- a/apps/kimi-web/src/App.vue +++ b/apps/kimi-web/src/App.vue @@ -177,6 +177,8 @@ function normalizePreviewPath(inputPath: string): { path: string } | { error: st async function openFilePreview(target: FilePreviewRequest): Promise { 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" /> (); 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)" >
{ v-else-if="group.active === 'terminal' && sessionId" :session-id="sessionId" /> + +