Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
5d7d216
feat(frontend): add app shell with resizable chat drawer (M6.3)
vgtray Apr 22, 2026
a793666
feat(database): add P-02 human-context seed for shift assignments and…
zestones Apr 22, 2026
da52913
feat(tools): add human-context and hierarchy tools for logbook, shift…
zestones Apr 22, 2026
793a1be
feat(repository): add date range filtering to list_assignments and li…
zestones Apr 22, 2026
0d534b7
feat(tests): add human-context tools tests and enhance existing tool …
zestones Apr 22, 2026
2871e6d
Merge pull request #75 from zestones/11-m24-toolspy-3-tools-contexte-…
zestones Apr 22, 2026
a30105e
feat(tools): add KB tools for equipment management and failure history
zestones Apr 22, 2026
8773bc2
feat(merge): implement RFC-7396 JSON Merge Patch for equipment_kb.str…
zestones Apr 22, 2026
e94d366
feat(tests): add unit tests for KB tools and enhance MCP tool registr…
zestones Apr 22, 2026
18e733f
chore(kb): update audit M2.5 §4 documentation and add TODO for ws_man…
zestones Apr 22, 2026
eba6bdc
Merge pull request #76 from zestones/12-m25-toolspy-3-tools-kb
zestones Apr 22, 2026
bbc0993
feat(kpi): add get_quality_metrics and get_production_stats tools for…
zestones Apr 22, 2026
4222c21
feat(tests): add tests for new production tools and verify their regi…
zestones Apr 22, 2026
e2cdba7
Merge pull request #77 from zestones/13-m26-toolspy-2-tools-productio…
zestones Apr 22, 2026
e01d352
feat(client): implement MCP HTTP client singleton with tool schema ca…
zestones Apr 22, 2026
877f424
feat(schema): add MCP to Anthropic tool-schema adapter for input sche…
zestones Apr 22, 2026
2cdecc3
fix(database): increase max_size of asyncpg pool from 10 to 20
zestones Apr 22, 2026
b79ea85
feat(tests): add unit tests for MCPClient singleton and schema adapter
zestones Apr 22, 2026
86720cb
Merge pull request #78 from zestones/14-m27-mcpclient-singleton-auto-…
zestones Apr 22, 2026
a26b516
feat(frontend): hierarchical equipment picker wired to ISA-95 tree
vgtray Apr 22, 2026
0db505b
feat: unit tests for MCPClient, schema adapter, and work order schemas
zestones Apr 22, 2026
adad8f6
Merge pull request #79 from zestones/15-m28-script-de-test-isolation
zestones Apr 22, 2026
10e2a1b
feat: add generative UI render tool schemas for ARIA agents
zestones Apr 22, 2026
090d471
feat(tests): add unit tests for agents.ui_tools
zestones Apr 22, 2026
6a9744c
Merge pull request #80 from zestones/16-m29-ui-tools-generative-rende…
zestones Apr 22, 2026
2045892
feat(frontend): add app shell with resizable chat drawer (M6.3)
vgtray Apr 22, 2026
54bf6b6
feat(frontend): hierarchical equipment picker wired to ISA-95 tree
vgtray Apr 22, 2026
19ab4b3
chore: reconcile rebased local with remote feat/36-app-shell
vgtray Apr 22, 2026
aa67504
refactor(frontend): operator-calm design pivot + ISA-95 tree equipmen…
vgtray Apr 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 2 additions & 20 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,5 @@
import { Navigate, Route, Routes } from "react-router-dom";
import RequireAuth from "./components/RequireAuth";
import DataPage from "./pages/DataPage";
import DesignPage from "./pages/DesignPage";
import LoginPage from "./pages/LoginPage";
import { AppRoutes } from "./app/routes";

export default function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/design" element={<DesignPage />} />
<Route
path="/data"
element={
<RequireAuth>
<DataPage />
</RequireAuth>
}
/>
<Route path="*" element={<Navigate to="/data" replace />} />
</Routes>
);
return <AppRoutes />;
}
108 changes: 108 additions & 0 deletions frontend/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { useCallback, useEffect, useId } from "react";
import { Outlet } from "react-router-dom";
import { useLocalStorage } from "../lib/useLocalStorage";
import { DRAWER_DEFAULT_WIDTH, DRAWER_MAX_WIDTH, DRAWER_MIN_WIDTH, Drawer } from "./Drawer";
import { EQUIPMENT_OPTIONS, TopBar } from "./TopBar";

