Skip to content

Commit f2e0844

Browse files
andresdjassoclaude
andcommitted
feat(mothership): home chat UX — grey tray, inline chat open, title bar, stationary panel toggle
- Restructure the home input into a stacked card (grey tray behind the input) that hosts the All Chats launcher; the chat list expands inline within the tray - Open existing chats inline with no route remount via adoptResolvedChatId (now exposed from useChat), with hover-prefetch and the slide-in morph - Add a Codex-style chat title bar (title + action menu) to the open chat view - Make the right resource-panel collapse/expand a single stationary toggle outside the animating panel, so it no longer moves on collapse - Auto-hide the chat scrollbar; reveal it only while actively scrolling - Bump the home input corner radius to 17px Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent d526b23 commit f2e0844

15 files changed

Lines changed: 557 additions & 75 deletions

File tree

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
'use client'
2+
3+
import { useEffect, useMemo, useRef, useState } from 'react'
4+
import { differenceInCalendarDays, isToday, isYesterday } from 'date-fns'
5+
import { useParams, useRouter } from 'next/navigation'
6+
import { Expandable, ExpandableContent, Skeleton } from '@/components/emcn'
7+
import { Clock, Search } from '@/components/emcn/icons'
8+
import { cn } from '@/lib/core/utils/cn'
9+
import { type TaskMetadata, usePrefetchChatHistory, useTasks } from '@/hooks/queries/tasks'
10+
11+
const CONFIG = {
12+
LIST_MAX_HEIGHT: 320,
13+
SKELETON_ROWS: 5,
14+
} as const
15+
16+
/** A recency bucket of chats rendered as one section in the history list. */
17+
interface ChatBucket {
18+
key: string
19+
label: string
20+
tasks: TaskMetadata[]
21+
}
22+
23+
/**
24+
* Buckets chats into Codex-style recency sections. Pinned chats are lifted out
25+
* of their date bucket into a dedicated section at the top; everything else is
26+
* grouped by how recently it was last updated. The server already returns the
27+
* list ordered (pinned first, then desc by `updatedAt`), so per-bucket order is
28+
* preserved by simply appending as we iterate.
29+
*/
30+
function bucketChats(tasks: readonly TaskMetadata[]): ChatBucket[] {
31+
const now = new Date()
32+
const pinned: TaskMetadata[] = []
33+
const today: TaskMetadata[] = []
34+
const yesterday: TaskMetadata[] = []
35+
const last7: TaskMetadata[] = []
36+
const last30: TaskMetadata[] = []
37+
const older: TaskMetadata[] = []
38+
39+
for (const task of tasks) {
40+
if (task.isPinned) {
41+
pinned.push(task)
42+
continue
43+
}
44+
const date = task.updatedAt
45+
if (isToday(date)) {
46+
today.push(task)
47+
} else if (isYesterday(date)) {
48+
yesterday.push(task)
49+
} else {
50+
const days = differenceInCalendarDays(now, date)
51+
if (days <= 7) last7.push(task)
52+
else if (days <= 30) last30.push(task)
53+
else older.push(task)
54+
}
55+
}
56+
57+
return (
58+
[
59+
{ key: 'pinned', label: 'Pinned', tasks: pinned },
60+
{ key: 'today', label: 'Today', tasks: today },
61+
{ key: 'yesterday', label: 'Yesterday', tasks: yesterday },
62+
{ key: 'last7', label: 'Previous 7 Days', tasks: last7 },
63+
{ key: 'last30', label: 'Previous 30 Days', tasks: last30 },
64+
{ key: 'older', label: 'Older', tasks: older },
65+
] as const
66+
).filter((bucket) => bucket.tasks.length > 0)
67+
}
68+
69+
/**
70+
* A small status dot mirroring the sidebar's semantics: yellow while a chat is
71+
* actively streaming, brand accent when it has unread activity. Rendered only
72+
* when one of those states applies.
73+
*/
74+
function StatusDot({ task }: { task: TaskMetadata }) {
75+
if (!task.isActive && !task.isUnread) return null
76+
return (
77+
<span
78+
aria-hidden='true'
79+
className='size-[6px] flex-shrink-0 rounded-full'
80+
style={{ backgroundColor: task.isActive ? '#EAB308' : 'var(--brand-accent)' }}
81+
/>
82+
)
83+
}
84+
85+
/**
86+
* A launcher into the workspace's prior Mothership chats, docked into the grey
87+
* shelf beneath the home input (Codex tray pattern). Collapsed, it's a compact
88+
* "All Chats" chip; opening animates a searchable, recency-grouped list open
89+
* INSIDE the grey tray — the shelf grows downward while the centered input
90+
* rides upward, in lockstep (300ms ease). Lives on the new-chat home view so a
91+
* chat can be resumed without the (collapsible) sidebar.
92+
*/
93+
interface ChatHistoryProps {
94+
/**
95+
* Opens the selected chat. When provided, the chat opens inline (the home
96+
* input morphs into the docked chat view) instead of navigating. Falls back
97+
* to a route push when omitted.
98+
*/
99+
onSelectChat?: (chatId: string) => void
100+
}
101+
102+
export function ChatHistory({ onSelectChat }: ChatHistoryProps) {
103+
const { workspaceId } = useParams<{ workspaceId: string }>()
104+
const router = useRouter()
105+
const prefetchChatHistory = usePrefetchChatHistory()
106+
const { data: tasks = [], isLoading } = useTasks(workspaceId)
107+
108+
const [open, setOpen] = useState(false)
109+
const [query, setQuery] = useState('')
110+
const panelRef = useRef<HTMLDivElement>(null)
111+
const inputRef = useRef<HTMLInputElement>(null)
112+
113+
const buckets = useMemo(() => {
114+
const trimmed = query.trim().toLowerCase()
115+
const filtered = trimmed
116+
? tasks.filter((task) => task.name.toLowerCase().includes(trimmed))
117+
: tasks
118+
return bucketChats(filtered)
119+
}, [tasks, query])
120+
121+
const hasChats = tasks.length > 0
122+
const hasResults = buckets.length > 0
123+
124+
/** Focus the search field and clear stale queries each time the panel opens. */
125+
useEffect(() => {
126+
if (open) {
127+
inputRef.current?.focus()
128+
} else {
129+
setQuery('')
130+
}
131+
}, [open])
132+
133+
/** Collapse on outside click or Escape, matching popover dismissal. */
134+
useEffect(() => {
135+
if (!open) return
136+
const handlePointerDown = (event: MouseEvent) => {
137+
if (!panelRef.current?.contains(event.target as Node)) setOpen(false)
138+
}
139+
const handleKeyDown = (event: KeyboardEvent) => {
140+
if (event.key === 'Escape') setOpen(false)
141+
}
142+
document.addEventListener('mousedown', handlePointerDown)
143+
document.addEventListener('keydown', handleKeyDown)
144+
return () => {
145+
document.removeEventListener('mousedown', handlePointerDown)
146+
document.removeEventListener('keydown', handleKeyDown)
147+
}
148+
}, [open])
149+
150+
const handleSelect = (chatId: string) => {
151+
setOpen(false)
152+
if (onSelectChat) {
153+
onSelectChat(chatId)
154+
return
155+
}
156+
router.push(`/workspace/${workspaceId}/task/${chatId}`)
157+
}
158+
159+
return (
160+
<div ref={panelRef} className='w-full'>
161+
<div className='flex items-center px-2 py-1.5'>
162+
<button
163+
type='button'
164+
onClick={() => setOpen((prev) => !prev)}
165+
aria-expanded={open}
166+
aria-label='All chats'
167+
className={cn(
168+
'flex items-center gap-1.5 rounded-[8px] px-2 py-1 transition-colors',
169+
'hover-hover:bg-[var(--surface-active)]',
170+
open && 'bg-[var(--surface-active)]'
171+
)}
172+
>
173+
<Clock className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
174+
<span className='text-[var(--text-body)] text-sm'>All Chats</span>
175+
</button>
176+
</div>
177+
178+
<Expandable expanded={open}>
179+
<ExpandableContent>
180+
<div className='flex flex-col px-1.5 pb-1.5'>
181+
<div className='flex items-center gap-2 px-2 py-1.5'>
182+
<Search className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
183+
<input
184+
ref={inputRef}
185+
value={query}
186+
onChange={(event) => setQuery(event.target.value)}
187+
placeholder='Search chats'
188+
aria-label='Search chats'
189+
className='w-full bg-transparent text-[var(--text-body)] text-sm placeholder:text-[var(--text-muted)] focus:outline-none'
190+
/>
191+
</div>
192+
<div
193+
className='flex flex-col overflow-y-auto overscroll-contain'
194+
style={{ maxHeight: CONFIG.LIST_MAX_HEIGHT }}
195+
>
196+
{isLoading ? (
197+
<div className='flex flex-col gap-1 px-1 py-1'>
198+
{Array.from({ length: CONFIG.SKELETON_ROWS }, (_, i) => (
199+
<Skeleton key={i} className='h-[28px] w-full' />
200+
))}
201+
</div>
202+
) : !hasChats ? (
203+
<p className='px-2 py-6 text-center text-[var(--text-muted)] text-caption'>
204+
No chats yet
205+
</p>
206+
) : !hasResults ? (
207+
<p className='px-2 py-6 text-center text-[var(--text-muted)] text-caption'>
208+
No chats found
209+
</p>
210+
) : (
211+
buckets.map((bucket) => (
212+
<div key={bucket.key} className='mt-1.5 first:mt-0'>
213+
<p className='px-2 py-1 font-medium text-[var(--text-muted)] text-caption'>
214+
{bucket.label}
215+
</p>
216+
{bucket.tasks.map((task) => (
217+
<button
218+
key={task.id}
219+
type='button'
220+
onClick={() => handleSelect(task.id)}
221+
onMouseEnter={() => prefetchChatHistory(task.id)}
222+
className='flex w-full items-center gap-2 rounded-[6px] px-2 py-1.5 text-left transition-colors hover-hover:bg-[var(--surface-active)]'
223+
>
224+
<span
225+
className={cn(
226+
'min-w-0 flex-1 truncate text-[var(--text-body)] text-sm',
227+
task.isUnread && 'font-medium text-[var(--text-primary)]'
228+
)}
229+
>
230+
{task.name}
231+
</span>
232+
<StatusDot task={task} />
233+
</button>
234+
))}
235+
</div>
236+
))
237+
)}
238+
</div>
239+
</div>
240+
</ExpandableContent>
241+
</Expandable>
242+
</div>
243+
)
244+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { ChatHistory } from './chat-history'

apps/sim/app/workspace/[workspaceId]/home/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export { ChatHistory } from './chat-history'
12
export { ChatMessageAttachments } from './chat-message-attachments'
23
export { ContextMentionIcon } from './context-mention-icon'
34
export { CreditsChip } from './credits-chip'
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
'use client'
2+
3+
import { useParams } from 'next/navigation'
4+
import {
5+
Button,
6+
DropdownMenu,
7+
DropdownMenuContent,
8+
DropdownMenuItem,
9+
DropdownMenuTrigger,
10+
} from '@/components/emcn'
11+
import { Link, MoreHorizontal, SquareArrowUpRight } from '@/components/emcn/icons'
12+
import { useTasks } from '@/hooks/queries/tasks'
13+
14+
const FALLBACK_TITLE = 'New chat'
15+
16+
interface ChatTitleBarProps {
17+
/** The open chat's id. Resolves the title from the task list and powers the actions. */
18+
chatId?: string
19+
}
20+
21+
/**
22+
* A Codex-style title bar pinned to the top of an open Mothership chat: the
23+
* chat title on the left, an action menu (kebab) on the right. The action set
24+
* is intentionally minimal for now — non-destructive, no-backend affordances —
25+
* and is the natural home for future per-chat actions (rename, pin, delete).
26+
*/
27+
export function ChatTitleBar({ chatId }: ChatTitleBarProps) {
28+
const { workspaceId } = useParams<{ workspaceId: string }>()
29+
const { data: tasks = [] } = useTasks(workspaceId)
30+
31+
const title = tasks.find((task) => task.id === chatId)?.name ?? FALLBACK_TITLE
32+
const taskPath = chatId ? `/workspace/${workspaceId}/task/${chatId}` : null
33+
34+
const handleOpenInNewTab = () => {
35+
if (taskPath) window.open(taskPath, '_blank', 'noopener,noreferrer')
36+
}
37+
38+
const handleCopyLink = () => {
39+
if (taskPath) void navigator.clipboard?.writeText(`${window.location.origin}${taskPath}`)
40+
}
41+
42+
return (
43+
<div className='flex h-[44px] flex-shrink-0 items-center gap-1 border-[var(--border)] border-b px-[24px]'>
44+
<span className='min-w-0 truncate font-medium text-[14px] text-[var(--text-primary)]'>
45+
{title}
46+
</span>
47+
<DropdownMenu>
48+
<DropdownMenuTrigger asChild>
49+
<Button
50+
variant='ghost'
51+
size={null}
52+
type='button'
53+
aria-label='Chat actions'
54+
className='size-[28px] flex-shrink-0 rounded-[8px] hover-hover:bg-[var(--surface-active)]'
55+
>
56+
<MoreHorizontal className='size-[16px] text-[var(--text-icon)]' />
57+
</Button>
58+
</DropdownMenuTrigger>
59+
<DropdownMenuContent align='end' side='bottom' sideOffset={4}>
60+
<DropdownMenuItem disabled={!taskPath} onSelect={handleOpenInNewTab}>
61+
<SquareArrowUpRight />
62+
Open in new tab
63+
</DropdownMenuItem>
64+
<DropdownMenuItem disabled={!taskPath} onSelect={handleCopyLink}>
65+
<Link />
66+
Copy link
67+
</DropdownMenuItem>
68+
</DropdownMenuContent>
69+
</DropdownMenu>
70+
</div>
71+
)
72+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { ChatTitleBar } from './chat-title-bar'

apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,10 @@ import type {
2525
QueuedMessage,
2626
} from '@/app/workspace/[workspaceId]/home/types'
2727
import { useAutoScroll } from '@/hooks/use-auto-scroll'
28+
import { useAutoHideScrollbar } from '@/hooks/use-autohide-scrollbar'
2829
import { useProgressiveList } from '@/hooks/use-progressive-list'
2930
import type { ChatContext } from '@/stores/panel'
31+
import { ChatTitleBar } from './components/chat-title-bar'
3032
import { MothershipChatSkeleton } from './components/mothership-chat-skeleton'
3133

3234
interface MothershipChatProps {
@@ -86,6 +88,17 @@ const LAYOUT_STYLES = {
8688

8789
const EMPTY_BLOCKS: ContentBlock[] = []
8890

91+
/**
92+
* Hides the scroll thumb by default and reveals it (color fade) only while the
93+
* container carries `data-scrolling="true"` — toggled by {@link useAutoHideScrollbar}.
94+
* Local override of the always-visible global scrollbar; covers WebKit + Firefox.
95+
*/
96+
const SCROLLBAR_AUTOHIDE = cn(
97+
'[&::-webkit-scrollbar-thumb]:bg-transparent [&::-webkit-scrollbar-thumb]:transition-colors [&::-webkit-scrollbar-thumb]:duration-300',
98+
'data-[scrolling=true]:[&::-webkit-scrollbar-thumb]:bg-[var(--scrollbar-thumb-color)]',
99+
'[scrollbar-color:transparent_transparent] data-[scrolling=true]:[scrollbar-color:var(--scrollbar-thumb-color)_transparent]'
100+
)
101+
89102
interface UserMessageRowProps {
90103
content: string
91104
contexts?: ChatMessageContext[]
@@ -209,6 +222,14 @@ export function MothershipChat({
209222
const { ref: scrollContainerRef, scrollToBottom } = useAutoScroll(isStreamActive, {
210223
scrollOnMount: true,
211224
})
225+
const attachAutoHideScrollbar = useAutoHideScrollbar()
226+
const setScrollContainer = useCallback(
227+
(el: HTMLDivElement | null) => {
228+
scrollContainerRef(el)
229+
attachAutoHideScrollbar(el)
230+
},
231+
[scrollContainerRef, attachAutoHideScrollbar]
232+
)
212233
const hasMessages = messages.length > 0
213234
const stagingKey = chatId ?? 'pending-chat'
214235
const { staged: stagedMessages, isStaging } = useProgressiveList(messages, stagingKey)
@@ -273,7 +294,8 @@ export function MothershipChat({
273294

274295
return (
275296
<div className={cn('flex h-full min-h-0 flex-col', className)}>
276-
<div ref={scrollContainerRef} className={styles.scrollContainer}>
297+
{layout === 'mothership-view' && <ChatTitleBar chatId={chatId} />}
298+
<div ref={setScrollContainer} className={cn(styles.scrollContainer, SCROLLBAR_AUTOHIDE)}>
277299
{isLoading && !hasMessages ? (
278300
<MothershipChatSkeleton layout={layout} />
279301
) : (

0 commit comments

Comments
 (0)