-
Notifications
You must be signed in to change notification settings - Fork 13
feat(frontend): chat shell with mock WS + rAF-batched streaming (M6.5) #84
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 1 commit
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ), | ||
| 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> | ||
| ); | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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 neededposition: sticky+top: 0(and appropriate background/z-index) styles for header cells, or update the description if it’s intentionally deferred.