Skip to content

Commit 62841cc

Browse files
authored
Merge pull request #84 from zestones/feat/38-chat-shell-mocked
feat(frontend): chat shell with mock WS + rAF-batched streaming (M6.5)
2 parents 426a23b + b4585b0 commit 62841cc

11 files changed

Lines changed: 1310 additions & 4 deletions

File tree

frontend/src/app/AppShell.tsx

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
import { useCallback, useEffect, useId } from "react";
1+
import { useCallback, useEffect, useId, useRef } from "react";
22
import { Outlet } from "react-router-dom";
33
import type { EquipmentSelection } from "../lib/hierarchy";
44
import { useLocalStorage } from "../lib/useLocalStorage";
5+
import { ChatPanel } from "./chat/ChatPanel";
6+
import { useChatStore } from "./chat/chatStore";
57
import { DRAWER_DEFAULT_WIDTH, DRAWER_MAX_WIDTH, DRAWER_MIN_WIDTH, Drawer } from "./Drawer";
68
import { TopBar } from "./TopBar";
79

@@ -57,6 +59,8 @@ export function AppShell() {
5759
);
5860

5961
const safeDrawer = sanitizeDrawer(drawer);
62+
const drawerOpenRef = useRef(safeDrawer.open);
63+
drawerOpenRef.current = safeDrawer.open;
6064
const drawerId = useId();
6165

