Opening an in-progress session that has a live Pi runtime now reconnects to its pending gate instead of the empty landing screen that read as "stopped". Cold/completed sessions keep the read-only documents panel with its explicit Resume, so a mere click never spawns a runtime. Backend GET /sessions now reports a per-session `active` flag (live runtime bound) to drive this. Also: - "New session" now closes any open session detail panel (left box). - The model-activity separator can be dragged to a full 50/50 split (was capped at 576px); central-min still guards narrow viewports. Test fixes uncovered along the way: - Node 25 ships an experimental global localStorage that shadows jsdom's and lacks clear(), failing every jsdom test at setup; install a spec-compliant in-memory Storage (feature-detected, inert on CI/LTS). - Fix 4 pre-existing session-mgmt tests that used an ambiguous getByText for a session shown in both nav and header; target the nav item by test id. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
228 lines
7.9 KiB
TypeScript
228 lines
7.9 KiB
TypeScript
import { useCallback, useLayoutEffect, useRef, useState } from "react";
|
|
import type { KeyboardEventHandler, PointerEventHandler, RefObject } from "react";
|
|
|
|
export const ACTIVITY_PANEL_DEFAULT_WIDTH = 384;
|
|
export const ACTIVITY_PANEL_MIN_WIDTH = 288;
|
|
/** Pre-measurement fallback ceiling only; the measured bound is 50% of the container (see below). */
|
|
export const ACTIVITY_PANEL_MAX_WIDTH = 576;
|
|
export const ACTIVITY_CENTRAL_MIN_WIDTH = 512;
|
|
export const ACTIVITY_PANEL_STORAGE_KEY = "thothii:model-activity-panel-width";
|
|
|
|
export type ActivityPanelBounds = { min: number; max: number };
|
|
|
|
export function getActivityPanelBounds(containerWidth: number): ActivityPanelBounds {
|
|
// The separator may be dragged out to a full 50/50 split. On containers too narrow
|
|
// for that (below ~1024px) the central-min guard binds first, keeping the chat usable.
|
|
return {
|
|
min: ACTIVITY_PANEL_MIN_WIDTH,
|
|
max: Math.max(
|
|
ACTIVITY_PANEL_MIN_WIDTH,
|
|
Math.min(containerWidth * 0.5, containerWidth - ACTIVITY_CENTRAL_MIN_WIDTH),
|
|
),
|
|
};
|
|
}
|
|
|
|
export function clampActivityPanelWidth(width: number, bounds: ActivityPanelBounds): number {
|
|
const candidate = Number.isFinite(width) ? width : ACTIVITY_PANEL_DEFAULT_WIDTH;
|
|
return Math.min(bounds.max, Math.max(bounds.min, candidate));
|
|
}
|
|
|
|
export function loadActivityPanelWidth(storage: Pick<Storage, "getItem"> | null): number {
|
|
if (!storage) return ACTIVITY_PANEL_DEFAULT_WIDTH;
|
|
try {
|
|
const raw = storage.getItem(ACTIVITY_PANEL_STORAGE_KEY);
|
|
if (raw === null || raw.trim() === "") return ACTIVITY_PANEL_DEFAULT_WIDTH;
|
|
const parsed = Number(raw);
|
|
return Number.isFinite(parsed) ? parsed : ACTIVITY_PANEL_DEFAULT_WIDTH;
|
|
} catch {
|
|
return ACTIVITY_PANEL_DEFAULT_WIDTH;
|
|
}
|
|
}
|
|
|
|
export function saveActivityPanelWidth(
|
|
width: number,
|
|
storage: Pick<Storage, "setItem"> | null,
|
|
): void {
|
|
if (!storage) return;
|
|
try {
|
|
storage.setItem(ACTIVITY_PANEL_STORAGE_KEY, String(Math.round(width)));
|
|
} catch {
|
|
// Storage can be unavailable in privacy-restricted browser contexts.
|
|
}
|
|
}
|
|
|
|
function getBrowserStorage(): Storage | null {
|
|
if (typeof window === "undefined") return null;
|
|
try {
|
|
return window.localStorage;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function resizeWidthFromKey(
|
|
width: number,
|
|
key: string,
|
|
shiftKey: boolean,
|
|
bounds: ActivityPanelBounds,
|
|
): number | null {
|
|
const step = shiftKey ? 48 : 16;
|
|
if (key === "ArrowLeft") return clampActivityPanelWidth(width - step, bounds);
|
|
if (key === "ArrowRight") return clampActivityPanelWidth(width + step, bounds);
|
|
if (key === "Home") return bounds.min;
|
|
if (key === "End") return bounds.max;
|
|
return null;
|
|
}
|
|
|
|
type DragState = {
|
|
pointerId: number;
|
|
startX: number;
|
|
startWidth: number;
|
|
target: HTMLDivElement;
|
|
};
|
|
|
|
export function useActivityPanelResize(): {
|
|
containerRef: RefObject<HTMLDivElement>;
|
|
panelWidth: number;
|
|
desktopSplit: boolean;
|
|
resizing: boolean;
|
|
separatorProps: {
|
|
role: "separator";
|
|
tabIndex: 0;
|
|
"aria-label": string;
|
|
"aria-orientation": "vertical";
|
|
"aria-valuemin": number;
|
|
"aria-valuemax": number;
|
|
"aria-valuenow": number;
|
|
onPointerDown: PointerEventHandler<HTMLDivElement>;
|
|
onPointerMove: PointerEventHandler<HTMLDivElement>;
|
|
onPointerUp: PointerEventHandler<HTMLDivElement>;
|
|
onPointerCancel: PointerEventHandler<HTMLDivElement>;
|
|
onKeyDown: KeyboardEventHandler<HTMLDivElement>;
|
|
};
|
|
} {
|
|
const storageRef = useRef<Storage | null>(getBrowserStorage());
|
|
const initialWidthRef = useRef(loadActivityPanelWidth(storageRef.current));
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const widthRef = useRef(initialWidthRef.current);
|
|
const boundsRef = useRef<ActivityPanelBounds>({
|
|
min: ACTIVITY_PANEL_MIN_WIDTH,
|
|
max: ACTIVITY_PANEL_MAX_WIDTH,
|
|
});
|
|
const dragRef = useRef<DragState | null>(null);
|
|
const [panelWidth, setPanelWidth] = useState(initialWidthRef.current);
|
|
const [bounds, setBounds] = useState(boundsRef.current);
|
|
const [desktopSplit, setDesktopSplit] = useState(false);
|
|
const [resizing, setResizing] = useState(false);
|
|
|
|
const applyWidth = useCallback((rawWidth: number, persist: boolean) => {
|
|
const next = clampActivityPanelWidth(rawWidth, boundsRef.current);
|
|
widthRef.current = next;
|
|
setPanelWidth(next);
|
|
if (persist) saveActivityPanelWidth(next, storageRef.current);
|
|
return next;
|
|
}, []);
|
|
|
|
useLayoutEffect(() => {
|
|
const container = containerRef.current;
|
|
if (!container) return;
|
|
const cancelDrag = () => {
|
|
const drag = dragRef.current;
|
|
if (drag?.target.hasPointerCapture?.(drag.pointerId)) {
|
|
drag.target.releasePointerCapture?.(drag.pointerId);
|
|
}
|
|
dragRef.current = null;
|
|
setResizing(false);
|
|
};
|
|
const syncLayout = () => {
|
|
const viewportIsDesktop = window.matchMedia?.("(min-width: 1024px)").matches
|
|
?? window.innerWidth >= 1024;
|
|
const hasUsableContainer = container.clientWidth >= ACTIVITY_PANEL_MIN_WIDTH + ACTIVITY_CENTRAL_MIN_WIDTH;
|
|
const nextDesktopSplit = viewportIsDesktop && hasUsableContainer;
|
|
setDesktopSplit(nextDesktopSplit);
|
|
if (!nextDesktopSplit) {
|
|
cancelDrag();
|
|
const savedWidth = loadActivityPanelWidth(storageRef.current);
|
|
widthRef.current = savedWidth;
|
|
setPanelWidth(savedWidth);
|
|
return;
|
|
}
|
|
const nextBounds = getActivityPanelBounds(container.clientWidth);
|
|
boundsRef.current = nextBounds;
|
|
setBounds(nextBounds);
|
|
applyWidth(widthRef.current, true);
|
|
};
|
|
syncLayout();
|
|
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(syncLayout);
|
|
observer?.observe(container);
|
|
const media = window.matchMedia?.("(min-width: 1024px)");
|
|
media?.addEventListener?.("change", syncLayout);
|
|
window.addEventListener("resize", syncLayout);
|
|
return () => {
|
|
observer?.disconnect();
|
|
media?.removeEventListener?.("change", syncLayout);
|
|
window.removeEventListener("resize", syncLayout);
|
|
};
|
|
}, [applyWidth]);
|
|
|
|
const onPointerDown: PointerEventHandler<HTMLDivElement> = (event) => {
|
|
dragRef.current = {
|
|
pointerId: event.pointerId,
|
|
startX: event.clientX,
|
|
startWidth: widthRef.current,
|
|
target: event.currentTarget,
|
|
};
|
|
event.currentTarget.setPointerCapture?.(event.pointerId);
|
|
setResizing(true);
|
|
};
|
|
const onPointerMove: PointerEventHandler<HTMLDivElement> = (event) => {
|
|
const drag = dragRef.current;
|
|
if (!drag || drag.pointerId !== event.pointerId) return;
|
|
applyWidth(drag.startWidth + event.clientX - drag.startX, false);
|
|
};
|
|
const onPointerUp: PointerEventHandler<HTMLDivElement> = (event) => {
|
|
const drag = dragRef.current;
|
|
if (!drag || drag.pointerId !== event.pointerId) return;
|
|
applyWidth(drag.startWidth + event.clientX - drag.startX, true);
|
|
if (event.currentTarget.hasPointerCapture?.(event.pointerId)) {
|
|
event.currentTarget.releasePointerCapture?.(event.pointerId);
|
|
}
|
|
dragRef.current = null;
|
|
setResizing(false);
|
|
};
|
|
const onPointerCancel: PointerEventHandler<HTMLDivElement> = (event) => {
|
|
const drag = dragRef.current;
|
|
if (!drag || drag.pointerId !== event.pointerId) return;
|
|
applyWidth(drag.startWidth, false);
|
|
dragRef.current = null;
|
|
setResizing(false);
|
|
};
|
|
const onKeyDown: KeyboardEventHandler<HTMLDivElement> = (event) => {
|
|
const next = resizeWidthFromKey(widthRef.current, event.key, event.shiftKey, boundsRef.current);
|
|
if (next === null) return;
|
|
event.preventDefault();
|
|
applyWidth(next, true);
|
|
};
|
|
|
|
return {
|
|
containerRef,
|
|
panelWidth,
|
|
desktopSplit,
|
|
resizing,
|
|
separatorProps: {
|
|
role: "separator",
|
|
tabIndex: 0,
|
|
"aria-label": "Resize model activity",
|
|
"aria-orientation": "vertical",
|
|
"aria-valuemin": bounds.min,
|
|
"aria-valuemax": bounds.max,
|
|
"aria-valuenow": panelWidth,
|
|
onPointerDown,
|
|
onPointerMove,
|
|
onPointerUp,
|
|
onPointerCancel,
|
|
onKeyDown,
|
|
},
|
|
};
|
|
}
|