Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
19 changes: 16 additions & 3 deletions frontend/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { useCallback, useEffect, useId } from "react";
import { useCallback, useEffect, useId, useRef } from "react";
import { Outlet } from "react-router-dom";
import type { EquipmentSelection } from "../lib/hierarchy";
import { useLocalStorage } from "../lib/useLocalStorage";
import { ChatPanel } from "./chat/ChatPanel";
import { useChatStore } from "./chat/chatStore";
import { DRAWER_DEFAULT_WIDTH, DRAWER_MAX_WIDTH, DRAWER_MIN_WIDTH, Drawer } from "./Drawer";
import { TopBar } from "./TopBar";

Expand Down Expand Up @@ -57,6 +59,8 @@ export function AppShell() {
);

const safeDrawer = sanitizeDrawer(drawer);
const drawerOpenRef = useRef(safeDrawer.open);
drawerOpenRef.current = safeDrawer.open;
const drawerId = useId();

const toggleDrawer = useCallback(() => {
Expand All @@ -79,7 +83,14 @@ export function AppShell() {
if (!comboPressed) return;
if (isTypingTarget(e.target)) return;
e.preventDefault();
toggleDrawer();
if (drawerOpenRef.current) {
useChatStore.getState().requestFocus();
} else {
toggleDrawer();
window.setTimeout(() => {
useChatStore.getState().requestFocus();
}, 240);
}
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
Expand Down Expand Up @@ -112,7 +123,9 @@ export function AppShell() {
width={safeDrawer.width}
onToggle={toggleDrawer}
onWidthChange={setDrawerWidth}
/>
>
<ChatPanel />
</Drawer>
</div>
</div>
);
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/app/Drawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ export function Drawer({ open, width, onToggle, onWidthChange, children, id }: D
</button>
</header>
{children ? (
<div className="flex-1 overflow-auto">{children}</div>
<div className="flex min-h-0 flex-1 flex-col">{children}</div>
) : (
<div className="flex flex-1 flex-col gap-4 overflow-auto p-4">
<Hairline label="Awaiting wire" />
Expand Down
102 changes: 102 additions & 0 deletions frontend/src/app/chat/ChatInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react";
import { Icons } from "../../design-system";

export interface ChatInputHandle {
focus(): void;
}

export interface ChatInputProps {
onSubmit: (value: string) => void;
disabled?: boolean;
placeholder?: string;
}

const MAX_ROWS = 8;
const BASE_ROW_HEIGHT_PX = 20;

function autoResize(el: HTMLTextAreaElement) {
el.style.height = "auto";
const capped = Math.min(el.scrollHeight, BASE_ROW_HEIGHT_PX * MAX_ROWS + 24);
el.style.height = `${capped}px`;
}

export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput(
{ onSubmit, disabled = false, placeholder = "Message the operator console…" },
ref,
) {
const [value, setValue] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);

useImperativeHandle(
ref,
() => ({
focus: () => textareaRef.current?.focus(),
}),
[],
);

useEffect(() => {
if (textareaRef.current) autoResize(textareaRef.current);
}, []);

const submit = useCallback(() => {
const trimmed = value.trim();
if (!trimmed || disabled) return;
onSubmit(trimmed);
setValue("");
requestAnimationFrame(() => {
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
textareaRef.current.focus();
}
});
}, [value, disabled, onSubmit]);

const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
submit();
}
};

const canSubmit = value.trim().length > 0 && !disabled;

return (
<form
onSubmit={(e) => {
e.preventDefault();
submit();
}}
className="border-t border-[var(--ds-border)] bg-[var(--ds-bg-surface)] px-3 py-3"
>
<div className="flex items-end gap-2 rounded-[var(--ds-radius-md)] border border-[var(--ds-border)] bg-[var(--ds-bg-elevated)] px-3 py-2 transition-colors duration-[var(--ds-motion-fast)] focus-within:border-[var(--ds-border-strong)] focus-within:ring-2 focus-within:ring-[var(--ds-accent-ring)]">
<textarea
ref={textareaRef}
value={value}
onChange={(e) => {
setValue(e.target.value);
autoResize(e.target);
}}
onKeyDown={onKeyDown}
placeholder={placeholder}
disabled={disabled}
rows={1}
spellCheck
aria-label="Message input"
className="min-h-[20px] flex-1 resize-none bg-transparent text-[var(--ds-text-sm)] leading-[1.4] text-[var(--ds-fg-primary)] placeholder:text-[var(--ds-fg-subtle)] focus:outline-none disabled:opacity-50"
/>
<button
type="submit"
disabled={!canSubmit}
aria-label="Send message"
className="inline-flex h-7 w-7 flex-none items-center justify-center rounded-[var(--ds-radius-sm)] bg-[var(--ds-accent)] text-[var(--ds-accent-fg)] transition-colors duration-[var(--ds-motion-fast)] hover:bg-[var(--ds-accent-hover)] disabled:cursor-not-allowed disabled:bg-[var(--ds-bg-hover)] disabled:text-[var(--ds-fg-subtle)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ds-accent-ring)]"
>
<Icons.ArrowRight className="size-3.5" />
</button>
</div>
<p className="mt-1.5 text-[var(--ds-text-xs)] text-[var(--ds-fg-subtle)]">
Enter to send · Shift + Enter for new line
</p>
</form>
);
});
74 changes: 74 additions & 0 deletions frontend/src/app/chat/ChatPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { useEffect, useRef } from "react";
import { useShallow } from "zustand/react/shallow";
import { ChatInput, type ChatInputHandle } from "./ChatInput";
import { useChatStore } from "./chatStore";
import { MessageList } from "./MessageList";
import { useThrottledMessages } from "./useThrottledMessages";

export function ChatPanel() {
const messages = useThrottledMessages();
const { status, sendMessage, connect, focusRequestId } = useChatStore(
useShallow((s) => ({
status: s.status,
sendMessage: s.sendMessage,
connect: s.connect,
focusRequestId: s.focusRequestId,
})),
);

const inputRef = useRef<ChatInputHandle>(null);

useEffect(() => {
connect();
}, [connect]);

useEffect(() => {
if (focusRequestId > 0) {
inputRef.current?.focus();
}
}, [focusRequestId]);

return (
<div className="flex h-full min-h-0 flex-col bg-[var(--ds-bg-surface)]">
<div className="flex-none border-b border-[var(--ds-border)] px-4 py-2">
<ConnectionIndicator status={status} />
</div>
<MessageList messages={messages} />
<ChatInput ref={inputRef} onSubmit={sendMessage} disabled={status === "error"} />
</div>
);
}

function ConnectionIndicator({ status }: { status: string }) {
const label =
status === "open"
? "Connected"
: status === "connecting"
? "Connecting…"
: status === "error"
? "Connection error"
: status === "closed"
? "Disconnected"
: "Idle";

const dotColor =
status === "open"
? "var(--ds-status-nominal)"
: status === "error"
? "var(--ds-status-critical)"
: status === "connecting"
? "var(--ds-status-warning)"
: "var(--ds-fg-subtle)";

return (
<div className="flex items-center gap-2 text-[var(--ds-text-xs)] text-[var(--ds-fg-muted)]">
<span
className="inline-block size-1.5 flex-none rounded-full"
style={{ backgroundColor: dotColor }}
aria-hidden
/>
<span>{label}</span>
<span className="ml-auto font-mono text-[var(--ds-fg-subtle)]">mock</span>
</div>
);
}
157 changes: 157 additions & 0 deletions frontend/src/app/chat/Markdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import type { ComponentPropsWithoutRef } from "react";
import ReactMarkdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";

const ANCHOR_CLASS =
"underline underline-offset-2 text-[var(--ds-accent)] hover:text-[var(--ds-accent-hover)] transition-colors duration-[var(--ds-motion-fast)]";

const CODE_INLINE_CLASS =
"font-mono bg-[var(--ds-bg-elevated)] text-[var(--ds-fg-primary)] px-1.5 py-0.5 rounded-[var(--ds-radius-sm)] text-[var(--ds-text-xs)] border border-[var(--ds-border)]";

const CODE_BLOCK_CLASS =
"font-mono bg-[var(--ds-bg-elevated)] text-[var(--ds-fg-primary)] p-3 rounded-[var(--ds-radius-md)] overflow-x-auto text-[var(--ds-text-xs)] leading-relaxed border border-[var(--ds-border)]";

type CodeProps = ComponentPropsWithoutRef<"code"> & { inline?: boolean };

const components: Components = {
p: ({ children, ...rest }) => (
<p
{...rest}
className="mb-2 last:mb-0 leading-[1.55] text-[var(--ds-text-sm)] text-[var(--ds-fg-primary)]"
>
{children}
</p>
),
a: ({ children, href, ...rest }) => (
<a {...rest} href={href} target="_blank" rel="noreferrer noopener" className={ANCHOR_CLASS}>
{children}
</a>
),
strong: ({ children, ...rest }) => (
<strong {...rest} className="font-semibold text-[var(--ds-fg-primary)]">
{children}
</strong>
),
em: ({ children, ...rest }) => (
<em {...rest} className="italic text-[var(--ds-fg-primary)]">
{children}
</em>
),
ul: ({ children, ...rest }) => (
<ul
{...rest}
className="mb-2 last:mb-0 ml-4 list-disc space-y-1 text-[var(--ds-text-sm)] marker:text-[var(--ds-fg-subtle)]"
>
{children}
</ul>
),
ol: ({ children, ...rest }) => (
<ol
{...rest}
className="mb-2 last:mb-0 ml-5 list-decimal space-y-1 text-[var(--ds-text-sm)] marker:text-[var(--ds-fg-subtle)]"
>
{children}
</ol>
),
li: ({ children, ...rest }) => (
<li {...rest} className="leading-[1.55] text-[var(--ds-fg-primary)]">
{children}
</li>
),
h1: ({ children, ...rest }) => (
<h1
{...rest}
className="mb-2 mt-3 first:mt-0 text-[var(--ds-text-lg)] font-semibold tracking-[-0.01em] text-[var(--ds-fg-primary)]"
>
{children}
</h1>
),
h2: ({ children, ...rest }) => (
<h2
{...rest}
className="mb-2 mt-3 first:mt-0 text-[var(--ds-text-md)] font-semibold tracking-[-0.01em] text-[var(--ds-fg-primary)]"
>
{children}
</h2>
),
h3: ({ children, ...rest }) => (
<h3
{...rest}
className="mb-1.5 mt-2 first:mt-0 text-[var(--ds-text-sm)] font-semibold text-[var(--ds-fg-primary)]"
>
{children}
</h3>
),
blockquote: ({ children, ...rest }) => (
<blockquote
{...rest}
className="mb-2 last:mb-0 border-l-2 border-[var(--ds-border-strong)] pl-3 text-[var(--ds-fg-muted)] italic"
>
{children}
</blockquote>
),
hr: () => <hr className="my-3 border-t border-[var(--ds-border)]" />,
table: ({ children, ...rest }) => (
<div className="mb-2 last:mb-0 overflow-x-auto rounded-[var(--ds-radius-sm)] border border-[var(--ds-border)]">
<table
{...rest}
className="w-full border-collapse text-[var(--ds-text-xs)] text-[var(--ds-fg-primary)]"
>
{children}
</table>
</div>
),
thead: ({ children, ...rest }) => (
<thead {...rest} className="bg-[var(--ds-bg-elevated)] text-left">
{children}
</thead>
),
th: ({ children, ...rest }) => (
<th
{...rest}
className="border-b border-[var(--ds-border)] px-2.5 py-1.5 font-medium text-[var(--ds-fg-muted)]"
>
{children}
</th>
),
Comment on lines +104 to +116

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.

PR description mentions markdown tables with a sticky header, but the custom table components don’t apply any sticky positioning to <thead>/<th>. If sticky headers are part of the acceptance criteria, add the needed position: sticky + top: 0 (and appropriate background/z-index) styles for header cells, or update the description if it’s intentionally deferred.

Copilot uses AI. Check for mistakes.
td: ({ children, ...rest }) => (
<td
{...rest}
className="border-b border-[var(--ds-border)] px-2.5 py-1.5 align-top last:border-b-0"
>
{children}
</td>
),
pre: ({ children, ...rest }) => (
<pre {...rest} className={`mb-2 last:mb-0 ${CODE_BLOCK_CLASS}`}>
{children}
</pre>
),
code: (props: CodeProps) => {
const { inline, className = "", children, ...rest } = props;
if (inline) {
return (
<code {...rest} className={`${CODE_INLINE_CLASS} ${className}`}>
{children}
</code>
);
}
return (
<code {...rest} className={className}>
{children}
</code>
);
},
};

export interface MarkdownProps {
children: string;
}

export function Markdown({ children }: MarkdownProps) {
return (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
{children}
</ReactMarkdown>
);
}
Loading
Loading