interface ChatDrawerState {
open: boolean;
width: number;
}

const CHAT_DRAWER_KEY = "aria.chatDrawer";
const EQUIPMENT_KEY = "aria.selectedEquipment";

const DEFAULT_DRAWER_STATE: ChatDrawerState = {
open: true,
width: DRAWER_DEFAULT_WIDTH,
};

function sanitizeDrawer(state: ChatDrawerState): ChatDrawerState {
return {
open: Boolean(state.open),
width: Math.max(
DRAWER_MIN_WIDTH,
Math.min(DRAWER_MAX_WIDTH, Math.round(state.width ?? DRAWER_DEFAULT_WIDTH)),
),
};
}

function isTypingTarget(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return false;
const tag = target.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
if (target.isContentEditable) return true;
return false;
}

export function AppShell() {
const [drawer, setDrawer] = useLocalStorage<ChatDrawerState>(
CHAT_DRAWER_KEY,
DEFAULT_DRAWER_STATE,
);
const [equipmentId, setEquipmentId] = useLocalStorage<string>(
EQUIPMENT_KEY,
EQUIPMENT_OPTIONS[0].id,
);

const safeDrawer = sanitizeDrawer(drawer);
const drawerId = useId();

const toggleDrawer = useCallback(() => {
setDrawer((prev) => ({ ...sanitizeDrawer(prev), open: !prev.open }));
}, [setDrawer]);

const setDrawerWidth = useCallback(
(width: number) => {
setDrawer((prev) => ({ ...sanitizeDrawer(prev), width }));
Comment on lines +63 to +68

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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 };
});

Copilot uses AI. Check for mistakes.
},
[setDrawer],
);

useEffect(() => {
function onKey(e: KeyboardEvent) {
const isMac =
typeof navigator !== "undefined" &&
navigator.platform.toLowerCase().includes("mac");
const comboPressed = (isMac ? e.metaKey : e.ctrlKey) && e.key.toLowerCase() === "k";
if (!comboPressed) return;
if (isTypingTarget(e.target)) return;
e.preventDefault();
toggleDrawer();
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [toggleDrawer]);

return (
<div className="flex h-screen w-screen flex-col overflow-hidden bg-[var(--ds-bg-base)] text-[var(--ds-fg-primary)]">
<TopBar
equipmentId={equipmentId}
onEquipmentChange={setEquipmentId}
drawerOpen={safeDrawer.open}
drawerControlsId={drawerId}
onDrawerToggle={toggleDrawer}
/>
<div
className="grid min-h-0 flex-1"
style={{
gridTemplateColumns: safeDrawer.open
? `minmax(0, 1fr) ${safeDrawer.width}px`
: "minmax(0, 1fr) 0",
transition: `grid-template-columns var(--ds-motion-base) var(--ds-ease-out)`,
}}
>
<main className="relative min-h-0 overflow-auto">
<Outlet />
</main>
<Drawer
id={drawerId}
open={safeDrawer.open}
width={safeDrawer.width}
onToggle={toggleDrawer}
onWidthChange={setDrawerWidth}
/>
</div>
</div>
);
}
155 changes: 155 additions & 0 deletions frontend/src/app/Drawer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import {
type KeyboardEvent as ReactKeyboardEvent,
type ReactNode,
useCallback,
useEffect,
useId,
useRef,
} from "react";
import { Hairline, Icons, SectionHeader } from "../design-system";

export const DRAWER_MIN_WIDTH = 360;
export const DRAWER_MAX_WIDTH = 640;
export const DRAWER_DEFAULT_WIDTH = 420;
const KEYBOARD_STEP = 10;

export interface DrawerProps {
open: boolean;
width: number;
onToggle: () => void;
onWidthChange: (width: number) => void;
children?: ReactNode;
/** DOM id used by the topbar toggle `aria-controls`. */
id?: string;
}

function clampWidth(width: number) {
return Math.max(DRAWER_MIN_WIDTH, Math.min(DRAWER_MAX_WIDTH, Math.round(width)));
}

/**
* Docked, resizable chat drawer. Sits inside the app-shell grid — not overlay.
* The primitive `design-system/Drawer` remains for modal contexts.
*/
export function Drawer({ open, width, onToggle, onWidthChange, children, id }: DrawerProps) {
const generatedId = useId();
const drawerId = id ?? `chat-drawer-${generatedId}`;
const asideRef = useRef<HTMLElement>(null);
const draggingRef = useRef(false);
const startXRef = useRef(0);
const startWidthRef = useRef(0);

const onPointerMove = useCallback(
(e: PointerEvent) => {
if (!draggingRef.current) return;
const delta = startXRef.current - e.clientX;
onWidthChange(clampWidth(startWidthRef.current + delta));
},
[onWidthChange],
);

const onPointerUp = useCallback(() => {
if (!draggingRef.current) return;
draggingRef.current = false;
document.body.style.cursor = "";
document.body.style.userSelect = "";
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onPointerUp);
}, [onPointerMove]);

useEffect(
() => () => {
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onPointerUp);
},
[onPointerMove, onPointerUp],
);
Comment on lines +60 to +66

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

const onHandlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
e.preventDefault();
draggingRef.current = true;
startXRef.current = e.clientX;
startWidthRef.current = width;
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
};

const onHandleKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
let next: number | null = null;
switch (e.key) {
case "ArrowLeft":
next = clampWidth(width + KEYBOARD_STEP);
break;
case "ArrowRight":
next = clampWidth(width - KEYBOARD_STEP);
break;
case "Home":
next = DRAWER_MIN_WIDTH;
break;
case "End":
next = DRAWER_MAX_WIDTH;
break;
default:
return;
}
e.preventDefault();
onWidthChange(next);
};

return (
<aside
ref={asideRef}
id={drawerId}
aria-label="Chat drawer"
className="relative h-full overflow-hidden border-l border-[var(--ds-border)] bg-[var(--ds-bg-surface)]"
style={{
width: open ? `${width}px` : 0,
transition: `width var(--ds-motion-base) var(--ds-ease-out)`,
}}
>
{open && (
// biome-ignore lint/a11y/useSemanticElements: separator must be interactive (draggable + keyboard resize) — <hr> cannot carry pointer/keyboard handlers
<div
role="separator"
aria-orientation="vertical"
aria-valuenow={width}
aria-valuemin={DRAWER_MIN_WIDTH}
aria-valuemax={DRAWER_MAX_WIDTH}
aria-label="Resize chat drawer"
tabIndex={0}
onPointerDown={onHandlePointerDown}
onKeyDown={onHandleKeyDown}
className="absolute left-0 top-0 z-10 h-full w-1.5 -translate-x-1/2 cursor-col-resize outline-none focus-visible:bg-[var(--ds-accent)]/60 hover:bg-[var(--ds-accent)]/40"
style={{ touchAction: "none" }}
/>
)}
{open && (
<div className="flex h-full w-full flex-col" style={{ width: `${width}px` }}>
<header className="flex items-center justify-between gap-4 border-b border-[var(--ds-border)] px-4 py-3">
<SectionHeader bracketed label="Chat" />
<button
type="button"
onClick={onToggle}
aria-label="Collapse chat drawer"
className="inline-flex h-7 w-7 items-center justify-center rounded-[var(--ds-radius-sm)] text-[var(--ds-fg-muted)] transition-colors duration-[var(--ds-motion-fast)] hover:bg-[var(--ds-bg-elevated)] hover:text-[var(--ds-fg-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ds-accent)]"
>
<Icons.PanelRightClose className="size-4" />
</button>
</header>
{children ? (
<div className="flex-1 overflow-auto">{children}</div>
) : (
<div className="flex flex-1 flex-col gap-4 overflow-auto p-4">
<Hairline label="Awaiting wire" />
<p className="text-[var(--ds-text-sm)] text-[var(--ds-fg-muted)]">
Chat shell mounts here in M6.5.
</p>
</div>
)}
</div>
)}
</aside>
);
}
Loading
Loading