sweep the dead code in section 8

Each item re-verified before deleting; three of the ten entries were stale
and are corrected in place rather than silently fixed.

- WorkspaceLayout's isMobile/mobilePanelId/onMobileBack: none of its ten
  callers set them, so the mobile collapse they fed was permanently off in
  that renderer. WorkspaceView passes the same props to WorkspaceRenderer
  itself, where they are live.
- fixedHeight on AppRegistryEntry, and getFixedHeight with it: no app has
  ever declared one, so it only contributed undefined. The flex-column
  branch it shared with fitContent stays, keyed on fitContent alone.
- getDefaults: getAllDashboardState already folds the defaults row into the
  one payload the client fetches, which is why it never got a caller.
- upsertScreen's terminals/hostTerminals: never read is right, never
  written was not — it inserted them, which is why all 15 rows hold {}.
  The columns are left in place; dropping them needs a db:push, and this
  tree holds another agent's uncommitted schema file.
- SELECTED_DASHBOARD_KEY: H2 (01365cb) replaced it with ?selected= four
  months ago and it has had no reader since.
- ui/sidebar.tsx and the stray ui/hooks/ beside it. use-mobile was not
  orphaned as claimed — the sidebar imported it — and the use-toast in
  there was a near-identical copy of the live one.
- findChildById's unreachable duplicate condition, and the doc comment that
  described the wrong behaviour rather than the code being wrong.

