From 999faf36a68a6bab794e49335db61b8e84770545 Mon Sep 17 00:00:00 2001 From: yovinchen Date: Mon, 17 Aug 2026 00:57:42 +0800 Subject: [PATCH 01/76] =?UTF-8?q?style(chat):=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E9=A2=84=E8=A7=88=E7=9B=B8=E5=85=B3=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E7=9A=84=20prettier=20=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/ComposerAttachmentCard.tsx | 68 +- .../src/components/chat/ImagePreview.tsx | 684 ++++++++++-------- .../components/chat/UserAttachmentCards.tsx | 19 +- .../chat/assistant-bubble/ToolImages.tsx | 10 +- .../src/components/chat/imagePreviewModel.ts | 10 +- 5 files changed, 420 insertions(+), 371 deletions(-) diff --git a/crates/agent-ui/src/components/chat/ComposerAttachmentCard.tsx b/crates/agent-ui/src/components/chat/ComposerAttachmentCard.tsx index b82383f6c..adb1a6af5 100644 --- a/crates/agent-ui/src/components/chat/ComposerAttachmentCard.tsx +++ b/crates/agent-ui/src/components/chat/ComposerAttachmentCard.tsx @@ -44,36 +44,37 @@ export function ComposerAttachmentCard(props: { src: string | null; status: "loaded" | "error"; } | null>(null); - const previewSlides = useMemo( - () => { - if (!imageSrc) return []; - const workdir = workspaceRoot?.trim() ?? ""; - const absolutePath = file?.absolutePath?.trim() ?? ""; - const relativePath = file?.relativePath.trim() ?? ""; - return [ - { - src: imageSrc, - alt: fileName, - title: fileName, - fileName, - sizeBytes: file?.sizeBytes, - ...(file && workdir && absolutePath && relativePath - ? { - attachment: { - workdir, - absolutePath, - relativePath, - }, - } - : {}), - }, - ]; - }, - [file, fileName, imageSrc, workspaceRoot], - ); + const previewSlides = useMemo(() => { + if (!imageSrc) return []; + const workdir = workspaceRoot?.trim() ?? ""; + const absolutePath = file?.absolutePath?.trim() ?? ""; + const relativePath = file?.relativePath.trim() ?? ""; + return [ + { + src: imageSrc, + alt: fileName, + title: fileName, + fileName, + sizeBytes: file?.sizeBytes, + ...(file && workdir && absolutePath && relativePath + ? { + attachment: { + workdir, + absolutePath, + relativePath, + }, + } + : {}), + }, + ]; + }, [file, fileName, imageSrc, workspaceRoot]); const previewSlide = previewSlides[0]; - const imageLoadFailed = Boolean(imageSrc && imageLoadState?.src === imageSrc && imageLoadState.status === "error"); - const canPreview = Boolean(imageSrc && imageLoadState?.src === imageSrc && imageLoadState.status === "loaded"); + const imageLoadFailed = Boolean( + imageSrc && imageLoadState?.src === imageSrc && imageLoadState.status === "error", + ); + const canPreview = Boolean( + imageSrc && imageLoadState?.src === imageSrc && imageLoadState.status === "loaded", + ); // 图片附件:纯缩略图方块,点击放大预览,文件名放悬浮提示,角标删除。 if (imageSrc || isImageLoading) { @@ -112,7 +113,9 @@ export function ComposerAttachmentCard(props: { /> ) : imageLoadFailed ? ( - {fallbackIcon} + + {fallbackIcon} + ) : ( )} @@ -143,10 +146,7 @@ export function ComposerAttachmentCard(props: { onActionError={setActionError} /> ) : null} - setActionError(null)} - /> + setActionError(null)} /> ); } diff --git a/crates/agent-ui/src/components/chat/ImagePreview.tsx b/crates/agent-ui/src/components/chat/ImagePreview.tsx index c82697e49..a58a056b6 100644 --- a/crates/agent-ui/src/components/chat/ImagePreview.tsx +++ b/crates/agent-ui/src/components/chat/ImagePreview.tsx @@ -71,7 +71,9 @@ type ImagePreviewProps = { }; type MenuPosition = { x: number; y: number }; -type ImagePreviewDataResolver = (slide: ImagePreviewSlide) => ReturnType; +type ImagePreviewDataResolver = ( + slide: ImagePreviewSlide, +) => ReturnType; function toMessage(error: unknown, fallback: string) { if (error instanceof Error && error.message.trim()) return error.message; @@ -91,7 +93,10 @@ function formatDimensions(size: ImageViewerSize) { return size.width > 0 && size.height > 0 ? `${size.width} x ${size.height}` : "-"; } -function imageViewerAnchor(event: { clientX: number; clientY: number }, viewport: HTMLElement | null) { +function imageViewerAnchor( + event: { clientX: number; clientY: number }, + viewport: HTMLElement | null, +) { const rect = viewport?.getBoundingClientRect(); if (!rect) return { x: 0, y: 0 }; return { @@ -342,13 +347,17 @@ export function ImagePreviewContextMenu(props: { ) : null} {capabilities.canSave ? ( - run(() => saveImagePreviewSlide(slide), t("chat.imageViewer.saveFailed"))}> + run(() => saveImagePreviewSlide(slide), t("chat.imageViewer.saveFailed"))} + > {t("chat.imageViewer.save")} ) : null} {capabilities.canCopyImage ? ( - run(() => copyImagePreviewSlide(slide), t("chat.imageViewer.copyFailed"))}> + run(() => copyImagePreviewSlide(slide), t("chat.imageViewer.copyFailed"))} + > {t("chat.imageViewer.copy")} @@ -649,9 +658,7 @@ export const ImagePreview = memo(function ImagePreview(props: ImagePreviewProps) }; return createPortal( -
+
- {imageCount > 1 ? ( - <> - setActiveImage(clampedIndex - 1)} - > - - + {imageCount > 1 ? ( + <> + setActiveImage(clampedIndex - 1)} + > + + + setActiveImage(clampedIndex + 1)} + > + + + + {clampedIndex + 1} / {imageCount} + + + ) : null} +
+
+ zoomByStep(-1)} + > + + + + {Math.round(viewerState.scale * 100)}% + + = IMAGE_VIEWER_MAX_SCALE} + onClick={() => zoomByStep(1)} + > + + + rotateImage(-1)} + > + + + rotateImage(1)} + > + + + setViewerState(resetImageViewerState())} + > + + + void saveImage()} + > + {isSaving ? ( + + ) : ( + + )} + + {capabilities?.canOpenSystem ? ( setActiveImage(clampedIndex + 1)} + label={t("chat.imageViewer.openSystem")} + onClick={() => void openSystemViewer()} > - + - - {clampedIndex + 1} / {imageCount} - - - ) : null} -
-
- zoomByStep(-1)} - > - - - - {Math.round(viewerState.scale * 100)}% - - = IMAGE_VIEWER_MAX_SCALE} - onClick={() => zoomByStep(1)} - > - - - rotateImage(-1)}> - - - rotateImage(1)}> - - - setViewerState(resetImageViewerState())} - > - - - void saveImage()} - > - {isSaving ? : } - - {capabilities?.canOpenSystem ? ( - void openSystemViewer()}> - + ) : null} + void copyImage()} + > + {isCopying ? ( + + ) : ( + + )} + + setShowInfo((current) => !current)} + > + + + void handleFullscreen()} + > + {isFullscreen ? : } + + + - ) : null} - void copyImage()} - > - {isCopying ? : } - - setShowInfo((current) => !current)} - > - - - void handleFullscreen()} - > - {isFullscreen ? : } - - - - -
-
-
{ - if (event.deltaY === 0) return; - event.preventDefault(); - zoomByWheel(event.deltaY, event.deltaMode, imageViewerAnchor(event, viewportRef.current)); - }} - onPointerDown={(event) => { - if (contextMenu) { - setContextMenu(null); - return; - } - if (event.button !== 0 || !canPan) return; - event.currentTarget.setPointerCapture(event.pointerId); - dragRef.current = { - pointerId: event.pointerId, - startX: event.clientX, - startY: event.clientY, - originX: viewerState.x, - originY: viewerState.y, - }; - setIsDragging(true); - }} - onPointerMove={(event) => { - const drag = dragRef.current; - if (!drag || drag.pointerId !== event.pointerId) return; - setViewerState((current) => ({ - ...current, - ...clampImageViewerPan( - { - x: drag.originX + event.clientX - drag.startX, - y: drag.originY + event.clientY - drag.startY, - }, - { ...viewerOptions, scale: current.scale, rotation: current.rotation }, - ), - })); - }} - onPointerUp={(event) => { - if (dragRef.current?.pointerId !== event.pointerId) return; - dragRef.current = null; - setIsDragging(false); - event.currentTarget.releasePointerCapture(event.pointerId); - }} - onPointerCancel={() => { - dragRef.current = null; - setIsDragging(false); - }} - onContextMenu={(event) => { - event.preventDefault(); - setContextMenu({ x: event.clientX, y: event.clientY }); - }} - > -
-
-
- {slide.alt { - setNaturalSize({ - width: event.currentTarget.naturalWidth, - height: event.currentTarget.naturalHeight, - }); - if ( - supportsDirectUploadedImageCopy && - getImagePreviewMimeType(slide) !== "image/svg+xml" && - isVerifiedImagePreviewAttachment(slide.attachment) - ) { - void prepareUploadedImagePreviewCopy({ - workdir: slide.attachment.workdir, - absolutePath: slide.attachment.absolutePath, - }).catch(() => undefined); - } - if (hasInlineImageData) void resolveCachedImageData(slide); - }} - onError={() => setActionError(t("chat.imageViewer.unavailable"))} - /> -
- {actionError ? ( -
- {actionError} -
- ) : null} - {showInfo ? ( -
-
-
{t("chat.imageViewer.infoPanel")}
- + {slide.alt { + setNaturalSize({ + width: event.currentTarget.naturalWidth, + height: event.currentTarget.naturalHeight, + }); + if ( + supportsDirectUploadedImageCopy && + getImagePreviewMimeType(slide) !== "image/svg+xml" && + isVerifiedImagePreviewAttachment(slide.attachment) + ) { + void prepareUploadedImagePreviewCopy({ + workdir: slide.attachment.workdir, + absolutePath: slide.attachment.absolutePath, + }).catch(() => undefined); + } + if (hasInlineImageData) void resolveCachedImageData(slide); + }} + onError={() => setActionError(t("chat.imageViewer.unavailable"))} + /> +
-
-
{t("chat.imageViewer.fileName")}
-
- {getImagePreviewDisplayName(slide)} -
-
{t("chat.imageViewer.dimensions")}
-
{formatDimensions(naturalSize)}
-
{t("chat.imageViewer.fileSize")}
-
{formatBytes(slide.sizeBytes)}
-
{t("chat.imageViewer.fileType")}
-
- {getImagePreviewMimeType(slide)} -
- {capabilities?.canCopyPaths && verifiedAttachment ? ( - <> -
{t("chat.imageViewer.absolutePath")}
-
- {verifiedAttachment.absolutePath} -
-
{t("chat.imageViewer.relativePath")}
-
- {verifiedAttachment.relativePath} -
- - ) : null} -
- ) : null} - {contextMenu ? ( - setContextMenu(null)} - onActionError={setActionError} - > - { - zoomByStep(-1); - setContextMenu(null); - }} - > - - {t("chat.imageViewer.zoomOut")} - - = IMAGE_VIEWER_MAX_SCALE} - onClick={() => { - zoomByStep(1); - setContextMenu(null); - }} - > - - {t("chat.imageViewer.zoomIn")} - - { - setViewerState(resetImageViewerState()); - setContextMenu(null); - }} - > - - {t("chat.imageViewer.reset")} - - { - rotateImage(-1); - setContextMenu(null); - }} + {actionError ? ( +
- - {t("chat.imageViewer.rotateLeft")} - - { - rotateImage(1); - setContextMenu(null); - }} - > - - {t("chat.imageViewer.rotateRight")} - - { - setShowInfo(true); - setContextMenu(null); - }} + {actionError} +
+ ) : null} + {showInfo ? ( +
- - {t("chat.imageViewer.info")} - - { - void handleFullscreen(); - setContextMenu(null); - }} +
+
{t("chat.imageViewer.infoPanel")}
+ +
+
+
{t("chat.imageViewer.fileName")}
+
+ {getImagePreviewDisplayName(slide)} +
+
{t("chat.imageViewer.dimensions")}
+
{formatDimensions(naturalSize)}
+
{t("chat.imageViewer.fileSize")}
+
{formatBytes(slide.sizeBytes)}
+
{t("chat.imageViewer.fileType")}
+
+ {getImagePreviewMimeType(slide)} +
+ {capabilities?.canCopyPaths && verifiedAttachment ? ( + <> +
{t("chat.imageViewer.absolutePath")}
+
+ {verifiedAttachment.absolutePath} +
+
{t("chat.imageViewer.relativePath")}
+
+ {verifiedAttachment.relativePath} +
+ + ) : null} +
+
+ ) : null} + {contextMenu ? ( + setContextMenu(null)} + onActionError={setActionError} > - {isFullscreen ? : } - {t(isFullscreen ? "chat.imageViewer.exitFullscreen" : "chat.imageViewer.fullscreen")} -
-
- ) : null} + { + zoomByStep(-1); + setContextMenu(null); + }} + > + + {t("chat.imageViewer.zoomOut")} + + = IMAGE_VIEWER_MAX_SCALE} + onClick={() => { + zoomByStep(1); + setContextMenu(null); + }} + > + + {t("chat.imageViewer.zoomIn")} + + { + setViewerState(resetImageViewerState()); + setContextMenu(null); + }} + > + + {t("chat.imageViewer.reset")} + + { + rotateImage(-1); + setContextMenu(null); + }} + > + + {t("chat.imageViewer.rotateLeft")} + + { + rotateImage(1); + setContextMenu(null); + }} + > + + {t("chat.imageViewer.rotateRight")} + + { + setShowInfo(true); + setContextMenu(null); + }} + > + + {t("chat.imageViewer.info")} + + { + void handleFullscreen(); + setContextMenu(null); + }} + > + {isFullscreen ? ( + + ) : ( + + )} + {t( + isFullscreen ? "chat.imageViewer.exitFullscreen" : "chat.imageViewer.fullscreen", + )} + + + ) : null}
, diff --git a/crates/agent-ui/src/components/chat/UserAttachmentCards.tsx b/crates/agent-ui/src/components/chat/UserAttachmentCards.tsx index 998a76dc4..485377535 100644 --- a/crates/agent-ui/src/components/chat/UserAttachmentCards.tsx +++ b/crates/agent-ui/src/components/chat/UserAttachmentCards.tsx @@ -75,16 +75,17 @@ function UserImageAttachmentCard(props: { } | null>(null); const labeledPreview = `${previewLabel}: ${file.fileName}`; const FallbackIcon = getUploadedFileTypeIcon(file); - const previewSlides = useMemo( - () => { - const slide = createUserAttachmentImagePreviewSlide(file, imageSrc, workspaceRoot); - return slide ? [slide] : []; - }, - [file, imageSrc, workspaceRoot], - ); + const previewSlides = useMemo(() => { + const slide = createUserAttachmentImagePreviewSlide(file, imageSrc, workspaceRoot); + return slide ? [slide] : []; + }, [file, imageSrc, workspaceRoot]); const previewSlide = previewSlides[0]; - const imageLoadFailed = Boolean(imageSrc && imageLoadState?.src === imageSrc && imageLoadState.status === "error"); - const canPreview = Boolean(imageSrc && imageLoadState?.src === imageSrc && imageLoadState.status === "loaded"); + const imageLoadFailed = Boolean( + imageSrc && imageLoadState?.src === imageSrc && imageLoadState.status === "error", + ); + const canPreview = Boolean( + imageSrc && imageLoadState?.src === imageSrc && imageLoadState.status === "loaded", + ); return (
) : null} - setActionError(null)} - /> + setActionError(null)} /> ); } @@ -678,10 +675,7 @@ export function NativeDisplayImageBlock(props: { onActionError={setActionError} /> ) : null} - setActionError(null)} - /> + setActionError(null)} /> ); } diff --git a/crates/agent-ui/src/components/chat/imagePreviewModel.ts b/crates/agent-ui/src/components/chat/imagePreviewModel.ts index a1cd76268..bacf628f1 100644 --- a/crates/agent-ui/src/components/chat/imagePreviewModel.ts +++ b/crates/agent-ui/src/components/chat/imagePreviewModel.ts @@ -198,10 +198,7 @@ export function isVerifiedImagePreviewAttachment( ); } -export function getImagePreviewCapabilities( - slide: ImagePreviewSlide, - supportsSystemOpen: boolean, -) { +export function getImagePreviewCapabilities(slide: ImagePreviewSlide, supportsSystemOpen: boolean) { const hasSource = Boolean(slide.src.trim() || slide.dataBase64?.trim()); const hasAttachment = isVerifiedImagePreviewAttachment(slide.attachment); return { @@ -295,7 +292,10 @@ export function fitImageViewerSize( return { width: 0, height: 0 }; } - const rotatedSize = rotatedImageViewerSize({ width: naturalWidth, height: naturalHeight }, rotation); + const rotatedSize = rotatedImageViewerSize( + { width: naturalWidth, height: naturalHeight }, + rotation, + ); const ratio = Math.min(viewportWidth / rotatedSize.width, viewportHeight / rotatedSize.height); return { width: naturalWidth * ratio, height: naturalHeight * ratio }; } From bd7d9595b3184c295ed1a8b4829871d0e598a743 Mon Sep 17 00:00:00 2001 From: yovinchen Date: Mon, 17 Aug 2026 01:14:23 +0800 Subject: [PATCH 02/76] =?UTF-8?q?feat(ui):=20=E6=96=B0=E5=A2=9E=20Session?= =?UTF-8?q?=20Workbench=20=E5=B8=83=E5=B1=80=E5=86=85=E6=A0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pane 树类型与不变量校验(invariants) - 分屏几何、邻接关系与命中测试(geometry/adjacency/hitTesting) - 布局命令 reducer 与序列化编解码(commands/reducer/codec) - 内部特性开关 readInternalFeatureFlag,默认关闭 --- .../agent-ui/src/lib/workbench/adjacency.ts | 55 ++ crates/agent-ui/src/lib/workbench/codec.ts | 277 +++++++++ crates/agent-ui/src/lib/workbench/commands.ts | 60 ++ .../src/lib/workbench/featureFlags.ts | 13 + crates/agent-ui/src/lib/workbench/geometry.ts | 149 +++++ .../agent-ui/src/lib/workbench/hitTesting.ts | 184 ++++++ crates/agent-ui/src/lib/workbench/index.ts | 9 + .../agent-ui/src/lib/workbench/invariants.ts | 289 +++++++++ crates/agent-ui/src/lib/workbench/reducer.ts | 554 ++++++++++++++++++ crates/agent-ui/src/lib/workbench/types.ts | 121 ++++ 10 files changed, 1711 insertions(+) create mode 100644 crates/agent-ui/src/lib/workbench/adjacency.ts create mode 100644 crates/agent-ui/src/lib/workbench/codec.ts create mode 100644 crates/agent-ui/src/lib/workbench/commands.ts create mode 100644 crates/agent-ui/src/lib/workbench/featureFlags.ts create mode 100644 crates/agent-ui/src/lib/workbench/geometry.ts create mode 100644 crates/agent-ui/src/lib/workbench/hitTesting.ts create mode 100644 crates/agent-ui/src/lib/workbench/index.ts create mode 100644 crates/agent-ui/src/lib/workbench/invariants.ts create mode 100644 crates/agent-ui/src/lib/workbench/reducer.ts create mode 100644 crates/agent-ui/src/lib/workbench/types.ts diff --git a/crates/agent-ui/src/lib/workbench/adjacency.ts b/crates/agent-ui/src/lib/workbench/adjacency.ts new file mode 100644 index 000000000..e35689315 --- /dev/null +++ b/crates/agent-ui/src/lib/workbench/adjacency.ts @@ -0,0 +1,55 @@ +import type { WorkbenchGeometry, WorkbenchRect } from "./geometry"; +import type { WorkbenchEdge } from "./types"; + +function perpendicularOverlap(a: WorkbenchRect, b: WorkbenchRect, horizontal: boolean): number { + if (horizontal) { + return Math.min(a.top + a.height, b.top + b.height) - Math.max(a.top, b.top); + } + return Math.min(a.left + a.width, b.left + b.width) - Math.max(a.left, b.left); +} + +/** + * Find the spatially adjacent pane in `direction` for keyboard focus moves. + * Candidates must start past the source rect in that direction; the winner is + * the nearest one, with perpendicular overlap breaking ties. + */ +export function findAdjacentPaneId( + geometry: WorkbenchGeometry, + fromPaneId: string, + direction: WorkbenchEdge, +): string | null { + const from = geometry.panes.find((pane) => pane.paneId === fromPaneId); + if (!from) return null; + const horizontal = direction === "left" || direction === "right"; + + let bestPaneId: string | null = null; + let bestDistance = Number.POSITIVE_INFINITY; + let bestOverlap = Number.NEGATIVE_INFINITY; + for (const pane of geometry.panes) { + if (pane.paneId === fromPaneId) continue; + let distance: number; + switch (direction) { + case "left": + distance = from.rect.left - (pane.rect.left + pane.rect.width); + break; + case "right": + distance = pane.rect.left - (from.rect.left + from.rect.width); + break; + case "top": + distance = from.rect.top - (pane.rect.top + pane.rect.height); + break; + case "bottom": + distance = pane.rect.top - (from.rect.top + from.rect.height); + break; + } + if (distance < 0) continue; + const overlap = perpendicularOverlap(from.rect, pane.rect, horizontal); + if (overlap <= 0) continue; + if (distance < bestDistance || (distance === bestDistance && overlap > bestOverlap)) { + bestPaneId = pane.paneId; + bestDistance = distance; + bestOverlap = overlap; + } + } + return bestPaneId; +} diff --git a/crates/agent-ui/src/lib/workbench/codec.ts b/crates/agent-ui/src/lib/workbench/codec.ts new file mode 100644 index 000000000..b1912235c --- /dev/null +++ b/crates/agent-ui/src/lib/workbench/codec.ts @@ -0,0 +1,277 @@ +import { clampSplitRatio } from "./geometry"; +import { collectWorkbenchLayoutIssues } from "./invariants"; +import { + createEmptyWorkbenchLayout, + type PaneNode, + type PaneRecord, + type ProjectRef, + surfaceIdentityKey, + WORKBENCH_LAYOUT_SCHEMA_VERSION, + type WorkbenchLayout, + type WorkbenchSurfaceSpec, +} from "./types"; + +export type WorkbenchLayoutDecodeResult = + | { ok: true; layout: WorkbenchLayout; repaired: boolean } + | { ok: false; reason: "corrupted-json" | "unsupported-schema" | "unrecoverable" }; + +export function encodeWorkbenchLayout(layout: WorkbenchLayout): string { + // Unsupported passthrough surfaces serialize as their original raw payload, + // so a newer build that understands the kind gets its record back intact. + let panes: WorkbenchLayout["panes"] = layout.panes; + if (Object.values(layout.panes).some((pane) => pane.surface.kind === "unsupported")) { + const rewritten: Record = {}; + for (const [paneId, pane] of Object.entries(layout.panes)) { + rewritten[paneId] = + pane.surface.kind === "unsupported" ? { ...pane, surface: pane.surface.raw } : pane; + } + panes = rewritten as WorkbenchLayout["panes"]; + } + return JSON.stringify({ + schemaVersion: layout.schemaVersion, + revision: layout.revision, + root: layout.root, + panes, + focusedPaneId: layout.focusedPaneId, + }); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readString(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value : null; +} + +function readProjectRef(value: unknown): ProjectRef | null { + if (!isRecord(value)) return null; + const projectId = readString(value.projectId); + const projectPathKey = readString(value.projectPathKey); + if (!projectId || !projectPathKey) return null; + return { projectId, projectPathKey }; +} + +function readSurface(value: unknown): WorkbenchSurfaceSpec | null { + if (!isRecord(value)) return null; + const kind = readString(value.kind); + if (!kind) return null; + + if (kind === "conversation") { + const conversationId = readString(value.conversationId); + if (!conversationId) return null; + const project = readProjectRef(value.project); + if (!project) return null; + return { kind: "conversation", conversationId, project }; + } + + if (kind === "localTerminal" || kind === "sshTerminal") { + const surfaceId = readString(value.surfaceId); + if (!surfaceId) return null; + const project = readProjectRef(value.project); + if (!project) return null; + const launchSpec = value.launchSpec; + if (!isRecord(launchSpec)) return null; + const cwd = readString(launchSpec.cwd); + if (!cwd) return null; + const shell = readString(launchSpec.shell) ?? undefined; + const title = readString(launchSpec.title) ?? undefined; + if (kind === "localTerminal") { + return { + kind: "localTerminal", + surfaceId, + project, + launchSpec: { cwd, ...(shell ? { shell } : {}), ...(title ? { title } : {}) }, + }; + } + const sshHostId = readString(launchSpec.sshHostId); + if (!sshHostId) return null; + return { + kind: "sshTerminal", + surfaceId, + project, + launchSpec: { + cwd, + sshHostId, + ...(title ? { title } : {}), + ...(launchSpec.sftpEnabled === true ? { sftpEnabled: true } : {}), + }, + }; + } + + if (kind === "unsupported") { + // Re-decoding a previously passed-through record: keep the original kind + // and raw payload instead of double-wrapping. + const originalKind = readString(value.originalKind); + const raw = value.raw; + if (!originalKind || !isRecord(raw)) return null; + return { kind: "unsupported", originalKind, raw }; + } + + // Unknown kind from a newer build: preserve it verbatim so a round-trip + // through this build never destroys the pane. Not a repair. + return { kind: "unsupported", originalKind: kind, raw: value }; +} + +function readPaneRecord(paneId: string, value: unknown): PaneRecord | null { + if (!isRecord(value)) return null; + const surface = readSurface(value.surface); + if (!surface) return null; + const view = isRecord(value.view) ? value.view : {}; + return { + paneId, + surface, + view: view.compactChrome === true ? { compactChrome: true } : {}, + }; +} + +type RebuildContext = { + panes: Record; + usedPaneIds: Set; + usedIdentityKeys: Set; + usedSplitIds: Set; + repaired: boolean; + splitSequence: number; +}; + +/** + * Rebuild a tree node from untrusted JSON. Invalid leaves are dropped and + * their parent splits collapse; duplicate pane or conversation references + * keep only the first occurrence. + */ +function rebuildNode(value: unknown, context: RebuildContext): PaneNode | null { + if (!isRecord(value)) { + context.repaired = true; + return null; + } + if (value.type === "leaf") { + const paneId = readString(value.paneId); + if (!paneId || !context.panes[paneId] || context.usedPaneIds.has(paneId)) { + context.repaired = true; + return null; + } + const surface = context.panes[paneId].surface; + // Unsupported passthrough panes carry no usable identity: exempt from dedup. + if (surface.kind !== "unsupported") { + const identityKey = surfaceIdentityKey(surface); + if (context.usedIdentityKeys.has(identityKey)) { + context.repaired = true; + return null; + } + context.usedIdentityKeys.add(identityKey); + } + context.usedPaneIds.add(paneId); + return { type: "leaf", paneId }; + } + if (value.type === "split") { + const first = rebuildNode(value.first, context); + const second = rebuildNode(value.second, context); + if (!first && !second) return null; + if (!first || !second) { + context.repaired = true; + return first ?? second; + } + let splitId = readString(value.splitId); + if (!splitId || context.usedSplitIds.has(splitId)) { + context.repaired = true; + context.splitSequence += 1; + splitId = `split-repaired-${context.splitSequence}`; + } + context.usedSplitIds.add(splitId); + const rawRatio = typeof value.ratio === "number" ? value.ratio : Number.NaN; + const ratio = clampSplitRatio(rawRatio); + if (ratio !== rawRatio) context.repaired = true; + return { + type: "split", + splitId, + axis: value.axis === "vertical" ? "vertical" : "horizontal", + ratio, + first, + second, + }; + } + context.repaired = true; + return null; +} + +/** + * Decode a persisted layout payload. Structural damage is repaired where a + * valid subset survives; JSON corruption and unknown schema versions fail so + * the caller can keep a diagnostic backup and fall back safely. + */ +export function decodeWorkbenchLayout(raw: string): WorkbenchLayoutDecodeResult { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return { ok: false, reason: "corrupted-json" }; + } + if (!isRecord(parsed)) return { ok: false, reason: "corrupted-json" }; + if (parsed.schemaVersion !== WORKBENCH_LAYOUT_SCHEMA_VERSION) { + return { ok: false, reason: "unsupported-schema" }; + } + + const context: RebuildContext = { + panes: {}, + usedPaneIds: new Set(), + usedIdentityKeys: new Set(), + usedSplitIds: new Set(), + repaired: false, + splitSequence: 0, + }; + if (isRecord(parsed.panes)) { + for (const [paneId, paneValue] of Object.entries(parsed.panes)) { + const record = readPaneRecord(paneId, paneValue); + if (record) { + context.panes[paneId] = record; + } else { + context.repaired = true; + } + } + } else if (parsed.panes !== undefined) { + context.repaired = true; + } + + const root = parsed.root === null ? null : rebuildNode(parsed.root, context); + const revision = + Number.isInteger(parsed.revision) && (parsed.revision as number) >= 0 + ? (parsed.revision as number) + : 0; + if (revision !== parsed.revision) context.repaired = true; + + if (root === null) { + if (parsed.root !== null || Object.keys(context.panes).length > 0) context.repaired = true; + const layout = { ...createEmptyWorkbenchLayout(), revision }; + return { ok: true, layout, repaired: context.repaired }; + } + + // Drop orphan pane records the (possibly repaired) tree no longer uses. + const panes: Record = {}; + for (const paneId of context.usedPaneIds) { + panes[paneId] = context.panes[paneId]; + } + if (Object.keys(panes).length !== Object.keys(context.panes).length) context.repaired = true; + + const focusCandidate = readString(parsed.focusedPaneId); + let focusedPaneId = focusCandidate && panes[focusCandidate] ? focusCandidate : null; + if (!focusedPaneId) { + focusedPaneId = firstLeafId(root); + if (focusCandidate !== focusedPaneId) context.repaired = true; + } + + const layout: WorkbenchLayout = { + schemaVersion: WORKBENCH_LAYOUT_SCHEMA_VERSION, + revision, + root, + panes, + focusedPaneId, + }; + if (collectWorkbenchLayoutIssues(layout).length > 0) { + return { ok: false, reason: "unrecoverable" }; + } + return { ok: true, layout, repaired: context.repaired }; +} + +function firstLeafId(node: PaneNode): string { + return node.type === "leaf" ? node.paneId : firstLeafId(node.first); +} diff --git a/crates/agent-ui/src/lib/workbench/commands.ts b/crates/agent-ui/src/lib/workbench/commands.ts new file mode 100644 index 000000000..954cba590 --- /dev/null +++ b/crates/agent-ui/src/lib/workbench/commands.ts @@ -0,0 +1,60 @@ +import type { PaneRecord, WorkbenchEdge, WorkbenchLayout } from "./types"; + +export type WorkbenchOpenTarget = + | { kind: "canvas-empty" } + | { kind: "canvas-edge"; edge: WorkbenchEdge } + | { kind: "pane-edge"; paneId: string; edge: WorkbenchEdge } + | { kind: "divider"; splitId: string; edge: WorkbenchEdge }; + +export type WorkbenchMoveTarget = + | Exclude + | { kind: "pane-center"; paneId: string }; + +type RevisionedWorkbenchCommand = { + expectedRevision: number; +}; + +export type WorkbenchCommand = RevisionedWorkbenchCommand & + ( + | { type: "OPEN_PANE"; pane: PaneRecord; target: WorkbenchOpenTarget } + | { type: "MOVE_PANE"; paneId: string; target: WorkbenchMoveTarget } + | { type: "SWAP_PANES"; firstPaneId: string; secondPaneId: string } + | { type: "CLOSE_PANE"; paneId: string } + | { type: "RESIZE_SPLIT"; splitId: string; ratio: number } + | { type: "EQUALIZE_SPLIT"; splitId: string } + | { type: "FOCUS_PANE"; paneId: string } + ); + +export type WorkbenchCommandErrorCode = + | "duplicate-conversation" + | "duplicate-surface" + | "invalid-layout" + | "minimum-size" + | "pane-not-found" + | "stale-revision" + | "target-not-found" + | "unsupported-surface"; + +export type WorkbenchCommandError = { + code: WorkbenchCommandErrorCode; + message: string; + currentRevision: number; +}; + +export type WorkbenchCommandResult = + | { ok: true; layout: WorkbenchLayout } + | { ok: false; error: WorkbenchCommandError }; + +export function getWorkbenchRevisionError( + layout: Pick, + expectedRevision: number, +): WorkbenchCommandError | null { + if (Number.isInteger(expectedRevision) && expectedRevision === layout.revision) { + return null; + } + return { + code: "stale-revision", + message: `Workbench revision changed from ${expectedRevision} to ${layout.revision}.`, + currentRevision: layout.revision, + }; +} diff --git a/crates/agent-ui/src/lib/workbench/featureFlags.ts b/crates/agent-ui/src/lib/workbench/featureFlags.ts new file mode 100644 index 000000000..858666770 --- /dev/null +++ b/crates/agent-ui/src/lib/workbench/featureFlags.ts @@ -0,0 +1,13 @@ +export type SessionWorkbenchFeature = Readonly<{ + enabled: boolean; +}>; + +export function readInternalFeatureFlag(value: unknown): boolean { + if (value === true) return true; + if (typeof value !== "string") return false; + return ["1", "on", "true", "yes"].includes(value.trim().toLowerCase()); +} + +export function createSessionWorkbenchFeature(value: unknown): SessionWorkbenchFeature { + return Object.freeze({ enabled: readInternalFeatureFlag(value) }); +} diff --git a/crates/agent-ui/src/lib/workbench/geometry.ts b/crates/agent-ui/src/lib/workbench/geometry.ts new file mode 100644 index 000000000..3049854be --- /dev/null +++ b/crates/agent-ui/src/lib/workbench/geometry.ts @@ -0,0 +1,149 @@ +import type { PaneNode, WorkbenchAxis } from "./types"; + +/** Integer CSS-pixel rectangle relative to the workbench canvas origin. */ +export type WorkbenchRect = { + left: number; + top: number; + width: number; + height: number; +}; + +export type PaneGeometry = { + paneId: string; + rect: WorkbenchRect; +}; + +export type DividerGeometry = { + splitId: string; + axis: WorkbenchAxis; + /** The visual/hit rect of the divider itself. */ + rect: WorkbenchRect; + /** The rect of the whole split region the divider belongs to. */ + splitArea: WorkbenchRect; +}; + +export type WorkbenchGeometry = { + canvas: WorkbenchRect; + panes: PaneGeometry[]; + dividers: DividerGeometry[]; +}; + +export const WORKBENCH_DIVIDER_SIZE = 8; +export const WORKBENCH_MIN_SPLIT_RATIO = 0.05; +export const WORKBENCH_MAX_SPLIT_RATIO = 0.95; +export const MIN_CONVERSATION_PANE_WIDTH = 320; +export const MIN_CONVERSATION_PANE_HEIGHT = 220; + +export function clampSplitRatio(ratio: number): number { + if (!Number.isFinite(ratio)) return 0.5; + return Math.min(WORKBENCH_MAX_SPLIT_RATIO, Math.max(WORKBENCH_MIN_SPLIT_RATIO, ratio)); +} + +/** + * Clamp a proposed split ratio so both sides keep at least `minSize` CSS + * pixels along the split axis. Falls back to plain ratio clamping when the + * region is too small to honour the minimum on both sides. + */ +export function clampRatioToMinSize(input: { + ratio: number; + axis: WorkbenchAxis; + splitArea: WorkbenchRect; + minSize: number; + dividerSize?: number; +}): number { + const dividerSize = input.dividerSize ?? WORKBENCH_DIVIDER_SIZE; + const total = + (input.axis === "horizontal" ? input.splitArea.width : input.splitArea.height) - dividerSize; + const base = clampSplitRatio(input.ratio); + if (total <= 0) return base; + const minRatio = input.minSize / total; + if (minRatio * 2 >= 1) return 0.5; + return Math.min(1 - minRatio, Math.max(minRatio, base)); +} + +export function roundWorkbenchRect(rect: WorkbenchRect): WorkbenchRect { + const left = Math.round(rect.left); + const top = Math.round(rect.top); + return { + left, + top, + width: Math.max(0, Math.round(rect.left + rect.width) - left), + height: Math.max(0, Math.round(rect.top + rect.height) - top), + }; +} + +export function workbenchRectContains(rect: WorkbenchRect, x: number, y: number): boolean { + return ( + x >= rect.left && x < rect.left + rect.width && y >= rect.top && y < rect.top + rect.height + ); +} + +/** + * Compute integer-pixel pane and divider rects for a pane tree. + * + * The first child receives `floor(usable * ratio)` and the second child the + * exact remainder, so panes and dividers tile the canvas with no gaps and no + * overlaps regardless of ratio precision. + */ +export function computeWorkbenchGeometry( + root: PaneNode | null, + canvas: WorkbenchRect, + options?: { dividerSize?: number }, +): WorkbenchGeometry { + const dividerSize = Math.max(0, Math.round(options?.dividerSize ?? WORKBENCH_DIVIDER_SIZE)); + const normalizedCanvas = roundWorkbenchRect(canvas); + const panes: PaneGeometry[] = []; + const dividers: DividerGeometry[] = []; + + const visit = (node: PaneNode, area: WorkbenchRect) => { + if (node.type === "leaf") { + panes.push({ paneId: node.paneId, rect: area }); + return; + } + const ratio = clampSplitRatio(node.ratio); + let firstRect: WorkbenchRect; + let dividerRect: WorkbenchRect; + let secondRect: WorkbenchRect; + if (node.axis === "horizontal") { + const usable = Math.max(0, area.width - dividerSize); + const firstWidth = Math.floor(usable * ratio); + firstRect = { left: area.left, top: area.top, width: firstWidth, height: area.height }; + dividerRect = { + left: area.left + firstWidth, + top: area.top, + width: Math.min(dividerSize, area.width - firstWidth), + height: area.height, + }; + secondRect = { + left: dividerRect.left + dividerRect.width, + top: area.top, + width: Math.max(0, area.left + area.width - (dividerRect.left + dividerRect.width)), + height: area.height, + }; + } else { + const usable = Math.max(0, area.height - dividerSize); + const firstHeight = Math.floor(usable * ratio); + firstRect = { left: area.left, top: area.top, width: area.width, height: firstHeight }; + dividerRect = { + left: area.left, + top: area.top + firstHeight, + width: area.width, + height: Math.min(dividerSize, area.height - firstHeight), + }; + secondRect = { + left: area.left, + top: dividerRect.top + dividerRect.height, + width: area.width, + height: Math.max(0, area.top + area.height - (dividerRect.top + dividerRect.height)), + }; + } + dividers.push({ splitId: node.splitId, axis: node.axis, rect: dividerRect, splitArea: area }); + visit(node.first, firstRect); + visit(node.second, secondRect); + }; + + if (root) { + visit(root, normalizedCanvas); + } + return { canvas: normalizedCanvas, panes, dividers }; +} diff --git a/crates/agent-ui/src/lib/workbench/hitTesting.ts b/crates/agent-ui/src/lib/workbench/hitTesting.ts new file mode 100644 index 000000000..37c1c0068 --- /dev/null +++ b/crates/agent-ui/src/lib/workbench/hitTesting.ts @@ -0,0 +1,184 @@ +import { + type DividerGeometry, + type WorkbenchGeometry, + type WorkbenchRect, + workbenchRectContains, +} from "./geometry"; +import type { WorkbenchEdge } from "./types"; + +export type WorkbenchDropTarget = + | { kind: "canvas-empty" } + | { kind: "canvas-edge"; edge: WorkbenchEdge } + | { kind: "divider"; splitId: string; edge: WorkbenchEdge } + | { kind: "pane-edge"; paneId: string; edge: WorkbenchEdge } + | { kind: "pane-center"; paneId: string }; + +export type WorkbenchHitTestOptions = { + /** Width of the canvas-edge band, in CSS pixels. */ + canvasEdgeInset?: number; + /** Fraction of a pane rect that counts as its edge band (0..0.5). */ + paneEdgeFraction?: number; + /** Extra padding around divider rects to make them easier to hit. */ + dividerHitPadding?: number; +}; + +const DEFAULT_CANVAS_EDGE_INSET = 16; +const DEFAULT_PANE_EDGE_FRACTION = 0.24; +const DEFAULT_DIVIDER_HIT_PADDING = 4; + +function inflateRect(rect: WorkbenchRect, amount: number): WorkbenchRect { + return { + left: rect.left - amount, + top: rect.top - amount, + width: rect.width + amount * 2, + height: rect.height + amount * 2, + }; +} + +function dividerEdge(divider: DividerGeometry, x: number, y: number): WorkbenchEdge { + if (divider.axis === "horizontal") { + return x < divider.rect.left + divider.rect.width / 2 ? "left" : "right"; + } + return y < divider.rect.top + divider.rect.height / 2 ? "top" : "bottom"; +} + +/** + * Resolve the drop target for a pointer position against a frozen geometry + * snapshot. Priority: canvas-edge > divider > pane-edge > pane-center. + * Returns null when the pointer is outside the canvas. + */ +export function hitTestWorkbenchDrop( + geometry: WorkbenchGeometry, + x: number, + y: number, + options?: WorkbenchHitTestOptions, +): WorkbenchDropTarget | null { + const { canvas } = geometry; + if (!workbenchRectContains(canvas, x, y)) return null; + if (geometry.panes.length === 0) return { kind: "canvas-empty" }; + + const inset = options?.canvasEdgeInset ?? DEFAULT_CANVAS_EDGE_INSET; + const distances: Array<[WorkbenchEdge, number]> = [ + ["left", x - canvas.left], + ["right", canvas.left + canvas.width - x], + ["top", y - canvas.top], + ["bottom", canvas.top + canvas.height - y], + ]; + let nearestEdge: WorkbenchEdge | null = null; + let nearestDistance = Number.POSITIVE_INFINITY; + for (const [edge, distance] of distances) { + if (distance <= inset && distance < nearestDistance) { + nearestEdge = edge; + nearestDistance = distance; + } + } + if (nearestEdge) return { kind: "canvas-edge", edge: nearestEdge }; + + const dividerPadding = options?.dividerHitPadding ?? DEFAULT_DIVIDER_HIT_PADDING; + for (const divider of geometry.dividers) { + if (workbenchRectContains(inflateRect(divider.rect, dividerPadding), x, y)) { + return { kind: "divider", splitId: divider.splitId, edge: dividerEdge(divider, x, y) }; + } + } + + const fraction = Math.min( + 0.5, + Math.max(0, options?.paneEdgeFraction ?? DEFAULT_PANE_EDGE_FRACTION), + ); + for (const pane of geometry.panes) { + if (!workbenchRectContains(pane.rect, x, y)) continue; + const bandX = pane.rect.width * fraction; + const bandY = pane.rect.height * fraction; + const paneDistances: Array<[WorkbenchEdge, number, number]> = [ + ["left", x - pane.rect.left, bandX], + ["right", pane.rect.left + pane.rect.width - x, bandX], + ["top", y - pane.rect.top, bandY], + ["bottom", pane.rect.top + pane.rect.height - y, bandY], + ]; + let paneEdge: WorkbenchEdge | null = null; + let paneEdgeScore = Number.POSITIVE_INFINITY; + for (const [edge, distance, band] of paneDistances) { + if (band <= 0) continue; + const score = distance / band; + if (distance <= band && score < paneEdgeScore) { + paneEdge = edge; + paneEdgeScore = score; + } + } + if (paneEdge) return { kind: "pane-edge", paneId: pane.paneId, edge: paneEdge }; + return { kind: "pane-center", paneId: pane.paneId }; + } + return null; +} + +function halfRect(rect: WorkbenchRect, edge: WorkbenchEdge): WorkbenchRect { + switch (edge) { + case "left": + return { ...rect, width: Math.floor(rect.width / 2) }; + case "right": { + const width = Math.floor(rect.width / 2); + return { ...rect, left: rect.left + rect.width - width, width }; + } + case "top": + return { ...rect, height: Math.floor(rect.height / 2) }; + case "bottom": { + const height = Math.floor(rect.height / 2); + return { ...rect, top: rect.top + rect.height - height, height }; + } + } +} + +/** + * The final rect a drop would produce, for the drop preview overlay. + * Mirrors the reducer's 0.5-ratio insertion semantics. + */ +export function previewRectForDropTarget( + geometry: WorkbenchGeometry, + target: WorkbenchDropTarget, +): WorkbenchRect | null { + switch (target.kind) { + case "canvas-empty": + return geometry.canvas; + case "canvas-edge": + return halfRect(geometry.canvas, target.edge); + case "pane-edge": { + const pane = geometry.panes.find((item) => item.paneId === target.paneId); + return pane ? halfRect(pane.rect, target.edge) : null; + } + case "pane-center": { + const pane = geometry.panes.find((item) => item.paneId === target.paneId); + return pane ? pane.rect : null; + } + case "divider": { + const divider = geometry.dividers.find((item) => item.splitId === target.splitId); + if (!divider) return null; + const { splitArea, rect, axis } = divider; + if (axis === "horizontal") { + const firstRegion: WorkbenchRect = { + ...splitArea, + width: rect.left - splitArea.left, + }; + const secondRegion: WorkbenchRect = { + ...splitArea, + left: rect.left + rect.width, + width: splitArea.left + splitArea.width - (rect.left + rect.width), + }; + return target.edge === "left" || target.edge === "top" + ? halfRect(firstRegion, "right") + : halfRect(secondRegion, "left"); + } + const firstRegion: WorkbenchRect = { + ...splitArea, + height: rect.top - splitArea.top, + }; + const secondRegion: WorkbenchRect = { + ...splitArea, + top: rect.top + rect.height, + height: splitArea.top + splitArea.height - (rect.top + rect.height), + }; + return target.edge === "top" || target.edge === "left" + ? halfRect(firstRegion, "bottom") + : halfRect(secondRegion, "top"); + } + } +} diff --git a/crates/agent-ui/src/lib/workbench/index.ts b/crates/agent-ui/src/lib/workbench/index.ts new file mode 100644 index 000000000..135b825e0 --- /dev/null +++ b/crates/agent-ui/src/lib/workbench/index.ts @@ -0,0 +1,9 @@ +export * from "./adjacency"; +export * from "./codec"; +export * from "./commands"; +export * from "./featureFlags"; +export * from "./geometry"; +export * from "./hitTesting"; +export * from "./invariants"; +export * from "./reducer"; +export * from "./types"; diff --git a/crates/agent-ui/src/lib/workbench/invariants.ts b/crates/agent-ui/src/lib/workbench/invariants.ts new file mode 100644 index 000000000..769c7d87e --- /dev/null +++ b/crates/agent-ui/src/lib/workbench/invariants.ts @@ -0,0 +1,289 @@ +import { + type PaneNode, + surfaceIdentityKey, + WORKBENCH_LAYOUT_SCHEMA_VERSION, + type WorkbenchLayout, +} from "./types"; + +export type WorkbenchLayoutIssueCode = + | "cyclic-tree" + | "duplicate-conversation" + | "duplicate-pane-reference" + | "duplicate-split-id" + | "duplicate-surface" + | "invalid-empty-layout" + | "invalid-focus" + | "invalid-pane-record" + | "invalid-project-ref" + | "invalid-ratio" + | "invalid-revision" + | "invalid-schema-version" + | "missing-pane-record" + | "orphan-pane-record"; + +export type WorkbenchLayoutIssue = { + code: WorkbenchLayoutIssueCode; + path: string; + message: string; +}; + +function issue( + code: WorkbenchLayoutIssueCode, + path: string, + message: string, +): WorkbenchLayoutIssue { + return { code, path, message }; +} + +/** + * Find the pane hosting the surface with the given identity key (see + * `surfaceIdentityKey`). Unsupported surfaces never match: they carry no + * usable identity. + */ +export function findPaneIdBySurfaceKey( + layout: Pick, + surfaceKey: string, +): string | null { + if (!surfaceKey) return null; + for (const [paneId, pane] of Object.entries(layout.panes)) { + if (pane.surface.kind === "unsupported") continue; + if (surfaceIdentityKey(pane.surface) === surfaceKey) { + return paneId; + } + } + return null; +} + +export function findPaneIdByConversationId( + layout: Pick, + conversationId: string, +): string | null { + const targetId = conversationId.trim(); + if (!targetId) return null; + return findPaneIdBySurfaceKey(layout, `conversation:${targetId}`); +} + +export function collectWorkbenchLayoutIssues(layout: WorkbenchLayout): WorkbenchLayoutIssue[] { + const issues: WorkbenchLayoutIssue[] = []; + if (layout.schemaVersion !== WORKBENCH_LAYOUT_SCHEMA_VERSION) { + issues.push( + issue( + "invalid-schema-version", + "schemaVersion", + `Expected schema version ${WORKBENCH_LAYOUT_SCHEMA_VERSION}.`, + ), + ); + } + if (!Number.isInteger(layout.revision) || layout.revision < 0) { + issues.push(issue("invalid-revision", "revision", "Revision must be a non-negative integer.")); + } + + const paneKeys = Object.keys(layout.panes); + if (layout.root === null) { + if (paneKeys.length > 0 || layout.focusedPaneId !== null) { + issues.push( + issue( + "invalid-empty-layout", + "root", + "An empty tree cannot retain pane records or a focused pane.", + ), + ); + } + return issues; + } + + const referencedPaneIds = new Set(); + const splitIds = new Set(); + const visitedNodes = new WeakSet(); + + const visit = (node: PaneNode, path: string) => { + if (visitedNodes.has(node)) { + issues.push(issue("cyclic-tree", path, "Pane tree nodes cannot contain cycles.")); + return; + } + visitedNodes.add(node); + + if (node.type === "leaf") { + const paneId = node.paneId.trim(); + if (!paneId || !layout.panes[paneId]) { + issues.push( + issue( + "missing-pane-record", + `${path}.paneId`, + `Leaf references missing pane record '${node.paneId}'.`, + ), + ); + return; + } + if (referencedPaneIds.has(paneId)) { + issues.push( + issue( + "duplicate-pane-reference", + `${path}.paneId`, + `Pane '${paneId}' is referenced more than once.`, + ), + ); + return; + } + referencedPaneIds.add(paneId); + return; + } + + const splitId = node.splitId.trim(); + if (!splitId || splitIds.has(splitId)) { + issues.push( + issue( + "duplicate-split-id", + `${path}.splitId`, + `Split id '${node.splitId}' must be non-empty and unique.`, + ), + ); + } else { + splitIds.add(splitId); + } + if (!Number.isFinite(node.ratio) || node.ratio <= 0 || node.ratio >= 1) { + issues.push( + issue( + "invalid-ratio", + `${path}.ratio`, + "Split ratio must be greater than 0 and less than 1.", + ), + ); + } + visit(node.first, `${path}.first`); + visit(node.second, `${path}.second`); + }; + + visit(layout.root, "root"); + + const surfacePaneIds = new Map(); + for (const [paneKey, pane] of Object.entries(layout.panes)) { + const panePath = `panes.${paneKey}`; + if (!referencedPaneIds.has(paneKey)) { + issues.push( + issue( + "orphan-pane-record", + panePath, + `Pane record '${paneKey}' is not referenced by the tree.`, + ), + ); + } + if (!pane.paneId.trim() || pane.paneId !== paneKey) { + issues.push( + issue( + "invalid-pane-record", + `${panePath}.paneId`, + "Pane record id must be non-empty and match its record key.", + ), + ); + } + + const surface = pane.surface; + let identityValid = false; + if (surface.kind === "conversation") { + if (!surface.conversationId.trim()) { + issues.push( + issue( + "invalid-pane-record", + `${panePath}.surface.conversationId`, + "Conversation id must be non-empty.", + ), + ); + } else { + identityValid = true; + } + } else if (surface.kind === "localTerminal" || surface.kind === "sshTerminal") { + if (!surface.surfaceId.trim()) { + issues.push( + issue( + "invalid-pane-record", + `${panePath}.surface.surfaceId`, + "Terminal surface id must be non-empty.", + ), + ); + } else { + identityValid = true; + } + if (!surface.launchSpec.cwd.trim()) { + issues.push( + issue( + "invalid-pane-record", + `${panePath}.surface.launchSpec.cwd`, + "Terminal launch specs require a working directory.", + ), + ); + } + } + + // Unsupported passthrough panes carry no usable identity or project ref; + // they are exempt from uniqueness and project validation by design. + if (surface.kind !== "unsupported") { + if (identityValid) { + const surfaceKey = surfaceIdentityKey(surface); + const previousPaneId = surfacePaneIds.get(surfaceKey); + if (previousPaneId) { + if (surface.kind === "conversation") { + issues.push( + issue( + "duplicate-conversation", + `${panePath}.surface.conversationId`, + `Conversation '${surface.conversationId.trim()}' is already bound to pane '${previousPaneId}'.`, + ), + ); + } else { + issues.push( + issue( + "duplicate-surface", + `${panePath}.surface.surfaceId`, + `Terminal surface '${surface.surfaceId.trim()}' is already bound to pane '${previousPaneId}'.`, + ), + ); + } + } else { + surfacePaneIds.set(surfaceKey, paneKey); + } + } + if (!surface.project.projectId.trim() || !surface.project.projectPathKey.trim()) { + issues.push( + issue( + "invalid-project-ref", + `${panePath}.surface.project`, + "Project references require both projectId and projectPathKey.", + ), + ); + } + } + } + + if (layout.focusedPaneId === null || !referencedPaneIds.has(layout.focusedPaneId)) { + issues.push( + issue( + "invalid-focus", + "focusedPaneId", + "A non-empty layout must focus one pane referenced by the tree.", + ), + ); + } + return issues; +} + +export function isWorkbenchLayoutValid(layout: WorkbenchLayout): boolean { + return collectWorkbenchLayoutIssues(layout).length === 0; +} + +export class WorkbenchLayoutInvariantError extends Error { + readonly issues: WorkbenchLayoutIssue[]; + + constructor(issues: WorkbenchLayoutIssue[]) { + super(issues.map((item) => `${item.path}: ${item.message}`).join("\n")); + this.name = "WorkbenchLayoutInvariantError"; + this.issues = issues; + } +} + +export function assertWorkbenchLayout(layout: WorkbenchLayout): void { + const issues = collectWorkbenchLayoutIssues(layout); + if (issues.length > 0) { + throw new WorkbenchLayoutInvariantError(issues); + } +} diff --git a/crates/agent-ui/src/lib/workbench/reducer.ts b/crates/agent-ui/src/lib/workbench/reducer.ts new file mode 100644 index 000000000..ed6ad9135 --- /dev/null +++ b/crates/agent-ui/src/lib/workbench/reducer.ts @@ -0,0 +1,554 @@ +import { + getWorkbenchRevisionError, + type WorkbenchCommand, + type WorkbenchCommandError, + type WorkbenchCommandErrorCode, + type WorkbenchCommandResult, + type WorkbenchMoveTarget, + type WorkbenchOpenTarget, +} from "./commands"; +import { clampSplitRatio } from "./geometry"; +import { collectWorkbenchLayoutIssues, findPaneIdBySurfaceKey } from "./invariants"; +import { + type PaneNode, + type PaneRecord, + surfaceIdentityKey, + type WorkbenchAxis, + type WorkbenchEdge, + type WorkbenchLayout, +} from "./types"; + +type SplitIdFactory = () => string; + +export type WorkbenchReducerOptions = { + /** Injectable id factory so tests and resume stay deterministic. */ + createSplitId?: SplitIdFactory; +}; + +let splitIdCounter = 0; + +function defaultCreateSplitId(): string { + splitIdCounter += 1; + return `split-${Date.now().toString(36)}-${splitIdCounter.toString(36)}`; +} + +function edgeAxis(edge: WorkbenchEdge): WorkbenchAxis { + return edge === "left" || edge === "right" ? "horizontal" : "vertical"; +} + +function edgeIsBefore(edge: WorkbenchEdge): boolean { + return edge === "left" || edge === "top"; +} + +function commandError( + code: WorkbenchCommandErrorCode, + message: string, + currentRevision: number, +): { ok: false; error: WorkbenchCommandError } { + return { ok: false, error: { code, message, currentRevision } }; +} + +function findLeaf(node: PaneNode | null, paneId: string): boolean { + if (!node) return false; + if (node.type === "leaf") return node.paneId === paneId; + return findLeaf(node.first, paneId) || findLeaf(node.second, paneId); +} + +function findSplit( + node: PaneNode | null, + splitId: string, +): Extract | null { + if (!node || node.type === "leaf") return null; + if (node.splitId === splitId) return node; + return findSplit(node.first, splitId) ?? findSplit(node.second, splitId); +} + +/** Remove a leaf; the parent split collapses into its surviving sibling. */ +function removeLeaf(node: PaneNode, paneId: string): { node: PaneNode | null; found: boolean } { + if (node.type === "leaf") { + return node.paneId === paneId ? { node: null, found: true } : { node, found: false }; + } + const first = removeLeaf(node.first, paneId); + if (first.found) { + return { node: first.node ? { ...node, first: first.node } : node.second, found: true }; + } + const second = removeLeaf(node.second, paneId); + if (second.found) { + return { node: second.node ? { ...node, second: second.node } : node.first, found: true }; + } + return { node, found: false }; +} + +/** Replace the leaf `targetPaneId` with a split hosting it plus `subtree`. */ +function graftAtLeaf( + node: PaneNode, + targetPaneId: string, + subtree: PaneNode, + edge: WorkbenchEdge, + createSplitId: SplitIdFactory, +): PaneNode | null { + if (node.type === "leaf") { + if (node.paneId !== targetPaneId) return null; + const before = edgeIsBefore(edge); + return { + type: "split", + splitId: createSplitId(), + axis: edgeAxis(edge), + ratio: 0.5, + first: before ? subtree : node, + second: before ? node : subtree, + }; + } + const first = graftAtLeaf(node.first, targetPaneId, subtree, edge, createSplitId); + if (first) return { ...node, first }; + const second = graftAtLeaf(node.second, targetPaneId, subtree, edge, createSplitId); + if (second) return { ...node, second }; + return null; +} + +/** Insert `subtree` at an existing divider, between the split's children. */ +function graftAtDivider( + node: PaneNode, + splitId: string, + subtree: PaneNode, + edge: WorkbenchEdge, + createSplitId: SplitIdFactory, +): PaneNode | null { + if (node.type === "leaf") return null; + if (node.splitId === splitId) { + const before = edgeIsBefore(edge); + // "before" groups the inserted pane with the first child, "after" with + // the second child; either way the pane lands visually at the divider. + if (before) { + return { + ...node, + first: { + type: "split", + splitId: createSplitId(), + axis: node.axis, + ratio: 0.5, + first: node.first, + second: subtree, + }, + }; + } + return { + ...node, + second: { + type: "split", + splitId: createSplitId(), + axis: node.axis, + ratio: 0.5, + first: subtree, + second: node.second, + }, + }; + } + const first = graftAtDivider(node.first, splitId, subtree, edge, createSplitId); + if (first) return { ...node, first }; + const second = graftAtDivider(node.second, splitId, subtree, edge, createSplitId); + if (second) return { ...node, second }; + return null; +} + +/** Wrap the whole tree in a root-level split with `subtree` on `edge`. */ +function graftAtRoot( + root: PaneNode | null, + subtree: PaneNode, + edge: WorkbenchEdge, + createSplitId: SplitIdFactory, +): PaneNode { + if (!root) return subtree; + const before = edgeIsBefore(edge); + return { + type: "split", + splitId: createSplitId(), + axis: edgeAxis(edge), + ratio: 0.5, + first: before ? subtree : root, + second: before ? root : subtree, + }; +} + +function graftAtTarget( + root: PaneNode | null, + subtree: PaneNode, + target: WorkbenchOpenTarget | Exclude, + createSplitId: SplitIdFactory, +): PaneNode | null { + switch (target.kind) { + case "canvas-empty": + return root === null ? subtree : null; + case "canvas-edge": + return graftAtRoot(root, subtree, target.edge, createSplitId); + case "pane-edge": + return root ? graftAtLeaf(root, target.paneId, subtree, target.edge, createSplitId) : null; + case "divider": + return root + ? graftAtDivider(root, target.splitId, subtree, target.edge, createSplitId) + : null; + } +} + +function swapLeaves(node: PaneNode, firstPaneId: string, secondPaneId: string): PaneNode { + if (node.type === "leaf") { + if (node.paneId === firstPaneId) return { ...node, paneId: secondPaneId }; + if (node.paneId === secondPaneId) return { ...node, paneId: firstPaneId }; + return node; + } + return { + ...node, + first: swapLeaves(node.first, firstPaneId, secondPaneId), + second: swapLeaves(node.second, firstPaneId, secondPaneId), + }; +} + +function firstLeafId(node: PaneNode | null): string | null { + if (!node) return null; + if (node.type === "leaf") return node.paneId; + return firstLeafId(node.first) ?? firstLeafId(node.second); +} + +/** + * The leaf that receives focus after `paneId` closes: the nearest leaf of the + * collapsed split's sibling subtree, falling back to the first leaf overall. + */ +function focusSuccessor(root: PaneNode, paneId: string): string | null { + if (root.type === "leaf") return null; + const locate = (node: PaneNode): string | null => { + if (node.type === "leaf") return null; + if (node.first.type === "leaf" && node.first.paneId === paneId) { + return firstLeafId(node.second); + } + if (node.second.type === "leaf" && node.second.paneId === paneId) { + return firstLeafId(node.first); + } + return locate(node.first) ?? locate(node.second); + }; + return locate(root); +} + +function setSplitRatio(node: PaneNode, splitId: string, ratio: number): PaneNode | null { + if (node.type === "leaf") return null; + if (node.splitId === splitId) return { ...node, ratio }; + const first = setSplitRatio(node.first, splitId, ratio); + if (first) return { ...node, first }; + const second = setSplitRatio(node.second, splitId, ratio); + if (second) return { ...node, second }; + return null; +} + +type PaneRecordIssue = { + code: WorkbenchCommandErrorCode; + message: string; +}; + +function validatePaneRecord(pane: PaneRecord): PaneRecordIssue | null { + if (!pane.paneId.trim()) { + return { code: "invalid-layout", message: "Pane records require a stable pane id." }; + } + const surface = pane.surface; + switch (surface.kind) { + case "conversation": { + if (!surface.conversationId.trim()) { + return { code: "invalid-layout", message: "Conversation surfaces require an id." }; + } + if (!surface.project.projectId.trim() || !surface.project.projectPathKey.trim()) { + return { + code: "invalid-layout", + message: "Conversation surfaces require a complete project reference.", + }; + } + return null; + } + case "localTerminal": + case "sshTerminal": { + if (!surface.surfaceId.trim()) { + return { code: "invalid-layout", message: "Terminal surfaces require a surface id." }; + } + if (!surface.launchSpec.cwd.trim()) { + return { + code: "invalid-layout", + message: "Terminal surfaces require a launch working directory.", + }; + } + if (!surface.project.projectId.trim() || !surface.project.projectPathKey.trim()) { + return { + code: "invalid-layout", + message: "Terminal surfaces require a complete project reference.", + }; + } + return null; + } + case "unsupported": + return { code: "unsupported-surface", message: "Unsupported surface kind." }; + } +} + +function commit(layout: WorkbenchLayout, next: WorkbenchLayout): WorkbenchCommandResult { + const issues = collectWorkbenchLayoutIssues(next); + if (issues.length > 0) { + return commandError( + "invalid-layout", + issues.map((item) => `${item.path}: ${item.message}`).join("; "), + layout.revision, + ); + } + return { ok: true, layout: next }; +} + +/** + * Pure workbench layout reducer. Never mutates the input layout; failures + * return the current revision and leave the layout untouched. + */ +export function applyWorkbenchCommand( + layout: WorkbenchLayout, + command: WorkbenchCommand, + options?: WorkbenchReducerOptions, +): WorkbenchCommandResult { + const revisionError = getWorkbenchRevisionError(layout, command.expectedRevision); + if (revisionError) return { ok: false, error: revisionError }; + const createSplitId = options?.createSplitId ?? defaultCreateSplitId; + + switch (command.type) { + case "OPEN_PANE": { + const pane = command.pane; + const recordIssue = validatePaneRecord(pane); + if (recordIssue) { + return commandError(recordIssue.code, recordIssue.message, layout.revision); + } + if (layout.panes[pane.paneId]) { + return commandError( + "invalid-layout", + `Pane '${pane.paneId}' already exists.`, + layout.revision, + ); + } + // validatePaneRecord already rejected unsupported surfaces, so every + // openable surface participates in identity uniqueness. + const existingPaneId = findPaneIdBySurfaceKey(layout, surfaceIdentityKey(pane.surface)); + if (existingPaneId) { + if (pane.surface.kind === "conversation") { + return commandError( + "duplicate-conversation", + `Conversation '${pane.surface.conversationId}' is already open in pane '${existingPaneId}'.`, + layout.revision, + ); + } + const surfaceId = pane.surface.kind === "unsupported" ? "" : pane.surface.surfaceId; + return commandError( + "duplicate-surface", + `Terminal surface '${surfaceId}' is already open in pane '${existingPaneId}'.`, + layout.revision, + ); + } + const target: WorkbenchOpenTarget = + command.target.kind === "canvas-edge" && layout.root === null + ? { kind: "canvas-empty" } + : command.target; + const nextRoot = graftAtTarget( + layout.root, + { type: "leaf", paneId: pane.paneId }, + target, + createSplitId, + ); + if (!nextRoot) { + return commandError( + "target-not-found", + "Open target does not exist in the current layout.", + layout.revision, + ); + } + return commit(layout, { + ...layout, + revision: layout.revision + 1, + root: nextRoot, + panes: { ...layout.panes, [pane.paneId]: pane }, + focusedPaneId: pane.paneId, + }); + } + + case "MOVE_PANE": { + if (!layout.root || !layout.panes[command.paneId]) { + return commandError( + "pane-not-found", + `Pane '${command.paneId}' does not exist.`, + layout.revision, + ); + } + if (command.target.kind === "pane-center") { + const targetPaneId = command.target.paneId; + if (targetPaneId === command.paneId) { + return commandError( + "target-not-found", + "A pane cannot swap with itself.", + layout.revision, + ); + } + if (!layout.panes[targetPaneId]) { + return commandError( + "target-not-found", + `Swap target '${targetPaneId}' does not exist.`, + layout.revision, + ); + } + return commit(layout, { + ...layout, + revision: layout.revision + 1, + root: swapLeaves(layout.root, command.paneId, targetPaneId), + focusedPaneId: command.paneId, + }); + } + if (command.target.kind === "pane-edge" && command.target.paneId === command.paneId) { + return commandError("target-not-found", "A pane cannot dock onto itself.", layout.revision); + } + // Edge/divider moves detach the pane first, then graft it back. The + // target is re-resolved against the detached tree so a divider that + // collapsed with the removal is a clean rejection, not a stale replay. + const removal = removeLeaf(layout.root, command.paneId); + if (!removal.found) { + return commandError( + "pane-not-found", + `Pane '${command.paneId}' is not mounted in the tree.`, + layout.revision, + ); + } + const nextRoot = graftAtTarget( + removal.node, + { type: "leaf", paneId: command.paneId }, + removal.node === null ? { kind: "canvas-empty" } : command.target, + createSplitId, + ); + if (!nextRoot) { + return commandError( + "target-not-found", + "Move target no longer exists after detaching the pane.", + layout.revision, + ); + } + return commit(layout, { + ...layout, + revision: layout.revision + 1, + root: nextRoot, + focusedPaneId: command.paneId, + }); + } + + case "SWAP_PANES": { + if (command.firstPaneId === command.secondPaneId) { + return commandError("target-not-found", "Cannot swap a pane with itself.", layout.revision); + } + if (!layout.root || !layout.panes[command.firstPaneId]) { + return commandError( + "pane-not-found", + `Pane '${command.firstPaneId}' does not exist.`, + layout.revision, + ); + } + if (!layout.panes[command.secondPaneId]) { + return commandError( + "pane-not-found", + `Pane '${command.secondPaneId}' does not exist.`, + layout.revision, + ); + } + return commit(layout, { + ...layout, + revision: layout.revision + 1, + root: swapLeaves(layout.root, command.firstPaneId, command.secondPaneId), + }); + } + + case "CLOSE_PANE": { + if (!layout.root || !layout.panes[command.paneId]) { + return commandError( + "pane-not-found", + `Pane '${command.paneId}' does not exist.`, + layout.revision, + ); + } + const successor = + layout.focusedPaneId === command.paneId + ? focusSuccessor(layout.root, command.paneId) + : layout.focusedPaneId; + const removal = removeLeaf(layout.root, command.paneId); + if (!removal.found) { + return commandError( + "pane-not-found", + `Pane '${command.paneId}' is not mounted in the tree.`, + layout.revision, + ); + } + const nextPanes = { ...layout.panes }; + delete nextPanes[command.paneId]; + const nextRoot = removal.node; + const nextFocus = nextRoot === null ? null : (successor ?? firstLeafId(nextRoot)); + return commit(layout, { + ...layout, + revision: layout.revision + 1, + root: nextRoot, + panes: nextRoot === null ? {} : nextPanes, + focusedPaneId: nextFocus, + }); + } + + case "RESIZE_SPLIT": { + if (!Number.isFinite(command.ratio)) { + return commandError("invalid-layout", "Split ratio must be finite.", layout.revision); + } + if (!layout.root || !findSplit(layout.root, command.splitId)) { + return commandError( + "target-not-found", + `Split '${command.splitId}' does not exist.`, + layout.revision, + ); + } + const nextRoot = setSplitRatio(layout.root, command.splitId, clampSplitRatio(command.ratio)); + if (!nextRoot) { + return commandError( + "target-not-found", + `Split '${command.splitId}' does not exist.`, + layout.revision, + ); + } + return commit(layout, { ...layout, revision: layout.revision + 1, root: nextRoot }); + } + + case "EQUALIZE_SPLIT": { + if (!layout.root || !findSplit(layout.root, command.splitId)) { + return commandError( + "target-not-found", + `Split '${command.splitId}' does not exist.`, + layout.revision, + ); + } + const nextRoot = setSplitRatio(layout.root, command.splitId, 0.5); + if (!nextRoot) { + return commandError( + "target-not-found", + `Split '${command.splitId}' does not exist.`, + layout.revision, + ); + } + return commit(layout, { ...layout, revision: layout.revision + 1, root: nextRoot }); + } + + case "FOCUS_PANE": { + if (!layout.root || !layout.panes[command.paneId] || !findLeaf(layout.root, command.paneId)) { + return commandError( + "pane-not-found", + `Pane '${command.paneId}' does not exist.`, + layout.revision, + ); + } + if (layout.focusedPaneId === command.paneId) { + return { ok: true, layout }; + } + return commit(layout, { + ...layout, + revision: layout.revision + 1, + focusedPaneId: command.paneId, + }); + } + } +} diff --git a/crates/agent-ui/src/lib/workbench/types.ts b/crates/agent-ui/src/lib/workbench/types.ts new file mode 100644 index 000000000..5f504543e --- /dev/null +++ b/crates/agent-ui/src/lib/workbench/types.ts @@ -0,0 +1,121 @@ +export const WORKBENCH_LAYOUT_SCHEMA_VERSION = 1; + +export type WorkbenchAxis = "horizontal" | "vertical"; +export type WorkbenchEdge = "top" | "right" | "bottom" | "left"; + +export type ProjectRef = { + projectId: string; + projectPathKey: string; +}; + +export type ConversationWorkbenchSurface = { + kind: "conversation"; + conversationId: string; + project: ProjectRef; +}; + +export type LocalTerminalLaunchSpec = { + cwd: string; + shell?: string; + title?: string; +}; + +export type SshTerminalLaunchSpec = { + cwd: string; + sshHostId: string; + title?: string; + sftpEnabled?: boolean; +}; + +export type LocalTerminalWorkbenchSurface = { + kind: "localTerminal"; + surfaceId: string; + project: ProjectRef; + launchSpec: LocalTerminalLaunchSpec; +}; + +export type SshTerminalWorkbenchSurface = { + kind: "sshTerminal"; + surfaceId: string; + project: ProjectRef; + launchSpec: SshTerminalLaunchSpec; +}; + +/** + * Forward-compat passthrough: a persisted pane whose surface kind this build + * does not understand. It survives decode/encode round-trips untouched but can + * never be opened, and it is exempt from surface-identity uniqueness. + */ +export type UnsupportedWorkbenchSurface = { + kind: "unsupported"; + originalKind: string; + raw: Readonly>; +}; + +export type TerminalWorkbenchSurface = LocalTerminalWorkbenchSurface | SshTerminalWorkbenchSurface; + +export type WorkbenchSurfaceSpec = + | ConversationWorkbenchSurface + | TerminalWorkbenchSurface + | UnsupportedWorkbenchSurface; + +/** + * Stable identity used for the "one pane per surface" invariant. Unsupported + * surfaces return a kind-scoped key that MUST NOT be used for uniqueness — + * every identity-aware call site exempts `kind === "unsupported"` instead. + */ +export function surfaceIdentityKey(surface: WorkbenchSurfaceSpec): string { + switch (surface.kind) { + case "conversation": + return `conversation:${surface.conversationId.trim()}`; + case "localTerminal": + case "sshTerminal": + return `terminal:${surface.surfaceId.trim()}`; + case "unsupported": + return `unsupported:${surface.originalKind}:`; + } +} + +export function surfaceProjectRef(surface: WorkbenchSurfaceSpec): ProjectRef | null { + return surface.kind === "unsupported" ? null : surface.project; +} + +export type PaneRecord = { + paneId: string; + surface: WorkbenchSurfaceSpec; + view: { + compactChrome?: boolean; + }; +}; + +export type PaneNode = + | { + type: "leaf"; + paneId: string; + } + | { + type: "split"; + splitId: string; + axis: WorkbenchAxis; + ratio: number; + first: PaneNode; + second: PaneNode; + }; + +export type WorkbenchLayout = { + schemaVersion: number; + revision: number; + root: PaneNode | null; + panes: Record; + focusedPaneId: string | null; +}; + +export function createEmptyWorkbenchLayout(): WorkbenchLayout { + return { + schemaVersion: WORKBENCH_LAYOUT_SCHEMA_VERSION, + revision: 0, + root: null, + panes: {}, + focusedPaneId: null, + }; +} From 394a9a5d9dc32963986810a8508cf85125f25d15 Mon Sep 17 00:00:00 2001 From: yovinchen Date: Mon, 17 Aug 2026 01:26:05 +0800 Subject: [PATCH 03/76] =?UTF-8?q?test(chat):=20=E8=A6=86=E7=9B=96=20workbe?= =?UTF-8?q?nch=20pane=20=E6=A0=91=E4=B8=8E=E7=BB=88=E7=AB=AF=20surface=20?= =?UTF-8?q?=E7=9A=84=E5=B8=83=E5=B1=80=E5=91=BD=E4=BB=A4=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../test/chat/workbench-pane-tree.test.mjs | 530 ++++++++++++++++++ .../chat/workbench-terminal-surfaces.test.mjs | 393 +++++++++++++ 2 files changed, 923 insertions(+) create mode 100644 crates/agent-gui/test/chat/workbench-pane-tree.test.mjs create mode 100644 crates/agent-gui/test/chat/workbench-terminal-surfaces.test.mjs diff --git a/crates/agent-gui/test/chat/workbench-pane-tree.test.mjs b/crates/agent-gui/test/chat/workbench-pane-tree.test.mjs new file mode 100644 index 000000000..6b89c7879 --- /dev/null +++ b/crates/agent-gui/test/chat/workbench-pane-tree.test.mjs @@ -0,0 +1,530 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +const loader = createTsModuleLoader(); +const workbench = loader.loadModule("@liveagent/ui/lib/workbench/index.ts"); + +const { + applyWorkbenchCommand, + clampRatioToMinSize, + computeWorkbenchGeometry, + createEmptyWorkbenchLayout, + decodeWorkbenchLayout, + encodeWorkbenchLayout, + findAdjacentPaneId, + hitTestWorkbenchDrop, + previewRectForDropTarget, + WORKBENCH_LAYOUT_SCHEMA_VERSION, +} = workbench; + +let splitCounter = 0; +const reducerOptions = { createSplitId: () => `split-${++splitCounter}` }; + +function pane(paneId, conversationId, projectId = "project-main") { + return { + paneId, + surface: { + kind: "conversation", + conversationId, + project: { projectId, projectPathKey: `/workspace/${projectId}` }, + }, + view: {}, + }; +} + +function apply(layout, command) { + return applyWorkbenchCommand( + layout, + { expectedRevision: layout.revision, ...command }, + reducerOptions, + ); +} + +function mustApply(layout, command) { + const result = apply(layout, command); + assert.equal(result.ok, true, `command ${command.type} failed: ${JSON.stringify(result)}`); + return result.layout; +} + +function openRoot(conversationId = "conversation-a", paneId = "pane-a") { + return mustApply(createEmptyWorkbenchLayout(), { + type: "OPEN_PANE", + pane: pane(paneId, conversationId), + target: { kind: "canvas-empty" }, + }); +} + +function leafIds(node) { + if (!node) return []; + if (node.type === "leaf") return [node.paneId]; + return [...leafIds(node.first), ...leafIds(node.second)]; +} + +const CANVAS = { left: 0, top: 0, width: 1200, height: 800 }; + +test("root open creates a focused single-pane layout", () => { + const layout = openRoot(); + assert.equal(layout.revision, 1); + assert.deepEqual(layout.root, { type: "leaf", paneId: "pane-a" }); + assert.equal(layout.focusedPaneId, "pane-a"); + assert.ok(layout.panes["pane-a"]); +}); + +test("open pane splits target pane on all four edges", () => { + for (const [edge, axis, newFirst] of [ + ["left", "horizontal", true], + ["right", "horizontal", false], + ["top", "vertical", true], + ["bottom", "vertical", false], + ]) { + const layout = mustApply(openRoot(), { + type: "OPEN_PANE", + pane: pane("pane-b", "conversation-b"), + target: { kind: "pane-edge", paneId: "pane-a", edge }, + }); + assert.equal(layout.root.type, "split"); + assert.equal(layout.root.axis, axis); + assert.equal(layout.root.ratio, 0.5); + const expectedOrder = newFirst ? ["pane-b", "pane-a"] : ["pane-a", "pane-b"]; + assert.deepEqual(leafIds(layout.root), expectedOrder); + assert.equal(layout.focusedPaneId, "pane-b"); + } +}); + +test("canvas-edge open performs a root-level split around the whole tree", () => { + let layout = openRoot(); + layout = mustApply(layout, { + type: "OPEN_PANE", + pane: pane("pane-b", "conversation-b"), + target: { kind: "pane-edge", paneId: "pane-a", edge: "right" }, + }); + const withRootSplit = mustApply(layout, { + type: "OPEN_PANE", + pane: pane("pane-c", "conversation-c"), + target: { kind: "canvas-edge", edge: "bottom" }, + }); + assert.equal(withRootSplit.root.type, "split"); + assert.equal(withRootSplit.root.axis, "vertical"); + assert.deepEqual(leafIds(withRootSplit.root), ["pane-a", "pane-b", "pane-c"]); + assert.equal(withRootSplit.root.second.type, "leaf"); +}); + +test("canvas-edge open on an empty canvas degrades to a root open", () => { + const layout = mustApply(createEmptyWorkbenchLayout(), { + type: "OPEN_PANE", + pane: pane("pane-a", "conversation-a"), + target: { kind: "canvas-edge", edge: "left" }, + }); + assert.deepEqual(layout.root, { type: "leaf", paneId: "pane-a" }); +}); + +test("duplicate conversation open is rejected without mutation", () => { + const layout = openRoot(); + const result = apply(layout, { + type: "OPEN_PANE", + pane: pane("pane-dup", "conversation-a"), + target: { kind: "pane-edge", paneId: "pane-a", edge: "right" }, + }); + assert.equal(result.ok, false); + assert.equal(result.error.code, "duplicate-conversation"); + assert.equal(result.error.currentRevision, layout.revision); +}); + +test("stale revision commands are rejected", () => { + const layout = openRoot(); + const result = applyWorkbenchCommand( + layout, + { + expectedRevision: layout.revision - 1, + type: "FOCUS_PANE", + paneId: "pane-a", + }, + reducerOptions, + ); + assert.equal(result.ok, false); + assert.equal(result.error.code, "stale-revision"); +}); + +test("close collapses the parent split and transfers focus to the sibling", () => { + let layout = openRoot(); + layout = mustApply(layout, { + type: "OPEN_PANE", + pane: pane("pane-b", "conversation-b"), + target: { kind: "pane-edge", paneId: "pane-a", edge: "right" }, + }); + assert.equal(layout.focusedPaneId, "pane-b"); + const closed = mustApply(layout, { type: "CLOSE_PANE", paneId: "pane-b" }); + assert.deepEqual(closed.root, { type: "leaf", paneId: "pane-a" }); + assert.equal(closed.focusedPaneId, "pane-a"); + assert.equal(closed.panes["pane-b"], undefined); +}); + +test("closing the last pane empties the layout", () => { + const closed = mustApply(openRoot(), { type: "CLOSE_PANE", paneId: "pane-a" }); + assert.equal(closed.root, null); + assert.equal(closed.focusedPaneId, null); + assert.deepEqual(closed.panes, {}); +}); + +test("edge move detaches the pane, then grafts it at the target edge", () => { + let layout = openRoot(); + layout = mustApply(layout, { + type: "OPEN_PANE", + pane: pane("pane-b", "conversation-b"), + target: { kind: "pane-edge", paneId: "pane-a", edge: "right" }, + }); + const recordBefore = layout.panes["pane-b"]; + const moved = mustApply(layout, { + type: "MOVE_PANE", + paneId: "pane-b", + target: { kind: "pane-edge", paneId: "pane-a", edge: "top" }, + }); + assert.equal(moved.root.axis, "vertical"); + assert.deepEqual(leafIds(moved.root), ["pane-b", "pane-a"]); + assert.equal(moved.panes["pane-b"], recordBefore, "move must not recreate the pane record"); + assert.equal(moved.focusedPaneId, "pane-b"); +}); + +test("moving a pane onto its own edge or center is rejected", () => { + const layout = openRoot(); + for (const target of [ + { kind: "pane-edge", paneId: "pane-a", edge: "left" }, + { kind: "pane-center", paneId: "pane-a" }, + ]) { + const result = apply(layout, { type: "MOVE_PANE", paneId: "pane-a", target }); + assert.equal(result.ok, false); + assert.equal(result.error.code, "target-not-found"); + } +}); + +test("move to a divider that collapsed with the detach is safely rejected", () => { + let layout = openRoot(); + layout = mustApply(layout, { + type: "OPEN_PANE", + pane: pane("pane-b", "conversation-b"), + target: { kind: "pane-edge", paneId: "pane-a", edge: "right" }, + }); + const rootSplitId = layout.root.splitId; + const result = apply(layout, { + type: "MOVE_PANE", + paneId: "pane-b", + target: { kind: "divider", splitId: rootSplitId, edge: "left" }, + }); + assert.equal(result.ok, false); + assert.equal(result.error.code, "target-not-found"); +}); + +test("pane-center move swaps the two panes in place", () => { + let layout = openRoot(); + layout = mustApply(layout, { + type: "OPEN_PANE", + pane: pane("pane-b", "conversation-b"), + target: { kind: "pane-edge", paneId: "pane-a", edge: "right" }, + }); + const swapped = mustApply(layout, { + type: "MOVE_PANE", + paneId: "pane-a", + target: { kind: "pane-center", paneId: "pane-b" }, + }); + assert.deepEqual(leafIds(swapped.root), ["pane-b", "pane-a"]); + assert.equal(swapped.root.splitId, layout.root.splitId, "swap keeps the split node"); +}); + +test("SWAP_PANES exchanges leaf positions and keeps focus valid", () => { + let layout = openRoot(); + layout = mustApply(layout, { + type: "OPEN_PANE", + pane: pane("pane-b", "conversation-b"), + target: { kind: "pane-edge", paneId: "pane-a", edge: "bottom" }, + }); + const swapped = mustApply(layout, { + type: "SWAP_PANES", + firstPaneId: "pane-a", + secondPaneId: "pane-b", + }); + assert.deepEqual(leafIds(swapped.root), ["pane-b", "pane-a"]); + assert.equal(swapped.focusedPaneId, "pane-b"); +}); + +test("divider insert places the new pane between the split children", () => { + let layout = openRoot(); + layout = mustApply(layout, { + type: "OPEN_PANE", + pane: pane("pane-b", "conversation-b"), + target: { kind: "pane-edge", paneId: "pane-a", edge: "right" }, + }); + const splitId = layout.root.splitId; + + const beforeSide = mustApply(layout, { + type: "OPEN_PANE", + pane: pane("pane-c", "conversation-c"), + target: { kind: "divider", splitId, edge: "left" }, + }); + assert.deepEqual(leafIds(beforeSide.root), ["pane-a", "pane-c", "pane-b"]); + + const afterSide = mustApply(layout, { + type: "OPEN_PANE", + pane: pane("pane-c", "conversation-c"), + target: { kind: "divider", splitId, edge: "right" }, + }); + assert.deepEqual(leafIds(afterSide.root), ["pane-a", "pane-c", "pane-b"]); +}); + +test("resize clamps the ratio and equalize restores 0.5", () => { + let layout = openRoot(); + layout = mustApply(layout, { + type: "OPEN_PANE", + pane: pane("pane-b", "conversation-b"), + target: { kind: "pane-edge", paneId: "pane-a", edge: "right" }, + }); + const splitId = layout.root.splitId; + const resized = mustApply(layout, { type: "RESIZE_SPLIT", splitId, ratio: 0.001 }); + assert.equal(resized.root.ratio, 0.05); + const oversized = mustApply(resized, { type: "RESIZE_SPLIT", splitId, ratio: 4 }); + assert.equal(oversized.root.ratio, 0.95); + const equalized = mustApply(oversized, { type: "EQUALIZE_SPLIT", splitId }); + assert.equal(equalized.root.ratio, 0.5); +}); + +test("focus command validates the pane and no-ops when already focused", () => { + let layout = openRoot(); + layout = mustApply(layout, { + type: "OPEN_PANE", + pane: pane("pane-b", "conversation-b"), + target: { kind: "pane-edge", paneId: "pane-a", edge: "right" }, + }); + const focused = mustApply(layout, { type: "FOCUS_PANE", paneId: "pane-a" }); + assert.equal(focused.focusedPaneId, "pane-a"); + const noop = mustApply(focused, { type: "FOCUS_PANE", paneId: "pane-a" }); + assert.equal(noop.revision, focused.revision); + const missing = apply(focused, { type: "FOCUS_PANE", paneId: "pane-zzz" }); + assert.equal(missing.ok, false); + assert.equal(missing.error.code, "pane-not-found"); +}); + +test("geometry tiles the canvas with integers, no gaps and no overlaps", () => { + let layout = openRoot(); + layout = mustApply(layout, { + type: "OPEN_PANE", + pane: pane("pane-b", "conversation-b"), + target: { kind: "pane-edge", paneId: "pane-a", edge: "right" }, + }); + layout = mustApply(layout, { + type: "OPEN_PANE", + pane: pane("pane-c", "conversation-c"), + target: { kind: "pane-edge", paneId: "pane-b", edge: "bottom" }, + }); + layout = mustApply(layout, { + type: "RESIZE_SPLIT", + splitId: layout.root.splitId, + ratio: 0.3337, + }); + + const geometry = computeWorkbenchGeometry(layout.root, CANVAS, { dividerSize: 8 }); + assert.equal(geometry.panes.length, 3); + assert.equal(geometry.dividers.length, 2); + + let area = 0; + for (const item of [...geometry.panes, ...geometry.dividers]) { + for (const value of [item.rect.left, item.rect.top, item.rect.width, item.rect.height]) { + assert.equal(Number.isInteger(value), true, "geometry must be integer pixels"); + } + area += item.rect.width * item.rect.height; + } + assert.equal(area, CANVAS.width * CANVAS.height, "panes + dividers must tile the canvas"); + + for (let i = 0; i < geometry.panes.length; i += 1) { + for (let j = i + 1; j < geometry.panes.length; j += 1) { + const a = geometry.panes[i].rect; + const b = geometry.panes[j].rect; + const overlaps = + a.left < b.left + b.width && + b.left < a.left + a.width && + a.top < b.top + b.height && + b.top < a.top + a.height; + assert.equal(overlaps, false, "pane rects must not overlap"); + } + } +}); + +test("hit testing priority is canvas-edge > divider > pane-edge > pane-center", () => { + let layout = openRoot(); + layout = mustApply(layout, { + type: "OPEN_PANE", + pane: pane("pane-b", "conversation-b"), + target: { kind: "pane-edge", paneId: "pane-a", edge: "right" }, + }); + const geometry = computeWorkbenchGeometry(layout.root, CANVAS, { dividerSize: 8 }); + const dividerX = geometry.dividers[0].rect.left + 4; + + assert.deepEqual(hitTestWorkbenchDrop(geometry, 4, 400), { kind: "canvas-edge", edge: "left" }); + assert.deepEqual(hitTestWorkbenchDrop(geometry, dividerX, 8), { kind: "canvas-edge", edge: "top" }); + assert.equal(hitTestWorkbenchDrop(geometry, dividerX, 400).kind, "divider"); + assert.deepEqual(hitTestWorkbenchDrop(geometry, 80, 400), { + kind: "pane-edge", + paneId: "pane-a", + edge: "left", + }); + assert.deepEqual(hitTestWorkbenchDrop(geometry, geometry.panes[0].rect.width / 2, 400), { + kind: "pane-center", + paneId: "pane-a", + }); + assert.equal(hitTestWorkbenchDrop(geometry, -10, 400), null); +}); + +test("hit testing an empty canvas returns canvas-empty", () => { + const geometry = computeWorkbenchGeometry(null, CANVAS); + assert.deepEqual(hitTestWorkbenchDrop(geometry, 600, 400), { kind: "canvas-empty" }); +}); + +test("drop preview rects mirror the 0.5-ratio insertion result", () => { + let layout = openRoot(); + layout = mustApply(layout, { + type: "OPEN_PANE", + pane: pane("pane-b", "conversation-b"), + target: { kind: "pane-edge", paneId: "pane-a", edge: "right" }, + }); + const geometry = computeWorkbenchGeometry(layout.root, CANVAS, { dividerSize: 8 }); + + const canvasPreview = previewRectForDropTarget(geometry, { kind: "canvas-edge", edge: "right" }); + assert.equal(canvasPreview.left, CANVAS.width - Math.floor(CANVAS.width / 2)); + assert.equal(canvasPreview.width, Math.floor(CANVAS.width / 2)); + + const paneA = geometry.panes.find((item) => item.paneId === "pane-a"); + const panePreview = previewRectForDropTarget(geometry, { + kind: "pane-edge", + paneId: "pane-a", + edge: "bottom", + }); + assert.equal(panePreview.height, Math.floor(paneA.rect.height / 2)); + assert.equal(panePreview.top + panePreview.height, paneA.rect.top + paneA.rect.height); + + const divider = geometry.dividers[0]; + const dividerPreview = previewRectForDropTarget(geometry, { + kind: "divider", + splitId: divider.splitId, + edge: "left", + }); + assert.equal(dividerPreview.left + dividerPreview.width, divider.rect.left); +}); + +test("keyboard adjacency picks the nearest pane with perpendicular overlap", () => { + let layout = openRoot(); + layout = mustApply(layout, { + type: "OPEN_PANE", + pane: pane("pane-b", "conversation-b"), + target: { kind: "pane-edge", paneId: "pane-a", edge: "right" }, + }); + layout = mustApply(layout, { + type: "OPEN_PANE", + pane: pane("pane-c", "conversation-c"), + target: { kind: "pane-edge", paneId: "pane-b", edge: "bottom" }, + }); + const geometry = computeWorkbenchGeometry(layout.root, CANVAS, { dividerSize: 8 }); + + assert.equal(findAdjacentPaneId(geometry, "pane-a", "right"), "pane-b"); + assert.equal(findAdjacentPaneId(geometry, "pane-b", "bottom"), "pane-c"); + assert.equal(findAdjacentPaneId(geometry, "pane-c", "left"), "pane-a"); + assert.equal(findAdjacentPaneId(geometry, "pane-a", "left"), null); +}); + +test("ratio min-size clamping keeps both sides above the pane minimum", () => { + const splitArea = { left: 0, top: 0, width: 1008, height: 600 }; + const clamped = clampRatioToMinSize({ + ratio: 0.05, + axis: "horizontal", + splitArea, + minSize: 320, + dividerSize: 8, + }); + assert.equal(clamped, 0.32); + const tiny = clampRatioToMinSize({ + ratio: 0.9, + axis: "horizontal", + splitArea: { left: 0, top: 0, width: 400, height: 600 }, + minSize: 320, + dividerSize: 8, + }); + assert.equal(tiny, 0.5, "regions too small for both minimums equalize instead"); +}); + +test("codec round-trips a valid layout without repairs", () => { + let layout = openRoot(); + layout = mustApply(layout, { + type: "OPEN_PANE", + pane: pane("pane-b", "conversation-b", "project-second"), + target: { kind: "pane-edge", paneId: "pane-a", edge: "right" }, + }); + const decoded = decodeWorkbenchLayout(encodeWorkbenchLayout(layout)); + assert.equal(decoded.ok, true); + assert.equal(decoded.repaired, false); + assert.deepEqual(decoded.layout, layout); +}); + +test("codec rejects corrupted JSON and unsupported schema versions", () => { + assert.deepEqual(decodeWorkbenchLayout("{not json"), { ok: false, reason: "corrupted-json" }); + assert.deepEqual(decodeWorkbenchLayout('"a string"'), { ok: false, reason: "corrupted-json" }); + const future = JSON.stringify({ + schemaVersion: WORKBENCH_LAYOUT_SCHEMA_VERSION + 1, + revision: 0, + root: null, + panes: {}, + focusedPaneId: null, + }); + assert.deepEqual(decodeWorkbenchLayout(future), { ok: false, reason: "unsupported-schema" }); +}); + +test("codec repairs leaves without records and collapses their splits", () => { + let layout = openRoot(); + layout = mustApply(layout, { + type: "OPEN_PANE", + pane: pane("pane-b", "conversation-b"), + target: { kind: "pane-edge", paneId: "pane-a", edge: "right" }, + }); + const damaged = JSON.parse(encodeWorkbenchLayout(layout)); + delete damaged.panes["pane-b"]; + const decoded = decodeWorkbenchLayout(JSON.stringify(damaged)); + assert.equal(decoded.ok, true); + assert.equal(decoded.repaired, true); + assert.deepEqual(decoded.layout.root, { type: "leaf", paneId: "pane-a" }); + assert.equal(decoded.layout.focusedPaneId, "pane-a"); +}); + +test("codec repairs duplicate pane references and invalid focus", () => { + const payload = JSON.stringify({ + schemaVersion: WORKBENCH_LAYOUT_SCHEMA_VERSION, + revision: 5, + root: { + type: "split", + splitId: "split-x", + axis: "horizontal", + ratio: 7, + first: { type: "leaf", paneId: "pane-a" }, + second: { type: "leaf", paneId: "pane-a" }, + }, + panes: { "pane-a": pane("pane-a", "conversation-a") }, + focusedPaneId: "pane-missing", + }); + const decoded = decodeWorkbenchLayout(payload); + assert.equal(decoded.ok, true); + assert.equal(decoded.repaired, true); + assert.deepEqual(decoded.layout.root, { type: "leaf", paneId: "pane-a" }); + assert.equal(decoded.layout.focusedPaneId, "pane-a"); + assert.equal(decoded.layout.revision, 5); +}); + +test("codec repairs a fully invalid tree into an empty layout", () => { + const payload = JSON.stringify({ + schemaVersion: WORKBENCH_LAYOUT_SCHEMA_VERSION, + revision: 2, + root: { type: "leaf", paneId: "pane-ghost" }, + panes: {}, + focusedPaneId: "pane-ghost", + }); + const decoded = decodeWorkbenchLayout(payload); + assert.equal(decoded.ok, true); + assert.equal(decoded.repaired, true); + assert.equal(decoded.layout.root, null); + assert.equal(decoded.layout.focusedPaneId, null); +}); diff --git a/crates/agent-gui/test/chat/workbench-terminal-surfaces.test.mjs b/crates/agent-gui/test/chat/workbench-terminal-surfaces.test.mjs new file mode 100644 index 000000000..64ba80815 --- /dev/null +++ b/crates/agent-gui/test/chat/workbench-terminal-surfaces.test.mjs @@ -0,0 +1,393 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +const loader = createTsModuleLoader(); +const workbench = loader.loadModule("@liveagent/ui/lib/workbench/index.ts"); + +const { + applyWorkbenchCommand, + collectWorkbenchLayoutIssues, + createEmptyWorkbenchLayout, + decodeWorkbenchLayout, + encodeWorkbenchLayout, + findPaneIdByConversationId, + findPaneIdBySurfaceKey, + surfaceIdentityKey, + surfaceProjectRef, + WORKBENCH_LAYOUT_SCHEMA_VERSION, +} = workbench; + +let splitCounter = 0; +const reducerOptions = { createSplitId: () => `terminal-split-${++splitCounter}` }; + +const project = (projectId = "project-main") => ({ + projectId, + projectPathKey: `/workspace/${projectId}`, +}); + +function conversationPane(paneId, conversationId, projectId = "project-main") { + return { + paneId, + surface: { kind: "conversation", conversationId, project: project(projectId) }, + view: {}, + }; +} + +function localTerminalPane(paneId, surfaceId, overrides = {}) { + return { + paneId, + surface: { + kind: "localTerminal", + surfaceId, + project: project(), + launchSpec: { cwd: "/workspace/project-main", ...overrides }, + }, + view: {}, + }; +} + +function sshTerminalPane(paneId, surfaceId, overrides = {}) { + return { + paneId, + surface: { + kind: "sshTerminal", + surfaceId, + project: project(), + launchSpec: { cwd: "/remote/home", sshHostId: "host-1", ...overrides }, + }, + view: {}, + }; +} + +function apply(layout, command) { + return applyWorkbenchCommand( + layout, + { expectedRevision: layout.revision, ...command }, + reducerOptions, + ); +} + +function mustApply(layout, command) { + const result = apply(layout, command); + assert.equal(result.ok, true, `command ${command.type} failed: ${JSON.stringify(result)}`); + return result.layout; +} + +function conversationRoot() { + return mustApply(createEmptyWorkbenchLayout(), { + type: "OPEN_PANE", + pane: conversationPane("pane-conversation", "conversation-a"), + target: { kind: "canvas-empty" }, + }); +} + +test("identity helpers distinguish surface kinds", () => { + assert.equal( + surfaceIdentityKey({ kind: "conversation", conversationId: " c1 ", project: project() }), + "conversation:c1", + ); + assert.equal(surfaceIdentityKey(localTerminalPane("p", " t1 ").surface), "terminal:t1"); + assert.equal(surfaceIdentityKey(sshTerminalPane("p", "t2").surface), "terminal:t2"); + assert.deepEqual(surfaceProjectRef(localTerminalPane("p", "t1").surface), project()); + assert.equal( + surfaceProjectRef({ kind: "unsupported", originalKind: "editor", raw: {} }), + null, + ); +}); + +test("opens a local terminal pane beside a conversation pane", () => { + let layout = conversationRoot(); + layout = mustApply(layout, { + type: "OPEN_PANE", + pane: localTerminalPane("pane-terminal", "terminal-1"), + target: { kind: "pane-edge", paneId: "pane-conversation", edge: "right" }, + }); + assert.equal(layout.focusedPaneId, "pane-terminal"); + assert.equal(Object.keys(layout.panes).length, 2); + assert.equal(findPaneIdBySurfaceKey(layout, "terminal:terminal-1"), "pane-terminal"); + assert.equal(findPaneIdByConversationId(layout, "conversation-a"), "pane-conversation"); + assert.deepEqual(collectWorkbenchLayoutIssues(layout), []); +}); + +test("rejects a second pane for the same terminal surface id", () => { + let layout = conversationRoot(); + layout = mustApply(layout, { + type: "OPEN_PANE", + pane: localTerminalPane("pane-terminal", "terminal-1"), + target: { kind: "pane-edge", paneId: "pane-conversation", edge: "right" }, + }); + const result = apply(layout, { + type: "OPEN_PANE", + pane: localTerminalPane("pane-terminal-2", "terminal-1"), + target: { kind: "pane-edge", paneId: "pane-conversation", edge: "bottom" }, + }); + assert.equal(result.ok, false); + assert.equal(result.error.code, "duplicate-surface"); +}); + +test("distinct surface ids with identical launch specs may coexist", () => { + let layout = conversationRoot(); + layout = mustApply(layout, { + type: "OPEN_PANE", + pane: localTerminalPane("pane-terminal-1", "terminal-1"), + target: { kind: "pane-edge", paneId: "pane-conversation", edge: "right" }, + }); + layout = mustApply(layout, { + type: "OPEN_PANE", + pane: localTerminalPane("pane-terminal-2", "terminal-2"), + target: { kind: "pane-edge", paneId: "pane-terminal-1", edge: "bottom" }, + }); + assert.equal(Object.keys(layout.panes).length, 3); + assert.deepEqual(collectWorkbenchLayoutIssues(layout), []); +}); + +test("rejects invalid terminal records and unsupported surfaces on open", () => { + const layout = conversationRoot(); + const missingCwd = apply(layout, { + type: "OPEN_PANE", + pane: localTerminalPane("pane-terminal", "terminal-1", { cwd: " " }), + target: { kind: "pane-edge", paneId: "pane-conversation", edge: "right" }, + }); + assert.equal(missingCwd.ok, false); + assert.equal(missingCwd.error.code, "invalid-layout"); + + const missingSurfaceId = apply(layout, { + type: "OPEN_PANE", + pane: localTerminalPane("pane-terminal", " "), + target: { kind: "pane-edge", paneId: "pane-conversation", edge: "right" }, + }); + assert.equal(missingSurfaceId.ok, false); + assert.equal(missingSurfaceId.error.code, "invalid-layout"); + + const unsupported = apply(layout, { + type: "OPEN_PANE", + pane: { + paneId: "pane-unknown", + surface: { kind: "unsupported", originalKind: "editor", raw: { kind: "editor" } }, + view: {}, + }, + target: { kind: "pane-edge", paneId: "pane-conversation", edge: "right" }, + }); + assert.equal(unsupported.ok, false); + assert.equal(unsupported.error.code, "unsupported-surface"); +}); + +test("terminal panes move, swap focus, and close like conversations", () => { + let layout = conversationRoot(); + layout = mustApply(layout, { + type: "OPEN_PANE", + pane: sshTerminalPane("pane-ssh", "terminal-ssh"), + target: { kind: "pane-edge", paneId: "pane-conversation", edge: "right" }, + }); + layout = mustApply(layout, { + type: "MOVE_PANE", + paneId: "pane-ssh", + target: { kind: "pane-edge", paneId: "pane-conversation", edge: "top" }, + }); + assert.equal(layout.focusedPaneId, "pane-ssh"); + layout = mustApply(layout, { type: "FOCUS_PANE", paneId: "pane-conversation" }); + layout = mustApply(layout, { type: "CLOSE_PANE", paneId: "pane-ssh" }); + assert.equal(layout.panes["pane-ssh"], undefined); + assert.equal(layout.focusedPaneId, "pane-conversation"); + assert.deepEqual(collectWorkbenchLayoutIssues(layout), []); +}); + +test("invariants flag duplicate terminal surfaces and invalid launch specs", () => { + let layout = conversationRoot(); + layout = mustApply(layout, { + type: "OPEN_PANE", + pane: localTerminalPane("pane-terminal-1", "terminal-1"), + target: { kind: "pane-edge", paneId: "pane-conversation", edge: "right" }, + }); + layout = mustApply(layout, { + type: "OPEN_PANE", + pane: localTerminalPane("pane-terminal-2", "terminal-2"), + target: { kind: "pane-edge", paneId: "pane-terminal-1", edge: "bottom" }, + }); + + const duplicated = { + ...layout, + panes: { + ...layout.panes, + "pane-terminal-2": { + ...layout.panes["pane-terminal-2"], + surface: { + ...layout.panes["pane-terminal-2"].surface, + surfaceId: "terminal-1", + }, + }, + }, + }; + assert.deepEqual( + collectWorkbenchLayoutIssues(duplicated).map((issue) => issue.code), + ["duplicate-surface"], + ); + + const badCwd = { + ...layout, + panes: { + ...layout.panes, + "pane-terminal-2": { + ...layout.panes["pane-terminal-2"], + surface: { + ...layout.panes["pane-terminal-2"].surface, + launchSpec: { cwd: " " }, + }, + }, + }, + }; + assert.deepEqual( + collectWorkbenchLayoutIssues(badCwd).map((issue) => issue.code), + ["invalid-pane-record"], + ); +}); + +test("codec round-trips conversation, local terminal, and ssh terminal panes", () => { + let layout = conversationRoot(); + layout = mustApply(layout, { + type: "OPEN_PANE", + pane: localTerminalPane("pane-local", "terminal-local", { shell: "zsh", title: "Build" }), + target: { kind: "pane-edge", paneId: "pane-conversation", edge: "right" }, + }); + layout = mustApply(layout, { + type: "OPEN_PANE", + pane: sshTerminalPane("pane-ssh", "terminal-ssh", { sftpEnabled: true, title: "Deploy" }), + target: { kind: "pane-edge", paneId: "pane-local", edge: "bottom" }, + }); + + const decoded = decodeWorkbenchLayout(encodeWorkbenchLayout(layout)); + assert.equal(decoded.ok, true); + assert.equal(decoded.repaired, false); + assert.deepEqual(decoded.layout.panes, layout.panes); + assert.deepEqual(decoded.layout.root, layout.root); + assert.equal(decoded.layout.focusedPaneId, layout.focusedPaneId); +}); + +test("codec preserves unknown surface kinds as unsupported passthrough", () => { + const rawSurface = { + kind: "editor", + surfaceId: "editor-1", + filePath: "/workspace/project-main/src/main.rs", + }; + const payload = JSON.stringify({ + schemaVersion: WORKBENCH_LAYOUT_SCHEMA_VERSION, + revision: 7, + root: { + type: "split", + splitId: "split-a", + axis: "horizontal", + ratio: 0.5, + first: { type: "leaf", paneId: "pane-a" }, + second: { type: "leaf", paneId: "pane-b" }, + }, + panes: { + "pane-a": conversationPane("pane-a", "conversation-a"), + "pane-b": { paneId: "pane-b", surface: rawSurface, view: {} }, + }, + focusedPaneId: "pane-a", + }); + + const decoded = decodeWorkbenchLayout(payload); + assert.equal(decoded.ok, true); + assert.equal(decoded.repaired, false); + const pane = decoded.layout.panes["pane-b"]; + assert.equal(pane.surface.kind, "unsupported"); + assert.equal(pane.surface.originalKind, "editor"); + assert.deepEqual(pane.surface.raw, rawSurface); + + // Round-trip: the raw record is written back verbatim so a newer build can + // recover it, and decoding again still tolerates the pane. + const reEncoded = encodeWorkbenchLayout(decoded.layout); + assert.deepEqual(JSON.parse(reEncoded).panes["pane-b"].surface, rawSurface); + const decodedAgain = decodeWorkbenchLayout(reEncoded); + assert.equal(decodedAgain.ok, true); + assert.equal(decodedAgain.layout.panes["pane-b"].surface.kind, "unsupported"); +}); + +test("codec drops structurally invalid terminal panes and repairs the tree", () => { + const payload = JSON.stringify({ + schemaVersion: WORKBENCH_LAYOUT_SCHEMA_VERSION, + revision: 3, + root: { + type: "split", + splitId: "split-a", + axis: "horizontal", + ratio: 0.5, + first: { type: "leaf", paneId: "pane-a" }, + second: { type: "leaf", paneId: "pane-b" }, + }, + panes: { + "pane-a": conversationPane("pane-a", "conversation-a"), + "pane-b": { + paneId: "pane-b", + surface: { + kind: "localTerminal", + surfaceId: "terminal-1", + project: project(), + launchSpec: {}, + }, + view: {}, + }, + }, + focusedPaneId: "pane-b", + }); + + const decoded = decodeWorkbenchLayout(payload); + assert.equal(decoded.ok, true); + assert.equal(decoded.repaired, true); + assert.deepEqual(Object.keys(decoded.layout.panes), ["pane-a"]); + assert.deepEqual(decoded.layout.root, { type: "leaf", paneId: "pane-a" }); + assert.equal(decoded.layout.focusedPaneId, "pane-a"); +}); + +test("codec deduplicates panes sharing one terminal surface id", () => { + const payload = JSON.stringify({ + schemaVersion: WORKBENCH_LAYOUT_SCHEMA_VERSION, + revision: 4, + root: { + type: "split", + splitId: "split-a", + axis: "vertical", + ratio: 0.5, + first: { type: "leaf", paneId: "pane-a" }, + second: { type: "leaf", paneId: "pane-b" }, + }, + panes: { + "pane-a": localTerminalPane("pane-a", "terminal-1"), + "pane-b": localTerminalPane("pane-b", "terminal-1"), + }, + focusedPaneId: "pane-a", + }); + + const decoded = decodeWorkbenchLayout(payload); + assert.equal(decoded.ok, true); + assert.equal(decoded.repaired, true); + assert.deepEqual(Object.keys(decoded.layout.panes), ["pane-a"]); +}); + +test("multiple unsupported panes may coexist without identity conflicts", () => { + const payload = JSON.stringify({ + schemaVersion: WORKBENCH_LAYOUT_SCHEMA_VERSION, + revision: 5, + root: { + type: "split", + splitId: "split-a", + axis: "horizontal", + ratio: 0.5, + first: { type: "leaf", paneId: "pane-a" }, + second: { type: "leaf", paneId: "pane-b" }, + }, + panes: { + "pane-a": { paneId: "pane-a", surface: { kind: "editor", file: "a.rs" }, view: {} }, + "pane-b": { paneId: "pane-b", surface: { kind: "editor", file: "b.rs" }, view: {} }, + }, + focusedPaneId: "pane-a", + }); + + const decoded = decodeWorkbenchLayout(payload); + assert.equal(decoded.ok, true); + assert.equal(decoded.repaired, false); + assert.equal(Object.keys(decoded.layout.panes).length, 2); + assert.deepEqual(collectWorkbenchLayoutIssues(decoded.layout), []); +}); From d7fb93d750f5741089f4c56ff6e6e47bd5b7fc13 Mon Sep 17 00:00:00 2001 From: yovinchen Date: Mon, 17 Aug 2026 01:52:47 +0800 Subject: [PATCH 04/76] =?UTF-8?q?feat(ui):=20=E5=AE=9E=E7=8E=B0=20Workbenc?= =?UTF-8?q?hCanvas=20=E7=94=BB=E5=B8=83=E4=B8=8E=20pane=20=E6=A1=86?= =?UTF-8?q?=E6=9E=B6=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PaneFrame/PaneChrome/PaneSurfaceLayer 分层渲染,预览几何与提交几何分离 - DividerLayer 分栏拖拽,DockIntentOverlay 停靠意图高亮 - 本地终端与不支持类型的 pane surface 占位 - 画布契约测试:drop 经 revision 校验后提交 --- .../chat/workbench-canvas-contracts.test.mjs | 107 ++++++++++ .../src/components/workbench/DividerLayer.tsx | 185 ++++++++++++++++++ .../workbench/DockIntentOverlay.tsx | 35 ++++ .../src/components/workbench/PaneChrome.tsx | 86 ++++++++ .../src/components/workbench/PaneFrame.tsx | 55 ++++++ .../components/workbench/PaneSurfaceLayer.tsx | 70 +++++++ .../components/workbench/WorkbenchCanvas.tsx | 183 +++++++++++++++++ .../workbench/WorkbenchEmptyState.tsx | 19 ++ .../src/components/workbench/index.ts | 9 + .../surfaces/LocalTerminalPaneSurface.tsx | 110 +++++++++++ .../surfaces/UnsupportedPaneSurface.tsx | 23 +++ 11 files changed, 882 insertions(+) create mode 100644 crates/agent-gui/test/chat/workbench-canvas-contracts.test.mjs create mode 100644 crates/agent-ui/src/components/workbench/DividerLayer.tsx create mode 100644 crates/agent-ui/src/components/workbench/DockIntentOverlay.tsx create mode 100644 crates/agent-ui/src/components/workbench/PaneChrome.tsx create mode 100644 crates/agent-ui/src/components/workbench/PaneFrame.tsx create mode 100644 crates/agent-ui/src/components/workbench/PaneSurfaceLayer.tsx create mode 100644 crates/agent-ui/src/components/workbench/WorkbenchCanvas.tsx create mode 100644 crates/agent-ui/src/components/workbench/WorkbenchEmptyState.tsx create mode 100644 crates/agent-ui/src/components/workbench/index.ts create mode 100644 crates/agent-ui/src/components/workbench/surfaces/LocalTerminalPaneSurface.tsx create mode 100644 crates/agent-ui/src/components/workbench/surfaces/UnsupportedPaneSurface.tsx diff --git a/crates/agent-gui/test/chat/workbench-canvas-contracts.test.mjs b/crates/agent-gui/test/chat/workbench-canvas-contracts.test.mjs new file mode 100644 index 000000000..fff415ed0 --- /dev/null +++ b/crates/agent-gui/test/chat/workbench-canvas-contracts.test.mjs @@ -0,0 +1,107 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +function readSource(relativePath) { + return readFileSync(new URL(relativePath, import.meta.url), "utf8"); +} + +const paneChromeSource = readSource( + "../../../agent-ui/src/components/workbench/PaneChrome.tsx", +); +const paneFrameSource = readSource("../../../agent-ui/src/components/workbench/PaneFrame.tsx"); +const paneSurfaceLayerSource = readSource( + "../../../agent-ui/src/components/workbench/PaneSurfaceLayer.tsx", +); +const dividerLayerSource = readSource( + "../../../agent-ui/src/components/workbench/DividerLayer.tsx", +); +const workbenchCanvasSource = readSource( + "../../../agent-ui/src/components/workbench/WorkbenchCanvas.tsx", +); +const chatPageSource = readSource("../../src/pages/ChatPage.tsx"); + +test("pane chrome never carries the native window drag region", () => { + // A pane drag handle marked as a Tauri drag region would turn pane moves + // into native window moves. + assert.equal(paneChromeSource.includes("data-tauri-drag-region"), false); + assert.match(paneChromeSource, /data-workbench-pane-drag-handle/); +}); + +test("pane frames are keyed by paneId and positioned with integer rects", () => { + assert.match(paneSurfaceLayerSource, /key=\{paneId\}/); + // Stable render order independent of tree structure keeps DOM alive on move. + assert.match(paneSurfaceLayerSource, /localeCompare/); + // Stable state uses left/top/width/height, not transform-based layout. + assert.match(paneFrameSource, /left: rect\.left/); + assert.equal(paneFrameSource.includes("transform"), false); +}); + +test("dividers use pointer capture and commit once per gesture", () => { + assert.match(dividerLayerSource, /setPointerCapture/); + assert.match(dividerLayerSource, /releasePointerCapture/); + assert.match(dividerLayerSource, /requestAnimationFrame/); + assert.match(dividerLayerSource, /role="separator"/); + assert.match(dividerLayerSource, /onResizeCommit/); +}); + +test("workbench canvas separates preview geometry from committed geometry", () => { + assert.match(workbenchCanvasSource, /committedGeometry/); + assert.match(workbenchCanvasSource, /ResizeObserver/); +}); + +test("chat page keeps the legacy single-pane path behind the feature flag", () => { + assert.match(chatPageSource, /sessionWorkbench\.enabled\s*\?/); + // Legacy path keeps the stable root pane id; the workbench path owns pane + // ids through useWindowWorkbench. + assert.match(chatPageSource, /root-conversation-pane/); + assert.match(chatPageSource, /useWindowWorkbench\(/); + assert.match(chatPageSource, /WorkbenchCanvas/); +}); + +test("workbench drops route through a revision-checked commit", () => { + // Stale layout revision cancels the transaction instead of replaying it. + assert.match(chatPageSource, /commit\.revision !== workbench\.layoutRef\.current\.revision/); + // Focusing a pane selects its conversation through the legacy pipeline. + assert.match(chatPageSource, /handleSelectConversation\(conversationId\)/); +}); + +test("workspace drops create the conversation before the pane, verified by workdir", () => { + // Directory check happens inside the legacy new-conversation pipeline; the + // pane opens only after the fresh draft's workdir matches the intent. + assert.match(chatPageSource, /pendingWorkspaceOpenRef/); + assert.match(chatPageSource, /handleNewConversationForProject\(project\)/); + assert.match( + chatPageSource, + /workspaceProjectPathKey\(draftWorkdir\) === pendingWorkspaceOpen\.projectPathKey/, + ); +}); + +test("archived and missing workspaces block panes and never rebind the dock", () => { + assert.match(chatPageSource, /workbench\.projectArchived/); + assert.match(chatPageSource, /workbench\.projectMissing/); + assert.match(chatPageSource, /!archivedWorkspaceProjectPathKeys\.has\(projectPathKey\)/); +}); + +test("native file drags focus the hovered pane so drops land in it", () => { + assert.match(chatPageSource, /workbenchNativeDropHoverRef/); + assert.match(chatPageSource, /onDropPositionChange/); +}); + +test("single pane renders chromeless and frames draw no border ring", () => { + const paneSurfaceLayerSource = readSource( + "../../../agent-ui/src/components/workbench/PaneSurfaceLayer.tsx", + ); + assert.match(paneSurfaceLayerSource, /chromeless=\{paneCount < 2\}/); + const paneFrameSource = readSource("../../../agent-ui/src/components/workbench/PaneFrame.tsx"); + assert.equal(paneFrameSource.includes("ring-"), false); +}); + +test("pane chrome is a minimal strip: grab pill plus close, no title text or border", () => { + // The strip renders no visible title/path and no border; the title only + // survives as tooltip/aria metadata on the grab pill. + assert.equal(paneChromeSource.includes("border-"), false); + assert.equal(paneChromeSource.includes("{title}"), false); + assert.match(paneChromeSource, /data-workbench-pane-close/); + assert.match(paneChromeSource, /rounded-full/); +}); diff --git a/crates/agent-ui/src/components/workbench/DividerLayer.tsx b/crates/agent-ui/src/components/workbench/DividerLayer.tsx new file mode 100644 index 000000000..422e32497 --- /dev/null +++ b/crates/agent-ui/src/components/workbench/DividerLayer.tsx @@ -0,0 +1,185 @@ +import { useCallback, useRef } from "react"; +import { cn } from "../../lib/shared/utils"; +import { + clampRatioToMinSize, + type DividerGeometry, + MIN_CONVERSATION_PANE_HEIGHT, + MIN_CONVERSATION_PANE_WIDTH, +} from "../../lib/workbench/geometry"; + +export type DividerLayerProps = { + dividers: readonly DividerGeometry[]; + dividerSize: number; + separatorLabel: string; + minPaneWidth?: number; + minPaneHeight?: number; + /** Live ratio preview while dragging; at most one call per frame. */ + onResizePreview: (splitId: string, ratio: number) => void; + /** Final ratio on pointer-up / keyboard commit. */ + onResizeCommit: (splitId: string, ratio: number) => void; + /** Double-click equalizes the split back to 50/50. */ + onEqualize?: (splitId: string) => void; +}; + +const KEYBOARD_RESIZE_STEP = 0.02; + +function ratioFromPointer(divider: DividerGeometry, dividerSize: number, x: number, y: number) { + const { splitArea, axis } = divider; + const usable = (axis === "horizontal" ? splitArea.width : splitArea.height) - dividerSize; + if (usable <= 0) return 0.5; + const offset = + axis === "horizontal" + ? x - splitArea.left - dividerSize / 2 + : y - splitArea.top - dividerSize / 2; + return offset / usable; +} + +function currentRatio(divider: DividerGeometry, dividerSize: number): number { + const usable = + (divider.axis === "horizontal" ? divider.splitArea.width : divider.splitArea.height) - + dividerSize; + if (usable <= 0) return 0.5; + return divider.axis === "horizontal" + ? (divider.rect.left - divider.splitArea.left) / usable + : (divider.rect.top - divider.splitArea.top) / usable; +} + +/** + * Pointer-captured split dividers. Rendered above the pane layer; each + * divider maps pointer moves to a clamped ratio for its own split only. + */ +export function DividerLayer(props: DividerLayerProps) { + const { + dividers, + dividerSize, + separatorLabel, + minPaneWidth = MIN_CONVERSATION_PANE_WIDTH, + minPaneHeight = MIN_CONVERSATION_PANE_HEIGHT, + onResizePreview, + onResizeCommit, + onEqualize, + } = props; + const dragRef = useRef<{ + pointerId: number; + splitId: string; + lastRatio: number; + frame: number | null; + } | null>(null); + + const clampFor = useCallback( + (divider: DividerGeometry, ratio: number) => + clampRatioToMinSize({ + ratio, + axis: divider.axis, + splitArea: divider.splitArea, + minSize: divider.axis === "horizontal" ? minPaneWidth : minPaneHeight, + dividerSize, + }), + [dividerSize, minPaneHeight, minPaneWidth], + ); + + return ( + <> + {dividers.map((divider) => { + const ratioNow = clampFor(divider, currentRatio(divider, dividerSize)); + return ( +
onEqualize?.(divider.splitId)} + onPointerDown={(event) => { + if (event.button !== 0) return; + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + dragRef.current = { + pointerId: event.pointerId, + splitId: divider.splitId, + lastRatio: ratioNow, + frame: null, + }; + }} + onPointerMove={(event) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + const canvas = event.currentTarget.parentElement?.getBoundingClientRect(); + if (!canvas) return; + const ratio = clampFor( + divider, + ratioFromPointer( + divider, + dividerSize, + event.clientX - canvas.left, + event.clientY - canvas.top, + ), + ); + drag.lastRatio = ratio; + if (drag.frame !== null) return; + drag.frame = requestAnimationFrame(() => { + const active = dragRef.current; + if (active?.splitId === divider.splitId) { + active.frame = null; + onResizePreview(divider.splitId, active.lastRatio); + } + }); + }} + onPointerUp={(event) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + if (drag.frame !== null) cancelAnimationFrame(drag.frame); + dragRef.current = null; + event.currentTarget.releasePointerCapture(event.pointerId); + onResizeCommit(divider.splitId, drag.lastRatio); + }} + onPointerCancel={(event) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + if (drag.frame !== null) cancelAnimationFrame(drag.frame); + dragRef.current = null; + onResizeCommit(divider.splitId, drag.lastRatio); + }} + onKeyDown={(event) => { + const horizontal = divider.axis === "horizontal"; + const decreaseKey = horizontal ? "ArrowLeft" : "ArrowUp"; + const increaseKey = horizontal ? "ArrowRight" : "ArrowDown"; + if (event.key !== decreaseKey && event.key !== increaseKey) return; + event.preventDefault(); + const delta = + event.key === increaseKey ? KEYBOARD_RESIZE_STEP : -KEYBOARD_RESIZE_STEP; + onResizeCommit(divider.splitId, clampFor(divider, ratioNow + delta)); + }} + > +
+ ); + })} + + ); +} diff --git a/crates/agent-ui/src/components/workbench/DockIntentOverlay.tsx b/crates/agent-ui/src/components/workbench/DockIntentOverlay.tsx new file mode 100644 index 000000000..bd4962d45 --- /dev/null +++ b/crates/agent-ui/src/components/workbench/DockIntentOverlay.tsx @@ -0,0 +1,35 @@ +import type { WorkbenchRect } from "../../lib/workbench/geometry"; + +export type DockIntentOverlayProps = { + /** Final rect the drop would produce, in canvas coordinates. */ + rect: WorkbenchRect; + /** Action text, e.g. "Open on the right". */ + label?: string; +}; + +/** + * Drop preview shown only while a workbench drag is active. Pure overlay: + * never intercepts pointer events and never affects layout. + */ +export function DockIntentOverlay(props: DockIntentOverlayProps) { + const { rect, label } = props; + return ( + + ); +} diff --git a/crates/agent-ui/src/components/workbench/PaneChrome.tsx b/crates/agent-ui/src/components/workbench/PaneChrome.tsx new file mode 100644 index 000000000..b12940fc7 --- /dev/null +++ b/crates/agent-ui/src/components/workbench/PaneChrome.tsx @@ -0,0 +1,86 @@ +import { cn } from "../../lib/shared/utils"; +import { X } from "../IconSet"; + +export type PaneChromeProps = { + paneId: string; + /** Conversation title — exposed via tooltip/aria only, never rendered. */ + title: string; + isFocused: boolean; + /** Accessible labels; the chrome itself stays i18n-agnostic. */ + dragHandleLabel: string; + closeLabel: string; + onClose?: () => void; + /** Arms a workbench pane drag; activation happens after a move threshold. */ + onDragHandlePointerDown?: (event: React.PointerEvent) => void; +}; + +/** + * Minimal pane strip rendered as a fully transparent overlay: a centered grab + * pill and a close dot, both hidden until the pane is hovered (or the pill is + * keyboard-focused). The pill grows slightly on hover. This is pane chrome, + * not app chrome — it must never be marked as a native window drag region, + * otherwise pane drags would move the window instead. + */ +export function PaneChrome(props: PaneChromeProps) { + const { + paneId, + title, + isFocused, + dragHandleLabel, + closeLabel, + onClose, + onDragHandlePointerDown, + } = props; + + const revealClass = cn( + "pointer-events-auto opacity-0 transition-opacity duration-150 motion-reduce:transition-none", + "group-hover/workbench-pane:opacity-100 focus-visible:opacity-100", + ); + + return ( +
+ + {onClose ? ( + + ) : null} +
+ ); +} diff --git a/crates/agent-ui/src/components/workbench/PaneFrame.tsx b/crates/agent-ui/src/components/workbench/PaneFrame.tsx new file mode 100644 index 000000000..3d6d56252 --- /dev/null +++ b/crates/agent-ui/src/components/workbench/PaneFrame.tsx @@ -0,0 +1,55 @@ +import type { ReactNode } from "react"; +import { cn } from "../../lib/shared/utils"; +import type { WorkbenchRect } from "../../lib/workbench/geometry"; + +export type PaneFrameProps = { + paneId: string; + rect: WorkbenchRect; + isFocused: boolean; + /** + * Single-pane rendering: no chrome, no background, no focus ring — the + * frame must be visually indistinguishable from the legacy conversation + * page. + */ + chromeless?: boolean; + regionLabel: string; + /** Focus the pane on pointer-down anywhere inside the frame. */ + onFocusRequest?: () => void; + chrome?: ReactNode; + children: ReactNode; +}; + +/** + * Stable absolutely-positioned pane container. The rect updates in place — + * the frame (and its children) never remounts on move or resize, which keeps + * conversation DOM, drafts, scroll and streams alive. + * + * Chrome is a transparent overlay on top of the content (revealed on pane + * hover via the `workbench-pane` group), so panes carry no reserved title + * band and no borders. + */ +export function PaneFrame(props: PaneFrameProps) { + const { paneId, rect, isFocused, chromeless, regionLabel, onFocusRequest, chrome, children } = + props; + return ( +
+
{children}
+ {chromeless ? null : chrome} +
+ ); +} diff --git a/crates/agent-ui/src/components/workbench/PaneSurfaceLayer.tsx b/crates/agent-ui/src/components/workbench/PaneSurfaceLayer.tsx new file mode 100644 index 000000000..a33e8cd7a --- /dev/null +++ b/crates/agent-ui/src/components/workbench/PaneSurfaceLayer.tsx @@ -0,0 +1,70 @@ +import type { ReactNode } from "react"; +import type { PaneGeometry, WorkbenchRect } from "../../lib/workbench/geometry"; +import type { PaneRecord } from "../../lib/workbench/types"; +import { PaneFrame } from "./PaneFrame"; + +export type PaneSurfaceRenderContext = { + isFocused: boolean; + rect: WorkbenchRect; + paneCount: number; +}; + +export type PaneSurfaceLayerProps = { + panes: Record; + paneGeometries: readonly PaneGeometry[]; + focusedPaneId: string | null; + renderPaneContent: (pane: PaneRecord, context: PaneSurfaceRenderContext) => ReactNode; + renderPaneChrome?: (pane: PaneRecord, context: PaneSurfaceRenderContext) => ReactNode; + getPaneRegionLabel: (pane: PaneRecord) => string; + onFocusPane?: (paneId: string) => void; +}; + +/** + * Flat, stable pane layer. Frames are keyed by paneId and rendered in a + * paneId-sorted order that is independent of tree structure, so moves and + * splits only update rects — React never remounts a surviving pane's DOM. + */ +export function PaneSurfaceLayer(props: PaneSurfaceLayerProps) { + const { + panes, + paneGeometries, + focusedPaneId, + renderPaneContent, + renderPaneChrome, + getPaneRegionLabel, + onFocusPane, + } = props; + + const ordered = [...paneGeometries].sort((a, b) => a.paneId.localeCompare(b.paneId)); + const paneCount = ordered.length; + + return ( + <> + {ordered.map(({ paneId, rect }) => { + const pane = panes[paneId]; + if (!pane) return null; + const context: PaneSurfaceRenderContext = { + isFocused: focusedPaneId === paneId, + rect, + paneCount, + }; + return ( + onFocusPane(paneId) : undefined + } + chrome={renderPaneChrome?.(pane, context) ?? null} + > + {renderPaneContent(pane, context)} + + ); + })} + + ); +} diff --git a/crates/agent-ui/src/components/workbench/WorkbenchCanvas.tsx b/crates/agent-ui/src/components/workbench/WorkbenchCanvas.tsx new file mode 100644 index 000000000..4ccc047be --- /dev/null +++ b/crates/agent-ui/src/components/workbench/WorkbenchCanvas.tsx @@ -0,0 +1,183 @@ +import { type ReactNode, useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { cn } from "../../lib/shared/utils"; +import { + computeWorkbenchGeometry, + type WorkbenchGeometry, + type WorkbenchRect, +} from "../../lib/workbench/geometry"; + +import type { PaneNode, PaneRecord, WorkbenchLayout } from "../../lib/workbench/types"; +import { DividerLayer } from "./DividerLayer"; +import { DockIntentOverlay } from "./DockIntentOverlay"; +import { PaneSurfaceLayer, type PaneSurfaceRenderContext } from "./PaneSurfaceLayer"; + +/** + * Visual gutter between panes. The gutter shares the canvas background with a + * centered hairline, so panes read as flush surfaces separated by a 1px rule. + */ +export const WORKBENCH_CANVAS_DIVIDER_SIZE = 6; + +export type WorkbenchCanvasLabels = { + paneRegion: (pane: PaneRecord) => string; + separator: string; +}; + +export type WorkbenchCanvasProps = { + layout: WorkbenchLayout; + labels: WorkbenchCanvasLabels; + renderPaneContent: (pane: PaneRecord, context: PaneSurfaceRenderContext) => ReactNode; + renderPaneChrome?: (pane: PaneRecord, context: PaneSurfaceRenderContext) => ReactNode; + /** Commit a divider resize (one layout transaction per pointer-up). */ + onResizeSplit?: (splitId: string, ratio: number) => void; + /** Double-click on a divider equalizes its split. */ + onEqualizeSplit?: (splitId: string) => void; + onFocusPane?: (paneId: string) => void; + /** Reported whenever the frozen-able geometry snapshot changes. */ + onGeometryChange?: (geometry: WorkbenchGeometry) => void; + dropPreview?: { rect: WorkbenchRect; label?: string } | null; + emptyState?: ReactNode; + dividerSize?: number; + minPaneWidth?: number; + minPaneHeight?: number; + className?: string; +}; + +function withRatioOverride(node: PaneNode | null, splitId: string, ratio: number): PaneNode | null { + if (!node || node.type === "leaf") return node; + if (node.splitId === splitId) return { ...node, ratio }; + return { + ...node, + first: withRatioOverride(node.first, splitId, ratio) as PaneNode, + second: withRatioOverride(node.second, splitId, ratio) as PaneNode, + }; +} + +/** + * Window-level pane canvas: measures itself, turns the pane tree into + * integer-pixel rects, and renders the stable surface, divider and preview + * layers. Divider drags preview locally at frame rate and commit exactly one + * layout transaction on pointer-up. + */ +export function WorkbenchCanvas(props: WorkbenchCanvasProps) { + const { + layout, + labels, + renderPaneContent, + renderPaneChrome, + onResizeSplit, + onEqualizeSplit, + onFocusPane, + onGeometryChange, + dropPreview, + emptyState, + dividerSize = WORKBENCH_CANVAS_DIVIDER_SIZE, + minPaneWidth, + minPaneHeight, + className, + } = props; + + const containerRef = useRef(null); + const [canvasSize, setCanvasSize] = useState<{ width: number; height: number } | null>(null); + const [resizePreview, setResizePreview] = useState<{ splitId: string; ratio: number } | null>( + null, + ); + + useLayoutEffect(() => { + const element = containerRef.current; + if (!element) return; + const measure = () => { + const rect = element.getBoundingClientRect(); + const width = Math.round(rect.width); + const height = Math.round(rect.height); + setCanvasSize((prev) => + prev && prev.width === width && prev.height === height ? prev : { width, height }, + ); + }; + measure(); + const observer = new ResizeObserver(measure); + observer.observe(element); + return () => observer.disconnect(); + }, []); + + const previewRoot = useMemo(() => { + if (!resizePreview) return layout.root; + return withRatioOverride(layout.root, resizePreview.splitId, resizePreview.ratio); + }, [layout.root, resizePreview]); + + const geometry = useMemo(() => { + if (!canvasSize || canvasSize.width <= 0 || canvasSize.height <= 0) return null; + return computeWorkbenchGeometry( + previewRoot, + { left: 0, top: 0, width: canvasSize.width, height: canvasSize.height }, + { dividerSize }, + ); + }, [previewRoot, canvasSize, dividerSize]); + + // Only committed (non-preview) geometry is a valid drag-freeze snapshot. + const committedGeometry = useMemo(() => { + if (!resizePreview) return geometry; + if (!canvasSize || canvasSize.width <= 0 || canvasSize.height <= 0) return null; + return computeWorkbenchGeometry( + layout.root, + { left: 0, top: 0, width: canvasSize.width, height: canvasSize.height }, + { dividerSize }, + ); + }, [geometry, resizePreview, layout.root, canvasSize, dividerSize]); + + useLayoutEffect(() => { + if (committedGeometry) onGeometryChange?.(committedGeometry); + }, [committedGeometry, onGeometryChange]); + + const handleResizePreview = useCallback((splitId: string, ratio: number) => { + setResizePreview({ splitId, ratio }); + }, []); + + const handleResizeCommit = useCallback( + (splitId: string, ratio: number) => { + setResizePreview(null); + onResizeSplit?.(splitId, ratio); + }, + [onResizeSplit], + ); + + const getPaneRegionLabel = useCallback((pane: PaneRecord) => labels.paneRegion(pane), [labels]); + + return ( +
+ {layout.root === null || !geometry ? ( + layout.root === null ? ( + emptyState + ) : null + ) : ( + <> + + + {dropPreview ? ( + + ) : null} + + )} +
+ ); +} diff --git a/crates/agent-ui/src/components/workbench/WorkbenchEmptyState.tsx b/crates/agent-ui/src/components/workbench/WorkbenchEmptyState.tsx new file mode 100644 index 000000000..f624d719c --- /dev/null +++ b/crates/agent-ui/src/components/workbench/WorkbenchEmptyState.tsx @@ -0,0 +1,19 @@ +export type WorkbenchEmptyStateProps = { + title: string; + description?: string; +}; + +/** Droppable empty-canvas placeholder shown when the pane tree is empty. */ +export function WorkbenchEmptyState(props: WorkbenchEmptyStateProps) { + return ( +
+

{props.title}

+ {props.description ? ( +

{props.description}

+ ) : null} +
+ ); +} diff --git a/crates/agent-ui/src/components/workbench/index.ts b/crates/agent-ui/src/components/workbench/index.ts new file mode 100644 index 000000000..d3d9a3e0b --- /dev/null +++ b/crates/agent-ui/src/components/workbench/index.ts @@ -0,0 +1,9 @@ +export * from "./DividerLayer"; +export * from "./DockIntentOverlay"; +export * from "./PaneChrome"; +export * from "./PaneFrame"; +export * from "./PaneSurfaceLayer"; +export * from "./surfaces/LocalTerminalPaneSurface"; +export * from "./surfaces/UnsupportedPaneSurface"; +export * from "./WorkbenchCanvas"; +export * from "./WorkbenchEmptyState"; diff --git a/crates/agent-ui/src/components/workbench/surfaces/LocalTerminalPaneSurface.tsx b/crates/agent-ui/src/components/workbench/surfaces/LocalTerminalPaneSurface.tsx new file mode 100644 index 000000000..49a5c1b6f --- /dev/null +++ b/crates/agent-ui/src/components/workbench/surfaces/LocalTerminalPaneSurface.tsx @@ -0,0 +1,110 @@ +import { Loader2, Terminal } from "@liveagent/ui/components/IconSet"; +import { useLocale } from "@liveagent/ui/i18n/index"; +import { cn } from "../../../lib/shared/utils"; +import type { TerminalClient, TerminalSession } from "../../../lib/terminal/types"; +import { XTermViewport } from "../../project-tools/XTermViewport"; +import { Button } from "../../ui/button"; + +export type TerminalPaneSurfacePhase = "connecting" | "ready" | "exited" | "error"; + +export type LocalTerminalPaneSurfaceProps = { + paneId: string; + client: TerminalClient; + /** 会话未建立(connecting/失败)时为 null;非 null 时视口保持挂载以保留输出。 */ + session: TerminalSession | null; + phase: TerminalPaneSurfacePhase; + theme: "light" | "dark"; + isActive: boolean; + errorMessage?: string | null; + onRetry?: () => void; + onError: (sessionId: string, message: string | null) => void; +}; + +/** + * 终端 Pane 的纯受控展示层:本地与 SSH 首期共用。会话存在时始终渲染 + * XTermViewport(exited/error 只叠加提示条,不清屏),仅无会话可显示时 + * 才使用居中占位,保证 phase 切换不重挂视口。 + */ +export function LocalTerminalPaneSurface(props: LocalTerminalPaneSurfaceProps) { + const { paneId, client, session, phase, theme, isActive, errorMessage, onRetry, onError } = props; + const { t } = useLocale(); + + const banner = + session && phase === "error" ? ( +
+ + {errorMessage || t("workbench.terminalError")} + + {onRetry ? ( + + ) : null} +
+ ) : session && phase === "exited" ? ( +
+ + {t("workbench.terminalExited")} + {session.exitCode != null ? ( + ({session.exitCode}) + ) : null} + + {onRetry ? ( + + ) : null} +
+ ) : null; + + return ( +
+ {banner} + {session ? ( +
+ +
+ ) : ( +
+
+ {phase === "connecting" ? ( + + ) : ( + + )} +
+
+ {phase === "connecting" + ? t("workbench.terminalConnecting") + : phase === "exited" + ? t("workbench.terminalExited") + : errorMessage || t("workbench.terminalError")} +
+ {phase !== "connecting" && onRetry ? ( + + ) : null} +
+ )} +
+ ); +} diff --git a/crates/agent-ui/src/components/workbench/surfaces/UnsupportedPaneSurface.tsx b/crates/agent-ui/src/components/workbench/surfaces/UnsupportedPaneSurface.tsx new file mode 100644 index 000000000..e4f1889c2 --- /dev/null +++ b/crates/agent-ui/src/components/workbench/surfaces/UnsupportedPaneSurface.tsx @@ -0,0 +1,23 @@ +import { useLocale } from "@liveagent/ui/i18n/index"; + +export type UnsupportedPaneSurfaceProps = { + paneId: string; + originalKind: string; +}; + +/** 前向兼容占位:布局中来自更新版本的未知 Surface,只展示、可移动/关闭。 */ +export function UnsupportedPaneSurface(props: UnsupportedPaneSurfaceProps) { + const { paneId, originalKind } = props; + const { t } = useLocale(); + + return ( +
+

{t("workbench.unsupportedPane")}

+

{originalKind}

+
+ ); +} From 8fc0910bd85ce231a2edaa9eaed6c2bb400dfeaa Mon Sep 17 00:00:00 2001 From: yovinchen Date: Mon, 17 Aug 2026 02:18:31 +0800 Subject: [PATCH 05/76] =?UTF-8?q?feat(settings):=20workbench=20=E5=B8=83?= =?UTF-8?q?=E5=B1=80=E6=9C=AC=E6=9C=BA=E6=8C=81=E4=B9=85=E5=8C=96(SQLite)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 workbench_layout 表与 load/save 命令:按 scope 存储 schema_version/revision/payload,payload 上限 96KB,revision 落后即拒绝; 布局只含稳定身份与空间信息,不参与 Gateway Settings Sync --- .../src/commands/config/settings/db.rs | 7 ++ .../src/commands/config/settings/mod.rs | 1 + .../config/settings/workbench_layout.rs | 92 +++++++++++++++++++ crates/agent-gui/src-tauri/src/lib.rs | 2 + 4 files changed, 102 insertions(+) create mode 100644 crates/agent-gui/src-tauri/src/commands/config/settings/workbench_layout.rs diff --git a/crates/agent-gui/src-tauri/src/commands/config/settings/db.rs b/crates/agent-gui/src-tauri/src/commands/config/settings/db.rs index fa6571a5d..6595e1d99 100644 --- a/crates/agent-gui/src-tauri/src/commands/config/settings/db.rs +++ b/crates/agent-gui/src-tauri/src/commands/config/settings/db.rs @@ -105,6 +105,13 @@ pub(crate) fn initialize_schema(conn: &Connection) -> Result<(), String> { payload_json TEXT NOT NULL, updated_at INTEGER NOT NULL ); + CREATE TABLE IF NOT EXISTS workbench_layout ( + scope_id TEXT PRIMARY KEY NOT NULL, + schema_version INTEGER NOT NULL, + revision INTEGER NOT NULL, + payload_json TEXT NOT NULL, + updated_at INTEGER NOT NULL + ); CREATE TABLE IF NOT EXISTS workspace_root_grants ( grant_id TEXT PRIMARY KEY, project_id TEXT NOT NULL, diff --git a/crates/agent-gui/src-tauri/src/commands/config/settings/mod.rs b/crates/agent-gui/src-tauri/src/commands/config/settings/mod.rs index ed0308a78..e93c8c94d 100644 --- a/crates/agent-gui/src-tauri/src/commands/config/settings/mod.rs +++ b/crates/agent-gui/src-tauri/src/commands/config/settings/mod.rs @@ -165,5 +165,6 @@ include!("memory_settings.rs"); include!("model_failover.rs"); include!("gateway_sync.rs"); include!("ssh/mod.rs"); +include!("workbench_layout.rs"); include!("commands.rs"); include!("tests.rs"); diff --git a/crates/agent-gui/src-tauri/src/commands/config/settings/workbench_layout.rs b/crates/agent-gui/src-tauri/src/commands/config/settings/workbench_layout.rs new file mode 100644 index 000000000..179d642ea --- /dev/null +++ b/crates/agent-gui/src-tauri/src/commands/config/settings/workbench_layout.rs @@ -0,0 +1,92 @@ +// 窗口级 Session Workbench 布局的本机持久化。布局只保存稳定身份与空间信息 +// (PaneTree/ratio/focus),绝不包含消息、草稿、Secret 或 Session ID; +// 该表不参与 Gateway Settings Sync。 + +/// 单窗口布局 Payload 上限(与设计文档一致)。 +const WORKBENCH_LAYOUT_PAYLOAD_MAX_BYTES: usize = 96 * 1024; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkbenchLayoutRecord { + pub scope_id: String, + pub schema_version: i64, + pub revision: i64, + pub payload_json: String, + pub updated_at: i64, +} + +fn load_workbench_layout( + conn: &Connection, + scope_id: &str, +) -> Result, String> { + conn.query_row( + "SELECT scope_id, schema_version, revision, payload_json, updated_at + FROM workbench_layout WHERE scope_id = ?1", + params![scope_id], + |row| { + Ok(WorkbenchLayoutRecord { + scope_id: row.get(0)?, + schema_version: row.get(1)?, + revision: row.get(2)?, + payload_json: row.get(3)?, + updated_at: row.get(4)?, + }) + }, + ) + .optional() + .map_err(|e| format!("读取工作台布局失败:{e}")) +} + +fn save_workbench_layout( + conn: &Connection, + scope_id: &str, + schema_version: i64, + revision: i64, + payload_json: &str, +) -> Result<(), String> { + if scope_id.trim().is_empty() { + return Err("工作台布局 scope_id 不能为空".to_string()); + } + if payload_json.len() > WORKBENCH_LAYOUT_PAYLOAD_MAX_BYTES { + return Err("工作台布局 Payload 超过 96 KiB 上限".to_string()); + } + conn.execute( + "INSERT INTO workbench_layout (scope_id, schema_version, revision, payload_json, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(scope_id) DO UPDATE SET + schema_version = excluded.schema_version, + revision = excluded.revision, + payload_json = excluded.payload_json, + updated_at = excluded.updated_at", + params![scope_id, schema_version, revision, payload_json, now_ms()], + ) + .map_err(|e| format!("保存工作台布局失败:{e}"))?; + Ok(()) +} + +#[tauri::command] +pub async fn workbench_layout_load( + scope_id: String, +) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + let conn = open_db()?; + load_workbench_layout(&conn, &scope_id) + }) + .await + .map_err(|e| format!("workbench_layout_load join 失败:{e}"))? +} + +#[tauri::command] +pub async fn workbench_layout_save( + scope_id: String, + schema_version: i64, + revision: i64, + payload_json: String, +) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + let conn = open_db()?; + save_workbench_layout(&conn, &scope_id, schema_version, revision, &payload_json) + }) + .await + .map_err(|e| format!("workbench_layout_save join 失败:{e}"))? +} diff --git a/crates/agent-gui/src-tauri/src/lib.rs b/crates/agent-gui/src-tauri/src/lib.rs index 657b2270b..3bdfc45a7 100644 --- a/crates/agent-gui/src-tauri/src/lib.rs +++ b/crates/agent-gui/src-tauri/src/lib.rs @@ -140,6 +140,8 @@ macro_rules! app_invoke_handler { commands::settings::settings_save_remote, commands::settings::settings_save_memory, commands::settings::settings_save_model_failover, + commands::settings::workbench_layout_load, + commands::settings::workbench_layout_save, commands::update::app_update_check, commands::update::app_update_install, commands::update::app_restart, From d5a8643ac6a5f9c3408225f0d3dd4254987af00d Mon Sep 17 00:00:00 2001 From: yovinchen Date: Mon, 17 Aug 2026 02:41:19 +0800 Subject: [PATCH 06/76] =?UTF-8?q?refactor(chat):=20=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E5=AE=A1=E6=89=B9=E6=94=B9=E4=B8=BA=E6=8C=89=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E8=AE=A2=E9=98=85=E5=BF=AB=E7=85=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit toolApproval 增加 per-conversation listener 与缓存快照, PendingToolApprovalBar 支持外部注入 approvals(工作台后台 pane 复用), 避免全局 version bump 导致所有会话审批条重渲染 --- .../agent-gui/src/lib/tools/toolApproval.ts | 56 ++++++++++++++++--- .../components/PendingToolApprovalBar.tsx | 42 +++++++++++--- 2 files changed, 84 insertions(+), 14 deletions(-) diff --git a/crates/agent-gui/src/lib/tools/toolApproval.ts b/crates/agent-gui/src/lib/tools/toolApproval.ts index cd48502a1..ed31fb493 100644 --- a/crates/agent-gui/src/lib/tools/toolApproval.ts +++ b/crates/agent-gui/src/lib/tools/toolApproval.ts @@ -39,11 +39,21 @@ const sessionAllowByConversation = new Map>(); // useSyncExternalStore 订阅:pending 表变更时 bump version 并通知,驱动审批卡片 // 在挂起出现/落定时重渲染(被审批的工具调用本身早已在转录中)。 const listeners = new Set<() => void>(); +const listenersByConversation = new Map void>>(); +const pendingSnapshotsByConversation = new Map(); +const EMPTY_PENDING_APPROVALS: PendingToolApprovalSummary[] = []; +Object.freeze(EMPTY_PENDING_APPROVALS); let version = 0; -function emitChange() { +function emitChange(conversationId: string) { + const key = conversationId.trim(); version += 1; for (const listener of listeners) listener(); + if (!key) return; + pendingSnapshotsByConversation.delete(key); + const conversationListeners = listenersByConversation.get(key); + if (!conversationListeners) return; + for (const listener of Array.from(conversationListeners)) listener(); } export function subscribeToolApprovals(listener: () => void): () => void { @@ -57,6 +67,23 @@ export function getToolApprovalVersion(): number { return version; } +export function subscribeToolApprovalsForConversation( + conversationId: string, + listener: () => void, +): () => void { + const key = conversationId.trim(); + if (!key) return () => undefined; + const conversationListeners = listenersByConversation.get(key) ?? new Set(); + conversationListeners.add(listener); + listenersByConversation.set(key, conversationListeners); + return () => { + conversationListeners.delete(listener); + if (conversationListeners.size === 0) { + listenersByConversation.delete(key); + } + }; +} + export function getPendingToolApproval(toolCallId: string): PendingToolApproval | null { return pendingByToolCallId.get(toolCallId.trim()) ?? null; } @@ -96,6 +123,19 @@ export function listPendingToolApprovalsForConversation( return out; } +export function getPendingToolApprovalsSnapshot( + conversationId: string, +): PendingToolApprovalSummary[] { + const key = conversationId.trim(); + if (!key) return EMPTY_PENDING_APPROVALS; + const cached = pendingSnapshotsByConversation.get(key); + if (cached) return cached; + const pending = listPendingToolApprovalsForConversation(key); + if (pending.length === 0) return EMPTY_PENDING_APPROVALS; + pendingSnapshotsByConversation.set(key, pending); + return pending; +} + export function isSessionApproved(conversationId: string, toolName: string): boolean { return sessionAllowByConversation.get(conversationId)?.has(toolName) ?? false; } @@ -131,13 +171,14 @@ export function answerToolApproval( /** 会话销毁兜底:挂起中的审批按“取消(未批准)”落定。正常中止由 AbortSignal 处理。 */ export function cancelPendingToolApprovalsForConversation(conversationId: string) { + const targetConversationId = conversationId.trim(); for (const [toolCallId, pending] of pendingByToolCallId) { - if (pending.conversationId === conversationId) { + if (pending.conversationId === targetConversationId) { pendingByToolCallId.delete(toolCallId); pending.settle({ kind: "cancelled" }); } } - sessionAllowByConversation.delete(conversationId); + sessionAllowByConversation.delete(targetConversationId); } /** @@ -155,6 +196,7 @@ export function requestToolApproval(params: { timeoutMs?: number; }): Promise { const toolCallId = params.toolCallId.trim(); + const conversationId = params.conversationId.trim(); const timeoutMs = params.timeoutMs ?? TOOL_APPROVAL_TIMEOUT_MS; const deadlineAt = Date.now() + timeoutMs; @@ -171,15 +213,15 @@ export function requestToolApproval(params: { params.signal?.removeEventListener("abort", onAbort); clearTimeout(timeoutId); if (settlement.kind === "decided" && settlement.decision === "approve_session") { - rememberSessionApproval(params.conversationId, params.toolName); + rememberSessionApproval(conversationId, params.toolName); } - emitChange(); + emitChange(conversationId); resolve(settlement); }; const onAbort = () => settle({ kind: "cancelled" }); const timeoutId = setTimeout(() => settle({ kind: "timeout" }), Math.max(0, timeoutMs)); const pending: PendingToolApproval = { - conversationId: params.conversationId, + conversationId, toolName: params.toolName, summary: params.summary ?? "", deadlineAt, @@ -187,6 +229,6 @@ export function requestToolApproval(params: { }; pendingByToolCallId.set(toolCallId, pending); params.signal?.addEventListener("abort", onAbort, { once: true }); - emitChange(); + emitChange(conversationId); }); } diff --git a/crates/agent-gui/src/pages/chat/components/PendingToolApprovalBar.tsx b/crates/agent-gui/src/pages/chat/components/PendingToolApprovalBar.tsx index 143209712..e6824c8a6 100644 --- a/crates/agent-gui/src/pages/chat/components/PendingToolApprovalBar.tsx +++ b/crates/agent-gui/src/pages/chat/components/PendingToolApprovalBar.tsx @@ -1,15 +1,35 @@ import { ToolApprovalBar } from "@liveagent/ui/components/chat/ToolApprovalBar"; -import { useSyncExternalStore } from "react"; +import { useCallback, useSyncExternalStore } from "react"; import { answerToolApproval, - getToolApprovalVersion, - listPendingToolApprovalsForConversation, - subscribeToolApprovals, + getPendingToolApprovalsSnapshot, + type PendingToolApprovalSummary, + subscribeToolApprovalsForConversation, } from "../../../lib/tools/toolApproval"; -export function PendingToolApprovalBar({ conversationId }: { conversationId: string }) { - useSyncExternalStore(subscribeToolApprovals, getToolApprovalVersion, getToolApprovalVersion); - const pending = listPendingToolApprovalsForConversation(conversationId); +type PendingToolApprovalBarProps = { + conversationId: string; + approvals?: PendingToolApprovalSummary[]; +}; + +function SubscribedPendingToolApprovalBar({ conversationId }: { conversationId: string }) { + const subscribe = useCallback( + (listener: () => void) => subscribeToolApprovalsForConversation(conversationId, listener), + [conversationId], + ); + const getSnapshot = useCallback( + () => getPendingToolApprovalsSnapshot(conversationId), + [conversationId], + ); + const pending = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + return ; +} + +function PendingToolApprovalBarContent(props: { + conversationId: string; + pending: PendingToolApprovalSummary[]; +}) { + const { conversationId, pending } = props; if (pending.length === 0) return null; return ( @@ -26,3 +46,11 @@ export function PendingToolApprovalBar({ conversationId }: { conversationId: str /> ); } + +export function PendingToolApprovalBar(props: PendingToolApprovalBarProps) { + const { conversationId, approvals } = props; + if (approvals) { + return ; + } + return ; +} From 0ba6c9a0ec240b91f18ef0061cff243fd05265ba Mon Sep 17 00:00:00 2001 From: yovinchen Date: Mon, 17 Aug 2026 02:58:36 +0800 Subject: [PATCH 07/76] =?UTF-8?q?refactor(chat):=20=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E6=80=81=E6=94=B6=E6=95=9B=E4=B8=BA=E5=8F=AF=E8=AE=A2=E9=98=85?= =?UTF-8?q?=20store,=E8=84=B1=E7=A6=BB=E5=BD=93=E5=89=8D=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 草稿/队列/上传/审批各自成 store,useSyncExternalStore 订阅 - 运行时缓存改为可观察 registry,setRuntimeEntry 通知工作台 pane - 上下文用量源抽出纯工厂 createContextUsageTokensSource, 供后台 pane 每面板各建一份 为多 pane 并行会话做准备:后台会话的队列与上传不再依赖挂载中的 ChatPage 状态 --- .../chat/composer/useComposerDraftCache.ts | 12 +- .../conversationApprovalStore.ts | 19 +++ .../conversations/conversationDraftStore.ts | 61 ++++++++ .../conversations/conversationQueueStore.ts | 103 ++++++++++++ .../conversations/conversationUploadStore.ts | 78 ++++++++++ .../createConversationRuntimeRegistry.ts | 146 ++++++++++++++++++ .../chat/hooks/useChatPageRuntimeStore.ts | 4 +- .../chat/hooks/useContextUsageTokensSource.ts | 105 ++++++++----- .../src/pages/chat/hooks/usePendingUploads.ts | 86 +++++------ .../src/pages/chat/queue/useChatTurnQueue.ts | 42 ++++- .../src/pages/chat/runtime/chatPageRuntime.ts | 7 + .../test/chat/chat-stop-timing.test.mjs | 3 + 12 files changed, 567 insertions(+), 99 deletions(-) create mode 100644 crates/agent-gui/src/pages/chat/conversations/conversationApprovalStore.ts create mode 100644 crates/agent-gui/src/pages/chat/conversations/conversationDraftStore.ts create mode 100644 crates/agent-gui/src/pages/chat/conversations/conversationQueueStore.ts create mode 100644 crates/agent-gui/src/pages/chat/conversations/conversationUploadStore.ts create mode 100644 crates/agent-gui/src/pages/chat/conversations/createConversationRuntimeRegistry.ts diff --git a/crates/agent-gui/src/pages/chat/composer/useComposerDraftCache.ts b/crates/agent-gui/src/pages/chat/composer/useComposerDraftCache.ts index 57960c498..0afb07e78 100644 --- a/crates/agent-gui/src/pages/chat/composer/useComposerDraftCache.ts +++ b/crates/agent-gui/src/pages/chat/composer/useComposerDraftCache.ts @@ -1,14 +1,13 @@ -import type { - MentionComposerDraft, - MentionComposerHandle, -} from "@liveagent/ui/components/chat/MentionComposer"; +import type { MentionComposerHandle } from "@liveagent/ui/components/chat/MentionComposer"; import { type MutableRefObject, useEffect, useRef } from "react"; +import type { ConversationDraftStore } from "../conversations/conversationDraftStore"; type UseComposerDraftCacheParams = { composerRef: MutableRefObject; currentConversationIdRef: MutableRefObject; activeView: "chat" | "skills-hub" | "mcp-hub"; currentConversationId: string; + draftStore: ConversationDraftStore; }; /** @@ -18,8 +17,9 @@ type UseComposerDraftCacheParams = { * belongs to, so restores never clobber freshly-typed input. */ export function useComposerDraftCache(params: UseComposerDraftCacheParams) { - const { composerRef, currentConversationIdRef, activeView, currentConversationId } = params; - const composerDraftCacheRef = useRef>(new Map()); + const { composerRef, currentConversationIdRef, activeView, currentConversationId, draftStore } = + params; + const composerDraftCacheRef = useRef(draftStore); const composerDraftOwnerRef = useRef(currentConversationId); function cacheActiveComposerDraft(conversationId = composerDraftOwnerRef.current) { diff --git a/crates/agent-gui/src/pages/chat/conversations/conversationApprovalStore.ts b/crates/agent-gui/src/pages/chat/conversations/conversationApprovalStore.ts new file mode 100644 index 000000000..4db05215c --- /dev/null +++ b/crates/agent-gui/src/pages/chat/conversations/conversationApprovalStore.ts @@ -0,0 +1,19 @@ +import { + getPendingToolApprovalsSnapshot, + type PendingToolApprovalSummary, + subscribeToolApprovalsForConversation, +} from "../../../lib/tools/toolApproval"; + +export class ConversationApprovalStore { + getSnapshot(conversationId: string): PendingToolApprovalSummary[] { + return getPendingToolApprovalsSnapshot(conversationId); + } + + subscribe(conversationId: string, listener: () => void): () => void { + return subscribeToolApprovalsForConversation(conversationId, listener); + } +} + +export function createConversationApprovalStore(): ConversationApprovalStore { + return new ConversationApprovalStore(); +} diff --git a/crates/agent-gui/src/pages/chat/conversations/conversationDraftStore.ts b/crates/agent-gui/src/pages/chat/conversations/conversationDraftStore.ts new file mode 100644 index 000000000..5d308499d --- /dev/null +++ b/crates/agent-gui/src/pages/chat/conversations/conversationDraftStore.ts @@ -0,0 +1,61 @@ +import type { MentionComposerDraft } from "@liveagent/ui/components/chat/MentionComposer"; + +export type ConversationDraftListener = () => void; + +export class ConversationDraftStore extends Map { + readonly #listenersByConversation = new Map>(); + + getSnapshot(conversationId: string): MentionComposerDraft | null { + return this.get(conversationId.trim()) ?? null; + } + + subscribe(conversationId: string, listener: ConversationDraftListener): () => void { + const key = conversationId.trim(); + if (!key) return () => undefined; + const listeners = this.#listenersByConversation.get(key) ?? new Set(); + listeners.add(listener); + this.#listenersByConversation.set(key, listeners); + return () => { + listeners.delete(listener); + if (listeners.size === 0) { + this.#listenersByConversation.delete(key); + } + }; + } + + set(conversationId: string, draft: MentionComposerDraft): this { + const key = conversationId.trim(); + if (!key) return this; + super.set(key, draft); + this.#emit(key); + return this; + } + + delete(conversationId: string): boolean { + const key = conversationId.trim(); + if (!key) return false; + const deleted = super.delete(key); + if (deleted) this.#emit(key); + return deleted; + } + + clear(): void { + const conversationIds = Array.from(this.keys()); + super.clear(); + for (const conversationId of conversationIds) { + this.#emit(conversationId); + } + } + + #emit(conversationId: string): void { + const listeners = this.#listenersByConversation.get(conversationId); + if (!listeners) return; + for (const listener of Array.from(listeners)) { + listener(); + } + } +} + +export function createConversationDraftStore(): ConversationDraftStore { + return new ConversationDraftStore(); +} diff --git a/crates/agent-gui/src/pages/chat/conversations/conversationQueueStore.ts b/crates/agent-gui/src/pages/chat/conversations/conversationQueueStore.ts new file mode 100644 index 000000000..a02124e43 --- /dev/null +++ b/crates/agent-gui/src/pages/chat/conversations/conversationQueueStore.ts @@ -0,0 +1,103 @@ +import type { QueuedChatTurn } from "../queue/chatTurnQueue"; + +export type ConversationQueueListener = () => void; + +const EMPTY_QUEUE: QueuedChatTurn[] = []; +Object.freeze(EMPTY_QUEUE); + +export class ConversationQueueStore { + #queue: QueuedChatTurn[] = EMPTY_QUEUE; + readonly #conversationSnapshots = new Map(); + readonly #listenersByConversation = new Map>(); + readonly #allListeners = new Set(); + + getAllSnapshot(): QueuedChatTurn[] { + return this.#queue; + } + + getSnapshot(conversationId: string): QueuedChatTurn[] { + return this.#conversationSnapshots.get(conversationId.trim()) ?? EMPTY_QUEUE; + } + + subscribeAll(listener: ConversationQueueListener): () => void { + this.#allListeners.add(listener); + return () => this.#allListeners.delete(listener); + } + + subscribe(conversationId: string, listener: ConversationQueueListener): () => void { + const key = conversationId.trim(); + if (!key) return () => undefined; + const listeners = this.#listenersByConversation.get(key) ?? new Set(); + listeners.add(listener); + this.#listenersByConversation.set(key, listeners); + return () => { + listeners.delete(listener); + if (listeners.size === 0) { + this.#listenersByConversation.delete(key); + } + }; + } + + update(updater: (current: QueuedChatTurn[]) => QueuedChatTurn[]): QueuedChatTurn[] { + return this.set(updater(this.#queue)); + } + + set(queue: readonly QueuedChatTurn[]): QueuedChatTurn[] { + const previous = this.#queue; + const next = queue.slice(); + if (areSameQueue(previous, next)) return previous; + this.#queue = next; + + const conversationIds = new Set(); + for (const item of previous) conversationIds.add(item.conversationId); + for (const item of next) conversationIds.add(item.conversationId); + + for (const conversationId of conversationIds) { + const key = conversationId.trim(); + if (!key) continue; + const previousSnapshot = this.getSnapshot(key); + const nextSnapshot = next.filter((item) => item.conversationId === key); + if (areSameQueue(previousSnapshot, nextSnapshot)) continue; + if (nextSnapshot.length > 0) { + this.#conversationSnapshots.set(key, nextSnapshot); + } else { + this.#conversationSnapshots.delete(key); + } + this.#emitConversation(key); + } + + for (const listener of Array.from(this.#allListeners)) { + listener(); + } + return next; + } + + clearConversation(conversationId: string): QueuedChatTurn[] { + const key = conversationId.trim(); + if (!key) return this.#queue; + return this.set(this.#queue.filter((item) => item.conversationId !== key)); + } + + clear(): void { + this.set(EMPTY_QUEUE); + } + + #emitConversation(conversationId: string): void { + const listeners = this.#listenersByConversation.get(conversationId); + if (!listeners) return; + for (const listener of Array.from(listeners)) { + listener(); + } + } +} + +function areSameQueue( + previous: readonly QueuedChatTurn[], + next: readonly QueuedChatTurn[], +): boolean { + return previous.length === next.length && previous.every((item, index) => item === next[index]); +} + +export function createConversationQueueStore(): ConversationQueueStore { + return new ConversationQueueStore(); +} diff --git a/crates/agent-gui/src/pages/chat/conversations/conversationUploadStore.ts b/crates/agent-gui/src/pages/chat/conversations/conversationUploadStore.ts new file mode 100644 index 000000000..dcd529487 --- /dev/null +++ b/crates/agent-gui/src/pages/chat/conversations/conversationUploadStore.ts @@ -0,0 +1,78 @@ +import type { PendingUploadedFile } from "@liveagent/ui/lib/chat/uploadedFiles"; + +export type ConversationUploadListener = () => void; + +const EMPTY_UPLOADS: PendingUploadedFile[] = []; +Object.freeze(EMPTY_UPLOADS); + +export class ConversationUploadStore { + readonly #uploadsByConversation = new Map(); + readonly #listenersByConversation = new Map>(); + + getSnapshot(conversationId: string): PendingUploadedFile[] { + return this.#uploadsByConversation.get(conversationId.trim()) ?? EMPTY_UPLOADS; + } + + subscribe(conversationId: string, listener: ConversationUploadListener): () => void { + const key = conversationId.trim(); + if (!key) return () => undefined; + const listeners = this.#listenersByConversation.get(key) ?? new Set(); + listeners.add(listener); + this.#listenersByConversation.set(key, listeners); + return () => { + listeners.delete(listener); + if (listeners.size === 0) { + this.#listenersByConversation.delete(key); + } + }; + } + + set(conversationId: string, uploads: readonly PendingUploadedFile[]): void { + const key = conversationId.trim(); + if (!key) return; + const previous = this.getSnapshot(key); + const next = uploads.slice(); + if (areSameUploads(previous, next)) return; + if (next.length > 0) { + this.#uploadsByConversation.set(key, next); + } else { + this.#uploadsByConversation.delete(key); + } + this.#emit(key); + } + + delete(conversationId: string): boolean { + const key = conversationId.trim(); + if (!key) return false; + const deleted = this.#uploadsByConversation.delete(key); + if (deleted) this.#emit(key); + return deleted; + } + + clear(): void { + const conversationIds = Array.from(this.#uploadsByConversation.keys()); + this.#uploadsByConversation.clear(); + for (const conversationId of conversationIds) { + this.#emit(conversationId); + } + } + + #emit(conversationId: string): void { + const listeners = this.#listenersByConversation.get(conversationId); + if (!listeners) return; + for (const listener of Array.from(listeners)) { + listener(); + } + } +} + +function areSameUploads( + previous: readonly PendingUploadedFile[], + next: readonly PendingUploadedFile[], +): boolean { + return previous.length === next.length && previous.every((item, index) => item === next[index]); +} + +export function createConversationUploadStore(): ConversationUploadStore { + return new ConversationUploadStore(); +} diff --git a/crates/agent-gui/src/pages/chat/conversations/createConversationRuntimeRegistry.ts b/crates/agent-gui/src/pages/chat/conversations/createConversationRuntimeRegistry.ts new file mode 100644 index 000000000..fcf6e6a27 --- /dev/null +++ b/crates/agent-gui/src/pages/chat/conversations/createConversationRuntimeRegistry.ts @@ -0,0 +1,146 @@ +import type { ConversationRuntimeEntry } from "../runtime/chatPageRuntime"; +import { + type ConversationApprovalStore, + createConversationApprovalStore, +} from "./conversationApprovalStore"; +import { + type ConversationDraftStore, + createConversationDraftStore, +} from "./conversationDraftStore"; +import { + type ConversationQueueStore, + createConversationQueueStore, +} from "./conversationQueueStore"; +import { + type ConversationUploadStore, + createConversationUploadStore, +} from "./conversationUploadStore"; + +export type ConversationRuntimeListener = () => void; + +export class ConversationRuntimeRegistry extends Map { + readonly #listenersByConversation = new Map>(); + readonly #viewCounts = new Map(); + readonly drafts: ConversationDraftStore; + readonly uploads: ConversationUploadStore; + readonly queue: ConversationQueueStore; + readonly approvals: ConversationApprovalStore; + + constructor( + entries?: Iterable, + drafts = createConversationDraftStore(), + uploads = createConversationUploadStore(), + queue = createConversationQueueStore(), + approvals = createConversationApprovalStore(), + ) { + super(); + this.drafts = drafts; + this.uploads = uploads; + this.queue = queue; + this.approvals = approvals; + if (!entries) return; + for (const [conversationId, entry] of entries) { + super.set(conversationId.trim(), entry); + } + } + + getSnapshot(conversationId: string): ConversationRuntimeEntry | null { + return this.get(conversationId.trim()) ?? null; + } + + subscribe(conversationId: string, listener: ConversationRuntimeListener): () => void { + const key = conversationId.trim(); + if (!key) return () => undefined; + const listeners = this.#listenersByConversation.get(key) ?? new Set(); + listeners.add(listener); + this.#listenersByConversation.set(key, listeners); + return () => { + listeners.delete(listener); + if (listeners.size === 0) { + this.#listenersByConversation.delete(key); + } + }; + } + + set(conversationId: string, entry: ConversationRuntimeEntry): this { + const key = conversationId.trim(); + if (!key) return this; + super.set(key, entry); + this.#emit(key); + return this; + } + + setRuntimeEntry(conversationId: string, entry: ConversationRuntimeEntry): void { + const key = conversationId.trim(); + if (!key) return; + if (super.has(key)) { + super.delete(key); + } + super.set(key, entry); + this.#emit(key); + } + + delete(conversationId: string): boolean { + const key = conversationId.trim(); + if (!key) return false; + const deletedRuntime = super.delete(key); + const deletedDraft = this.drafts.delete(key); + if (deletedRuntime || deletedDraft) { + this.#viewCounts.delete(key); + } + if (deletedRuntime) { + this.#emit(key); + } + return deletedRuntime || deletedDraft; + } + + clear(): void { + const conversationIds = Array.from(this.keys()); + super.clear(); + this.drafts.clear(); + this.#viewCounts.clear(); + for (const conversationId of conversationIds) { + this.#emit(conversationId); + } + } + + retainView(conversationId: string): () => void { + const key = conversationId.trim(); + if (!key) return () => undefined; + this.#viewCounts.set(key, (this.#viewCounts.get(key) ?? 0) + 1); + let released = false; + return () => { + if (released) return; + released = true; + this.releaseView(key); + }; + } + + releaseView(conversationId: string): void { + const key = conversationId.trim(); + const count = this.#viewCounts.get(key) ?? 0; + if (count <= 1) { + this.#viewCounts.delete(key); + return; + } + this.#viewCounts.set(key, count - 1); + } + + getViewCount(conversationId: string): number { + return this.#viewCounts.get(conversationId.trim()) ?? 0; + } + + #emit(conversationId: string): void { + const listeners = this.#listenersByConversation.get(conversationId); + if (!listeners) return; + for (const listener of Array.from(listeners)) { + listener(); + } + } +} + +export function createConversationRuntimeRegistry( + entries?: Iterable, +): ConversationRuntimeRegistry { + return new ConversationRuntimeRegistry(entries); +} diff --git a/crates/agent-gui/src/pages/chat/hooks/useChatPageRuntimeStore.ts b/crates/agent-gui/src/pages/chat/hooks/useChatPageRuntimeStore.ts index d702e0338..2a776ad63 100644 --- a/crates/agent-gui/src/pages/chat/hooks/useChatPageRuntimeStore.ts +++ b/crates/agent-gui/src/pages/chat/hooks/useChatPageRuntimeStore.ts @@ -6,6 +6,7 @@ import { } from "../../../lib/chat/conversation/conversationState"; import type { ConversationPersistenceCursor } from "../../../lib/chat/history/chatHistory"; import type { SelectedModel } from "../../../lib/settings"; +import { createConversationRuntimeRegistry } from "../conversations/createConversationRuntimeRegistry"; import { type ConversationRuntimeEntry, createConversationRuntimeEntry, @@ -70,7 +71,7 @@ export function useChatPageRuntimeStore(params: UseChatPageRuntimeStoreParams) { const currentConversationIdRef = useRef(initialConversation.conversationId); const conversationRuntimeCacheRef = useRef( - new Map([ + createConversationRuntimeRegistry([ [ initialConversation.conversationId, createConversationRuntimeEntry({ @@ -366,6 +367,7 @@ export function useChatPageRuntimeStore(params: UseChatPageRuntimeStoreParams) { return { currentConversationIdRef, + conversationRuntimeRegistry: conversationRuntimeCacheRef.current, conversationRuntimeCacheRef, conversationPersistenceCursorRef, runningConversationIdsRef, diff --git a/crates/agent-gui/src/pages/chat/hooks/useContextUsageTokensSource.ts b/crates/agent-gui/src/pages/chat/hooks/useContextUsageTokensSource.ts index 188600c2c..4c6e89482 100644 --- a/crates/agent-gui/src/pages/chat/hooks/useContextUsageTokensSource.ts +++ b/crates/agent-gui/src/pages/chat/hooks/useContextUsageTokensSource.ts @@ -7,13 +7,66 @@ import type { CompactionController } from "../../../lib/chat/compaction/controll import type { RenderTimelineItem } from "../../../lib/chat/conversation/conversationState"; import type { LiveTranscriptStore } from "../../../lib/chat/conversation/liveTranscriptStore"; -export function useContextUsageTokensSource(params: { +export type ContextUsageTokensSourceParams = { isRunning: boolean; conversationId: string; transcriptItems: readonly RenderTimelineItem[]; liveTranscriptStore: LiveTranscriptStore; getCompactionController: (conversationId: string) => CompactionController; -}) { +}; + +/** + * Pure factory shared by the current conversation (memoized via the hook + * below) and workbench background panes, which build one source per pane + * from their runtime cache entry and per-conversation live store. + */ +export function createContextUsageTokensSource(params: ContextUsageTokensSourceParams) { + const { + isRunning, + conversationId, + transcriptItems, + liveTranscriptStore, + getCompactionController, + } = params; + + let cache: { + rounds: unknown; + draft: string; + runtimeValue: number | undefined; + value: number | undefined; + } | null = null; + return { + subscribe: liveTranscriptStore.subscribe, + getContextUsageTokens: () => { + const live = liveTranscriptStore.getSnapshot(); + const includeLive = isRunning && !live.isSettled; + const rounds = includeLive ? live.liveRounds : null; + const draft = includeLive ? live.draftAssistantText : ""; + const runtimeValue = getCompactionController(conversationId).contextUsageTokens; + if ( + cache && + cache.rounds === rounds && + cache.draft === draft && + cache.runtimeValue === runtimeValue + ) { + return cache.value; + } + let value: number | undefined; + if (isRunning && runtimeValue !== undefined) { + value = runtimeValue; + } else { + const transcriptValue = deriveContextUsageTokens( + buildContextUsageScanItems(transcriptItems, includeLive ? live : null), + ); + value = transcriptValue ?? runtimeValue; + } + cache = { rounds, draft, runtimeValue, value }; + return value; + }, + }; +} + +export function useContextUsageTokensSource(params: ContextUsageTokensSourceParams) { const { isRunning, conversationId, @@ -22,41 +75,15 @@ export function useContextUsageTokensSource(params: { getCompactionController, } = params; - return useMemo(() => { - let cache: { - rounds: unknown; - draft: string; - runtimeValue: number | undefined; - value: number | undefined; - } | null = null; - return { - subscribe: liveTranscriptStore.subscribe, - getContextUsageTokens: () => { - const live = liveTranscriptStore.getSnapshot(); - const includeLive = isRunning && !live.isSettled; - const rounds = includeLive ? live.liveRounds : null; - const draft = includeLive ? live.draftAssistantText : ""; - const runtimeValue = getCompactionController(conversationId).contextUsageTokens; - if ( - cache && - cache.rounds === rounds && - cache.draft === draft && - cache.runtimeValue === runtimeValue - ) { - return cache.value; - } - let value: number | undefined; - if (isRunning && runtimeValue !== undefined) { - value = runtimeValue; - } else { - const transcriptValue = deriveContextUsageTokens( - buildContextUsageScanItems(transcriptItems, includeLive ? live : null), - ); - value = transcriptValue ?? runtimeValue; - } - cache = { rounds, draft, runtimeValue, value }; - return value; - }, - }; - }, [conversationId, getCompactionController, isRunning, liveTranscriptStore, transcriptItems]); + return useMemo( + () => + createContextUsageTokensSource({ + isRunning, + conversationId, + transcriptItems, + liveTranscriptStore, + getCompactionController, + }), + [conversationId, getCompactionController, isRunning, liveTranscriptStore, transcriptItems], + ); } diff --git a/crates/agent-gui/src/pages/chat/hooks/usePendingUploads.ts b/crates/agent-gui/src/pages/chat/hooks/usePendingUploads.ts index 31dbdf340..a5b56f144 100644 --- a/crates/agent-gui/src/pages/chat/hooks/usePendingUploads.ts +++ b/crates/agent-gui/src/pages/chat/hooks/usePendingUploads.ts @@ -6,7 +6,18 @@ import { } from "@liveagent/ui/lib/chat/uploadedFiles"; import { invalidateUploadedImagePreviewCache } from "@liveagent/ui/lib/chat/uploadedImagePreview"; import { invoke } from "@tauri-apps/api/core"; -import { type MutableRefObject, useCallback, useEffect, useRef, useState } from "react"; +import { + type MutableRefObject, + useCallback, + useEffect, + useRef, + useState, + useSyncExternalStore, +} from "react"; +import { + type ConversationUploadStore, + createConversationUploadStore, +} from "../conversations/conversationUploadStore"; type SystemPickReadableFilesResponse = { files: PendingUploadedFile[]; @@ -29,6 +40,7 @@ type UsePendingUploadsParams = { isAgentMode: boolean; workdir: string; conversationId: string; + uploadStore?: ConversationUploadStore; currentConversationIdRef: MutableRefObject; composerRef: MutableRefObject; setErrorMessage: (message: string | null) => void; @@ -61,16 +73,32 @@ export function usePendingUploads(params: UsePendingUploadsParams) { isAgentMode, workdir, conversationId, + uploadStore: providedUploadStore, currentConversationIdRef, composerRef, setErrorMessage, addNotify, } = params; - const [pendingUploadedFiles, setPendingUploadedFiles] = useState([]); + const fallbackUploadStoreRef = useRef(null); + if (!fallbackUploadStoreRef.current) { + fallbackUploadStoreRef.current = createConversationUploadStore(); + } + const uploadStore = providedUploadStore ?? fallbackUploadStoreRef.current; const [isUploadingFiles, setIsUploadingFiles] = useState(false); const uploadTaskActiveRef = useRef(false); - const pendingUploadsByConversationRef = useRef(new Map()); - const pendingUploadedFilesRef = useRef(pendingUploadedFiles); + const subscribePendingUploads = useCallback( + (listener: () => void) => uploadStore.subscribe(conversationId, listener), + [conversationId, uploadStore], + ); + const getPendingUploadsSnapshot = useCallback( + () => uploadStore.getSnapshot(conversationId), + [conversationId, uploadStore], + ); + const pendingUploadedFiles = useSyncExternalStore( + subscribePendingUploads, + getPendingUploadsSnapshot, + getPendingUploadsSnapshot, + ); // Render-assigned mirrors: an in-flight import settling between a render // and its effects must still see the latest mode/workdir when it decides // whether its result is stale. @@ -85,53 +113,19 @@ export function usePendingUploads(params: UsePendingUploadsParams) { } | null>(null); const getPendingUploadsForConversation = useCallback( - (conversationId: string) => { - const targetConversationId = conversationId.trim(); - if ( - !targetConversationId || - currentConversationIdRef.current.trim() === targetConversationId - ) { - return pendingUploadedFilesRef.current; - } - return pendingUploadsByConversationRef.current.get(targetConversationId) ?? []; - }, - [currentConversationIdRef], + (conversationId: string) => uploadStore.getSnapshot(conversationId), + [uploadStore], ); - // The single write path: keeps the per-conversation map, the synchronous - // read ref, and the rendered state in step within the same tick. Every + // The single write path keeps uploads owned by their conversation. Every // pending-uploads mutation (including the consumers') must go through it. const setPendingUploadsForConversation = useCallback( (conversationId: string, nextFiles: PendingUploadedFile[]) => { - const targetConversationId = conversationId.trim(); - const normalizedFiles = nextFiles.slice(); - if (targetConversationId) { - if (normalizedFiles.length > 0) { - pendingUploadsByConversationRef.current.set(targetConversationId, normalizedFiles); - } else { - pendingUploadsByConversationRef.current.delete(targetConversationId); - } - } - if ( - !targetConversationId || - currentConversationIdRef.current.trim() === targetConversationId - ) { - pendingUploadedFilesRef.current = normalizedFiles; - setPendingUploadedFiles(normalizedFiles); - } + uploadStore.set(conversationId, nextFiles); }, - [currentConversationIdRef], + [uploadStore], ); - useEffect(() => { - const targetConversationId = conversationId.trim(); - const nextFiles = targetConversationId - ? (pendingUploadsByConversationRef.current.get(targetConversationId) ?? []) - : []; - pendingUploadedFilesRef.current = nextFiles; - setPendingUploadedFiles(nextFiles); - }, [conversationId]); - useEffect(() => { const previous = uploadContextRef.current; uploadContextRef.current = { isAgentMode, workdir, conversationId }; @@ -139,9 +133,7 @@ export function usePendingUploads(params: UsePendingUploadsParams) { if (previous.isAgentMode !== isAgentMode) { // Attachments are only usable in tools mode; a mode flip invalidates // every conversation's pending uploads. - pendingUploadsByConversationRef.current.clear(); - pendingUploadedFilesRef.current = []; - setPendingUploadedFiles([]); + uploadStore.clear(); return; } // Switching conversations must not invalidate any conversation's @@ -151,7 +143,7 @@ export function usePendingUploads(params: UsePendingUploadsParams) { if (previous.conversationId !== conversationId) return; if (previous.workdir === workdir) return; setPendingUploadsForConversation(conversationId, []); - }, [isAgentMode, workdir, conversationId, setPendingUploadsForConversation]); + }, [isAgentMode, workdir, conversationId, setPendingUploadsForConversation, uploadStore]); const captureUploadTarget = useCallback((): UploadTarget | null => { const targetConversationId = currentConversationIdRef.current.trim(); diff --git a/crates/agent-gui/src/pages/chat/queue/useChatTurnQueue.ts b/crates/agent-gui/src/pages/chat/queue/useChatTurnQueue.ts index 8f47de2cc..b50f674c9 100644 --- a/crates/agent-gui/src/pages/chat/queue/useChatTurnQueue.ts +++ b/crates/agent-gui/src/pages/chat/queue/useChatTurnQueue.ts @@ -6,7 +6,14 @@ import type { PendingUploadedFile } from "@liveagent/ui/lib/chat/uploadedFiles"; import type { ChatQueueTurnPreview } from "@liveagent/ui/pages/chat/ChatComposerBar"; import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; -import { type MutableRefObject, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + type MutableRefObject, + useCallback, + useEffect, + useMemo, + useRef, + useSyncExternalStore, +} from "react"; import type { LiveTranscriptStore } from "../../../lib/chat/conversation/liveTranscriptStore"; import { type AppSettings, @@ -18,6 +25,10 @@ import { import { answerAskUserQuestion } from "../../../lib/tools/askUserQuestionTools"; import { answerToolApproval } from "../../../lib/tools/toolApproval"; import { createTextComposerDraft } from "../composer/composerDraftText"; +import { + type ConversationQueueStore, + createConversationQueueStore, +} from "../conversations/conversationQueueStore"; import type { ActiveGatewayBridgeRequest, SendChatAction } from "../gateway/gatewayBridgeTypes"; import { type GatewayChatClaimedRequest, @@ -50,6 +61,7 @@ import { type UseChatTurnQueueParams = { settings: AppSettings; currentConversationId: string; + queueStore?: ConversationQueueStore; currentConversationIdRef: MutableRefObject; conversationRuntimeCacheRef: MutableRefObject>; buildRuntimeEntryFromVisibleState: () => ConversationRuntimeEntry; @@ -95,6 +107,7 @@ export function useChatTurnQueue(params: UseChatTurnQueueParams) { const { settings, currentConversationId, + queueStore: providedQueueStore, currentConversationIdRef, conversationRuntimeCacheRef, buildRuntimeEntryFromVisibleState, @@ -119,9 +132,27 @@ export function useChatTurnQueue(params: UseChatTurnQueueParams) { sendActionRef, manualCompactActionRef, } = params; + const fallbackQueueStoreRef = useRef(null); + if (!fallbackQueueStoreRef.current) { + fallbackQueueStoreRef.current = createConversationQueueStore(); + } + const queueStore = providedQueueStore ?? fallbackQueueStoreRef.current; - const [queuedChatTurns, setQueuedChatTurns] = useState([]); - const queuedChatTurnsRef = useRef([]); + const queuedChatTurnsRef = useRef(queueStore.getAllSnapshot()); + const subscribeQueuedChatTurns = useCallback( + (listener: () => void) => + queueStore.subscribeAll(() => { + queuedChatTurnsRef.current = queueStore.getAllSnapshot(); + listener(); + }), + [queueStore], + ); + const getQueuedChatTurnsSnapshot = useCallback(() => queueStore.getAllSnapshot(), [queueStore]); + const queuedChatTurns = useSyncExternalStore( + subscribeQueuedChatTurns, + getQueuedChatTurnsSnapshot, + getQueuedChatTurnsSnapshot, + ); const queuedChatProcessingConversationIdsRef = useRef(new Set()); const queuedChatStopVersionsRef = useRef(new Map()); // 打断并执行的恢复意图:conversationId → 触发打断那一刻的 stop-request 版本号。 @@ -285,9 +316,8 @@ export function useChatTurnQueue(params: UseChatTurnQueueParams) { const setQueuedChatTurnsState = useCallback( (updater: (current: QueuedChatTurn[]) => QueuedChatTurn[]) => { const previous = queuedChatTurnsRef.current; - const next = updater(previous).slice(); + const next = queueStore.update(updater); queuedChatTurnsRef.current = next; - setQueuedChatTurns(next); chatQueueRevisionRef.current += 1; const conversationIds = new Set(); for (const item of previous) conversationIds.add(item.conversationId); @@ -297,7 +327,7 @@ export function useChatTurnQueue(params: UseChatTurnQueueParams) { publishChatQueueSnapshots(conversationIds, next); return next; }, - [], + [queueStore], ); const queuedChatTurnsForCurrentConversation = useMemo( diff --git a/crates/agent-gui/src/pages/chat/runtime/chatPageRuntime.ts b/crates/agent-gui/src/pages/chat/runtime/chatPageRuntime.ts index 544991bde..e395abe70 100644 --- a/crates/agent-gui/src/pages/chat/runtime/chatPageRuntime.ts +++ b/crates/agent-gui/src/pages/chat/runtime/chatPageRuntime.ts @@ -129,6 +129,13 @@ export function setConversationRuntimeCacheEntry( ) { const key = conversationId.trim(); if (!key) return; + const observableCache = cache as Map & { + setRuntimeEntry?: (conversationId: string, entry: ConversationRuntimeEntry) => void; + }; + if (observableCache.setRuntimeEntry) { + observableCache.setRuntimeEntry(key, entry); + return; + } if (cache.has(key)) { cache.delete(key); } diff --git a/crates/agent-gui/test/chat/chat-stop-timing.test.mjs b/crates/agent-gui/test/chat/chat-stop-timing.test.mjs index 3d5c42d14..f15cc5568 100644 --- a/crates/agent-gui/test/chat/chat-stop-timing.test.mjs +++ b/crates/agent-gui/test/chat/chat-stop-timing.test.mjs @@ -33,6 +33,9 @@ function createHookHarness() { useMemo(factory) { return factory(); }, + useSyncExternalStore(_subscribe, getSnapshot) { + return getSnapshot(); + }, useEffect(effect, deps = []) { const index = effectIndex++; const previous = effects[index]; From 6e3fdcc6ceb424781d03ab9c31da409ad020e9db Mon Sep 17 00:00:00 2001 From: yovinchen Date: Mon, 17 Aug 2026 03:12:54 +0800 Subject: [PATCH 08/76] =?UTF-8?q?feat(chat):=20=E5=BC=95=E5=85=A5=20Conver?= =?UTF-8?q?sationSurfaceController=20=E9=A9=B1=E5=8A=A8=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ConversationSurface 不再直接持有 conversationId + 静态内容,改为 controller + snapshot 渲染:快照聚合转录/审批/压缩相位, useConversationPaneHostBridge 暴露 composer 与滚动句柄给宿主 --- .../conversationControllerTypes.ts | 48 ++++++ .../createConversationSurfaceController.ts | 144 ++++++++++++++++++ .../useConversationPaneHostBridge.ts | 32 ++++ .../useConversationSurfaceSnapshot.ts | 12 ++ .../chat/surfaces/ConversationSurface.tsx | 23 ++- 5 files changed, 255 insertions(+), 4 deletions(-) create mode 100644 crates/agent-gui/src/pages/chat/conversations/conversationControllerTypes.ts create mode 100644 crates/agent-gui/src/pages/chat/conversations/createConversationSurfaceController.ts create mode 100644 crates/agent-gui/src/pages/chat/conversations/useConversationPaneHostBridge.ts create mode 100644 crates/agent-gui/src/pages/chat/conversations/useConversationSurfaceSnapshot.ts diff --git a/crates/agent-gui/src/pages/chat/conversations/conversationControllerTypes.ts b/crates/agent-gui/src/pages/chat/conversations/conversationControllerTypes.ts new file mode 100644 index 000000000..9508ff0ae --- /dev/null +++ b/crates/agent-gui/src/pages/chat/conversations/conversationControllerTypes.ts @@ -0,0 +1,48 @@ +import type { MentionComposerDraft } from "@liveagent/ui/components/chat/MentionComposer"; +import type { PendingUploadedFile } from "@liveagent/ui/lib/chat/uploadedFiles"; +import type { ProjectRef } from "@liveagent/ui/lib/workbench/types"; +import type { CompactionStatus } from "../../../lib/chat/compaction/types"; +import type { SelectedModel } from "../../../lib/settings"; +import type { PendingToolApprovalSummary } from "../../../lib/tools/toolApproval"; +import type { QueuedChatTurn } from "../queue/chatTurnQueue"; +import type { ConversationRuntimeEntry } from "../runtime/chatPageRuntime"; + +export type ConversationApprovalSlice = PendingToolApprovalSummary[]; +export type ConversationModelSlice = SelectedModel | null; +export type ConversationCompactionSlice = CompactionStatus; + +export type ConversationSurfaceSnapshot = { + conversationId: string; + project: ProjectRef; + runtime: ConversationRuntimeEntry | null; + draft: MentionComposerDraft | null; + uploads: PendingUploadedFile[]; + queue: QueuedChatTurn[]; + approvals: ConversationApprovalSlice; + model: ConversationModelSlice; + compaction: ConversationCompactionSlice; +}; + +export type ConversationControllerActions = { + hydrate(input: { conversationId: string; project: ProjectRef }): Promise; + send(input: { conversationId: string; draft: MentionComposerDraft }): Promise; + stop(input: { conversationId: string }): void; + compact(input: { conversationId: string }): Promise; + retry(input: { conversationId: string }): Promise; +}; + +export type ConversationSurfaceController = { + readonly conversationId: string; + readonly project: ProjectRef; + getSnapshot(): ConversationSurfaceSnapshot; + subscribe(listener: () => void): () => void; + retainView(): () => void; + setDraft(draft: MentionComposerDraft): void; + clearDraft(): void; + hydrate(): Promise; + send(draft: MentionComposerDraft): Promise; + stop(): void; + compact(): Promise; + retry(): Promise; + dispose(): void; +}; diff --git a/crates/agent-gui/src/pages/chat/conversations/createConversationSurfaceController.ts b/crates/agent-gui/src/pages/chat/conversations/createConversationSurfaceController.ts new file mode 100644 index 000000000..4310f748e --- /dev/null +++ b/crates/agent-gui/src/pages/chat/conversations/createConversationSurfaceController.ts @@ -0,0 +1,144 @@ +import type { ProjectRef } from "@liveagent/ui/lib/workbench/types"; +import type { + ConversationControllerActions, + ConversationSurfaceController, + ConversationSurfaceSnapshot, +} from "./conversationControllerTypes"; +import type { ConversationRuntimeRegistry } from "./createConversationRuntimeRegistry"; + +type CreateConversationSurfaceControllerParams = { + conversationId: string; + project: ProjectRef; + registry: ConversationRuntimeRegistry; + actions: ConversationControllerActions; +}; + +const IDLE_COMPACTION = { phase: "idle" } as const; + +export function createConversationSurfaceController( + params: CreateConversationSurfaceControllerParams, +): ConversationSurfaceController { + const conversationId = params.conversationId.trim(); + if (!conversationId) { + throw new Error("ConversationSurfaceController requires a conversationId."); + } + + const listeners = new Set<() => void>(); + let unsubscribeRuntime: (() => void) | null = null; + let unsubscribeDraft: (() => void) | null = null; + let unsubscribeUploads: (() => void) | null = null; + let unsubscribeQueue: (() => void) | null = null; + let unsubscribeApprovals: (() => void) | null = null; + let disposed = false; + let snapshot: ConversationSurfaceSnapshot = readSnapshot(); + + function readSnapshot(): ConversationSurfaceSnapshot { + const runtime = params.registry.getSnapshot(conversationId); + return { + conversationId, + project: params.project, + runtime, + draft: params.registry.drafts.getSnapshot(conversationId), + uploads: params.registry.uploads.getSnapshot(conversationId), + queue: params.registry.queue.getSnapshot(conversationId), + approvals: params.registry.approvals.getSnapshot(conversationId), + model: runtime?.selectedModel ?? null, + compaction: runtime?.compactionStatus ?? IDLE_COMPACTION, + }; + } + + function refresh(emit: boolean) { + const next = readSnapshot(); + if ( + next.runtime === snapshot.runtime && + next.draft === snapshot.draft && + next.uploads === snapshot.uploads && + next.queue === snapshot.queue && + next.approvals === snapshot.approvals && + next.model === snapshot.model && + next.compaction === snapshot.compaction + ) { + return; + } + snapshot = next; + if (!emit) return; + for (const listener of Array.from(listeners)) { + listener(); + } + } + + function connect() { + if (unsubscribeRuntime || disposed) return; + unsubscribeRuntime = params.registry.subscribe(conversationId, () => refresh(true)); + unsubscribeDraft = params.registry.drafts.subscribe(conversationId, () => refresh(true)); + unsubscribeUploads = params.registry.uploads.subscribe(conversationId, () => refresh(true)); + unsubscribeQueue = params.registry.queue.subscribe(conversationId, () => refresh(true)); + unsubscribeApprovals = params.registry.approvals.subscribe(conversationId, () => refresh(true)); + } + + function disconnect() { + unsubscribeRuntime?.(); + unsubscribeDraft?.(); + unsubscribeUploads?.(); + unsubscribeQueue?.(); + unsubscribeApprovals?.(); + unsubscribeRuntime = null; + unsubscribeDraft = null; + unsubscribeUploads = null; + unsubscribeQueue = null; + unsubscribeApprovals = null; + } + + return { + conversationId, + project: params.project, + getSnapshot() { + refresh(false); + return snapshot; + }, + subscribe(listener) { + if (disposed) return () => undefined; + connect(); + listeners.add(listener); + return () => { + listeners.delete(listener); + if (listeners.size === 0) disconnect(); + }; + }, + retainView() { + if (disposed) return () => undefined; + return params.registry.retainView(conversationId); + }, + setDraft(draft) { + if (draft.isEmpty) { + params.registry.drafts.delete(conversationId); + return; + } + params.registry.drafts.set(conversationId, draft); + }, + clearDraft() { + params.registry.drafts.delete(conversationId); + }, + hydrate() { + return params.actions.hydrate({ conversationId, project: params.project }); + }, + send(draft) { + return params.actions.send({ conversationId, draft }); + }, + stop() { + params.actions.stop({ conversationId }); + }, + compact() { + return params.actions.compact({ conversationId }); + }, + retry() { + return params.actions.retry({ conversationId }); + }, + dispose() { + if (disposed) return; + disposed = true; + disconnect(); + listeners.clear(); + }, + }; +} diff --git a/crates/agent-gui/src/pages/chat/conversations/useConversationPaneHostBridge.ts b/crates/agent-gui/src/pages/chat/conversations/useConversationPaneHostBridge.ts new file mode 100644 index 000000000..b7490448c --- /dev/null +++ b/crates/agent-gui/src/pages/chat/conversations/useConversationPaneHostBridge.ts @@ -0,0 +1,32 @@ +import type { MentionComposerHandle } from "@liveagent/ui/components/chat/MentionComposer"; +import type { ScrollFollowHandle } from "@liveagent/ui/lib/chat-scroll/useScrollFollow"; +import { type MutableRefObject, useMemo, useRef } from "react"; + +export type ConversationPaneHostHandle = { + getComposer(): MentionComposerHandle | null; + getScrollFollow(): ScrollFollowHandle | null; +}; + +export function useConversationPaneHostBridge() { + const hostRef = useRef(null); + const composerRef = useMemo>( + () => ({ + get current() { + return hostRef.current?.getComposer() ?? null; + }, + set current(_value: MentionComposerHandle | null) {}, + }), + [], + ); + const scrollFollowRef = useMemo>( + () => ({ + get current() { + return hostRef.current?.getScrollFollow() ?? null; + }, + set current(_value: ScrollFollowHandle | null) {}, + }), + [], + ); + + return { hostRef, composerRef, scrollFollowRef }; +} diff --git a/crates/agent-gui/src/pages/chat/conversations/useConversationSurfaceSnapshot.ts b/crates/agent-gui/src/pages/chat/conversations/useConversationSurfaceSnapshot.ts new file mode 100644 index 000000000..2ba8009ab --- /dev/null +++ b/crates/agent-gui/src/pages/chat/conversations/useConversationSurfaceSnapshot.ts @@ -0,0 +1,12 @@ +import { useEffect, useSyncExternalStore } from "react"; +import type { + ConversationSurfaceController, + ConversationSurfaceSnapshot, +} from "./conversationControllerTypes"; + +export function useConversationSurfaceSnapshot( + controller: ConversationSurfaceController, +): ConversationSurfaceSnapshot { + useEffect(() => controller.retainView(), [controller]); + return useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot); +} diff --git a/crates/agent-gui/src/pages/chat/surfaces/ConversationSurface.tsx b/crates/agent-gui/src/pages/chat/surfaces/ConversationSurface.tsx index 9d9f4a480..9a6931205 100644 --- a/crates/agent-gui/src/pages/chat/surfaces/ConversationSurface.tsx +++ b/crates/agent-gui/src/pages/chat/surfaces/ConversationSurface.tsx @@ -1,18 +1,33 @@ import type { ReactNode } from "react"; +import type { + ConversationSurfaceController, + ConversationSurfaceSnapshot, +} from "../conversations/conversationControllerTypes"; +import { useConversationSurfaceSnapshot } from "../conversations/useConversationSurfaceSnapshot"; -type ConversationSurfaceProps = { - conversationId: string; +type ConversationSurfaceContent = { transcript: ReactNode; composer: ReactNode; }; +type ConversationSurfaceProps = { + paneId: string; + controller: ConversationSurfaceController; + renderContent(snapshot: ConversationSurfaceSnapshot): ConversationSurfaceContent; +}; + export function ConversationSurface(props: ConversationSurfaceProps) { - const { conversationId, transcript, composer } = props; + const { paneId, controller, renderContent } = props; + const snapshot = useConversationSurfaceSnapshot(controller); + const { transcript, composer } = renderContent(snapshot); return (
From 3bd430658c0b0b4658d0d885a629df1ce81c264c Mon Sep 17 00:00:00 2001 From: yovinchen Date: Mon, 17 Aug 2026 03:33:08 +0800 Subject: [PATCH 09/76] =?UTF-8?q?feat(chat):=20=E7=BB=88=E7=AB=AF=20pane?= =?UTF-8?q?=20=E7=BB=91=E5=AE=9A/=E7=A7=9F=E7=BA=A6=E4=B8=8E=E6=8B=96?= =?UTF-8?q?=E5=85=A5=E6=8F=90=E4=BA=A4=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - terminalPaneBindingStore:pane 与终端会话的稳定绑定 - terminalPaneLeaseStore:被 pane 租用的会话从右侧 dock 隐藏, 避免输出流双消费 - terminalPaneRuntime + terminalDropCommit:拖出 dock 后经 revision 校验落位为终端 pane - 附各 store 与 commit 链路的单测 --- .../chat/workbench/terminalDropCommit.ts | 124 +++++++++++ .../workbench/terminalPaneBindingStore.ts | 144 +++++++++++++ .../chat/workbench/terminalPaneLeaseStore.ts | 109 ++++++++++ .../chat/workbench/terminalPaneRuntime.ts | 111 ++++++++++ .../test/chat/terminal-drop-commit.test.mjs | 204 ++++++++++++++++++ .../chat/terminal-pane-binding-store.test.mjs | 172 +++++++++++++++ .../chat/terminal-pane-lease-store.test.mjs | 100 +++++++++ .../test/chat/terminal-pane-runtime.test.mjs | 183 ++++++++++++++++ 8 files changed, 1147 insertions(+) create mode 100644 crates/agent-gui/src/pages/chat/workbench/terminalDropCommit.ts create mode 100644 crates/agent-gui/src/pages/chat/workbench/terminalPaneBindingStore.ts create mode 100644 crates/agent-gui/src/pages/chat/workbench/terminalPaneLeaseStore.ts create mode 100644 crates/agent-gui/src/pages/chat/workbench/terminalPaneRuntime.ts create mode 100644 crates/agent-gui/test/chat/terminal-drop-commit.test.mjs create mode 100644 crates/agent-gui/test/chat/terminal-pane-binding-store.test.mjs create mode 100644 crates/agent-gui/test/chat/terminal-pane-lease-store.test.mjs create mode 100644 crates/agent-gui/test/chat/terminal-pane-runtime.test.mjs diff --git a/crates/agent-gui/src/pages/chat/workbench/terminalDropCommit.ts b/crates/agent-gui/src/pages/chat/workbench/terminalDropCommit.ts new file mode 100644 index 000000000..425b62318 --- /dev/null +++ b/crates/agent-gui/src/pages/chat/workbench/terminalDropCommit.ts @@ -0,0 +1,124 @@ +import type { TerminalSession } from "@liveagent/ui/lib/terminal/types"; +import type { + WorkbenchDropTarget, + WorkbenchMoveTarget, + WorkbenchOpenTarget, +} from "@liveagent/ui/lib/workbench/index"; +import type { + ProjectRef, + TerminalWorkbenchSurface, + WorkbenchLayout, +} from "@liveagent/ui/lib/workbench/types"; +import type { TerminalPaneBindingStore } from "./terminalPaneBindingStore"; +import type { TerminalPaneLeaseStore } from "./terminalPaneLeaseStore"; +import type { WorkbenchDragPayload } from "./useWorkbenchDragSession"; + +export type TerminalDropPayload = Extract< + WorkbenchDragPayload, + { kind: "terminalSession" } | { kind: "newTerminal" } +>; + +export type TerminalDropDeps = { + layout: WorkbenchLayout; + sessions: readonly TerminalSession[]; + lease: Pick; + bindings: Pick; + /** newTerminal 需要真实 cwd;ProjectRef 只有 pathKey,由调用方解析回项目路径。 */ + resolveProjectPath(project: ProjectRef): string | null; + createSurfaceId(): string; + openTerminalSurface( + surface: TerminalWorkbenchSurface, + target: WorkbenchOpenTarget, + ): { paneId: string } | null; + movePane(paneId: string, target: WorkbenchMoveTarget): boolean; + focusPane(paneId: string): unknown; +}; + +export type TerminalDropResult = + | { action: "moved" | "focused"; paneId: string } + | { action: "opened"; paneId: string; surfaceId: string } + | { action: "ignored" }; + +/** 从既有会话记录构造可持久化的启动规格;sessionId 本身绝不进入 Surface。 */ +export function terminalSurfaceForSession( + session: TerminalSession, + surfaceId: string, + project: ProjectRef, +): TerminalWorkbenchSurface { + if (session.kind === "ssh" && session.ssh?.hostId) { + return { + kind: "sshTerminal", + surfaceId, + project, + launchSpec: { + cwd: session.cwd, + sshHostId: session.ssh.hostId, + title: session.title || undefined, + sftpEnabled: session.ssh.sftpEnabled || undefined, + }, + }; + } + return { + kind: "localTerminal", + surfaceId, + project, + launchSpec: { + cwd: session.cwd, + shell: session.shell || undefined, + title: session.title || undefined, + }, + }; +} + +/** + * 终端拖拽的 drop 事务(设计文档"几何先行"):布局立即提交,PTY 由 + * TerminalPaneHost 挂载后异步保障。既有会话拖入时先写绑定再开 Pane, + * 让宿主直接复用会话而不是新建;已在画板中的会话只移动/聚焦。 + */ +export function commitTerminalDrop( + payload: TerminalDropPayload, + target: WorkbenchDropTarget, + deps: TerminalDropDeps, +): TerminalDropResult { + // 非 pane 拖拽在命中归一化中已被自动贴靠,pane-center 到这里只能是陈旧命中。 + if (target.kind === "pane-center") return { action: "ignored" }; + + if (payload.kind === "terminalSession") { + const leasedPaneId = deps.lease.paneIdFor(payload.sessionId); + if (leasedPaneId && deps.layout.panes[leasedPaneId]) { + if (target.kind === "canvas-empty") { + deps.focusPane(leasedPaneId); + return { action: "focused", paneId: leasedPaneId }; + } + return deps.movePane(leasedPaneId, target) + ? { action: "moved", paneId: leasedPaneId } + : { action: "ignored" }; + } + const session = deps.sessions.find((entry) => entry.id === payload.sessionId); + if (!session) return { action: "ignored" }; + const surfaceId = deps.createSurfaceId(); + const surface = terminalSurfaceForSession(session, surfaceId, payload.project); + // 先绑定后开 Pane:宿主挂载即可命中既有会话,不触发 ensure 新建。 + deps.bindings.set(surfaceId, session.id); + const opened = deps.openTerminalSurface(surface, target); + if (!opened) { + deps.bindings.delete(surfaceId); + return { action: "ignored" }; + } + return { action: "opened", paneId: opened.paneId, surfaceId }; + } + + const cwd = deps.resolveProjectPath(payload.project); + if (!cwd) return { action: "ignored" }; + const surfaceId = deps.createSurfaceId(); + const opened = deps.openTerminalSurface( + { + kind: "localTerminal", + surfaceId, + project: payload.project, + launchSpec: { cwd }, + }, + target, + ); + return opened ? { action: "opened", paneId: opened.paneId, surfaceId } : { action: "ignored" }; +} diff --git a/crates/agent-gui/src/pages/chat/workbench/terminalPaneBindingStore.ts b/crates/agent-gui/src/pages/chat/workbench/terminalPaneBindingStore.ts new file mode 100644 index 000000000..629ce2ed7 --- /dev/null +++ b/crates/agent-gui/src/pages/chat/workbench/terminalPaneBindingStore.ts @@ -0,0 +1,144 @@ +export type TerminalPaneBindingListener = () => void; + +export type TerminalPaneBindingStorage = Pick; + +export type TerminalPaneBindingStoreOptions = { + storage?: TerminalPaneBindingStorage; + storageKey?: string; +}; + +/** + * 终端运行时绑定(Runtime Binding)层:surfaceId → sessionId。 + * 布局 JSON 只持久化 launchSpec + surfaceId,sessionId 存 sessionStorage: + * webview reload 后 Rust 终端注册表仍活着,绑定可对账恢复;应用重启后 + * sessionStorage 清空,恰好对应终端会话已死。无 window / 存储异常时降级为纯内存。 + */ +export type TerminalPaneBindingStore = { + get(surfaceId: string): string | null; + set(surfaceId: string, sessionId: string): void; + delete(surfaceId: string): void; + /** 当前全部已绑定 surfaceId;引用在绑定不变时保持稳定(恢复对账/快照订阅用)。 */ + surfaceIds(): readonly string[]; + /** 对账:只保留 sessionId 仍在 liveSessionIds 中的绑定,返回被清除的 surfaceId 列表。 */ + reconcile(liveSessionIds: ReadonlySet): string[]; + subscribe(listener: TerminalPaneBindingListener): () => void; +}; + +export const TERMINAL_PANE_BINDING_STORAGE_KEY = "liveagent.terminalPaneBindings.v1"; + +function resolveDefaultStorage(): TerminalPaneBindingStorage | null { + if (typeof window === "undefined") return null; + try { + return window.sessionStorage ?? null; + } catch { + return null; + } +} + +function readPersistedBindings( + storage: TerminalPaneBindingStorage | null, + storageKey: string, +): Map { + const bindings = new Map(); + if (!storage) return bindings; + let raw: string | null = null; + try { + raw = storage.getItem(storageKey); + } catch { + return bindings; + } + if (!raw) return bindings; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + // 坏 JSON:忽略并从空状态重建,下次写入覆盖脏数据。 + return bindings; + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return bindings; + } + for (const [surfaceId, sessionId] of Object.entries(parsed)) { + const surfaceKey = surfaceId.trim(); + if (!surfaceKey || typeof sessionId !== "string" || !sessionId.trim()) continue; + bindings.set(surfaceKey, sessionId.trim()); + } + return bindings; +} + +export function createTerminalPaneBindingStore( + options?: TerminalPaneBindingStoreOptions, +): TerminalPaneBindingStore { + const storageKey = options?.storageKey?.trim() || TERMINAL_PANE_BINDING_STORAGE_KEY; + const storage = options?.storage ?? resolveDefaultStorage(); + const bindings = readPersistedBindings(storage, storageKey); + const listeners = new Set(); + let surfaceIdsSnapshot: readonly string[] = Array.from(bindings.keys()); + + const emit = () => { + surfaceIdsSnapshot = Array.from(bindings.keys()); + for (const listener of Array.from(listeners)) { + listener(); + } + }; + + const persist = () => { + if (!storage) return; + try { + if (bindings.size === 0) { + storage.removeItem(storageKey); + } else { + storage.setItem(storageKey, JSON.stringify(Object.fromEntries(bindings))); + } + } catch { + // 存储写失败(配额/隐私模式)只降级为内存态,不影响调用方。 + } + }; + + return { + get(surfaceId) { + const key = surfaceId.trim(); + if (!key) return null; + return bindings.get(key) ?? null; + }, + set(surfaceId, sessionId) { + const surfaceKey = surfaceId.trim(); + const sessionKey = sessionId.trim(); + if (!surfaceKey || !sessionKey) return; + if (bindings.get(surfaceKey) === sessionKey) return; + bindings.set(surfaceKey, sessionKey); + persist(); + emit(); + }, + delete(surfaceId) { + const key = surfaceId.trim(); + if (!key) return; + if (!bindings.delete(key)) return; + persist(); + emit(); + }, + surfaceIds() { + return surfaceIdsSnapshot; + }, + reconcile(liveSessionIds) { + const removed: string[] = []; + for (const [surfaceId, sessionId] of bindings) { + if (!liveSessionIds.has(sessionId)) { + bindings.delete(surfaceId); + removed.push(surfaceId); + } + } + if (removed.length > 0) { + persist(); + emit(); + } + return removed; + }, + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; +} diff --git a/crates/agent-gui/src/pages/chat/workbench/terminalPaneLeaseStore.ts b/crates/agent-gui/src/pages/chat/workbench/terminalPaneLeaseStore.ts new file mode 100644 index 000000000..47be54019 --- /dev/null +++ b/crates/agent-gui/src/pages/chat/workbench/terminalPaneLeaseStore.ts @@ -0,0 +1,109 @@ +export type TerminalPaneLeaseListener = () => void; + +/** + * 终端视图租约(View Lease)层:一个终端 sessionId 在画板中至多被一个 pane 持有。 + * Pane 持有租约期间,Right Dock 等其他宿主不得再挂载该会话的 XTermViewport, + * 否则输出流会被双消费、输入会被双写。租约纯内存,不持久化。 + */ +export type TerminalPaneLeaseStore = { + /** + * 获取租约并返回 release 函数。sessionId 已被其他 pane 持有时抛错 + * (调用方必须先用 paneIdFor 查询);同一 pane 对同一 session 重复 + * acquire 幂等返回既有 release。release 幂等,且不会误释放后建租约。 + */ + acquire(sessionId: string, paneId: string): () => void; + paneIdFor(sessionId: string): string | null; + sessionIdFor(paneId: string): string | null; + /** 当前被 pane 持有的全部 sessionId;引用在租约不变时保持稳定(useSyncExternalStore 快照)。 */ + leasedSessionIds(): readonly string[]; + subscribe(listener: TerminalPaneLeaseListener): () => void; +}; + +type LeaseRecord = { + sessionId: string; + paneId: string; + release: () => void; +}; + +export function createTerminalPaneLeaseStore(): TerminalPaneLeaseStore { + const leasesBySessionId = new Map(); + const leasesByPaneId = new Map(); + const listeners = new Set(); + let leasedSessionIdsSnapshot: readonly string[] = []; + + const emit = () => { + leasedSessionIdsSnapshot = Array.from(leasesBySessionId.keys()); + for (const listener of Array.from(listeners)) { + listener(); + } + }; + + const drop = (record: LeaseRecord) => { + // 只清除仍指向该 record 的索引,防止陈旧 release 误删后建租约。 + if (leasesBySessionId.get(record.sessionId) === record) { + leasesBySessionId.delete(record.sessionId); + } + if (leasesByPaneId.get(record.paneId) === record) { + leasesByPaneId.delete(record.paneId); + } + }; + + return { + acquire(sessionId, paneId) { + const sessionKey = sessionId.trim(); + const paneKey = paneId.trim(); + if (!sessionKey || !paneKey) { + throw new Error("Terminal pane lease requires a sessionId and a paneId."); + } + const existing = leasesBySessionId.get(sessionKey); + if (existing) { + if (existing.paneId !== paneKey) { + throw new Error( + `Terminal session '${sessionKey}' is already leased by pane '${existing.paneId}'.`, + ); + } + return existing.release; + } + // 同一 pane 换绑新会话(重建终端)时,先释放它持有的旧租约, + // 维持 “一个 pane 至多一个终端视图” 的双向不变量。 + const previous = leasesByPaneId.get(paneKey); + if (previous) { + drop(previous); + } + let released = false; + const record: LeaseRecord = { + sessionId: sessionKey, + paneId: paneKey, + release: () => { + if (released) return; + released = true; + drop(record); + emit(); + }, + }; + leasesBySessionId.set(sessionKey, record); + leasesByPaneId.set(paneKey, record); + emit(); + return record.release; + }, + paneIdFor(sessionId) { + const key = sessionId.trim(); + if (!key) return null; + return leasesBySessionId.get(key)?.paneId ?? null; + }, + sessionIdFor(paneId) { + const key = paneId.trim(); + if (!key) return null; + return leasesByPaneId.get(key)?.sessionId ?? null; + }, + leasedSessionIds() { + return leasedSessionIdsSnapshot; + }, + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; +} diff --git a/crates/agent-gui/src/pages/chat/workbench/terminalPaneRuntime.ts b/crates/agent-gui/src/pages/chat/workbench/terminalPaneRuntime.ts new file mode 100644 index 000000000..eff7e888a --- /dev/null +++ b/crates/agent-gui/src/pages/chat/workbench/terminalPaneRuntime.ts @@ -0,0 +1,111 @@ +import type { TerminalClient, TerminalSession } from "@liveagent/ui/lib/terminal/types"; +import type { TerminalWorkbenchSurface } from "@liveagent/ui/lib/workbench/types"; +import { + createTerminalPaneBindingStore, + type TerminalPaneBindingStore, +} from "./terminalPaneBindingStore"; +import { createTerminalPaneLeaseStore } from "./terminalPaneLeaseStore"; + +/** + * 终端 Pane 的窗口级运行时单例:租约(View Lease)与绑定(Runtime Binding) + * 必须全窗口共享,ChatPage、TerminalPaneHost 与测试引用同一实例。 + */ +export const terminalPaneLease = createTerminalPaneLeaseStore(); +export const terminalPaneBindings = createTerminalPaneBindingStore(); + +let terminalSurfaceIdCounter = 0; + +/** 布局内稳定的终端 Surface 身份;与 useWindowWorkbench 的 paneId 生成风格一致。 */ +export function createTerminalSurfaceId(): string { + terminalSurfaceIdCounter += 1; + return `term-${Date.now().toString(36)}-${terminalSurfaceIdCounter.toString(36)}`; +} + +/** SSH 建连返回交互提示(host key/认证)时抛出;Pane 内无法应答,需在项目工具面板完成。 */ +export class TerminalPaneSshPromptError extends Error { + constructor() { + super("SSH session requires an interactive prompt."); + this.name = "TerminalPaneSshPromptError"; + } +} + +export type EnsureTerminalPaneSessionDeps = { + client: TerminalClient; + bindings: Pick; + /** 测试注入;省略时使用模块级共享表。 */ + inflight?: Map>; +}; + +const sharedEnsureInflight = new Map>(); + +/** + * 按 launchSpec 建立终端会话并写入绑定,返回创建的会话记录(调用方可在 + * `terminal:event` 尚未送达前直接渲染)。同一 surfaceId 的并发调用 + * (StrictMode 双挂载、快速重试)复用同一个 in-flight Promise,保证不会 + * 创建两个 PTY。失败的 Promise 结算后从表中移除,后续重试可再次发起。 + */ +export function ensureTerminalPaneSession( + surface: TerminalWorkbenchSurface, + deps: EnsureTerminalPaneSessionDeps, +): Promise { + const inflight = deps.inflight ?? sharedEnsureInflight; + const surfaceId = surface.surfaceId.trim(); + const existing = inflight.get(surfaceId); + if (existing) return existing; + + const run = (async (): Promise => { + if (surface.kind === "localTerminal") { + const snapshot = await deps.client.create({ + cwd: surface.launchSpec.cwd, + projectPathKey: surface.project.projectPathKey, + shell: surface.launchSpec.shell, + title: surface.launchSpec.title, + }); + deps.bindings.set(surfaceId, snapshot.session.id); + return snapshot.session; + } + const result = await deps.client.createSsh({ + cwd: surface.launchSpec.cwd, + projectPathKey: surface.project.projectPathKey, + hostId: surface.launchSpec.sshHostId, + title: surface.launchSpec.title, + sftpEnabled: surface.launchSpec.sftpEnabled, + }); + if (!result.snapshot) { + throw new TerminalPaneSshPromptError(); + } + deps.bindings.set(surfaceId, result.snapshot.session.id); + return result.snapshot.session; + })(); + + const tracked = run.finally(() => { + if (inflight.get(surfaceId) === tracked) { + inflight.delete(surfaceId); + } + }); + inflight.set(surfaceId, tracked); + return tracked; +} + +export type ResolveLiveTerminalSurfaceIdsDeps = { + client: Pick; + bindings: Pick; +}; + +/** + * 恢复期对账:用后端存活会话清理死绑定,返回仍然存活的 surfaceId 集合。 + * webview reload 时 Rust 终端注册表仍在,这些 surfaceId 对应的 Pane 可以恢复; + * list 失败返回 null,调用方按安全默认丢弃全部终端 Pane,不阻塞会话恢复。 + */ +export async function resolveLiveTerminalSurfaceIds( + deps: ResolveLiveTerminalSurfaceIdsDeps, +): Promise | null> { + try { + const sessions = await deps.client.list(); + const liveSessionIds = new Set(sessions.map((session) => session.id)); + deps.bindings.reconcile(liveSessionIds); + return new Set(deps.bindings.surfaceIds()); + } catch { + return null; + } +} diff --git a/crates/agent-gui/test/chat/terminal-drop-commit.test.mjs b/crates/agent-gui/test/chat/terminal-drop-commit.test.mjs new file mode 100644 index 000000000..d830d7b13 --- /dev/null +++ b/crates/agent-gui/test/chat/terminal-drop-commit.test.mjs @@ -0,0 +1,204 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +const loader = createTsModuleLoader(); +const { commitTerminalDrop, terminalSurfaceForSession } = loader.loadModule( + "src/pages/chat/workbench/terminalDropCommit.ts", +); +const { createTerminalPaneBindingStore } = loader.loadModule( + "src/pages/chat/workbench/terminalPaneBindingStore.ts", +); +const { createTerminalPaneLeaseStore } = loader.loadModule( + "src/pages/chat/workbench/terminalPaneLeaseStore.ts", +); + +const PROJECT = { projectId: "project-1", projectPathKey: "/repo" }; +const EDGE_TARGET = { kind: "pane-edge", paneId: "pane-a", edge: "right" }; + +function session(id, overrides = {}) { + return { + id, + projectPathKey: "/repo", + cwd: "/repo", + shell: "zsh", + title: "Build", + kind: "local", + cols: 80, + rows: 24, + createdAt: 1, + updatedAt: 1, + running: true, + ...overrides, + }; +} + +function makeDeps(overrides = {}) { + let surfaceCounter = 0; + const calls = { open: [], move: [], focus: [] }; + const deps = { + layout: { panes: {} }, + sessions: [], + lease: createTerminalPaneLeaseStore(), + bindings: createTerminalPaneBindingStore({ storage: null }), + resolveProjectPath: () => "/repo", + createSurfaceId: () => { + surfaceCounter += 1; + return `surface-${surfaceCounter}`; + }, + openTerminalSurface: (surface, target) => { + calls.open.push({ surface, target }); + return { paneId: `pane-for-${surface.surfaceId}` }; + }, + movePane: (paneId, target) => { + calls.move.push({ paneId, target }); + return true; + }, + focusPane: (paneId) => { + calls.focus.push(paneId); + }, + ...overrides, + }; + return { deps, calls }; +} + +test("dropping a dock session binds it and opens a pane at the target", () => { + const { deps, calls } = makeDeps({ sessions: [session("session-1")] }); + const result = commitTerminalDrop( + { kind: "terminalSession", sessionId: "session-1", project: PROJECT, title: "Build" }, + EDGE_TARGET, + deps, + ); + assert.deepEqual(result, { + action: "opened", + paneId: "pane-for-surface-1", + surfaceId: "surface-1", + }); + assert.equal(deps.bindings.get("surface-1"), "session-1"); + assert.equal(calls.open.length, 1); + const surface = calls.open[0].surface; + assert.equal(surface.kind, "localTerminal"); + assert.deepEqual(surface.launchSpec, { cwd: "/repo", shell: "zsh", title: "Build" }); + assert.equal(calls.open[0].target, EDGE_TARGET); +}); + +test("a session already leased by a pane is moved instead of duplicated", () => { + const { deps, calls } = makeDeps({ + layout: { panes: { "pane-a": { paneId: "pane-a" } } }, + sessions: [session("session-1")], + }); + deps.lease.acquire("session-1", "pane-a"); + const result = commitTerminalDrop( + { kind: "terminalSession", sessionId: "session-1", project: PROJECT, title: "Build" }, + { kind: "canvas-edge", edge: "left" }, + deps, + ); + assert.deepEqual(result, { action: "moved", paneId: "pane-a" }); + assert.equal(calls.open.length, 0); + assert.equal(calls.move.length, 1); +}); + +test("a leased session dropped on empty canvas only refocuses its pane", () => { + const { deps, calls } = makeDeps({ + layout: { panes: { "pane-a": { paneId: "pane-a" } } }, + sessions: [session("session-1")], + }); + deps.lease.acquire("session-1", "pane-a"); + const result = commitTerminalDrop( + { kind: "terminalSession", sessionId: "session-1", project: PROJECT, title: "Build" }, + { kind: "canvas-empty" }, + deps, + ); + assert.deepEqual(result, { action: "focused", paneId: "pane-a" }); + assert.deepEqual(calls.focus, ["pane-a"]); +}); + +test("an unknown session id is ignored without side effects", () => { + const { deps, calls } = makeDeps(); + const result = commitTerminalDrop( + { kind: "terminalSession", sessionId: "missing", project: PROJECT, title: "Gone" }, + EDGE_TARGET, + deps, + ); + assert.deepEqual(result, { action: "ignored" }); + assert.equal(calls.open.length, 0); + assert.deepEqual(deps.bindings.surfaceIds(), []); +}); + +test("a failed open rolls the fresh binding back", () => { + const { deps } = makeDeps({ + sessions: [session("session-1")], + openTerminalSurface: () => null, + }); + const result = commitTerminalDrop( + { kind: "terminalSession", sessionId: "session-1", project: PROJECT, title: "Build" }, + EDGE_TARGET, + deps, + ); + assert.deepEqual(result, { action: "ignored" }); + assert.deepEqual(deps.bindings.surfaceIds(), []); +}); + +test("newTerminal opens an unbound local surface with the project cwd", () => { + const { deps, calls } = makeDeps({ resolveProjectPath: () => "/workspace/app" }); + const result = commitTerminalDrop( + { kind: "newTerminal", project: PROJECT, title: "Terminal" }, + EDGE_TARGET, + deps, + ); + assert.equal(result.action, "opened"); + const surface = calls.open[0].surface; + assert.equal(surface.kind, "localTerminal"); + assert.deepEqual(surface.launchSpec, { cwd: "/workspace/app" }); + // PTY 由宿主挂载后创建:drop 阶段不得预建会话或写绑定。 + assert.deepEqual(deps.bindings.surfaceIds(), []); +}); + +test("newTerminal with an unresolvable project path is ignored", () => { + const { deps, calls } = makeDeps({ resolveProjectPath: () => null }); + const result = commitTerminalDrop( + { kind: "newTerminal", project: PROJECT, title: "Terminal" }, + EDGE_TARGET, + deps, + ); + assert.deepEqual(result, { action: "ignored" }); + assert.equal(calls.open.length, 0); +}); + +test("stale pane-center targets are ignored for terminal payloads", () => { + const { deps, calls } = makeDeps({ sessions: [session("session-1")] }); + const result = commitTerminalDrop( + { kind: "terminalSession", sessionId: "session-1", project: PROJECT, title: "Build" }, + { kind: "pane-center", paneId: "pane-a" }, + deps, + ); + assert.deepEqual(result, { action: "ignored" }); + assert.equal(calls.open.length, 0); +}); + +test("terminalSurfaceForSession maps ssh sessions to sshTerminal launch specs", () => { + const sshSession = session("ssh-1", { + kind: "ssh", + cwd: "/srv", + ssh: { + hostId: "host-1", + hostName: "prod", + username: "ops", + host: "prod.example.com", + port: 22, + authType: "key", + status: "connected", + reconnectAttempt: 0, + reconnectMaxAttempts: 3, + sftpEnabled: true, + }, + }); + const surface = terminalSurfaceForSession(sshSession, "surface-9", PROJECT); + assert.equal(surface.kind, "sshTerminal"); + assert.deepEqual(surface.launchSpec, { + cwd: "/srv", + sshHostId: "host-1", + title: "Build", + sftpEnabled: true, + }); +}); diff --git a/crates/agent-gui/test/chat/terminal-pane-binding-store.test.mjs b/crates/agent-gui/test/chat/terminal-pane-binding-store.test.mjs new file mode 100644 index 000000000..dab501501 --- /dev/null +++ b/crates/agent-gui/test/chat/terminal-pane-binding-store.test.mjs @@ -0,0 +1,172 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +const loader = createTsModuleLoader(); +const { createTerminalPaneBindingStore, TERMINAL_PANE_BINDING_STORAGE_KEY } = loader.loadModule( + "src/pages/chat/workbench/terminalPaneBindingStore.ts", +); + +function createMemoryStorage(initial = {}) { + const entries = new Map(Object.entries(initial)); + return { + entries, + getItem: (key) => entries.get(key) ?? null, + setItem: (key, value) => { + entries.set(key, value); + }, + removeItem: (key) => { + entries.delete(key); + }, + }; +} + +test("set and get roundtrip through the provided storage", () => { + const storage = createMemoryStorage(); + const store = createTerminalPaneBindingStore({ storage }); + store.set("surface-a", "session-1"); + assert.equal(store.get("surface-a"), "session-1"); + assert.equal( + storage.getItem(TERMINAL_PANE_BINDING_STORAGE_KEY), + JSON.stringify({ "surface-a": "session-1" }), + ); + + const rehydrated = createTerminalPaneBindingStore({ storage }); + assert.equal(rehydrated.get("surface-a"), "session-1"); +}); + +test("delete removes the binding and clears empty storage", () => { + const storage = createMemoryStorage(); + const store = createTerminalPaneBindingStore({ storage }); + store.set("surface-a", "session-1"); + store.delete("surface-a"); + assert.equal(store.get("surface-a"), null); + assert.equal(storage.getItem(TERMINAL_PANE_BINDING_STORAGE_KEY), null); +}); + +test("blank identifiers are ignored", () => { + const store = createTerminalPaneBindingStore({ storage: createMemoryStorage() }); + store.set(" ", "session-1"); + store.set("surface-a", " "); + assert.equal(store.get(" "), null); + assert.equal(store.get("surface-a"), null); + store.delete(" "); +}); + +test("identifiers are trimmed on write and read", () => { + const store = createTerminalPaneBindingStore({ storage: createMemoryStorage() }); + store.set(" surface-a ", " session-1 "); + assert.equal(store.get("surface-a"), "session-1"); + assert.equal(store.get(" surface-a "), "session-1"); +}); + +test("corrupted storage payloads are ignored and rebuilt on next write", () => { + const storage = createMemoryStorage({ [TERMINAL_PANE_BINDING_STORAGE_KEY]: "{not json" }); + const store = createTerminalPaneBindingStore({ storage }); + assert.equal(store.get("surface-a"), null); + store.set("surface-a", "session-1"); + assert.equal( + storage.getItem(TERMINAL_PANE_BINDING_STORAGE_KEY), + JSON.stringify({ "surface-a": "session-1" }), + ); +}); + +test("non-object and malformed persisted entries are dropped", () => { + const storage = createMemoryStorage({ + [TERMINAL_PANE_BINDING_STORAGE_KEY]: JSON.stringify({ + "surface-a": "session-1", + "surface-b": 42, + " ": "session-2", + "surface-c": " ", + }), + }); + const store = createTerminalPaneBindingStore({ storage }); + assert.equal(store.get("surface-a"), "session-1"); + assert.equal(store.get("surface-b"), null); + assert.equal(store.get("surface-c"), null); +}); + +test("reconcile drops bindings for dead sessions and reports them", () => { + const storage = createMemoryStorage(); + const store = createTerminalPaneBindingStore({ storage }); + store.set("surface-a", "session-live"); + store.set("surface-b", "session-dead"); + store.set("surface-c", "session-gone"); + const removed = store.reconcile(new Set(["session-live"])); + assert.deepEqual(removed.sort(), ["surface-b", "surface-c"]); + assert.equal(store.get("surface-a"), "session-live"); + assert.equal(store.get("surface-b"), null); + assert.equal( + storage.getItem(TERMINAL_PANE_BINDING_STORAGE_KEY), + JSON.stringify({ "surface-a": "session-live" }), + ); + assert.deepEqual(store.reconcile(new Set(["session-live"])), []); +}); + +test("subscribe notifies on effective changes only", () => { + const store = createTerminalPaneBindingStore({ storage: createMemoryStorage() }); + let notifications = 0; + const unsubscribe = store.subscribe(() => { + notifications += 1; + }); + store.set("surface-a", "session-1"); + assert.equal(notifications, 1); + store.set("surface-a", "session-1"); + assert.equal(notifications, 1, "same-value set must not notify"); + store.delete("surface-a"); + assert.equal(notifications, 2); + store.delete("surface-a"); + assert.equal(notifications, 2, "no-op delete must not notify"); + store.set("surface-b", "session-2"); + store.reconcile(new Set()); + assert.equal(notifications, 4); + store.reconcile(new Set()); + assert.equal(notifications, 4, "no-op reconcile must not notify"); + unsubscribe(); + store.set("surface-c", "session-3"); + assert.equal(notifications, 4); +}); + +test("falls back to memory when storage is unavailable or throwing", () => { + const store = createTerminalPaneBindingStore({ storage: undefined }); + store.set("surface-a", "session-1"); + assert.equal(store.get("surface-a"), "session-1"); + + const throwingStorage = { + getItem: () => { + throw new Error("denied"); + }, + setItem: () => { + throw new Error("denied"); + }, + removeItem: () => { + throw new Error("denied"); + }, + }; + const degraded = createTerminalPaneBindingStore({ storage: throwingStorage }); + degraded.set("surface-b", "session-2"); + assert.equal(degraded.get("surface-b"), "session-2"); + degraded.delete("surface-b"); + assert.equal(degraded.get("surface-b"), null); +}); + +test("surfaceIds exposes a stable snapshot including persisted bindings", () => { + const backing = new Map([ + ["liveagent.terminalPaneBindings.v1", JSON.stringify({ "surface-a": "session-1" })], + ]); + const storage = { + getItem: (key) => backing.get(key) ?? null, + setItem: (key, value) => backing.set(key, value), + removeItem: (key) => backing.delete(key), + }; + const store = createTerminalPaneBindingStore({ storage }); + assert.deepEqual(store.surfaceIds(), ["surface-a"], "boot snapshot covers persisted bindings"); + const snapshot = store.surfaceIds(); + assert.equal(store.surfaceIds(), snapshot, "unchanged bindings keep the same reference"); + store.set("surface-b", "session-2"); + assert.deepEqual([...store.surfaceIds()].sort(), ["surface-a", "surface-b"]); + store.reconcile(new Set(["session-2"])); + assert.deepEqual(store.surfaceIds(), ["surface-b"]); + store.delete("surface-b"); + assert.deepEqual(store.surfaceIds(), []); +}); diff --git a/crates/agent-gui/test/chat/terminal-pane-lease-store.test.mjs b/crates/agent-gui/test/chat/terminal-pane-lease-store.test.mjs new file mode 100644 index 000000000..f3036783a --- /dev/null +++ b/crates/agent-gui/test/chat/terminal-pane-lease-store.test.mjs @@ -0,0 +1,100 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +const loader = createTsModuleLoader(); +const { createTerminalPaneLeaseStore } = loader.loadModule( + "src/pages/chat/workbench/terminalPaneLeaseStore.ts", +); + +test("acquire establishes a bidirectional lease", () => { + const store = createTerminalPaneLeaseStore(); + store.acquire("session-a", "pane-1"); + assert.equal(store.paneIdFor("session-a"), "pane-1"); + assert.equal(store.sessionIdFor("pane-1"), "session-a"); + assert.equal(store.paneIdFor("session-unknown"), null); + assert.equal(store.sessionIdFor("pane-unknown"), null); +}); + +test("acquire rejects a session already leased by another pane", () => { + const store = createTerminalPaneLeaseStore(); + store.acquire("session-a", "pane-1"); + assert.throws(() => store.acquire("session-a", "pane-2"), /already leased by pane 'pane-1'/); + assert.equal(store.paneIdFor("session-a"), "pane-1"); +}); + +test("acquire is idempotent for the same pane and session", () => { + const store = createTerminalPaneLeaseStore(); + const releaseFirst = store.acquire("session-a", "pane-1"); + const releaseSecond = store.acquire("session-a", "pane-1"); + assert.equal(releaseFirst, releaseSecond); + releaseSecond(); + assert.equal(store.paneIdFor("session-a"), null); + assert.equal(store.sessionIdFor("pane-1"), null); +}); + +test("acquire rejects blank identifiers", () => { + const store = createTerminalPaneLeaseStore(); + assert.throws(() => store.acquire(" ", "pane-1")); + assert.throws(() => store.acquire("session-a", "")); + assert.equal(store.paneIdFor(" "), null); + assert.equal(store.sessionIdFor(""), null); +}); + +test("acquire trims identifiers", () => { + const store = createTerminalPaneLeaseStore(); + store.acquire(" session-a ", " pane-1 "); + assert.equal(store.paneIdFor("session-a"), "pane-1"); + assert.equal(store.sessionIdFor("pane-1"), "session-a"); +}); + +test("re-acquiring with a new session rebinds the pane and frees the old session", () => { + const store = createTerminalPaneLeaseStore(); + store.acquire("session-a", "pane-1"); + store.acquire("session-b", "pane-1"); + assert.equal(store.sessionIdFor("pane-1"), "session-b"); + assert.equal(store.paneIdFor("session-a"), null); + assert.equal(store.paneIdFor("session-b"), "pane-1"); +}); + +test("release is idempotent and never frees a newer lease", () => { + const store = createTerminalPaneLeaseStore(); + const staleRelease = store.acquire("session-a", "pane-1"); + staleRelease(); + store.acquire("session-a", "pane-2"); + staleRelease(); + assert.equal(store.paneIdFor("session-a"), "pane-2"); + assert.equal(store.sessionIdFor("pane-2"), "session-a"); +}); + +test("subscribe notifies on acquire and release, and unsubscribe stops notifications", () => { + const store = createTerminalPaneLeaseStore(); + let notifications = 0; + const unsubscribe = store.subscribe(() => { + notifications += 1; + }); + const release = store.acquire("session-a", "pane-1"); + assert.equal(notifications, 1); + store.acquire("session-a", "pane-1"); + assert.equal(notifications, 1, "idempotent acquire must not notify"); + release(); + assert.equal(notifications, 2); + release(); + assert.equal(notifications, 2, "idempotent release must not notify"); + unsubscribe(); + store.acquire("session-b", "pane-2"); + assert.equal(notifications, 2); +}); + +test("leasedSessionIds exposes a stable snapshot of held sessions", () => { + const store = createTerminalPaneLeaseStore(); + assert.deepEqual(store.leasedSessionIds(), []); + const releaseA = store.acquire("session-a", "pane-1"); + store.acquire("session-b", "pane-2"); + assert.deepEqual([...store.leasedSessionIds()].sort(), ["session-a", "session-b"]); + const snapshot = store.leasedSessionIds(); + assert.equal(store.leasedSessionIds(), snapshot, "unchanged leases keep the same reference"); + releaseA(); + assert.notEqual(store.leasedSessionIds(), snapshot, "a release produces a fresh snapshot"); + assert.deepEqual(store.leasedSessionIds(), ["session-b"]); +}); diff --git a/crates/agent-gui/test/chat/terminal-pane-runtime.test.mjs b/crates/agent-gui/test/chat/terminal-pane-runtime.test.mjs new file mode 100644 index 000000000..b3188c175 --- /dev/null +++ b/crates/agent-gui/test/chat/terminal-pane-runtime.test.mjs @@ -0,0 +1,183 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +const loader = createTsModuleLoader(); +const { + createTerminalSurfaceId, + ensureTerminalPaneSession, + resolveLiveTerminalSurfaceIds, + TerminalPaneSshPromptError, +} = loader.loadModule("src/pages/chat/workbench/terminalPaneRuntime.ts"); +const { createTerminalPaneBindingStore } = loader.loadModule( + "src/pages/chat/workbench/terminalPaneBindingStore.ts", +); + +const PROJECT = { projectId: "project-1", projectPathKey: "/repo" }; + +function localSurface(surfaceId = "surface-1") { + return { + kind: "localTerminal", + surfaceId, + project: PROJECT, + launchSpec: { cwd: "/repo", shell: "zsh", title: "Build" }, + }; +} + +function sshSurface(surfaceId = "surface-ssh") { + return { + kind: "sshTerminal", + surfaceId, + project: PROJECT, + launchSpec: { cwd: "/srv", sshHostId: "host-1", sftpEnabled: true }, + }; +} + +function session(id, overrides = {}) { + return { + id, + projectPathKey: "/repo", + cwd: "/repo", + shell: "zsh", + title: "Build", + kind: "local", + cols: 80, + rows: 24, + createdAt: 1, + updatedAt: 1, + running: true, + ...overrides, + }; +} + +test("createTerminalSurfaceId yields unique term- prefixed ids", () => { + const first = createTerminalSurfaceId(); + const second = createTerminalSurfaceId(); + assert.match(first, /^term-/); + assert.notEqual(first, second); +}); + +test("ensure creates a local session, binds it, and returns the record", async () => { + const bindings = createTerminalPaneBindingStore({ storage: null }); + const calls = []; + const client = { + create: async (params) => { + calls.push(params); + return { session: session("session-1"), output: "", truncated: false }; + }, + }; + const created = await ensureTerminalPaneSession(localSurface(), { + client, + bindings, + inflight: new Map(), + }); + assert.equal(created.id, "session-1"); + assert.equal(bindings.get("surface-1"), "session-1"); + assert.deepEqual(calls, [{ cwd: "/repo", projectPathKey: "/repo", shell: "zsh", title: "Build" }]); +}); + +test("concurrent ensure calls for one surface share a single create", async () => { + const bindings = createTerminalPaneBindingStore({ storage: null }); + let createCount = 0; + let releaseCreate; + const gate = new Promise((resolve) => { + releaseCreate = resolve; + }); + const client = { + create: async () => { + createCount += 1; + await gate; + return { session: session("session-1"), output: "", truncated: false }; + }, + }; + const inflight = new Map(); + const first = ensureTerminalPaneSession(localSurface(), { client, bindings, inflight }); + const second = ensureTerminalPaneSession(localSurface(), { client, bindings, inflight }); + releaseCreate(); + const [a, b] = await Promise.all([first, second]); + assert.equal(createCount, 1); + assert.equal(a.id, "session-1"); + assert.equal(b.id, "session-1"); + assert.equal(inflight.size, 0); +}); + +test("a failed ensure clears the in-flight slot so a retry can run", async () => { + const bindings = createTerminalPaneBindingStore({ storage: null }); + let attempt = 0; + const client = { + create: async () => { + attempt += 1; + if (attempt === 1) throw new Error("spawn failed"); + return { session: session("session-2"), output: "", truncated: false }; + }, + }; + const inflight = new Map(); + await assert.rejects( + ensureTerminalPaneSession(localSurface(), { client, bindings, inflight }), + /spawn failed/, + ); + assert.equal(bindings.get("surface-1"), null); + const created = await ensureTerminalPaneSession(localSurface(), { client, bindings, inflight }); + assert.equal(created.id, "session-2"); + assert.equal(bindings.get("surface-1"), "session-2"); +}); + +test("ssh ensure binds the created session and forwards the launch spec", async () => { + const bindings = createTerminalPaneBindingStore({ storage: null }); + const calls = []; + const client = { + createSsh: async (params) => { + calls.push(params); + return { snapshot: { session: session("ssh-1", { kind: "ssh" }), output: "", truncated: false } }; + }, + }; + const created = await ensureTerminalPaneSession(sshSurface(), { + client, + bindings, + inflight: new Map(), + }); + assert.equal(created.id, "ssh-1"); + assert.equal(bindings.get("surface-ssh"), "ssh-1"); + assert.deepEqual(calls, [ + { cwd: "/srv", projectPathKey: "/repo", hostId: "host-1", title: undefined, sftpEnabled: true }, + ]); +}); + +test("ssh ensure surfaces an interactive prompt as a typed error", async () => { + const bindings = createTerminalPaneBindingStore({ storage: null }); + const client = { + createSsh: async () => ({ prompt: { id: "prompt-1" } }), + }; + await assert.rejects( + ensureTerminalPaneSession(sshSurface(), { client, bindings, inflight: new Map() }), + (error) => error instanceof TerminalPaneSshPromptError, + ); + assert.equal(bindings.get("surface-ssh"), null); +}); + +test("resolveLiveTerminalSurfaceIds reconciles bindings against the registry", async () => { + const bindings = createTerminalPaneBindingStore({ storage: null }); + bindings.set("surface-live", "session-live"); + bindings.set("surface-dead", "session-dead"); + const live = await resolveLiveTerminalSurfaceIds({ + client: { list: async () => [session("session-live")] }, + bindings, + }); + assert.deepEqual([...live].sort(), ["surface-live"]); + assert.equal(bindings.get("surface-dead"), null); +}); + +test("resolveLiveTerminalSurfaceIds returns null when the registry is unreachable", async () => { + const bindings = createTerminalPaneBindingStore({ storage: null }); + bindings.set("surface-live", "session-live"); + const live = await resolveLiveTerminalSurfaceIds({ + client: { + list: async () => { + throw new Error("ipc down"); + }, + }, + bindings, + }); + assert.equal(live, null); + assert.equal(bindings.get("surface-live"), "session-live"); +}); From 25d045c9f20b9a5f114cdc8d7fde175949ab388b Mon Sep 17 00:00:00 2001 From: yovinchen Date: Mon, 17 Aug 2026 03:51:26 +0800 Subject: [PATCH 10/76] =?UTF-8?q?feat(chat):=20=E7=AA=97=E5=8F=A3=E7=BA=A7?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E5=8F=B0=E7=8A=B6=E6=80=81=E4=B8=8E=E5=B8=83?= =?UTF-8?q?=E5=B1=80=E6=81=A2=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useWindowWorkbench:布局命令执行、焦点/邻接导航、恢复时过滤 已缺失会话并折叠空分栏 - useWorkbenchDragSession:侧栏/dock 指针拖拽会话,移动阈值内不 影响原有点击语义 - layoutPersistence:localStorage 快路径 + Tauri workbench_layout 命令持久化,损坏 payload 留诊断副本 - Flag VITE_LIVEAGENT_SESSION_WORKBENCH 控制,默认关闭 --- .../pages/chat/workbench/layoutPersistence.ts | 86 +++ .../pages/chat/workbench/sessionWorkbench.ts | 5 + .../chat/workbench/useWindowWorkbench.ts | 515 ++++++++++++++++++ .../chat/workbench/useWorkbenchDragSession.ts | 327 +++++++++++ .../chat/workbench-window-session.test.mjs | 141 +++++ .../chat/workbench-window-terminal.test.mjs | 217 ++++++++ 6 files changed, 1291 insertions(+) create mode 100644 crates/agent-gui/src/pages/chat/workbench/layoutPersistence.ts create mode 100644 crates/agent-gui/src/pages/chat/workbench/sessionWorkbench.ts create mode 100644 crates/agent-gui/src/pages/chat/workbench/useWindowWorkbench.ts create mode 100644 crates/agent-gui/src/pages/chat/workbench/useWorkbenchDragSession.ts create mode 100644 crates/agent-gui/test/chat/workbench-window-session.test.mjs create mode 100644 crates/agent-gui/test/chat/workbench-window-terminal.test.mjs diff --git a/crates/agent-gui/src/pages/chat/workbench/layoutPersistence.ts b/crates/agent-gui/src/pages/chat/workbench/layoutPersistence.ts new file mode 100644 index 000000000..7bc65d26d --- /dev/null +++ b/crates/agent-gui/src/pages/chat/workbench/layoutPersistence.ts @@ -0,0 +1,86 @@ +import { invoke, isTauri } from "@tauri-apps/api/core"; +import { WORKBENCH_LAYOUT_STORAGE_KEY } from "./useWindowWorkbench"; + +const WORKBENCH_LAYOUT_SCOPE_ID = "main-window"; + +type PersistedWorkbenchLayoutRecord = { + scopeId: string; + schemaVersion: number; + revision: number; + payloadJson: string; + updatedAt: number; +}; + +export type WorkbenchLayoutPersistence = { + load(): Promise; + save(input: { payloadJson: string; schemaVersion: number; revision: number }): void; + /** Keep a diagnostic copy of a corrupted payload, then drop the original. */ + saveCorrupted(raw: string): void; +}; + +function readLocalStorage(): string | null { + try { + return window.localStorage.getItem(WORKBENCH_LAYOUT_STORAGE_KEY); + } catch { + return null; + } +} + +function writeLocalStorage(payloadJson: string): void { + try { + window.localStorage.setItem(WORKBENCH_LAYOUT_STORAGE_KEY, payloadJson); + } catch { + // Quota failures must never break the workbench. + } +} + +/** + * Native persistence for the window workbench layout: the SQLite + * `workbench_layout` table on desktop (never synced through the Gateway), + * with a one-shot migration from the earlier localStorage payload and a + * localStorage fallback for browser dev sessions. + */ +export function createWorkbenchLayoutPersistence(): WorkbenchLayoutPersistence { + const native = isTauri(); + return { + async load() { + if (!native) return readLocalStorage(); + try { + const record = await invoke( + "workbench_layout_load", + { scopeId: WORKBENCH_LAYOUT_SCOPE_ID }, + ); + if (record?.payloadJson) return record.payloadJson; + } catch (error) { + console.warn("failed to load workbench layout from sqlite", error); + return readLocalStorage(); + } + // Migrate the pre-SQLite localStorage payload once, then keep SQLite + // authoritative (the local copy stays as a harmless shadow). + return readLocalStorage(); + }, + save(input) { + if (!native) { + writeLocalStorage(input.payloadJson); + return; + } + void invoke("workbench_layout_save", { + scopeId: WORKBENCH_LAYOUT_SCOPE_ID, + schemaVersion: input.schemaVersion, + revision: input.revision, + payloadJson: input.payloadJson, + }).catch((error) => { + console.warn("failed to persist workbench layout", error); + writeLocalStorage(input.payloadJson); + }); + }, + saveCorrupted(raw) { + try { + window.localStorage.setItem(`${WORKBENCH_LAYOUT_STORAGE_KEY}.corrupted`, raw); + window.localStorage.removeItem(WORKBENCH_LAYOUT_STORAGE_KEY); + } catch { + // Diagnostics only. + } + }, + }; +} diff --git a/crates/agent-gui/src/pages/chat/workbench/sessionWorkbench.ts b/crates/agent-gui/src/pages/chat/workbench/sessionWorkbench.ts new file mode 100644 index 000000000..f85582ff4 --- /dev/null +++ b/crates/agent-gui/src/pages/chat/workbench/sessionWorkbench.ts @@ -0,0 +1,5 @@ +import { createSessionWorkbenchFeature } from "@liveagent/ui/lib/workbench/featureFlags"; + +export const sessionWorkbench = createSessionWorkbenchFeature( + import.meta.env.VITE_LIVEAGENT_SESSION_WORKBENCH, +); diff --git a/crates/agent-gui/src/pages/chat/workbench/useWindowWorkbench.ts b/crates/agent-gui/src/pages/chat/workbench/useWindowWorkbench.ts new file mode 100644 index 000000000..88a346482 --- /dev/null +++ b/crates/agent-gui/src/pages/chat/workbench/useWindowWorkbench.ts @@ -0,0 +1,515 @@ +import { + applyWorkbenchCommand, + decodeWorkbenchLayout, + encodeWorkbenchLayout, + findPaneIdByConversationId, + findPaneIdBySurfaceKey, + isWorkbenchLayoutValid, + WORKBENCH_LAYOUT_SCHEMA_VERSION, + type WorkbenchCommand, + type WorkbenchCommandResult, + type WorkbenchLayout, + type WorkbenchMoveTarget, + type WorkbenchOpenTarget, +} from "@liveagent/ui/lib/workbench/index"; +import { + type PaneNode, + type PaneRecord, + type ProjectRef, + surfaceIdentityKey, + type TerminalWorkbenchSurface, +} from "@liveagent/ui/lib/workbench/types"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +export const WORKBENCH_LAYOUT_STORAGE_KEY = "liveagent.sessionWorkbench.layout.v1"; +const PERSIST_DEBOUNCE_MS = 250; +export const ROOT_CONVERSATION_PANE_ID = "root-conversation-pane"; + +let paneIdCounter = 0; + +function createPaneId(): string { + paneIdCounter += 1; + return `pane-${Date.now().toString(36)}-${paneIdCounter.toString(36)}`; +} + +function conversationPaneRecord( + paneId: string, + conversationId: string, + project: ProjectRef, +): PaneRecord { + return { + paneId, + surface: { kind: "conversation", conversationId, project }, + view: {}, + }; +} + +function singlePaneLayout(conversationId: string, project: ProjectRef): WorkbenchLayout { + return { + schemaVersion: WORKBENCH_LAYOUT_SCHEMA_VERSION, + revision: 0, + root: { type: "leaf", paneId: ROOT_CONVERSATION_PANE_ID }, + panes: { + [ROOT_CONVERSATION_PANE_ID]: conversationPaneRecord( + ROOT_CONVERSATION_PANE_ID, + conversationId, + project, + ), + }, + focusedPaneId: ROOT_CONVERSATION_PANE_ID, + }; +} + +function removeLeaf(node: PaneNode, paneId: string): { node: PaneNode | null; found: boolean } { + if (node.type === "leaf") { + return node.paneId === paneId ? { node: null, found: true } : { node, found: false }; + } + const first = removeLeaf(node.first, paneId); + if (first.found) { + return { node: first.node ? { ...node, first: first.node } : node.second, found: true }; + } + const second = removeLeaf(node.second, paneId); + if (second.found) { + return { node: second.node ? { ...node, second: second.node } : node.first, found: true }; + } + return { node, found: false }; +} + +export type LiveWorkbenchSurfaces = { + validConversationIds: ReadonlySet; + /** + * Terminal panes survive a restore only when their surfaceId is confirmed + * alive (terminal_list reconciliation). Omitted → all terminal panes drop, + * the safe default while restore has no live-session information. + */ + liveTerminalSurfaceIds?: ReadonlySet; +}; + +function surfaceIsLive(pane: PaneRecord, live: LiveWorkbenchSurfaces): boolean { + switch (pane.surface.kind) { + case "conversation": + return live.validConversationIds.has(pane.surface.conversationId); + case "localTerminal": + case "sshTerminal": + return live.liveTerminalSurfaceIds?.has(pane.surface.surfaceId) ?? false; + case "unsupported": + // Forward-compat passthrough: newer-version panes must survive restore. + return true; + } +} + +/** + * Drop panes whose surface no longer maps to anything live; their splits + * collapse. Used when restoring a persisted layout against the live sidebar + * and terminal registry. + */ +export function filterLayoutToLiveSurfaces( + layout: WorkbenchLayout, + live: LiveWorkbenchSurfaces, +): WorkbenchLayout { + let root = layout.root; + const panes: Record = {}; + for (const [paneId, pane] of Object.entries(layout.panes)) { + if (surfaceIsLive(pane, live)) { + panes[paneId] = pane; + continue; + } + if (root) { + const removal = removeLeaf(root, paneId); + if (removal.found) root = removal.node; + } + } + const focusedPaneId = + layout.focusedPaneId && panes[layout.focusedPaneId] + ? layout.focusedPaneId + : root + ? firstLeafId(root) + : null; + return { + schemaVersion: WORKBENCH_LAYOUT_SCHEMA_VERSION, + revision: layout.revision, + root, + panes: root ? panes : {}, + focusedPaneId: root ? focusedPaneId : null, + }; +} + +function firstLeafId(node: PaneNode): string { + return node.type === "leaf" ? node.paneId : firstLeafId(node.first); +} + +/** First conversation pane in tree order, or null when none exists. */ +function firstConversationId(layout: WorkbenchLayout): string | null { + const walk = (node: PaneNode): string | null => { + if (node.type === "leaf") { + const surface = layout.panes[node.paneId]?.surface; + return surface?.kind === "conversation" ? surface.conversationId : null; + } + return walk(node.first) ?? walk(node.second); + }; + return layout.root ? walk(layout.root) : null; +} + +/** Distributive omit of the CAS field; filled in from the live revision. */ +type WorkbenchCommandInput = WorkbenchCommand extends infer Command + ? Command extends WorkbenchCommand + ? Omit + : never + : never; + +export type WorkbenchLayoutPersistenceAdapter = { + load(): Promise; + save(input: { payloadJson: string; schemaVersion: number; revision: number }): void; + saveCorrupted(raw: string): void; +}; + +export type UseWindowWorkbenchParams = { + enabled: boolean; + initialConversationId: string; + initialProject: ProjectRef; + /** Native layout persistence; omitted in tests and non-workbench sessions. */ + persistence?: WorkbenchLayoutPersistenceAdapter; +}; + +export type WorkbenchOpenConversationInput = { + conversationId: string; + project: ProjectRef; +}; + +export type WindowWorkbench = { + layout: WorkbenchLayout; + layoutRef: React.MutableRefObject; + paneIdForConversation(conversationId: string): string | null; + /** Raw transaction entry point (drag commits pass their frozen revision). */ + dispatch(command: WorkbenchCommand): WorkbenchCommandResult; + focusPane(paneId: string): PaneRecord | null; + openConversation( + input: WorkbenchOpenConversationInput, + target: WorkbenchOpenTarget, + ): { paneId: string } | null; + /** Open a terminal surface, focusing its existing pane when already placed. */ + openTerminalSurface( + surface: TerminalWorkbenchSurface, + target: WorkbenchOpenTarget, + ): { paneId: string } | null; + movePane(paneId: string, target: WorkbenchMoveTarget): boolean; + closePane(paneId: string): { closedFocused: boolean; nextConversationId: string | null }; + resizeSplit(splitId: string, ratio: number): void; + equalizeSplit(splitId: string): void; + /** Keep the focused pane bound to the page's current conversation. */ + syncCurrentConversation(conversationId: string, project: ProjectRef): void; + /** + * One-shot restore of the persisted layout. `focusConversationId` is null + * when the restored layout holds no conversation pane (e.g. terminals only). + */ + attemptRestore( + live: LiveWorkbenchSurfaces, + ): Promise<{ focusConversationId: string | null } | null>; +}; + +/** + * Window-level workbench layout owner. The layout is authoritative for pane + * topology and focus; the page's "current conversation" is kept equal to the + * focused pane's conversation by `syncCurrentConversation` plus the caller + * selecting a conversation whenever focus moves to another pane. + */ +export function useWindowWorkbench(params: UseWindowWorkbenchParams): WindowWorkbench { + const { enabled, initialConversationId, initialProject, persistence } = params; + + const [layout, setLayout] = useState(() => + singlePaneLayout(initialConversationId, initialProject), + ); + const layoutRef = useRef(layout); + layoutRef.current = layout; + const restoreAttemptedRef = useRef(false); + // Writes stay disabled until the restore round-trip finished, so a fresh + // boot layout cannot clobber a stored multi-pane layout mid-load. + const restoreCompletedRef = useRef(false); + const persistTimerRef = useRef(null); + const persistenceRef = useRef(persistence); + persistenceRef.current = persistence; + + useEffect(() => { + if (!enabled || !persistenceRef.current || !restoreCompletedRef.current) return; + if (persistTimerRef.current !== null) window.clearTimeout(persistTimerRef.current); + persistTimerRef.current = window.setTimeout(() => { + persistTimerRef.current = null; + persistenceRef.current?.save({ + payloadJson: encodeWorkbenchLayout(layout), + schemaVersion: layout.schemaVersion, + revision: layout.revision, + }); + }, PERSIST_DEBOUNCE_MS); + return () => { + if (persistTimerRef.current !== null) { + window.clearTimeout(persistTimerRef.current); + persistTimerRef.current = null; + } + }; + }, [enabled, layout]); + + const dispatch = useCallback((command: WorkbenchCommand): WorkbenchCommandResult => { + const result = applyWorkbenchCommand(layoutRef.current, command); + if (result.ok) { + layoutRef.current = result.layout; + setLayout(result.layout); + } + return result; + }, []); + + const dispatchCurrent = useCallback( + (command: WorkbenchCommandInput): WorkbenchCommandResult => + dispatch({ + ...command, + expectedRevision: layoutRef.current.revision, + } as WorkbenchCommand), + [dispatch], + ); + + const paneIdForConversation = useCallback( + (conversationId: string) => findPaneIdByConversationId(layoutRef.current, conversationId), + [], + ); + + const focusPane = useCallback( + (paneId: string): PaneRecord | null => { + const pane = layoutRef.current.panes[paneId]; + if (!pane) return null; + dispatchCurrent({ type: "FOCUS_PANE", paneId }); + return pane; + }, + [dispatchCurrent], + ); + + const openConversation = useCallback( + ( + input: WorkbenchOpenConversationInput, + target: WorkbenchOpenTarget, + ): { paneId: string } | null => { + const existingPaneId = findPaneIdByConversationId(layoutRef.current, input.conversationId); + if (existingPaneId) { + dispatchCurrent({ type: "FOCUS_PANE", paneId: existingPaneId }); + return { paneId: existingPaneId }; + } + const paneId = createPaneId(); + const result = dispatchCurrent({ + type: "OPEN_PANE", + pane: conversationPaneRecord(paneId, input.conversationId, input.project), + target, + }); + return result.ok ? { paneId } : null; + }, + [dispatchCurrent], + ); + + const openTerminalSurface = useCallback( + (surface: TerminalWorkbenchSurface, target: WorkbenchOpenTarget): { paneId: string } | null => { + const existingPaneId = findPaneIdBySurfaceKey(layoutRef.current, surfaceIdentityKey(surface)); + if (existingPaneId) { + dispatchCurrent({ type: "FOCUS_PANE", paneId: existingPaneId }); + return { paneId: existingPaneId }; + } + const paneId = createPaneId(); + const result = dispatchCurrent({ + type: "OPEN_PANE", + pane: { paneId, surface, view: {} }, + target, + }); + return result.ok ? { paneId } : null; + }, + [dispatchCurrent], + ); + + const movePane = useCallback( + (paneId: string, target: WorkbenchMoveTarget): boolean => { + const result = dispatchCurrent({ type: "MOVE_PANE", paneId, target }); + return result.ok; + }, + [dispatchCurrent], + ); + + const closePane = useCallback( + (paneId: string): { closedFocused: boolean; nextConversationId: string | null } => { + const closedFocused = layoutRef.current.focusedPaneId === paneId; + const result = dispatchCurrent({ type: "CLOSE_PANE", paneId }); + if (!result.ok) return { closedFocused: false, nextConversationId: null }; + const nextFocused = result.layout.focusedPaneId; + const nextPane = nextFocused ? result.layout.panes[nextFocused] : null; + // Focus landing on a terminal/unsupported pane keeps the page's current + // conversation unchanged: only conversation panes yield a next id. + return { + closedFocused, + nextConversationId: + nextPane?.surface.kind === "conversation" ? nextPane.surface.conversationId : null, + }; + }, + [dispatchCurrent], + ); + + const resizeSplit = useCallback( + (splitId: string, ratio: number) => { + dispatchCurrent({ type: "RESIZE_SPLIT", splitId, ratio }); + }, + [dispatchCurrent], + ); + + const equalizeSplit = useCallback( + (splitId: string) => { + dispatchCurrent({ type: "EQUALIZE_SPLIT", splitId }); + }, + [dispatchCurrent], + ); + + const syncCurrentConversation = useCallback((conversationId: string, project: ProjectRef) => { + const current = layoutRef.current; + const key = conversationId.trim(); + if (!key) return; + + const commit = (next: WorkbenchLayout) => { + if (!isWorkbenchLayoutValid(next)) return; + layoutRef.current = next; + setLayout(next); + }; + + // Empty canvas: the current conversation becomes a fresh root pane. + if (!current.root || !current.focusedPaneId) { + commit({ + ...singlePaneLayout(key, project), + revision: current.revision + 1, + }); + return; + } + + const focusedPane = current.panes[current.focusedPaneId]; + if (!focusedPane) return; + // Terminal/unsupported panes never host a conversation: focusing one must + // not pull the page's current conversation into it. + if (focusedPane.surface.kind !== "conversation") return; + + if (focusedPane.surface.conversationId === key) { + const focusedProject = focusedPane.surface.project; + if ( + focusedProject.projectId === project.projectId && + focusedProject.projectPathKey === project.projectPathKey + ) { + return; + } + commit({ + ...current, + revision: current.revision + 1, + panes: { + ...current.panes, + [focusedPane.paneId]: { + ...focusedPane, + surface: { kind: "conversation", conversationId: key, project }, + }, + }, + }); + return; + } + + // Another pane already hosts the conversation: focus moves there so the + // uniqueness invariant holds (never two panes for one conversation). + const existingPaneId = findPaneIdByConversationId(current, key); + if (existingPaneId) { + commit({ ...current, revision: current.revision + 1, focusedPaneId: existingPaneId }); + return; + } + + // The page navigated to a conversation with no pane: the focused pane + // follows it, exactly like the legacy single-pane behaviour. + commit({ + ...current, + revision: current.revision + 1, + panes: { + ...current.panes, + [focusedPane.paneId]: { + ...focusedPane, + surface: { kind: "conversation", conversationId: key, project }, + }, + }, + }); + }, []); + + const attemptRestore = useCallback( + async (live: LiveWorkbenchSurfaces): Promise<{ focusConversationId: string | null } | null> => { + if (restoreAttemptedRef.current) return null; + restoreAttemptedRef.current = true; + try { + const adapter = persistenceRef.current; + if (!enabled || !adapter) return null; + let raw: string | null = null; + try { + raw = await adapter.load(); + } catch { + return null; + } + if (!raw) return null; + const decoded = decodeWorkbenchLayout(raw); + if (!decoded.ok) { + // Keep a diagnostic copy of the corrupted payload, then fall back. + adapter.saveCorrupted(raw); + return null; + } + const filtered = filterLayoutToLiveSurfaces(decoded.layout, live); + if (!filtered.root || !filtered.focusedPaneId || !isWorkbenchLayoutValid(filtered)) { + return null; + } + // Nothing to restore beyond what boot already shows. + if (Object.keys(filtered.panes).length < 2) return null; + // Keep revisions monotonic across sessions so persisted records keep + // increasing rather than restarting from the boot layout's zero. + const next = { + ...filtered, + revision: Math.max(filtered.revision, layoutRef.current.revision) + 1, + }; + layoutRef.current = next; + setLayout(next); + const focusedPane = filtered.panes[filtered.focusedPaneId]; + // Focus restored onto a terminal/unsupported pane: fall back to the + // first conversation pane in tree order, or null when none survive. + return { + focusConversationId: + focusedPane.surface.kind === "conversation" + ? focusedPane.surface.conversationId + : firstConversationId(filtered), + }; + } finally { + restoreCompletedRef.current = true; + } + }, + [enabled], + ); + + return useMemo( + () => ({ + layout, + layoutRef, + paneIdForConversation, + dispatch, + focusPane, + openConversation, + openTerminalSurface, + movePane, + closePane, + resizeSplit, + equalizeSplit, + syncCurrentConversation, + attemptRestore, + }), + [ + layout, + paneIdForConversation, + dispatch, + focusPane, + openConversation, + openTerminalSurface, + movePane, + closePane, + resizeSplit, + equalizeSplit, + syncCurrentConversation, + attemptRestore, + ], + ); +} diff --git a/crates/agent-gui/src/pages/chat/workbench/useWorkbenchDragSession.ts b/crates/agent-gui/src/pages/chat/workbench/useWorkbenchDragSession.ts new file mode 100644 index 000000000..e7f5670e4 --- /dev/null +++ b/crates/agent-gui/src/pages/chat/workbench/useWorkbenchDragSession.ts @@ -0,0 +1,327 @@ +import { WORKBENCH_CANVAS_DIVIDER_SIZE as CANVAS_DIVIDER_SIZE } from "@liveagent/ui/components/workbench/WorkbenchCanvas"; +import { + hitTestWorkbenchDrop, + MIN_CONVERSATION_PANE_HEIGHT, + MIN_CONVERSATION_PANE_WIDTH, + previewRectForDropTarget, + type WorkbenchDropTarget, + type WorkbenchEdge, + type WorkbenchGeometry, + type WorkbenchRect, +} from "@liveagent/ui/lib/workbench/index"; +import { + type ProjectRef, + surfaceIdentityKey, + type WorkbenchLayout, +} from "@liveagent/ui/lib/workbench/types"; +import { useCallback, useEffect, useRef, useState } from "react"; + +const DRAG_THRESHOLD_PX = 6; +/** Pointer-splitting is disabled on very narrow canvases (doc §22). */ +const MIN_CANVAS_WIDTH_FOR_POINTER_SPLIT = 440; + +/** Both halves of a split must keep the conversation hard minimum size. */ +export function canSplitRectAtEdge(rect: WorkbenchRect, edge: WorkbenchEdge): boolean { + const divider = CANVAS_DIVIDER_SIZE; + if (edge === "left" || edge === "right") { + return (rect.width - divider) / 2 >= MIN_CONVERSATION_PANE_WIDTH; + } + return (rect.height - divider) / 2 >= MIN_CONVERSATION_PANE_HEIGHT; +} + +export type WorkbenchDragPayload = + | { kind: "conversation"; conversationId: string; project: ProjectRef; title: string } + /** Moving an existing pane; surfaceKey is surfaceIdentityKey(pane.surface). */ + | { kind: "pane"; paneId: string; surfaceKey: string; title: string } + /** Dragging a workspace creates a new conversation for it at the drop spot. */ + | { kind: "workspace"; projectId: string; projectPath: string; title: string } + /** Dragging an existing terminal session (e.g. from the Right Dock) into a pane. */ + | { kind: "terminalSession"; sessionId: string; project: ProjectRef; title: string } + /** Dragging a "new terminal" affordance creates a terminal at the drop spot. */ + | { kind: "newTerminal"; project: ProjectRef; title: string }; + +export type WorkbenchDropCommit = { + payload: WorkbenchDragPayload; + target: WorkbenchDropTarget; + /** Layout revision frozen when the drag activated (CAS at commit time). */ + revision: number; +}; + +export type WorkbenchDragState = { + payload: WorkbenchDragPayload; + pointer: { x: number; y: number }; + target: WorkbenchDropTarget | null; + previewRect: WorkbenchRect | null; +}; + +type PendingDrag = { + payload: WorkbenchDragPayload; + pointerId: number; + startX: number; + startY: number; +}; + +type ActiveDrag = PendingDrag & { + canvasOrigin: { left: number; top: number }; + geometry: WorkbenchGeometry; + revision: number; +}; + +export type UseWorkbenchDragSessionParams = { + enabled: boolean; + layoutRef: React.MutableRefObject; + geometryRef: React.MutableRefObject; + onCommit: (commit: WorkbenchDropCommit) => void; +}; + +/** + * Pointer-driven drag session shared by sidebar conversation drags and pane + * chrome drags. Arms on pointer-down, activates after a 6px threshold with a + * frozen geometry + revision snapshot, previews the drop target on move, and + * commits exactly once on pointer-up. Esc, pointer-cancel and window blur + * cancel without layout changes; clicks are suppressed once a drag activates. + */ +export function useWorkbenchDragSession(params: UseWorkbenchDragSessionParams) { + const { enabled, layoutRef, geometryRef, onCommit } = params; + const [dragState, setDragState] = useState(null); + const pendingRef = useRef(null); + const activeRef = useRef(null); + const onCommitRef = useRef(onCommit); + onCommitRef.current = onCommit; + + const cleanupListenersRef = useRef<(() => void) | null>(null); + + const teardown = useCallback(() => { + pendingRef.current = null; + activeRef.current = null; + cleanupListenersRef.current?.(); + cleanupListenersRef.current = null; + document.documentElement.style.removeProperty("cursor"); + setDragState(null); + }, []); + + useEffect(() => teardown, [teardown]); + + /** + * Normalize a raw hit-test target for the payload: + * - own-pane hits become focus/no-op (pane-center on itself); + * - sidebar payloads never overwrite a pane center — they auto-dock + * (bottom-first on narrow canvases, else right, then the other axis); + * - every split target is rejected when either half would fall below the + * conversation hard minimum size, so drops with insufficient space show + * no preview and commit nothing. + */ + const resolveTarget = useCallback( + ( + raw: WorkbenchDropTarget | null, + payload: WorkbenchDragPayload, + geometry: WorkbenchGeometry, + ): WorkbenchDropTarget | null => { + if (!raw) return null; + const layout = layoutRef.current; + // terminalSession drags have no own pane here: the session→pane mapping + // lives in the lease store, and a leased session is not draggable from + // the sidebar in the first place. + const ownPaneId = + payload.kind === "pane" + ? payload.paneId + : payload.kind === "conversation" + ? Object.values(layout.panes).find( + (pane) => + surfaceIdentityKey(pane.surface) === `conversation:${payload.conversationId}`, + )?.paneId + : undefined; + + const paneRect = (paneId: string): WorkbenchRect | null => + geometry.panes.find((pane) => pane.paneId === paneId)?.rect ?? null; + + if (raw.kind === "pane-center") { + if (ownPaneId && raw.paneId === ownPaneId) { + return { kind: "pane-center", paneId: ownPaneId }; + } + // Sidebar payloads never overwrite a pane: deterministic auto-dock. + if (payload.kind !== "pane") { + const rect = paneRect(raw.paneId); + if (!rect) return null; + const preferVertical = geometry.canvas.width < 680; + const edges: WorkbenchEdge[] = preferVertical ? ["bottom", "right"] : ["right", "bottom"]; + for (const edge of edges) { + if (canSplitRectAtEdge(rect, edge)) { + return { kind: "pane-edge", paneId: raw.paneId, edge }; + } + } + return null; + } + return raw; + } + if (raw.kind === "pane-edge") { + if (ownPaneId && raw.paneId === ownPaneId) { + return { kind: "pane-center", paneId: ownPaneId }; + } + const rect = paneRect(raw.paneId); + if (!rect || !canSplitRectAtEdge(rect, raw.edge)) return null; + return raw; + } + if (raw.kind === "canvas-edge") { + return canSplitRectAtEdge(geometry.canvas, raw.edge) ? raw : null; + } + if (raw.kind === "divider") { + const divider = geometry.dividers.find((entry) => entry.splitId === raw.splitId); + if (!divider) return null; + // The inserted pane halves the region on the chosen side of the bar. + const before = raw.edge === "left" || raw.edge === "top"; + const region: WorkbenchRect = + divider.axis === "horizontal" + ? before + ? { ...divider.splitArea, width: divider.rect.left - divider.splitArea.left } + : { + ...divider.splitArea, + left: divider.rect.left + divider.rect.width, + width: + divider.splitArea.left + + divider.splitArea.width - + (divider.rect.left + divider.rect.width), + } + : before + ? { ...divider.splitArea, height: divider.rect.top - divider.splitArea.top } + : { + ...divider.splitArea, + top: divider.rect.top + divider.rect.height, + height: + divider.splitArea.top + + divider.splitArea.height - + (divider.rect.top + divider.rect.height), + }; + if (!canSplitRectAtEdge(region, divider.axis === "horizontal" ? "right" : "bottom")) { + return null; + } + return raw; + } + if (raw.kind === "canvas-empty" && payload.kind === "pane") { + return null; + } + return raw; + }, + [layoutRef], + ); + + const beginDrag = useCallback( + ( + payload: WorkbenchDragPayload, + event: { pointerId: number; clientX: number; clientY: number }, + ) => { + if (!enabled || pendingRef.current || activeRef.current) return; + pendingRef.current = { + payload, + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + }; + + // Suppress the synthetic click that follows the drag's pointer-up so a + // completed drag never doubles as a row/handle click. Disarms itself on + // the first click it consumes or on the next fresh pointer-down. + const disarmClickSuppressor = () => { + window.removeEventListener("click", suppressClick, true); + window.removeEventListener("pointerdown", disarmClickSuppressor, true); + }; + const suppressClick = (clickEvent: MouseEvent) => { + clickEvent.preventDefault(); + clickEvent.stopPropagation(); + disarmClickSuppressor(); + }; + const armClickSuppressor = () => { + window.addEventListener("click", suppressClick, true); + window.addEventListener("pointerdown", disarmClickSuppressor, true); + }; + + const handleMove = (moveEvent: PointerEvent) => { + const pending = pendingRef.current; + if (!pending || moveEvent.pointerId !== pending.pointerId) return; + if (!activeRef.current) { + const dx = moveEvent.clientX - pending.startX; + const dy = moveEvent.clientY - pending.startY; + if (dx * dx + dy * dy < DRAG_THRESHOLD_PX * DRAG_THRESHOLD_PX) return; + const canvasElement = document.querySelector("[data-workbench-canvas]"); + const geometry = geometryRef.current; + if (!canvasElement || !geometry) { + teardown(); + return; + } + // Very narrow canvases disable pointer splitting entirely. + if (geometry.canvas.width < MIN_CANVAS_WIDTH_FOR_POINTER_SPLIT) { + teardown(); + return; + } + const canvasRect = canvasElement.getBoundingClientRect(); + activeRef.current = { + ...pending, + canvasOrigin: { left: canvasRect.left, top: canvasRect.top }, + geometry, + revision: layoutRef.current.revision, + }; + armClickSuppressor(); + document.documentElement.style.setProperty("cursor", "grabbing"); + } + const active = activeRef.current; + const localX = moveEvent.clientX - active.canvasOrigin.left; + const localY = moveEvent.clientY - active.canvasOrigin.top; + const target = resolveTarget( + hitTestWorkbenchDrop(active.geometry, localX, localY), + active.payload, + active.geometry, + ); + setDragState({ + payload: active.payload, + pointer: { x: moveEvent.clientX, y: moveEvent.clientY }, + target, + previewRect: target ? previewRectForDropTarget(active.geometry, target) : null, + }); + }; + + const handleUp = (upEvent: PointerEvent) => { + const pending = pendingRef.current; + if (!pending || upEvent.pointerId !== pending.pointerId) return; + const active = activeRef.current; + if (active) { + const localX = upEvent.clientX - active.canvasOrigin.left; + const localY = upEvent.clientY - active.canvasOrigin.top; + const target = resolveTarget( + hitTestWorkbenchDrop(active.geometry, localX, localY), + active.payload, + active.geometry, + ); + if (target) { + onCommitRef.current({ + payload: active.payload, + target, + revision: active.revision, + }); + } + } + teardown(); + }; + + const handleCancel = () => teardown(); + const handleKeyDown = (keyEvent: KeyboardEvent) => { + if (keyEvent.key === "Escape") teardown(); + }; + + window.addEventListener("pointermove", handleMove); + window.addEventListener("pointerup", handleUp); + window.addEventListener("pointercancel", handleCancel); + window.addEventListener("blur", handleCancel); + window.addEventListener("keydown", handleKeyDown, true); + cleanupListenersRef.current = () => { + window.removeEventListener("pointermove", handleMove); + window.removeEventListener("pointerup", handleUp); + window.removeEventListener("pointercancel", handleCancel); + window.removeEventListener("blur", handleCancel); + window.removeEventListener("keydown", handleKeyDown, true); + }; + }, + [enabled, geometryRef, layoutRef, resolveTarget, teardown], + ); + + return { dragState, beginDrag }; +} diff --git a/crates/agent-gui/test/chat/workbench-window-session.test.mjs b/crates/agent-gui/test/chat/workbench-window-session.test.mjs new file mode 100644 index 000000000..51f39f469 --- /dev/null +++ b/crates/agent-gui/test/chat/workbench-window-session.test.mjs @@ -0,0 +1,141 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +const loader = createTsModuleLoader(); +const workbench = loader.loadModule("@liveagent/ui/lib/workbench/index.ts"); +const { filterLayoutToLiveSurfaces, WORKBENCH_LAYOUT_STORAGE_KEY } = loader.loadModule( + "src/pages/chat/workbench/useWindowWorkbench.ts", +); + +function pane(paneId, conversationId) { + return { + paneId, + surface: { + kind: "conversation", + conversationId, + project: { projectId: `p-${conversationId}`, projectPathKey: `/w/${conversationId}` }, + }, + view: {}, + }; +} + +function terminalPane(paneId, surfaceId) { + return { + paneId, + surface: { + kind: "localTerminal", + surfaceId, + project: { projectId: `p-${surfaceId}`, projectPathKey: `/w/${surfaceId}` }, + launchSpec: { cwd: `/w/${surfaceId}` }, + }, + view: {}, + }; +} + +function unsupportedPane(paneId) { + return { + paneId, + surface: { + kind: "unsupported", + originalKind: "future-kind", + raw: { kind: "future-kind" }, + }, + view: {}, + }; +} + +function threePaneLayout() { + return { + schemaVersion: workbench.WORKBENCH_LAYOUT_SCHEMA_VERSION, + revision: 7, + root: { + type: "split", + splitId: "s1", + axis: "horizontal", + ratio: 0.5, + first: { type: "leaf", paneId: "pane-a" }, + second: { + type: "split", + splitId: "s2", + axis: "vertical", + ratio: 0.5, + first: { type: "leaf", paneId: "pane-b" }, + second: { type: "leaf", paneId: "pane-c" }, + }, + }, + panes: { + "pane-a": pane("pane-a", "conv-a"), + "pane-b": pane("pane-b", "conv-b"), + "pane-c": pane("pane-c", "conv-c"), + }, + focusedPaneId: "pane-b", + }; +} + +function mixedLayout() { + const layout = threePaneLayout(); + layout.panes["pane-b"] = terminalPane("pane-b", "term-b"); + layout.panes["pane-c"] = unsupportedPane("pane-c"); + return layout; +} + +test("restore filtering drops panes for missing conversations and collapses splits", () => { + const filtered = filterLayoutToLiveSurfaces(threePaneLayout(), { + validConversationIds: new Set(["conv-a", "conv-c"]), + }); + assert.deepEqual(Object.keys(filtered.panes).sort(), ["pane-a", "pane-c"]); + assert.equal(filtered.root.type, "split"); + assert.equal(filtered.root.second.type, "leaf"); + assert.equal(filtered.root.second.paneId, "pane-c"); + // The dropped focused pane falls back to the first surviving leaf. + assert.equal(filtered.focusedPaneId, "pane-a"); + assert.equal(workbench.isWorkbenchLayoutValid(filtered), true); +}); + +test("restore filtering keeps a fully valid layout untouched", () => { + const layout = threePaneLayout(); + const filtered = filterLayoutToLiveSurfaces(layout, { + validConversationIds: new Set(["conv-a", "conv-b", "conv-c"]), + }); + assert.deepEqual(filtered.root, layout.root); + assert.equal(filtered.focusedPaneId, "pane-b"); +}); + +test("restore filtering empties the layout when nothing survives", () => { + const filtered = filterLayoutToLiveSurfaces(threePaneLayout(), { + validConversationIds: new Set(), + }); + assert.equal(filtered.root, null); + assert.deepEqual(filtered.panes, {}); + assert.equal(filtered.focusedPaneId, null); +}); + +test("restore filtering drops terminal panes when no live-session information exists", () => { + const filtered = filterLayoutToLiveSurfaces(mixedLayout(), { + validConversationIds: new Set(["conv-a"]), + }); + // Terminal pane drops (no liveTerminalSurfaceIds); unsupported pane survives. + assert.deepEqual(Object.keys(filtered.panes).sort(), ["pane-a", "pane-c"]); + assert.equal(filtered.panes["pane-c"].surface.kind, "unsupported"); +}); + +test("restore filtering keeps terminal panes whose surfaceId is confirmed live", () => { + const filtered = filterLayoutToLiveSurfaces(mixedLayout(), { + validConversationIds: new Set(["conv-a"]), + liveTerminalSurfaceIds: new Set(["term-b"]), + }); + assert.deepEqual(Object.keys(filtered.panes).sort(), ["pane-a", "pane-b", "pane-c"]); + assert.equal(filtered.panes["pane-b"].surface.kind, "localTerminal"); + // The focused terminal pane keeps focus; conversation selection is the + // caller's concern (attemptRestore falls back to the first conversation). + assert.equal(filtered.focusedPaneId, "pane-b"); +}); + +test("persisted layout round-trips through the codec", () => { + const layout = threePaneLayout(); + const decoded = workbench.decodeWorkbenchLayout(workbench.encodeWorkbenchLayout(layout)); + assert.equal(decoded.ok, true); + assert.deepEqual(decoded.layout, layout); + assert.equal(typeof WORKBENCH_LAYOUT_STORAGE_KEY, "string"); +}); diff --git a/crates/agent-gui/test/chat/workbench-window-terminal.test.mjs b/crates/agent-gui/test/chat/workbench-window-terminal.test.mjs new file mode 100644 index 000000000..d44d2b386 --- /dev/null +++ b/crates/agent-gui/test/chat/workbench-window-terminal.test.mjs @@ -0,0 +1,217 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +// 终端 Pane 泛化后的 useWindowWorkbench 行为:openTerminalSurface 复用、 +// closePane/syncCurrentConversation 对非会话 Pane 的收窄、attemptRestore 回退。 + +function createHookHarness() { + const refs = []; + const states = []; + const effects = []; + let refIndex = 0; + let stateIndex = 0; + let effectIndex = 0; + + const react = { + useRef(initialValue) { + const index = refIndex++; + refs[index] ??= { current: initialValue }; + return refs[index]; + }, + useState(initialValue) { + const index = stateIndex++; + if (!(index in states)) { + states[index] = typeof initialValue === "function" ? initialValue() : initialValue; + } + const setState = (next) => { + states[index] = typeof next === "function" ? next(states[index]) : next; + }; + return [states[index], setState]; + }, + useCallback(callback) { + return callback; + }, + useMemo(factory) { + return factory(); + }, + useEffect(effect, deps = []) { + const index = effectIndex++; + const previous = effects[index]; + const changed = + !previous || + deps.length !== previous.deps.length || + deps.some((value, depIndex) => value !== previous.deps[depIndex]); + if (!changed) return; + previous?.cleanup?.(); + effects[index] = { deps: [...deps], cleanup: effect() }; + }, + }; + + return { + react, + render(run) { + refIndex = 0; + stateIndex = 0; + effectIndex = 0; + return run(); + }, + cleanup() { + for (const effect of effects) { + effect?.cleanup?.(); + } + }, + }; +} + +function loadHook(harness) { + const loader = createTsModuleLoader({ mocks: { react: harness.react } }); + const workbenchLib = loader.loadModule("@liveagent/ui/lib/workbench/index.ts"); + const { useWindowWorkbench, WORKBENCH_LAYOUT_STORAGE_KEY } = loader.loadModule( + "src/pages/chat/workbench/useWindowWorkbench.ts", + ); + return { workbenchLib, useWindowWorkbench, WORKBENCH_LAYOUT_STORAGE_KEY }; +} + +const PROJECT = { projectId: "project-main", projectPathKey: "/workspace/project-main" }; + +function terminalSurface(surfaceId) { + return { + kind: "localTerminal", + surfaceId, + project: PROJECT, + launchSpec: { cwd: "/workspace/project-main" }, + }; +} + +function renderWorkbench(harness, hook, params) { + return harness.render(() => + hook({ + enabled: true, + initialConversationId: "conv-root", + initialProject: PROJECT, + ...params, + }), + ); +} + +test("openTerminalSurface opens a pane and focuses the existing one on reuse", () => { + const harness = createHookHarness(); + const { useWindowWorkbench } = loadHook(harness); + const workbench = renderWorkbench(harness, useWindowWorkbench, {}); + const rootPaneId = workbench.layoutRef.current.focusedPaneId; + + const opened = workbench.openTerminalSurface(terminalSurface("term-1"), { + kind: "pane-edge", + paneId: rootPaneId, + edge: "right", + }); + assert.ok(opened); + assert.equal(workbench.layoutRef.current.panes[opened.paneId].surface.surfaceId, "term-1"); + assert.equal(workbench.layoutRef.current.focusedPaneId, opened.paneId); + + // Same surfaceId again: no second pane, the existing one is focused. + workbench.focusPane(rootPaneId); + const reused = workbench.openTerminalSurface(terminalSurface("term-1"), { + kind: "pane-edge", + paneId: rootPaneId, + edge: "bottom", + }); + assert.ok(reused); + assert.equal(reused.paneId, opened.paneId); + assert.equal(Object.keys(workbench.layoutRef.current.panes).length, 2); + assert.equal(workbench.layoutRef.current.focusedPaneId, opened.paneId); + harness.cleanup(); +}); + +test("closePane returns a null next conversation when focus lands on a terminal pane", () => { + const harness = createHookHarness(); + const { useWindowWorkbench } = loadHook(harness); + const workbench = renderWorkbench(harness, useWindowWorkbench, {}); + const rootPaneId = workbench.layoutRef.current.focusedPaneId; + const opened = workbench.openTerminalSurface(terminalSurface("term-1"), { + kind: "pane-edge", + paneId: rootPaneId, + edge: "right", + }); + assert.ok(opened); + workbench.focusPane(rootPaneId); + + const result = workbench.closePane(rootPaneId); + assert.equal(result.closedFocused, true); + // The surviving pane is the terminal: the page keeps its conversation. + assert.equal(result.nextConversationId, null); + assert.equal(workbench.layoutRef.current.focusedPaneId, opened.paneId); + harness.cleanup(); +}); + +test("syncCurrentConversation is a no-op while a terminal pane is focused", () => { + const harness = createHookHarness(); + const { useWindowWorkbench } = loadHook(harness); + const workbench = renderWorkbench(harness, useWindowWorkbench, {}); + const rootPaneId = workbench.layoutRef.current.focusedPaneId; + const opened = workbench.openTerminalSurface(terminalSurface("term-1"), { + kind: "pane-edge", + paneId: rootPaneId, + edge: "right", + }); + assert.ok(opened); + assert.equal(workbench.layoutRef.current.focusedPaneId, opened.paneId); + + const before = workbench.layoutRef.current; + workbench.syncCurrentConversation("conv-other", PROJECT); + const after = workbench.layoutRef.current; + // The conversation must never be written into the terminal pane. + assert.equal(after, before); + assert.equal(after.panes[opened.paneId].surface.kind, "localTerminal"); + harness.cleanup(); +}); + +test("attemptRestore falls back to the first conversation pane when a terminal holds focus", async () => { + const harness = createHookHarness(); + const { useWindowWorkbench, workbenchLib } = loadHook(harness); + + const persisted = { + schemaVersion: workbenchLib.WORKBENCH_LAYOUT_SCHEMA_VERSION, + revision: 3, + root: { + type: "split", + splitId: "s1", + axis: "horizontal", + ratio: 0.5, + first: { type: "leaf", paneId: "pane-conv" }, + second: { type: "leaf", paneId: "pane-term" }, + }, + panes: { + "pane-conv": { + paneId: "pane-conv", + surface: { kind: "conversation", conversationId: "conv-a", project: PROJECT }, + view: {}, + }, + "pane-term": { + paneId: "pane-term", + surface: terminalSurface("term-1"), + view: {}, + }, + }, + focusedPaneId: "pane-term", + }; + const persistence = { + async load() { + return workbenchLib.encodeWorkbenchLayout(persisted); + }, + save() {}, + saveCorrupted() {}, + }; + + const workbench = renderWorkbench(harness, useWindowWorkbench, { persistence }); + const restored = await workbench.attemptRestore({ + validConversationIds: new Set(["conv-a"]), + liveTerminalSurfaceIds: new Set(["term-1"]), + }); + assert.ok(restored); + assert.equal(restored.focusConversationId, "conv-a"); + assert.equal(workbench.layoutRef.current.focusedPaneId, "pane-term"); + assert.equal(Object.keys(workbench.layoutRef.current.panes).length, 2); + harness.cleanup(); +}); From 5139d36534c461928e429180d475c378536583e4 Mon Sep 17 00:00:00 2001 From: yovinchen Date: Mon, 17 Aug 2026 04:07:12 +0800 Subject: [PATCH 11/76] =?UTF-8?q?feat(chat):=20=E4=BC=9A=E8=AF=9D/?= =?UTF-8?q?=E7=BB=88=E7=AB=AF=20pane=20=E5=AE=BF=E4=B8=BB=E4=B8=8E?= =?UTF-8?q?=E5=90=8E=E5=8F=B0=E4=BC=9A=E8=AF=9D=20harness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ConversationPaneHost:单 pane 内的转录 + 审批条 + 任务进度 + composer,上传 drop zone 收归 pane 内 - ConversationPaneHostEnvironment 注入宿主依赖,后台 pane 经 ConversationPaneHarness 按 spec 挂载 - TerminalPaneHost 桥接终端 pane 运行时 - 契约测试锁定 controller/registry/harness 行为与 flag 默认值 --- .../chat/surfaces/ConversationPaneHost.tsx | 136 +++++ .../ConversationPaneHostEnvironment.tsx | 124 +++++ .../pages/chat/surfaces/TerminalPaneHost.tsx | 177 +++++++ .../workbench/ConversationPaneHarness.tsx | 48 ++ .../workbench/conversationPaneHarnessModel.ts | 27 + .../chat/session-workbench-contracts.test.mjs | 491 ++++++++++++++++++ 6 files changed, 1003 insertions(+) create mode 100644 crates/agent-gui/src/pages/chat/surfaces/ConversationPaneHost.tsx create mode 100644 crates/agent-gui/src/pages/chat/surfaces/ConversationPaneHostEnvironment.tsx create mode 100644 crates/agent-gui/src/pages/chat/surfaces/TerminalPaneHost.tsx create mode 100644 crates/agent-gui/src/pages/chat/workbench/ConversationPaneHarness.tsx create mode 100644 crates/agent-gui/src/pages/chat/workbench/conversationPaneHarnessModel.ts create mode 100644 crates/agent-gui/test/chat/session-workbench-contracts.test.mjs diff --git a/crates/agent-gui/src/pages/chat/surfaces/ConversationPaneHost.tsx b/crates/agent-gui/src/pages/chat/surfaces/ConversationPaneHost.tsx new file mode 100644 index 000000000..e0d582ea1 --- /dev/null +++ b/crates/agent-gui/src/pages/chat/surfaces/ConversationPaneHost.tsx @@ -0,0 +1,136 @@ +import { ChangedFilesActionsProvider } from "@liveagent/ui/components/chat/ChangedFilesCard"; +import { FileDropOverlay } from "@liveagent/ui/components/chat/FileDropOverlay"; +import type { MentionComposerHandle } from "@liveagent/ui/components/chat/MentionComposer"; +import type { ScrollFollowHandle } from "@liveagent/ui/lib/chat-scroll/useScrollFollow"; +import type { ProjectRef } from "@liveagent/ui/lib/workbench/types"; +import { ChatComposerBar } from "@liveagent/ui/pages/chat/ChatComposerBar"; +import { forwardRef, useImperativeHandle, useLayoutEffect, useRef, useState } from "react"; +import { CurrentTaskProgress } from "../components/CurrentTaskProgress"; +import { PendingToolApprovalBar } from "../components/PendingToolApprovalBar"; +import type { ConversationPaneHostHandle } from "../conversations/useConversationPaneHostBridge"; +import { buildQueuedChatTurnPreview } from "../queue/chatTurnQueue"; +import { ChatTranscript } from "../transcript/ChatTranscript"; +import { useConversationPaneBinding } from "./ConversationPaneHostEnvironment"; +import { ConversationSurface } from "./ConversationSurface"; + +export type ConversationPaneHostProps = { + paneId: string; + conversationId: string; + project: ProjectRef; +}; + +export const ConversationPaneHost = forwardRef< + ConversationPaneHostHandle, + ConversationPaneHostProps +>(function ConversationPaneHost(props, forwardedRef) { + const { paneId, conversationId, project } = props; + const { controller, transcript, composer, changedFilesActions, isConversationRunning, fileDrop } = + useConversationPaneBinding({ paneId, conversationId, project }); + const composerRef = useRef(null); + const scrollFollowRef = useRef(null); + const [composerOverlayHeight, setComposerOverlayHeight] = useState(0); + + useImperativeHandle( + forwardedRef, + () => ({ + getComposer: () => composerRef.current, + getScrollFollow: () => scrollFollowRef.current, + }), + [], + ); + + useLayoutEffect(() => { + const composer = composerRef.current; + const draft = controller.getSnapshot().draft; + if (draft) { + composer?.setDraft(draft); + } else { + composer?.clear(); + } + + return () => { + // Save only non-empty drafts. The page pipeline may already have + // cleared this composer mid-switch (legacy reset semantics), so an + // empty composer must not delete the draft cached in the registry — + // deliberate clears propagate through the page-level draft cache. + const nextDraft = composer?.getDraft(); + if (!nextDraft || nextDraft.isEmpty || !nextDraft.text.trim()) { + return; + } + controller.setDraft(nextDraft); + }; + }, [controller]); + + return ( + { + const runtime = snapshot.runtime; + const historyItems = runtime?.state.transcript.items ?? []; + const isSending = runtime?.isSending ?? false; + const isCompactionRunning = snapshot.compaction.phase === "running"; + const queuedTurns = snapshot.queue.map((item) => ({ + id: item.id, + previewText: buildQueuedChatTurnPreview(item.draft), + fileCount: item.uploadedFiles.length, + })); + + return { + transcript: ( + + + + ), + composer: ( + + } + approvalBar={ + + } + fileDropOverlay={ + fileDrop.active ? ( + + ) : null + } + /> + ), + }; + }} + /> + ); +}); diff --git a/crates/agent-gui/src/pages/chat/surfaces/ConversationPaneHostEnvironment.tsx b/crates/agent-gui/src/pages/chat/surfaces/ConversationPaneHostEnvironment.tsx new file mode 100644 index 000000000..d502566bd --- /dev/null +++ b/crates/agent-gui/src/pages/chat/surfaces/ConversationPaneHostEnvironment.tsx @@ -0,0 +1,124 @@ +import type { ChangedFilesActions } from "@liveagent/ui/components/chat/ChangedFilesCard"; +import type { ProjectRef } from "@liveagent/ui/lib/workbench/types"; +import type { ChatComposerBarProps } from "@liveagent/ui/pages/chat/ChatComposerBar"; +import { createContext, type ReactNode, useContext } from "react"; +import type { ConversationSurfaceController } from "../conversations/conversationControllerTypes"; +import type { ChatTranscriptProps } from "../transcript/ChatTranscript"; + +export type ConversationTranscriptBindings = Omit< + ChatTranscriptProps, + | "conversationId" + | "followRef" + | "historyItems" + | "hasMoreHistory" + | "isSending" + | "isCompactionRunning" + | "bottomReservePx" +>; + +export type ConversationComposerBindings = Omit< + ChatComposerBarProps, + | "composerRef" + | "isSending" + | "pendingUploadedFiles" + | "queuedTurns" + | "onStop" + | "onManualCompactConfirm" + | "manualCompactBlocked" + | "onHeightChange" + | "taskProgressBar" + | "approvalBar" + | "fileDropOverlay" +>; + +export type ConversationPaneFileDropState = { + active: boolean; + canDropUpload: boolean; + title: string; + description: string; + limitHint: string; +}; + +export type ConversationPaneIdentity = { + paneId: string; + conversationId: string; + project: ProjectRef; +}; + +export type ConversationPaneBinding = { + controller: ConversationSurfaceController; + transcript: ConversationTranscriptBindings; + composer: ConversationComposerBindings; + changedFilesActions: ChangedFilesActions; + isConversationRunning: boolean; + fileDrop: ConversationPaneFileDropState; +}; + +export type ConversationPaneHostEnvironment = { + resolvePane(identity: ConversationPaneIdentity): ConversationPaneBinding; +}; + +export type ConversationPaneRegistration = { + identity: ConversationPaneIdentity; + binding: ConversationPaneBinding; +}; + +export function createConversationPaneHostEnvironment( + registrations: readonly ConversationPaneRegistration[], +): ConversationPaneHostEnvironment { + const registrationsByPaneId = new Map(); + for (const registration of registrations) { + const paneId = registration.identity.paneId.trim(); + if (!paneId) { + throw new Error("Conversation pane registrations require a stable pane id."); + } + if (registrationsByPaneId.has(paneId)) { + throw new Error(`Duplicate conversation pane registration: ${paneId}`); + } + if (registration.binding.controller.conversationId !== registration.identity.conversationId) { + throw new Error("Conversation pane registration controller identity mismatch."); + } + registrationsByPaneId.set(paneId, registration); + } + + return { + resolvePane(identity) { + const registration = registrationsByPaneId.get(identity.paneId.trim()); + if ( + !registration || + registration.identity.conversationId !== identity.conversationId || + registration.identity.project.projectId !== identity.project.projectId || + registration.identity.project.projectPathKey !== identity.project.projectPathKey + ) { + throw new Error(`Conversation pane environment cannot resolve pane: ${identity.paneId}`); + } + return registration.binding; + }, + }; +} + +const ConversationPaneHostEnvironmentContext = + createContext(null); + +export function ConversationPaneHostEnvironmentProvider(props: { + value: ConversationPaneHostEnvironment; + children: ReactNode; +}) { + return ( + + {props.children} + + ); +} + +export function useConversationPaneBinding(identity: ConversationPaneIdentity) { + const environment = useContext(ConversationPaneHostEnvironmentContext); + if (!environment) { + throw new Error("ConversationPaneHost requires a ConversationPaneHostEnvironmentProvider."); + } + const binding = environment.resolvePane(identity); + if (binding.controller.conversationId !== identity.conversationId) { + throw new Error("ConversationPaneHost resolved a controller for a different conversation."); + } + return binding; +} diff --git a/crates/agent-gui/src/pages/chat/surfaces/TerminalPaneHost.tsx b/crates/agent-gui/src/pages/chat/surfaces/TerminalPaneHost.tsx new file mode 100644 index 000000000..7ce918719 --- /dev/null +++ b/crates/agent-gui/src/pages/chat/surfaces/TerminalPaneHost.tsx @@ -0,0 +1,177 @@ +import { + LocalTerminalPaneSurface, + type TerminalPaneSurfacePhase, +} from "@liveagent/ui/components/workbench/index"; +import { useLocale } from "@liveagent/ui/i18n/index"; +import type { TerminalSession } from "@liveagent/ui/lib/terminal/types"; +import type { TerminalWorkbenchSurface } from "@liveagent/ui/lib/workbench/types"; +import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; +import { tauriTerminalClient } from "../../../lib/terminal/tauriTerminalClient"; +import { + ensureTerminalPaneSession, + TerminalPaneSshPromptError, + terminalPaneBindings, + terminalPaneLease, +} from "../workbench/terminalPaneRuntime"; + +export type TerminalPaneHostProps = { + paneId: string; + surface: TerminalWorkbenchSurface; + isFocused: boolean; + theme: "light" | "dark"; + /** 全窗口会话列表(未按项目过滤):Pane 可承载任意项目的终端。 */ + sessions: readonly TerminalSession[]; + sessionsLoaded: boolean; +}; + +type TerminalPaneErrorState = + | { kind: "session-missing" } + | { kind: "ssh-prompt" } + | { kind: "create-failed"; message: string } + | { kind: "lease"; message: string }; + +/** + * 终端 Pane 的页面侧宿主:把布局层的 launchSpec 身份接到运行时—— + * 绑定(surfaceId→sessionId)解析既有会话,缺失时按 launchSpec 异步建会话 + * (布局先行,创建失败停在可重试的 error 态);渲染前必须持有该会话的 + * 视图租约,保证输出流单消费、输入单写。 + */ +export function TerminalPaneHost(props: TerminalPaneHostProps) { + const { paneId, surface, isFocused, theme, sessions, sessionsLoaded } = props; + const { t } = useLocale(); + + const boundSessionId = useSyncExternalStore(terminalPaneBindings.subscribe, () => + terminalPaneBindings.get(surface.surfaceId), + ); + // create 响应先于 terminal:event 到达时的直接渲染兜底;事件送达后列表版本优先。 + const [createdSession, setCreatedSession] = useState(null); + const [errorState, setErrorState] = useState(null); + const [viewportError, setViewportError] = useState(null); + const [leasedSessionId, setLeasedSessionId] = useState(null); + + const liveSession = boundSessionId + ? (sessions.find((entry) => entry.id === boundSessionId) ?? null) + : null; + const session = + liveSession ?? (createdSession && createdSession.id === boundSessionId ? createdSession : null); + const sessionId = session?.id ?? null; + + useEffect(() => { + if (liveSession && createdSession) setCreatedSession(null); + }, [createdSession, liveSession]); + + useEffect(() => { + if (!sessionsLoaded || session || errorState) return; + if (boundSessionId) { + // 绑定指向的会话已不在注册表:会话被外部关闭,或应用重启后的陈旧绑定。 + setErrorState({ kind: "session-missing" }); + return; + } + let cancelled = false; + void ensureTerminalPaneSession(surface, { + client: tauriTerminalClient, + bindings: terminalPaneBindings, + }) + .then((created) => { + if (!cancelled) setCreatedSession(created); + }) + .catch((error) => { + if (cancelled) return; + setErrorState( + error instanceof TerminalPaneSshPromptError + ? { kind: "ssh-prompt" } + : { + kind: "create-failed", + message: error instanceof Error ? error.message : String(error), + }, + ); + }); + return () => { + cancelled = true; + }; + }, [boundSessionId, errorState, session, sessionsLoaded, surface]); + + useEffect(() => { + if (!sessionId) return; + try { + const release = terminalPaneLease.acquire(sessionId, paneId); + setLeasedSessionId(sessionId); + return () => { + release(); + setLeasedSessionId((current) => (current === sessionId ? null : current)); + }; + } catch (error) { + // reducer 的 surface 唯一性已挡住双 Pane;这里只做防御性降级。 + setErrorState({ + kind: "lease", + message: error instanceof Error ? error.message : String(error), + }); + return; + } + }, [paneId, sessionId]); + + const handleViewportError = useCallback((_sessionId: string, message: string | null) => { + setViewportError(message); + }, []); + + const restartFromLaunchSpec = useCallback(() => { + const staleSessionId = terminalPaneBindings.get(surface.surfaceId); + if (staleSessionId) { + // 退出的会话重启时顺手回收注册表条目;失败不阻塞重建。 + void tauriTerminalClient.close(staleSessionId).catch(() => {}); + } + terminalPaneBindings.delete(surface.surfaceId); + setCreatedSession(null); + setViewportError(null); + setErrorState(null); + }, [surface.surfaceId]); + + const errorMessageFor = (state: TerminalPaneErrorState): string => { + switch (state.kind) { + case "session-missing": + return t("workbench.terminalSessionMissing"); + case "ssh-prompt": + return t("workbench.terminalSshPrompt"); + case "create-failed": + case "lease": + return state.message || t("workbench.terminalError"); + } + }; + + const leased = session !== null && leasedSessionId === session.id; + let phase: TerminalPaneSurfacePhase; + let renderSession: TerminalSession | null = null; + let errorMessage: string | null = null; + let onRetry: (() => void) | undefined = restartFromLaunchSpec; + if (errorState) { + phase = "error"; + errorMessage = errorMessageFor(errorState); + } else if (leased && session) { + renderSession = session; + if (viewportError) { + // 视口自身会退避重试 attach;提示条只反映瞬时错误,重试仅清除提示。 + phase = "error"; + errorMessage = viewportError; + onRetry = () => setViewportError(null); + } else { + phase = session.running ? "ready" : "exited"; + } + } else { + phase = "connecting"; + onRetry = undefined; + } + + return ( + + ); +} diff --git a/crates/agent-gui/src/pages/chat/workbench/ConversationPaneHarness.tsx b/crates/agent-gui/src/pages/chat/workbench/ConversationPaneHarness.tsx new file mode 100644 index 000000000..349a47ad2 --- /dev/null +++ b/crates/agent-gui/src/pages/chat/workbench/ConversationPaneHarness.tsx @@ -0,0 +1,48 @@ +import { ConversationPaneHost } from "../surfaces/ConversationPaneHost"; +import { + type ConversationPaneHostEnvironment, + ConversationPaneHostEnvironmentProvider, +} from "../surfaces/ConversationPaneHostEnvironment"; +import { + assertConversationPaneHarnessSpecs, + type ConversationPaneHarnessSpec, +} from "./conversationPaneHarnessModel"; + +export type { ConversationPaneHarnessSpec } from "./conversationPaneHarnessModel"; +export { assertConversationPaneHarnessSpecs } from "./conversationPaneHarnessModel"; + +export type ConversationPaneHarnessProps = { + environment: ConversationPaneHostEnvironment; + panes: readonly [ConversationPaneHarnessSpec, ConversationPaneHarnessSpec]; +}; + +export function ConversationPaneHarness(props: ConversationPaneHarnessProps) { + const { environment, panes } = props; + assertConversationPaneHarnessSpecs(panes); + + return ( + +
+ {panes.map((pane, index) => ( +
+ +
+ ))} +
+
+ ); +} diff --git a/crates/agent-gui/src/pages/chat/workbench/conversationPaneHarnessModel.ts b/crates/agent-gui/src/pages/chat/workbench/conversationPaneHarnessModel.ts new file mode 100644 index 000000000..7ebaf8103 --- /dev/null +++ b/crates/agent-gui/src/pages/chat/workbench/conversationPaneHarnessModel.ts @@ -0,0 +1,27 @@ +import type { ProjectRef } from "@liveagent/ui/lib/workbench/types"; + +export type ConversationPaneHarnessSpec = { + paneId: string; + conversationId: string; + project: ProjectRef; +}; + +export function assertConversationPaneHarnessSpecs(panes: readonly ConversationPaneHarnessSpec[]) { + const paneIds = new Set(); + const conversationIds = new Set(); + for (const pane of panes) { + if (!pane.paneId.trim() || !pane.conversationId.trim()) { + throw new Error("ConversationPaneHarness requires stable pane and conversation ids."); + } + if (paneIds.has(pane.paneId)) { + throw new Error(`ConversationPaneHarness received duplicate pane id: ${pane.paneId}`); + } + if (conversationIds.has(pane.conversationId)) { + throw new Error( + `ConversationPaneHarness cannot mount one editable conversation twice: ${pane.conversationId}`, + ); + } + paneIds.add(pane.paneId); + conversationIds.add(pane.conversationId); + } +} diff --git a/crates/agent-gui/test/chat/session-workbench-contracts.test.mjs b/crates/agent-gui/test/chat/session-workbench-contracts.test.mjs new file mode 100644 index 000000000..218bc49c0 --- /dev/null +++ b/crates/agent-gui/test/chat/session-workbench-contracts.test.mjs @@ -0,0 +1,491 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +const loader = createTsModuleLoader(); +const workbench = loader.loadModule("@liveagent/ui/lib/workbench/index.ts"); +const { createConversationRuntimeRegistry } = loader.loadModule( + "src/pages/chat/conversations/createConversationRuntimeRegistry.ts", +); +const { createConversationSurfaceController } = loader.loadModule( + "src/pages/chat/conversations/createConversationSurfaceController.ts", +); +const { setConversationRuntimeCacheEntry } = loader.loadModule( + "src/pages/chat/runtime/chatPageRuntime.ts", +); +const { createTextComposerDraft } = loader.loadModule("@liveagent/ui/lib/chat/composerDraft.ts"); +const { answerToolApproval, requestToolApproval } = loader.loadModule( + "src/lib/tools/toolApproval.ts", +); +const { assertConversationPaneHarnessSpecs } = loader.loadModule( + "src/pages/chat/workbench/conversationPaneHarnessModel.ts", +); + +function conversationPane(paneId, conversationId, projectId = "project-main") { + return { + paneId, + surface: { + kind: "conversation", + conversationId, + project: { + projectId, + projectPathKey: `/workspace/${projectId}`, + }, + }, + view: {}, + }; +} + +function conversationPaneHarnessSpec(paneId, conversationId) { + return { + paneId, + conversationId, + project: { + projectId: `project-${conversationId}`, + projectPathKey: `/workspace/${conversationId}`, + }, + }; +} + +function twoPaneLayout() { + const first = conversationPane("pane-a", "conversation-a"); + const second = conversationPane("pane-b", "conversation-b"); + return { + schemaVersion: workbench.WORKBENCH_LAYOUT_SCHEMA_VERSION, + revision: 3, + root: { + type: "split", + splitId: "split-root", + axis: "horizontal", + ratio: 0.5, + first: { type: "leaf", paneId: first.paneId }, + second: { type: "leaf", paneId: second.paneId }, + }, + panes: { + [first.paneId]: first, + [second.paneId]: second, + }, + focusedPaneId: first.paneId, + }; +} + +function issueCodes(layout) { + return workbench.collectWorkbenchLayoutIssues(layout).map((item) => item.code); +} + +function runtimeEntry(conversationId, isSending = false) { + return { + state: { id: conversationId }, + compactionStatus: { phase: "idle" }, + isSending, + errorMessage: null, + hookWarning: null, + sessionId: `${conversationId}-session`, + createdAt: 1, + }; +} + +function uploadedFile(fileName) { + return { + relativePath: `uploads/${fileName}`, + fileName, + kind: "text", + sizeBytes: 12, + }; +} + +function queuedTurn(conversationId, id) { + return { + id, + conversationId, + draft: createTextComposerDraft(`queued-${id}`), + uploadedFiles: [], + executionMode: "tools", + workdir: `/workspace/${conversationId}`, + runtimeControls: {}, + createdAt: 1, + }; +} + +function controllerActions(events) { + return { + async hydrate(input) { + events.push(["hydrate", input.conversationId]); + }, + async send(input) { + events.push(["send", input.conversationId, input.draft.text]); + }, + stop(input) { + events.push(["stop", input.conversationId]); + }, + async compact(input) { + events.push(["compact", input.conversationId]); + }, + async retry(input) { + events.push(["retry", input.conversationId]); + }, + }; +} + +function surfaceController(registry, conversationId, events = []) { + return createConversationSurfaceController({ + conversationId, + project: { + projectId: `project-${conversationId}`, + projectPathKey: `/workspace/${conversationId}`, + }, + registry, + actions: controllerActions(events), + }); +} + +test("session workbench feature flag is internal and defaults to disabled", () => { + assert.deepEqual(workbench.createSessionWorkbenchFeature(undefined), { enabled: false }); + assert.deepEqual(workbench.createSessionWorkbenchFeature("false"), { enabled: false }); + assert.deepEqual(workbench.createSessionWorkbenchFeature(" true "), { enabled: true }); + assert.deepEqual(workbench.createSessionWorkbenchFeature("1"), { enabled: true }); +}); + +test("empty and two-conversation workbench layouts satisfy the frozen contract", () => { + const empty = workbench.createEmptyWorkbenchLayout(); + const split = twoPaneLayout(); + + assert.equal(workbench.isWorkbenchLayoutValid(empty), true); + assert.equal(workbench.isWorkbenchLayoutValid(split), true); + assert.doesNotThrow(() => workbench.assertWorkbenchLayout(split)); + assert.equal(workbench.findPaneIdByConversationId(split, "conversation-b"), "pane-b"); +}); + +test("the same conversation cannot own two editable panes", () => { + const layout = twoPaneLayout(); + layout.panes["pane-b"].surface.conversationId = "conversation-a"; + + assert.deepEqual(issueCodes(layout), ["duplicate-conversation"]); + assert.throws( + () => workbench.assertWorkbenchLayout(layout), + (error) => + error.name === "WorkbenchLayoutInvariantError" && + error.issues.some((item) => item.code === "duplicate-conversation"), + ); +}); + +test("pane records and tree leaves must have a one-to-one relationship", () => { + const layout = twoPaneLayout(); + layout.root.second.paneId = "pane-missing"; + + assert.deepEqual(issueCodes(layout), ["missing-pane-record", "orphan-pane-record"]); +}); + +test("non-empty layouts require a valid focused pane", () => { + const missingFocus = twoPaneLayout(); + missingFocus.focusedPaneId = null; + const unknownFocus = twoPaneLayout(); + unknownFocus.focusedPaneId = "pane-missing"; + + assert.deepEqual(issueCodes(missingFocus), ["invalid-focus"]); + assert.deepEqual(issueCodes(unknownFocus), ["invalid-focus"]); +}); + +test("empty layouts cannot retain panes or focus", () => { + const layout = workbench.createEmptyWorkbenchLayout(); + layout.panes["pane-a"] = conversationPane("pane-a", "conversation-a"); + layout.focusedPaneId = "pane-a"; + + assert.deepEqual(issueCodes(layout), ["invalid-empty-layout"]); +}); + +test("split ids, ratios, schema versions, and revisions are validated", () => { + const layout = twoPaneLayout(); + layout.schemaVersion += 1; + layout.revision = -1; + layout.root.splitId = ""; + layout.root.ratio = 1; + + assert.deepEqual(issueCodes(layout), [ + "invalid-schema-version", + "invalid-revision", + "duplicate-split-id", + "invalid-ratio", + ]); +}); + +test("revision guard rejects stale drag transactions before mutation", () => { + const layout = twoPaneLayout(); + + assert.equal(workbench.getWorkbenchRevisionError(layout, 3), null); + assert.deepEqual(workbench.getWorkbenchRevisionError(layout, 2), { + code: "stale-revision", + message: "Workbench revision changed from 2 to 3.", + currentRevision: 3, + }); +}); + +test("two-pane harness requires distinct stable pane and conversation identities", () => { + const first = conversationPaneHarnessSpec("pane-a", "conversation-a"); + const second = conversationPaneHarnessSpec("pane-b", "conversation-b"); + + assert.doesNotThrow(() => assertConversationPaneHarnessSpecs([first, second])); + assert.throws( + () => + assertConversationPaneHarnessSpecs([ + first, + conversationPaneHarnessSpec("pane-a", "conversation-b"), + ]), + /duplicate pane id/, + ); + assert.throws( + () => + assertConversationPaneHarnessSpecs([ + first, + conversationPaneHarnessSpec("pane-b", "conversation-a"), + ]), + /cannot mount one editable conversation twice/, + ); +}); + +test("runtime registry subscriptions stay isolated by conversation id", () => { + const registry = createConversationRuntimeRegistry([ + ["conversation-a", runtimeEntry("conversation-a")], + ["conversation-b", runtimeEntry("conversation-b")], + ]); + const events = []; + const unsubscribeA = registry.subscribe("conversation-a", () => events.push("a")); + const unsubscribeB = registry.subscribe("conversation-b", () => events.push("b")); + + setConversationRuntimeCacheEntry( + registry, + "conversation-a", + runtimeEntry("conversation-a", true), + ); + + assert.deepEqual(events, ["a"]); + assert.equal(registry.getSnapshot("conversation-a").isSending, true); + assert.equal(registry.getSnapshot("conversation-b").isSending, false); + unsubscribeA(); + unsubscribeB(); +}); + +test("runtime registry preserves runtime state after the last view is released", () => { + const registry = createConversationRuntimeRegistry([ + ["conversation-a", runtimeEntry("conversation-a", true)], + ]); + const releaseFirst = registry.retainView("conversation-a"); + const releaseSecond = registry.retainView("conversation-a"); + + assert.equal(registry.getViewCount("conversation-a"), 2); + releaseFirst(); + releaseFirst(); + releaseSecond(); + + assert.equal(registry.getViewCount("conversation-a"), 0); + assert.equal(registry.getSnapshot("conversation-a").isSending, true); +}); + +test("deleting one runtime notifies only its subscribers", () => { + const registry = createConversationRuntimeRegistry([ + ["conversation-a", runtimeEntry("conversation-a")], + ["conversation-b", runtimeEntry("conversation-b")], + ]); + const events = []; + registry.subscribe("conversation-a", () => events.push("a")); + registry.subscribe("conversation-b", () => events.push("b")); + + registry.delete("conversation-b"); + + assert.deepEqual(events, ["b"]); + assert.equal(registry.getSnapshot("conversation-a").sessionId, "conversation-a-session"); + assert.equal(registry.getSnapshot("conversation-b"), null); +}); + +test("two surface controllers isolate runtime and composer draft snapshots", () => { + const registry = createConversationRuntimeRegistry([ + ["conversation-a", runtimeEntry("conversation-a")], + ["conversation-b", runtimeEntry("conversation-b")], + ]); + const controllerA = surfaceController(registry, "conversation-a"); + const controllerB = surfaceController(registry, "conversation-b"); + const events = []; + controllerA.subscribe(() => events.push("a")); + controllerB.subscribe(() => events.push("b")); + + controllerB.setDraft(createTextComposerDraft("draft-b")); + setConversationRuntimeCacheEntry( + registry, + "conversation-a", + runtimeEntry("conversation-a", true), + ); + + assert.deepEqual(events, ["b", "a"]); + assert.equal(controllerA.getSnapshot().runtime.isSending, true); + assert.equal(controllerA.getSnapshot().draft, null); + assert.equal(controllerB.getSnapshot().runtime.isSending, false); + assert.equal(controllerB.getSnapshot().draft.text, "draft-b"); +}); + +test("two surface controllers isolate upload and queue snapshots", () => { + const registry = createConversationRuntimeRegistry([ + ["conversation-a", runtimeEntry("conversation-a")], + ["conversation-b", runtimeEntry("conversation-b")], + ]); + const controllerA = surfaceController(registry, "conversation-a"); + const controllerB = surfaceController(registry, "conversation-b"); + const events = []; + controllerA.subscribe(() => events.push("a")); + controllerB.subscribe(() => events.push("b")); + + registry.uploads.set("conversation-a", [uploadedFile("a.txt")]); + registry.queue.set([queuedTurn("conversation-b", "queue-b")]); + + assert.deepEqual(events, ["a", "b"]); + assert.deepEqual( + controllerA.getSnapshot().uploads.map((item) => item.fileName), + ["a.txt"], + ); + assert.deepEqual(controllerA.getSnapshot().queue, []); + assert.deepEqual(controllerB.getSnapshot().uploads, []); + assert.deepEqual( + controllerB.getSnapshot().queue.map((item) => item.id), + ["queue-b"], + ); +}); + +test("conversation queue store preserves global order and emits only changed slices", () => { + const registry = createConversationRuntimeRegistry(); + const events = []; + registry.queue.subscribe("conversation-a", () => events.push("a")); + registry.queue.subscribe("conversation-b", () => events.push("b")); + const firstA = queuedTurn("conversation-a", "queue-a-1"); + const firstB = queuedTurn("conversation-b", "queue-b-1"); + const secondA = queuedTurn("conversation-a", "queue-a-2"); + + registry.queue.set([firstA, firstB, secondA]); + registry.queue.set([firstA, secondA, firstB]); + registry.queue.set([secondA, firstA, firstB]); + + assert.deepEqual(events, ["a", "b", "a"]); + assert.deepEqual( + registry.queue.getAllSnapshot().map((item) => item.id), + ["queue-a-2", "queue-a-1", "queue-b-1"], + ); + assert.deepEqual( + registry.queue.getSnapshot("conversation-a").map((item) => item.id), + ["queue-a-2", "queue-a-1"], + ); + assert.deepEqual( + registry.queue.getSnapshot("conversation-b").map((item) => item.id), + ["queue-b-1"], + ); +}); + +test("two surface controllers isolate approval snapshots", async () => { + const registry = createConversationRuntimeRegistry([ + ["conversation-a", runtimeEntry("conversation-a")], + ["conversation-b", runtimeEntry("conversation-b")], + ]); + const controllerA = surfaceController(registry, "conversation-a"); + const controllerB = surfaceController(registry, "conversation-b"); + const events = []; + controllerA.subscribe(() => events.push("a")); + controllerB.subscribe(() => events.push("b")); + + const settlement = requestToolApproval({ + toolCallId: "approval-a", + toolName: "Bash", + summary: "pnpm test", + conversationId: "conversation-a", + timeoutMs: 10_000, + }); + + assert.deepEqual(events, ["a"]); + assert.deepEqual( + controllerA.getSnapshot().approvals.map((item) => item.toolCallId), + ["approval-a"], + ); + assert.deepEqual(controllerB.getSnapshot().approvals, []); + + assert.equal( + answerToolApproval("approval-a", "deny", { conversationId: "conversation-a" }).ok, + true, + ); + assert.deepEqual(await settlement, { kind: "decided", decision: "deny" }); + assert.deepEqual(events, ["a", "a"]); +}); + +test("two surface controllers isolate model and compaction slices", () => { + const registry = createConversationRuntimeRegistry([ + ["conversation-a", runtimeEntry("conversation-a")], + ["conversation-b", runtimeEntry("conversation-b")], + ]); + const controllerA = surfaceController(registry, "conversation-a"); + const controllerB = surfaceController(registry, "conversation-b"); + const events = []; + controllerA.subscribe(() => events.push("a")); + controllerB.subscribe(() => events.push("b")); + const selectedModel = { customProviderId: "provider-a", model: "model-a" }; + const compaction = { + phase: "running", + trigger: "manual", + startedAt: 2, + sourceSegmentIndex: 1, + }; + + setConversationRuntimeCacheEntry(registry, "conversation-a", { + ...runtimeEntry("conversation-a"), + selectedModel, + compactionStatus: compaction, + }); + + assert.deepEqual(events, ["a"]); + assert.equal(controllerA.getSnapshot().model, selectedModel); + assert.equal(controllerA.getSnapshot().compaction, compaction); + assert.equal(controllerB.getSnapshot().model, null); + assert.deepEqual(controllerB.getSnapshot().compaction, { phase: "idle" }); +}); + +test("surface controller actions always route through their bound conversation id", async () => { + const registry = createConversationRuntimeRegistry([ + ["conversation-a", runtimeEntry("conversation-a")], + ]); + const events = []; + const controller = surfaceController(registry, "conversation-a", events); + const draft = createTextComposerDraft("send-a"); + + await controller.hydrate(); + await controller.send(draft); + controller.stop(); + await controller.compact(); + await controller.retry(); + + assert.deepEqual(events, [ + ["hydrate", "conversation-a"], + ["send", "conversation-a", "send-a"], + ["stop", "conversation-a"], + ["compact", "conversation-a"], + ["retry", "conversation-a"], + ]); +}); + +test("deleting a conversation clears runtime and draft in one controller update", () => { + const registry = createConversationRuntimeRegistry([ + ["conversation-a", runtimeEntry("conversation-a")], + ]); + const controller = surfaceController(registry, "conversation-a"); + controller.setDraft(createTextComposerDraft("draft-a")); + const snapshots = []; + controller.subscribe(() => snapshots.push(controller.getSnapshot())); + + registry.delete("conversation-a"); + + assert.equal(snapshots.length, 1); + assert.equal(snapshots[0].runtime, null); + assert.equal(snapshots[0].draft, null); + assert.equal(registry.drafts.getSnapshot("conversation-a"), null); +}); + +test("deleting a draft-only conversation does not leave an orphaned composer slice", () => { + const registry = createConversationRuntimeRegistry(); + registry.drafts.set("conversation-draft", createTextComposerDraft("draft-only")); + + assert.equal(registry.delete("conversation-draft"), true); + assert.equal(registry.drafts.getSnapshot("conversation-draft"), null); +}); From 11d06259578c75d1d09b1ebf6c3e3c0d5486e659 Mon Sep 17 00:00:00 2001 From: yovinchen Date: Mon, 17 Aug 2026 04:22:39 +0800 Subject: [PATCH 12/76] =?UTF-8?q?feat(ui):=20=E4=BE=A7=E6=A0=8F=E4=B8=8E?= =?UTF-8?q?=E5=8F=B3=E4=BE=A7=20dock=20=E6=8E=A5=E5=85=A5=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E5=8F=B0=E6=8B=96=E6=8B=BD=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 会话/项目行支持指针拖入工作台,菜单新增「在分屏中打开」(Columns2) - 终端 tab 可拖出 dock 成为终端 pane,grip 保留 tab 排序 - dock 隐藏被 pane 租用的会话(hiddenSessionIds) --- .../chat/sidebar/ChatSidebarContainer.tsx | 12 +++ crates/agent-ui/src/components/IconSet.tsx | 2 + .../components/chat/ChatHistorySidebar.tsx | 9 ++ .../chat/ChatHistorySidebarRows.tsx | 82 ++++++++++++++++++- .../chat/ChatHistorySidebarTypes.ts | 12 +++ .../project-tools/RightDockPanel.tsx | 11 +++ .../project-tools/RightDockTabStrip.tsx | 29 ++++++- .../project-tools/useRightDockSessions.ts | 13 ++- 8 files changed, 166 insertions(+), 4 deletions(-) diff --git a/crates/agent-gui/src/pages/chat/sidebar/ChatSidebarContainer.tsx b/crates/agent-gui/src/pages/chat/sidebar/ChatSidebarContainer.tsx index de634006e..f5d48c86a 100644 --- a/crates/agent-gui/src/pages/chat/sidebar/ChatSidebarContainer.tsx +++ b/crates/agent-gui/src/pages/chat/sidebar/ChatSidebarContainer.tsx @@ -45,6 +45,15 @@ type ChatSidebarContainerProps = ChatHistorySidebarContainerSource & { // and replaces the current conversation when needed. onConversationDeleted: (id: string) => void; onConversationCwdChanged: (id: string, cwd: string) => void; + onConversationWorkbenchDragIntent?: ( + item: SidebarConversation, + event: { pointerId: number; clientX: number; clientY: number }, + ) => void; + onConversationOpenInWorkbenchSplit?: (item: SidebarConversation) => void; + onProjectWorkbenchDragIntent?: ( + project: WorkspaceProject, + event: { pointerId: number; clientX: number; clientY: number }, + ) => void; appUpdate?: AppUpdateController; }; @@ -204,6 +213,9 @@ export function ChatSidebarContainer(props: ChatSidebarContainerProps) { onDeleteConversations: handleDeleteConversations, onLoadMore: handleLoadMore, })} + onConversationWorkbenchDragIntent={props.onConversationWorkbenchDragIntent} + onConversationOpenInWorkbenchSplit={props.onConversationOpenInWorkbenchSplit} + onProjectWorkbenchDragIntent={props.onProjectWorkbenchDragIntent} headerTop={} brand={} hideCloseButton={hideDesktopSidebarCloseButton()} diff --git a/crates/agent-ui/src/components/IconSet.tsx b/crates/agent-ui/src/components/IconSet.tsx index d017c6cc4..e0a13b9f9 100644 --- a/crates/agent-ui/src/components/IconSet.tsx +++ b/crates/agent-ui/src/components/IconSet.tsx @@ -32,6 +32,7 @@ import ClipboardPasteSource from "~icons/lucide/clipboard-paste"; import Clock3Source from "~icons/lucide/clock-3"; import CloudSource from "~icons/lucide/cloud"; import CloudDownloadSource from "~icons/lucide/cloud-download"; +import Columns2Source from "~icons/lucide/columns-2"; import CopySource from "~icons/lucide/copy"; import CpuSource from "~icons/lucide/cpu"; import TargetSource from "~icons/lucide/crosshair"; @@ -499,6 +500,7 @@ export const Clock3 = createIcon(Clock3Source); export const Cloud = createIcon(CloudSource); export const CloudDownload = createIcon(CloudDownloadSource); export const ConnectionIcon = createIcon(ConnectionIconSource); +export const Columns2 = createIcon(Columns2Source); export const Copy = createIcon(CopySource); export const Cpu = createIcon(CpuSource); export const Download = createIcon(DownloadSource); diff --git a/crates/agent-ui/src/components/chat/ChatHistorySidebar.tsx b/crates/agent-ui/src/components/chat/ChatHistorySidebar.tsx index 9f8e2aa0f..5937ceafa 100644 --- a/crates/agent-ui/src/components/chat/ChatHistorySidebar.tsx +++ b/crates/agent-ui/src/components/chat/ChatHistorySidebar.tsx @@ -209,6 +209,9 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi archivedProjectPathKeys = EMPTY_PROJECT_PATH_KEYS, onNewConversation, onSelectConversation, + onConversationWorkbenchDragIntent, + onConversationOpenInWorkbenchSplit, + onProjectWorkbenchDragIntent, onStartRenaming, onRenameDraftChange, onCommitRename, @@ -1125,6 +1128,8 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi menuOpen={!sectionsDisabled && openMenuId === item.id} menuSide={menuSide} onMenuOpenChange={handleMenuOpenChange} + onWorkbenchDragIntent={onConversationWorkbenchDragIntent} + onOpenInWorkbenchSplit={onConversationOpenInWorkbenchSplit} /> ), [ @@ -1136,6 +1141,8 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi handleMenuOpenChange, handleRenameDraftChange, handleSelectConversation, + onConversationWorkbenchDragIntent, + onConversationOpenInWorkbenchSplit, handleMoveToWorkspace, handleSetPinned, handleSetPendingDelete, @@ -1454,6 +1461,7 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi } isInteractionDisabled={sectionsDisabled} onSelectProject={handleSelectProject} + onWorkbenchDragIntent={onProjectWorkbenchDragIntent} onBrowseProjectInFileTree={ onBrowseProjectInFileTree ? handleBrowseProjectInFileTree @@ -1519,6 +1527,7 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi } isInteractionDisabled={sectionsDisabled} onSelectProject={handleSelectProject} + onWorkbenchDragIntent={onProjectWorkbenchDragIntent} onBrowseProjectInFileTree={ onBrowseProjectInFileTree ? handleBrowseProjectInFileTree diff --git a/crates/agent-ui/src/components/chat/ChatHistorySidebarRows.tsx b/crates/agent-ui/src/components/chat/ChatHistorySidebarRows.tsx index 49d092166..77347c5d9 100644 --- a/crates/agent-ui/src/components/chat/ChatHistorySidebarRows.tsx +++ b/crates/agent-ui/src/components/chat/ChatHistorySidebarRows.tsx @@ -9,6 +9,7 @@ import { ArchiveRestore, Check, ChevronRight, + Columns2, Edit3, Folder, FolderClosed, @@ -96,6 +97,17 @@ type HistoryRowProps = { menuOpen: boolean; menuSide: "bottom" | "right"; onMenuOpenChange: (id: string, open: boolean) => void; + /** + * Workbench pointer-drag intent from the row title area (desktop only). + * The drag session activates after a movement threshold, so plain clicks + * keep their existing select semantics. + */ + onWorkbenchDragIntent?: ( + item: SidebarConversation, + event: { pointerId: number; clientX: number; clientY: number }, + ) => void; + /** Menu alternative to dragging: open the conversation in a split pane. */ + onOpenInWorkbenchSplit?: (item: SidebarConversation) => void; }; function areRenderedHistoryItemsEqual(previous: SidebarConversation, next: SidebarConversation) { @@ -140,7 +152,9 @@ function areHistoryRowPropsEqual(previous: HistoryRowProps, next: HistoryRowProp previous.onSetPendingDelete === next.onSetPendingDelete && previous.onSelectForBulk === next.onSelectForBulk && previous.onEnterSelectionMode === next.onEnterSelectionMode && - previous.onMenuOpenChange === next.onMenuOpenChange + previous.onMenuOpenChange === next.onMenuOpenChange && + previous.onWorkbenchDragIntent === next.onWorkbenchDragIntent && + previous.onOpenInWorkbenchSplit === next.onOpenInWorkbenchSplit ); } @@ -176,6 +190,8 @@ export const HistoryRow = memo(function HistoryRow(props: HistoryRowProps) { menuOpen, menuSide, onMenuOpenChange, + onWorkbenchDragIntent, + onOpenInWorkbenchSplit, } = props; const { t } = useLocale(); @@ -344,6 +360,26 @@ export const HistoryRow = memo(function HistoryRow(props: HistoryRowProps) { const handleTitlePointerDown = useCallback( (event: ReactPointerEvent) => { + // Desktop: arm a workbench pane drag from the title area. Touch keeps + // the long-press menu; renaming/selection/menu states never drag. + if ( + onWorkbenchDragIntent && + !isMobileMenuLayout && + !isInteractionDisabled && + !isSelectionMode && + !isRenaming && + !isPendingDelete && + !menuOpen && + event.pointerType !== "touch" && + event.button === 0 && + !item.isPending + ) { + onWorkbenchDragIntent(item, { + pointerId: event.pointerId, + clientX: event.clientX, + clientY: event.clientY, + }); + } if (isInteractionDisabled || isSelectionMode || !isMobileMenuLayout || isBusy) { return; } @@ -365,8 +401,13 @@ export const HistoryRow = memo(function HistoryRow(props: HistoryRowProps) { clearLongPressTimer, isBusy, isInteractionDisabled, + isPendingDelete, + isRenaming, isSelectionMode, isMobileMenuLayout, + item, + menuOpen, + onWorkbenchDragIntent, openMobileMenuFromLongPress, ], ); @@ -752,6 +793,16 @@ export const HistoryRow = memo(function HistoryRow(props: HistoryRowProps) { {t("chat.conversationBulkSelect")} + {onOpenInWorkbenchSplit && !item.isPending ? ( + onOpenInWorkbenchSplit(item)} + className="gap-2" + > + + {t("workbench.openInSplit")} + + ) : null} void; menuOpen: boolean; onMenuOpenChange: (projectId: string, open: boolean) => void; + /** + * Workbench pointer-drag intent from the project title (desktop only): + * dragging a workspace into the pane canvas creates a new conversation for + * it at the drop position. Never armed for archived or missing projects. + */ + onWorkbenchDragIntent?: ( + project: WorkspaceProject, + event: { pointerId: number; clientX: number; clientY: number }, + ) => void; }) { const { project, @@ -971,6 +1031,7 @@ export const ProjectRow = memo(function ProjectRow(props: { onMoveProjectToGroup, menuOpen, onMenuOpenChange, + onWorkbenchDragIntent, } = props; const { t } = useLocale(); const rowRef = useRef(null); @@ -1181,6 +1242,25 @@ export const ProjectRow = memo(function ProjectRow(props: { onSelectProject(project); } }} + onPointerDown={(event) => { + if ( + !onWorkbenchDragIntent || + isArchived || + isMissing || + isInteractionDisabled || + menuOpen || + pendingAction !== null || + event.pointerType === "touch" || + event.button !== 0 + ) { + return; + } + onWorkbenchDragIntent(project, { + pointerId: event.pointerId, + clientX: event.clientX, + clientY: event.clientY, + }); + }} onDoubleClick={(event) => { event.preventDefault(); if (!isInteractionDisabled) { diff --git a/crates/agent-ui/src/components/chat/ChatHistorySidebarTypes.ts b/crates/agent-ui/src/components/chat/ChatHistorySidebarTypes.ts index fd04ef6f4..b3de6286a 100644 --- a/crates/agent-ui/src/components/chat/ChatHistorySidebarTypes.ts +++ b/crates/agent-ui/src/components/chat/ChatHistorySidebarTypes.ts @@ -90,6 +90,18 @@ export type ChatHistorySidebarProps = { archivedProjectPathKeys?: ReadonlySet; onNewConversation: () => void; onSelectConversation: (id: string) => void; + /** Workbench drag intent from a conversation row title (desktop pointer). */ + onConversationWorkbenchDragIntent?: ( + item: SidebarConversation, + event: { pointerId: number; clientX: number; clientY: number }, + ) => void; + /** Menu alternative to dragging: open a conversation in a split pane. */ + onConversationOpenInWorkbenchSplit?: (item: SidebarConversation) => void; + /** Workbench drag intent from a project row title (creates a conversation). */ + onProjectWorkbenchDragIntent?: ( + project: WorkspaceProject, + event: { pointerId: number; clientX: number; clientY: number }, + ) => void; onStartRenaming: (item: SidebarConversation) => void; onRenameDraftChange: (value: string) => void; onCommitRename: () => void; diff --git a/crates/agent-ui/src/components/project-tools/RightDockPanel.tsx b/crates/agent-ui/src/components/project-tools/RightDockPanel.tsx index a7cf608c6..fd156ae0c 100644 --- a/crates/agent-ui/src/components/project-tools/RightDockPanel.tsx +++ b/crates/agent-ui/src/components/project-tools/RightDockPanel.tsx @@ -63,6 +63,8 @@ type RightDockPanelProps = { cwd: string; sessions?: TerminalSession[]; sessionsLoaded?: boolean; + /** 被工作台 Pane 租用的会话:从 dock 的 tab/视口中隐藏,避免输出流双消费。 */ + hiddenSessionIds?: ReadonlySet; width: number; theme: "light" | "dark"; disabledMessage?: string; @@ -89,6 +91,11 @@ type RightDockPanelProps = { onSshProjectHostIdsChange?: (hostIds: string[]) => void; onOpenSshSession?: (session: TerminalSession, kind?: "bash" | "sftp") => void; onSessionsChange?: (sessions: TerminalSession[]) => void; + /** 存在时终端 tab 可拖出 dock(工作台宿主);默认无行为。 */ + onTerminalTabDragStart?: ( + session: TerminalSession, + event: { pointerId: number; clientX: number; clientY: number }, + ) => void; onInsertFileMention?: (path: string, kind: "file" | "dir") => void; onOpenFile?: (path: string, imagePaths?: string[]) => void; onInsertCodeReviewSkill?: () => void; @@ -345,6 +352,7 @@ export const RightDockPanel = memo(function RightDockPanel(props: RightDockPanel cwd, sessions: externalSessions, sessionsLoaded: externalSessionsLoaded, + hiddenSessionIds, width, theme, disabledMessage, @@ -369,6 +377,7 @@ export const RightDockPanel = memo(function RightDockPanel(props: RightDockPanel onSshProjectHostIdsChange, onOpenSshSession, onSessionsChange, + onTerminalTabDragStart, onInsertFileMention, onOpenFile, onInsertCodeReviewSkill, @@ -422,6 +431,7 @@ export const RightDockPanel = memo(function RightDockPanel(props: RightDockPanel cwd, externalSessions, externalSessionsLoaded, + hiddenSessionIds, isOpen, onProjectStateChange, onSessionsChange, @@ -830,6 +840,7 @@ export const RightDockPanel = memo(function RightDockPanel(props: RightDockPanel onActivateTerminalSession={activateTerminalSession} onCloseToolTab={closeToolTab} onCloseTerminalRequest={handleCloseRequest} + onTerminalTabDragStart={onTerminalTabDragStart} />
diff --git a/crates/agent-ui/src/components/project-tools/RightDockTabStrip.tsx b/crates/agent-ui/src/components/project-tools/RightDockTabStrip.tsx index 665e7ade2..84ce7eec0 100644 --- a/crates/agent-ui/src/components/project-tools/RightDockTabStrip.tsx +++ b/crates/agent-ui/src/components/project-tools/RightDockTabStrip.tsx @@ -27,6 +27,17 @@ type RightDockTabStripProps = { onActivateTerminalSession: (session: TerminalSession) => void; onCloseToolTab: (kind: RightDockSingletonTabKind) => void; onCloseTerminalRequest: (session: TerminalSession) => void; + /** + * Provided when terminal tabs can be dragged out of the dock (workbench + * hosts). The tab body then arms the drag-out gesture instead of tab + * reorder; reorder stays available from the grip handle. Click activation + * is unaffected — the drag session suppresses the click only after its + * movement threshold. + */ + onTerminalTabDragStart?: ( + session: TerminalSession, + event: { pointerId: number; clientX: number; clientY: number }, + ) => void; }; // One descriptor per tab regardless of kind, so every tab shares a single @@ -43,6 +54,8 @@ type DockTabDescriptor = { closeTitle: string; closeIcon?: ReactNode; closeDisabled?: boolean; + /** Overrides the default reorder pointer-down on the tab body (drag-out). */ + dragProps?: RightDockTabDragProps; onActivate: () => void; onClose: () => void; }; @@ -73,6 +86,7 @@ export function RightDockTabStrip(props: RightDockTabStripProps) { onActivateTerminalSession, onCloseToolTab, onCloseTerminalRequest, + onTerminalTabDragStart, } = props; const { t } = useLocale(); @@ -89,7 +103,7 @@ export function RightDockTabStrip(props: RightDockTabStripProps) { )} title={tab.label} style={getTabDragStyle(tab.id)} - {...getTabDragProps(tab.id)} + {...(tab.dragProps ?? getTabDragProps(tab.id))} > +
+ ); + } + const host = ( + + ); + if (!blockedBanner) return host; + return ( +
+ {blockedBanner} + {host} +
+ ); + }} + renderPaneChrome={renderWorkbenchPaneChrome} + onResizeSplit={workbench.resizeSplit} + onEqualizeSplit={workbench.equalizeSplit} + onFocusPane={handleWorkbenchFocusPane} + onGeometryChange={handleWorkbenchGeometryChange} + dropPreview={ + workbenchDragState?.previewRect + ? { rect: workbenchDragState.previewRect, label: workbenchDragState.payload.title } + : null + } + emptyState={ + + } + /> + + ) : ( + + + + ); + + const workbenchDragGhost = + sessionWorkbench.enabled && workbenchDragState ? ( +
+ {workbenchDragState.payload.title || t("chat.pendingTitle")} +
+ ) : null; + return (
onOpenSettings()} appUpdate={appUpdate} /> + {workbenchDragGhost} {/* ---- Left column: navigation/sidebar ---- */} - - - } - composer={ - - } - approvalBar={approvalBar} - fileDropOverlay={ - isFileDropActive ? ( - - ) : null - } - /> - } - /> - ), - }} + chat={{ content: chatContent }} workspaceOverlays={ Promise; importWorkspaceFolderPaths: (paths: string[]) => Promise; + /** + * Logical (CSS pixel) hover position while a native drag is over the + * window, null when it leaves or drops. The session workbench uses this to + * focus the hovered conversation pane so the drop lands in it. + */ + onDropPositionChange?: (point: { x: number; y: number } | null) => void; }; /** @@ -21,9 +27,11 @@ type UseTauriFileDropParams = { * surface ignores the drop. */ export function useTauriFileDrop(params: UseTauriFileDropParams) { - const { importUploadZonePaths, importWorkspaceFolderPaths } = params; + const { importUploadZonePaths, importWorkspaceFolderPaths, onDropPositionChange } = params; const [activeDropTarget, setActiveDropTarget] = useState(null); const activeDropTargetRef = useRef(null); + const onDropPositionChangeRef = useRef(onDropPositionChange); + onDropPositionChangeRef.current = onDropPositionChange; useEffect(() => { // The Vite page can also be opened directly in a browser during @@ -44,6 +52,10 @@ export function useTauriFileDrop(params: UseTauriFileDropParams) { const nextTarget = resolveNativeFileDropTarget(event.payload.position, { scaleFactor }); activeDropTargetRef.current = nextTarget; setActiveDropTarget(nextTarget); + onDropPositionChangeRef.current?.({ + x: event.payload.position.x / (scaleFactor || 1), + y: event.payload.position.y / (scaleFactor || 1), + }); return; } @@ -59,6 +71,7 @@ export function useTauriFileDrop(params: UseTauriFileDropParams) { ); setActiveDropTarget(null); activeDropTargetRef.current = null; + onDropPositionChangeRef.current?.(null); if (dropTarget === "workspace") { void importWorkspaceFolderPaths(event.payload.paths); return; @@ -70,6 +83,7 @@ export function useTauriFileDrop(params: UseTauriFileDropParams) { setActiveDropTarget(null); activeDropTargetRef.current = null; + onDropPositionChangeRef.current?.(null); }) .then((nextUnlisten) => { if (cancelled) { diff --git a/crates/agent-gui/test/chat/native-file-drop-routing.test.mjs b/crates/agent-gui/test/chat/native-file-drop-routing.test.mjs index cb6d9f491..ed7bce571 100644 --- a/crates/agent-gui/test/chat/native-file-drop-routing.test.mjs +++ b/crates/agent-gui/test/chat/native-file-drop-routing.test.mjs @@ -234,14 +234,19 @@ test("upload rectangle fallback does not widen beyond the composer zone", () => test("native upload marker covers only the composer dialog", () => { const chatPage = readFileSync("src/pages/ChatPage.tsx", "utf8"); + const conversationPaneHost = readFileSync( + "src/pages/chat/surfaces/ConversationPaneHost.tsx", + "utf8", + ); const conversationSurface = readFileSync( "src/pages/chat/surfaces/ConversationSurface.tsx", "utf8", ); const composer = readFileSync("../agent-ui/src/pages/chat/ChatComposerBar.tsx", "utf8"); - assert.match(chatPage, / readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), "utf8"), @@ -127,5 +128,5 @@ test("GUI projects canonical live results without a sequencing compatibility lay .join("\n"); assert.match(source, /liveTranscriptStore\.subscribe/); assert.match(source, /selectLatestTaskProgress\(historyItems, liveRounds\)/); - assert.match(source, /key=\{currentConversationId\}/); + assert.match(source, /key=\{snapshot\.conversationId\}/); }); diff --git a/crates/agent-gui/test/chat/workbench-dom-boundaries.test.mjs b/crates/agent-gui/test/chat/workbench-dom-boundaries.test.mjs index 6c32bd9f8..7af5cfe30 100644 --- a/crates/agent-gui/test/chat/workbench-dom-boundaries.test.mjs +++ b/crates/agent-gui/test/chat/workbench-dom-boundaries.test.mjs @@ -37,6 +37,28 @@ const conversationSurfaceSource = readFileSync( new URL("../../src/pages/chat/surfaces/ConversationSurface.tsx", import.meta.url), "utf8", ); +const conversationPaneHostSource = readFileSync( + new URL("../../src/pages/chat/surfaces/ConversationPaneHost.tsx", import.meta.url), + "utf8", +); +const conversationPaneEnvironmentSource = readFileSync( + new URL( + "../../src/pages/chat/surfaces/ConversationPaneHostEnvironment.tsx", + import.meta.url, + ), + "utf8", +); +const conversationPaneHarnessSource = readFileSync( + new URL("../../src/pages/chat/workbench/ConversationPaneHarness.tsx", import.meta.url), + "utf8", +); +const conversationPaneHarnessModelSource = readFileSync( + new URL( + "../../src/pages/chat/workbench/conversationPaneHarnessModel.ts", + import.meta.url, + ), + "utf8", +); test("application chrome is attached to the center column instead of the right dock", () => { assert.match(chatPageSource, /data-app-frame="three-column"/); @@ -76,11 +98,39 @@ test("right dock width moves the center-column chrome with the panel", () => { }); test("conversation transcript and composer share one stable workbench surface", () => { - assert.match(chatPageSource, / { + assert.match(conversationPaneHarnessSource, /data-conversation-pane-harness="two-pane"/); + assert.match(conversationPaneHarnessSource, /readonly \[ConversationPaneHarnessSpec, ConversationPaneHarnessSpec\]/); + assert.match(conversationPaneHarnessSource, /panes\.map/); + assert.match(conversationPaneHarnessSource, / Date: Mon, 17 Aug 2026 05:02:48 +0800 Subject: [PATCH 15/76] =?UTF-8?q?feat(i18n):=20=E8=A1=A5=E5=85=85=20Sessio?= =?UTF-8?q?n=20Workbench=20=E4=B8=AD=E8=8B=B1=E6=96=87=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/i18n/translations/enUSCommon.ts | 21 ++++++++++++++++++ .../src/i18n/translations/zhCNCommon.ts | 22 +++++++++++++++++-- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/crates/agent-ui/src/i18n/translations/enUSCommon.ts b/crates/agent-ui/src/i18n/translations/enUSCommon.ts index ee4d59a4f..ba0bad637 100644 --- a/crates/agent-ui/src/i18n/translations/enUSCommon.ts +++ b/crates/agent-ui/src/i18n/translations/enUSCommon.ts @@ -1260,4 +1260,25 @@ export const EN_US_COMMON_TRANSLATIONS = { "mcpHub.storePreviewDetailPage": "Detail page", "mcpHub.storePreviewHomepage": "Homepage", "mcpHub.storePreviewRepository": "Repository", + "workbench.paneRegion": "Conversation pane", + "workbench.resizeDivider": "Resize split", + "workbench.emptyTitle": "No conversation panes", + "workbench.emptyDescription": "Drag a conversation from the sidebar to open it here.", + "workbench.dragPane": "Move pane", + "workbench.closePane": "Close pane view", + "workbench.loadConversation": "Load conversation", + "workbench.openInSplit": "Open in split pane", + "workbench.noSpaceForSplit": "Not enough space for another pane", + "workbench.projectArchived": "This workspace is archived", + "workbench.projectMissing": "This workspace folder is missing", + "workbench.terminalConnecting": "Starting terminal…", + "workbench.terminalError": "Terminal connection failed", + "workbench.terminalExited": "Terminal process exited", + "workbench.terminalRestart": "Restart terminal", + "workbench.terminalRetry": "Retry", + "workbench.terminalSessionMissing": "This terminal session is no longer available", + "workbench.terminalSshPrompt": + "SSH authentication is required — open this host from the project tools panel first", + "workbench.unsupportedPane": + "This layout item was created by a newer version and cannot be displayed here.", } as const satisfies Record; diff --git a/crates/agent-ui/src/i18n/translations/zhCNCommon.ts b/crates/agent-ui/src/i18n/translations/zhCNCommon.ts index 6e858e7d4..7eecf439b 100644 --- a/crates/agent-ui/src/i18n/translations/zhCNCommon.ts +++ b/crates/agent-ui/src/i18n/translations/zhCNCommon.ts @@ -5,8 +5,7 @@ export const ZH_CN_COMMON_TRANSLATIONS = { "app.errorBoundaryTitle": "页面出现异常", "app.loading": "正在加载设置...", "app.name": "LiveAgent", - "app.settingsSshSettingsChanged": - "SSH 设置已在另一端更新,已刷新为最新状态,请重新提交。", + "app.settingsSshSettingsChanged": "SSH 设置已在另一端更新,已刷新为最新状态,请重新提交。", "common.currentUser": "当前用户", "common.dismissNotification": "关闭通知", "common.logout": "退出登录", @@ -1202,4 +1201,23 @@ export const ZH_CN_COMMON_TRANSLATIONS = { "mcpHub.storePreviewDetailPage": "详情页", "mcpHub.storePreviewHomepage": "主页", "mcpHub.storePreviewRepository": "仓库", + "workbench.paneRegion": "会话面板", + "workbench.resizeDivider": "调整分栏大小", + "workbench.emptyTitle": "暂无会话面板", + "workbench.emptyDescription": "从左侧拖入一个会话即可在此打开。", + "workbench.dragPane": "移动面板", + "workbench.closePane": "关闭面板视图", + "workbench.loadConversation": "加载会话", + "workbench.openInSplit": "在分屏中打开", + "workbench.noSpaceForSplit": "空间不足,无法再打开新面板", + "workbench.projectArchived": "该工作空间已归档", + "workbench.projectMissing": "该工作空间目录已缺失", + "workbench.terminalConnecting": "正在启动终端…", + "workbench.terminalError": "终端连接失败", + "workbench.terminalExited": "终端进程已退出", + "workbench.terminalRestart": "重新启动终端", + "workbench.terminalRetry": "重试", + "workbench.terminalSessionMissing": "该终端会话已不存在", + "workbench.terminalSshPrompt": "SSH 认证需先在项目工具面板中完成该主机的连接", + "workbench.unsupportedPane": "此布局项来自更新版本,当前版本无法显示。", } as const satisfies Record; From 7b9fdbe70f6df9838ae60a7f82c8bd0ff267ec93 Mon Sep 17 00:00:00 2001 From: yovinchen Date: Mon, 17 Aug 2026 05:14:37 +0800 Subject: [PATCH 16/76] =?UTF-8?q?feat(ui):=20=E5=B8=83=E5=B1=80=E5=86=85?= =?UTF-8?q?=E6=A0=B8=E6=94=AF=E6=8C=81=E5=88=86=E8=A3=82=E7=A9=BA=E9=97=B4?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E4=B8=8E=E7=88=B6=E7=BA=A7=20split=20?= =?UTF-8?q?=E6=9F=A5=E6=89=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OPEN_SURFACE/MOVE_PANE 可携带画布像素上下文,两半低于会话硬最小尺寸时 返回 insufficient-space 且布局不变;缺省上下文保持旧的宽松语义 - MOVE_PANE 以摘除后的树测量目标区域,腾出的空间参与判定 - geometry 抽出 splitRegionForTarget/canSplitRectOnAxis 供 reducer 与拖拽复用 - invariants 新增 findParentSplitId,支撑 pane 级等分命令 - pane 树测试覆盖水平/垂直不足、divider/canvas-edge 与 detach 后测量 --- .../test/chat/workbench-pane-tree.test.mjs | 197 ++++++++++++++++++ crates/agent-ui/src/lib/workbench/commands.ts | 15 ++ crates/agent-ui/src/lib/workbench/geometry.ts | 72 ++++++- .../agent-ui/src/lib/workbench/invariants.ts | 22 ++ crates/agent-ui/src/lib/workbench/reducer.ts | 63 +++++- 5 files changed, 367 insertions(+), 2 deletions(-) diff --git a/crates/agent-gui/test/chat/workbench-pane-tree.test.mjs b/crates/agent-gui/test/chat/workbench-pane-tree.test.mjs index 6b89c7879..41641ede1 100644 --- a/crates/agent-gui/test/chat/workbench-pane-tree.test.mjs +++ b/crates/agent-gui/test/chat/workbench-pane-tree.test.mjs @@ -13,6 +13,7 @@ const { decodeWorkbenchLayout, encodeWorkbenchLayout, findAdjacentPaneId, + findParentSplitId, hitTestWorkbenchDrop, previewRectForDropTarget, WORKBENCH_LAYOUT_SCHEMA_VERSION, @@ -429,6 +430,31 @@ test("keyboard adjacency picks the nearest pane with perpendicular overlap", () assert.equal(findAdjacentPaneId(geometry, "pane-a", "left"), null); }); +test("parent split lookup resolves the split a pane-scoped equalize acts on", () => { + // A lone root leaf sits in no split, so there is nothing to equalize. + assert.equal(findParentSplitId(openRoot(), "pane-a"), null); + + let layout = openRoot(); + layout = mustApply(layout, { + type: "OPEN_PANE", + pane: pane("pane-b", "conversation-b"), + target: { kind: "pane-edge", paneId: "pane-a", edge: "right" }, + }); + const rootSplitId = layout.root.splitId; + layout = mustApply(layout, { + type: "OPEN_PANE", + pane: pane("pane-c", "conversation-c"), + target: { kind: "pane-edge", paneId: "pane-b", edge: "bottom" }, + }); + + // pane-a still hangs off the root split; pane-b/pane-c share the nested one. + assert.equal(findParentSplitId(layout, "pane-a"), rootSplitId); + const nestedSplitId = findParentSplitId(layout, "pane-b"); + assert.notEqual(nestedSplitId, rootSplitId); + assert.equal(findParentSplitId(layout, "pane-c"), nestedSplitId); + assert.equal(findParentSplitId(layout, "pane-missing"), null); +}); + test("ratio min-size clamping keeps both sides above the pane minimum", () => { const splitArea = { left: 0, top: 0, width: 1008, height: 600 }; const clamped = clampRatioToMinSize({ @@ -528,3 +554,174 @@ test("codec repairs a fully invalid tree into an empty layout", () => { assert.equal(decoded.layout.root, null); assert.equal(decoded.layout.focusedPaneId, null); }); + +// --- B-16: split feasibility (`context.canvasSize`) ------------------------- +// The reducer is a pure tree model, so it can only reject a split when the +// caller hands it the canvas it is laying out into. Commands without `context` +// keep the old permissive behaviour. + +const TIGHT_CANVAS = { width: 700, height: 500 }; + +test("split with insufficient width is rejected and leaves the layout untouched", () => { + const layout = openRoot(); + // 700px canvas: halving it leaves 346px per side, over the 320px minimum. + const wideEnough = apply(layout, { + type: "OPEN_PANE", + pane: pane("pane-b", "conversation-b"), + target: { kind: "pane-edge", paneId: "pane-a", edge: "right" }, + context: { canvasSize: TIGHT_CANVAS }, + }); + assert.equal(wideEnough.ok, true); + + // Splitting either half again would leave ~169px per side. + const tooNarrow = apply(wideEnough.layout, { + type: "OPEN_PANE", + pane: pane("pane-c", "conversation-c"), + target: { kind: "pane-edge", paneId: "pane-b", edge: "right" }, + context: { canvasSize: TIGHT_CANVAS }, + }); + assert.equal(tooNarrow.ok, false); + assert.equal(tooNarrow.error.code, "insufficient-space"); + assert.equal(tooNarrow.error.currentRevision, wideEnough.layout.revision); + assert.equal(wideEnough.layout.panes["pane-c"], undefined); + assert.deepEqual(leafIds(wideEnough.layout.root), ["pane-a", "pane-b"]); +}); + +test("split with insufficient height is rejected on the vertical axis", () => { + const layout = openRoot(); + const short = { width: 1200, height: 400 }; + const ok = apply(layout, { + type: "OPEN_PANE", + pane: pane("pane-b", "conversation-b"), + target: { kind: "pane-edge", paneId: "pane-a", edge: "bottom" }, + context: { canvasSize: { width: 1200, height: 800 } }, + }); + assert.equal(ok.ok, true); + + // 400px tall: each half would be 196px, under the 220px minimum. + const rejected = apply(layout, { + type: "OPEN_PANE", + pane: pane("pane-b", "conversation-b"), + target: { kind: "pane-edge", paneId: "pane-a", edge: "bottom" }, + context: { canvasSize: short }, + }); + assert.equal(rejected.ok, false); + assert.equal(rejected.error.code, "insufficient-space"); + assert.equal(rejected.error.currentRevision, layout.revision); +}); + +test("split feasibility is not enforced when no context is supplied", () => { + let layout = openRoot(); + // Same tree as the rejected case above, but with no pixel context: the + // reducer has nothing to judge against, so the old behaviour stands. + layout = mustApply(layout, { + type: "OPEN_PANE", + pane: pane("pane-b", "conversation-b"), + target: { kind: "pane-edge", paneId: "pane-a", edge: "right" }, + }); + layout = mustApply(layout, { + type: "OPEN_PANE", + pane: pane("pane-c", "conversation-c"), + target: { kind: "pane-edge", paneId: "pane-b", edge: "right" }, + }); + assert.deepEqual(leafIds(layout.root), ["pane-a", "pane-b", "pane-c"]); +}); + +test("canvas-edge and divider splits honour the minimum too", () => { + let layout = openRoot(); + layout = mustApply(layout, { + type: "OPEN_PANE", + pane: pane("pane-b", "conversation-b"), + target: { kind: "pane-edge", paneId: "pane-a", edge: "right" }, + context: { canvasSize: TIGHT_CANVAS }, + }); + + // Wrapping a 600px canvas in another horizontal split leaves 296px halves. + const canvasEdge = apply(openRoot(), { + type: "OPEN_PANE", + pane: pane("pane-c", "conversation-c"), + target: { kind: "canvas-edge", edge: "left" }, + context: { canvasSize: { width: 600, height: 800 } }, + }); + assert.equal(canvasEdge.ok, false); + assert.equal(canvasEdge.error.code, "insufficient-space"); + + // Inserting at the divider halves one side of it — also too tight. + const divider = apply(layout, { + type: "OPEN_PANE", + pane: pane("pane-c", "conversation-c"), + target: { kind: "divider", splitId: layout.root.splitId, edge: "left" }, + context: { canvasSize: TIGHT_CANVAS }, + }); + assert.equal(divider.ok, false); + assert.equal(divider.error.code, "insufficient-space"); + + // The same divider insert succeeds once the canvas is wide enough. + const roomy = apply(layout, { + type: "OPEN_PANE", + pane: pane("pane-c", "conversation-c"), + target: { kind: "divider", splitId: layout.root.splitId, edge: "left" }, + context: { canvasSize: { width: 1600, height: 800 } }, + }); + assert.equal(roomy.ok, true); + assert.deepEqual(leafIds(roomy.layout.root), ["pane-a", "pane-c", "pane-b"]); +}); + +test("MOVE_PANE measures space against the tree with the pane detached", () => { + let layout = openRoot(); + layout = mustApply(layout, { + type: "OPEN_PANE", + pane: pane("pane-b", "conversation-b"), + target: { kind: "pane-edge", paneId: "pane-a", edge: "right" }, + context: { canvasSize: TIGHT_CANVAS }, + }); + + // Moving pane-b below pane-a: detaching it first gives pane-a the full + // 700x500 canvas, so the vertical split has 246px per half — legal. + const moved = apply(layout, { + type: "MOVE_PANE", + paneId: "pane-b", + target: { kind: "pane-edge", paneId: "pane-a", edge: "bottom" }, + context: { canvasSize: TIGHT_CANVAS }, + }); + assert.equal(moved.ok, true); + assert.equal(moved.layout.root.axis, "vertical"); + + // The same move on a 400px-tall canvas leaves 196px per half — rejected. + const tooShort = apply(layout, { + type: "MOVE_PANE", + paneId: "pane-b", + target: { kind: "pane-edge", paneId: "pane-a", edge: "bottom" }, + context: { canvasSize: { width: 700, height: 400 } }, + }); + assert.equal(tooShort.ok, false); + assert.equal(tooShort.error.code, "insufficient-space"); + assert.equal(tooShort.error.currentRevision, layout.revision); + assert.equal(layout.root.axis, "horizontal"); +}); + +test("space checks do not apply to pane-center swaps or the empty canvas", () => { + const empty = apply(createEmptyWorkbenchLayout(), { + type: "OPEN_PANE", + pane: pane("pane-a", "conversation-a"), + target: { kind: "canvas-edge", edge: "left" }, + context: { canvasSize: { width: 100, height: 100 } }, + }); + assert.equal(empty.ok, true, "an edge drop on an empty canvas becomes the root pane"); + + let layout = openRoot(); + layout = mustApply(layout, { + type: "OPEN_PANE", + pane: pane("pane-b", "conversation-b"), + target: { kind: "pane-edge", paneId: "pane-a", edge: "right" }, + context: { canvasSize: TIGHT_CANVAS }, + }); + const swapped = apply(layout, { + type: "MOVE_PANE", + paneId: "pane-b", + target: { kind: "pane-center", paneId: "pane-a" }, + context: { canvasSize: { width: 100, height: 100 } }, + }); + assert.equal(swapped.ok, true, "swaps create no split, so space is irrelevant"); + assert.deepEqual(leafIds(swapped.layout.root), ["pane-b", "pane-a"]); +}); diff --git a/crates/agent-ui/src/lib/workbench/commands.ts b/crates/agent-ui/src/lib/workbench/commands.ts index 954cba590..0981909cf 100644 --- a/crates/agent-ui/src/lib/workbench/commands.ts +++ b/crates/agent-ui/src/lib/workbench/commands.ts @@ -1,3 +1,4 @@ +import type { WorkbenchRect } from "./geometry"; import type { PaneRecord, WorkbenchEdge, WorkbenchLayout } from "./types"; export type WorkbenchOpenTarget = @@ -10,8 +11,21 @@ export type WorkbenchMoveTarget = | Exclude | { kind: "pane-center"; paneId: string }; +/** + * Optional pixel context for commands that split a region. The reducer is a + * pure tree model, so minimum-size feasibility can only be judged when the + * caller supplies the canvas it is laying out into. Omit it and splits are + * accepted unconditionally (pre-existing behaviour). + */ +export type WorkbenchCommandContext = { + canvasSize: Pick; + /** Defaults to WORKBENCH_DIVIDER_SIZE; pass the canvas' real divider size. */ + dividerSize?: number; +}; + type RevisionedWorkbenchCommand = { expectedRevision: number; + context?: WorkbenchCommandContext; }; export type WorkbenchCommand = RevisionedWorkbenchCommand & @@ -28,6 +42,7 @@ export type WorkbenchCommand = RevisionedWorkbenchCommand & export type WorkbenchCommandErrorCode = | "duplicate-conversation" | "duplicate-surface" + | "insufficient-space" | "invalid-layout" | "minimum-size" | "pane-not-found" diff --git a/crates/agent-ui/src/lib/workbench/geometry.ts b/crates/agent-ui/src/lib/workbench/geometry.ts index 3049854be..9627e4875 100644 --- a/crates/agent-ui/src/lib/workbench/geometry.ts +++ b/crates/agent-ui/src/lib/workbench/geometry.ts @@ -1,4 +1,4 @@ -import type { PaneNode, WorkbenchAxis } from "./types"; +import type { PaneNode, WorkbenchAxis, WorkbenchEdge } from "./types"; /** Integer CSS-pixel rectangle relative to the workbench canvas origin. */ export type WorkbenchRect = { @@ -34,6 +34,76 @@ export const WORKBENCH_MAX_SPLIT_RATIO = 0.95; export const MIN_CONVERSATION_PANE_WIDTH = 320; export const MIN_CONVERSATION_PANE_HEIGHT = 220; +export function workbenchEdgeAxis(edge: WorkbenchEdge): WorkbenchAxis { + return edge === "left" || edge === "right" ? "horizontal" : "vertical"; +} + +/** Hard minimum a pane must keep along `axis` for the layout to stay usable. */ +export function minPaneSizeForAxis(axis: WorkbenchAxis): number { + return axis === "horizontal" ? MIN_CONVERSATION_PANE_WIDTH : MIN_CONVERSATION_PANE_HEIGHT; +} + +/** + * Whether halving `rect` along `axis` leaves both sides at or above the hard + * minimum. Splits always start at ratio 0.5, so this is the exact feasibility + * test for inserting a pane into that region. + */ +export function canSplitRectOnAxis( + rect: WorkbenchRect, + axis: WorkbenchAxis, + dividerSize: number = WORKBENCH_DIVIDER_SIZE, +): boolean { + const total = (axis === "horizontal" ? rect.width : rect.height) - dividerSize; + return total / 2 >= minPaneSizeForAxis(axis); +} + +/** Drop targets that insert a new pane by splitting an existing region. */ +export type WorkbenchSplitTarget = + | { kind: "canvas-edge"; edge: WorkbenchEdge } + | { kind: "pane-edge"; paneId: string; edge: WorkbenchEdge } + | { kind: "divider"; splitId: string; edge: WorkbenchEdge }; + +/** + * The region a split target would halve, plus the axis it is halved along. + * Returns null when the target no longer exists in `geometry` — callers treat + * that as a missing target rather than a space failure. + */ +export function splitRegionForTarget( + geometry: WorkbenchGeometry, + target: WorkbenchSplitTarget, +): { rect: WorkbenchRect; axis: WorkbenchAxis } | null { + if (target.kind === "canvas-edge") { + return { rect: geometry.canvas, axis: workbenchEdgeAxis(target.edge) }; + } + if (target.kind === "pane-edge") { + const pane = geometry.panes.find((entry) => entry.paneId === target.paneId); + return pane ? { rect: pane.rect, axis: workbenchEdgeAxis(target.edge) } : null; + } + const divider = geometry.dividers.find((entry) => entry.splitId === target.splitId); + if (!divider) return null; + // A divider insert halves the region on the chosen side of the bar, along + // the existing split's own axis. + const before = target.edge === "left" || target.edge === "top"; + const { rect: bar, splitArea } = divider; + const rect: WorkbenchRect = + divider.axis === "horizontal" + ? before + ? { ...splitArea, width: bar.left - splitArea.left } + : { + ...splitArea, + left: bar.left + bar.width, + width: splitArea.left + splitArea.width - (bar.left + bar.width), + } + : before + ? { ...splitArea, height: bar.top - splitArea.top } + : { + ...splitArea, + top: bar.top + bar.height, + height: splitArea.top + splitArea.height - (bar.top + bar.height), + }; + return { rect, axis: divider.axis }; +} + export function clampSplitRatio(ratio: number): number { if (!Number.isFinite(ratio)) return 0.5; return Math.min(WORKBENCH_MAX_SPLIT_RATIO, Math.max(WORKBENCH_MIN_SPLIT_RATIO, ratio)); diff --git a/crates/agent-ui/src/lib/workbench/invariants.ts b/crates/agent-ui/src/lib/workbench/invariants.ts index 769c7d87e..77f1f98ad 100644 --- a/crates/agent-ui/src/lib/workbench/invariants.ts +++ b/crates/agent-ui/src/lib/workbench/invariants.ts @@ -63,6 +63,28 @@ export function findPaneIdByConversationId( return findPaneIdBySurfaceKey(layout, `conversation:${targetId}`); } +/** + * The split directly hosting `paneId` as one of its two children — the split a + * pane-scoped equalize acts on. Null when the pane is the whole tree (a lone + * root leaf has no parent split) or is not mounted at all. + */ +export function findParentSplitId( + layout: Pick, + paneId: string, +): string | null { + const targetId = paneId.trim(); + if (!targetId || !layout.root) return null; + const walk = (node: PaneNode): string | null => { + if (node.type === "leaf") return null; + const hostsTarget = + (node.first.type === "leaf" && node.first.paneId === targetId) || + (node.second.type === "leaf" && node.second.paneId === targetId); + if (hostsTarget) return node.splitId; + return walk(node.first) ?? walk(node.second); + }; + return walk(layout.root); +} + export function collectWorkbenchLayoutIssues(layout: WorkbenchLayout): WorkbenchLayoutIssue[] { const issues: WorkbenchLayoutIssue[] = []; if (layout.schemaVersion !== WORKBENCH_LAYOUT_SCHEMA_VERSION) { diff --git a/crates/agent-ui/src/lib/workbench/reducer.ts b/crates/agent-ui/src/lib/workbench/reducer.ts index ed6ad9135..e5075153a 100644 --- a/crates/agent-ui/src/lib/workbench/reducer.ts +++ b/crates/agent-ui/src/lib/workbench/reducer.ts @@ -1,13 +1,21 @@ import { getWorkbenchRevisionError, type WorkbenchCommand, + type WorkbenchCommandContext, type WorkbenchCommandError, type WorkbenchCommandErrorCode, type WorkbenchCommandResult, type WorkbenchMoveTarget, type WorkbenchOpenTarget, } from "./commands"; -import { clampSplitRatio } from "./geometry"; +import { + canSplitRectOnAxis, + clampSplitRatio, + computeWorkbenchGeometry, + minPaneSizeForAxis, + splitRegionForTarget, + WORKBENCH_DIVIDER_SIZE, +} from "./geometry"; import { collectWorkbenchLayoutIssues, findPaneIdBySurfaceKey } from "./invariants"; import { type PaneNode, @@ -190,6 +198,43 @@ function graftAtTarget( } } +/** + * Reject a split whose two halves cannot both hold the hard minimum pane size. + * + * Only runs when the caller supplied pixel `context`; without it the reducer + * has no geometry to judge against and stays permissive. The target rect is + * measured against `tree` — for moves that is the tree with the pane already + * detached, so the rejection matches what the user would actually get. + */ +function insufficientSpaceError( + tree: PaneNode | null, + target: WorkbenchOpenTarget | WorkbenchMoveTarget, + context: WorkbenchCommandContext | undefined, + currentRevision: number, +): { ok: false; error: WorkbenchCommandError } | null { + if (!context) return null; + if (target.kind === "canvas-empty" || target.kind === "pane-center") return null; + // An edge drop onto an empty canvas becomes the root pane, never a split. + if (tree === null) return null; + const dividerSize = context.dividerSize ?? WORKBENCH_DIVIDER_SIZE; + const geometry = computeWorkbenchGeometry( + tree, + { left: 0, top: 0, width: context.canvasSize.width, height: context.canvasSize.height }, + { dividerSize }, + ); + const region = splitRegionForTarget(geometry, target); + // A target absent from the geometry is a missing target, not a space + // failure; the graft below reports it as `target-not-found`. + if (!region) return null; + if (canSplitRectOnAxis(region.rect, region.axis, dividerSize)) return null; + const available = region.axis === "horizontal" ? region.rect.width : region.rect.height; + return commandError( + "insufficient-space", + `Splitting this region ${region.axis === "horizontal" ? "horizontally" : "vertically"} would leave panes under the ${minPaneSizeForAxis(region.axis)}px minimum (region is ${available}px).`, + currentRevision, + ); +} + function swapLeaves(node: PaneNode, firstPaneId: string, secondPaneId: string): PaneNode { if (node.type === "leaf") { if (node.paneId === firstPaneId) return { ...node, paneId: secondPaneId }; @@ -346,6 +391,13 @@ export function applyWorkbenchCommand( command.target.kind === "canvas-edge" && layout.root === null ? { kind: "canvas-empty" } : command.target; + const spaceError = insufficientSpaceError( + layout.root, + target, + command.context, + layout.revision, + ); + if (spaceError) return spaceError; const nextRoot = graftAtTarget( layout.root, { type: "leaf", paneId: pane.paneId }, @@ -413,6 +465,15 @@ export function applyWorkbenchCommand( layout.revision, ); } + // The space check also measures the detached tree, so freeing the pane's + // own room is reflected before the split is judged. + const spaceError = insufficientSpaceError( + removal.node, + command.target, + command.context, + layout.revision, + ); + if (spaceError) return spaceError; const nextRoot = graftAtTarget( removal.node, { type: "leaf", paneId: command.paneId }, From e85dcb2af2e3160f89e2a09d3530eac22d2cbd7d Mon Sep 17 00:00:00 2001 From: yovinchen Date: Mon, 17 Aug 2026 05:29:52 +0800 Subject: [PATCH 17/76] =?UTF-8?q?fix(ui):=20=E5=B8=83=E5=B1=80=E5=BA=8F?= =?UTF-8?q?=E5=88=97=E5=8C=96=E6=94=B9=E4=B8=BA=E6=98=BE=E5=BC=8F=20allow-?= =?UTF-8?q?list=20=E6=8A=95=E5=BD=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - encode 按字段逐一重建 node/surface/pane,内存里多挂的属性不再落盘 (持久化 payload 直达 SQLite/localStorage,无中间脱敏层) - unsupported surface 仍整体透传 raw,保证新版本 round-trip 不丢字段 - PaneViewState 收敛为空槽位类型,decode/encode 双向丢弃未知 view 键 - 新增布局隐私测试:锁定持久化 shape、注入字段剥离与透传契约 --- .../chat/workbench-layout-privacy.test.mjs | 431 ++++++++++++++++++ crates/agent-ui/src/lib/workbench/codec.ts | 103 ++++- crates/agent-ui/src/lib/workbench/types.ts | 10 +- 3 files changed, 524 insertions(+), 20 deletions(-) create mode 100644 crates/agent-gui/test/chat/workbench-layout-privacy.test.mjs diff --git a/crates/agent-gui/test/chat/workbench-layout-privacy.test.mjs b/crates/agent-gui/test/chat/workbench-layout-privacy.test.mjs new file mode 100644 index 000000000..c49a290a0 --- /dev/null +++ b/crates/agent-gui/test/chat/workbench-layout-privacy.test.mjs @@ -0,0 +1,431 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +const loader = createTsModuleLoader(); +const workbench = loader.loadModule("@liveagent/ui/lib/workbench/index.ts"); +const { terminalSurfaceForSession } = loader.loadModule( + "src/pages/chat/workbench/terminalDropCommit.ts", +); + +const { + applyWorkbenchCommand, + createEmptyWorkbenchLayout, + decodeWorkbenchLayout, + encodeWorkbenchLayout, + isWorkbenchLayoutValid, + WORKBENCH_LAYOUT_SCHEMA_VERSION, +} = workbench; + +/** + * The persisted workbench layout is a privacy boundary: it lands in SQLite / + * localStorage untouched by any redaction layer, so its shape is an explicit + * allow-list rather than "whatever the pane record happened to carry". + * Derived from codec.ts (encode payload + readSurface/readPaneRecord) and + * types.ts (PaneRecord / WorkbenchSurfaceSpec / launch specs). + */ +const LAYOUT_KEY_ALLOWLIST = new Set([ + // Envelope (codec.ts encodeWorkbenchLayout) + "schemaVersion", + "revision", + "root", + "panes", + "focusedPaneId", + // Pane tree nodes (types.ts PaneNode) + "type", + "paneId", + "splitId", + "axis", + "ratio", + "first", + "second", + // Pane records (types.ts PaneRecord). `view` is PaneViewState = + // Record: a reserved empty slot, so it contributes no keys. + "surface", + "view", + // Surfaces (types.ts WorkbenchSurfaceSpec) + "kind", + "conversationId", + "surfaceId", + "project", + "launchSpec", + "originalKind", + "raw", + // Project reference (types.ts ProjectRef) + "projectId", + "projectPathKey", + // Launch specs (types.ts Local/SshTerminalLaunchSpec) + "cwd", + "shell", + "title", + "sshHostId", + "sftpEnabled", +]); + +/** + * Secrets and per-run state that must never reach a persisted layout. Terminal + * `sessionId` in particular lives only in sessionStorage (see + * terminalPaneBindingStore) precisely so a layout file cannot resurrect a dead + * PTY handle. + */ +const FORBIDDEN_KEYS = [ + "sessionId", + "draft", + "drafts", + "messages", + "transcript", + "token", + "apiKey", + "accessToken", + "prompt", + "attachments", + "uploads", + "approvals", + "env", +]; + +let splitCounter = 0; +const reducerOptions = { createSplitId: () => `privacy-split-${++splitCounter}` }; + +function apply(layout, command) { + const result = applyWorkbenchCommand( + layout, + { expectedRevision: layout.revision, ...command }, + reducerOptions, + ); + assert.equal(result.ok, true, `command ${command.type} failed: ${JSON.stringify(result)}`); + return result.layout; +} + +function conversationPane(paneId, conversationId) { + return { + paneId, + surface: { + kind: "conversation", + conversationId, + project: { projectId: "project-main", projectPathKey: "/workspace/main" }, + }, + view: {}, + }; +} + +function terminalSession(id, overrides = {}) { + return { + id, + projectPathKey: "/workspace/main", + cwd: "/workspace/main", + shell: "zsh", + title: "Build", + kind: "local", + cols: 80, + rows: 24, + createdAt: 1, + updatedAt: 1, + running: true, + ...overrides, + }; +} + +const KNOWN_SURFACE_KINDS = new Set(["conversation", "localTerminal", "sshTerminal"]); + +/** + * Every object key reachable in a persisted payload, minus the two places + * where keys are data rather than schema: the `panes` map is keyed by pane id, + * and an unsupported surface's body is an opaque newer-build payload that this + * build re-serializes verbatim. + */ +function collectKeys(value, options = {}) { + const found = new Set(); + const walk = (node, { keysAreData = false, isSurface = false } = {}) => { + if (Array.isArray(node)) { + for (const item of node) walk(item); + return; + } + if (typeof node !== "object" || node === null) return; + // An unsupported surface persists as its original payload, which by + // definition carries keys this build's schema does not know. + const opaque = + options.skipUnsupportedSurfaces && + (isSurface || node.kind === "unsupported") && + !KNOWN_SURFACE_KINDS.has(node.kind); + for (const [key, child] of Object.entries(node)) { + if (!keysAreData && !opaque) found.add(key); + if (opaque) continue; + walk(child, { keysAreData: key === "panes", isSurface: key === "surface" }); + } + }; + walk(value); + return found; +} + +function encodedKeys(layout) { + return collectKeys(JSON.parse(encodeWorkbenchLayout(layout))); +} + +function assertAllowlisted(keys) { + const extra = [...keys].filter((key) => !LAYOUT_KEY_ALLOWLIST.has(key)); + assert.deepEqual(extra, [], `persisted layout leaked non-allow-listed keys: ${extra.join(", ")}`); +} + +function pollutedPaneRecord(paneId, conversationId) { + return { + paneId, + // Pane-record level pollution. + draft: "unsent private draft", + messages: [{ role: "user", content: "secret" }], + surface: { + kind: "conversation", + conversationId, + // Surface level pollution, including the terminal handle that must + // never leave sessionStorage. + sessionId: "session-must-not-persist", + apiKey: "sk-live-must-not-persist", + token: "bearer-must-not-persist", + prompt: "system prompt", + attachments: ["/private/secret.pdf"], + project: { + projectId: "project-main", + projectPathKey: "/workspace/main", + accessToken: "must-not-persist", + }, + }, + view: { compactChrome: true, transcript: ["leaked round"] }, + }; +} +function pollutedLayout() { + return { + schemaVersion: WORKBENCH_LAYOUT_SCHEMA_VERSION, + revision: 4, + root: { + type: "split", + splitId: "split-root", + axis: "horizontal", + ratio: 0.5, + first: { type: "leaf", paneId: "pane-a" }, + second: { type: "leaf", paneId: "pane-b" }, + env: { HOME: "/Users/secret" }, + }, + panes: { + "pane-a": pollutedPaneRecord("pane-a", "conversation-a"), + "pane-b": pollutedPaneRecord("pane-b", "conversation-b"), + }, + focusedPaneId: "pane-a", + uploads: [{ relativePath: "uploads/secret.pdf" }], + }; +} + +function threePaneLayout() { + let layout = apply(createEmptyWorkbenchLayout(), { + type: "OPEN_PANE", + pane: conversationPane("pane-a", "conversation-a"), + target: { kind: "canvas-empty" }, + }); + layout = apply(layout, { + type: "OPEN_PANE", + pane: { + paneId: "pane-local", + surface: terminalSurfaceForSession(terminalSession("session-local-1"), "surface-local-1", { + projectId: "project-main", + projectPathKey: "/workspace/main", + }), + view: {}, + }, + target: { kind: "pane-edge", paneId: "pane-a", edge: "right" }, + }); + layout = apply(layout, { + type: "OPEN_PANE", + pane: { + paneId: "pane-ssh", + surface: terminalSurfaceForSession( + terminalSession("session-ssh-1", { + kind: "ssh", + cwd: "/srv", + ssh: { + hostId: "host-1", + hostName: "prod", + username: "ops", + host: "prod.example.com", + port: 22, + authType: "key", + status: "connected", + reconnectAttempt: 0, + reconnectMaxAttempts: 3, + sftpEnabled: true, + }, + }), + "surface-ssh-1", + { projectId: "project-main", projectPathKey: "/workspace/main" }, + ), + view: {}, + }, + target: { kind: "pane-edge", paneId: "pane-local", edge: "bottom" }, + }); + return layout; +} + +test("a persisted production layout carries only allow-listed schema keys", () => { + const layout = threePaneLayout(); + const keys = encodedKeys(layout); + + assertAllowlisted(keys); + // The allow-list is only meaningful if the payload actually exercises every + // surface family it is supposed to cover. + for (const expected of ["conversationId", "surfaceId", "launchSpec", "sshHostId", "cwd"]) { + assert.equal(keys.has(expected), true, `expected the fixture to persist '${expected}'`); + } +}); + +test("terminal panes persist launch specs, never the live session handle", () => { + const payload = encodeWorkbenchLayout(threePaneLayout()); + const keys = collectKeys(JSON.parse(payload)); + + assert.equal(keys.has("sessionId"), false, "terminal sessionId must stay in sessionStorage"); + assert.equal( + payload.includes("session-local-1"), + false, + "a local PTY session id must never appear in a persisted layout", + ); + assert.equal( + payload.includes("session-ssh-1"), + false, + "an ssh PTY session id must never appear in a persisted layout", + ); +}); + +test("decode drops every injected field from an untrusted persisted payload", () => { + const raw = JSON.stringify(pollutedLayout()); + const decoded = decodeWorkbenchLayout(raw); + + assert.equal(decoded.ok, true); + const keys = encodedKeys(decoded.layout); + assertAllowlisted(keys); + for (const forbidden of FORBIDDEN_KEYS) { + assert.equal(keys.has(forbidden), false, `decode must strip '${forbidden}'`); + } + + const rehydrated = encodeWorkbenchLayout(decoded.layout); + for (const secret of [ + "session-must-not-persist", + "sk-live-must-not-persist", + "bearer-must-not-persist", + "unsent private draft", + "/private/secret.pdf", + "/Users/secret", + ]) { + assert.equal(rehydrated.includes(secret), false, `re-encoded layout leaked '${secret}'`); + } +}); + +test("decoding a polluted payload still yields the clean, valid layout", () => { + const decoded = decodeWorkbenchLayout(JSON.stringify(pollutedLayout())); + + assert.equal(decoded.ok, true); + // Injected fields are not structural damage: dropping them is not a repair. + assert.equal(decoded.repaired, false); + assert.equal(isWorkbenchLayoutValid(decoded.layout), true); + assert.deepEqual(decoded.layout, { + schemaVersion: WORKBENCH_LAYOUT_SCHEMA_VERSION, + revision: 4, + root: { + type: "split", + splitId: "split-root", + axis: "horizontal", + ratio: 0.5, + first: { type: "leaf", paneId: "pane-a" }, + second: { type: "leaf", paneId: "pane-b" }, + }, + panes: { + "pane-a": conversationPane("pane-a", "conversation-a"), + "pane-b": conversationPane("pane-b", "conversation-b"), + }, + focusedPaneId: "pane-a", + }); +}); + +test("a polluted layout is idempotent after one decode round-trip", () => { + const first = decodeWorkbenchLayout(JSON.stringify(pollutedLayout())); + assert.equal(first.ok, true); + const second = decodeWorkbenchLayout(encodeWorkbenchLayout(first.layout)); + + assert.equal(second.ok, true); + assert.equal(second.repaired, false); + assert.deepEqual(second.layout, first.layout); +}); + +test("encode strips injected fields: sanitation is a save boundary too", () => { + // encodeWorkbenchLayout projects onto the schema field by field rather than + // serializing the in-memory layout verbatim, so a pane record that somehow + // picked up extra fields cannot reach SQLite / localStorage and sit there + // until the next decode. The allow-list is enforced on save and on load. + const encoded = JSON.parse(encodeWorkbenchLayout(pollutedLayout())); + + assertAllowlisted(collectKeys(encoded)); + for (const forbidden of FORBIDDEN_KEYS) { + assert.equal( + collectKeys(encoded).has(forbidden), + false, + `encode must strip '${forbidden}' rather than persist it`, + ); + } + assert.equal(encoded.panes["pane-a"].surface.apiKey, undefined); + assert.deepEqual(Object.keys(encoded.panes["pane-a"]).sort(), ["paneId", "surface", "view"]); + // Pollution nested below the pane record is dropped at every depth. + assert.equal(encoded.panes["pane-a"].surface.project.accessToken, undefined); + assert.equal(encoded.root.env, undefined); + assert.equal(encoded.uploads, undefined); + + const payload = encodeWorkbenchLayout(pollutedLayout()); + for (const secret of [ + "session-must-not-persist", + "sk-live-must-not-persist", + "bearer-must-not-persist", + "unsent private draft", + "/private/secret.pdf", + "/Users/secret", + ]) { + assert.equal(payload.includes(secret), false, `encoded layout leaked '${secret}'`); + } + + // The schema-owned content still survives the projection intact. + const sanitized = decodeWorkbenchLayout(payload); + assert.equal(sanitized.ok, true); + assertAllowlisted(encodedKeys(sanitized.layout)); + assert.equal(sanitized.layout.panes["pane-a"].surface.conversationId, "conversation-a"); +}); + +test("an unsupported surface's opaque payload survives without widening the schema", () => { + const raw = JSON.stringify({ + schemaVersion: WORKBENCH_LAYOUT_SCHEMA_VERSION, + revision: 1, + root: { type: "leaf", paneId: "pane-future" }, + panes: { + "pane-future": { + paneId: "pane-future", + surface: { kind: "notebook", notebookId: "nb-1", sessionId: "session-future" }, + view: {}, + draft: "leaked", + }, + }, + focusedPaneId: "pane-future", + }); + const decoded = decodeWorkbenchLayout(raw); + + assert.equal(decoded.ok, true); + assert.equal(decoded.layout.panes["pane-future"].surface.kind, "unsupported"); + // The opaque payload is re-serialized verbatim in place of the surface, so + // its own keys are deliberately outside the allow-list. Everything the + // schema does own around it must still be clean: the pane-record pollution + // is dropped even though the surface body is preserved. + const encoded = JSON.parse(encodeWorkbenchLayout(decoded.layout)); + assertAllowlisted(collectKeys(encoded, { skipUnsupportedSurfaces: true })); + assert.deepEqual(Object.keys(encoded.panes["pane-future"]).sort(), [ + "paneId", + "surface", + "view", + ]); + assert.deepEqual(decoded.layout.panes["pane-future"].surface.raw, { + kind: "notebook", + notebookId: "nb-1", + sessionId: "session-future", + }); +}); diff --git a/crates/agent-ui/src/lib/workbench/codec.ts b/crates/agent-ui/src/lib/workbench/codec.ts index b1912235c..826ad8911 100644 --- a/crates/agent-ui/src/lib/workbench/codec.ts +++ b/crates/agent-ui/src/lib/workbench/codec.ts @@ -15,22 +15,94 @@ export type WorkbenchLayoutDecodeResult = | { ok: true; layout: WorkbenchLayout; repaired: boolean } | { ok: false; reason: "corrupted-json" | "unsupported-schema" | "unrecoverable" }; +/** + * Project a pane tree onto the node schema. Rebuilt field by field so a node + * that picked up extra properties in memory cannot carry them to disk. + */ +function writeNode(node: PaneNode): unknown { + if (node.type === "leaf") { + return { type: "leaf", paneId: node.paneId }; + } + return { + type: "split", + splitId: node.splitId, + axis: node.axis, + ratio: node.ratio, + first: writeNode(node.first), + second: writeNode(node.second), + }; +} + +/** + * Project a surface onto its kind's schema. Unsupported surfaces persist as + * the original raw payload so a newer build that understands the kind gets its + * record back intact — that body is opaque by contract and passes through + * whole, which is the one place the allow-list deliberately does not apply. + */ +function writeSurface(surface: WorkbenchSurfaceSpec): unknown { + switch (surface.kind) { + case "conversation": + return { + kind: "conversation", + conversationId: surface.conversationId, + project: writeProjectRef(surface.project), + }; + case "localTerminal": + return { + kind: "localTerminal", + surfaceId: surface.surfaceId, + project: writeProjectRef(surface.project), + launchSpec: { + cwd: surface.launchSpec.cwd, + shell: surface.launchSpec.shell, + title: surface.launchSpec.title, + }, + }; + case "sshTerminal": + return { + kind: "sshTerminal", + surfaceId: surface.surfaceId, + project: writeProjectRef(surface.project), + launchSpec: { + cwd: surface.launchSpec.cwd, + sshHostId: surface.launchSpec.sshHostId, + title: surface.launchSpec.title, + sftpEnabled: surface.launchSpec.sftpEnabled, + }, + }; + case "unsupported": + return surface.raw; + } +} + +function writeProjectRef(project: ProjectRef): unknown { + return { projectId: project.projectId, projectPathKey: project.projectPathKey }; +} + +/** + * Serialize a layout for persistence. + * + * The output is an explicit allow-list projection, not a verbatim dump: the + * persisted payload lands in SQLite / localStorage with no redaction layer in + * front of it, so sanitation has to happen on save as well as on load. Any + * field a pane record picked up in memory is dropped here rather than living + * on disk until the next decode. `undefined` optional fields are omitted by + * JSON.stringify, matching what decode produces. + */ export function encodeWorkbenchLayout(layout: WorkbenchLayout): string { - // Unsupported passthrough surfaces serialize as their original raw payload, - // so a newer build that understands the kind gets its record back intact. - let panes: WorkbenchLayout["panes"] = layout.panes; - if (Object.values(layout.panes).some((pane) => pane.surface.kind === "unsupported")) { - const rewritten: Record = {}; - for (const [paneId, pane] of Object.entries(layout.panes)) { - rewritten[paneId] = - pane.surface.kind === "unsupported" ? { ...pane, surface: pane.surface.raw } : pane; - } - panes = rewritten as WorkbenchLayout["panes"]; + const panes: Record = {}; + for (const [paneId, pane] of Object.entries(layout.panes)) { + panes[paneId] = { + paneId: pane.paneId, + surface: writeSurface(pane.surface), + // PaneViewState is a reserved empty slot; it contributes no keys. + view: {}, + }; } return JSON.stringify({ schemaVersion: layout.schemaVersion, revision: layout.revision, - root: layout.root, + root: layout.root === null ? null : writeNode(layout.root), panes, focusedPaneId: layout.focusedPaneId, }); @@ -117,12 +189,9 @@ function readPaneRecord(paneId: string, value: unknown): PaneRecord | null { if (!isRecord(value)) return null; const surface = readSurface(value.surface); if (!surface) return null; - const view = isRecord(value.view) ? value.view : {}; - return { - paneId, - surface, - view: view.compactChrome === true ? { compactChrome: true } : {}, - }; + // `view` is decoded by allowlist and currently has no fields, so any keys a + // newer (or older) build persisted there are dropped rather than passed on. + return { paneId, surface, view: {} }; } type RebuildContext = { diff --git a/crates/agent-ui/src/lib/workbench/types.ts b/crates/agent-ui/src/lib/workbench/types.ts index 5f504543e..23a346ad0 100644 --- a/crates/agent-ui/src/lib/workbench/types.ts +++ b/crates/agent-ui/src/lib/workbench/types.ts @@ -80,12 +80,16 @@ export function surfaceProjectRef(surface: WorkbenchSurfaceSpec): ProjectRef | n return surface.kind === "unsupported" ? null : surface.project; } +/** + * Per-pane view state. Currently empty — the slot is kept so persisted layouts + * keep a stable shape and future view options have a home without a schema bump. + */ +export type PaneViewState = Record; + export type PaneRecord = { paneId: string; surface: WorkbenchSurfaceSpec; - view: { - compactChrome?: boolean; - }; + view: PaneViewState; }; export type PaneNode = From cd379125e2a78251b3436794325682c5b01e8184 Mon Sep 17 00:00:00 2001 From: yovinchen Date: Mon, 17 Aug 2026 05:47:21 +0800 Subject: [PATCH 18/76] =?UTF-8?q?refactor(chat):=20=E6=8B=96=E6=8B=BD?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E6=8B=86=E5=87=BA=E7=BA=AF=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E6=9C=BA=20workbenchDragMachine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Idle→Armed→Dragging→Commit/Cancel 迁移与落点归一化收敛为纯 reducer, useWorkbenchDragSession 只剩 DOM 事件适配(监听、点击抑制、光标) - 落点解析规则不变:own-pane 焦点化、侧栏 payload 自动停靠、 空间不足的 split 目标一律拒绝 - 阈值/窄画布判定导出为 exceedsDragThreshold/canvasAllowsPointerSplit - 新增状态机测试:武装/激活/取消/单次提交与各 payload 落点语义 --- .../chat/workbench/useWorkbenchDragSession.ts | 290 +++------- .../chat/workbench/workbenchDragMachine.ts | 343 ++++++++++++ .../test/chat/workbench-drag-session.test.mjs | 515 ++++++++++++++++++ 3 files changed, 926 insertions(+), 222 deletions(-) create mode 100644 crates/agent-gui/src/pages/chat/workbench/workbenchDragMachine.ts create mode 100644 crates/agent-gui/test/chat/workbench-drag-session.test.mjs diff --git a/crates/agent-gui/src/pages/chat/workbench/useWorkbenchDragSession.ts b/crates/agent-gui/src/pages/chat/workbench/useWorkbenchDragSession.ts index e7f5670e4..a7eb3ab70 100644 --- a/crates/agent-gui/src/pages/chat/workbench/useWorkbenchDragSession.ts +++ b/crates/agent-gui/src/pages/chat/workbench/useWorkbenchDragSession.ts @@ -1,71 +1,25 @@ -import { WORKBENCH_CANVAS_DIVIDER_SIZE as CANVAS_DIVIDER_SIZE } from "@liveagent/ui/components/workbench/WorkbenchCanvas"; -import { - hitTestWorkbenchDrop, - MIN_CONVERSATION_PANE_HEIGHT, - MIN_CONVERSATION_PANE_WIDTH, - previewRectForDropTarget, - type WorkbenchDropTarget, - type WorkbenchEdge, - type WorkbenchGeometry, - type WorkbenchRect, -} from "@liveagent/ui/lib/workbench/index"; -import { - type ProjectRef, - surfaceIdentityKey, - type WorkbenchLayout, -} from "@liveagent/ui/lib/workbench/types"; +import type { WorkbenchGeometry } from "@liveagent/ui/lib/workbench/index"; +import type { WorkbenchLayout } from "@liveagent/ui/lib/workbench/types"; import { useCallback, useEffect, useRef, useState } from "react"; - -const DRAG_THRESHOLD_PX = 6; -/** Pointer-splitting is disabled on very narrow canvases (doc §22). */ -const MIN_CANVAS_WIDTH_FOR_POINTER_SPLIT = 440; - -/** Both halves of a split must keep the conversation hard minimum size. */ -export function canSplitRectAtEdge(rect: WorkbenchRect, edge: WorkbenchEdge): boolean { - const divider = CANVAS_DIVIDER_SIZE; - if (edge === "left" || edge === "right") { - return (rect.width - divider) / 2 >= MIN_CONVERSATION_PANE_WIDTH; - } - return (rect.height - divider) / 2 >= MIN_CONVERSATION_PANE_HEIGHT; -} - -export type WorkbenchDragPayload = - | { kind: "conversation"; conversationId: string; project: ProjectRef; title: string } - /** Moving an existing pane; surfaceKey is surfaceIdentityKey(pane.surface). */ - | { kind: "pane"; paneId: string; surfaceKey: string; title: string } - /** Dragging a workspace creates a new conversation for it at the drop spot. */ - | { kind: "workspace"; projectId: string; projectPath: string; title: string } - /** Dragging an existing terminal session (e.g. from the Right Dock) into a pane. */ - | { kind: "terminalSession"; sessionId: string; project: ProjectRef; title: string } - /** Dragging a "new terminal" affordance creates a terminal at the drop spot. */ - | { kind: "newTerminal"; project: ProjectRef; title: string }; - -export type WorkbenchDropCommit = { - payload: WorkbenchDragPayload; - target: WorkbenchDropTarget; - /** Layout revision frozen when the drag activated (CAS at commit time). */ - revision: number; -}; - -export type WorkbenchDragState = { - payload: WorkbenchDragPayload; - pointer: { x: number; y: number }; - target: WorkbenchDropTarget | null; - previewRect: WorkbenchRect | null; -}; - -type PendingDrag = { - payload: WorkbenchDragPayload; - pointerId: number; - startX: number; - startY: number; -}; - -type ActiveDrag = PendingDrag & { - canvasOrigin: { left: number; top: number }; - geometry: WorkbenchGeometry; - revision: number; -}; +import { + canvasAllowsPointerSplit, + type DragSessionEvent, + type DragSessionState, + dragSessionReducer, + dragStateFor, + exceedsDragThreshold, + IDLE_DRAG_SESSION, + type WorkbenchDragPayload, + type WorkbenchDragState, + type WorkbenchDropCommit, +} from "./workbenchDragMachine"; + +export { + canSplitRectAtEdge, + type WorkbenchDragPayload, + type WorkbenchDragState, + type WorkbenchDropCommit, +} from "./workbenchDragMachine"; export type UseWorkbenchDragSessionParams = { enabled: boolean; @@ -80,20 +34,21 @@ export type UseWorkbenchDragSessionParams = { * frozen geometry + revision snapshot, previews the drop target on move, and * commits exactly once on pointer-up. Esc, pointer-cancel and window blur * cancel without layout changes; clicks are suppressed once a drag activates. + * + * This hook is the DOM event adapter; the Idle→Armed→Dragging→Commit/Cancel + * machine and the drop-target resolution live in ./workbenchDragMachine. */ export function useWorkbenchDragSession(params: UseWorkbenchDragSessionParams) { const { enabled, layoutRef, geometryRef, onCommit } = params; const [dragState, setDragState] = useState(null); - const pendingRef = useRef(null); - const activeRef = useRef(null); + const sessionRef = useRef(IDLE_DRAG_SESSION); const onCommitRef = useRef(onCommit); onCommitRef.current = onCommit; const cleanupListenersRef = useRef<(() => void) | null>(null); const teardown = useCallback(() => { - pendingRef.current = null; - activeRef.current = null; + sessionRef.current = IDLE_DRAG_SESSION; cleanupListenersRef.current?.(); cleanupListenersRef.current = null; document.documentElement.style.removeProperty("cursor"); @@ -102,121 +57,27 @@ export function useWorkbenchDragSession(params: UseWorkbenchDragSessionParams) { useEffect(() => teardown, [teardown]); - /** - * Normalize a raw hit-test target for the payload: - * - own-pane hits become focus/no-op (pane-center on itself); - * - sidebar payloads never overwrite a pane center — they auto-dock - * (bottom-first on narrow canvases, else right, then the other axis); - * - every split target is rejected when either half would fall below the - * conversation hard minimum size, so drops with insufficient space show - * no preview and commit nothing. - */ - const resolveTarget = useCallback( - ( - raw: WorkbenchDropTarget | null, - payload: WorkbenchDragPayload, - geometry: WorkbenchGeometry, - ): WorkbenchDropTarget | null => { - if (!raw) return null; - const layout = layoutRef.current; - // terminalSession drags have no own pane here: the session→pane mapping - // lives in the lease store, and a leased session is not draggable from - // the sidebar in the first place. - const ownPaneId = - payload.kind === "pane" - ? payload.paneId - : payload.kind === "conversation" - ? Object.values(layout.panes).find( - (pane) => - surfaceIdentityKey(pane.surface) === `conversation:${payload.conversationId}`, - )?.paneId - : undefined; - - const paneRect = (paneId: string): WorkbenchRect | null => - geometry.panes.find((pane) => pane.paneId === paneId)?.rect ?? null; - - if (raw.kind === "pane-center") { - if (ownPaneId && raw.paneId === ownPaneId) { - return { kind: "pane-center", paneId: ownPaneId }; - } - // Sidebar payloads never overwrite a pane: deterministic auto-dock. - if (payload.kind !== "pane") { - const rect = paneRect(raw.paneId); - if (!rect) return null; - const preferVertical = geometry.canvas.width < 680; - const edges: WorkbenchEdge[] = preferVertical ? ["bottom", "right"] : ["right", "bottom"]; - for (const edge of edges) { - if (canSplitRectAtEdge(rect, edge)) { - return { kind: "pane-edge", paneId: raw.paneId, edge }; - } - } - return null; - } - return raw; - } - if (raw.kind === "pane-edge") { - if (ownPaneId && raw.paneId === ownPaneId) { - return { kind: "pane-center", paneId: ownPaneId }; - } - const rect = paneRect(raw.paneId); - if (!rect || !canSplitRectAtEdge(rect, raw.edge)) return null; - return raw; - } - if (raw.kind === "canvas-edge") { - return canSplitRectAtEdge(geometry.canvas, raw.edge) ? raw : null; - } - if (raw.kind === "divider") { - const divider = geometry.dividers.find((entry) => entry.splitId === raw.splitId); - if (!divider) return null; - // The inserted pane halves the region on the chosen side of the bar. - const before = raw.edge === "left" || raw.edge === "top"; - const region: WorkbenchRect = - divider.axis === "horizontal" - ? before - ? { ...divider.splitArea, width: divider.rect.left - divider.splitArea.left } - : { - ...divider.splitArea, - left: divider.rect.left + divider.rect.width, - width: - divider.splitArea.left + - divider.splitArea.width - - (divider.rect.left + divider.rect.width), - } - : before - ? { ...divider.splitArea, height: divider.rect.top - divider.splitArea.top } - : { - ...divider.splitArea, - top: divider.rect.top + divider.rect.height, - height: - divider.splitArea.top + - divider.splitArea.height - - (divider.rect.top + divider.rect.height), - }; - if (!canSplitRectAtEdge(region, divider.axis === "horizontal" ? "right" : "bottom")) { - return null; - } - return raw; - } - if (raw.kind === "canvas-empty" && payload.kind === "pane") { - return null; - } - return raw; - }, - [layoutRef], - ); + /** Run one machine event, publish the overlay model and fire any commit. */ + const dispatch = useCallback((event: DragSessionEvent) => { + const result = dragSessionReducer(sessionRef.current, event); + sessionRef.current = result.state; + setDragState(dragStateFor(result.state)); + if (result.commit) onCommitRef.current(result.commit); + }, []); const beginDrag = useCallback( ( payload: WorkbenchDragPayload, event: { pointerId: number; clientX: number; clientY: number }, ) => { - if (!enabled || pendingRef.current || activeRef.current) return; - pendingRef.current = { + if (!enabled || sessionRef.current.phase !== "idle") return; + dispatch({ + type: "arm", payload, pointerId: event.pointerId, - startX: event.clientX, - startY: event.clientY, - }; + clientX: event.clientX, + clientY: event.clientY, + }); // Suppress the synthetic click that follows the drag's pointer-up so a // completed drag never doubles as a row/handle click. Disarms itself on @@ -236,69 +97,54 @@ export function useWorkbenchDragSession(params: UseWorkbenchDragSessionParams) { }; const handleMove = (moveEvent: PointerEvent) => { - const pending = pendingRef.current; - if (!pending || moveEvent.pointerId !== pending.pointerId) return; - if (!activeRef.current) { - const dx = moveEvent.clientX - pending.startX; - const dy = moveEvent.clientY - pending.startY; - if (dx * dx + dy * dy < DRAG_THRESHOLD_PX * DRAG_THRESHOLD_PX) return; + const session = sessionRef.current; + if (session.phase === "idle" || moveEvent.pointerId !== session.pointerId) return; + if (session.phase === "armed") { + if ( + !exceedsDragThreshold(session.start, { x: moveEvent.clientX, y: moveEvent.clientY }) + ) { + return; + } const canvasElement = document.querySelector("[data-workbench-canvas]"); const geometry = geometryRef.current; if (!canvasElement || !geometry) { teardown(); return; } - // Very narrow canvases disable pointer splitting entirely. - if (geometry.canvas.width < MIN_CANVAS_WIDTH_FOR_POINTER_SPLIT) { + if (!canvasAllowsPointerSplit(geometry)) { teardown(); return; } const canvasRect = canvasElement.getBoundingClientRect(); - activeRef.current = { - ...pending, + dispatch({ + type: "activate", + pointerId: session.pointerId, canvasOrigin: { left: canvasRect.left, top: canvasRect.top }, geometry, revision: layoutRef.current.revision, - }; + }); armClickSuppressor(); document.documentElement.style.setProperty("cursor", "grabbing"); } - const active = activeRef.current; - const localX = moveEvent.clientX - active.canvasOrigin.left; - const localY = moveEvent.clientY - active.canvasOrigin.top; - const target = resolveTarget( - hitTestWorkbenchDrop(active.geometry, localX, localY), - active.payload, - active.geometry, - ); - setDragState({ - payload: active.payload, - pointer: { x: moveEvent.clientX, y: moveEvent.clientY }, - target, - previewRect: target ? previewRectForDropTarget(active.geometry, target) : null, + dispatch({ + type: "pointer-move", + pointerId: moveEvent.pointerId, + clientX: moveEvent.clientX, + clientY: moveEvent.clientY, + layout: layoutRef.current, }); }; const handleUp = (upEvent: PointerEvent) => { - const pending = pendingRef.current; - if (!pending || upEvent.pointerId !== pending.pointerId) return; - const active = activeRef.current; - if (active) { - const localX = upEvent.clientX - active.canvasOrigin.left; - const localY = upEvent.clientY - active.canvasOrigin.top; - const target = resolveTarget( - hitTestWorkbenchDrop(active.geometry, localX, localY), - active.payload, - active.geometry, - ); - if (target) { - onCommitRef.current({ - payload: active.payload, - target, - revision: active.revision, - }); - } - } + const session = sessionRef.current; + if (session.phase === "idle" || upEvent.pointerId !== session.pointerId) return; + dispatch({ + type: "pointer-up", + pointerId: upEvent.pointerId, + clientX: upEvent.clientX, + clientY: upEvent.clientY, + layout: layoutRef.current, + }); teardown(); }; @@ -320,7 +166,7 @@ export function useWorkbenchDragSession(params: UseWorkbenchDragSessionParams) { window.removeEventListener("keydown", handleKeyDown, true); }; }, - [enabled, geometryRef, layoutRef, resolveTarget, teardown], + [dispatch, enabled, geometryRef, layoutRef, teardown], ); return { dragState, beginDrag }; diff --git a/crates/agent-gui/src/pages/chat/workbench/workbenchDragMachine.ts b/crates/agent-gui/src/pages/chat/workbench/workbenchDragMachine.ts new file mode 100644 index 000000000..f964a9691 --- /dev/null +++ b/crates/agent-gui/src/pages/chat/workbench/workbenchDragMachine.ts @@ -0,0 +1,343 @@ +import { WORKBENCH_CANVAS_DIVIDER_SIZE as CANVAS_DIVIDER_SIZE } from "@liveagent/ui/components/workbench/WorkbenchCanvas"; +import { + hitTestWorkbenchDrop, + MIN_CONVERSATION_PANE_HEIGHT, + MIN_CONVERSATION_PANE_WIDTH, + previewRectForDropTarget, + type WorkbenchDropTarget, + type WorkbenchEdge, + type WorkbenchGeometry, + type WorkbenchRect, +} from "@liveagent/ui/lib/workbench/index"; +import { + type ProjectRef, + surfaceIdentityKey, + type WorkbenchLayout, +} from "@liveagent/ui/lib/workbench/types"; + +export const DRAG_THRESHOLD_PX = 6; +/** Pointer-splitting is disabled on very narrow canvases (doc §22). */ +export const MIN_CANVAS_WIDTH_FOR_POINTER_SPLIT = 440; +/** Below this canvas width the sidebar auto-dock prefers the vertical axis. */ +const NARROW_CANVAS_WIDTH_FOR_AUTO_DOCK = 680; + +/** Both halves of a split must keep the conversation hard minimum size. */ +export function canSplitRectAtEdge(rect: WorkbenchRect, edge: WorkbenchEdge): boolean { + const divider = CANVAS_DIVIDER_SIZE; + if (edge === "left" || edge === "right") { + return (rect.width - divider) / 2 >= MIN_CONVERSATION_PANE_WIDTH; + } + return (rect.height - divider) / 2 >= MIN_CONVERSATION_PANE_HEIGHT; +} + +/** A drag arms on pointer-down and only activates once it clears this radius. */ +export function exceedsDragThreshold( + start: { x: number; y: number }, + current: { x: number; y: number }, +): boolean { + const dx = current.x - start.x; + const dy = current.y - start.y; + return dx * dx + dy * dy >= DRAG_THRESHOLD_PX * DRAG_THRESHOLD_PX; +} + +/** Very narrow canvases disable pointer splitting entirely. */ +export function canvasAllowsPointerSplit(geometry: WorkbenchGeometry): boolean { + return geometry.canvas.width >= MIN_CANVAS_WIDTH_FOR_POINTER_SPLIT; +} + +export type WorkbenchDragPayload = + | { kind: "conversation"; conversationId: string; project: ProjectRef; title: string } + /** Moving an existing pane; surfaceKey is surfaceIdentityKey(pane.surface). */ + | { kind: "pane"; paneId: string; surfaceKey: string; title: string } + /** Dragging a workspace creates a new conversation for it at the drop spot. */ + | { kind: "workspace"; projectId: string; projectPath: string; title: string } + /** Dragging an existing terminal session (e.g. from the Right Dock) into a pane. */ + | { kind: "terminalSession"; sessionId: string; project: ProjectRef; title: string } + /** Dragging a "new terminal" affordance creates a terminal at the drop spot. */ + | { kind: "newTerminal"; project: ProjectRef; title: string }; + +export type WorkbenchDropCommit = { + payload: WorkbenchDragPayload; + target: WorkbenchDropTarget; + /** Layout revision frozen when the drag activated (CAS at commit time). */ + revision: number; +}; + +export type WorkbenchDragState = { + payload: WorkbenchDragPayload; + pointer: { x: number; y: number }; + target: WorkbenchDropTarget | null; + previewRect: WorkbenchRect | null; +}; + +/** + * The pane a payload already owns, if any. Dropping onto it is a focus/no-op + * rather than a move. terminalSession drags have no own pane here: the + * session→pane mapping lives in the lease store, and a leased session is not + * draggable from the sidebar in the first place. + */ +function ownPaneIdForPayload( + payload: WorkbenchDragPayload, + layout: WorkbenchLayout, +): string | undefined { + if (payload.kind === "pane") return payload.paneId; + if (payload.kind !== "conversation") return undefined; + return Object.values(layout.panes).find( + (pane) => surfaceIdentityKey(pane.surface) === `conversation:${payload.conversationId}`, + )?.paneId; +} + +/** + * The region a divider drop would halve: everything on the chosen side of the + * bar, within the split area the divider belongs to. + */ +function dividerInsertionRegion( + divider: WorkbenchGeometry["dividers"][number], + edge: WorkbenchEdge, +): WorkbenchRect { + const before = edge === "left" || edge === "top"; + if (divider.axis === "horizontal") { + return before + ? { ...divider.splitArea, width: divider.rect.left - divider.splitArea.left } + : { + ...divider.splitArea, + left: divider.rect.left + divider.rect.width, + width: + divider.splitArea.left + + divider.splitArea.width - + (divider.rect.left + divider.rect.width), + }; + } + return before + ? { ...divider.splitArea, height: divider.rect.top - divider.splitArea.top } + : { + ...divider.splitArea, + top: divider.rect.top + divider.rect.height, + height: + divider.splitArea.top + + divider.splitArea.height - + (divider.rect.top + divider.rect.height), + }; +} + +/** + * Normalize a raw hit-test target for the payload: + * - own-pane hits become focus/no-op (pane-center on itself); + * - sidebar payloads never overwrite a pane center — they auto-dock + * (bottom-first on narrow canvases, else right, then the other axis); + * - every split target is rejected when either half would fall below the + * conversation hard minimum size, so drops with insufficient space show + * no preview and commit nothing. + */ +export function resolveWorkbenchDropTarget( + raw: WorkbenchDropTarget | null, + payload: WorkbenchDragPayload, + geometry: WorkbenchGeometry, + layout: WorkbenchLayout, +): WorkbenchDropTarget | null { + if (!raw) return null; + const ownPaneId = ownPaneIdForPayload(payload, layout); + const paneRect = (paneId: string): WorkbenchRect | null => + geometry.panes.find((pane) => pane.paneId === paneId)?.rect ?? null; + + if (raw.kind === "pane-center") { + if (ownPaneId && raw.paneId === ownPaneId) { + return { kind: "pane-center", paneId: ownPaneId }; + } + // Sidebar payloads never overwrite a pane: deterministic auto-dock. + if (payload.kind !== "pane") { + const rect = paneRect(raw.paneId); + if (!rect) return null; + const preferVertical = geometry.canvas.width < NARROW_CANVAS_WIDTH_FOR_AUTO_DOCK; + const edges: WorkbenchEdge[] = preferVertical ? ["bottom", "right"] : ["right", "bottom"]; + for (const edge of edges) { + if (canSplitRectAtEdge(rect, edge)) { + return { kind: "pane-edge", paneId: raw.paneId, edge }; + } + } + return null; + } + return raw; + } + if (raw.kind === "pane-edge") { + if (ownPaneId && raw.paneId === ownPaneId) { + return { kind: "pane-center", paneId: ownPaneId }; + } + const rect = paneRect(raw.paneId); + if (!rect || !canSplitRectAtEdge(rect, raw.edge)) return null; + return raw; + } + if (raw.kind === "canvas-edge") { + return canSplitRectAtEdge(geometry.canvas, raw.edge) ? raw : null; + } + if (raw.kind === "divider") { + const divider = geometry.dividers.find((entry) => entry.splitId === raw.splitId); + if (!divider) return null; + const region = dividerInsertionRegion(divider, raw.edge); + if (!canSplitRectAtEdge(region, divider.axis === "horizontal" ? "right" : "bottom")) { + return null; + } + return raw; + } + if (raw.kind === "canvas-empty" && payload.kind === "pane") { + return null; + } + return raw; +} + +/** Canvas-relative snapshot frozen when a drag activates. */ +export type DragActivation = { + canvasOrigin: { left: number; top: number }; + geometry: WorkbenchGeometry; + /** Layout revision at activation time; the commit is CAS-checked against it. */ + revision: number; +}; + +export type DragSessionState = + | { phase: "idle" } + | { + phase: "armed"; + payload: WorkbenchDragPayload; + pointerId: number; + start: { x: number; y: number }; + } + | ({ + phase: "dragging"; + payload: WorkbenchDragPayload; + pointerId: number; + start: { x: number; y: number }; + drag: WorkbenchDragState | null; + } & DragActivation); + +export type DragSessionEvent = + | { + type: "arm"; + payload: WorkbenchDragPayload; + pointerId: number; + clientX: number; + clientY: number; + } + /** Emitted by the adapter once the threshold is cleared and a snapshot exists. */ + | ({ type: "activate"; pointerId: number } & DragActivation) + | { + type: "pointer-move"; + pointerId: number; + clientX: number; + clientY: number; + layout: WorkbenchLayout; + } + | { + type: "pointer-up"; + pointerId: number; + clientX: number; + clientY: number; + layout: WorkbenchLayout; + } + /** Esc, pointer-cancel, window blur and teardown all land here. */ + | { type: "cancel" }; + +export type DragSessionResult = { + state: DragSessionState; + /** Set exactly once, on the pointer-up that resolves to a target. */ + commit: WorkbenchDropCommit | null; +}; + +export const IDLE_DRAG_SESSION: DragSessionState = { phase: "idle" }; + +/** + * Pure Idle→Armed→Dragging→Commit/Cancel state machine behind the drag + * session. Activation is an explicit event because the snapshot it freezes + * (canvas origin, geometry, layout revision) can only be read from the DOM by + * the hook adapter. + */ +export function dragSessionReducer( + state: DragSessionState, + event: DragSessionEvent, +): DragSessionResult { + switch (event.type) { + case "arm": { + // A second pointer never preempts a live gesture. + if (state.phase !== "idle") return { state, commit: null }; + return { + state: { + phase: "armed", + payload: event.payload, + pointerId: event.pointerId, + start: { x: event.clientX, y: event.clientY }, + }, + commit: null, + }; + } + case "activate": { + if (state.phase !== "armed" || state.pointerId !== event.pointerId) { + return { state, commit: null }; + } + return { + state: { + phase: "dragging", + payload: state.payload, + pointerId: state.pointerId, + start: state.start, + canvasOrigin: event.canvasOrigin, + geometry: event.geometry, + revision: event.revision, + drag: null, + }, + commit: null, + }; + } + case "pointer-move": { + if (state.phase !== "dragging" || state.pointerId !== event.pointerId) { + return { state, commit: null }; + } + const target = resolveTargetAtPointer(state, event.clientX, event.clientY, event.layout); + return { + state: { + ...state, + drag: { + payload: state.payload, + pointer: { x: event.clientX, y: event.clientY }, + target, + previewRect: target ? previewRectForDropTarget(state.geometry, target) : null, + }, + }, + commit: null, + }; + } + case "pointer-up": { + if (state.phase === "idle" || state.pointerId !== event.pointerId) { + return { state, commit: null }; + } + // Armed-but-never-activated gestures end as plain clicks. + if (state.phase !== "dragging") return { state: IDLE_DRAG_SESSION, commit: null }; + const target = resolveTargetAtPointer(state, event.clientX, event.clientY, event.layout); + return { + state: IDLE_DRAG_SESSION, + commit: target ? { payload: state.payload, target, revision: state.revision } : null, + }; + } + case "cancel": + return { state: IDLE_DRAG_SESSION, commit: null }; + } +} + +function resolveTargetAtPointer( + state: Extract, + clientX: number, + clientY: number, + layout: WorkbenchLayout, +): WorkbenchDropTarget | null { + const localX = clientX - state.canvasOrigin.left; + const localY = clientY - state.canvasOrigin.top; + return resolveWorkbenchDropTarget( + hitTestWorkbenchDrop(state.geometry, localX, localY), + state.payload, + state.geometry, + layout, + ); +} + +/** The drag overlay model for a state, or null while idle/armed. */ +export function dragStateFor(state: DragSessionState): WorkbenchDragState | null { + return state.phase === "dragging" ? state.drag : null; +} diff --git a/crates/agent-gui/test/chat/workbench-drag-session.test.mjs b/crates/agent-gui/test/chat/workbench-drag-session.test.mjs new file mode 100644 index 000000000..69ef25e87 --- /dev/null +++ b/crates/agent-gui/test/chat/workbench-drag-session.test.mjs @@ -0,0 +1,515 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +const loader = createTsModuleLoader(); +const machine = loader.loadModule("src/pages/chat/workbench/workbenchDragMachine.ts"); + +const { + canSplitRectAtEdge, + canvasAllowsPointerSplit, + dragSessionReducer, + dragStateFor, + DRAG_THRESHOLD_PX, + exceedsDragThreshold, + IDLE_DRAG_SESSION, + resolveWorkbenchDropTarget, +} = machine; + +const PROJECT = { projectId: "project-main", projectPathKey: "/workspace/main" }; + +function rect(left, top, width, height) { + return { left, top, width, height }; +} + +/** A layout whose panes hold conversation surfaces, keyed by pane id. */ +function layoutWith(panes) { + return { + schemaVersion: 1, + revision: 7, + root: null, + panes: Object.fromEntries( + Object.entries(panes).map(([paneId, conversationId]) => [ + paneId, + { + paneId, + surface: { kind: "conversation", conversationId, project: PROJECT }, + view: {}, + }, + ]), + ), + focusedPaneId: null, + }; +} + +const EMPTY_LAYOUT = layoutWith({}); + +function geometry({ canvas, panes = [], dividers = [] }) { + return { canvas, panes, dividers }; +} + +function conversationPayload(conversationId = "conversation-a") { + return { kind: "conversation", conversationId, project: PROJECT, title: conversationId }; +} + +function panePayload(paneId = "pane-a", conversationId = "conversation-a") { + return { + kind: "pane", + paneId, + surfaceKey: `conversation:${conversationId}`, + title: conversationId, + }; +} + +// A canvas wide enough that both split axes clear the conversation minimums: +// width (1000-6)/2 = 497 >= 320, height (800-6)/2 = 397 >= 220. +const WIDE_CANVAS = rect(0, 0, 1000, 800); + +test("drag threshold arms below 6px and activates at exactly 6px", () => { + assert.equal(DRAG_THRESHOLD_PX, 6); + const start = { x: 100, y: 100 }; + assert.equal(exceedsDragThreshold(start, { x: 105.9, y: 100 }), false); + assert.equal(exceedsDragThreshold(start, { x: 100, y: 105.9 }), false); + assert.equal(exceedsDragThreshold(start, { x: 106, y: 100 }), true); + assert.equal(exceedsDragThreshold(start, { x: 100, y: 94 }), true); + // Radial, not per-axis: 4/4 is only 5.66px away and must not activate. + assert.equal(exceedsDragThreshold(start, { x: 104, y: 104 }), false); + assert.equal(exceedsDragThreshold(start, { x: 100, y: 100 }), false); +}); + +test("canSplitRectAtEdge enforces the conversation hard minimums per axis", () => { + // Horizontal split needs (width - 6) / 2 >= 320 -> width >= 646. + assert.equal(canSplitRectAtEdge(rect(0, 0, 646, 800), "right"), true); + assert.equal(canSplitRectAtEdge(rect(0, 0, 645, 800), "right"), false); + assert.equal(canSplitRectAtEdge(rect(0, 0, 645, 800), "left"), false); + // Vertical split needs (height - 6) / 2 >= 220 -> height >= 446. + assert.equal(canSplitRectAtEdge(rect(0, 0, 1000, 446), "bottom"), true); + assert.equal(canSplitRectAtEdge(rect(0, 0, 1000, 445), "bottom"), false); + assert.equal(canSplitRectAtEdge(rect(0, 0, 1000, 445), "top"), false); +}); + +test("pointer splitting is disabled below the narrow-canvas cutoff", () => { + assert.equal(canvasAllowsPointerSplit(geometry({ canvas: rect(0, 0, 440, 800) })), true); + assert.equal(canvasAllowsPointerSplit(geometry({ canvas: rect(0, 0, 439, 800) })), false); +}); + +test("resolveTarget passes a null hit straight through", () => { + const geo = geometry({ canvas: WIDE_CANVAS, panes: [{ paneId: "pane-a", rect: WIDE_CANVAS }] }); + assert.equal( + resolveWorkbenchDropTarget(null, conversationPayload(), geo, EMPTY_LAYOUT), + null, + ); +}); + +test("dropping a conversation on its own pane resolves to focus, not a split", () => { + const geo = geometry({ canvas: WIDE_CANVAS, panes: [{ paneId: "pane-a", rect: WIDE_CANVAS }] }); + const layout = layoutWith({ "pane-a": "conversation-a" }); + assert.deepEqual( + resolveWorkbenchDropTarget( + { kind: "pane-center", paneId: "pane-a" }, + conversationPayload("conversation-a"), + geo, + layout, + ), + { kind: "pane-center", paneId: "pane-a" }, + ); + // An own-pane *edge* hit also collapses to focus rather than self-splitting. + assert.deepEqual( + resolveWorkbenchDropTarget( + { kind: "pane-edge", paneId: "pane-a", edge: "right" }, + panePayload("pane-a", "conversation-a"), + geo, + layout, + ), + { kind: "pane-center", paneId: "pane-a" }, + ); +}); + +test("sidebar payloads auto-dock on a pane center: right first on wide canvases", () => { + const geo = geometry({ canvas: WIDE_CANVAS, panes: [{ paneId: "pane-a", rect: WIDE_CANVAS }] }); + assert.deepEqual( + resolveWorkbenchDropTarget( + { kind: "pane-center", paneId: "pane-a" }, + conversationPayload("conversation-new"), + geo, + EMPTY_LAYOUT, + ), + { kind: "pane-edge", paneId: "pane-a", edge: "right" }, + ); +}); + +test("sidebar auto-dock prefers bottom when the canvas is narrower than 680", () => { + // 679 wide still allows a horizontal split (>= 646), so the bottom-first + // preference — not a fallback — is what selects the vertical axis. + const canvas = rect(0, 0, 679, 800); + const geo = geometry({ canvas, panes: [{ paneId: "pane-a", rect: canvas }] }); + assert.deepEqual( + resolveWorkbenchDropTarget( + { kind: "pane-center", paneId: "pane-a" }, + conversationPayload("conversation-new"), + geo, + EMPTY_LAYOUT, + ), + { kind: "pane-edge", paneId: "pane-a", edge: "bottom" }, + ); + // At 680 the wide preference kicks back in. + const wide = rect(0, 0, 680, 800); + assert.deepEqual( + resolveWorkbenchDropTarget( + { kind: "pane-center", paneId: "pane-a" }, + conversationPayload("conversation-new"), + geometry({ canvas: wide, panes: [{ paneId: "pane-a", rect: wide }] }), + EMPTY_LAYOUT, + ), + { kind: "pane-edge", paneId: "pane-a", edge: "right" }, + ); +}); + +test("auto-dock falls back to the other axis, then to no target at all", () => { + // Too narrow to split horizontally (600 < 646) but tall enough for bottom. + const narrowPane = rect(0, 0, 600, 800); + const geo = geometry({ + canvas: rect(0, 0, 900, 800), + panes: [{ paneId: "pane-a", rect: narrowPane }], + }); + assert.deepEqual( + resolveWorkbenchDropTarget( + { kind: "pane-center", paneId: "pane-a" }, + conversationPayload("conversation-new"), + geo, + EMPTY_LAYOUT, + ), + { kind: "pane-edge", paneId: "pane-a", edge: "bottom" }, + ); + // Neither axis fits: no preview, nothing to commit. + const tinyPane = rect(0, 0, 600, 400); + assert.equal( + resolveWorkbenchDropTarget( + { kind: "pane-center", paneId: "pane-a" }, + conversationPayload("conversation-new"), + geometry({ canvas: rect(0, 0, 900, 800), panes: [{ paneId: "pane-a", rect: tinyPane }] }), + EMPTY_LAYOUT, + ), + null, + ); +}); + +test("pane payloads keep a foreign pane center as a swap target", () => { + const geo = geometry({ canvas: WIDE_CANVAS, panes: [{ paneId: "pane-a", rect: WIDE_CANVAS }] }); + assert.deepEqual( + resolveWorkbenchDropTarget( + { kind: "pane-center", paneId: "pane-a" }, + panePayload("pane-b", "conversation-b"), + geo, + layoutWith({ "pane-a": "conversation-a", "pane-b": "conversation-b" }), + ), + { kind: "pane-center", paneId: "pane-a" }, + ); + // An unknown pane id has no rect to auto-dock into. + assert.equal( + resolveWorkbenchDropTarget( + { kind: "pane-center", paneId: "pane-missing" }, + conversationPayload("conversation-new"), + geo, + EMPTY_LAYOUT, + ), + null, + ); +}); + +test("pane-edge drops are rejected when either half loses the minimum size", () => { + const geo = geometry({ + canvas: rect(0, 0, 900, 800), + panes: [{ paneId: "pane-a", rect: rect(0, 0, 600, 400) }], + }); + const payload = conversationPayload("conversation-new"); + assert.equal( + resolveWorkbenchDropTarget({ kind: "pane-edge", paneId: "pane-a", edge: "right" }, payload, geo, EMPTY_LAYOUT), + null, + ); + assert.equal( + resolveWorkbenchDropTarget({ kind: "pane-edge", paneId: "pane-a", edge: "bottom" }, payload, geo, EMPTY_LAYOUT), + null, + ); + assert.equal( + resolveWorkbenchDropTarget({ kind: "pane-edge", paneId: "pane-missing", edge: "right" }, payload, geo, EMPTY_LAYOUT), + null, + ); + // Same edge on a pane with room passes through untouched. + const roomy = geometry({ canvas: WIDE_CANVAS, panes: [{ paneId: "pane-a", rect: WIDE_CANVAS }] }); + assert.deepEqual( + resolveWorkbenchDropTarget({ kind: "pane-edge", paneId: "pane-a", edge: "right" }, payload, roomy, EMPTY_LAYOUT), + { kind: "pane-edge", paneId: "pane-a", edge: "right" }, + ); +}); + +test("canvas-edge drops are measured against the canvas rect", () => { + const payload = conversationPayload("conversation-new"); + const roomy = geometry({ canvas: WIDE_CANVAS }); + assert.deepEqual( + resolveWorkbenchDropTarget({ kind: "canvas-edge", edge: "left" }, payload, roomy, EMPTY_LAYOUT), + { kind: "canvas-edge", edge: "left" }, + ); + // 445 tall: (445 - 6) / 2 = 219.5 < 220. + const shortCanvas = geometry({ canvas: rect(0, 0, 1000, 445) }); + assert.equal( + resolveWorkbenchDropTarget({ kind: "canvas-edge", edge: "top" }, payload, shortCanvas, EMPTY_LAYOUT), + null, + ); + const narrowCanvas = geometry({ canvas: rect(0, 0, 645, 800) }); + assert.equal( + resolveWorkbenchDropTarget({ kind: "canvas-edge", edge: "right" }, payload, narrowCanvas, EMPTY_LAYOUT), + null, + ); +}); + +test("divider drops halve the region on the pointed-at side of a horizontal bar", () => { + // splitArea 1400 wide, bar at x=700..706: both regions are 700 / 694 wide, + // so (700 - 6) / 2 = 347 and (694 - 6) / 2 = 344 both clear 320. + const geo = geometry({ + canvas: rect(0, 0, 1400, 800), + dividers: [ + { + splitId: "split-1", + axis: "horizontal", + rect: rect(700, 0, 6, 800), + splitArea: rect(0, 0, 1400, 800), + }, + ], + }); + const payload = conversationPayload("conversation-new"); + for (const edge of ["left", "right"]) { + assert.deepEqual( + resolveWorkbenchDropTarget({ kind: "divider", splitId: "split-1", edge }, payload, geo, EMPTY_LAYOUT), + { kind: "divider", splitId: "split-1", edge }, + ); + } + // An off-centre bar starves the left region: 500 -> (500 - 6) / 2 = 247. + const lopsided = geometry({ + canvas: rect(0, 0, 1400, 800), + dividers: [ + { + splitId: "split-1", + axis: "horizontal", + rect: rect(500, 0, 6, 800), + splitArea: rect(0, 0, 1400, 800), + }, + ], + }); + assert.equal( + resolveWorkbenchDropTarget({ kind: "divider", splitId: "split-1", edge: "left" }, payload, lopsided, EMPTY_LAYOUT), + null, + ); + assert.deepEqual( + resolveWorkbenchDropTarget({ kind: "divider", splitId: "split-1", edge: "right" }, payload, lopsided, EMPTY_LAYOUT), + { kind: "divider", splitId: "split-1", edge: "right" }, + ); +}); + +test("divider drops on a vertical bar measure the height axis, offset by splitArea", () => { + // splitArea starts at y=100 so a naive `rect.top` region height would be + // wrong by 100px; top region is 400 tall -> (400 - 6) / 2 = 197 < 220. + const geo = geometry({ + canvas: rect(0, 0, 1000, 1100), + dividers: [ + { + splitId: "split-v", + axis: "vertical", + rect: rect(0, 500, 1000, 6), + splitArea: rect(0, 100, 1000, 1000), + }, + ], + }); + const payload = conversationPayload("conversation-new"); + assert.equal( + resolveWorkbenchDropTarget({ kind: "divider", splitId: "split-v", edge: "top" }, payload, geo, EMPTY_LAYOUT), + null, + ); + // Bottom region: 1100 - 506 = 594 tall -> (594 - 6) / 2 = 294 >= 220. + assert.deepEqual( + resolveWorkbenchDropTarget({ kind: "divider", splitId: "split-v", edge: "bottom" }, payload, geo, EMPTY_LAYOUT), + { kind: "divider", splitId: "split-v", edge: "bottom" }, + ); + // Unknown split id has no region to measure. + assert.equal( + resolveWorkbenchDropTarget({ kind: "divider", splitId: "split-gone", edge: "top" }, payload, geo, EMPTY_LAYOUT), + null, + ); +}); + +test("canvas-empty accepts new surfaces but never a pane move", () => { + const geo = geometry({ canvas: WIDE_CANVAS }); + assert.equal( + resolveWorkbenchDropTarget({ kind: "canvas-empty" }, panePayload("pane-a"), geo, layoutWith({ "pane-a": "conversation-a" })), + null, + ); + for (const payload of [ + conversationPayload("conversation-new"), + { kind: "workspace", projectId: "project-main", projectPath: "/workspace/main", title: "main" }, + { kind: "newTerminal", project: PROJECT, title: "Terminal" }, + ]) { + assert.deepEqual( + resolveWorkbenchDropTarget({ kind: "canvas-empty" }, payload, geo, EMPTY_LAYOUT), + { kind: "canvas-empty" }, + `payload ${payload.kind} should fill an empty canvas`, + ); + } +}); + +test("terminal payloads have no own pane and auto-dock like sidebar drags", () => { + const geo = geometry({ canvas: WIDE_CANVAS, panes: [{ paneId: "pane-a", rect: WIDE_CANVAS }] }); + const layout = layoutWith({ "pane-a": "conversation-a" }); + for (const payload of [ + { kind: "terminalSession", sessionId: "session-1", project: PROJECT, title: "zsh" }, + { kind: "newTerminal", project: PROJECT, title: "Terminal" }, + ]) { + assert.deepEqual( + resolveWorkbenchDropTarget({ kind: "pane-center", paneId: "pane-a" }, payload, geo, layout), + { kind: "pane-edge", paneId: "pane-a", edge: "right" }, + `payload ${payload.kind} should auto-dock instead of overwriting`, + ); + // Edge hits keep their edge: a terminal never owns the pane it lands on. + assert.deepEqual( + resolveWorkbenchDropTarget( + { kind: "pane-edge", paneId: "pane-a", edge: "left" }, + payload, + geo, + layout, + ), + { kind: "pane-edge", paneId: "pane-a", edge: "left" }, + ); + } +}); + +// --- state machine ------------------------------------------------------- + +const SESSION_GEOMETRY = geometry({ + canvas: WIDE_CANVAS, + panes: [{ paneId: "pane-a", rect: WIDE_CANVAS }], +}); + +const ACTIVATION = { + canvasOrigin: { left: 50, top: 20 }, + geometry: SESSION_GEOMETRY, + revision: 7, +}; + +/** Feed a sequence of events, collecting every commit the machine emits. */ +function run(events, initial = IDLE_DRAG_SESSION) { + let state = initial; + const commits = []; + for (const event of events) { + const result = dragSessionReducer(state, event); + state = result.state; + if (result.commit) commits.push(result.commit); + } + return { state, commits }; +} + +const ARM = { type: "arm", payload: conversationPayload("conversation-new"), pointerId: 1, clientX: 100, clientY: 100 }; +const ACTIVATE = { type: "activate", pointerId: 1, ...ACTIVATION }; +// Canvas origin (50, 20) puts this pointer at the pane centre (450, 380). +const MOVE_CENTER = { type: "pointer-move", pointerId: 1, clientX: 500, clientY: 400, layout: EMPTY_LAYOUT }; + +test("the machine walks idle -> armed -> dragging and previews on move", () => { + const armed = dragSessionReducer(IDLE_DRAG_SESSION, ARM); + assert.equal(armed.state.phase, "armed"); + assert.equal(armed.commit, null); + // No overlay until the threshold is cleared. + assert.equal(dragStateFor(armed.state), null); + + const dragging = dragSessionReducer(armed.state, ACTIVATE); + assert.equal(dragging.state.phase, "dragging"); + assert.equal(dragging.state.revision, 7); + assert.equal(dragStateFor(dragging.state), null); + + const moved = dragSessionReducer(dragging.state, MOVE_CENTER); + const drag = dragStateFor(moved.state); + assert.deepEqual(drag.pointer, { x: 500, y: 400 }); + assert.deepEqual(drag.target, { kind: "pane-edge", paneId: "pane-a", edge: "right" }); + assert.ok(drag.previewRect, "an accepted target must render a preview rect"); + assert.equal(moved.commit, null); +}); + +test("Escape and pointer-cancel return to idle without committing", () => { + for (const label of ["escape", "pointercancel", "blur"]) { + const { state, commits } = run([ARM, ACTIVATE, MOVE_CENTER, { type: "cancel" }]); + assert.equal(state.phase, "idle", `${label} must land in idle`); + assert.equal(dragStateFor(state), null); + assert.deepEqual(commits, []); + } + // A cancel mid-gesture also blocks the pointer-up that follows it. + const { state, commits } = run([ + ARM, + ACTIVATE, + MOVE_CENTER, + { type: "cancel" }, + { type: "pointer-up", pointerId: 1, clientX: 500, clientY: 400, layout: EMPTY_LAYOUT }, + ]); + assert.equal(state.phase, "idle"); + assert.deepEqual(commits, []); +}); + +test("pointer-up over a target commits exactly once with the frozen revision", () => { + const upEvent = { type: "pointer-up", pointerId: 1, clientX: 500, clientY: 400, layout: EMPTY_LAYOUT }; + const { state, commits } = run([ARM, ACTIVATE, MOVE_CENTER, upEvent, upEvent]); + assert.equal(state.phase, "idle"); + assert.equal(commits.length, 1); + assert.deepEqual(commits[0], { + payload: ARM.payload, + target: { kind: "pane-edge", paneId: "pane-a", edge: "right" }, + // Frozen at activation, not read at drop time: the CAS input. + revision: 7, + }); +}); + +test("pointer-up without a resolvable target commits nothing", () => { + // Outside the canvas: the hit test returns null. + const outside = { type: "pointer-up", pointerId: 1, clientX: 5000, clientY: 5000, layout: EMPTY_LAYOUT }; + const { state, commits } = run([ARM, ACTIVATE, MOVE_CENTER, outside]); + assert.equal(state.phase, "idle"); + assert.deepEqual(commits, []); + + // Armed but never activated: a plain click, not a drop. + const armedUp = run([ARM, { type: "pointer-up", pointerId: 1, clientX: 500, clientY: 400, layout: EMPTY_LAYOUT }]); + assert.equal(armedUp.state.phase, "idle"); + assert.deepEqual(armedUp.commits, []); +}); + +test("events from a second pointer never disturb a live gesture", () => { + const armed = dragSessionReducer(IDLE_DRAG_SESSION, ARM).state; + // A second arm cannot preempt the first. + const reArmed = dragSessionReducer(armed, { ...ARM, pointerId: 2, clientX: 700 }); + assert.deepEqual(reArmed.state, armed); + + const dragging = dragSessionReducer(armed, ACTIVATE).state; + const foreignMove = dragSessionReducer(dragging, { ...MOVE_CENTER, pointerId: 2 }); + assert.deepEqual(foreignMove.state, dragging); + const foreignUp = dragSessionReducer(dragging, { + type: "pointer-up", + pointerId: 2, + clientX: 500, + clientY: 400, + layout: EMPTY_LAYOUT, + }); + assert.deepEqual(foreignUp.state, dragging); + assert.equal(foreignUp.commit, null); +}); + +test("moves use the frozen geometry snapshot, not a later layout", () => { + const dragging = run([ARM, ACTIVATE]).state; + // Rebasing the layout mid-drag (revision bump, pane now owned) changes the + // resolution rules but must not change the frozen revision on the commit. + const ownedLayout = { ...layoutWith({ "pane-a": "conversation-new" }), revision: 99 }; + const moved = dragSessionReducer(dragging, { ...MOVE_CENTER, layout: ownedLayout }); + assert.deepEqual(dragStateFor(moved.state).target, { kind: "pane-center", paneId: "pane-a" }); + const up = dragSessionReducer(moved.state, { + type: "pointer-up", + pointerId: 1, + clientX: 500, + clientY: 400, + layout: ownedLayout, + }); + assert.equal(up.commit.revision, 7); + assert.deepEqual(up.commit.target, { kind: "pane-center", paneId: "pane-a" }); +}); From b404857db0683f6d48055e2b39cce562e98b3e56 Mon Sep 17 00:00:00 2001 From: yovinchen Date: Mon, 17 Aug 2026 06:02:48 +0800 Subject: [PATCH 19/76] =?UTF-8?q?feat(i18n):=20=E7=BB=88=E7=AB=AF=20pane?= =?UTF-8?q?=20=E7=BB=93=E6=9D=9F/=E9=87=8D=E8=BF=9E=E3=80=81SSH=20?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E4=B8=8E=E5=8C=BA=E5=9F=9F=E6=A0=87=E7=AD=BE?= =?UTF-8?q?=E6=96=87=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/agent-ui/src/i18n/translations/enUSCommon.ts | 12 ++++++++++++ crates/agent-ui/src/i18n/translations/zhCNCommon.ts | 12 ++++++++++++ 2 files changed, 24 insertions(+) diff --git a/crates/agent-ui/src/i18n/translations/enUSCommon.ts b/crates/agent-ui/src/i18n/translations/enUSCommon.ts index ba0bad637..18f2ebb55 100644 --- a/crates/agent-ui/src/i18n/translations/enUSCommon.ts +++ b/crates/agent-ui/src/i18n/translations/enUSCommon.ts @@ -988,6 +988,8 @@ export const EN_US_COMMON_TRANSLATIONS = { "This host uses keyboard-interactive authentication and cannot reconnect in place; reconnect it from the SSH tunnel panel.", "workspaceSshTerminal.closeTab": "Close SSH tab", "workspaceSshTerminal.sftpTab": "SFTP", + "workspaceSshTerminal.openedInWorkbench": "This session is open in the workbench", + "workspaceSshTerminal.focusWorkbenchPane": "Go to pane", "workspaceSftp.local": "Local Project", "workspaceSftp.remote": "Remote Device", "workspaceSftp.projectRoot": "Project", @@ -1261,6 +1263,10 @@ export const EN_US_COMMON_TRANSLATIONS = { "mcpHub.storePreviewHomepage": "Homepage", "mcpHub.storePreviewRepository": "Repository", "workbench.paneRegion": "Conversation pane", + "workbench.paneRegionConversation": "Conversation pane: {title}", + "workbench.paneRegionConversationInWorkspace": "Conversation pane: {title} in {workspace}", + "workbench.paneRegionTerminal": "Terminal pane: {title}", + "workbench.paneRegionUnsupported": "Unsupported pane", "workbench.resizeDivider": "Resize split", "workbench.emptyTitle": "No conversation panes", "workbench.emptyDescription": "Drag a conversation from the sidebar to open it here.", @@ -1279,6 +1285,12 @@ export const EN_US_COMMON_TRANSLATIONS = { "workbench.terminalSessionMissing": "This terminal session is no longer available", "workbench.terminalSshPrompt": "SSH authentication is required — open this host from the project tools panel first", + "workbench.terminalKill": "End session", + "workbench.terminalKillConfirm": "Confirm end", + "workbench.sshStatusConnected": "Connected", + "workbench.sshStatusReconnecting": "Reconnecting", + "workbench.sshStatusDisconnected": "Disconnected", + "workbench.sshReconnect": "Reconnect", "workbench.unsupportedPane": "This layout item was created by a newer version and cannot be displayed here.", } as const satisfies Record; diff --git a/crates/agent-ui/src/i18n/translations/zhCNCommon.ts b/crates/agent-ui/src/i18n/translations/zhCNCommon.ts index 7eecf439b..f35b17c85 100644 --- a/crates/agent-ui/src/i18n/translations/zhCNCommon.ts +++ b/crates/agent-ui/src/i18n/translations/zhCNCommon.ts @@ -936,6 +936,8 @@ export const ZH_CN_COMMON_TRANSLATIONS = { "该主机使用交互式认证,无法原地重连;请在 SSH 隧道面板中重新连接。", "workspaceSshTerminal.closeTab": "关闭 SSH 标签", "workspaceSshTerminal.sftpTab": "SFTP", + "workspaceSshTerminal.openedInWorkbench": "该会话已在画板中打开", + "workspaceSshTerminal.focusWorkbenchPane": "前往 Pane", "workspaceSftp.local": "本地项目", "workspaceSftp.remote": "远端设备", "workspaceSftp.projectRoot": "项目", @@ -1202,6 +1204,10 @@ export const ZH_CN_COMMON_TRANSLATIONS = { "mcpHub.storePreviewHomepage": "主页", "mcpHub.storePreviewRepository": "仓库", "workbench.paneRegion": "会话面板", + "workbench.paneRegionConversation": "会话面板:{title}", + "workbench.paneRegionConversationInWorkspace": "会话面板:{workspace} 的 {title}", + "workbench.paneRegionTerminal": "终端面板:{title}", + "workbench.paneRegionUnsupported": "不支持的面板", "workbench.resizeDivider": "调整分栏大小", "workbench.emptyTitle": "暂无会话面板", "workbench.emptyDescription": "从左侧拖入一个会话即可在此打开。", @@ -1219,5 +1225,11 @@ export const ZH_CN_COMMON_TRANSLATIONS = { "workbench.terminalRetry": "重试", "workbench.terminalSessionMissing": "该终端会话已不存在", "workbench.terminalSshPrompt": "SSH 认证需先在项目工具面板中完成该主机的连接", + "workbench.terminalKill": "结束会话", + "workbench.terminalKillConfirm": "确认结束", + "workbench.sshStatusConnected": "已连接", + "workbench.sshStatusReconnecting": "重连中", + "workbench.sshStatusDisconnected": "已断开", + "workbench.sshReconnect": "重连", "workbench.unsupportedPane": "此布局项来自更新版本,当前版本无法显示。", } as const satisfies Record; From 12a614e67891dd9b1bfd4a0b40f84697a817be15 Mon Sep 17 00:00:00 2001 From: yovinchen Date: Mon, 17 Aug 2026 06:19:05 +0800 Subject: [PATCH 20/76] =?UTF-8?q?feat(ui):=20=E6=96=B0=E5=A2=9E=20SshTermi?= =?UTF-8?q?nalPaneSurface=20=E4=B8=8E=E5=85=B1=E4=BA=AB=20SSH=20=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E6=8E=A8=E5=AF=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sshSessionStatus/sshSessionEndpointLabel 抽为共享模块:以后端 ssh.status 为准,进程已停一律视为 disconnected,未知状态保守处理 - SSH Pane 在 Local surface 之上叠紧凑状态行(状态点/端点/重连按钮), SFTP 仍留在 workspace overlay,Pane 内只承载 shell 视口 - 状态推导与端点标签测试覆盖 running/status 组合 --- .../test/chat/ssh-session-status.test.mjs | 70 ++++++++++ .../src/components/workbench/index.ts | 1 + .../surfaces/SshTerminalPaneSurface.tsx | 120 ++++++++++++++++++ .../src/lib/terminal/sshSessionStatus.ts | 23 ++++ 4 files changed, 214 insertions(+) create mode 100644 crates/agent-gui/test/chat/ssh-session-status.test.mjs create mode 100644 crates/agent-ui/src/components/workbench/surfaces/SshTerminalPaneSurface.tsx create mode 100644 crates/agent-ui/src/lib/terminal/sshSessionStatus.ts diff --git a/crates/agent-gui/test/chat/ssh-session-status.test.mjs b/crates/agent-gui/test/chat/ssh-session-status.test.mjs new file mode 100644 index 000000000..5edc37809 --- /dev/null +++ b/crates/agent-gui/test/chat/ssh-session-status.test.mjs @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +// SSH 会话状态推导与端点标签:SshTerminalPaneSurface / WorkspaceSshTerminalOverlay 共用。 + +const loader = createTsModuleLoader(); +const { sshSessionStatus, sshSessionEndpointLabel } = loader.loadModule( + "../agent-ui/src/lib/terminal/sshSessionStatus.ts", +); + +function sshSession(overrides = {}, sshOverrides = {}) { + return { + id: "ssh-1", + projectPathKey: "/proj", + cwd: "/proj", + shell: "ssh", + title: "host", + kind: "ssh", + running: true, + cols: 80, + rows: 24, + createdAt: 1, + updatedAt: 1, + ssh: { + hostId: "h1", + hostName: "host", + username: "root", + host: "10.0.0.1", + port: 22, + authType: "key", + status: "connected", + reconnectAttempt: 0, + reconnectMaxAttempts: 3, + sftpEnabled: false, + ...sshOverrides, + }, + ...overrides, + }; +} + +test("connected status passes through while the session is running", () => { + assert.equal(sshSessionStatus(sshSession()), "connected"); +}); + +test("connected status downgrades to disconnected once the process stops", () => { + assert.equal(sshSessionStatus(sshSession({ running: false })), "disconnected"); +}); + +test("reconnecting status passes through", () => { + assert.equal(sshSessionStatus(sshSession({}, { status: "reconnecting" })), "reconnecting"); +}); + +test("unknown backend status is treated as disconnected", () => { + assert.equal(sshSessionStatus(sshSession({}, { status: "handshaking" })), "disconnected"); +}); + +test("missing ssh metadata falls back to running flag", () => { + assert.equal(sshSessionStatus(sshSession({ ssh: undefined })), "connected"); + assert.equal(sshSessionStatus(sshSession({ ssh: undefined, running: false })), "disconnected"); +}); + +test("endpoint label renders user@host:port and falls back to cwd", () => { + assert.equal(sshSessionEndpointLabel(sshSession()), "root@10.0.0.1:22"); + assert.equal( + sshSessionEndpointLabel(sshSession({}, { username: " " })), + "10.0.0.1:22", + ); + assert.equal(sshSessionEndpointLabel(sshSession({ ssh: undefined })), "/proj"); +}); diff --git a/crates/agent-ui/src/components/workbench/index.ts b/crates/agent-ui/src/components/workbench/index.ts index d3d9a3e0b..2462a3098 100644 --- a/crates/agent-ui/src/components/workbench/index.ts +++ b/crates/agent-ui/src/components/workbench/index.ts @@ -4,6 +4,7 @@ export * from "./PaneChrome"; export * from "./PaneFrame"; export * from "./PaneSurfaceLayer"; export * from "./surfaces/LocalTerminalPaneSurface"; +export * from "./surfaces/SshTerminalPaneSurface"; export * from "./surfaces/UnsupportedPaneSurface"; export * from "./WorkbenchCanvas"; export * from "./WorkbenchEmptyState"; diff --git a/crates/agent-ui/src/components/workbench/surfaces/SshTerminalPaneSurface.tsx b/crates/agent-ui/src/components/workbench/surfaces/SshTerminalPaneSurface.tsx new file mode 100644 index 000000000..ad0f23335 --- /dev/null +++ b/crates/agent-ui/src/components/workbench/surfaces/SshTerminalPaneSurface.tsx @@ -0,0 +1,120 @@ +import { RefreshCw } from "@liveagent/ui/components/IconSet"; +import { useLocale } from "@liveagent/ui/i18n/index"; +import { cn } from "../../../lib/shared/utils"; +import { sshSessionEndpointLabel, sshSessionStatus } from "../../../lib/terminal/sshSessionStatus"; +import type { TerminalClient, TerminalSession } from "../../../lib/terminal/types"; +import { Button } from "../../ui/button"; +import { + LocalTerminalPaneSurface, + type TerminalPaneSurfacePhase, +} from "./LocalTerminalPaneSurface"; + +export type SshTerminalPaneSurfaceProps = { + paneId: string; + client: TerminalClient; + session: TerminalSession | null; + phase: TerminalPaneSurfacePhase; + theme: "light" | "dark"; + isActive: boolean; + errorMessage?: string | null; + onRetry?: () => void; + onError: (sessionId: string, message: string | null) => void; + /** 触发 ssh 重连;由宿主注入(组件不接触 Tauri)。省略时不显示重连按钮。 */ + onReconnect?: () => void; + /** 宿主的重连调用进行中(与会话自身的 reconnecting 状态叠加显示)。 */ + isReconnecting?: boolean; + /** 透传给 LocalTerminalPaneSurface 的显式 kill 入口。 */ + onKillSession?: () => void; +}; + +/** + * SSH 终端 Pane:在 LocalTerminalPaneSurface 之上叠一条紧凑连接状态行 + * (状态点/端点标签/重连按钮)。exited/error/占位语义完全沿用 Local; + * SFTP 保留在 workspace overlay,Pane 内只承载 shell 视口。 + * TODO(terminal-pane): 状态行可选显示 sshLatency 轮询结果;首期只做状态点+重连。 + */ +export function SshTerminalPaneSurface(props: SshTerminalPaneSurfaceProps) { + const { + paneId, + client, + session, + phase, + theme, + isActive, + errorMessage, + onRetry, + onError, + onReconnect, + isReconnecting, + onKillSession, + } = props; + const { t } = useLocale(); + + const status = session ? sshSessionStatus(session) : null; + const reconnecting = Boolean(isReconnecting) || status === "reconnecting"; + const statusLabel = + status === "connected" + ? t("workbench.sshStatusConnected") + : status === "reconnecting" + ? t("workbench.sshStatusReconnecting") + : t("workbench.sshStatusDisconnected"); + + return ( +
+ {session ? ( +
+
+ ) : null} + +
+ ); +} diff --git a/crates/agent-ui/src/lib/terminal/sshSessionStatus.ts b/crates/agent-ui/src/lib/terminal/sshSessionStatus.ts new file mode 100644 index 000000000..c25877384 --- /dev/null +++ b/crates/agent-ui/src/lib/terminal/sshSessionStatus.ts @@ -0,0 +1,23 @@ +import type { TerminalSession } from "./types"; + +export type SshSessionStatus = "connected" | "reconnecting" | "disconnected"; + +/** + * SSH 会话连接状态的统一推导:后端 `ssh.status` 为准,但会话进程已停止时 + * 一律视为 disconnected(状态事件可能晚于进程退出)。未知状态按 disconnected + * 保守处理。WorkspaceSshTerminalOverlay 与 SshTerminalPaneSurface 共用。 + */ +export function sshSessionStatus(session: TerminalSession): SshSessionStatus { + const status = session.ssh?.status ?? (session.running ? "connected" : "disconnected"); + if (status === "connected" && !session.running) return "disconnected"; + if (status === "connected" || status === "reconnecting") return status; + return "disconnected"; +} + +/** SSH 会话的目标端点标签(user@host:port);非 SSH 会话退回 cwd。 */ +export function sshSessionEndpointLabel(session: TerminalSession): string { + const ssh = session.ssh; + if (!ssh) return session.cwd || session.projectPathKey; + const userPrefix = ssh.username.trim() ? `${ssh.username.trim()}@` : ""; + return `${userPrefix}${ssh.host}:${ssh.port}`; +} From 677517c908555832ab06a8028e898fd288281ea3 Mon Sep 17 00:00:00 2001 From: yovinchen Date: Mon, 17 Aug 2026 06:31:44 +0800 Subject: [PATCH 21/76] =?UTF-8?q?feat(ui):=20=E7=BB=88=E7=AB=AF=20pane=20?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E4=B8=A4=E6=80=81=E7=A1=AE=E8=AE=A4=E7=9A=84?= =?UTF-8?q?=E7=BB=93=E6=9D=9F=E4=BC=9A=E8=AF=9D=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 悬停显示的 kill 按钮:点一次武装、再点执行,3s 超时/失焦自动复位 - 与 Pane 关闭(Detach,进程保留回 dock)语义分离;省略回调则无入口 --- .../surfaces/LocalTerminalPaneSurface.tsx | 68 ++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/crates/agent-ui/src/components/workbench/surfaces/LocalTerminalPaneSurface.tsx b/crates/agent-ui/src/components/workbench/surfaces/LocalTerminalPaneSurface.tsx index 49a5c1b6f..feacd25b4 100644 --- a/crates/agent-ui/src/components/workbench/surfaces/LocalTerminalPaneSurface.tsx +++ b/crates/agent-ui/src/components/workbench/surfaces/LocalTerminalPaneSurface.tsx @@ -1,5 +1,6 @@ import { Loader2, Terminal } from "@liveagent/ui/components/IconSet"; import { useLocale } from "@liveagent/ui/i18n/index"; +import { useEffect, useRef, useState } from "react"; import { cn } from "../../../lib/shared/utils"; import type { TerminalClient, TerminalSession } from "../../../lib/terminal/types"; import { XTermViewport } from "../../project-tools/XTermViewport"; @@ -18,16 +19,59 @@ export type LocalTerminalPaneSurfaceProps = { errorMessage?: string | null; onRetry?: () => void; onError: (sessionId: string, message: string | null) => void; + /** + * 显式结束会话(kill 进程)。与 Pane 关闭(Detach,进程保留回 dock)语义 + * 分离;入口为悬停显示的两态确认按钮。省略时无 kill 入口。 + */ + onKillSession?: () => void; }; +const KILL_CONFIRM_RESET_MS = 3_000; + /** * 终端 Pane 的纯受控展示层:本地与 SSH 首期共用。会话存在时始终渲染 * XTermViewport(exited/error 只叠加提示条,不清屏),仅无会话可显示时 * 才使用居中占位,保证 phase 切换不重挂视口。 */ export function LocalTerminalPaneSurface(props: LocalTerminalPaneSurfaceProps) { - const { paneId, client, session, phase, theme, isActive, errorMessage, onRetry, onError } = props; + const { + paneId, + client, + session, + phase, + theme, + isActive, + errorMessage, + onRetry, + onError, + onKillSession, + } = props; const { t } = useLocale(); + // 两态确认(点一次武装、再点执行),超时自动复位;纯视图态,不进入宿主。 + const [killArmed, setKillArmed] = useState(false); + const killResetTimerRef = useRef(null); + useEffect( + () => () => { + if (killResetTimerRef.current !== null) window.clearTimeout(killResetTimerRef.current); + }, + [], + ); + const handleKillClick = () => { + if (killResetTimerRef.current !== null) { + window.clearTimeout(killResetTimerRef.current); + killResetTimerRef.current = null; + } + if (killArmed) { + setKillArmed(false); + onKillSession?.(); + return; + } + setKillArmed(true); + killResetTimerRef.current = window.setTimeout(() => { + killResetTimerRef.current = null; + setKillArmed(false); + }, KILL_CONFIRM_RESET_MS); + }; const banner = session && phase === "error" ? ( @@ -63,6 +107,27 @@ export function LocalTerminalPaneSurface(props: LocalTerminalPaneSurfaceProps) {
) : null; + const killButton = + session && onKillSession && phase !== "connecting" ? ( + + ) : null; + return (
+ {killButton} {banner} {session ? (
From ef67f4673462f09a9fa07881989e8dcde51548a2 Mon Sep 17 00:00:00 2001 From: yovinchen Date: Mon, 17 Aug 2026 06:50:12 +0800 Subject: [PATCH 22/76] =?UTF-8?q?feat(ui):=20SSH=20overlay=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=20pane=20=E7=A7=9F=E7=BA=A6=E5=8D=A0=E4=BD=8D?= =?UTF-8?q?=E4=B8=8E=20shell=20tab=20=E6=8B=96=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 被画板 Pane 租用的会话在 overlay 内显示"已在画板中打开"占位, 可一键前往 Pane;SFTP tab 走独立通道不受互斥影响 - shell tab pointerdown 上报拖出意图(SFTP tab 不可拖),阈值与点击抑制 由工作台拖拽会话统一处理 - 状态推导/端点标签改用共享 sshSessionStatus 模块 --- .../workspace-editor/WorkspaceOverlayHost.tsx | 13 ++++ .../WorkspaceSshTerminalOverlay.tsx | 63 ++++++++++++++++--- 2 files changed, 66 insertions(+), 10 deletions(-) diff --git a/crates/agent-ui/src/components/workspace-editor/WorkspaceOverlayHost.tsx b/crates/agent-ui/src/components/workspace-editor/WorkspaceOverlayHost.tsx index 6ecfe7a39..58e2f4419 100644 --- a/crates/agent-ui/src/components/workspace-editor/WorkspaceOverlayHost.tsx +++ b/crates/agent-ui/src/components/workspace-editor/WorkspaceOverlayHost.tsx @@ -71,6 +71,13 @@ type WorkspaceOverlayHostProps = { terminalSessions: TerminalSession[]; onWorkspaceSshTerminalHide: () => void; onSshTerminalOpenFile?: (session: TerminalSession, request: SftpOpenFileRequest) => void; + /** 工作台互斥/拖出(可选透传;缺省时 overlay 行为不变)。 */ + sshTerminalPaneLeasedSessionIds?: ReadonlySet; + onSshTerminalFocusLeasedSession?: (sessionId: string) => void; + onSshTerminalSessionTabDragStart?: ( + session: TerminalSession, + event: { pointerId: number; clientX: number; clientY: number }, + ) => void; }; function WorkspaceOverlayLoading(props: { className: string; label: string }) { @@ -121,6 +128,9 @@ export function WorkspaceOverlayHost(props: WorkspaceOverlayHostProps) { terminalSessions, onWorkspaceSshTerminalHide, onSshTerminalOpenFile, + sshTerminalPaneLeasedSessionIds, + onSshTerminalFocusLeasedSession, + onSshTerminalSessionTabDragStart, } = props; return ( @@ -185,6 +195,9 @@ export function WorkspaceOverlayHost(props: WorkspaceOverlayHostProps) { isOpen={workspaceSshTerminalOpen} onHide={onWorkspaceSshTerminalHide} onOpenSftpFile={onSshTerminalOpenFile} + paneLeasedSessionIds={sshTerminalPaneLeasedSessionIds} + onFocusLeasedSession={onSshTerminalFocusLeasedSession} + onSessionTabDragStart={onSshTerminalSessionTabDragStart} /> ) : null} diff --git a/crates/agent-ui/src/components/workspace-editor/WorkspaceSshTerminalOverlay.tsx b/crates/agent-ui/src/components/workspace-editor/WorkspaceSshTerminalOverlay.tsx index 38477c6a0..d0428c829 100644 --- a/crates/agent-ui/src/components/workspace-editor/WorkspaceSshTerminalOverlay.tsx +++ b/crates/agent-ui/src/components/workspace-editor/WorkspaceSshTerminalOverlay.tsx @@ -13,6 +13,10 @@ import { XTermViewport } from "@liveagent/ui/components/project-tools/XTermViewp import { useLocale } from "@liveagent/ui/i18n/index"; import type { SftpClient } from "@liveagent/ui/lib/sftp/types"; import { cn } from "@liveagent/ui/lib/shared/utils"; +import { + sshSessionEndpointLabel, + sshSessionStatus, +} from "@liveagent/ui/lib/terminal/sshSessionStatus"; import type { SshTerminalTab, SshTerminalTabKind, @@ -46,25 +50,32 @@ type WorkspaceSshTerminalOverlayProps = { isOpen: boolean; onHide: () => void; onOpenSftpFile?: (session: TerminalSession, request: SftpOpenFileRequest) => void; + /** + * 被工作台 Pane 租用的会话:shell 视口与 Pane 互斥(输出流单消费), + * overlay 内显示"已在画板中打开"占位。SFTP tab 不受影响——SFTP 走独立 + * 通道,不与 XTermViewport 争夺输出流。 + */ + paneLeasedSessionIds?: ReadonlySet; + /** 点击占位跳转聚焦画板中的 Pane;省略时只显示占位文案。 */ + onFocusLeasedSession?: (sessionId: string) => void; + /** + * 存在时 shell tab 可拖出到工作台画板(SFTP tab 不可拖)。pointerdown 上报, + * 激活阈值与点击抑制由工作台拖拽会话统一处理,tab 点击激活不受影响。 + */ + onSessionTabDragStart?: ( + session: TerminalSession, + event: { pointerId: number; clientX: number; clientY: number }, + ) => void; }; const SSH_TERMINAL_OVERLAY_ANIMATION_MS = 180; -function sshSessionStatus(session: TerminalSession) { - const status = session.ssh?.status ?? (session.running ? "connected" : "disconnected"); - if (status === "connected" && !session.running) return "disconnected"; - return status; -} - function sessionTitle(session: TerminalSession, fallback: string) { return session.title || session.ssh?.hostName || fallback; } function sessionEndpointLabel(session: TerminalSession) { - const ssh = session.ssh; - if (!ssh) return session.cwd || session.projectPathKey; - const userPrefix = ssh.username.trim() ? `${ssh.username.trim()}@` : ""; - return `${userPrefix}${ssh.host}:${ssh.port}`; + return sshSessionEndpointLabel(session); } function statusDotClassName(session: TerminalSession) { @@ -89,6 +100,9 @@ export function WorkspaceSshTerminalOverlay(props: WorkspaceSshTerminalOverlayPr isOpen, onHide, onOpenSftpFile, + paneLeasedSessionIds, + onFocusLeasedSession, + onSessionTabDragStart, } = props; const { t } = useLocale(); const [isVisible, setIsVisible] = useState(isOpen); @@ -385,6 +399,19 @@ export function WorkspaceSshTerminalOverlay(props: WorkspaceSshTerminalOverlayPr title={sessionEndpointLabel(session)} aria-label={sessionEndpointLabel(session)} onClick={() => activateTab(tab.id)} + onPointerDown={ + onSessionTabDragStart && tab.kind !== "sftp" + ? (event) => { + // 触控仍用于滚动 tab 条;拖出仅响应鼠标/笔主键。 + if (event.button !== 0 || event.pointerType === "touch") return; + onSessionTabDragStart(session, { + pointerId: event.pointerId, + clientX: event.clientX, + clientY: event.clientY, + }); + } + : undefined + } > + ) : paneLeasedSessionIds?.has(session.id) ? ( +
+
+ +
+
{t("workspaceSshTerminal.openedInWorkbench")}
+ {onFocusLeasedSession ? ( + + ) : null} +
) : ( Date: Mon, 17 Aug 2026 07:07:39 +0800 Subject: [PATCH 23/76] =?UTF-8?q?feat(ui):=20=E5=8F=B3=E4=BE=A7=20dock=20?= =?UTF-8?q?=E7=A9=BA=E6=80=81"=E6=96=B0=E5=BB=BA=E7=BB=88=E7=AB=AF"?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=8B=96=E5=87=BA=E5=88=B0=E7=94=BB=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 按钮 pointerdown 上报拖出意图(仅鼠标/笔主键),点击行为不变 - 回调缺省时 dock 行为与拖拽入口完全不变 --- .../project-tools/RightDockContent.tsx | 24 ++++++++++++++++++- .../project-tools/RightDockPanel.tsx | 4 ++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/agent-ui/src/components/project-tools/RightDockContent.tsx b/crates/agent-ui/src/components/project-tools/RightDockContent.tsx index 215329ded..fa6ad45ea 100644 --- a/crates/agent-ui/src/components/project-tools/RightDockContent.tsx +++ b/crates/agent-ui/src/components/project-tools/RightDockContent.tsx @@ -22,6 +22,11 @@ type RightDockContentProps = { onTerminalError: (sessionId: string, message: string | null) => void; onInitialTerminalSnapshotConsumed: (sessionId: string) => void; onCreateTerminal: () => void; + /** + * 存在时"新建终端"按钮可拖出到工作台画板(拖到落点新建终端 Pane); + * 点击行为不变(新建并进 dock)。拖拽阈值与点击抑制由工作台拖拽会话处理。 + */ + onNewTerminalDragStart?: (event: { pointerId: number; clientX: number; clientY: number }) => void; }; export function RightDockContent(props: RightDockContentProps) { @@ -37,6 +42,7 @@ export function RightDockContent(props: RightDockContentProps) { onTerminalError, onInitialTerminalSnapshotConsumed, onCreateTerminal, + onNewTerminalDragStart, } = props; const { t } = useLocale(); const context = useRightDockToolContext(); @@ -126,7 +132,23 @@ export function RightDockContent(props: RightDockContentProps) {
)}
- {loading ? ( diff --git a/crates/agent-ui/src/components/project-tools/RightDockPanel.tsx b/crates/agent-ui/src/components/project-tools/RightDockPanel.tsx index fd156ae0c..e03a299ad 100644 --- a/crates/agent-ui/src/components/project-tools/RightDockPanel.tsx +++ b/crates/agent-ui/src/components/project-tools/RightDockPanel.tsx @@ -96,6 +96,8 @@ type RightDockPanelProps = { session: TerminalSession, event: { pointerId: number; clientX: number; clientY: number }, ) => void; + /** 存在时空态"新建终端"按钮可拖出到工作台画板;点击行为不变。 */ + onNewTerminalDragStart?: (event: { pointerId: number; clientX: number; clientY: number }) => void; onInsertFileMention?: (path: string, kind: "file" | "dir") => void; onOpenFile?: (path: string, imagePaths?: string[]) => void; onInsertCodeReviewSkill?: () => void; @@ -378,6 +380,7 @@ export const RightDockPanel = memo(function RightDockPanel(props: RightDockPanel onOpenSshSession, onSessionsChange, onTerminalTabDragStart, + onNewTerminalDragStart, onInsertFileMention, onOpenFile, onInsertCodeReviewSkill, @@ -935,6 +938,7 @@ export const RightDockPanel = memo(function RightDockPanel(props: RightDockPanel onTerminalError={handleTerminalError} onInitialTerminalSnapshotConsumed={handleInitialTerminalSnapshotConsumed} onCreateTerminal={handleCreate} + onNewTerminalDragStart={onNewTerminalDragStart} /> )} From 4d2c0539cb430f3a12640b55e92388ee1bcbaec6 Mon Sep 17 00:00:00 2001 From: yovinchen Date: Mon, 17 Aug 2026 07:24:58 +0800 Subject: [PATCH 24/76] =?UTF-8?q?feat(chat):=20TerminalPaneHost=20?= =?UTF-8?q?=E6=8E=A5=E5=85=A5=20SSH=20surface=20=E4=B8=8E=20kill/=E9=87=8D?= =?UTF-8?q?=E8=BF=9E=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sshTerminal surface 渲染 SshTerminalPaneSurface,本地继续走 Local - 显式 kill:结束进程→回收绑定→页面收尾关 Pane,失败也按 kill 语义收尾 - SSH 重连注入宿主调用,"already in progress"视为自动重连已接管不报错 --- .../pages/chat/surfaces/TerminalPaneHost.tsx | 77 +++++++++++++++---- 1 file changed, 63 insertions(+), 14 deletions(-) diff --git a/crates/agent-gui/src/pages/chat/surfaces/TerminalPaneHost.tsx b/crates/agent-gui/src/pages/chat/surfaces/TerminalPaneHost.tsx index 7ce918719..2889b7c53 100644 --- a/crates/agent-gui/src/pages/chat/surfaces/TerminalPaneHost.tsx +++ b/crates/agent-gui/src/pages/chat/surfaces/TerminalPaneHost.tsx @@ -1,5 +1,6 @@ import { LocalTerminalPaneSurface, + SshTerminalPaneSurface, type TerminalPaneSurfacePhase, } from "@liveagent/ui/components/workbench/index"; import { useLocale } from "@liveagent/ui/i18n/index"; @@ -22,6 +23,8 @@ export type TerminalPaneHostProps = { /** 全窗口会话列表(未按项目过滤):Pane 可承载任意项目的终端。 */ sessions: readonly TerminalSession[]; sessionsLoaded: boolean; + /** 显式 kill(结束进程)后的布局收尾:由页面注入 closePane。 */ + onSessionKilled?: () => void; }; type TerminalPaneErrorState = @@ -37,7 +40,7 @@ type TerminalPaneErrorState = * 视图租约,保证输出流单消费、输入单写。 */ export function TerminalPaneHost(props: TerminalPaneHostProps) { - const { paneId, surface, isFocused, theme, sessions, sessionsLoaded } = props; + const { paneId, surface, isFocused, theme, sessions, sessionsLoaded, onSessionKilled } = props; const { t } = useLocale(); const boundSessionId = useSyncExternalStore(terminalPaneBindings.subscribe, () => @@ -114,6 +117,42 @@ export function TerminalPaneHost(props: TerminalPaneHostProps) { setViewportError(message); }, []); + // 显式 kill:结束进程 → 回收绑定(租约随 Pane 关闭卸载释放)→ 页面收尾关 Pane。 + // 与 Detach(Pane 关闭,进程保留回 dock)语义分离。 + const [killPending, setKillPending] = useState(false); + const killSession = useCallback(() => { + const targetSessionId = terminalPaneBindings.get(surface.surfaceId); + if (!targetSessionId || killPending) return; + setKillPending(true); + void tauriTerminalClient + .close(targetSessionId) + .catch(() => { + // 进程可能已自行退出;绑定与 Pane 仍按 kill 语义收尾。 + }) + .finally(() => { + terminalPaneBindings.delete(surface.surfaceId); + setKillPending(false); + onSessionKilled?.(); + }); + }, [killPending, onSessionKilled, surface.surfaceId]); + + // SSH 重连:错误按提示条展示;"already in progress" 表示自动重连循环已接管。 + const [reconnectPending, setReconnectPending] = useState(false); + const reconnectSsh = useCallback(() => { + const targetSessionId = terminalPaneBindings.get(surface.surfaceId); + if (!targetSessionId || reconnectPending) return; + setReconnectPending(true); + void tauriTerminalClient + .sshReconnect(targetSessionId) + .catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + if (!message.includes("already in progress")) { + setViewportError(message); + } + }) + .finally(() => setReconnectPending(false)); + }, [reconnectPending, surface.surfaceId]); + const restartFromLaunchSpec = useCallback(() => { const staleSessionId = terminalPaneBindings.get(surface.surfaceId); if (staleSessionId) { @@ -161,17 +200,27 @@ export function TerminalPaneHost(props: TerminalPaneHostProps) { onRetry = undefined; } - return ( - - ); + const killAvailable = Boolean(session && boundSessionId); + const commonProps = { + paneId, + client: tauriTerminalClient, + session: renderSession, + phase, + theme, + isActive: isFocused, + errorMessage, + onRetry, + onError: handleViewportError, + onKillSession: killAvailable && !killPending ? killSession : undefined, + }; + if (surface.kind === "sshTerminal") { + return ( + + ); + } + return ; } From b0bb89f4b3637433bd40813beea2cb1ef1bcadff Mon Sep 17 00:00:00 2001 From: yovinchen Date: Mon, 17 Aug 2026 07:52:16 +0800 Subject: [PATCH 25/76] =?UTF-8?q?feat(chat):=20ChatPage=20=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E7=BB=88=E7=AB=AF=E6=8B=96=E5=87=BA=E5=85=A5=E5=8F=A3?= =?UTF-8?q?=E3=80=81pane=20=E5=BF=AB=E6=8D=B7=E9=94=AE=E4=B8=8E=E5=8C=BA?= =?UTF-8?q?=E5=9F=9F=E6=A0=87=E7=AD=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SSH overlay shell tab 与空态"新建终端"接入工作台拖拽通路, 租约会话提供"前往 Pane"聚焦 - 快捷键统一到 Meta/Ctrl+Alt:Shift+方向移动 pane、W 关闭、=/+ 等分 父级 split;少于两个 pane 时全部短路 - pane 区域标签按 surface 细分(会话标题+工作区/终端/不支持), 屏幕阅读器可区分各 pane - unsupported 占位改用 UnsupportedPaneSurface 组件 --- crates/agent-gui/src/pages/ChatPage.tsx | 162 ++++++++++++++++++++---- 1 file changed, 134 insertions(+), 28 deletions(-) diff --git a/crates/agent-gui/src/pages/ChatPage.tsx b/crates/agent-gui/src/pages/ChatPage.tsx index cbd9be776..cabd80b40 100644 --- a/crates/agent-gui/src/pages/ChatPage.tsx +++ b/crates/agent-gui/src/pages/ChatPage.tsx @@ -10,6 +10,7 @@ import { ProjectToolsPanelToggle } from "@liveagent/ui/components/project-tools/ import { RightDockPanel } from "@liveagent/ui/components/project-tools/RightDockPanel"; import { useConfirmDialog } from "@liveagent/ui/components/ui/confirm-dialog"; import { PaneChrome } from "@liveagent/ui/components/workbench/PaneChrome"; +import { UnsupportedPaneSurface } from "@liveagent/ui/components/workbench/surfaces/UnsupportedPaneSurface"; import { WorkbenchCanvas } from "@liveagent/ui/components/workbench/WorkbenchCanvas"; import { WorkbenchEmptyState } from "@liveagent/ui/components/workbench/WorkbenchEmptyState"; import { useWorkspaceOverlays } from "@liveagent/ui/components/workspace-editor/useWorkspaceOverlays"; @@ -43,6 +44,7 @@ import type { TerminalSession } from "@liveagent/ui/lib/terminal/types"; import type { LocalTunnelClient } from "@liveagent/ui/lib/tunnels/constants"; import { findAdjacentPaneId, + findParentSplitId, hitTestWorkbenchDrop, type WorkbenchDropTarget, type WorkbenchGeometry, @@ -2116,8 +2118,8 @@ export function ChatPage(props: ChatPageProps) { [beginWorkbenchDrag, workbenchProjectForConversation], ); - // Right Dock 终端 tab 拖出:既有会话进入画板。dock 的 tab 只列本地会话, - // SSH 会话的宿主是 workspace overlay,首期不提供拖出入口。 + // Right Dock 终端 tab 拖出:既有会话进入画板。dock 的 tab 只列本地会话; + // SSH 会话从 workspace overlay 的 shell tab 拖出(handleSshTerminalTabDragIntent)。 const handleTerminalTabWorkbenchDragIntent = useCallback( (session: TerminalSession, event: { pointerId: number; clientX: number; clientY: number }) => { const projectPathKey = session.projectPathKey || workspaceProjectPathKey(session.cwd); @@ -2140,6 +2142,44 @@ export function ChatPage(props: ChatPageProps) { [beginWorkbenchDrag, workspaceProjects], ); + // SSH overlay 的 shell tab 拖出与 dock tab 同一 payload 通路;drop 时由 + // terminalSurfaceForSession 依 session.ssh.hostId 构造 sshTerminal surface, + // 租约建立后 overlay 自动显示"已在画板中打开"占位。 + const handleSshTerminalTabDragIntent = handleTerminalTabWorkbenchDragIntent; + + // 空态"新建终端"按钮拖出:落点新建终端 Pane(几何先行,PTY 由宿主异步建)。 + const handleNewTerminalWorkbenchDragIntent = useCallback( + (event: { pointerId: number; clientX: number; clientY: number }) => { + if (!terminalProjectPath) return; + const project = workspaceProjects.find( + (entry) => workspaceProjectPathKey(entry.path) === terminalProjectPathKey, + ); + beginWorkbenchDrag( + { + kind: "newTerminal", + project: { + projectId: project?.id ?? `project:${terminalProjectPathKey}`, + projectPathKey: terminalProjectPathKey, + }, + title: t("projectTools.newTerminal"), + }, + event, + ); + }, + [beginWorkbenchDrag, t, terminalProjectPath, terminalProjectPathKey, workspaceProjects], + ); + + // 画板 Pane 持有租约的会话:overlay/占位的"前往 Pane"聚焦通路。 + const focusWorkbenchTerminalPane = useCallback( + (sessionId: string) => { + const paneId = terminalPaneLease.paneIdFor(sessionId); + if (paneId && workbench.layoutRef.current.panes[paneId]) { + handleWorkbenchFocusPane(paneId); + } + }, + [handleWorkbenchFocusPane, workbench], + ); + const handleProjectWorkbenchDragIntent = useCallback( (project: WorkspaceProject, event: { pointerId: number; clientX: number; clientY: number }) => { beginWorkbenchDrag( @@ -2332,11 +2372,18 @@ export function ChatPage(props: ChatPageProps) { workbench, ]); - // Keyboard equivalents for pane focus moves: Meta/Ctrl+Alt+Arrow. + // Keyboard equivalents for workbench pane commands, all on Meta/Ctrl+Alt: + // Arrow focuses the adjacent pane, Shift+Arrow moves the focused pane there, + // W closes it, and =/+ equalizes its parent split. Every command needs at + // least two panes; with one pane the workbench has nothing to navigate. useEffect(() => { if (!sessionWorkbench.enabled) return; const handleKeyDown = (event: KeyboardEvent) => { if (event.isComposing || !event.altKey || !(event.metaKey || event.ctrlKey)) return; + const layout = workbench.layoutRef.current; + const focusedPaneId = layout.focusedPaneId; + if (!focusedPaneId || Object.keys(layout.panes).length < 2) return; + const direction = event.key === "ArrowLeft" ? ("left" as const) @@ -2347,18 +2394,42 @@ export function ChatPage(props: ChatPageProps) { : event.key === "ArrowDown" ? ("bottom" as const) : null; - if (!direction) return; - const layout = workbench.layoutRef.current; - const geometry = workbenchGeometryRef.current; - if (!layout.focusedPaneId || !geometry) return; - const nextPaneId = findAdjacentPaneId(geometry, layout.focusedPaneId, direction); - if (!nextPaneId) return; - event.preventDefault(); - handleWorkbenchFocusPane(nextPaneId); + if (direction) { + const geometry = workbenchGeometryRef.current; + if (!geometry) return; + const nextPaneId = findAdjacentPaneId(geometry, focusedPaneId, direction); + if (!nextPaneId) return; + event.preventDefault(); + // Shift grafts the focused pane onto the neighbour's far edge, so the + // pane ends up exactly where a plain focus move would have gone. + if (event.shiftKey) { + workbench.movePane(focusedPaneId, { + kind: "pane-edge", + paneId: nextPaneId, + edge: direction, + }); + return; + } + handleWorkbenchFocusPane(nextPaneId); + return; + } + + if (event.shiftKey) return; + if (event.key === "w" || event.key === "W") { + event.preventDefault(); + handleWorkbenchClosePane(focusedPaneId); + return; + } + if (event.key === "=" || event.key === "+") { + const splitId = findParentSplitId(layout, focusedPaneId); + if (!splitId) return; + event.preventDefault(); + workbench.equalizeSplit(splitId); + } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [handleWorkbenchFocusPane, workbench]); + }, [handleWorkbenchClosePane, handleWorkbenchFocusPane, workbench]); // Background pane controllers (conversations visible in unfocused panes). const backgroundControllersRef = useRef(new Map()); @@ -2555,20 +2626,50 @@ export function ChatPage(props: ChatPageProps) { const conversationPaneHostEnvironment = createConversationPaneHostEnvironment(workbenchRegistrations); + // Human-readable pane title, shared by the chrome tooltip/drag payload and + // the pane's accessible region label. + const workbenchPaneTitle = (surface: PaneRecord["surface"]): string => { + switch (surface.kind) { + case "conversation": + return sidebarConversationsById.get(surface.conversationId)?.title?.trim() || ""; + case "localTerminal": + return surface.launchSpec.title?.trim() || surface.launchSpec.shell?.trim() || "Terminal"; + case "sshTerminal": + return surface.launchSpec.title?.trim() || surface.launchSpec.sshHostId.trim() || "SSH"; + case "unsupported": + return surface.originalKind; + } + }; + + // Per-pane region label: screen readers must be able to tell panes apart, so + // terminals never read as "Conversation pane" and conversations carry their + // title (plus the workspace name when the pane resolves to a known project). + const workbenchPaneRegionLabel = (pane: PaneRecord): string => { + const surface = pane.surface; + if (surface.kind === "unsupported") return t("workbench.paneRegionUnsupported"); + const title = workbenchPaneTitle(surface); + if (surface.kind === "localTerminal" || surface.kind === "sshTerminal") { + return t("workbench.paneRegionTerminal").replace("{title}", title); + } + if (!title) return t("workbench.paneRegion"); + const workspaceName = workspaceProjects + .find((entry) => workspaceProjectPathKey(entry.path) === surface.project.projectPathKey) + ?.name.trim(); + if (!workspaceName) { + return t("workbench.paneRegionConversation").replace("{title}", title); + } + return t("workbench.paneRegionConversationInWorkspace") + .replace("{title}", title) + .replace("{workspace}", workspaceName); + }; + const renderWorkbenchPaneChrome = ( pane: PaneRecord, context: { isFocused: boolean; paneCount: number }, ) => { if (context.paneCount < 2) return null; const surface = pane.surface; - const title = - surface.kind === "conversation" - ? sidebarConversationsById.get(surface.conversationId)?.title?.trim() || "" - : surface.kind === "localTerminal" - ? surface.launchSpec.title?.trim() || surface.launchSpec.shell?.trim() || "Terminal" - : surface.kind === "sshTerminal" - ? surface.launchSpec.title?.trim() || surface.launchSpec.sshHostId.trim() || "SSH" - : surface.originalKind; + const title = workbenchPaneTitle(surface); return ( t("workbench.paneRegion"), + paneRegion: (pane) => workbenchPaneRegionLabel(pane), separator: t("workbench.resizeDivider"), }} renderPaneContent={(pane, paneContext) => { @@ -2606,18 +2707,13 @@ export function ChatPage(props: ChatPageProps) { theme={effectiveTheme} sessions={terminalSessions} sessionsLoaded={terminalSessionsLoaded} + onSessionKilled={() => handleWorkbenchClosePane(pane.paneId)} /> ); } if (surface.kind === "unsupported") { return ( -
-

{t("workbench.unsupportedPane")}

-

{surface.originalKind}

-
+ ); } const conversationId = surface.conversationId; @@ -2922,6 +3018,13 @@ export function ChatPage(props: ChatPageProps) { workspaceOverlays.setWorkspaceSshTerminalOpen(false) } onSshTerminalOpenFile={workspaceOverlays.handleOpenSftpFile} + sshTerminalPaneLeasedSessionIds={hiddenDockSessionIds} + onSshTerminalFocusLeasedSession={ + sessionWorkbench.enabled ? focusWorkbenchTerminalPane : undefined + } + onSshTerminalSessionTabDragStart={ + sessionWorkbench.enabled ? handleSshTerminalTabDragIntent : undefined + } /> } /> @@ -2960,6 +3063,9 @@ export function ChatPage(props: ChatPageProps) { onTerminalTabDragStart={ sessionWorkbench.enabled ? handleTerminalTabWorkbenchDragIntent : undefined } + onNewTerminalDragStart={ + sessionWorkbench.enabled ? handleNewTerminalWorkbenchDragIntent : undefined + } onInsertFileMention={handleRightDockInsertFileMention} onOpenFile={handleOpenWorkspaceFile} gitReviewFocusRequest={gitReviewFocusRequest} From 78d72cfcbe6a7240896c8f5007ff5ccef0120803 Mon Sep 17 00:00:00 2001 From: yovinchen Date: Mon, 17 Aug 2026 08:11:43 +0800 Subject: [PATCH 26/76] =?UTF-8?q?feat(settings):=20=E6=8D=9F=E5=9D=8F=20wo?= =?UTF-8?q?rkbench=20=E5=B8=83=E5=B1=80=E6=97=B6=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E6=B8=85=E7=90=86=20SQLite=20=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 workbench_layout_delete 命令并注册 - 前端 clearCorrupted 在原生环境下同时删除 SQLite 行, 避免下次启动重新加载同一份损坏 payload --- .../config/settings/workbench_layout.rs | 22 +++++++++++++++++++ crates/agent-gui/src-tauri/src/lib.rs | 1 + .../pages/chat/workbench/layoutPersistence.ts | 8 +++++++ 3 files changed, 31 insertions(+) diff --git a/crates/agent-gui/src-tauri/src/commands/config/settings/workbench_layout.rs b/crates/agent-gui/src-tauri/src/commands/config/settings/workbench_layout.rs index 179d642ea..3d2da7229 100644 --- a/crates/agent-gui/src-tauri/src/commands/config/settings/workbench_layout.rs +++ b/crates/agent-gui/src-tauri/src/commands/config/settings/workbench_layout.rs @@ -64,6 +64,18 @@ fn save_workbench_layout( Ok(()) } +fn delete_workbench_layout(conn: &Connection, scope_id: &str) -> Result<(), String> { + if scope_id.trim().is_empty() { + return Err("工作台布局 scope_id 不能为空".to_string()); + } + conn.execute( + "DELETE FROM workbench_layout WHERE scope_id = ?1", + params![scope_id], + ) + .map_err(|e| format!("删除工作台布局失败:{e}"))?; + Ok(()) +} + #[tauri::command] pub async fn workbench_layout_load( scope_id: String, @@ -90,3 +102,13 @@ pub async fn workbench_layout_save( .await .map_err(|e| format!("workbench_layout_save join 失败:{e}"))? } + +#[tauri::command] +pub async fn workbench_layout_delete(scope_id: String) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + let conn = open_db()?; + delete_workbench_layout(&conn, &scope_id) + }) + .await + .map_err(|e| format!("workbench_layout_delete join 失败:{e}"))? +} diff --git a/crates/agent-gui/src-tauri/src/lib.rs b/crates/agent-gui/src-tauri/src/lib.rs index 3bdfc45a7..50e4f5e81 100644 --- a/crates/agent-gui/src-tauri/src/lib.rs +++ b/crates/agent-gui/src-tauri/src/lib.rs @@ -142,6 +142,7 @@ macro_rules! app_invoke_handler { commands::settings::settings_save_model_failover, commands::settings::workbench_layout_load, commands::settings::workbench_layout_save, + commands::settings::workbench_layout_delete, commands::update::app_update_check, commands::update::app_update_install, commands::update::app_restart, diff --git a/crates/agent-gui/src/pages/chat/workbench/layoutPersistence.ts b/crates/agent-gui/src/pages/chat/workbench/layoutPersistence.ts index 7bc65d26d..f4dfb6215 100644 --- a/crates/agent-gui/src/pages/chat/workbench/layoutPersistence.ts +++ b/crates/agent-gui/src/pages/chat/workbench/layoutPersistence.ts @@ -81,6 +81,14 @@ export function createWorkbenchLayoutPersistence(): WorkbenchLayoutPersistence { } catch { // Diagnostics only. } + if (!native) return; + // Drop the SQLite row too, otherwise the next launch reloads the same + // corrupted payload. + void invoke("workbench_layout_delete", { + scopeId: WORKBENCH_LAYOUT_SCOPE_ID, + }).catch((error) => { + console.warn("failed to drop corrupted workbench layout", error); + }); }, }; } From 1146c048d4fac704b192527bb7513a5916b2af33 Mon Sep 17 00:00:00 2001 From: yovinchen Date: Mon, 17 Aug 2026 08:26:54 +0800 Subject: [PATCH 27/76] =?UTF-8?q?fix(ui):=20=E5=88=86=E9=9A=94=E6=9D=A1?= =?UTF-8?q?=E4=B8=8E=E6=8B=96=E6=8B=BD=E6=89=8B=E6=9F=84=E9=80=82=E9=85=8D?= =?UTF-8?q?=20forced-colors=20=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 强制对比色下主题背景被丢弃,改用 CanvasText/Highlight 系统色保证可见 - 分隔条过渡动画尊重 motion-reduce --- crates/agent-ui/src/components/workbench/DividerLayer.tsx | 6 +++++- crates/agent-ui/src/components/workbench/PaneChrome.tsx | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/agent-ui/src/components/workbench/DividerLayer.tsx b/crates/agent-ui/src/components/workbench/DividerLayer.tsx index 422e32497..ecf2da9e0 100644 --- a/crates/agent-ui/src/components/workbench/DividerLayer.tsx +++ b/crates/agent-ui/src/components/workbench/DividerLayer.tsx @@ -170,8 +170,12 @@ export function DividerLayer(props: DividerLayerProps) {