- {streamingText ? (
- // aria-atomic="false": only the newly-appended text is announced as it streams in,
- // rather than re-reading the whole growing reply on every token.
-
diff --git a/frontend/src/features/chat/components/SessionHistory.css b/frontend/src/features/chat/components/SessionHistory.css
new file mode 100644
index 0000000..6a0c2a6
--- /dev/null
+++ b/frontend/src/features/chat/components/SessionHistory.css
@@ -0,0 +1,70 @@
+.tu-session-history {
+ flex: none;
+ width: 220px;
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+ padding: var(--space-4) var(--space-3) var(--space-4) 0;
+ border-right: 1px solid var(--color-hairline);
+ overflow-y: auto;
+}
+
+.tu-session-history__new {
+ width: 100%;
+ justify-content: flex-start;
+}
+
+.tu-session-history__list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.tu-session-history__item {
+ width: 100%;
+ text-align: left;
+ padding: var(--space-2);
+ border: none;
+ border-radius: var(--radius-sm);
+ background: none;
+ color: var(--color-text-secondary);
+ font-size: var(--font-size-caption);
+ cursor: pointer;
+ transition: background var(--duration-fast) var(--ease-standard);
+}
+
+.tu-session-history__item:hover {
+ background: var(--color-surface-sunken);
+}
+
+.tu-session-history__item--active {
+ background: var(--color-accent-subtle);
+ color: var(--color-text);
+ font-weight: var(--font-weight-body-medium);
+}
+
+.tu-session-history__item:disabled {
+ cursor: not-allowed;
+ opacity: 0.6;
+}
+
+/* Below 768px (the app's one real breakpoint), the sidebar is a mobile-only inline collapse, not
+ an overlay -- it pushes the transcript down when open rather than blocking interaction with it,
+ so it doesn't need a focus trap the way a true modal panel would. */
+@media (max-width: 767px) {
+ .tu-session-history {
+ display: none;
+ }
+
+ .tu-session-history--open {
+ display: flex;
+ width: 100%;
+ border-right: none;
+ border-bottom: 1px solid var(--color-hairline);
+ padding: 0 0 var(--space-4);
+ max-height: 240px;
+ }
+}
diff --git a/frontend/src/features/chat/components/SessionHistory.jsx b/frontend/src/features/chat/components/SessionHistory.jsx
new file mode 100644
index 0000000..8c2331c
--- /dev/null
+++ b/frontend/src/features/chat/components/SessionHistory.jsx
@@ -0,0 +1,50 @@
+import { Button } from '../../../components/Button'
+import { formatDateTime } from '../../../utils/format.js'
+import './SessionHistory.css'
+
+/**
+ * A basic past-conversations list. The backend only returns `{id, status, created_at}` per
+ * session (no preview text), so each item is labeled by its real timestamp rather than an
+ * invented title — a first-message preview would need a backend addition, flagged as a follow-up
+ * rather than built here. `open` only matters below the 768px breakpoint (desktop shows this
+ * unconditionally via CSS); switching is disabled while a stream is in progress, since there's no
+ * safe way to abandon an in-flight turn mid-switch.
+ */
+export function SessionHistory({ sessions, activeSessionId, onSelect, onStartNew, disabled, open }) {
+ return (
+
+ )
+}
diff --git a/frontend/src/features/chat/components/ToolTraceDisclosure.css b/frontend/src/features/chat/components/ToolTraceDisclosure.css
index 1b4cf1b..e1ba394 100644
--- a/frontend/src/features/chat/components/ToolTraceDisclosure.css
+++ b/frontend/src/features/chat/components/ToolTraceDisclosure.css
@@ -18,21 +18,36 @@
color: var(--color-text-secondary);
}
-/* Distinct from the assistant bubble's own `--color-surface-sunken` background so the trace reads
- as a panel inside the bubble rather than disappearing into it. */
+/* A distinct sunken panel so the trace reads as an aside, not a continuation of the reply's own
+ flowing text. Plain sans, not monospace: the labels are humanized phrases now, not raw
+ identifiers, so this should read as prose, not a log. */
.tu-tool-trace__list {
margin-top: var(--space-2);
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
padding: var(--space-2) var(--space-3);
- background: var(--color-surface);
+ background: var(--color-surface-sunken);
border: 1px solid var(--color-hairline);
border-radius: var(--radius-sm);
font-size: var(--font-size-caption);
- font-family: ui-monospace, monospace;
- white-space: pre-wrap;
- word-break: break-word;
animation: tu-tool-trace-in var(--duration-fast) var(--ease-standard);
}
+.tu-tool-trace__item {
+ display: flex;
+ gap: var(--space-2);
+}
+
+.tu-tool-trace__item-label {
+ flex: none;
+ color: var(--color-text);
+}
+
+.tu-tool-trace__item-summary {
+ color: var(--color-text-muted);
+}
+
@keyframes tu-tool-trace-in {
from {
opacity: 0;
diff --git a/frontend/src/features/chat/components/ToolTraceDisclosure.jsx b/frontend/src/features/chat/components/ToolTraceDisclosure.jsx
index 596c16e..90c9848 100644
--- a/frontend/src/features/chat/components/ToolTraceDisclosure.jsx
+++ b/frontend/src/features/chat/components/ToolTraceDisclosure.jsx
@@ -2,6 +2,15 @@ import { useState } from 'react'
import { Icon } from '../../../components/Icon'
import './ToolTraceDisclosure.css'
+// The only two tools the assistant can call (chat_orchestration_service.py's system prompt names
+// them explicitly). Mapped to plain phrases so a Python identifier never reaches a customer on a
+// financial platform's own surface; an unrecognized future tool_name still falls back to itself
+// rather than rendering nothing.
+const TOOL_LABELS = {
+ get_database_schema: 'Looked up what data is available',
+ execute_read_only_sql: 'Looked up your data',
+}
+
/** S11: only rendered on request, not raw SQL by default. Field names match the real backend
* payload (`ChatToolCallSummaryView` in `backend/app/views/chat.py`): `tool_name` + `summary` —
* never `tool`/`arguments`/`result`, which don't exist on the wire. */
@@ -26,8 +35,11 @@ export function ToolTraceDisclosure({ toolCalls }) {
{expanded && (
{toolCalls.map((call, index) => (
-
- {call.tool_name}: {call.summary}
+
+
+ {TOOL_LABELS[call.tool_name] || call.tool_name}
+
+ {call.summary}
))}
diff --git a/frontend/src/features/chat/hooks/useChatSession.js b/frontend/src/features/chat/hooks/useChatSession.js
index 51fbd8c..bfbbe11 100644
--- a/frontend/src/features/chat/hooks/useChatSession.js
+++ b/frontend/src/features/chat/hooks/useChatSession.js
@@ -1,27 +1,48 @@
import { useCallback, useEffect, useState } from 'react'
import { chatApi } from '../api/chatApi.js'
-const IDLE = { status: 'idle', sessionId: null, messages: [], error: null }
+const IDLE = { status: 'idle', sessionId: null, messages: [], sessions: [], error: null }
+/**
+ * Owns "which session, what messages, what past sessions exist" as one piece of state
+ * (frontend/CLAUDE.md: merge state that always changes together). On mount, resumes the most
+ * recent session (`listSessions` is ordered newest-first by the backend --
+ * `chat_session.py`'s `order_by(ChatSession.created_at.desc())`) instead of always starting a
+ * blank one, so a conversation survives navigating away and back. `selectSession`/
+ * `startNewSession` are the two explicit ways to change that from the session-history sidebar.
+ */
export function useChatSession() {
const [state, setState] = useState(IDLE)
useEffect(() => {
let cancelled = false
- async function start() {
+ async function resumeOrStart() {
setState((prev) => ({ ...prev, status: 'loading' }))
try {
+ // A missing/failed history list shouldn't block the chat itself from working — fall back
+ // to an empty list and just start a fresh session.
+ const sessions = await chatApi.listSessions().catch(() => [])
+ if (cancelled) return
+
+ if (sessions.length > 0) {
+ const mostRecent = sessions[0]
+ const messages = await chatApi.getMessages(mostRecent.id)
+ if (cancelled) return
+ setState({ status: 'loaded', sessionId: mostRecent.id, messages, sessions, error: null })
+ return
+ }
+
const session = await chatApi.createSession()
if (cancelled) return
- setState({ status: 'loaded', sessionId: session.id, messages: [], error: null })
+ setState({ status: 'loaded', sessionId: session.id, messages: [], sessions: [], error: null })
} catch (error) {
if (cancelled) return
- setState({ status: 'error', sessionId: null, messages: [], error })
+ setState({ status: 'error', sessionId: null, messages: [], sessions: [], error })
}
}
- start()
+ resumeOrStart()
return () => {
cancelled = true
}
@@ -31,5 +52,32 @@ export function useChatSession() {
setState((prev) => ({ ...prev, messages: [...prev.messages, ...newMessages] }))
}, [])
- return { ...state, appendMessages }
+ const selectSession = useCallback(async (sessionId) => {
+ setState((prev) => ({ ...prev, status: 'loading' }))
+ try {
+ const messages = await chatApi.getMessages(sessionId)
+ setState((prev) => ({ ...prev, status: 'loaded', sessionId, messages, error: null }))
+ } catch (error) {
+ setState((prev) => ({ ...prev, status: 'error', error }))
+ }
+ }, [])
+
+ const startNewSession = useCallback(async () => {
+ setState((prev) => ({ ...prev, status: 'loading' }))
+ try {
+ const session = await chatApi.createSession()
+ setState((prev) => ({
+ ...prev,
+ status: 'loaded',
+ sessionId: session.id,
+ messages: [],
+ sessions: [{ id: session.id, status: 'idle', created_at: new Date().toISOString() }, ...prev.sessions],
+ error: null,
+ }))
+ } catch (error) {
+ setState((prev) => ({ ...prev, status: 'error', error }))
+ }
+ }, [])
+
+ return { ...state, appendMessages, selectSession, startNewSession }
}
diff --git a/frontend/src/features/chat/hooks/useChatStream.js b/frontend/src/features/chat/hooks/useChatStream.js
index 48e0e67..a2263ad 100644
--- a/frontend/src/features/chat/hooks/useChatStream.js
+++ b/frontend/src/features/chat/hooks/useChatStream.js
@@ -40,5 +40,14 @@ export function useChatStream(sessionId) {
controllerRef.current?.abort()
}, [])
- return { status, streamingText, error, send, stop }
+ // Switching to a different session must not leave a stale error/streaming state from whatever
+ // the previous session's stream was doing bleeding into the newly-selected transcript.
+ const reset = useCallback(() => {
+ controllerRef.current?.abort()
+ setStatus('idle')
+ setStreamingText('')
+ setError(null)
+ }, [])
+
+ return { status, streamingText, error, send, stop, reset }
}
diff --git a/frontend/src/features/chat/utils/renderMarkdown.jsx b/frontend/src/features/chat/utils/renderMarkdown.jsx
index 9dfffec..6c73681 100644
--- a/frontend/src/features/chat/utils/renderMarkdown.jsx
+++ b/frontend/src/features/chat/utils/renderMarkdown.jsx
@@ -1,15 +1,32 @@
/**
- * Minimal Markdown renderer for streamed assistant chat replies — bold/italic/inline-code/lists
- * only, matching `structure.md`'s framing of this surface as short-form streamed text, not
- * documents. Hand-rolled instead of a library (e.g. react-markdown + remark/rehype): a reply here
- * is a sentence or two, occasionally a short list, never headings/tables/nested blocks, so a full
- * CommonMark pipeline is a lot of dependency weight for four inline/block rules. Output is plain
- * React elements — never `dangerouslySetInnerHTML` — so it can't become an XSS vector no matter
- * what the model returns, and partially-streamed text (an unclosed `**`/`` ` ``) just renders as a
- * literal character until the closing marker arrives on a later token.
+ * Minimal Markdown renderer for streamed assistant chat replies — bold/italic/inline-code/lists/
+ * GFM pipe tables, matching `structure.md`'s framing of this surface as short-form streamed text,
+ * not documents. Hand-rolled instead of a library (e.g. react-markdown + remark/rehype): a reply
+ * here is a sentence or two, occasionally a short list or table, never headings/nested blocks, so
+ * a full CommonMark pipeline is a lot of dependency weight for a handful of inline/block rules.
+ * Output is plain React elements — never `dangerouslySetInnerHTML` — so it can't become an XSS
+ * vector no matter what the model returns, and partially-streamed text (an unclosed `**`/`` ` ``,
+ * or a table whose closing rows haven't arrived yet) just renders as literal characters or a
+ * plain paragraph until enough of it exists to parse.
+ *
+ * Table support exists because the chat system prompt (chat_orchestration_service.py's
+ * `_SYSTEM_PROMPT_TEMPLATE`, rule 6) asks the model to format multi-row results -- holdings,
+ * transactions, tax lots -- as a table, and gpt-4o-mini reliably does this unprompted anyway for
+ * tabular data. Tables render through the app's real `Table` compound component
+ * (`components/Table/`), not bespoke markup, so a chat table looks like every other table.
*/
+import { Table } from '../../../components/Table'
+
const INLINE_PATTERN = /(\*\*[^*]+\*\*|`[^`]+`|\*[^*]+\*)/g
+// GFM separator row: one or more `-`-runs (each side optionally `:` for alignment), pipe-joined,
+// with optional leading/trailing pipes -- e.g. `| --- | :--- | ---: |` or `--- | ---`.
+const TABLE_SEPARATOR_PATTERN = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/
+
+function splitTableRow(line) {
+ const trimmed = line.trim().replace(/^\|/, '').replace(/\|$/, '')
+ return trimmed.split('|').map((cell) => cell.trim())
+}
function renderInline(text, keyPrefix) {
return text
@@ -70,7 +87,54 @@ export function renderMarkdown(text) {
listType = null
}
- for (const line of lines) {
+ const pushTable = (headerCells, rows) => {
+ const blockKey = `table-${blocks.length}`
+ blocks.push(
+
+
+ {headerCells.map((cell, index) => (
+
+ {renderInline(cell, `${blockKey}-h-${index}`)}
+
+ ))}
+
+
+ {rows.map((row, rowIndex) => (
+
+ {row.map((cell, cellIndex) => (
+
+ {renderInline(cell, `${blockKey}-r-${rowIndex}-c-${cellIndex}`)}
+
+ ))}
+
+ ))}
+
+
,
+ )
+ }
+
+ let i = 0
+ while (i < lines.length) {
+ const line = lines[i]
+ const nextLine = lines[i + 1]
+
+ // A table needs its header row to actually contain a pipe and its very next line to be a
+ // real separator row -- otherwise a stray `|` in prose (e.g. "cash | investable") must never
+ // be mistaken for a table.
+ if (line.includes('|') && nextLine !== undefined && TABLE_SEPARATOR_PATTERN.test(nextLine)) {
+ flushList()
+ flushParagraph()
+ const headerCells = splitTableRow(line)
+ const rows = []
+ i += 2
+ while (i < lines.length && lines[i].trim() !== '' && lines[i].includes('|')) {
+ rows.push(splitTableRow(lines[i]))
+ i += 1
+ }
+ pushTable(headerCells, rows)
+ continue
+ }
+
const bulletMatch = /^\s*[-*]\s+(.+)/.exec(line)
const orderedMatch = /^\s*\d+\.\s+(.+)/.exec(line)
@@ -97,6 +161,7 @@ export function renderMarkdown(text) {
flushList()
paraLines.push(line)
}
+ i += 1
}
flushList()
flushParagraph()
diff --git a/frontend/src/pages/AppLayout.css b/frontend/src/pages/AppLayout.css
index 1dc7515..c2903e1 100644
--- a/frontend/src/pages/AppLayout.css
+++ b/frontend/src/pages/AppLayout.css
@@ -359,6 +359,18 @@
.tu-app-layout__main {
flex: 1;
+ min-height: 0;
+ /* `display: flex` here (not just `flex: 1` on this element as somebody else's child) is the
+ actual fix: this element's own `height` CSS property is still `auto` even once flexbox has
+ given it a real 833px-style computed size -- a `height: 100%` on ITS child can't resolve
+ against that (percentage heights need an ancestor with a definite `height` property, and
+ flex-grow's resolved size doesn't count as one). Making this a flex container lets a page
+ that wants a bounded, internally-scrolling area (ChatPage) use `flex: 1` on itself instead,
+ which propagates through pure flex-grow with no percentage-height gotcha. A single child with
+ the default `align-items: stretch` behaves identically to normal block flow for every other
+ page, which already centers itself via max-width + margin auto. */
+ display: flex;
+ flex-direction: column;
}
@media (max-width: 767px) {
diff --git a/frontend/src/pages/ChatPage.css b/frontend/src/pages/ChatPage.css
new file mode 100644
index 0000000..9ecdf68
--- /dev/null
+++ b/frontend/src/pages/ChatPage.css
@@ -0,0 +1,42 @@
+/* Chat needs a real, bounded height (composer pinned to the bottom, transcript scrolling
+ internally) rather than the generic .tu-page's grow-with-content shape every other page wants
+ -- so this page opts out of PageLayout.css entirely rather than fighting its cascade. `flex: 1`
+ (not `height: 100%`) is deliberate: its parent, `.tu-app-layout__main`, is itself a flex
+ container now specifically so this can size via flex-grow rather than percentage height. */
+.tu-chat-page {
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ max-width: var(--content-max-width);
+ margin: 0 auto;
+ width: 100%;
+ padding: var(--space-6) var(--space-6) 0;
+}
+
+.tu-chat-page__header {
+ flex: none;
+ padding-bottom: var(--space-4);
+}
+
+.tu-chat-page__title {
+ font-size: var(--font-size-heading-lg);
+ line-height: var(--line-height-heading-lg);
+ font-weight: var(--font-weight-heading-lg);
+ color: var(--color-text);
+ margin: 0;
+}
+
+.tu-chat-page__subtitle {
+ font-size: var(--font-size-body);
+ color: var(--color-text-secondary);
+ margin: var(--space-1) 0 0;
+}
+
+.tu-chat-page__body {
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ gap: var(--space-4);
+ border-top: 1px solid var(--color-hairline);
+}
diff --git a/frontend/src/pages/ChatPage.jsx b/frontend/src/pages/ChatPage.jsx
index fe1f8a1..fb6a229 100644
--- a/frontend/src/pages/ChatPage.jsx
+++ b/frontend/src/pages/ChatPage.jsx
@@ -1,11 +1,18 @@
import { ChatWindow } from '../features/chat/components/ChatWindow.jsx'
-import './PageLayout.css'
+import './ChatPage.css'
export function ChatPage() {
return (
-
-
Ask Trueup
-
+
+
+
Ask Trueup
+
+ Ask about your balance, holdings, orders, and account activity.
+
+
+
+
+
)
}
diff --git a/frontend/src/utils/apiErrorMessage.js b/frontend/src/utils/apiErrorMessage.js
index b2ca79d..11bf00b 100644
--- a/frontend/src/utils/apiErrorMessage.js
+++ b/frontend/src/utils/apiErrorMessage.js
@@ -8,6 +8,8 @@ const MESSAGES_BY_CODE = {
conflict: 'That request has already been submitted.',
too_many_requests: 'Too many attempts — please wait a moment and try again.',
network_error: 'Network error — check your connection and try again.',
+ daily_chat_query_cap_exceeded: "You've reached today's question limit — try again tomorrow.",
+ chat_turn_in_progress: 'Still working on your last question — one moment.',
}
export function getErrorMessage(error, fallback = 'Something went wrong. Please try again.') {