Left deliberately: DragOverlay/LayoutEditor (gated on 5.3, an owner
decision) and the two chat-owned channels, whose docs are fixed here even
though the publishers are not mine to delete.
This commit is contained in:
2026-08-07 11:26:33 +00:00
parent f2ae10bd36
commit b419af32de
14 changed files with 88 additions and 907 deletions
+3 -2
View File
@@ -253,8 +253,9 @@ in `usePanelChannel`/`useGlobal`, and has no anchor semantics (no cmd-click, no
link-focusable). Half the app still does this; none of the new code should. link-focusable). Half the app still does this; none of the new code should.
- **Addressable state goes in the URL** (`useParams` / `useSearchParams`), never in a channel. - **Addressable state goes in the URL** (`useParams` / `useSearchParams`), never in a channel.
`usePanelChannel` is for genuine signals and refresh buses (`preview:refresh`, `usePanelChannel` is for genuine signals and refresh buses (`files:refresh-signal`,
`files:refresh-signal`, `SLSKD_REFRESH_CHANNEL`, `MUSIC_RESYNC_CHANNEL`). `SLSKD_REFRESH_CHANNEL`, `MUSIC_RESYNC_CHANNEL`) — and declare one with `defineChannel` in
`officerdev/src/channels.ts` rather than spelling the name and type at each site.
"Which thing is open" is a URL. Panels each read the URL rather than passing it between themselves. "Which thing is open" is a URL. Panels each read the URL rather than passing it between themselves.
- **Rows and nav items are real links.** `<Link>` for rows (exemplar: the `/chat` session list, - **Rows and nav items are real links.** `<Link>` for rows (exemplar: the `/chat` session list,
`f35c145`); **react-router's `<NavLink>`** for nav chrome, so active state comes from the router. `f35c145`); **react-router's `<NavLink>`** for nav chrome, so active state comes from the router.
+9 -4
View File
@@ -123,10 +123,15 @@ This is the primary surface to convert to URL-driven selection.
| settings `useGlobal(*_SELECTED)` | settings section | `/settings/:page/:section` | `SettingsPanel.tsx:56` (M6) | | settings `useGlobal(*_SELECTED)` | settings section | `/settings/:page/:section` | `SettingsPanel.tsx:56` (M6) |
| local `useState` selections | search / room / conversation / peer / open file / preview slug | respective nested routes | `SearchView`, `SoulseekRooms`, `SoulseekChat`, `SoulseekUsers`, `CodeEditor/useEditorState`, `PreviewProvider` | | local `useState` selections | search / room / conversation / peer / open file / preview slug | respective nested routes | `SearchView`, `SoulseekRooms`, `SoulseekChat`, `SoulseekUsers`, `CodeEditor/useEditorState`, `PreviewProvider` |
**Excluded — event-bus / refresh signals, NOT selection:** `preview:refresh`, `files:refresh-signal`, **Excluded — event-bus / refresh signals, NOT selection:** `files:refresh-signal`,
`SLSKD_REFRESH_CHANNEL`, `MUSIC_RESYNC_CHANNEL`, `chat:active-session` (embedded-chat signal). `SLSKD_REFRESH_CHANNEL`, `MUSIC_RESYNC_CHANNEL`.
(`FILE_VIEWER_CHANNEL` was listed here too; it had no publisher and has been deleted — the file viewer (`FILE_VIEWER_CHANNEL` was listed here too; it had no publisher and has been deleted — the file viewer
reads `?view=` from the URL.) reads `?view=` from the URL. `preview:refresh` and `chat:active-session` were also listed, and
`preview:refresh` was cited above as the exemplar of a *legitimate* channel — but both have a publisher
in `ChatPanelWrapper` and **no subscriber at all**, and `preview:refresh`'s reader, `PreviewProvider`, is
no longer in the repo. They are declared in `officerdev/src/channels.ts` with that stated; deleting the
publishers means changing the chat panel, which is another agent's, so it is written up in
`COMMS/chat-agent-handoff-2026-08-07.md` instead. Use `files:refresh-signal` as the exemplar.)
--- ---
@@ -177,7 +182,7 @@ reads `?view=` from the URL.)
### Phase 1 — Quick wins (routes already exist; mechanical, high value) ### Phase 1 — Quick wins (routes already exist; mechanical, high value)
- [x] **H1** Jobs rows → `<Link to={`/jobs/${job.id}`}>` (`JobsPage.tsx`). Done — `46482f3`. (active-row highlight already keyed off `useParams().id`.) - [x] **H1** Jobs rows → `<Link to={`/jobs/${job.id}`}>` (`JobsPage.tsx`). Done — `46482f3`. (active-row highlight already keyed off `useParams().id`.)
- [x] **H2** Dashboards rows → `<Link>`; `SELECTED_DASHBOARD_KEY` global replaced by `?selected=` URL param across `DashboardListApp`/`DashboardsScreen`/`DashboardPreview`. Done — `01365cb`. **Needs runtime test.** - [x] **H2** Dashboards rows → `<Link>`; `SELECTED_DASHBOARD_KEY` global replaced by `?selected=` URL param across `DashboardListApp`/`DashboardsScreen`/`DashboardPreview`. Done — `01365cb`. **Needs runtime test.** (The constant itself outlived its last reader by four months and has now been deleted; its siblings in `Dashboards/constants.ts` are dialog form state, not selection, and stay.)
- [x] **H3** Projects rows → `<Link>`; `SELECTED_PROJECT` global replaced by `?selected=` URL param across `ProjectListApp`/`ProjectListScreen`/`ProjectPreview`. Done — `2aaacc8`. **Needs runtime test.** - [x] **H3** Projects rows → `<Link>`; `SELECTED_PROJECT` global replaced by `?selected=` URL param across `ProjectListApp`/`ProjectListScreen`/`ProjectPreview`. Done — `2aaacc8`. **Needs runtime test.**
- [ ] **H4** Chat detail: read `sessionId` from `useParams`, retire `chat:selected-session` as source of truth (`ChatDetailPanel.tsx:136`) — **finishes the /chat fix**. *Deferred: overlaps the in-flight `sidecars-*` chat-comms work; do after that lands.* - [ ] **H4** Chat detail: read `sessionId` from `useParams`, retire `chat:selected-session` as source of truth (`ChatDetailPanel.tsx:136`) — **finishes the /chat fix**. *Deferred: overlaps the in-flight `sidecars-*` chat-comms work; do after that lands.*
- [ ] **H5** Email rows → `<Link>` driven by `useParams().emailId`; delete the state↔URL sync effects (`EmailList.tsx:336`, `EmailScreen.tsx:27-33`). - [ ] **H5** Email rows → `<Link>` driven by `useParams().emailId`; delete the state↔URL sync effects (`EmailList.tsx:336`, `EmailScreen.tsx:27-33`).
+44 -13
View File
@@ -836,23 +836,54 @@ _(move items here with the commit and a one-line resolution)_
Low priority, but each line here is a line someone will read and believe. Low priority, but each line here is a line someone will read and believe.
Swept 2026-08-07 (`__SWEEP__`). Every claim was re-checked against the code first; three were stale, and
they are marked below, because a dead-code list that is itself wrong is the worst kind.
- [ ] `DragOverlay.tsx` (99) + `LayoutEditor.tsx` (51) — imported by nothing. Gated on 5.3. - [ ] `DragOverlay.tsx` (99) + `LayoutEditor.tsx` (51) — imported by nothing. Gated on 5.3.
- [ ] `WorkspaceLayout`'s `isMobile`/`mobilePanelId`/`onMobileBack` props — no caller passes them. **Deliberately left**: 5.3 is an owner decision ("decide, then act"), and deleting these makes it
- [ ] `fixedHeight` on `AppRegistryEntry` — declared, read at `WorkspaceRenderer.tsx:76`, set by **zero** for him.
metas. - [x] `WorkspaceLayout`'s `isMobile`/`mobilePanelId`/`onMobileBack` props — no caller passes them.
- [ ] `registerApp` (`useAppRegistry`) — zero callers. Confirmed across all ten call sites; removed. `WorkspaceView` still passes the same props to
- [ ] `getDefaults` (`queries/dashboards.ts:136`) — zero callers. `WorkspaceRenderer`, where they are live — it was only the `WorkspaceLayout` pass-through that was
- [ ] `screens.terminals` / `screens.hostTerminals` columns — never written, never read. dead.
- [ ] `SELECTED_DASHBOARD` constant — zero consumers. - [x] `fixedHeight` on `AppRegistryEntry` — declared, read at `WorkspaceRenderer.tsx:76`, set by **zero**
metas. Removed, along with `getFixedHeight` and the pixel-height branch it fed; the flex-column
layout it shared with `fitContent` stays, now keyed on `fitContent` alone.
- [x] `registerApp` (`useAppRegistry`) — zero callers. Already deleted in `9fcc9c2` (§5.6), with
`registerWidget`.
- [x] `getDefaults` (~~`queries/dashboards.ts:136`~~ `:195` — **line was stale**) — zero callers.
Removed. `getAllDashboardState` already folds the defaults row into the one state payload, which
is why the single-row getter never acquired one.
- [x] `screens.terminals` / `screens.hostTerminals` columns — never read (confirmed), **but "never
written" was stale**: `upsertScreen` accepted and inserted them, so all 15 rows hold the `{}` it
wrote. The dead parameters and inserts are gone. **The columns themselves are not dropped** — that
needs `bun db:push`, which diffs the *whole* schema, and this tree currently holds another agent's
uncommitted `schema/agent-panels.ts`. Drop them in a push of their own.
- [x] ~~`SELECTED_DASHBOARD`~~ **`SELECTED_DASHBOARD_KEY`** constant — zero consumers. The parenthetical
claiming `SELECTED_DASHBOARD_KEY` was the live one was **backwards**: `'SELECTED_DASHBOARD'` is the
string value, `SELECTED_DASHBOARD_KEY` the constant, and H2 (`01365cb`) took its last reader four
months ago. Deleted, with its two barrel re-exports. Its file-mates survive — they are dialog form
state, not selection.
- [ ] `preview:refresh` and `chat:active-session` channels — written by `ChatPanelWrapper.tsx:58-59`, - [ ] `preview:refresh` and `chat:active-session` channels — written by `ChatPanelWrapper.tsx:58-59`,
**read by nobody**. Note `preview:refresh` is the exemplar `docs/navigation-audit.md:126` cites as **read by nobody**. **Deliberately left**: deleting the publishers changes the chat panel, which is
the canonical _good_ channel; fix the doc too. another agent's. Declared with the fact stated in `officerdev/src/channels.ts` and written up in
- [ ] `components/ui/hooks/use-mobile.tsx` + `ui/sidebar.tsx` (~720 lines) — a second `useIsMobile` `COMMS/chat-agent-handoff-2026-08-07.md` §6. The doc half **is** done —
implementation, and a sidebar imported by nothing. `docs/navigation-audit.md:126` and `CLAUDE.md` no longer offer `preview:refresh` as the exemplar
- [ ] `WorkspaceRenderer.tsx:84` — unreachable duplicate condition inside `findChildById`. While there: channel; `files:refresh-signal` is.
- [x] `components/ui/hooks/use-mobile.tsx` + `ui/sidebar.tsx` (~720 lines) — a second `useIsMobile`
implementation, and a sidebar imported by nothing. Both deleted. **Nuance the entry missed**:
`use-mobile.tsx` was not orphaned, it was imported by `sidebar.tsx` — dead only once the sidebar
went. The whole `ui/hooks/` directory turned out to be a stray shadcn copy, its `use-toast.ts` a
near-identical duplicate of the live `ui/use-toast.ts`, referenced by nothing; it went too.
_Noticed, not fixed:_ `ui/toaster.tsx` + `ui/use-toast.ts` are their own dead chain (the app uses
`sonner`), and `globals.css` carries eight `--sidebar-*` tokens now used by nothing.
- [x] `WorkspaceRenderer.tsx:84` — unreachable duplicate condition inside `findChildById`. While there:
the doc comment says "find a panel node by id anywhere in the tree", but it returns _the direct the doc comment says "find a panel node by id anywhere in the tree", but it returns _the direct
child whose subtree contains the id_, which is what the mobile collapse needs. **The behaviour is child whose subtree contains the id_, which is what the mobile collapse needs. **The behaviour is
right and the description is wrong** — fix the comment, not the code. right and the description is wrong** — fix the comment, not the code. Done exactly that. (Worth
recording: a reviewer sent at this function without the entry in hand reported the `return child`
as the bug and the dead line as incidental. The entry is right and it is the reason the code
survived review.)
--- ---
-1
View File
@@ -61,7 +61,6 @@ export {
setDashboardPanelState, setDashboardPanelState,
upsertScreen, upsertScreen,
deleteScreen, deleteScreen,
getDefaults,
upsertDefaults, upsertDefaults,
setDefaultsPanelState, setDefaultsPanelState,
} from './queries/dashboards'; } from './queries/dashboards';
@@ -152,18 +152,20 @@ export async function deleteDashboard(userId: number, id: string): Promise<void>
// ── Screen CRUD ── // ── Screen CRUD ──
// No `terminals`/`hostTerminals` here, unlike `UpsertDefaultsData` below. A screen's terminal state
// resolves to the `-default` key — the `dashboard_defaults` row — so the two same-named columns on
// `screens` have never been read by anything, and the only caller (`api/dashboards`) has never passed
// them: every one of the 15 rows holds the `{}` the column default wrote. The columns still exist; they
// are not dropped here because `db:push` diffs the whole schema and there is another agent's uncommitted
// schema file in this tree.
type UpsertScreenData = { type UpsertScreenData = {
layout?: unknown; layout?: unknown;
terminals?: unknown;
hostTerminals?: unknown;
}; };
export async function upsertScreen(userId: number, name: string, data: UpsertScreenData): Promise<void> { export async function upsertScreen(userId: number, name: string, data: UpsertScreenData): Promise<void> {
const now = new Date(); const now = new Date();
const set: Record<string, unknown> = { updatedAt: now }; const set: Record<string, unknown> = { updatedAt: now };
if (data.layout !== undefined) set.layout = data.layout; if (data.layout !== undefined) set.layout = data.layout;
if (data.terminals !== undefined) set.terminals = data.terminals;
if (data.hostTerminals !== undefined) set.hostTerminals = data.hostTerminals;
await db await db
.insert(screens) .insert(screens)
@@ -171,8 +173,6 @@ export async function upsertScreen(userId: number, name: string, data: UpsertScr
userId, userId,
name, name,
layout: (data.layout ?? null) as never, layout: (data.layout ?? null) as never,
terminals: (data.terminals ?? {}) as never,
hostTerminals: (data.hostTerminals ?? {}) as never,
updatedAt: now, updatedAt: now,
}) })
.onConflictDoUpdate({ .onConflictDoUpdate({
@@ -192,11 +192,9 @@ type UpsertDefaultsData = {
hostTerminals?: unknown; hostTerminals?: unknown;
}; };
export async function getDefaults(userId: number): Promise<{ terminals: unknown; hostTerminals: unknown }> { // No `getDefaults`. Nothing read the defaults row on its own: `getAllDashboardState` above already folds
const [row] = await db.select().from(dashboardDefaults).where(eq(dashboardDefaults.userId, userId)); // it into the one state payload the client fetches, which is why the single-row getter never acquired a
return { terminals: row?.terminals ?? {}, hostTerminals: row?.hostTerminals ?? {} }; // caller. `upsertDefaults` below is live (`api/dashboards`).
}
export async function upsertDefaults(userId: number, data: UpsertDefaultsData): Promise<void> { export async function upsertDefaults(userId: number, data: UpsertDefaultsData): Promise<void> {
const now = new Date(); const now = new Date();
const set: Record<string, unknown> = { updatedAt: now }; const set: Record<string, unknown> = { updatedAt: now };
@@ -1,19 +0,0 @@
import * as React from 'react';
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener('change', onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener('change', onChange);
}, []);
return !!isMobile;
}
@@ -1,189 +0,0 @@
'use client';
// Inspired by react-hot-toast library
import * as React from 'react';
import type { ToastActionElement, ToastProps } from '@/components/ui/toast';
const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast = ToastProps & {
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
};
const actionTypes = {
ADD_TOAST: 'ADD_TOAST',
UPDATE_TOAST: 'UPDATE_TOAST',
DISMISS_TOAST: 'DISMISS_TOAST',
REMOVE_TOAST: 'REMOVE_TOAST',
} as const;
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString();
}
type ActionType = typeof actionTypes;
type Action =
| {
type: ActionType['ADD_TOAST'];
toast: ToasterToast;
}
| {
type: ActionType['UPDATE_TOAST'];
toast: Partial<ToasterToast>;
}
| {
type: ActionType['DISMISS_TOAST'];
toastId?: ToasterToast['id'];
}
| {
type: ActionType['REMOVE_TOAST'];
toastId?: ToasterToast['id'];
};
interface State {
toasts: ToasterToast[];
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return;
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({
type: 'REMOVE_TOAST',
toastId: toastId,
});
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'ADD_TOAST':
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
case 'UPDATE_TOAST':
return {
...state,
toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)),
};
case 'DISMISS_TOAST': {
const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId);
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id);
});
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t,
),
};
}
case 'REMOVE_TOAST':
if (action.toastId === undefined) {
return {
...state,
toasts: [],
};
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
}
};
const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
listener(memoryState);
});
}
type Toast = Omit<ToasterToast, 'id'>;
function toast({ ...props }: Toast) {
const id = genId();
const update = (props: ToasterToast) =>
dispatch({
type: 'UPDATE_TOAST',
toast: { ...props, id },
});
const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id });
dispatch({
type: 'ADD_TOAST',
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss();
},
},
});
return {
id: id,
dismiss,
update,
};
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, [state]);
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }),
};
}
export { useToast, toast };
-638
View File
@@ -1,638 +0,0 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { type VariantProps, cva } from 'class-variance-authority';
import { PanelLeft } from 'lucide-react';
import { useIsMobile } from './hooks/use-mobile';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
import { Sheet, SheetContent } from '@/components/ui/sheet';
import { Skeleton } from '@/components/ui/skeleton';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
const SIDEBAR_COOKIE_NAME = 'sidebar:state';
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = '16rem';
const SIDEBAR_WIDTH_MOBILE = '18rem';
const SIDEBAR_WIDTH_ICON = '3rem';
const SIDEBAR_KEYBOARD_SHORTCUT = 'b';
type SidebarContext = {
state: 'expanded' | 'collapsed';
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContext | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error('useSidebar must be used within a SidebarProvider.');
}
return context;
}
const SidebarProvider = React.forwardRef<
HTMLDivElement,
React.ComponentProps<'div'> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
>(({ defaultOpen = true, open: openProp, onOpenChange: setOpenProp, className, style, children, ...props }, ref) => {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === 'function' ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState);
} else {
_setOpen(openState);
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open],
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
toggleSidebar();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? 'expanded' : 'collapsed';
const contextValue = React.useMemo<SidebarContext>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
);
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
style={
{
'--sidebar-width': SIDEBAR_WIDTH,
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn('group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar', className)}
ref={ref}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
);
});
SidebarProvider.displayName = 'SidebarProvider';
const Sidebar = React.forwardRef<
HTMLDivElement,
React.ComponentProps<'div'> & {
side?: 'left' | 'right';
variant?: 'sidebar' | 'floating' | 'inset';
collapsible?: 'offcanvas' | 'icon' | 'none';
}
>(({ side = 'left', variant = 'sidebar', collapsible = 'offcanvas', className, children, ...props }, ref) => {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === 'none') {
return (
<div
className={cn('flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground', className)}
ref={ref}
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
forceMount
data-sidebar="sidebar"
data-mobile="true"
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
ref={ref}
className="group peer hidden md:block text-sidebar-foreground"
data-state={state}
data-collapsible={state === 'collapsed' ? collapsible : ''}
data-variant={variant}
data-side={side}
>
{/* This is what handles the sidebar gap on desktop */}
<div
className={cn(
'duration-200 relative h-svh w-(--sidebar-width) bg-transparent transition-[width] ease-linear',
'group-data-[collapsible=offcanvas]:w-0',
'group-data-[side=right]:rotate-180',
variant === 'floating' || variant === 'inset'
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)',
)}
/>
<div
className={cn(
'duration-200 fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] ease-linear md:flex',
side === 'left'
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
// Adjust the padding for floating and inset variants.
variant === 'floating' || variant === 'inset'
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l',
className,
)}
{...props}
>
<div
data-sidebar="sidebar"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
);
});
Sidebar.displayName = 'Sidebar';
const SidebarTrigger = React.forwardRef<React.ElementRef<typeof Button>, React.ComponentProps<typeof Button>>(
({ className, onClick, ...props }, ref) => {
const { toggleSidebar } = useSidebar();
return (
<Button
ref={ref}
data-sidebar="trigger"
variant="ghost"
size="icon"
className={cn('h-7 w-7', className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeft />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
},
);
SidebarTrigger.displayName = 'SidebarTrigger';
const SidebarRail = React.forwardRef<HTMLButtonElement, React.ComponentProps<'button'>>(
({ className, ...props }, ref) => {
const { toggleSidebar } = useSidebar();
return (
<button
ref={ref}
data-sidebar="rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
'absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex',
'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize',
'[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
'group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar',
'[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
'[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
className,
)}
{...props}
/>
);
},
);
SidebarRail.displayName = 'SidebarRail';
const SidebarInset = React.forwardRef<HTMLDivElement, React.ComponentProps<'main'>>(({ className, ...props }, ref) => {
return (
<main
ref={ref}
className={cn(
'relative flex min-h-svh flex-1 flex-col bg-background',
'peer-data-[variant=inset]:min-h-[calc(100svh-(--spacing(4)))] md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm',
className,
)}
{...props}
/>
);
});
SidebarInset.displayName = 'SidebarInset';
const SidebarInput = React.forwardRef<React.ElementRef<typeof Input>, React.ComponentProps<typeof Input>>(
({ className, ...props }, ref) => {
return (
<Input
ref={ref}
data-sidebar="input"
className={cn(
'h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring',
className,
)}
{...props}
/>
);
},
);
SidebarInput.displayName = 'SidebarInput';
const SidebarHeader = React.forwardRef<HTMLDivElement, React.ComponentProps<'div'>>(({ className, ...props }, ref) => {
return <div ref={ref} data-sidebar="header" className={cn('flex flex-col gap-2 p-2', className)} {...props} />;
});
SidebarHeader.displayName = 'SidebarHeader';
const SidebarFooter = React.forwardRef<HTMLDivElement, React.ComponentProps<'div'>>(({ className, ...props }, ref) => {
return <div ref={ref} data-sidebar="footer" className={cn('flex flex-col gap-2 p-2', className)} {...props} />;
});
SidebarFooter.displayName = 'SidebarFooter';
const SidebarSeparator = React.forwardRef<React.ElementRef<typeof Separator>, React.ComponentProps<typeof Separator>>(
({ className, ...props }, ref) => {
return (
<Separator
ref={ref}
data-sidebar="separator"
className={cn('mx-2 w-auto bg-sidebar-border', className)}
{...props}
/>
);
},
);
SidebarSeparator.displayName = 'SidebarSeparator';
const SidebarContent = React.forwardRef<HTMLDivElement, React.ComponentProps<'div'>>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="content"
className={cn(
'flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden',
className,
)}
{...props}
/>
);
});
SidebarContent.displayName = 'SidebarContent';
const SidebarGroup = React.forwardRef<HTMLDivElement, React.ComponentProps<'div'>>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="group"
className={cn('relative flex w-full min-w-0 flex-col p-2', className)}
{...props}
/>
);
});
SidebarGroup.displayName = 'SidebarGroup';
const SidebarGroupLabel = React.forwardRef<HTMLDivElement, React.ComponentProps<'div'> & { asChild?: boolean }>(
({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'div';
return (
<Comp
ref={ref}
data-sidebar="group-label"
className={cn(
'duration-200 flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-hidden ring-sidebar-ring transition-[margin,opa] ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',
className,
)}
{...props}
/>
);
},
);
SidebarGroupLabel.displayName = 'SidebarGroupLabel';
const SidebarGroupAction = React.forwardRef<HTMLButtonElement, React.ComponentProps<'button'> & { asChild?: boolean }>(
({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
ref={ref}
data-sidebar="group-action"
className={cn(
'absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
// Increases the hit area of the button on mobile.
'after:absolute after:-inset-2 md:after:hidden',
'group-data-[collapsible=icon]:hidden',
className,
)}
{...props}
/>
);
},
);
SidebarGroupAction.displayName = 'SidebarGroupAction';
const SidebarGroupContent = React.forwardRef<HTMLDivElement, React.ComponentProps<'div'>>(
({ className, ...props }, ref) => (
<div ref={ref} data-sidebar="group-content" className={cn('w-full text-sm', className)} {...props} />
),
);
SidebarGroupContent.displayName = 'SidebarGroupContent';
const SidebarMenu = React.forwardRef<HTMLUListElement, React.ComponentProps<'ul'>>(({ className, ...props }, ref) => (
<ul ref={ref} data-sidebar="menu" className={cn('flex w-full min-w-0 flex-col gap-1', className)} {...props} />
));
SidebarMenu.displayName = 'SidebarMenu';
const SidebarMenuItem = React.forwardRef<HTMLLIElement, React.ComponentProps<'li'>>(({ className, ...props }, ref) => (
<li ref={ref} data-sidebar="menu-item" className={cn('group/menu-item relative', className)} {...props} />
));
SidebarMenuItem.displayName = 'SidebarMenuItem';
const sidebarMenuButtonVariants = cva(
'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
{
variants: {
variant: {
default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
outline:
'bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]',
},
size: {
default: 'h-8 text-sm',
sm: 'h-7 text-xs',
lg: 'h-12 text-sm group-data-[collapsible=icon]:p-0!',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
const SidebarMenuButton = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<'button'> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>
>(({ asChild = false, isActive = false, variant = 'default', size = 'default', tooltip, className, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
const { isMobile, state } = useSidebar();
const button = (
<Comp
ref={ref}
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
if (!tooltip) {
return button;
}
if (typeof tooltip === 'string') {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent side="right" align="center" hidden={state !== 'collapsed' || isMobile} {...tooltip} />
</Tooltip>
);
});
SidebarMenuButton.displayName = 'SidebarMenuButton';
const SidebarMenuAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<'button'> & {
asChild?: boolean;
showOnHover?: boolean;
}
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
ref={ref}
data-sidebar="menu-action"
className={cn(
'absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0',
// Increases the hit area of the button on mobile.
'after:absolute after:-inset-2 md:after:hidden',
'peer-data-[size=sm]/menu-button:top-1',
'peer-data-[size=default]/menu-button:top-1.5',
'peer-data-[size=lg]/menu-button:top-2.5',
'group-data-[collapsible=icon]:hidden',
showOnHover &&
'group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0',
className,
)}
{...props}
/>
);
});
SidebarMenuAction.displayName = 'SidebarMenuAction';
const SidebarMenuBadge = React.forwardRef<HTMLDivElement, React.ComponentProps<'div'>>(
({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="menu-badge"
className={cn(
'absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground select-none pointer-events-none',
'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground',
'peer-data-[size=sm]/menu-button:top-1',
'peer-data-[size=default]/menu-button:top-1.5',
'peer-data-[size=lg]/menu-button:top-2.5',
'group-data-[collapsible=icon]:hidden',
className,
)}
{...props}
/>
),
);
SidebarMenuBadge.displayName = 'SidebarMenuBadge';
const SidebarMenuSkeleton = React.forwardRef<
HTMLDivElement,
React.ComponentProps<'div'> & {
showIcon?: boolean;
}
>(({ className, showIcon = false, ...props }, ref) => {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
return (
<div
ref={ref}
data-sidebar="menu-skeleton"
className={cn('rounded-md h-8 flex gap-2 px-2 items-center', className)}
{...props}
>
{showIcon && <Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />}
<Skeleton
className="h-4 flex-1 max-w-(--skeleton-width)"
data-sidebar="menu-skeleton-text"
style={
{
'--skeleton-width': width,
} as React.CSSProperties
}
/>
</div>
);
});
SidebarMenuSkeleton.displayName = 'SidebarMenuSkeleton';
const SidebarMenuSub = React.forwardRef<HTMLUListElement, React.ComponentProps<'ul'>>(
({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu-sub"
className={cn(
'mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5',
'group-data-[collapsible=icon]:hidden',
className,
)}
{...props}
/>
),
);
SidebarMenuSub.displayName = 'SidebarMenuSub';
const SidebarMenuSubItem = React.forwardRef<HTMLLIElement, React.ComponentProps<'li'>>(({ ...props }, ref) => (
<li ref={ref} {...props} />
));
SidebarMenuSubItem.displayName = 'SidebarMenuSubItem';
const SidebarMenuSubButton = React.forwardRef<
HTMLAnchorElement,
React.ComponentProps<'a'> & {
asChild?: boolean;
size?: 'sm' | 'md';
isActive?: boolean;
}
>(({ asChild = false, size = 'md', isActive, className, ...props }, ref) => {
const Comp = asChild ? Slot : 'a';
return (
<Comp
ref={ref}
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
'flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-hidden ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground',
'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground',
size === 'sm' && 'text-xs',
size === 'md' && 'text-sm',
'group-data-[collapsible=icon]:hidden',
className,
)}
{...props}
/>
);
});
SidebarMenuSubButton.displayName = 'SidebarMenuSubButton';
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
};
@@ -1,4 +1,6 @@
export const SELECTED_DASHBOARD_KEY = 'SELECTED_DASHBOARD'; // No SELECTED_DASHBOARD_KEY. Which dashboard is selected is `?selected=` in the URL (01365cb), and the
// global it named lost its last reader then — see `docs/navigation-audit.md` H2. The keys below are
// genuinely global: they are transient *form* state for the create/edit dialog, not a selection.
export const CREATING_DASHBOARD_KEY = 'CREATING_DASHBOARD'; export const CREATING_DASHBOARD_KEY = 'CREATING_DASHBOARD';
export const EDITING_DASHBOARD_KEY = 'EDITING_DASHBOARD'; export const EDITING_DASHBOARD_KEY = 'EDITING_DASHBOARD';
export const NEW_DASH_NAME_KEY = 'NEW_DASH_NAME'; export const NEW_DASH_NAME_KEY = 'NEW_DASH_NAME';
@@ -5,7 +5,7 @@ import { DashboardPreview } from './DashboardPreview';
export { DashboardListApp }; export { DashboardListApp };
export { DashboardPreview }; export { DashboardPreview };
export { SELECTED_DASHBOARD_KEY, CREATING_DASHBOARD_KEY, EDITING_DASHBOARD_KEY, NEW_DASH_NAME_KEY, NEW_DASH_DESC_KEY, NEW_DASH_TEMPLATE_KEY } from './constants'; export { CREATING_DASHBOARD_KEY, EDITING_DASHBOARD_KEY, NEW_DASH_NAME_KEY, NEW_DASH_DESC_KEY, NEW_DASH_TEMPLATE_KEY } from './constants';
export const appRegistryMetas: AppRegistryMeta[] = [ export const appRegistryMetas: AppRegistryMeta[] = [
{ {
@@ -12,14 +12,15 @@ type WorkspaceLayoutProps = {
components?: PanelComponents; components?: PanelComponents;
cwd?: string; cwd?: string;
noHeader?: boolean; noHeader?: boolean;
isMobile?: boolean;
mobilePanelId?: string;
onMobileBack?: (() => void) | null;
}; };
const noop = () => {}; const noop = () => {};
export const WorkspaceLayout = ({ layout, onLayoutChange, registry: registryProp, components, cwd, noHeader, isMobile, mobilePanelId, onMobileBack }: WorkspaceLayoutProps) => { // No mobile props. This renderer had `isMobile`/`mobilePanelId`/`onMobileBack` pass-throughs that none of
// its ten call sites ever set, so the mobile collapse they feed was permanently off here — and being
// optional, that read as a feature rather than the dead wiring it was. `WorkspaceView` is where mobile
// collapse actually happens, and it passes those to `WorkspaceRenderer` itself.
export const WorkspaceLayout = ({ layout, onLayoutChange, registry: registryProp, components, cwd, noHeader }: WorkspaceLayoutProps) => {
const { registry: globalRegistry } = useAppRegistry(); const { registry: globalRegistry } = useAppRegistry();
const registry = registryProp ?? globalRegistry; const registry = registryProp ?? globalRegistry;
@@ -36,14 +37,12 @@ export const WorkspaceLayout = ({ layout, onLayoutChange, registry: registryProp
// dashboard previews. It is not a dashboard and not a screen, and a panel that asks should be told so // dashboard previews. It is not a dashboard and not a screen, and a panel that asks should be told so
// rather than handed something that parses. // rather than handed something that parses.
return ( return (
<WorkspaceProvider value={{ workspace: null, cwd: cwd ?? '~', panelConfigs: collectPanelConfigs(layout), ...inertInteraction, isMobile: isMobile ?? false, onMobileBack: onMobileBack ?? null }}> <WorkspaceProvider value={{ workspace: null, cwd: cwd ?? '~', panelConfigs: collectPanelConfigs(layout), ...inertInteraction, isMobile: false, onMobileBack: null }}>
<WorkspaceRenderer <WorkspaceRenderer
layout={layout} layout={layout}
registry={registry} registry={registry}
components={components} components={components}
noHeader={noHeader} noHeader={noHeader}
isMobile={isMobile}
mobilePanelId={mobilePanelId}
onSetApp={noop} onSetApp={noop}
onSplit={noop} onSplit={noop}
onRemove={noop} onRemove={noop}
@@ -72,19 +72,17 @@ type LayoutNodeRendererProps = {
onResized: (groupId: string, sizes: number[]) => void; onResized: (groupId: string, sizes: number[]) => void;
}; };
const getFixedHeight = (node: LayoutNode, registry: AppRegistryMap): number | undefined => { /**
if (node.type === 'panel' && node.appType) return registry[node.appType]?.fixedHeight; * Given a group's direct children, find the one whose subtree contains `id` — the child itself if it is
return undefined; * the match, otherwise the ancestor it sits under. Not "the node with that id": the mobile collapse below
}; * renders one direct child at a time, so what it needs back is the child to render, not the descendant.
*/
/** Find a panel node by id anywhere in the layout tree */
const findChildById = ( const findChildById = (
children: { node: LayoutNode; size: number }[], children: { node: LayoutNode; size: number }[],
id: string, id: string,
): { node: LayoutNode; size: number } | undefined => { ): { node: LayoutNode; size: number } | undefined => {
for (const child of children) { for (const child of children) {
if (child.node.id === id) return child; if (child.node.id === id) return child;
if (child.node.type === 'panel' && child.node.id === id) return child;
if (child.node.type === 'group') { if (child.node.type === 'group') {
const found = findChildById(child.node.children, id); const found = findChildById(child.node.children, id);
if (found) return child; if (found) return child;
@@ -189,24 +187,20 @@ const LayoutNodeRenderer = ({
); );
} }
const hasFixedChild = // A vertical group holding a fit-to-content panel lays out as a flex column instead of a resizable
node.direction === 'vertical' && // group: the fitting panel takes its natural height and the rest share what is left. There used to be a
node.children.some( // second way in here — a `fixedHeight` in pixels declared on the app's registry entry — but no app has
(c) => getFixedHeight(c.node, registry) !== undefined || (c.node.type === 'panel' && c.node.fitContent), // ever declared one, so it only ever contributed `undefined` to this test.
); const hasFitChild =
node.direction === 'vertical' && node.children.some((c) => c.node.type === 'panel' && c.node.fitContent);
if (hasFixedChild) { if (hasFitChild) {
return ( return (
<div className="flex h-full w-full flex-col"> <div className="flex h-full w-full flex-col">
{node.children.map((child) => { {node.children.map((child) => {
const fixed = getFixedHeight(child.node, registry);
const fit = child.node.type === 'panel' && child.node.fitContent; const fit = child.node.type === 'panel' && child.node.fitContent;
return ( return (
<div <div key={child.node.id} className={fit ? 'shrink-0' : 'min-h-0 flex-1'}>
key={child.node.id}
className={fixed !== undefined || fit ? 'shrink-0' : 'min-h-0 flex-1'}
style={fixed !== undefined ? { height: fixed } : undefined}
>
<LayoutNodeRenderer <LayoutNodeRenderer
node={child.node} node={child.node}
registry={registry} registry={registry}
@@ -59,7 +59,6 @@ export type AppRegistryEntry = {
header?: ComponentType<{ panelId: string }>; header?: ComponentType<{ panelId: string }>;
provider?: ComponentType<{ panelId: string; children: ReactNode }>; provider?: ComponentType<{ panelId: string; children: ReactNode }>;
transparent?: boolean; transparent?: boolean;
fixedHeight?: number;
availableOnPanel?: boolean; availableOnPanel?: boolean;
// Opt out of the panel zoom control. For apps that scale badly under a CSS transform: a terminal // Opt out of the panel zoom control. For apps that scale badly under a CSS transform: a terminal
// (its own font sizing, and scale blurs the glyph grid) or a remote-desktop canvas (noVNC does its // (its own font sizing, and scale blurs the glyph grid) or a remote-desktop canvas (noVNC does its
-1
View File
@@ -106,7 +106,6 @@ export type { DesktopViewProps } from './apps/Desktop';
export { export {
DashboardListApp, DashboardListApp,
DashboardPreview, DashboardPreview,
SELECTED_DASHBOARD_KEY,
CREATING_DASHBOARD_KEY, CREATING_DASHBOARD_KEY,
EDITING_DASHBOARD_KEY, EDITING_DASHBOARD_KEY,
NEW_DASH_NAME_KEY, NEW_DASH_NAME_KEY,