diff --git a/CLAUDE.md b/CLAUDE.md index b857e9be..e241aab5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. - **Addressable state goes in the URL** (`useParams` / `useSearchParams`), never in a channel. - `usePanelChannel` is for genuine signals and refresh buses (`preview:refresh`, - `files:refresh-signal`, `SLSKD_REFRESH_CHANNEL`, `MUSIC_RESYNC_CHANNEL`). + `usePanelChannel` is for genuine signals and refresh buses (`files:refresh-signal`, + `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. - **Rows and nav items are real links.** `` for rows (exemplar: the `/chat` session list, `f35c145`); **react-router's ``** for nav chrome, so active state comes from the router. diff --git a/docs/navigation-audit.md b/docs/navigation-audit.md index b43b969b..78d97856 100644 --- a/docs/navigation-audit.md +++ b/docs/navigation-audit.md @@ -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) | | 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`, -`SLSKD_REFRESH_CHANNEL`, `MUSIC_RESYNC_CHANNEL`, `chat:active-session` (embedded-chat signal). +**Excluded — event-bus / refresh signals, NOT selection:** `files:refresh-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 -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) - [x] **H1** Jobs rows → `` (`JobsPage.tsx`). Done — `46482f3`. (active-row highlight already keyed off `useParams().id`.) -- [x] **H2** Dashboards rows → ``; `SELECTED_DASHBOARD_KEY` global replaced by `?selected=` URL param across `DashboardListApp`/`DashboardsScreen`/`DashboardPreview`. Done — `01365cb`. **Needs runtime test.** +- [x] **H2** Dashboards rows → ``; `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 → ``; `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.* - [ ] **H5** Email rows → `` driven by `useParams().emailId`; delete the state↔URL sync effects (`EmailList.tsx:336`, `EmailScreen.tsx:27-33`). diff --git a/docs/workspace-panel-todo.md b/docs/workspace-panel-todo.md index 0f47b5b6..f4a289d3 100644 --- a/docs/workspace-panel-todo.md +++ b/docs/workspace-panel-todo.md @@ -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. +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. -- [ ] `WorkspaceLayout`'s `isMobile`/`mobilePanelId`/`onMobileBack` props — no caller passes them. -- [ ] `fixedHeight` on `AppRegistryEntry` — declared, read at `WorkspaceRenderer.tsx:76`, set by **zero** - metas. -- [ ] `registerApp` (`useAppRegistry`) — zero callers. -- [ ] `getDefaults` (`queries/dashboards.ts:136`) — zero callers. -- [ ] `screens.terminals` / `screens.hostTerminals` columns — never written, never read. -- [ ] `SELECTED_DASHBOARD` constant — zero consumers. + **Deliberately left**: 5.3 is an owner decision ("decide, then act"), and deleting these makes it + for him. +- [x] `WorkspaceLayout`'s `isMobile`/`mobilePanelId`/`onMobileBack` props — no caller passes them. + Confirmed across all ten call sites; removed. `WorkspaceView` still passes the same props to + `WorkspaceRenderer`, where they are live — it was only the `WorkspaceLayout` pass-through that was + dead. +- [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`, - **read by nobody**. Note `preview:refresh` is the exemplar `docs/navigation-audit.md:126` cites as - the canonical _good_ channel; fix the doc too. -- [ ] `components/ui/hooks/use-mobile.tsx` + `ui/sidebar.tsx` (~720 lines) — a second `useIsMobile` - implementation, and a sidebar imported by nothing. -- [ ] `WorkspaceRenderer.tsx:84` — unreachable duplicate condition inside `findChildById`. While there: + **read by nobody**. **Deliberately left**: deleting the publishers changes the chat panel, which is + another agent's. Declared with the fact stated in `officerdev/src/channels.ts` and written up in + `COMMS/chat-agent-handoff-2026-08-07.md` §6. The doc half **is** done — + `docs/navigation-audit.md:126` and `CLAUDE.md` no longer offer `preview:refresh` as the exemplar + 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 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.) --- diff --git a/src/databases/officer_db/src/index.ts b/src/databases/officer_db/src/index.ts index cd12995d..953bc9b5 100644 --- a/src/databases/officer_db/src/index.ts +++ b/src/databases/officer_db/src/index.ts @@ -61,7 +61,6 @@ export { setDashboardPanelState, upsertScreen, deleteScreen, - getDefaults, upsertDefaults, setDefaultsPanelState, } from './queries/dashboards'; diff --git a/src/databases/officer_db/src/queries/dashboards.ts b/src/databases/officer_db/src/queries/dashboards.ts index 0cec5fb4..a79cc036 100644 --- a/src/databases/officer_db/src/queries/dashboards.ts +++ b/src/databases/officer_db/src/queries/dashboards.ts @@ -152,18 +152,20 @@ export async function deleteDashboard(userId: number, id: string): Promise // ── 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 = { layout?: unknown; - terminals?: unknown; - hostTerminals?: unknown; }; export async function upsertScreen(userId: number, name: string, data: UpsertScreenData): Promise { const now = new Date(); const set: Record = { updatedAt: now }; 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 .insert(screens) @@ -171,8 +173,6 @@ export async function upsertScreen(userId: number, name: string, data: UpsertScr userId, name, layout: (data.layout ?? null) as never, - terminals: (data.terminals ?? {}) as never, - hostTerminals: (data.hostTerminals ?? {}) as never, updatedAt: now, }) .onConflictDoUpdate({ @@ -192,11 +192,9 @@ type UpsertDefaultsData = { hostTerminals?: unknown; }; -export async function getDefaults(userId: number): Promise<{ terminals: unknown; hostTerminals: unknown }> { - const [row] = await db.select().from(dashboardDefaults).where(eq(dashboardDefaults.userId, userId)); - return { terminals: row?.terminals ?? {}, hostTerminals: row?.hostTerminals ?? {} }; -} - +// No `getDefaults`. Nothing read the defaults row on its own: `getAllDashboardState` above already folds +// it into the one state payload the client fetches, which is why the single-row getter never acquired a +// caller. `upsertDefaults` below is live (`api/dashboards`). export async function upsertDefaults(userId: number, data: UpsertDefaultsData): Promise { const now = new Date(); const set: Record = { updatedAt: now }; diff --git a/src/workspaces/components/ui/hooks/use-mobile.tsx b/src/workspaces/components/ui/hooks/use-mobile.tsx deleted file mode 100644 index ba553c6f..00000000 --- a/src/workspaces/components/ui/hooks/use-mobile.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import * as React from 'react'; - -const MOBILE_BREAKPOINT = 768; - -export function useIsMobile() { - const [isMobile, setIsMobile] = React.useState(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; -} diff --git a/src/workspaces/components/ui/hooks/use-toast.ts b/src/workspaces/components/ui/hooks/use-toast.ts deleted file mode 100644 index 5eb0ac36..00000000 --- a/src/workspaces/components/ui/hooks/use-toast.ts +++ /dev/null @@ -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; - } - | { - type: ActionType['DISMISS_TOAST']; - toastId?: ToasterToast['id']; - } - | { - type: ActionType['REMOVE_TOAST']; - toastId?: ToasterToast['id']; - }; - -interface State { - toasts: ToasterToast[]; -} - -const toastTimeouts = new Map>(); - -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; - -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(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 }; diff --git a/src/workspaces/components/ui/sidebar.tsx b/src/workspaces/components/ui/sidebar.tsx deleted file mode 100644 index 33bee0a6..00000000 --- a/src/workspaces/components/ui/sidebar.tsx +++ /dev/null @@ -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(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( - () => ({ - state, - open, - setOpen, - isMobile, - openMobile, - setOpenMobile, - toggleSidebar, - }), - [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar], - ); - - return ( - - -
- {children} -
-
-
- ); -}); -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 ( -
- {children} -
- ); - } - - if (isMobile) { - return ( - - -
{children}
-
-
- ); - } - - return ( -
- {/* This is what handles the sidebar gap on desktop */} -
- -
- ); -}); -Sidebar.displayName = 'Sidebar'; - -const SidebarTrigger = React.forwardRef, React.ComponentProps>( - ({ className, onClick, ...props }, ref) => { - const { toggleSidebar } = useSidebar(); - - return ( - - ); - }, -); -SidebarTrigger.displayName = 'SidebarTrigger'; - -const SidebarRail = React.forwardRef>( - ({ className, ...props }, ref) => { - const { toggleSidebar } = useSidebar(); - - return ( -