Skip to content

Commit cc61b11

Browse files
jsell-rhclaude
andauthored
feat(ambient-ui): chat tab, action bar, persistent chat sidebar (#1636)
## Summary - **Chat tab**: Markdown rendering (react-markdown + remark-gfm), tool call/result grouping into collapsible blocks, auto-scroll with scroll-to-top/bottom buttons and tooltips, message input - **Action bar**: Stop/Restart as primary buttons, Export/Delete in 3-dot dropdown menu with confirmation dialogs - **Persistent chat sidebar**: Pop-out from Chat tab or fleet table rows, docks right edge, resizable/collapsible, persists across navigation, full-screen on mobile - **URL state**: Active tab (`?tab=logs`) and sidebar session (`?chat=<id>`) persisted in URL — survives reload - **Runner tool args**: Accumulates TOOL_CALL_ARGS events, pushes complete tool_use with full input on TOOL_CALL_END - **Dark mode**: All chat components use semantic Tailwind tokens - **Assistant message extraction**: Pulls `last_assistant_message` from system events when assistant payload is empty ## Spec changes - Added "Persistent Chat Sidebar" requirement to ambient-ui spec - Updated Chat tab scenario with markdown rendering, tool call details, input behavior ## Test plan - [x] `npx tsc --noEmit` — zero type errors - [x] Runner tests: 22 passing (tool arg accumulation flow) - [x] Chat tab renders user/assistant/tool messages with markdown - [x] Pop-out opens sidebar, "Bring back" returns to tab - [x] Fleet table chat icon opens sidebar - [x] Sidebar persists across navigation - [x] URL params survive reload - [x] Sidebar full-screen on mobile - [x] Action bar doesn't overlap sidebar - [ ] Verify on staging with real agent sessions 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Full chat UI: chat tab and persistent resizable sidebar with live-tail scrolling, message grouping, Markdown rendering, collapsible tool-call blocks, and input (enter to send / Shift+Enter newline). * Fleet table chat button to open sidebar. * Session export (download JSON) and session delete with confirmation. * **Chores** * Updated UI/dependency list to enable dialogs, typography, and Markdown rendering. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0fd9a46 commit cc61b11

23 files changed

Lines changed: 3778 additions & 120 deletions

File tree

components/ambient-ui/package-lock.json

Lines changed: 1596 additions & 14 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

components/ambient-ui/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"test:e2e": "npx playwright test"
1414
},
1515
"dependencies": {
16+
"@radix-ui/react-alert-dialog": "^1.1.15",
1617
"@radix-ui/react-avatar": "^1.1.10",
1718
"@radix-ui/react-dialog": "^1.1.15",
1819
"@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -22,6 +23,7 @@
2223
"@radix-ui/react-slot": "^1.2.4",
2324
"@radix-ui/react-tabs": "^1.1.13",
2425
"@radix-ui/react-tooltip": "^1.2.8",
26+
"@tailwindcss/typography": "^0.5.19",
2527
"@tanstack/react-query": "^5.90.2",
2628
"@tanstack/react-table": "^8.21.3",
2729
"ambient-sdk": "file:../ambient-sdk/ts-sdk",
@@ -35,7 +37,9 @@
3537
"openid-client": "^6.8.4",
3638
"react": "^19.1.0",
3739
"react-dom": "^19.1.0",
40+
"react-markdown": "^10.1.0",
3841
"react-resizable-panels": "^3.0.6",
42+
"remark-gfm": "^4.0.1",
3943
"sonner": "^2.0.7",
4044
"tailwind-merge": "^3.3.1",
4145
"tw-animate-css": "^1.4.0",

components/ambient-ui/src/adapters/sdk-sessions.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ function createSdkSessionsAdapter(api: SessionAPI): SessionsPort {
5151
const session = await api.start(sessionId)
5252
return mapSdkSessionToDomain(session)
5353
},
54+
55+
async delete(sessionId: string): Promise<void> {
56+
await api.delete(sessionId)
57+
},
5458
}
5559
}
5660

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
'use client'
2+
3+
import { useMemo, useRef, useEffect, useState, useCallback } from 'react'
4+
import { ExternalLink, ArrowDownLeft, Bot, ChevronUp, ChevronDown } from 'lucide-react'
5+
import { Button } from '@/components/ui/button'
6+
import { Card, CardContent } from '@/components/ui/card'
7+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
8+
import type { DomainSession } from '@/domain/types'
9+
import { useSessionMessages } from '@/queries/use-session-messages'
10+
import {
11+
ChatItemsList,
12+
ChatInput,
13+
buildChatItems,
14+
} from '@/components/chat-messages'
15+
import { useChatSidebar } from '@/components/chat-sidebar-context'
16+
import { useLiveTail, LiveIndicator } from './live-tail-indicator'
17+
18+
// ---- Main Chat Tab ----
19+
20+
export function ChatTab({ session }: { session: DomainSession }) {
21+
const { data, isLoading, error } = useSessionMessages(session.id)
22+
const { openSessionId, openSidebar, closeSidebar } = useChatSidebar()
23+
const isInSidebar = openSessionId === session.id
24+
25+
const chatItems = useMemo(() => {
26+
return buildChatItems(data?.items ?? [])
27+
}, [data])
28+
29+
const chatItemCount = chatItems.length
30+
31+
const [showScrollToTop, setShowScrollToTop] = useState(false)
32+
const [scrolledUp, setScrolledUp] = useState(false)
33+
34+
const { scrollRef, sentinelRef, isAtBottom, newEventCount, scrollToBottom } =
35+
useLiveTail(chatItemCount)
36+
37+
const handleScroll = useCallback(() => {
38+
const container = scrollRef.current
39+
if (!container) return
40+
setShowScrollToTop(container.scrollTop > 300)
41+
const atBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 50
42+
setScrolledUp(!atBottom)
43+
}, [scrollRef])
44+
45+
const scrollToTop = useCallback(() => {
46+
scrollRef.current?.scrollTo({ top: 0, behavior: 'smooth' })
47+
}, [scrollRef])
48+
49+
// Auto-scroll on initial load
50+
const hasScrolledOnLoad = useRef(false)
51+
useEffect(() => {
52+
if (!isLoading && chatItemCount > 0 && !hasScrolledOnLoad.current) {
53+
hasScrolledOnLoad.current = true
54+
requestAnimationFrame(() => {
55+
scrollToBottom()
56+
})
57+
}
58+
}, [isLoading, chatItemCount, scrollToBottom])
59+
60+
if (error) {
61+
return (
62+
<div className="pt-4">
63+
<p className="text-sm text-destructive">
64+
Failed to load messages: {error.message}
65+
</p>
66+
</div>
67+
)
68+
}
69+
70+
// When the chat is open in the sidebar, show a placeholder with bring-back option
71+
if (isInSidebar) {
72+
return (
73+
<div className="pt-4">
74+
<Card className="flex flex-col items-center justify-center py-16 p-0">
75+
<CardContent className="flex flex-col items-center gap-4 text-center p-6">
76+
<Bot className="h-10 w-10 text-muted-foreground opacity-40" aria-hidden="true" />
77+
<div>
78+
<p className="text-sm font-medium text-foreground">
79+
Chat is open in the sidebar
80+
</p>
81+
<p className="text-xs text-muted-foreground mt-1">
82+
You can continue chatting from the sidebar while navigating other pages.
83+
</p>
84+
</div>
85+
<Button
86+
variant="outline"
87+
size="sm"
88+
onClick={() => closeSidebar()}
89+
aria-label="Bring chat back to this tab"
90+
>
91+
<ArrowDownLeft className="h-4 w-4 mr-1.5" />
92+
Bring back
93+
</Button>
94+
</CardContent>
95+
</Card>
96+
</div>
97+
)
98+
}
99+
100+
return (
101+
<div className="pt-4">
102+
<Card className="flex flex-col overflow-hidden p-0">
103+
{/* Header with pop-out button */}
104+
<div className="flex items-center justify-between border-b px-4 py-2">
105+
<div className="flex items-center gap-2">
106+
{isAtBottom && chatItemCount > 0 && <LiveIndicator />}
107+
</div>
108+
<Button
109+
variant="ghost"
110+
size="sm"
111+
onClick={() => openSidebar(session.id)}
112+
aria-label="Pop out chat to sidebar"
113+
className="text-xs text-muted-foreground hover:text-foreground"
114+
>
115+
<ExternalLink className="h-3.5 w-3.5 mr-1.5" />
116+
Pop out
117+
</Button>
118+
</div>
119+
120+
{/* Message area */}
121+
<CardContent className="flex-1 p-0 relative">
122+
<div
123+
ref={scrollRef}
124+
className="max-h-[600px] overflow-y-auto"
125+
role="log"
126+
aria-label="Chat messages"
127+
onScroll={handleScroll}
128+
>
129+
<ChatItemsList items={chatItems} isLoading={isLoading} />
130+
<div ref={sentinelRef} className="h-1" aria-hidden="true" />
131+
</div>
132+
133+
{/* Scroll buttons */}
134+
<TooltipProvider>
135+
<div className="absolute bottom-3 right-3 z-10 flex flex-col gap-1">
136+
<div className={`transition-all duration-200 ${showScrollToTop ? 'opacity-100 scale-100' : 'opacity-0 scale-75 pointer-events-none'}`}>
137+
<Tooltip>
138+
<TooltipTrigger asChild>
139+
<Button
140+
variant="outline"
141+
size="icon"
142+
className="h-7 w-7 rounded-full shadow-md cursor-pointer"
143+
onClick={scrollToTop}
144+
aria-label="Scroll to top"
145+
>
146+
<ChevronUp className="h-4 w-4" />
147+
</Button>
148+
</TooltipTrigger>
149+
<TooltipContent side="left">Scroll to top</TooltipContent>
150+
</Tooltip>
151+
</div>
152+
<div className={`transition-all duration-200 ${scrolledUp && chatItemCount > 0 ? 'opacity-100 scale-100' : 'opacity-0 scale-75 pointer-events-none'}`}>
153+
<Tooltip>
154+
<TooltipTrigger asChild>
155+
<Button
156+
variant="outline"
157+
size="icon"
158+
className="h-7 w-7 rounded-full shadow-md cursor-pointer"
159+
onClick={scrollToBottom}
160+
aria-label="Scroll to bottom"
161+
>
162+
<ChevronDown className="h-4 w-4" />
163+
{newEventCount > 0 && (
164+
<span className="absolute -top-1 -right-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground px-1">
165+
{newEventCount}
166+
</span>
167+
)}
168+
</Button>
169+
</TooltipTrigger>
170+
<TooltipContent side="left">Scroll to bottom</TooltipContent>
171+
</Tooltip>
172+
</div>
173+
</div>
174+
</TooltipProvider>
175+
</CardContent>
176+
177+
{/* Input area */}
178+
<ChatInput
179+
sessionId={session.id}
180+
phase={session.phase}
181+
disabled={isLoading}
182+
/>
183+
</Card>
184+
</div>
185+
)
186+
}

0 commit comments

Comments
 (0)