6266
const toggleDrawer = useCallback(() => {
@@ -79,7 +83,14 @@ export function AppShell() {
7983
if (!comboPressed) return;
8084
if (isTypingTarget(e.target)) return;
8185
e.preventDefault();
82-
toggleDrawer();
86+
if (drawerOpenRef.current) {
87+
useChatStore.getState().requestFocus();
88+
} else {
89+
toggleDrawer();
90+
window.setTimeout(() => {
91+
useChatStore.getState().requestFocus();
92+
}, 240);
93+
}
8394
}
8495
window.addEventListener("keydown", onKey);
8596
return () => window.removeEventListener("keydown", onKey);
@@ -112,7 +123,9 @@ export function AppShell() {
112123
width={safeDrawer.width}
113124
onToggle={toggleDrawer}
114125
onWidthChange={setDrawerWidth}
115-
/>
126+
>
127+
<ChatPanel />
128+
</Drawer>
116129
</div>
117130
</div>
118131
);

frontend/src/app/Drawer.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ export function Drawer({ open, width, onToggle, onWidthChange, children, id }: D
139139
</button>
140140
</header>
141141
{children ? (
142-
<div className="flex-1 overflow-auto">{children}</div>
142+
<div className="flex min-h-0 flex-1 flex-col">{children}</div>
143143
) : (
144144
<div className="flex flex-1 flex-col gap-4 overflow-auto p-4">
145145
<Hairline label="Awaiting wire" />
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react";
2+
import { Icons } from "../../design-system";
3+
4+
export interface ChatInputHandle {
5+
focus(): void;
6+
}
7+
8+
export interface ChatInputProps {
9+
onSubmit: (value: string) => void;
10+
disabled?: boolean;
11+
placeholder?: string;
12+
}
13+
14+
const MAX_ROWS = 8;
15+
const BASE_ROW_HEIGHT_PX = 20;
16+
17+
function autoResize(el: HTMLTextAreaElement) {
18+
el.style.height = "auto";
19+
const capped = Math.min(el.scrollHeight, BASE_ROW_HEIGHT_PX * MAX_ROWS + 24);
20+
el.style.height = `${capped}px`;
21+
}
22+
23+
export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput(
24+
{ onSubmit, disabled = false, placeholder = "Message the operator console…" },
25+
ref,
26+
) {
27+
const [value, setValue] = useState("");
28+
const textareaRef = useRef<HTMLTextAreaElement>(null);
29+
30+
useImperativeHandle(
31+
ref,
32+
() => ({
33+
focus: () => textareaRef.current?.focus(),
34+
}),
35+
[],
36+
);
37+
38+
useEffect(() => {
39+
if (textareaRef.current) autoResize(textareaRef.current);
40+
}, []);
41+
42+
const submit = useCallback(() => {
43+
const trimmed = value.trim();
44+
if (!trimmed || disabled) return;
45+
onSubmit(trimmed);
46+
setValue("");
47+
requestAnimationFrame(() => {
48+
if (textareaRef.current) {
49+
textareaRef.current.style.height = "auto";
50+
textareaRef.current.focus();
51+
}
52+
});
53+
}, [value, disabled, onSubmit]);
54+
55+
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
56+
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
57+
e.preventDefault();
58+
submit();
59+
}
60+
};
61+
62+
const canSubmit = value.trim().length > 0 && !disabled;
63+
64+
return (
65+
<form
66+
onSubmit={(e) => {
67+
e.preventDefault();
68+
submit();
69+
}}
70+
className="border-t border-[var(--ds-border)] bg-[var(--ds-bg-surface)] px-3 py-2"
71+
>
72+
<div className="rounded-[var(--ds-radius-md)] border border-[var(--ds-border)] bg-[var(--ds-bg-elevated)] 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)]">
73+
<div className="flex items-end gap-2 px-3 pt-2">
74+
<textarea
75+
ref={textareaRef}
76+
value={value}
77+
onChange={(e) => {
78+
setValue(e.target.value);
79+
autoResize(e.target);
80+
}}
81+
onKeyDown={onKeyDown}
82+
placeholder={placeholder}
83+
disabled={disabled}
84+
rows={1}
85+
spellCheck
86+
aria-label="Message input"
87+
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"
88+
/>
89+
<button
90+
type="submit"
91+
disabled={!canSubmit}
92+
aria-label="Send message"
93+
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)]"
94+
>
95+
<Icons.ArrowRight className="size-3.5" />
96+
</button>
97+
</div>
98+
<p className="px-3 pb-1.5 pt-1 text-[var(--ds-text-xs)] text-[var(--ds-fg-subtle)]">
99+
Enter to send · Shift + Enter for new line
100+
</p>
101+
</div>
102+
</form>
103+
);
104+
});
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { useEffect, useRef } from "react";
2+
import { useShallow } from "zustand/react/shallow";
3+
import { ChatInput, type ChatInputHandle } from "./ChatInput";
4+
import { useChatStore } from "./chatStore";
5+
import { MessageList } from "./MessageList";
6+
import { useThrottledMessages } from "./useThrottledMessages";
7+
8+
export function ChatPanel() {
9+
const messages = useThrottledMessages();
10+
const { status, sendMessage, connect, focusRequestId } = useChatStore(
11+
useShallow((s) => ({
12+
status: s.status,
13+
sendMessage: s.sendMessage,
14+
connect: s.connect,
15+
focusRequestId: s.focusRequestId,
16+
})),
17+
);
18+
19+
const inputRef = useRef<ChatInputHandle>(null);
20+
21+
useEffect(() => {
22+
connect();
23+
}, [connect]);
24+
25+
useEffect(() => {
26+
if (focusRequestId > 0) {
27+
inputRef.current?.focus();
28+
}
29+
}, [focusRequestId]);
30+
31+
return (
32+
<div className="flex h-full min-h-0 flex-col bg-[var(--ds-bg-surface)]">
33+
<div className="flex-none border-b border-[var(--ds-border)] px-4 py-2">
34+
<ConnectionIndicator status={status} />
35+
</div>
36+
<MessageList messages={messages} />
37+
<ChatInput ref={inputRef} onSubmit={sendMessage} disabled={status === "error"} />
38+
</div>
39+
);
40+
}
41+
42+
function ConnectionIndicator({ status }: { status: string }) {
43+
const label =
44+
status === "open"
45+
? "Connected"
46+
: status === "connecting"
47+
? "Connecting…"
48+
: status === "error"
49+
? "Connection error"
50+
: status === "closed"
51+
? "Disconnected"
52+
: "Idle";
53+
54+
const dotColor =
55+
status === "open"
56+
? "var(--ds-status-nominal)"
57+
: status === "error"
58+
? "var(--ds-status-critical)"
59+
: status === "connecting"
60+
? "var(--ds-status-warning)"
61+
: "var(--ds-fg-subtle)";
62+
63+
return (
64+
<div className="flex items-center gap-2 text-[var(--ds-text-xs)] text-[var(--ds-fg-muted)]">
65+
<span
66+
className="inline-block size-1.5 flex-none rounded-full"
67+
style={{ backgroundColor: dotColor }}
68+
aria-hidden
69+
/>
70+
<span>{label}</span>
71+
<span className="ml-auto font-mono text-[var(--ds-fg-subtle)]">mock</span>
72+
</div>
73+
);
74+
}

