From 4481975fee670d74d65f0182dcdb08aab50b1cb2 Mon Sep 17 00:00:00 2001 From: larryro <371767072@qq.com> Date: Thu, 25 Jun 2026 18:07:53 +0800 Subject: [PATCH 1/6] feat(platform): fullscreen toggle for the chat side panel Add an expand/collapse control to the chat panel header so a cramped docked panel can grow to a full-viewport overlay (reusing the mobile overlay) for reading documents or the live browser, with Escape to exit. Raise the overlay to z-50 so it covers the sticky composer instead of letting it bleed through. --- .../chat-panel/chat-panel-context.tsx | 26 +++++- .../chat/components/chat-panel/chat-panel.tsx | 87 ++++++++++++++++--- services/platform/messages/de.json | 4 +- services/platform/messages/en.json | 4 +- services/platform/messages/fr.json | 4 +- 5 files changed, 108 insertions(+), 17 deletions(-) diff --git a/services/platform/app/features/chat/components/chat-panel/chat-panel-context.tsx b/services/platform/app/features/chat/components/chat-panel/chat-panel-context.tsx index a014ad7415..bf75a011ec 100644 --- a/services/platform/app/features/chat/components/chat-panel/chat-panel-context.tsx +++ b/services/platform/app/features/chat/components/chat-panel/chat-panel-context.tsx @@ -29,10 +29,18 @@ interface ChatPanelContextType { activeTab: ChatPaneId | null; /** Maximized (full pane + tabs) vs minimized (the shared strip). */ isMaximized: boolean; + /** + * Whether the maximized pane is expanded to a full-viewport overlay (desktop + * only — mobile is already full-width). Lets a cramped docked panel grow to + * the whole window for reading documents / the live browser, then snap back. + */ + isFullscreen: boolean; /** Open a pane maximized and make it the active tab. */ openPane: (id: ChatPaneId) => void; /** Collapse to the strip without losing content (never a dead-end). */ minimize: () => void; + /** Toggle the full-viewport overlay on the maximized pane (desktop only). */ + toggleFullscreen: () => void; /** Clear shell state — used on new-chat / thread switch. */ reset: () => void; } @@ -85,6 +93,7 @@ export function ChatPanelProvider({ children }: ChatPanelProviderProps) { ); const [activeTab, setActiveTab] = useState(null); const [isMaximized, setIsMaximized] = useState(false); + const [isFullscreen, setIsFullscreen] = useState(false); const registerPane = useCallback((descriptor: ChatPaneDescriptor) => { setRegistry((prev) => { @@ -117,11 +126,18 @@ export function ChatPanelProvider({ children }: ChatPanelProviderProps) { setIsMaximized(true); }, []); - const minimize = useCallback(() => setIsMaximized(false), []); + // Collapsing to the strip also leaves fullscreen, so re-opening starts docked. + const minimize = useCallback(() => { + setIsMaximized(false); + setIsFullscreen(false); + }, []); + + const toggleFullscreen = useCallback(() => setIsFullscreen((v) => !v), []); const reset = useCallback(() => { setActiveTab(null); setIsMaximized(false); + setIsFullscreen(false); }, []); // Reset shell state on every thread switch (mirrors the per-thread reset in @@ -132,6 +148,7 @@ export function ChatPanelProvider({ children }: ChatPanelProviderProps) { if (prevThreadIdRef.current !== threadId) { setActiveTab(null); setIsMaximized(false); + setIsFullscreen(false); prevThreadIdRef.current = threadId; } }, [threadId]); @@ -144,6 +161,7 @@ export function ChatPanelProvider({ children }: ChatPanelProviderProps) { if (visiblePanes.length === 0) { if (activeTab !== null) setActiveTab(null); if (isMaximized) setIsMaximized(false); + if (isFullscreen) setIsFullscreen(false); return; } const stillVisible = @@ -151,7 +169,7 @@ export function ChatPanelProvider({ children }: ChatPanelProviderProps) { if (!stillVisible) { setActiveTab(visiblePanes[0].id); } - }, [visiblePanes, activeTab, isMaximized]); + }, [visiblePanes, activeTab, isMaximized, isFullscreen]); const value = useMemo( () => ({ @@ -160,8 +178,10 @@ export function ChatPanelProvider({ children }: ChatPanelProviderProps) { visiblePanes, activeTab, isMaximized, + isFullscreen, openPane, minimize, + toggleFullscreen, reset, }), [ @@ -170,8 +190,10 @@ export function ChatPanelProvider({ children }: ChatPanelProviderProps) { visiblePanes, activeTab, isMaximized, + isFullscreen, openPane, minimize, + toggleFullscreen, reset, ], ); diff --git a/services/platform/app/features/chat/components/chat-panel/chat-panel.tsx b/services/platform/app/features/chat/components/chat-panel/chat-panel.tsx index 65d7b87ad6..b0245a626f 100644 --- a/services/platform/app/features/chat/components/chat-panel/chat-panel.tsx +++ b/services/platform/app/features/chat/components/chat-panel/chat-panel.tsx @@ -1,8 +1,8 @@ 'use client'; import { Button } from '@tale/ui/button'; -import { PanelRightClose } from 'lucide-react'; -import { useRef, type KeyboardEvent } from 'react'; +import { Maximize2, Minimize2, PanelRightClose } from 'lucide-react'; +import { useEffect, useRef, type KeyboardEvent } from 'react'; import { Tooltip } from '@/app/components/ui/overlays/tooltip'; import { useIsMobile } from '@/app/hooks/use-is-mobile'; @@ -39,8 +39,15 @@ const bodyId = (id: ChatPaneId) => `chat-panel-body-${id}`; export function ChatPanel() { const { t } = useT('chat'); const isMobile = useIsMobile(); - const { visiblePanes, activeTab, isMaximized, openPane, minimize } = - useChatPanel(); + const { + visiblePanes, + activeTab, + isMaximized, + isFullscreen, + openPane, + minimize, + toggleFullscreen, + } = useChatPanel(); const panelRef = useRef(null); const { width, minWidth, maxWidth, handleMouseDown, handleKeyDown } = @@ -50,6 +57,20 @@ export function ChatPanel() { initialWidth: DEFAULT_WIDTH, }); + // Desktop fullscreen expands to the same full-viewport overlay mobile always + // uses (mobile has no docked state to grow from, so its toggle is hidden). + const isOverlay = isMobile || isFullscreen; + + // Escape leaves the desktop fullscreen overlay (back to the docked pane). + useEffect(() => { + if (!isFullscreen) return undefined; + const onKey = (e: globalThis.KeyboardEvent) => { + if (e.key === 'Escape') toggleFullscreen(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [isFullscreen, toggleFullscreen]); + if (visiblePanes.length === 0) return null; // The active descriptor governs the visible body + header actions. The @@ -98,19 +119,23 @@ export function ChatPanel() {
- {/* Drag-to-resize handle — desktop only (mobile is full-width). */} - {!isMobile && ( + {/* Drag-to-resize handle — only when docked (overlay is full-width). */} + {!isOverlay && (
{activeHeaderActions} + {/* Fullscreen toggle — desktop only; mobile is already full-screen. */} + {!isMobile && ( + + + + )} Date: Thu, 25 Jun 2026 18:07:56 +0800 Subject: [PATCH 2/6] fix(platform): keep the canvas file-action toolbar from clipping in the docked panel Cap the floating action card to the viewer width and let it wrap, so its buttons stack to a second row instead of overflowing a narrow docked column and being clipped. Size the content gutter from the card's measured height so the last line always scrolls clear of it (the fixed pb-14 cleared by only ~2px and tucked content behind a wrapped card). --- .../workspace/viewers/canvas-viewer-frame.tsx | 46 ++++++++++++++++--- 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/services/platform/app/features/workspace/viewers/canvas-viewer-frame.tsx b/services/platform/app/features/workspace/viewers/canvas-viewer-frame.tsx index 27259cf935..3410e71d42 100644 --- a/services/platform/app/features/workspace/viewers/canvas-viewer-frame.tsx +++ b/services/platform/app/features/workspace/viewers/canvas-viewer-frame.tsx @@ -1,6 +1,6 @@ 'use client'; -import { memo, type ReactNode } from 'react'; +import { memo, useEffect, useRef, useState, type ReactNode } from 'react'; import { useT } from '@/lib/i18n/client'; @@ -13,14 +13,29 @@ interface CanvasViewerFrameProps { children: ReactNode; } +// The card sits `bottom-3` (12px) above the frame's bottom edge; reserve a gap +// of one more `gap-3` (12px) between the card's top and the last line so they +// never touch. The content gutter = measured card height + these two insets. +const CARD_BOTTOM_INSET = 12; +const CARD_TOP_GAP = 12; +// Seed the gutter for the first paint (≈ a one-row card) so content doesn't +// briefly render under where the card will land before the measurement lands. +const INITIAL_CARD_HEIGHT = 44; + /** * Shared shell for every canvas file viewer: the content pane plus the floating, * bottom-right action card. Hoisted out of `renderable-file-viewer.tsx` so the * code/markdown/html/svg/mermaid/image viewers all present one identical control * surface — previously the code viewer used a full-width top strip while the * renderable viewer used this card. Viewers pass their own buttons as `actions`; - * the frame owns the position, the `pb-14` gutter that keeps the last line of + * the frame owns the position, the bottom gutter that keeps the last line of * content clear of the card, and the size label. + * + * The card is capped to the viewer width and wraps, so on a narrow (docked) + * viewer the buttons stack to a second row instead of overflowing the column and + * being clipped. Because that makes the card height variable, the content gutter + * is measured from the live card height (a fixed pad would be too short for a + * wrapped card and tuck the final line behind the toolbar). */ function CanvasViewerFrameComponent({ sizeLabel, @@ -28,14 +43,33 @@ function CanvasViewerFrameComponent({ children, }: CanvasViewerFrameProps) { const { t } = useT('chat'); + const cardRef = useRef(null); + const [cardHeight, setCardHeight] = useState(INITIAL_CARD_HEIGHT); + + useEffect(() => { + const el = cardRef.current; + if (!el) return undefined; + const measure = () => setCardHeight(el.offsetHeight); + measure(); + const observer = new ResizeObserver(measure); + observer.observe(el); + return () => observer.disconnect(); + }, []); + return (
- {/* `pb-14` reserves a gutter the height of the floating card so the last - line of content can scroll clear of it instead of hiding behind it. */} -
{children}
+ {/* Bottom gutter the height of the floating card so the last line of + content can scroll clear of it instead of hiding behind it. */} +
+ {children} +
Date: Thu, 25 Jun 2026 18:07:58 +0800 Subject: [PATCH 3/6] fix(platform): preserve expanded folders and open file when refreshing workspace files Key the files body by threadId alone instead of threadId+refreshNonce, so Refresh no longer remounts and collapses the tree to root. The tree already re-fetches its expanded dirs in place off the refreshNonce prop; a stopped session is re-probed via an effect that flips sessionRunning back on, without remounting the whole body. --- .../components/workspace-files-pane.tsx | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/services/platform/app/features/workspace/components/workspace-files-pane.tsx b/services/platform/app/features/workspace/components/workspace-files-pane.tsx index e6af735fde..8474d157d3 100644 --- a/services/platform/app/features/workspace/components/workspace-files-pane.tsx +++ b/services/platform/app/features/workspace/components/workspace-files-pane.tsx @@ -933,6 +933,16 @@ function WorkspaceFilesBody({ const handleSelectFile = useCallback((p: string) => setSelectedPath(p), []); + // An explicit Refresh re-probes a stopped session without remounting the body + // (a remount would collapse the tree's expanded folders and drop the open + // file — the very thing the user wants to keep). Flipping back to "running" + // re-mounts the tree, whose first load reports the real state and flips this + // back to stopped if the session is still down. When already running this is a + // no-op, and the tree refreshes its expanded dirs in place off `refreshNonce`. + useEffect(() => { + setSessionRunning(true); + }, [refreshNonce]); + if (!sessionRunning) { return ; } @@ -1065,14 +1075,16 @@ function WorkspaceFilesPaneComponent({ available }: WorkspaceFilesPaneProps) { hasContent: true, headerActions, body: ( - // Key by threadId + refreshNonce so the body remounts — resetting - // `selectedPath` and `sessionRunning` to their fresh defaults — both on - // a thread switch (else the previous thread's file shows) and on an - // explicit Refresh. The Refresh remount is what lets a STOPPED session - // recover: once `sessionRunning` is false the tree unmounts and can no - // longer report itself running, so only a remount re-checks it. + // Key by threadId alone so the body remounts on a thread switch — + // resetting `selectedPath` and `sessionRunning` to fresh defaults so the + // previous thread's file/state never bleeds in. Deliberately NOT keyed + // by `refreshNonce`: Refresh must preserve the open file and the tree's + // expanded folders. The tree re-fetches its expanded dirs in place off + // the `refreshNonce` prop, and a STOPPED session is re-probed by the + // effect in the body (which re-mounts the tree to re-check) — no remount + // of the whole body, so nothing collapses. Date: Thu, 25 Jun 2026 18:08:00 +0800 Subject: [PATCH 4/6] feat(platform): make the canvas Source/Preview choice sticky across files Track the last picked view mode and use it as the default for files the user hasn't explicitly toggled, so choosing Preview on one doc carries to the next (like the shared wrap toggle) while individual files still override. --- .../workspace/hooks/canvas-preferences.tsx | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/services/platform/app/features/workspace/hooks/canvas-preferences.tsx b/services/platform/app/features/workspace/hooks/canvas-preferences.tsx index 7b7d4a6725..72b8a9b452 100644 --- a/services/platform/app/features/workspace/hooks/canvas-preferences.tsx +++ b/services/platform/app/features/workspace/hooks/canvas-preferences.tsx @@ -16,7 +16,13 @@ interface CanvasPreferences { * every file, so flipping it on holds as you browse the rest of the files. */ wrap: boolean; toggleWrap: () => void; - /** The Source/Preview choice, remembered per file path. */ + /** + * The Source/Preview choice. A file the user has explicitly toggled keeps its + * own choice; any file they haven't touched defaults to the LAST mode they + * picked anywhere (then to `fallback` before any pick). So choosing Preview on + * one doc carries to the next doc you open — like `wrap` — while still letting + * individual files override. + */ getViewMode: (path: string, fallback: ViewMode) => ViewMode; setViewMode: (path: string, mode: ViewMode) => void; } @@ -24,17 +30,20 @@ interface CanvasPreferences { function useCanvasPreferencesState(): CanvasPreferences { const [wrap, setWrap] = useState(false); const [modeByPath, setModeByPath] = useState>({}); + // The last Source/Preview pick, used as the sticky default for files that + // don't have their own explicit choice yet. + const [lastMode, setLastMode] = useState(null); const toggleWrap = useCallback(() => setWrap((w) => !w), []); const getViewMode = useCallback( - (path: string, fallback: ViewMode) => modeByPath[path] ?? fallback, - [modeByPath], - ); - const setViewMode = useCallback( - (path: string, mode: ViewMode) => - setModeByPath((prev) => ({ ...prev, [path]: mode })), - [], + (path: string, fallback: ViewMode) => + modeByPath[path] ?? lastMode ?? fallback, + [modeByPath, lastMode], ); + const setViewMode = useCallback((path: string, mode: ViewMode) => { + setModeByPath((prev) => ({ ...prev, [path]: mode })); + setLastMode(mode); + }, []); return useMemo( () => ({ wrap, toggleWrap, getViewMode, setViewMode }), From 7b1e2d17b1bbc369320194e9d88a12b769f697a8 Mon Sep 17 00:00:00 2001 From: larryro <371767072@qq.com> Date: Thu, 25 Jun 2026 18:08:02 +0800 Subject: [PATCH 5/6] feat(sandbox): bake LaTeX/XeTeX toolchain for pandoc PDF output Add texlive-xetex/latex-recommended/fonts-recommended/lang-chinese + lmodern so 'pandoc --pdf-engine=xelatex --toc' produces publication-grade PDFs (real TOC, page numbers, headers/footers, title page) instead of the render-and-stitch fallback. lmodern is pulled explicitly because pandoc's default template needs lmodern.sty but --no-install-recommends drops it; lang-chinese provides xeCJK so the Chinese workspace docs typeset (Noto CJK fonts already baked). --- services/sandbox-runtime/Dockerfile | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/services/sandbox-runtime/Dockerfile b/services/sandbox-runtime/Dockerfile index 19c5ec813d..b1743b02ee 100644 --- a/services/sandbox-runtime/Dockerfile +++ b/services/sandbox-runtime/Dockerfile @@ -102,6 +102,35 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ pandoc \ && rm -rf /var/lib/apt/lists/* +# --- LaTeX (XeTeX) for publication-grade PDF ------------------------------- +# Gives `pandoc -o out.pdf --pdf-engine=xelatex` a real TeX engine, so PDFs get +# a genuine `--toc`, page numbers, running headers/footers, a title page and +# full font/margin control — instead of the brittle render-then-stitch route +# (LibreOffice/fitz) the agent otherwise falls back to. A separate layer from +# the Office/PDF block above: it's a large, self-contained concern that caches +# independently. Kept to the XeTeX subset pandoc's default template needs, NOT +# the multi-GB full `texlive` meta-package: +# texlive-xetex — the `xelatex` engine (Unicode/OpenType native). +# texlive-latex-recommended — the core package set the default template loads. +# texlive-fonts-recommended — Latin Modern / TeX Gyre fonts that template uses. +# texlive-lang-chinese — xeCJK / ctex so the Chinese workspace docs +# (e.g. *-zh-CN.md) typeset instead of dropping to tofu. The CJK *fonts* +# (Noto CJK) are already baked above for the live browser, so this adds +# only the macro packages; it also pulls texlive-latex-extra, handy for +# richer templates (fancyhdr, mdframed, …). +# lmodern — Latin Modern `lmodern.sty`. Pandoc's default +# LaTeX template does `\usepackage{lmodern}`, but it's only a *Recommends* +# of texlive-latex-recommended, so `--no-install-recommends` drops it and +# every `pandoc --pdf-engine=xelatex` then dies with "lmodern.sty not +# found". Pull it explicitly (the one Recommends we actually need). +RUN apt-get update && apt-get install -y --no-install-recommends \ + texlive-xetex \ + texlive-latex-recommended \ + texlive-fonts-recommended \ + texlive-lang-chinese \ + lmodern \ + && rm -rf /var/lib/apt/lists/* + # uv — fast Python package installer/resolver. See https://github.com/astral-sh/uv COPY --from=ghcr.io/astral-sh/uv:0.5 /uv /usr/local/bin/uv From 5c28be8f55e877a006e15079aa02fd9db10a7105 Mon Sep 17 00:00:00 2001 From: larryro <371767072@qq.com> Date: Thu, 25 Jun 2026 18:23:07 +0800 Subject: [PATCH 6/6] test(platform): raise sandbox-runtime image-size budget for the document toolchain The Validate-images gate was already failing on main: the document-conversion tools (libreoffice/poppler/pandoc) pushed sandbox-runtime to 3114 MB, over the 2800 MB budget. The new LaTeX/XeTeX layer adds ~660 MB more (~4.0 GB total), so raise the budget to 4400 MB (~10% headroom) and document the toolchain that justifies the size. --- .../tests/integration/container-image-test.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/services/platform/tests/integration/container-image-test.ts b/services/platform/tests/integration/container-image-test.ts index f8196d295a..2fdd64b617 100644 --- a/services/platform/tests/integration/container-image-test.ts +++ b/services/platform/tests/integration/container-image-test.ts @@ -45,10 +45,13 @@ const SIZE_BUDGETS: Record = { convex: 2500, sandbox: 320, 'sandbox-egress': 80, - // Grew past the prior 2100 budget with native docker/compose-in-session - // (runtime tiers, #1881) on top of the playwright/chromium toolchain. ~10% - // headroom over the current optimized size. - 'sandbox-runtime': 2800, + // Carries a heavy toolchain by design, on top of the playwright/chromium base + // and native docker/compose-in-session (runtime tiers, #1881): + // - document conversion: libreoffice + poppler + pandoc (~570 MB) + // - LaTeX/XeTeX for pandoc publication-grade PDF: texlive-xetex + + // latex-recommended + fonts-recommended + lang-chinese + lmodern (~660 MB) + // The amd64 image is ~4.0 GB as a result; ~10% headroom over that. + 'sandbox-runtime': 4400, }; const SERVICES = [