ai-design-system 0.1.115 → 0.1.117

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,10 +5,11 @@
5
5
  * Displays open documents as closeable tabs with dirty indicators.
6
6
  */
7
7
 
8
- import React from 'react'
8
+ import React, { useCallback, useEffect, useRef, useState } from 'react'
9
9
  import { Tabs, TabsList, TabsTrigger, Button, ScrollArea } from '@/components/primitives'
10
10
  import { Icon } from '@/components/primitives/Icon'
11
11
  import { cn } from '@/lib/utils'
12
+
12
13
  export interface DocumentFile {
13
14
  id: string
14
15
  name: string
@@ -50,6 +51,12 @@ export interface DocumentTabBarProps {
50
51
  * Callback to open/toggle the file explorer (e.g. mobile drawer)
51
52
  */
52
53
  onToggleExplorer?: () => void
54
+
55
+ /**
56
+ * Whether to show navigation scroll arrows when tabs overflow
57
+ * @default true
58
+ */
59
+ showScrollArrows?: boolean
53
60
  }
54
61
 
55
62
  /**
@@ -62,13 +69,103 @@ export interface DocumentTabBarProps {
62
69
  * - Close button (X)
63
70
  *
64
71
  * Features:
65
- * - Scrollable when tabs overflow horizontally
72
+ * - Scrollable when tabs overflow horizontally with multi-device support
73
+ * - Mouse wheel vertical-to-horizontal scroll translation
74
+ * - Auto-scroll active tab into view
75
+ * - Overflow indicators (edge gradients & navigation chevrons)
66
76
  * - Accessible tab navigation via Radix UI Tabs
67
77
  * - Close button to remove tabs
68
78
  * - Dirty state indicator
69
79
  */
70
80
  export const DocumentTabBar = React.memo<DocumentTabBarProps>(
71
- ({ tabs, activeTabId, onTabSelect, onTabClose, onToggleExplorer, className }) => {
81
+ ({
82
+ tabs,
83
+ activeTabId,
84
+ onTabSelect,
85
+ onTabClose,
86
+ onToggleExplorer,
87
+ className,
88
+ showScrollArrows = true,
89
+ }) => {
90
+ const scrollContainerRef = useRef<HTMLDivElement>(null)
91
+ const [canScrollLeft, setCanScrollLeft] = useState(false)
92
+ const [canScrollRight, setCanScrollRight] = useState(false)
93
+
94
+ const getViewport = useCallback((): HTMLElement | null => {
95
+ return (
96
+ scrollContainerRef.current?.querySelector<HTMLElement>(
97
+ '[data-slot="scroll-area-viewport"]'
98
+ ) ?? null
99
+ )
100
+ }, [])
101
+
102
+ const updateScrollState = useCallback(() => {
103
+ const viewport = getViewport()
104
+ if (!viewport) return
105
+ const { scrollLeft, scrollWidth, clientWidth } = viewport
106
+ setCanScrollLeft(scrollLeft > 2)
107
+ setCanScrollRight(scrollLeft + clientWidth < scrollWidth - 2)
108
+ }, [getViewport])
109
+
110
+ useEffect(() => {
111
+ const viewport = getViewport()
112
+ if (!viewport) return
113
+
114
+ updateScrollState()
115
+ viewport.addEventListener('scroll', updateScrollState, { passive: true })
116
+
117
+ const resizeObserver = new ResizeObserver(() => {
118
+ updateScrollState()
119
+ })
120
+ resizeObserver.observe(viewport)
121
+
122
+ return () => {
123
+ viewport.removeEventListener('scroll', updateScrollState)
124
+ resizeObserver.disconnect()
125
+ }
126
+ }, [getViewport, updateScrollState, tabs])
127
+
128
+ // Convert mouse wheel vertical scroll to horizontal scroll
129
+ const handleWheel = useCallback(
130
+ (e: React.WheelEvent) => {
131
+ if (e.deltaY === 0) return
132
+ const viewport = getViewport()
133
+ if (viewport) {
134
+ viewport.scrollLeft += e.deltaY
135
+ }
136
+ },
137
+ [getViewport]
138
+ )
139
+
140
+ // Auto-scroll active tab into view
141
+ useEffect(() => {
142
+ if (!activeTabId || !scrollContainerRef.current) return
143
+ const activeEl = scrollContainerRef.current.querySelector<HTMLElement>(
144
+ '[data-state="active"]'
145
+ )
146
+ if (activeEl) {
147
+ activeEl.scrollIntoView({
148
+ behavior: 'smooth',
149
+ block: 'nearest',
150
+ inline: 'nearest',
151
+ })
152
+ }
153
+ }, [activeTabId, tabs])
154
+
155
+ const handleScrollLeft = useCallback(() => {
156
+ const viewport = getViewport()
157
+ if (viewport) {
158
+ viewport.scrollBy({ left: -200, behavior: 'smooth' })
159
+ }
160
+ }, [getViewport])
161
+
162
+ const handleScrollRight = useCallback(() => {
163
+ const viewport = getViewport()
164
+ if (viewport) {
165
+ viewport.scrollBy({ left: 200, behavior: 'smooth' })
166
+ }
167
+ }, [getViewport])
168
+
72
169
  if (tabs.length === 0 && !onToggleExplorer) {
73
170
  return null
74
171
  }
@@ -76,7 +173,7 @@ export const DocumentTabBar = React.memo<DocumentTabBarProps>(
76
173
  return (
77
174
  <div
78
175
  className={cn(
79
- 'flex items-center border-b border-border bg-background',
176
+ 'flex items-center border-b border-border bg-background relative',
80
177
  className
81
178
  )}
82
179
  data-slot="document-tab-bar"
@@ -92,67 +189,114 @@ export const DocumentTabBar = React.memo<DocumentTabBarProps>(
92
189
  <Icon name="folder" size="xs" />
93
190
  </Button>
94
191
  )}
192
+
95
193
  {tabs.length > 0 && (
96
- <ScrollArea className="flex-1">
97
- <Tabs
98
- value={activeTabId || tabs[0]?.id}
99
- onValueChange={onTabSelect}
100
- className="h-auto flex-1"
101
- >
102
- <TabsList className="h-10 rounded-none border-none bg-transparent p-0 w-full justify-start gap-0">
103
- {tabs.map((tab) => (
194
+ <div
195
+ ref={scrollContainerRef}
196
+ onWheel={handleWheel}
197
+ className="flex-1 min-w-0 flex items-center relative"
198
+ >
199
+ {showScrollArrows && canScrollLeft && (
200
+ <Button
201
+ variant="ghost"
202
+ size="icon-sm"
203
+ onClick={handleScrollLeft}
204
+ className="h-10 w-7 shrink-0 rounded-none border-r border-border/50 text-muted-foreground hover:text-foreground transition-colors z-10"
205
+ aria-label="Scroll tabs left"
206
+ >
207
+ <Icon name="chevron-left" size="xs" />
208
+ </Button>
209
+ )}
210
+
211
+ <div className="flex-1 min-w-0 relative h-10">
212
+ {canScrollLeft && (
104
213
  <div
105
- key={tab.id}
106
- className="flex items-center border-r border-border/50 last:border-r-0"
214
+ className="pointer-events-none absolute left-0 top-0 bottom-0 w-5 bg-gradient-to-r from-background to-transparent z-10"
215
+ aria-hidden="true"
216
+ />
217
+ )}
218
+
219
+ <ScrollArea orientation="horizontal" className="w-full h-10">
220
+ <Tabs
221
+ value={activeTabId || tabs[0]?.id}
222
+ onValueChange={onTabSelect}
223
+ className="h-auto flex-1"
107
224
  >
108
- <TabsTrigger
109
- value={tab.id}
110
- className={cn(
111
- 'data-[state=inactive]:bg-muted/20 data-[state=inactive]:text-muted-foreground',
112
- 'rounded-none border-b-2 border-transparent data-[state=active]:border-primary',
113
- 'px-2.5 sm:px-3 py-2 text-xs sm:text-sm font-medium whitespace-nowrap',
114
- 'flex items-center gap-1.5 sm:gap-2 h-10',
115
- 'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0',
116
- 'transition-colors'
117
- )}
118
- >
119
- <div className="flex items-center gap-1.5 sm:gap-2">
120
- {/* Dirty indicator */}
121
- {tab.isDirty && (
122
- <Icon
123
- name="circle"
124
- className="size-2 fill-primary text-primary"
125
- aria-label="unsaved changes"
126
- />
127
- )}
128
- {/* Tab name */}
129
- <span className="truncate max-w-[120px] sm:max-w-[200px]">{tab.name}</span>
130
- </div>
131
- </TabsTrigger>
132
-
133
- {/* Close button */}
134
- <Button
135
- variant="ghost"
136
- size="icon-sm"
137
- onClick={(e) => {
138
- e.stopPropagation()
139
- onTabClose?.(tab.id)
140
- }}
141
- className={cn(
142
- 'h-8 w-8 mr-0.5',
143
- 'hover:bg-destructive/10 hover:text-destructive',
144
- 'focus-visible:ring-2 focus-visible:ring-ring',
145
- 'transition-colors'
146
- )}
147
- aria-label={`Close ${tab.name}`}
148
- >
149
- <Icon name="x" size="xs" />
150
- </Button>
151
- </div>
152
- ))}
153
- </TabsList>
154
- </Tabs>
155
- </ScrollArea>
225
+ <TabsList className="h-10 rounded-none border-none bg-transparent p-0 w-full justify-start gap-0">
226
+ {tabs.map((tab) => (
227
+ <div
228
+ key={tab.id}
229
+ className="flex items-center shrink-0 border-r border-border/50 last:border-r-0"
230
+ >
231
+ <TabsTrigger
232
+ value={tab.id}
233
+ className={cn(
234
+ 'data-[state=inactive]:bg-muted/20 data-[state=inactive]:text-muted-foreground',
235
+ 'rounded-none border-b-2 border-transparent data-[state=active]:border-primary',
236
+ 'px-2.5 sm:px-3 py-2 text-xs sm:text-sm font-medium whitespace-nowrap',
237
+ 'flex items-center gap-1.5 sm:gap-2 h-10',
238
+ 'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0',
239
+ 'transition-colors'
240
+ )}
241
+ >
242
+ <div className="flex items-center gap-1.5 sm:gap-2">
243
+ {/* Dirty indicator */}
244
+ {tab.isDirty && (
245
+ <Icon
246
+ name="circle"
247
+ className="size-2 fill-primary text-primary"
248
+ aria-label="unsaved changes"
249
+ />
250
+ )}
251
+ {/* Tab name */}
252
+ <span className="truncate max-w-[120px] sm:max-w-[200px]">{tab.name}</span>
253
+ </div>
254
+ </TabsTrigger>
255
+
256
+ {/* Close button */}
257
+ <Button
258
+ variant="ghost"
259
+ size="icon-sm"
260
+ onClick={(e) => {
261
+ e.stopPropagation()
262
+ onTabClose?.(tab.id)
263
+ }}
264
+ className={cn(
265
+ 'h-8 w-8 mr-0.5',
266
+ 'hover:bg-destructive/10 hover:text-destructive',
267
+ 'focus-visible:ring-2 focus-visible:ring-ring',
268
+ 'transition-colors'
269
+ )}
270
+ aria-label={`Close ${tab.name}`}
271
+ >
272
+ <Icon name="x" size="xs" />
273
+ </Button>
274
+ </div>
275
+ ))}
276
+ </TabsList>
277
+ </Tabs>
278
+ </ScrollArea>
279
+
280
+ {canScrollRight && (
281
+ <div
282
+ className="pointer-events-none absolute right-0 top-0 bottom-0 w-5 bg-gradient-to-l from-background to-transparent z-10"
283
+ aria-hidden="true"
284
+ />
285
+ )}
286
+ </div>
287
+
288
+ {showScrollArrows && canScrollRight && (
289
+ <Button
290
+ variant="ghost"
291
+ size="icon-sm"
292
+ onClick={handleScrollRight}
293
+ className="h-10 w-7 shrink-0 rounded-none border-l border-border/50 text-muted-foreground hover:text-foreground transition-colors z-10"
294
+ aria-label="Scroll tabs right"
295
+ >
296
+ <Icon name="chevron-right" size="xs" />
297
+ </Button>
298
+ )}
299
+ </div>
156
300
  )}
157
301
  </div>
158
302
  )
@@ -160,3 +304,4 @@ export const DocumentTabBar = React.memo<DocumentTabBarProps>(
160
304
  )
161
305
 
162
306
  DocumentTabBar.displayName = 'DocumentTabBar'
307
+
@@ -1,2 +1,2 @@
1
1
  export { DocumentTabBar } from './DocumentTabBar'
2
- export type { DocumentTabBarProps } from './DocumentTabBar'
2
+ export type { DocumentTabBarProps, DocumentFile } from './DocumentTabBar'
@@ -17,10 +17,24 @@ export interface ChatSessionInfo {
17
17
  }
18
18
 
19
19
  import type { FileDownloadResult } from '@/components/composites/FileTreeExplorer';
20
+ import { formatDayLabel } from '@/lib/date';
20
21
 
21
22
 
22
23
  export interface SessionHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
23
24
  title?: string;
25
+ /**
26
+ * What a session is called.
27
+ * - "title": the session's own title (default).
28
+ * - "date": the day it was created, as "21 Aug 2025", in the reader's time
29
+ * zone. For a surface that keeps one session per day, where the day is
30
+ * what tells sessions apart and a first message is not.
31
+ * @default "title"
32
+ */
33
+ titleFormat?: 'title' | 'date';
34
+ /**
35
+ * IANA time zone for "date" titles. Defaults to the reader's own.
36
+ */
37
+ timeZone?: string;
24
38
  sessions?: ChatSessionInfo[];
25
39
  activeSessionId?: string | null;
26
40
  onNewSession?: () => void;
@@ -48,7 +62,7 @@ export interface SessionHeaderProps extends React.HTMLAttributes<HTMLDivElement>
48
62
  * and a dropdown history of past sessions.
49
63
  */
50
64
  export const SessionHeader = React.forwardRef<HTMLDivElement, SessionHeaderProps>(
51
- ({ title, sessions, activeSessionId, onNewSession, onCloseSession, onSelectSession, onDownloadSession, showActions = true, showNewSession = true, showDownloadSession = true, onOverview, showOverview = true, className, ...props }, ref) => {
65
+ ({ title, titleFormat = 'title', timeZone, sessions, activeSessionId, onNewSession, onCloseSession, onSelectSession, onDownloadSession, showActions = true, showNewSession = true, showDownloadSession = true, onOverview, showOverview = true, className, ...props }, ref) => {
52
66
  const activeSession = sessions?.find(s => s.id === activeSessionId);
53
67
  const downloadRef = React.useRef<HTMLAnchorElement>(null);
54
68
 
@@ -66,9 +80,16 @@ export const SessionHeader = React.forwardRef<HTMLDivElement, SessionHeaderProps
66
80
  URL.revokeObjectURL(url);
67
81
  }, [onDownloadSession]);
68
82
 
69
- const displayTitle = title || (activeSessionId
70
- ? (activeSession?.title || 'Untitled Session')
71
- : 'New Session');
83
+ const byDate = titleFormat === 'date';
84
+ const sessionLabel = (session: ChatSessionInfo | undefined): string => {
85
+ if (!byDate) return session?.title || 'Untitled Session';
86
+ // A session not yet in the list is the one being started now: today's.
87
+ return formatDayLabel(session?.created_at ?? new Date(), { timeZone }) || 'Untitled Session';
88
+ };
89
+
90
+ const displayTitle = title || (activeSessionId
91
+ ? sessionLabel(activeSession)
92
+ : byDate ? formatDayLabel(new Date(), { timeZone }) : 'New Session');
72
93
 
73
94
  return (
74
95
  <div
@@ -121,8 +142,11 @@ export const SessionHeader = React.forwardRef<HTMLDivElement, SessionHeaderProps
121
142
  onClick={() => onSelectSession?.(session.id)}
122
143
  className="flex flex-col items-start py-2 cursor-pointer"
123
144
  >
124
- <span className="text-sm font-medium truncate w-full">{session.title || 'Untitled Session'}</span>
125
- <span className="text-xs text-muted-foreground">{new Date(session.created_at).toLocaleString()}</span>
145
+ <span className="text-sm font-medium truncate w-full">{sessionLabel(session)}</span>
146
+ {/* The date is the title in "date" mode; repeating it underneath says nothing. */}
147
+ {!byDate && (
148
+ <span className="text-xs text-muted-foreground">{new Date(session.created_at).toLocaleString()}</span>
149
+ )}
126
150
  </DropdownMenuItem>
127
151
  ))
128
152
  ) : (
@@ -75,7 +75,7 @@ export type { ModeToggleProps } from './ModeToggle'
75
75
 
76
76
  // DocumentTabBar Composite
77
77
  export { DocumentTabBar } from './DocumentTabBar'
78
- export type { DocumentTabBarProps } from './DocumentTabBar'
78
+ export type { DocumentTabBarProps, DocumentFile } from './DocumentTabBar'
79
79
 
80
80
  // DefaultSwitcher Composite
81
81
  export { DefaultSwitcher } from './DefaultSwitcher'
@@ -220,6 +220,17 @@ export interface ChatPanelProps {
220
220
  * Title override for the session header
221
221
  */
222
222
  sessionTitle?: string;
223
+ /**
224
+ * What sessions are called in the header and the history list: their own
225
+ * title ("title", default) or the day they were created ("date", shown as
226
+ * "21 Aug 2025" in the reader's time zone).
227
+ * @default "title"
228
+ */
229
+ sessionTitleFormat?: 'title' | 'date';
230
+ /**
231
+ * IANA time zone for "date" session titles. Defaults to the reader's own.
232
+ */
233
+ sessionTimeZone?: string;
223
234
  /**
224
235
  * Whether to show action buttons in the session header
225
236
  * @default true
@@ -304,6 +315,8 @@ export const ChatPanel = React.memo<ChatPanelProps>(
304
315
  enableSpeech = true,
305
316
  showHeader = true,
306
317
  sessionTitle,
318
+ sessionTitleFormat = 'title',
319
+ sessionTimeZone,
307
320
  showSessionActions = true,
308
321
  showNewSession = true,
309
322
  showDownloadSession = true,
@@ -468,6 +481,8 @@ export const ChatPanel = React.memo<ChatPanelProps>(
468
481
  {showHeader && (
469
482
  <SessionHeader
470
483
  title={sessionTitle}
484
+ titleFormat={sessionTitleFormat}
485
+ timeZone={sessionTimeZone}
471
486
  showActions={showSessionActions}
472
487
  showNewSession={showNewSession}
473
488
  showDownloadSession={showDownloadSession}
@@ -94,6 +94,9 @@ export type { ExternalToast, ToastT, ToasterProps } from 'sonner';
94
94
 
95
95
  // Utilities
96
96
  export { cn } from '@/lib/utils';
97
+ // Calendar days in the reader's time zone
98
+ export { formatDayLabel, dayKey, isSameDay, userTimeZone } from '@/lib/date';
99
+ export type { DayOptions } from '@/lib/date';
97
100
  export { ButtonSwitcher } from './composites';
98
101
  export type { ButtonSwitcherProps, ButtonSwitcherItem } from './composites';
99
102
 
@@ -5,11 +5,17 @@ import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
5
5
 
6
6
  import { cn } from "@/lib/utils"
7
7
 
8
+ export interface ScrollAreaProps
9
+ extends React.ComponentProps<typeof ScrollAreaPrimitive.Root> {
10
+ orientation?: "vertical" | "horizontal" | "both"
11
+ }
12
+
8
13
  function ScrollArea({
9
14
  className,
10
15
  children,
16
+ orientation = "vertical",
11
17
  ...props
12
- }: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
18
+ }: ScrollAreaProps) {
13
19
  return (
14
20
  <ScrollAreaPrimitive.Root
15
21
  data-slot="scroll-area"
@@ -22,7 +28,12 @@ function ScrollArea({
22
28
  >
23
29
  {children}
24
30
  </ScrollAreaPrimitive.Viewport>
25
- <ScrollBar />
31
+ {(orientation === "vertical" || orientation === "both") && (
32
+ <ScrollBar orientation="vertical" />
33
+ )}
34
+ {(orientation === "horizontal" || orientation === "both") && (
35
+ <ScrollBar orientation="horizontal" className="h-1.5 border-t border-t-transparent" />
36
+ )}
26
37
  <ScrollAreaPrimitive.Corner />
27
38
  </ScrollAreaPrimitive.Root>
28
39
  )