frontend/src/app/chat/Markdown.tsx

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import type { ComponentPropsWithoutRef } from "react";
2+
import ReactMarkdown, { type Components } from "react-markdown";
3+
import remarkGfm from "remark-gfm";
4+
5+
const ANCHOR_CLASS =
6+
"underline underline-offset-2 text-[var(--ds-accent)] hover:text-[var(--ds-accent-hover)] transition-colors duration-[var(--ds-motion-fast)]";
7+
8+
const CODE_INLINE_CLASS =
9+
"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)]";
10+
11+
const CODE_BLOCK_CLASS =
12+
"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)]";
13+
14+
type CodeProps = ComponentPropsWithoutRef<"code"> & { inline?: boolean };
15+
16+
const components: Components = {
17+
p: ({ children, ...rest }) => (
18+
<p
19+
{...rest}
20+
className="mb-2 last:mb-0 leading-[1.55] text-[var(--ds-text-sm)] text-[var(--ds-fg-primary)]"
21+
>
22+
{children}
23+
</p>
24+
),
25+
a: ({ children, href, ...rest }) => (
26+
<a {...rest} href={href} target="_blank" rel="noreferrer noopener" className={ANCHOR_CLASS}>
27+
{children}
28+
</a>
29+
),
30+
strong: ({ children, ...rest }) => (
31+
<strong {...rest} className="font-semibold text-[var(--ds-fg-primary)]">
32+
{children}
33+
</strong>
34+
),
35+
em: ({ children, ...rest }) => (
36+
<em {...rest} className="italic text-[var(--ds-fg-primary)]">
37+
{children}
38+
</em>
39+
),
40+
ul: ({ children, ...rest }) => (
41+
<ul
42+
{...rest}
43+
className="mb-2 last:mb-0 ml-4 list-disc space-y-1 text-[var(--ds-text-sm)] marker:text-[var(--ds-fg-subtle)]"
44+
>
45+
{children}
46+
</ul>
47+
),
48+
ol: ({ children, ...rest }) => (
49+
<ol
50+
{...rest}
51+
className="mb-2 last:mb-0 ml-5 list-decimal space-y-1 text-[var(--ds-text-sm)] marker:text-[var(--ds-fg-subtle)]"
52+
>
53+
{children}
54+
</ol>
55+
),
56+
li: ({ children, ...rest }) => (
57+
<li {...rest} className="leading-[1.55] text-[var(--ds-fg-primary)]">
58+
{children}
59+
</li>
60+
),
61+
h1: ({ children, ...rest }) => (
62+
<h1
63+
{...rest}
64+
className="mb-2 mt-3 first:mt-0 text-[var(--ds-text-lg)] font-semibold tracking-[-0.01em] text-[var(--ds-fg-primary)]"
65+
>
66+
{children}
67+
</h1>
68+
),
69+
h2: ({ children, ...rest }) => (
70+
<h2
71+
{...rest}
72+
className="mb-2 mt-3 first:mt-0 text-[var(--ds-text-md)] font-semibold tracking-[-0.01em] text-[var(--ds-fg-primary)]"
73+
>
74+
{children}
75+
</h2>
76+
),
77+
h3: ({ children, ...rest }) => (
78+
<h3
79+
{...rest}
80+
className="mb-1.5 mt-2 first:mt-0 text-[var(--ds-text-sm)] font-semibold text-[var(--ds-fg-primary)]"
81+
>
82+
{children}
83+
</h3>
84+
),
85+
blockquote: ({ children, ...rest }) => (
86+
<blockquote
87+
{...rest}
88+
className="mb-2 last:mb-0 border-l-2 border-[var(--ds-border-strong)] pl-3 text-[var(--ds-fg-muted)] italic"
89+
>
90+
{children}
91+
</blockquote>
92+
),
93+
hr: () => <hr className="my-3 border-t border-[var(--ds-border)]" />,
94+
table: ({ children, ...rest }) => (
95+
<div className="mb-2 last:mb-0 overflow-x-auto rounded-[var(--ds-radius-sm)] border border-[var(--ds-border)]">
96+
<table
97+
{...rest}
98+
className="w-full border-collapse text-[var(--ds-text-xs)] text-[var(--ds-fg-primary)]"
99+
>
100+
{children}
101+
</table>
102+
</div>
103+
),
104+
thead: ({ children, ...rest }) => (
105+
<thead {...rest} className="bg-[var(--ds-bg-elevated)] text-left">
106+
{children}
107+
</thead>
108+
),
109+
th: ({ children, ...rest }) => (
110+
<th
111+
{...rest}
112+
className="border-b border-[var(--ds-border)] px-2.5 py-1.5 font-medium text-[var(--ds-fg-muted)]"
113+
>
114+
{children}
115+
</th>
116+
),
117+
td: ({ children, ...rest }) => (
118+
<td
119+
{...rest}
120+
className="border-b border-[var(--ds-border)] px-2.5 py-1.5 align-top last:border-b-0"
121+
>
122+
{children}
123+
</td>
124+
),
125+
pre: ({ children, ...rest }) => (
126+
<pre {...rest} className={`mb-2 last:mb-0 ${CODE_BLOCK_CLASS}`}>
127+
{children}
128+
</pre>
129+
),
130+
code: (props: CodeProps) => {
131+
const { inline, className = "", children, ...rest } = props;
132+
if (inline) {
133+
return (
134+
<code {...rest} className={`${CODE_INLINE_CLASS} ${className}`}>
135+
{children}
136+
</code>
137+
);
138+
}
139+
return (
140+
<code {...rest} className={className}>
141+
{children}
142+
</code>
143+
);
144+
},
145+
};
146+
147+
export interface MarkdownProps {
148+
children: string;
149+
}
150+
151+
export function Markdown({ children }: MarkdownProps) {
152+
return (
153+
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
154+
{children}
155+
</ReactMarkdown>
156+
);
157+
}

0 commit comments

Comments
 (0)