Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -85,6 +93,7 @@ export function ChatPanelProvider({ children }: ChatPanelProviderProps) {
);
const [activeTab, setActiveTab] = useState<ChatPaneId | null>(null);
const [isMaximized, setIsMaximized] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);

const registerPane = useCallback((descriptor: ChatPaneDescriptor) => {
setRegistry((prev) => {
Expand Down Expand Up @@ -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
Expand All @@ -132,6 +148,7 @@ export function ChatPanelProvider({ children }: ChatPanelProviderProps) {
if (prevThreadIdRef.current !== threadId) {
setActiveTab(null);
setIsMaximized(false);
setIsFullscreen(false);
prevThreadIdRef.current = threadId;
}
}, [threadId]);
Expand All @@ -144,14 +161,15 @@ 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 =
activeTab !== null && visiblePanes.some((d) => d.id === activeTab);
if (!stillVisible) {
setActiveTab(visiblePanes[0].id);
}
}, [visiblePanes, activeTab, isMaximized]);
}, [visiblePanes, activeTab, isMaximized, isFullscreen]);

const value = useMemo<ChatPanelContextType>(
() => ({
Expand All @@ -160,8 +178,10 @@ export function ChatPanelProvider({ children }: ChatPanelProviderProps) {
visiblePanes,
activeTab,
isMaximized,
isFullscreen,
openPane,
minimize,
toggleFullscreen,
reset,
}),
[
Expand All @@ -170,8 +190,10 @@ export function ChatPanelProvider({ children }: ChatPanelProviderProps) {
visiblePanes,
activeTab,
isMaximized,
isFullscreen,
openPane,
minimize,
toggleFullscreen,
reset,
],
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<HTMLDivElement>(null);
const { width, minWidth, maxWidth, handleMouseDown, handleKeyDown } =
Expand All @@ -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
Expand Down Expand Up @@ -98,19 +119,23 @@ export function ChatPanel() {
<div
className={cn(
'border-border bg-background relative flex h-full shrink-0 flex-col border-l',
// On mobile the panel is a full-screen fixed overlay above the chat so
// the tabs + body are usable on a narrow viewport; on desktop it's a
// fixed-width docked column in the flex row that the user can resize.
// The panel is a full-screen fixed overlay above the chat when it has no
// docked column to live in — always on mobile (narrow viewport), and on
// desktop when the user toggles fullscreen to read a cramped document.
// Fixed positioning (not a Radix Dialog) keeps every body mounted, so
// the Live Browser's RFB socket survives minimize/maximize on mobile.
isMobile && 'fixed inset-0 z-40 w-full border-l-0',
// the Live Browser's RFB socket survives minimize/maximize/fullscreen.
// z-50 matches the app's top-layer tier so the overlay also covers the
// `sticky z-50` composer; it renders after the chat column in the DOM,
// and Radix-portaled tooltips/dialogs (also z-50, but later in the body)
// still sit above it.
isOverlay && 'fixed inset-0 z-50 w-full border-l-0',
)}
style={isMobile ? undefined : { width }}
style={isOverlay ? undefined : { width }}
role="complementary"
aria-label={t('chatPanel.ariaLabel', { defaultValue: 'Chat panel' })}
>
{/* Drag-to-resize handle — desktop only (mobile is full-width). */}
{!isMobile && (
{/* Drag-to-resize handle — only when docked (overlay is full-width). */}
{!isOverlay && (
<div
ref={panelRef}
role="separator"
Expand Down Expand Up @@ -170,6 +195,44 @@ export function ChatPanel() {
</div>
<div className="flex shrink-0 items-center gap-1">
{activeHeaderActions}
{/* Fullscreen toggle — desktop only; mobile is already full-screen. */}
{!isMobile && (
<Tooltip
content={
isFullscreen
? t('chatPanel.exitFullscreen', {
defaultValue: 'Exit full screen',
})
: t('chatPanel.fullscreen', {
defaultValue: 'Expand to full screen',
})
}
side="bottom"
>
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={toggleFullscreen}
aria-pressed={isFullscreen}
aria-label={
isFullscreen
? t('chatPanel.exitFullscreen', {
defaultValue: 'Exit full screen',
})
: t('chatPanel.fullscreen', {
defaultValue: 'Expand to full screen',
})
}
>
{isFullscreen ? (
<Minimize2 className="size-3.5" />
) : (
<Maximize2 className="size-3.5" />
)}
</Button>
</Tooltip>
)}
<Tooltip
content={t('chatPanel.minimize', {
defaultValue: 'Minimize chat panel',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <SessionStoppedState />;
}
Expand Down Expand Up @@ -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.
<WorkspaceFilesBody
key={`${threadId}:${refreshNonce}`}
key={threadId}
threadId={threadId}
showHidden={showHidden}
refreshNonce={refreshNonce}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,25 +16,34 @@ 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;
}

function useCanvasPreferencesState(): CanvasPreferences {
const [wrap, setWrap] = useState(false);
const [modeByPath, setModeByPath] = useState<Record<string, ViewMode>>({});
// 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<ViewMode | null>(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 }),
Expand Down
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -13,29 +13,63 @@ 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,
actions,
children,
}: CanvasViewerFrameProps) {
const { t } = useT('chat');
const cardRef = useRef<HTMLDivElement>(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 (
<div className="relative flex h-full min-h-0 flex-col">
{/* `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. */}
<div className="min-h-0 flex-1 overflow-hidden pb-14">{children}</div>
{/* Bottom gutter the height of the floating card so the last line of
content can scroll clear of it instead of hiding behind it. */}
<div
className="min-h-0 flex-1 overflow-hidden"
style={{ paddingBottom: cardHeight + CARD_BOTTOM_INSET + CARD_TOP_GAP }}
>
{children}
</div>

<div
className="border-border bg-background/95 absolute right-3 bottom-3 z-10 flex items-center gap-1 rounded-lg border p-1 shadow-md backdrop-blur"
ref={cardRef}
className="border-border bg-background/95 absolute right-3 bottom-3 z-10 flex max-w-[calc(100%-1.5rem)] flex-wrap items-center justify-end gap-1 rounded-lg border p-1 shadow-md backdrop-blur"
role="group"
aria-label={t('canvas.fileActionsAriaLabel', {
defaultValue: 'File actions',
Expand Down
4 changes: 3 additions & 1 deletion services/platform/messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -1778,7 +1778,9 @@
"stripAria": "Chat-Bereich",
"ariaLabel": "Chat-Bereich",
"minimize": "Chat-Bereich minimieren",
"resizeAria": "Chat-Bereich vergrößern"
"resizeAria": "Chat-Bereich vergrößern",
"fullscreen": "Auf Vollbild erweitern",
"exitFullscreen": "Vollbild beenden"
}
},
"common": {
Expand Down
Loading