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
29 changes: 21 additions & 8 deletions apps/desktop/src/components/workpanel/WorkPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,10 @@ import { SubagentTranscriptTab } from "./SubagentTranscriptTab";
import {
MAIN_PANE_MIN_WIDTH,
WORK_PANEL_COMPACT_MIN_WIDTH,
WORK_PANEL_MIN_WIDTH,
clampWorkPanelWidth,
workPanelGestureMinimum,
workPanelLayout,
workPanelRequestedWidth,
workPanelResetWidth,
workPanelWidthBounds,
} from "../../lib/work-panel-resize";
Expand Down Expand Up @@ -211,6 +212,7 @@ function LocalWorkPanel({
const activeSessionId = useAppStore((s) => s.activeSessionId);
const pluginViews = useAppStore((s) => s.pluginViews);
const width = useAppStore((s) => s.workPanelWidth);
const yieldedWidth = useAppStore((s) => s.workPanelYieldedWidth);
const activateTab = useAppStore((s) => s.activateWorkPanelTab);
const reorderTabs = useAppStore((s) => s.reorderWorkPanelTabs);
const closeTab = useAppStore((s) => s.closeWorkPanelTab);
Expand Down Expand Up @@ -251,21 +253,27 @@ function LocalWorkPanel({
const [nativeSurfaceReadyForExit, setNativeSurfaceReadyForExit] =
useState(false);

const requestedPanelWidth = panelDragWidth ?? width;
const panelMinimum =
requestedPanelWidth < WORK_PANEL_MIN_WIDTH
? WORK_PANEL_COMPACT_MIN_WIDTH
: WORK_PANEL_MIN_WIDTH;
// The first render can precede ResizeObserver's first notification. Use a
// conservative shell estimate for that frame; the measured width takes over
// before a user can interact with the divider.
const sidebarOccupiesBudget = !sidebarCollapsed || sidebarExiting;
const budgetWidth =
containerWidth > 0
? containerWidth
: requestedPanelWidth +
: (panelDragWidth ?? width) +
(sidebarOccupiesBudget ? sidebarWidth : 0) +
MAIN_PANE_MIN_WIDTH;
// A drag preview is an explicit width; otherwise the panel asks for its
// preferred width, or the width it lent to a manually reopened sidebar.
const requestedPanelWidth =
panelDragWidth ??
workPanelRequestedWidth({
containerWidth: budgetWidth,
sidebarWidth,
sidebarCollapsed: !sidebarOccupiesBudget,
preferredWidth: width,
yieldedWidth,
});
const layout = workPanelLayout({
containerWidth: budgetWidth,
sidebarWidth,
Expand All @@ -274,6 +282,7 @@ function LocalWorkPanel({
maximized,
});
const renderPanelWidth = layout.panelWidth;
const panelMinimum = workPanelGestureMinimum(renderPanelWidth);
const isResizing = panelDragWidth !== null;

useLayoutEffect(() => {
Expand Down Expand Up @@ -731,7 +740,11 @@ function LocalWorkPanel({
else if (event.key === "End") nextWidth = maximum;
if (nextWidth === null) return;
event.preventDefault();
setWidth(clampWorkPanelWidth(nextWidth, minimum));
const committedWidth = clampWorkPanelWidth(nextWidth, minimum);
// A key that cannot move the separator commits nothing, so it neither
// rewrites the preferred width nor ends a width lent to the sidebar.
if (committedWidth === renderPanelWidth) return;
setWidth(committedWidth);
},
[finishPanelResize, layout.maxPanelWidth, maximized, panelMinimum, renderPanelWidth, setWidth],
);
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/features/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ export function AppShell() {
startupRetrying,
sidebarToggleShortcut,
workPanelToggleTooltip,
workPanelWidth,
requestedWorkPanelWidth,
} = useAppShellRuntime();
useCopyTex();

Expand All @@ -113,7 +113,7 @@ export function AppShell() {
containerWidth: shellWidth,
sidebarWidth,
sidebarCollapsed,
requestedPanelWidth: workPanelWidth,
requestedPanelWidth: requestedWorkPanelWidth,
maximized: workPanelMaximized,
}).panelWidth;

Expand Down
50 changes: 42 additions & 8 deletions apps/desktop/src/features/app/useAppShellRuntime.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { sidebarWidthBudget } from "../../lib/sidebar-resize";
import { commitWorkPanelPresentation } from "../../lib/work-panel-presentation";
import {
MAIN_PANE_MIN_WIDTH,
workPanelRequestedWidth,
workPanelWidthForSidebarReopen,
} from "../../lib/work-panel-resize";
import { browserPluginTab, browserPluginTabForReveal } from "../../lib/work-panel-tabs";
Expand Down Expand Up @@ -62,6 +63,7 @@ export function useAppShellRuntime() {
const settings = useAppStore((s) => s.settings);
const workPanelOpen = useAppStore((s) => s.workPanelOpen);
const workPanelWidth = useAppStore((s) => s.workPanelWidth);
const workPanelYieldedWidth = useAppStore((s) => s.workPanelYieldedWidth);
const pluginThemes = useAppStore((s) => s.pluginThemes);
const refreshPluginThemes = useAppStore((s) => s.refreshPluginThemes);
const plugins = useAppStore((s) => s.plugins);
Expand All @@ -87,13 +89,15 @@ export function useAppShellRuntime() {
const sidebarPreferredWidthRef = useRef(sidebarWidth);
const shellWidthRef = useRef(shellWidth);
const workPanelWidthRef = useRef(workPanelWidth);
const workPanelYieldedWidthRef = useRef(workPanelYieldedWidth);
const workPanelOpenRef = useRef(workPanelVisible);
const workPanelMaximizedRef = useRef(false);
const autoCollapsedSidebarRef = useRef(false);
sidebarCollapsedRef.current = sidebarCollapsed;
sidebarWidthRef.current = sidebarWidth;
shellWidthRef.current = shellWidth;
workPanelWidthRef.current = workPanelWidth;
workPanelYieldedWidthRef.current = workPanelYieldedWidth;
workPanelOpenRef.current = workPanelVisible;

// The shell is a fixed client area: the three-column budget needs its real
Expand All @@ -112,13 +116,22 @@ export function useAppShellRuntime() {
observer.observe(shell);
return () => observer.disconnect();
}, []);
const resolveSidebarMax = () =>
sidebarWidthBudget({
containerWidth: appShellRef.current?.clientWidth || shellWidthRef.current,
const resolveSidebarMax = () => {
const containerWidth =
appShellRef.current?.clientWidth || shellWidthRef.current;
return sidebarWidthBudget({
containerWidth,
workPanelOpen: workPanelOpenRef.current,
workPanelWidth: workPanelWidthRef.current,
workPanelWidth: workPanelRequestedWidth({
containerWidth,
sidebarWidth: sidebarWidthRef.current,
sidebarCollapsed: sidebarCollapsedRef.current,
preferredWidth: workPanelWidthRef.current,
yieldedWidth: workPanelYieldedWidthRef.current,
}),
workPanelMaximized: workPanelMaximizedRef.current,
});
};
const handleSidebarWidthChange = useCallback((width: number) => {
setSidebarWidth(clampSidebarWidth(width, resolveSidebarMax()));
}, []);
Expand All @@ -135,7 +148,8 @@ export function useAppShellRuntime() {
// Reopening prefers the right column: the work panel gives up width first so
// MainChat keeps the width it already had, and only a would-be breach of the
// 450px floor falls back to the 460px reopen target. The column comes back at
// the user's preferred width, which a collapsing drag never overwrites.
// the user's preferred width, which a collapsing drag never overwrites. The
// panel only lends that width: its own preferred width stays stored.
const reopenSidebar = useCallback(() => {
if (!sidebarCollapsedRef.current) return;
const preferredWidth = clampSidebarWidth(sidebarPreferredWidthRef.current);
Expand All @@ -150,7 +164,11 @@ export function useAppShellRuntime() {
sidebarWidth: preferredWidth,
currentPanelWidth,
});
useAppStore.getState().setWorkPanelWidth(nextPanelWidth);
useAppStore
.getState()
.setWorkPanelYieldedWidth(
nextPanelWidth < currentPanelWidth ? nextPanelWidth : null,
);
}
autoCollapsedSidebarRef.current = false;
setSidebarWidth(preferredWidth);
Expand All @@ -175,6 +193,13 @@ export function useAppShellRuntime() {
autoCollapsedSidebarRef.current = true;
setSidebarCollapsed(true);
}, []);
// Width the panel lent to a reopened sidebar returns once the sidebar has
// finished collapsing, whichever path collapsed it.
useEffect(() => {
if (sidebarCollapsed && !sidebarExiting) {
useAppStore.getState().setWorkPanelYieldedWidth(null);
}
}, [sidebarCollapsed, sidebarExiting]);
const [presentedWorkPanelOpen, setPresentedWorkPanelOpen] = useState(false);
const [workPanelMaximized, setWorkPanelMaximized] = useState(false);
const [workPanelExiting, setWorkPanelExiting] = useState(false);
Expand Down Expand Up @@ -943,10 +968,19 @@ export function useAppShellRuntime() {
: workPanelToggleLabel;


// What the panel asks of the shared budget, including a width it lent to a
// reopened sidebar; the stored preference may be wider.
const requestedWorkPanelWidth = workPanelRequestedWidth({
containerWidth: shellWidth,
sidebarWidth,
sidebarCollapsed: sidebarCollapsed && !sidebarExiting,
preferredWidth: workPanelWidth,
yieldedWidth: workPanelYieldedWidth,
});
const sidebarWidthMax = sidebarWidthBudget({
containerWidth: shellWidth,
workPanelOpen: workPanelVisible || presentedWorkPanelOpen,
workPanelWidth,
workPanelWidth: requestedWorkPanelWidth,
workPanelMaximized,
});

Expand All @@ -972,7 +1006,7 @@ export function useAppShellRuntime() {
autoCollapseSidebar,
appShellRef,
shellWidth,
workPanelWidth,
requestedWorkPanelWidth,
runMenuCommand,
handleSidebarAnimationEnd,
presentedWorkPanelOpen,
Expand Down
47 changes: 46 additions & 1 deletion apps/desktop/src/lib/work-panel-resize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ export const MAIN_PANE_REOPEN_TARGET_WIDTH = MAIN_PANE_MIN_WIDTH + 10;
/**
* The regular panel minimum is a presentation affordance. The sidebar reopen
* path may temporarily spend the whole right column to preserve MainChat, so
* a positive compact width must remain representable in persisted state.
* a positive compact width must remain representable as a displayed width.
* It is never persisted: the preferred width keeps the regular minimum.
*/
export const WORK_PANEL_COMPACT_MIN_WIDTH = 1;

Expand Down Expand Up @@ -46,6 +47,50 @@ export function clampWorkPanelWidth(
return Math.max(workPanelWidthLimits(min).min, width);
}

/**
* Lower bound for a manual resize gesture. Gestures keep the regular minimum;
* a panel shown narrower than that, by a tight budget or a reopened sidebar,
* starts from its current width instead of jumping, and cannot shrink further.
*/
export function workPanelGestureMinimum(displayedWidth: number) {
return Math.min(
WORK_PANEL_MIN_WIDTH,
Math.max(WORK_PANEL_COMPACT_MIN_WIDTH, Math.round(displayedWidth)),
);
}

/**
* The width the panel asks of the three-column budget. A manual sidebar reopen
* lends part of the panel to the sidebar (see `workPanelWidthForSidebarReopen`)
* without changing the preferred width. While that sidebar stays expanded the
* panel keeps at least the lent width, and extra client width goes back to the
* panel first, up to the preferred width, so MainChat stays at its 460px
* reopen target. A collapsed sidebar ends the loan.
*/
export function workPanelRequestedWidth({
containerWidth,
sidebarWidth,
sidebarCollapsed,
preferredWidth,
yieldedWidth,
}: {
containerWidth: number;
sidebarWidth: number;
sidebarCollapsed: boolean;
preferredWidth: number;
yieldedWidth: number | null;
}) {
if (yieldedWidth === null || sidebarCollapsed) return preferredWidth;
const returned =
Math.round(containerWidth) -
Math.round(sidebarWidth) -
MAIN_PANE_REOPEN_TARGET_WIDTH;
return Math.max(
WORK_PANEL_COMPACT_MIN_WIDTH,
Math.min(preferredWidth, Math.max(Math.round(yieldedWidth), returned)),
);
}

/**
* Live bounds for a manual panel width: the compact minimum replaces the
* regular minimum while the panel is below it, and the shared three-column
Expand Down
7 changes: 7 additions & 0 deletions apps/desktop/src/stores/app-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,13 @@ export type AppState = {
activeWorkPanelTabId: string | null;
/** Runtime-only work panel state owned by each conversation. */
workPanelContexts: Record<string, WorkPanelContext>;
/** The user's preferred panel width; persisted, never below the regular minimum. */
workPanelWidth: number;
/**
* Runtime-only width a manual sidebar reopen lends to the sidebar. It ends
* when the sidebar collapses or the user resizes the panel.
*/
workPanelYieldedWidth: number | null;
/** Chat-initiated "preview this file" request consumed by the files viewer. */
workPanelFileRequest: {
path: string;
Expand Down Expand Up @@ -419,6 +425,7 @@ export type AppState = {
/** Hide the visible panel while retaining its session-owned context. */
resetWorkPanelContext: () => void;
setWorkPanelWidth: (width: number) => void;
setWorkPanelYieldedWidth: (width: number | null) => void;
openFileInWorkPanel: (
path: string,
mimeType?: string,
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/stores/slices/initial-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export function createInitialState(): AppStateData {
activeWorkPanelTabId: null,
workPanelContexts: {},
workPanelWidth: initialWorkPanelWidth,
workPanelYieldedWidth: null,
workPanelFileRequest: null,
projectSort: initialSidebarPreferences.projectSort,
messages: [],
Expand Down
24 changes: 16 additions & 8 deletions apps/desktop/src/stores/slices/work-panel-slice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
import {
WORK_PANEL_COMPACT_MIN_WIDTH,
WORK_PANEL_DEFAULT_WIDTH,
WORK_PANEL_MIN_WIDTH,
} from "../../lib/work-panel-resize";
import type { AppState } from "../app-state";
import type { StoreAccess } from "./types";
Expand All @@ -33,11 +34,10 @@ export function loadWorkPanelWidth(): number {
if (!raw) return WORK_PANEL_DEFAULT_WIDTH;
const parsed = JSON.parse(raw) as Record<string, unknown>;
const width = Number(parsed.width);
// Only the former sidebar-reopen path stored widths below the regular
// minimum; that width was lent to the sidebar, not chosen by the user.
return Number.isFinite(width)
? Math.max(
WORK_PANEL_COMPACT_MIN_WIDTH,
Math.round(width),
)
? Math.max(WORK_PANEL_MIN_WIDTH, Math.round(width))
: WORK_PANEL_DEFAULT_WIDTH;
} catch {
return WORK_PANEL_DEFAULT_WIDTH;
Expand Down Expand Up @@ -114,6 +114,7 @@ export function createWorkPanelSlice({
| "collapseWorkPanel"
| "resetWorkPanelContext"
| "setWorkPanelWidth"
| "setWorkPanelYieldedWidth"
| "openFileInWorkPanel"
| "openUrlInWorkPanel"
| "updateBrowserWorkPanelTab"
Expand Down Expand Up @@ -352,13 +353,20 @@ export function createWorkPanelSlice({
setWorkPanelWidth: (width) => {
const committedWidth = Math.round(width);
set({
workPanelWidth: Math.max(
WORK_PANEL_COMPACT_MIN_WIDTH,
committedWidth,
),
workPanelWidth: Math.max(WORK_PANEL_MIN_WIDTH, committedWidth),
// An explicit resize replaces any width lent to a reopened sidebar.
workPanelYieldedWidth: null,
});
saveWorkPanelWidth(get().workPanelWidth);
},
setWorkPanelYieldedWidth: (width) => {
const next =
width === null
? null
: Math.max(WORK_PANEL_COMPACT_MIN_WIDTH, Math.round(width));
if (get().workPanelYieldedWidth === next) return;
set({ workPanelYieldedWidth: next });
},

openFileInWorkPanel: (path, mimeType, position) => {
get().openWorkPanelTab(fileWorkPanelTab(path, mimeType, position));
Expand Down
Loading
Loading