feat(frontend): app shell with resizable chat drawer (M6.3) - #71
Conversation
Introduce the unique layout that hosts the full demo: - `AppShell`: flex viewport host with sticky topbar + CSS grid main area (control room left / docked chat drawer right) - `TopBar`: AriaMark + equipment selector (P-01/P-02/Tank-A/Tank-B), KpiBar placeholder slot (filled in M7.2), time-based shift indicator (A/B/C), MetaStrip with UNIT/CELL/REV - `Drawer`: docked resizable chat drawer (360-640px), pointer drag + keyboard (arrows/Home/End) on a window-splitter separator, ARIA-wired - `routes`: `/` -> `/control-room`, RequireAuth + AppShell + Outlet; `/login`, `/data`, `/design` preserved; catch-all -> `/control-room` - `ControlRoomPage`: placeholder shell (SectionHeader + Hairline) - `useLocalStorage`: SSR-safe typed hook with cross-tab sync - Drawer state (`aria.chatDrawer`) and equipment (`aria.selectedEquipment`) persist across reloads, width clamped 360-640 defensively - Cmd/Ctrl+K toggles the drawer, bails out when typing in input/select/ textarea/contenteditable Zero new deps, tokens-only (no hex literals), icons via DS wrapper, motion via `--ds-motion-*` tokens (respects prefers-reduced-motion). Closes #36
There was a problem hiding this comment.
Pull request overview
Adds the M6.3 frontend “app shell” layout for the demo: a sticky top bar plus a CSS-grid main area with a docked, resizable chat drawer, and updates routing so /control-room becomes the default authenticated landing page.
Changes:
- Introduces
AppShell+TopBar+ docked resizableDrawer(pointer + keyboard resize, ARIA-wired) and persists drawer/equipment state viauseLocalStorage. - Adds
/control-roomroute (protected) and redirects/+ catch-all to it; keeps/login,/data,/design. - Adds a placeholder
ControlRoomPage.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| frontend/src/pages/ControlRoomPage.tsx | Adds placeholder content for the new control-room route. |
| frontend/src/lib/useLocalStorage.ts | Adds typed localStorage hook with SSR-safe read and cross-tab sync. |
| frontend/src/app/routes.tsx | Defines new routing structure and default redirect to /control-room. |
| frontend/src/app/TopBar.tsx | Implements the sticky top bar UI and drawer toggle control. |
| frontend/src/app/Drawer.tsx | Implements docked resizable chat drawer with pointer + keyboard interactions. |
| frontend/src/app/AppShell.tsx | Composes top bar + main grid + drawer; adds global Cmd/Ctrl+K handler and persistence wiring. |
| frontend/src/App.tsx | Switches App to use the new centralized AppRoutes. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| function onStorage(e: StorageEvent) { | ||
| if (e.key !== keyRef.current || e.newValue === null) return; | ||
| try { | ||
| setStored(JSON.parse(e.newValue) as T); | ||
| } catch { | ||
| // ignore | ||
| } | ||
| } | ||
| window.addEventListener("storage", onStorage); | ||
| return () => window.removeEventListener("storage", onStorage); | ||
| }, []); |
There was a problem hiding this comment.
useLocalStorage ignores storage events where e.newValue === null (e.g., another tab calls removeItem/clear). That means state will not actually stay in sync across tabs in that case, despite the hook’s contract. Consider resetting to initialValue (or re-running readValue()) when newValue is null, and also handling key changes if the hook is used with a dynamic key.
| function onStorage(e: StorageEvent) { | |
| if (e.key !== keyRef.current || e.newValue === null) return; | |
| try { | |
| setStored(JSON.parse(e.newValue) as T); | |
| } catch { | |
| // ignore | |
| } | |
| } | |
| window.addEventListener("storage", onStorage); | |
| return () => window.removeEventListener("storage", onStorage); | |
| }, []); | |
| setStored(readValue()); | |
| }, [readValue]); | |
| useEffect(() => { | |
| function onStorage(e: StorageEvent) { | |
| if (e.storageArea !== window.localStorage) return; | |
| if (e.key !== null && e.key !== keyRef.current) return; | |
| setStored(readValue()); | |
| } | |
| window.addEventListener("storage", onStorage); | |
| return () => window.removeEventListener("storage", onStorage); | |
| }, [readValue]); |
| useEffect( | ||
| () => () => { | ||
| window.removeEventListener("pointermove", onPointerMove); | ||
| window.removeEventListener("pointerup", onPointerUp); | ||
| }, | ||
| [onPointerMove, onPointerUp], | ||
| ); |
There was a problem hiding this comment.
The useEffect cleanup removes pointer listeners but doesn’t reset document.body.style.cursor / userSelect if the component unmounts mid-drag (e.g., route change while resizing). This can leave the whole app stuck in col-resize cursor / userSelect: none. Update the cleanup (and/or onPointerUp) to always restore the body styles and clear draggingRef when tearing down.
| setDrawer((prev) => ({ ...sanitizeDrawer(prev), open: !prev.open })); | ||
| }, [setDrawer]); | ||
|
|
||
| const setDrawerWidth = useCallback( | ||
| (width: number) => { | ||
| setDrawer((prev) => ({ ...sanitizeDrawer(prev), width })); |
There was a problem hiding this comment.
toggleDrawer sanitizes prev but then computes the next open value from the unsanitized prev.open (open: !prev.open). If the persisted value is non-boolean (corrupt localStorage), toggling can behave inconsistently with safeDrawer. Compute the toggle from the sanitized value instead (and similarly clamp width when persisting in setDrawerWidth so localStorage doesn’t store out-of-range widths if onWidthChange is ever called with an invalid value).
| setDrawer((prev) => ({ ...sanitizeDrawer(prev), open: !prev.open })); | |
| }, [setDrawer]); | |
| const setDrawerWidth = useCallback( | |
| (width: number) => { | |
| setDrawer((prev) => ({ ...sanitizeDrawer(prev), width })); | |
| setDrawer((prev) => { | |
| const safePrev = sanitizeDrawer(prev); | |
| return { ...safePrev, open: !safePrev.open }; | |
| }); | |
| }, [setDrawer]); | |
| const setDrawerWidth = useCallback( | |
| (width: number) => { | |
| setDrawer((prev) => { | |
| const safePrev = sanitizeDrawer(prev); | |
| const safeWidth = Math.max( | |
| DRAWER_MIN_WIDTH, | |
| Math.min(DRAWER_MAX_WIDTH, Math.round(width ?? DRAWER_DEFAULT_WIDTH)), | |
| ); | |
| return { ...safePrev, width: safeWidth }; | |
| }); |
| <DrawerIcon className="size-4" /> | ||
| <KbdKey className="ml-1">⌘K</KbdKey> | ||
| </button> |
There was a problem hiding this comment.
The shortcut hint is always rendered as “⌘K”, but the handler uses Ctrl+K on non-Mac platforms. This is misleading for Windows/Linux users; consider rendering “CtrlK” (or “Ctrl+K”) when metaKey isn’t the primary modifier, or making the handler accept either Ctrl or ⌘ and updating the label accordingly.
… assignments, and cell resolution
…st methods for shifts and work orders
…humain 11 m24 toolspy 3 tools contexte humain
12 m25 toolspy 3 tools kb
… production analysis
…n-peuvent-être-skippés 13 m26 toolspy 2 tools production peuvent être skippés
…ching and error handling
…ma validation and sanitization
…discovery-call_tool 14 m27 mcpclient singleton auto discovery call tool
Replace the flat hardcoded equipment select with a SCADA-style
command-palette that reflects the real DB model (Enterprise → Site →
Area → Line → Cell) via `GET /api/v1/hierarchy/tree`.
- `EquipmentPicker`: trigger button with bracketed breadcrumb
(`[EQ] cellName · SITE · AREA · LINE`) + `⌘⇧E` shortcut. Popover
with search (token-split, case-insensitive), tree grouped by line,
keyboard navigation (↑↓ Home End wrap, Enter commit, Esc restore),
StatusRail per row, disabled cells greyed. Full ARIA (dialog+modal,
listbox/option, activedescendant, expanded/haspopup/controls).
- `lib/hierarchy`: ISA-95 DTO types, `useHierarchyTree` TanStack query
(staleTime 60s), `flattenTree/searchCells/groupByLine`, `findCell`
helpers. `EquipmentSelection` now carries `{lineId, lineName, cellId,
cellName, siteName, areaName}` for live MetaStrip binding.
- `TopBar`: MetaStrip binds to the live selection (`SITE / LINE / REV`
instead of hardcoded `UNIT / CELL / REV`).
- `AppShell`: `aria.selectedEquipment` shape migrated; old string
payload ("P-01") is dropped via a new `validator` option on
`useLocalStorage`, which also filters out stale shapes arriving on
cross-tab `storage` events. Auto-seeds the first enabled cell when
no selection exists.
Zero new deps. All tokens-only (no hex literals). Icons via DS wrapper.
Motion via `Motion.fadeInUp` + `--ds-motion-*` tokens.
`npm run typecheck` / `npm run build` / `npm run check` all green.
Bundle 453.33 kB / 143.36 kB gzipped (+3 kB vs baseline).
Update — hierarchical equipment pickerThe flat What changed
Compliance
Gates
|
- Introduced unit tests for the MCPClient singleton and its interaction with FastMCP. - Added tests for the MCP to Anthropic schema adapter to ensure correct transformation of tool descriptors. - Implemented smoke tests for ARIA MCP tool registration to verify the registration of KPI and signal tools. - Created unit tests for datetime helper functions to validate timezone-aware parsing. - Added threshold evaluation tests to ensure correct behavior of threshold breach logic. - Developed tests for merging structured data in the knowledge base module. - Implemented validation tests for the KbRepository to ensure upsert operations maintain data integrity. - Added contract tests for the EquipmentKB domain model to validate the expected JSON structure. - Created unit tests for WorkOrder schemas to validate new fields and status transitions.
feat: unit tests for MCPClient, schema adapter, and work order schemas
…r_-déclarés-dans-les-agents 16 m29 UI tools generative render déclarés dans les agents
Introduce the unique layout that hosts the full demo: - `AppShell`: flex viewport host with sticky topbar + CSS grid main area (control room left / docked chat drawer right) - `TopBar`: AriaMark + equipment selector (P-01/P-02/Tank-A/Tank-B), KpiBar placeholder slot (filled in M7.2), time-based shift indicator (A/B/C), MetaStrip with UNIT/CELL/REV - `Drawer`: docked resizable chat drawer (360-640px), pointer drag + keyboard (arrows/Home/End) on a window-splitter separator, ARIA-wired - `routes`: `/` -> `/control-room`, RequireAuth + AppShell + Outlet; `/login`, `/data`, `/design` preserved; catch-all -> `/control-room` - `ControlRoomPage`: placeholder shell (SectionHeader + Hairline) - `useLocalStorage`: SSR-safe typed hook with cross-tab sync - Drawer state (`aria.chatDrawer`) and equipment (`aria.selectedEquipment`) persist across reloads, width clamped 360-640 defensively - Cmd/Ctrl+K toggles the drawer, bails out when typing in input/select/ textarea/contenteditable Zero new deps, tokens-only (no hex literals), icons via DS wrapper, motion via `--ds-motion-*` tokens (respects prefers-reduced-motion). Closes #36
Replace the flat hardcoded equipment select with a SCADA-style
command-palette that reflects the real DB model (Enterprise → Site →
Area → Line → Cell) via `GET /api/v1/hierarchy/tree`.
- `EquipmentPicker`: trigger button with bracketed breadcrumb
(`[EQ] cellName · SITE · AREA · LINE`) + `⌘⇧E` shortcut. Popover
with search (token-split, case-insensitive), tree grouped by line,
keyboard navigation (↑↓ Home End wrap, Enter commit, Esc restore),
StatusRail per row, disabled cells greyed. Full ARIA (dialog+modal,
listbox/option, activedescendant, expanded/haspopup/controls).
- `lib/hierarchy`: ISA-95 DTO types, `useHierarchyTree` TanStack query
(staleTime 60s), `flattenTree/searchCells/groupByLine`, `findCell`
helpers. `EquipmentSelection` now carries `{lineId, lineName, cellId,
cellName, siteName, areaName}` for live MetaStrip binding.
- `TopBar`: MetaStrip binds to the live selection (`SITE / LINE / REV`
instead of hardcoded `UNIT / CELL / REV`).
- `AppShell`: `aria.selectedEquipment` shape migrated; old string
payload ("P-01") is dropped via a new `validator` option on
`useLocalStorage`, which also filters out stale shapes arriving on
cross-tab `storage` events. Auto-seeds the first enabled cell when
no selection exists.
Zero new deps. All tokens-only (no hex literals). Icons via DS wrapper.
Motion via `Motion.fadeInUp` + `--ds-motion-*` tokens.
`npm run typecheck` / `npm run build` / `npm run check` all green.
Bundle 453.33 kB / 143.36 kB gzipped (+3 kB vs baseline).
…t picker
Pivot the visual identity from SCADA/Bloomberg-Terminal ("Editorial
Industrial Telemetry" v1) to Linear/Vercel/Stripe-calm ("Operator-calm"
v2). Refactor the equipment picker from a flat line-grouped list to a
5-level ISA-95 Plant Structure tree.
Design pivot (DESIGN_PLAN_v2.md):
- Palette: warm neutrals, blue accent (#3478f6 dark / #2563eb light),
no more cyan. Dark + light mode first-class via ThemeProvider +
ThemeToggle (System/Dark/Light), pre-mount script in index.html for
zero flash.
- Typography: Inter everywhere, JetBrains Mono reserved to numerics /
IDs / kbd / code only. Normal-case everywhere; no more mono-caps
+0.08em as default signature.
- Radii: collapsed from 5 to 3 levels (sm 6 / md 10 / lg 14). No more
`rounded-full` except StatusDot.
- Signatures: bracketed labels removed ("[ CONTROL ROOM ]" → "Control
room"), registration marks removed ("UNIT / D-02 · CELL / 02.01" →
"P-02 · Apr 22, 2026" meta-lines). Grain overlay, CRT scanline, and
default status rail on all cards all removed. Shadow overlay token
reintroduced for floating surfaces only.
- Legacy `styles/dark.css` + `styles/light.css` dropped (unused tokens
concurrent with v2). `--ds-accent-glow`, `--ds-radius-xs/xl`,
`--ds-text-display`, Badge `tag` prop all removed.
Equipment picker tree:
- 5-level ISA-95 tree (Enterprise → Site → Area → Line → Cell) with
chevrons, monochrome level icons (Building2/MapPin/Layers/GitBranch/
Cpu), right-aligned counts on containers.
- Auto-expand chain to current selection on open (or full expand when
no selection). Live search auto-expands matching branches; siblings
remain visible dimmed for hierarchical context.
- Keyboard: ↑/↓ navigate all visible nodes, →/← expand/collapse-or-
ascend, Enter commit cell or toggle container, Home/End jump, Esc
close. Full ARIA tree pattern (role=tree/treeitem, aria-level,
aria-expanded, aria-selected, aria-activedescendant).
- Popover resized 460×520 to accommodate indent depth. localStorage
persistence, cross-tab validator, auto-seed, ⌘⇧E shortcut all
preserved.
Zero new deps. All quality gates green:
- typecheck ✓
- build ✓ (461.30 kB / 145.62 kB gzip)
- check (biome) ✓ (35 files, 0 errors)
Known follow-up: 5 Lucide icons imported directly in EquipmentPicker
bypass the design-system/icons wrapper (scope strict on that file). To
route through the DS wrapper in a follow-up issue.
Follows DESIGN_PLAN_v2.md §1-§12. Follow-ups tracked in issues.
Update 3 — operator-calm design pivot + ISA-95 tree view (final for this PR)Two big changes squashed into commit `aa67504`: 1. Design pivot (DESIGN_PLAN_v2 "Operator-calm")Visual identity moved from SCADA / Bloomberg Terminal to Linear / Vercel / Stripe-calm (user feedback on the previous round).
2. Equipment picker — full ISA-95 tree viewThe picker moved from flat line-grouped list to the 5-level ISA-95 Plant Structure pattern (Enterprise → Site → Area → Line → Cell).
Gates
Follow-ups opened
QA statusQA round not run on this iteration — user choice (visual validation in loop). A follow-up QA sweep can be done before merge if desired. |
Summary
Introduces the unique layout hosting the full demo (issue #36): sticky topbar + CSS grid main area with docked resizable chat drawer.
AppShell(frontend/src/app/AppShell.tsx) — viewport host, topbar + main grid,Cmd/Ctrl+Kglobal handler with input bail-outTopBar(frontend/src/app/TopBar.tsx) — AriaMark + equipment selector (P-01 / P-02 / Tank-A / Tank-B) + KpiBar placeholder slot (filled M7.2) + time-based shift indicator (A/B/C) + MetaStrip UNIT/CELL/REV + toggle buttonDrawer(frontend/src/app/Drawer.tsx) — docked resizable 360-640px, pointer drag + keyboard separator (Arrow/Home/End), ARIA-wired (role="separator",aria-valuenow/min/max, toggle hasaria-expanded/aria-controls). Intentionally distinct from the overlay-modalDrawerprimitive in the DS.routes(frontend/src/app/routes.tsx) —/→/control-room;/control-room(RequireAuth + AppShell + Outlet) ;/login,/data,/designpreserved ; catch-all →/control-roomControlRoomPage(frontend/src/pages/ControlRoomPage.tsx) — placeholderuseLocalStorage(frontend/src/lib/useLocalStorage.ts) — SSR-safe typed hook withstorageevent cross-tab sync, quota/JSON errors swallowedDrawer state (
aria.chatDrawer) and equipment (aria.selectedEquipment) persist across reloads. Width is clamped 360-640 defensively at read-time (sanitize()).Design discipline
tokens.css(palette §2)design-system/icons(Icons.PanelRightOpen/Close) with stroke-width 1.5 (§7)--ds-motion-*tokens — no framer-motion runtime for the shell itself; tokens already fall to 0ms underprefers-reduced-motion(§6)Acceptance (issue #36)
Cmd/Ctrl+Ktoggles drawer, bails out when typing in input/textarea/select/contenteditabletop-0)Test plan
npm run typecheck— greennpm run build— green (439.85 kB / 139.93 kB gz)npm run check(Biome) — green, 31 files, 0 errors/control-room, resize drawer, reload, verify width/open persistCmd/Ctrl+Ktoggles drawer ; does nothing when focus is in an inputNotes for reviewer
Three DS-related nits flagged by QA for follow-up (out of scope for this PR):
LoginPageredirect target is still/data, to realign onceControlRoomPagehas contentLoginPageuses off-DS classes (pre-existing, 43 occurrences to refactor before demo)admin/admin123hardcoded inLoginPage(dev convenience, guard withimport.meta.env.DEVbefore demo)Will open separate issues for these.
Closes #36