feat(web): swap space assets from the title bar
Moving between a space's repos, boards, agents and channels meant going back to the sidebar every time. Turn the shell title into a searchable menu: type a few letters, arrow through the ranked matches and jump straight there. It opens from every space surface (chat, board, repo) and falls back to the plain heading where it has nothing to offer, such as the private-message space.
ZhenYi committed
Modified web/src/features/chat/ui/chat-room.tsx web/src/features/chat/ui/chat-room.tsx +10 −12 View web/src/features/chat/ui/chat-room.tsx
@@ -22,6 +22,7 @@
2222 type CSSProperties,
2323 type KeyboardEvent as ReactKeyboardEvent,
2424 type PointerEvent as ReactPointerEvent,
25 type ReactNode,
2526} from 'react'
2627import {
2728 BellOff,
@@ -34,7 +35,6 @@
3435 Phone,
3536 Pin,
3637 RefreshCw,
37 Search,
3838 Settings2,
3939 Star,
4040 Users,
@@ -107,6 +107,8 @@
107107 viewerId?: string
108108 /** Sidebar row, used only until the authoritative room row arrives. */
109109 hint?: { name?: string; topic?: string | null; kind?: RoomKindName; peer?: UserInfo | null }
110 /** Replaces the plain title heading with an interactive node (e.g. the asset search menu). */
111 titleMenu?: (title: string) => ReactNode
110112 /** Space-level `manage_channels`: channel settings and deletion. */
111113 canManageSpace?: boolean
112114 state: ChatViewState
@@ -126,6 +128,7 @@
126128 roomId,
127129 viewerId,
128130 hint,
131 titleMenu,
129132 canManageSpace = false,
130133 state,
131134 onStateChange,
@@ -488,7 +491,11 @@
488491 </span>
489492 <div className="chat-heading">
490493 <div className="flex min-w-0 items-center gap-2">
491 <h1 className="truncate text-[16px] font-semibold" title={title}>{title}</h1>
494 {titleMenu ? (
495 titleMenu(title)
496 ) : (
497 <h1 className="truncate text-[16px] font-semibold" title={title}>{title}</h1>
498 )}
492499 {room?.is_private ? <Lock className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" /> : null}
493500 {slowModeSeconds > 0 ? (
494501 <span
@@ -521,15 +528,6 @@
521528 </IconAction>
522529 ) : null}
523530 <IconAction
524 label={t('chat.search.placeholder')}
525 className="chat-search-action"
526 active={state.panel === 'search'}
527 onClick={() => togglePanel('search')}
528 >
529 <Search className="h-[17px] w-[17px]" />
530 <span className="chat-action-label">{t('chat.search.placeholder')}</span>
531 </IconAction>
532 <IconAction
533531 label={t('chat.room.pins')}
534532 active={state.panel === 'pins'}
535533 onClick={() => togglePanel('pins')}
@@ -911,7 +909,7 @@
911909
912910function UserAvatarOrInitial({ hint }: { hint?: { peer?: UserInfo | null } }) {
913911 if (!hint?.peer) return <Users className="h-[18px] w-[18px] shrink-0 text-shell-ink-muted" />
914 return <UserAvatar user={hint.peer} id={hint.peer.user_id} size={22} />
912 return <UserAvatar user={hint.peer} id={hint.peer.user_id} size={38} />
915913}
916914
917915function ConnectionBanner({ status, onRetry }: { status: string; onRetry: () => void }) {
Modified web/src/pages/workspace/ui/chat/chat-view.tsx web/src/pages/workspace/ui/chat/chat-view.tsx +6 −0 View web/src/pages/workspace/ui/chat/chat-view.tsx
@@ -10,6 +10,7 @@
1010import { ChatRoom, type ChatViewState } from '@/features/chat'
1111import type { DmBrief, RoomBrief } from '@/entities/chat'
1212import { ChatLayout } from '../layout/chat-layout'
13import { SpaceAssetMenu } from '../asset-menu'
1314import { useChatViewLocation } from '../../model/chat-location'
1415
1516export interface ChatViewProps {
@@ -105,6 +106,11 @@
105106 spaceName={spaceName}
106107 roomId={roomId}
107108 viewerId={viewerId}
109 titleMenu={
110 spaceName !== '@me' && spaceName !== ''
111 ? node => <SpaceAssetMenu title={node} />
112 : undefined
113 }
108114 hint={
109115 roomHint
110116 ? { name: roomHint.name, topic: roomHint.topic ?? null, kind: roomHint.kind }
Modified web/src/pages/workspace/ui/layout/board-layout.tsx web/src/pages/workspace/ui/layout/board-layout.tsx +3 −1 View web/src/pages/workspace/ui/layout/board-layout.tsx
@@ -2,6 +2,7 @@
22import { Plus, SquareKanban } from 'lucide-react'
33import { useT } from '@/shared/i18n'
44import { AppShell, ShellTopBar, type AppShellProps } from '@/shared/ui'
5import { SpaceAssetMenu } from '../asset-menu'
56
67/**
78 * Issue-board main-column layout (design/layout.pen “GitData · 工单看板”):
@@ -44,8 +45,9 @@
4445 return (
4546 <AppShell {...shell}>
4647 <ShellTopBar
47 icon={<SquareKanban className="h-[18px] w-[18px] shrink-0 text-shell-ink-muted" />}
48 icon={<SquareKanban className="h-[18px] w-[18px] shrink-0" />}
4849 title={title}
50 titleMenu={<SpaceAssetMenu title={title} />}
4951 topic={topic}
5052 tabs={tabs}
5153 actions={actions}
Modified web/src/pages/workspace/ui/layout/chat-layout.tsx web/src/pages/workspace/ui/layout/chat-layout.tsx +3 −1 View web/src/pages/workspace/ui/layout/chat-layout.tsx
@@ -1,5 +1,6 @@
11import { Hash } from 'lucide-react'
22import { AppShell, ShellTopBar, type AppShellProps } from '@/shared/ui'
3import { SpaceAssetMenu } from '../asset-menu'
34
45/**
56 * Chat main-column layout (design/layout.pen “Discord App”).
@@ -24,8 +25,9 @@
2425 <AppShell {...shell}>
2526 {title ? (
2627 <ShellTopBar
27 icon={<Hash className="h-[18px] w-[18px] shrink-0 text-shell-ink-muted" />}
28 icon={<Hash className="h-[18px] w-[18px] shrink-0" />}
2829 title={title}
30 titleMenu={<SpaceAssetMenu title={title} />}
2931 topic={topic}
3032 actions={actions}
3133 />
Modified web/src/pages/workspace/ui/layout/repo-layout.tsx web/src/pages/workspace/ui/layout/repo-layout.tsx +8 −3 View web/src/pages/workspace/ui/layout/repo-layout.tsx
@@ -3,6 +3,7 @@
33import { useT, type MessageKey } from '@/shared/i18n'
44import { AppShell, ShellTopBar, type AppShellProps } from '@/shared/ui'
55import type { RepoTabId } from '../../model/route'
6import { SpaceAssetMenu } from '../asset-menu'
67
78/**
89 * Repository-browsing main-column layout (design/layout.pen “GitData · 仓库浏览”):
@@ -29,6 +30,8 @@
2930 canAdmin?: boolean
3031 /** Interactive branch/tag picker replacing the static ref chips. */
3132 refsSlot?: ReactNode
33 /** Hide the toolbar refs row when the page owns it inside its content. */
34 showRefs?: boolean
3235 /** Right segment of the repo toolbar (clone box). */
3336 clone?: ReactNode
3437 /** Default branch shown on the branch chip. */
@@ -63,6 +66,7 @@
6366 onTabChange,
6467 canAdmin = false,
6568 refsSlot,
69 showRefs = true,
6670 clone,
6771 branch,
6872 ref,
@@ -77,13 +81,14 @@
7781 return (
7882 <AppShell {...shell}>
7983 <ShellTopBar
80 icon={<FolderGit2 className="h-[18px] w-[18px] shrink-0 text-shell-ink-muted" />}
84 icon={<FolderGit2 className="h-[18px] w-[18px] shrink-0" />}
8185 title={title}
86 titleMenu={<SpaceAssetMenu title={title} />}
8287 topic={topic}
8388 actions={actions}
8489 />
8590 <div className="repo-toolbar shrink-0">
86 <div className="repo-toolbar-refs">{refsSlot ?? (
91 {showRefs ? <div className="repo-toolbar-refs">{refsSlot ?? (
8792 <>
8893 <span className="flex h-[30px] shrink-0 items-center gap-1.5 rounded-md bg-shell-field px-2.5 text-[13px] font-medium">
8994 <GitBranch className="h-3.5 w-3.5 text-shell-ink-muted" />
@@ -97,7 +102,7 @@
97102 </span>
98103 </>
99104 )}
100 </div>
105 </div> : null}
101106 <div className="repo-toolbar-tabs">{tabs ?? <DefaultTabs active={activeTab} onChange={onTabChange} canAdmin={canAdmin} tabOptions={tabOptions} />}</div>
102107 <div className={clone ? 'repo-toolbar-clone' : 'repo-toolbar-clone empty'}>
103108 {showAside ? (
Added web/src/pages/workspace/ui/asset-menu.tsx web/src/pages/workspace/ui/asset-menu.tsx +356 −0 View web/src/pages/workspace/ui/asset-menu.tsx
@@ -0,0 +1,356 @@
1import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'
2import { useNavigate } from 'react-router-dom'
3import { Bot, ChevronDown, FolderGit2, Hash, Search, SquareKanban } from 'lucide-react'
4import { useT, type MessageKey } from '@/shared/i18n'
5import {
6 spaceAgentCollectionsKey,
7 spaceBoardsKey,
8 spaceReposKey,
9 spaceRoomsKey,
10 useAsync,
11} from '@/shared/lib'
12import {
13 fetchSpaceAgentCollections,
14 fetchSpaceBoards,
15 fetchSpaceRepos,
16 fetchSpaceRooms,
17} from '../model/shell-api'
18import { useShellRoute } from '../model/workspace-route'
19
20/** 可被名字直达的空间资产种类(与侧边栏顶层行一一对应)。 */
21type AssetKind = 'repo' | 'board' | 'agent' | 'room'
22
23interface AssetOption {
24 key: string
25 kind: AssetKind
26 name: string
27 href: string
28}
29
30/** 默认态分组顺序;搜索时同一批数据参与打分排序。 */
31const KINDS: AssetKind[] = ['repo', 'board', 'agent', 'room']
32
33const KIND_ICONS: Record<AssetKind, ReactNode> = {
34 repo: <FolderGit2 className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />,
35 board: <SquareKanban className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />,
36 agent: <Bot className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />,
37 room: <Hash className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />,
38}
39
40const KIND_LABEL_KEYS: Record<AssetKind, MessageKey> = {
41 repo: 'shell.nav.repo',
42 board: 'shell.nav.board',
43 agent: 'agent.titleFallback',
44 room: 'shell.sidebar.rooms',
45}
46
47const MAX_MATCHES = 20
48/** 默认态每一类最多列出的行数,超出的靠输入过滤。 */
49const GROUP_LIMIT = 6
50
51/** 面板的一行:类型标题或一个可跳转的资产。 */
52type Row =
53 | { type: 'header'; kind: AssetKind }
54 | { type: 'item'; option: AssetOption; index: number }
55
56/**
57 * 顶栏标题上的空间资产速查:标题默认原样渲染,点击弹出下拉搜索框,
58 * 未输入时按类型分组列出当前空间资产,输入即按名字过滤并跳转。
59 *
60 * 数据与侧边栏共用同一批缓存键,本地优先:`useWorkspaceData` 已加载的列
61 * 表会被同步命中,网络刷新在后台去重进行。
62 */
63export function SpaceAssetMenu({ title }: { title: ReactNode }) {
64 const { t } = useT()
65 const navigate = useNavigate()
66 const { spaceName } = useShellRoute()
67 const scoped = spaceName !== '' && spaceName !== '@me'
68
69 const repos = useAsync(
70 () => (scoped ? fetchSpaceRepos(spaceName) : Promise.resolve([])),
71 [spaceName, scoped],
72 { cacheKey: scoped ? spaceReposKey(spaceName) : null },
73 )
74 const boards = useAsync(
75 () => (scoped ? fetchSpaceBoards(spaceName) : Promise.resolve([])),
76 [spaceName, scoped],
77 { cacheKey: scoped ? spaceBoardsKey(spaceName) : null },
78 )
79 const collections = useAsync(
80 () => (scoped ? fetchSpaceAgentCollections(spaceName) : Promise.resolve([])),
81 [spaceName, scoped],
82 { cacheKey: scoped ? spaceAgentCollectionsKey(spaceName) : null },
83 )
84 const rooms = useAsync(
85 () => (scoped ? fetchSpaceRooms(spaceName) : Promise.resolve([])),
86 [spaceName, scoped],
87 { cacheKey: scoped ? spaceRoomsKey(spaceName) : null },
88 )
89
90 const [open, setOpen] = useState(false)
91 const [query, setQuery] = useState('')
92 const [active, setActive] = useState(0)
93 const rootRef = useRef<HTMLDivElement>(null)
94 const triggerRef = useRef<HTMLButtonElement>(null)
95 const inputRef = useRef<HTMLInputElement>(null)
96
97 const options = useMemo<AssetOption[]>(() => {
98 if (!scoped) return []
99 const spacePath = encodeURIComponent(spaceName)
100 const rows: AssetOption[] = []
101 for (const repo of repos.data ?? []) {
102 rows.push({
103 key: `repo:${repo.id}`,
104 kind: 'repo',
105 name: repo.name,
106 href: `/app/${spacePath}/repo/${encodeURIComponent(repo.name)}`,
107 })
108 }
109 for (const board of boards.data ?? []) {
110 rows.push({
111 key: `board:${board.id}`,
112 kind: 'board',
113 name: board.name,
114 href: `/app/${spacePath}/board/${encodeURIComponent(board.id)}`,
115 })
116 }
117 for (const collection of collections.data ?? []) {
118 rows.push({
119 key: `agent:${collection.id}`,
120 kind: 'agent',
121 name: collection.name,
122 href: `/app/${spacePath}/agent/${encodeURIComponent(collection.id)}`,
123 })
124 }
125 for (const room of rooms.data ?? []) {
126 if (room.kind === 'Category' || room.kind === 'Thread' || room.hidden) continue
127 rows.push({
128 key: `room:${room.id}`,
129 kind: 'room',
130 name: room.name,
131 href: `/app/${spacePath}/chat/${encodeURIComponent(room.id)}`,
132 })
133 }
134 return rows
135 }, [scoped, spaceName, repos.data, boards.data, collections.data, rooms.data])
136
137 const needle = query.trim().toLowerCase()
138 const matched = useMemo(() => {
139 if (!needle) return []
140 const scored: Array<{ option: AssetOption; score: number }> = []
141 for (const option of options) {
142 const at = option.name.toLowerCase().indexOf(needle)
143 if (at < 0) continue
144 // 前缀/词首命中排在内文命中之前,其次按命中位置与名字排序。
145 const boundary = at === 0 || /[\s\-_/.]/.test(option.name[at - 1] ?? '')
146 scored.push({ option, score: (boundary ? 0 : 1000) + at })
147 }
148 scored.sort((a, b) => a.score - b.score || a.option.name.localeCompare(b.option.name))
149 return scored.slice(0, MAX_MATCHES).map(entry => entry.option)
150 }, [needle, options])
151
152 const rows = useMemo<Row[]>(() => {
153 const out: Row[] = []
154 if (needle) {
155 matched.forEach((option, index) => out.push({ type: 'item', option, index }))
156 return out
157 }
158 let index = 0
159 for (const kind of KINDS) {
160 const items = options.filter(option => option.kind === kind).slice(0, GROUP_LIMIT)
161 if (items.length === 0) continue
162 out.push({ type: 'header', kind })
163 for (const option of items) {
164 out.push({ type: 'item', option, index })
165 index += 1
166 }
167 }
168 return out
169 }, [needle, matched, options])
170
171 const items = useMemo(
172 () => rows.filter((row): row is Extract<Row, { type: 'item' }> => row.type === 'item'),
173 [rows],
174 )
175 const loading = repos.loading || boards.loading || collections.loading || rooms.loading
176 const activeIndex = items.length === 0 ? 0 : Math.min(active, items.length - 1)
177
178 useEffect(() => {
179 if (!open) return
180 inputRef.current?.focus()
181 }, [open])
182
183 useEffect(() => {
184 if (!open) return
185 const onPointerDown = (event: PointerEvent) => {
186 if (!rootRef.current?.contains(event.target as Node)) close()
187 }
188 const onKeyDown = (event: globalThis.KeyboardEvent) => {
189 if (event.key !== 'Escape') return
190 close()
191 triggerRef.current?.focus()
192 }
193 document.addEventListener('pointerdown', onPointerDown)
194 document.addEventListener('keydown', onKeyDown)
195 return () => {
196 document.removeEventListener('pointerdown', onPointerDown)
197 document.removeEventListener('keydown', onKeyDown)
198 }
199 // `close` 只做本地状态复位,不随渲染变化。
200 // eslint-disable-next-line react-hooks/exhaustive-deps
201 }, [open])
202
203 useEffect(() => {
204 if (!open || items.length === 0) return
205 const node = rootRef.current?.querySelector<HTMLElement>(`[data-asset-index="${activeIndex}"]`)
206 node?.scrollIntoView({ block: 'nearest' })
207 }, [open, activeIndex, items.length])
208
209 if (!scoped) return <h1 className="truncate text-base font-semibold">{title}</h1>
210
211 function close() {
212 setOpen(false)
213 setQuery('')
214 setActive(0)
215 }
216
217 function pick(option: AssetOption) {
218 navigate(option.href)
219 close()
220 }
221
222 function onKeyDown(event: KeyboardEvent<HTMLInputElement>) {
223 if (event.key === 'Escape') {
224 close()
225 triggerRef.current?.focus()
226 return
227 }
228 if (items.length === 0) return
229 if (event.key === 'ArrowDown') {
230 event.preventDefault()
231 setActive((activeIndex + 1) % items.length)
232 } else if (event.key === 'ArrowUp') {
233 event.preventDefault()
234 setActive((activeIndex - 1 + items.length) % items.length)
235 } else if (event.key === 'Enter') {
236 event.preventDefault()
237 pick(items[activeIndex].option)
238 }
239 }
240
241 return (
242 <div ref={rootRef} className="relative min-w-0">
243 <h1 className="min-w-0">
244 <button
245 ref={triggerRef}
246 type="button"
247 aria-haspopup="dialog"
248 aria-expanded={open}
249 aria-controls="workspace-asset-menu"
250 title={t('shell.search.placeholder')}
251 onClick={() => (open ? close() : setOpen(true))}
252 className="-ml-1.5 flex min-w-0 items-center gap-1.5 rounded-lg px-1.5 py-0.5 text-base font-semibold transition-colors hover:bg-shell-field"
253 >
254 {/* 标题必须完整展示:空间不足时换行,不截断。 */}
255 <span className="min-w-0 break-words">{title}</span>
256 <ChevronDown
257 className={`h-3.5 w-3.5 shrink-0 text-shell-ink-muted transition-transform ${open ? 'rotate-180' : ''}`}
258 aria-hidden
259 />
260 </button>
261 </h1>
262 {open ? (
263 <div
264 id="workspace-asset-menu"
265 role="dialog"
266 aria-label={t('shell.search.placeholder')}
267 className="absolute left-0 top-full z-50 mt-2 w-[340px] max-w-[calc(100vw-2rem)] overflow-hidden rounded-xl border border-shell-hairline bg-shell-surface shadow-xl"
268 >
269 <div className="flex items-center gap-2 border-b border-shell-hairline px-3">
270 <Search className="h-4 w-4 shrink-0 text-shell-ink-muted" aria-hidden />
271 <input
272 ref={inputRef}
273 value={query}
274 onChange={event => {
275 setQuery(event.target.value)
276 setActive(0)
277 }}
278 onKeyDown={onKeyDown}
279 role="combobox"
280 aria-expanded={items.length > 0}
281 aria-controls="workspace-asset-menu-list"
282 aria-activedescendant={
283 items.length > 0 ? `workspace-asset-option-${activeIndex}` : undefined
284 }
285 aria-autocomplete="list"
286 placeholder={t('shell.search.placeholder')}
287 className="h-10 min-w-0 flex-1 bg-transparent text-[13.5px] text-shell-ink outline-none placeholder:text-shell-ink-muted"
288 />
289 </div>
290 <div
291 id="workspace-asset-menu-list"
292 role="listbox"
293 aria-label={t('shell.search.placeholder')}
294 className="max-h-[380px] overflow-y-auto p-1.5"
295 >
296 {items.length === 0 ? (
297 <p className="px-3 py-6 text-center text-[12.5px] text-shell-ink-muted">
298 {loading ? t('search.command.loading') : t('shell.search.empty')}
299 </p>
300 ) : (
301 rows.map(row =>
302 row.type === 'header' ? (
303 <p
304 key={`header:${row.kind}`}
305 className="px-2.5 pb-1 pt-2 text-[11px] font-semibold text-shell-ink-muted"
306 >
307 {t(KIND_LABEL_KEYS[row.kind])}
308 </p>
309 ) : (
310 <button
311 key={row.option.key}
312 id={`workspace-asset-option-${row.index}`}
313 data-asset-index={row.index}
314 type="button"
315 role="option"
316 aria-selected={row.index === activeIndex}
317 onMouseEnter={() => setActive(row.index)}
318 onClick={() => pick(row.option)}
319 className={`flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-[13px] ${
320 row.index === activeIndex ? 'bg-shell-field text-shell-ink' : 'text-shell-ink/85'
321 }`}
322 >
323 {KIND_ICONS[row.option.kind]}
324 <span className="min-w-0 flex-1 truncate">
325 <Highlighted name={row.option.name} needle={needle} />
326 </span>
327 {needle ? (
328 <span className="shrink-0 rounded bg-shell-active px-1.5 py-0.5 text-[10px] text-shell-ink-muted">
329 {t(KIND_LABEL_KEYS[row.option.kind])}
330 </span>
331 ) : null}
332 </button>
333 ),
334 )
335 )}
336 </div>
337 </div>
338 ) : null}
339 </div>
340 )
341}
342
343/** 名字中命中的那一段加粗,其余保持原样。 */
344function Highlighted({ name, needle }: { name: string; needle: string }) {
345 const at = name.toLowerCase().indexOf(needle)
346 if (at < 0) return <>{name}</>
347 return (
348 <>
349 {name.slice(0, at)}
350 <mark className="bg-transparent font-semibold text-shell-ink">
351 {name.slice(at, at + needle.length)}
352 </mark>
353 {name.slice(at + needle.length)}
354 </>
355 )
356}
No newline at end of file
Modified web/src/shared/i18n/messages/en/shell.ts web/src/shared/i18n/messages/en/shell.ts +1 −1 View web/src/shared/i18n/messages/en/shell.ts
@@ -23,7 +23,7 @@
2323 'shell.account.settings': 'User settings',
2424 'shell.account.logout': 'Sign out',
2525 'shell.account.logoutFailed': 'Sign-out failed. Please try again.',
26 'shell.search.placeholder': 'Search repositories, channels, issues…',
26 'shell.search.placeholder': 'Search repos, boards, agents, channels…',
2727 'shell.search.empty': 'No matches',
2828 'shell.search.hint': 'Type a keyword to search',
2929 'shell.title': 'GitDataAI',
Modified web/src/shared/i18n/messages/zh-CN/shell.ts web/src/shared/i18n/messages/zh-CN/shell.ts +1 −1 View web/src/shared/i18n/messages/zh-CN/shell.ts
@@ -23,7 +23,7 @@
2323 'shell.account.settings': '用户设置',
2424 'shell.account.logout': '退出登录',
2525 'shell.account.logoutFailed': '退出登录失败,请重试。',
26 'shell.search.placeholder': '搜索仓库、频道、工单…',
26 'shell.search.placeholder': '搜索仓库、问题集、智能体、频道…',
2727 'shell.search.empty': '没有匹配结果',
2828 'shell.search.hint': '输入关键词开始搜索',
2929 'shell.title': 'GitDataAI',
Modified web/src/shared/ui/app-shell.tsx web/src/shared/ui/app-shell.tsx +4 −2 View web/src/shared/ui/app-shell.tsx
@@ -96,19 +96,21 @@
9696export interface ShellTopBarProps {
9797 icon?: ReactNode
9898 title: ReactNode
99 /** Replaces the plain title heading with an interactive node (e.g. the asset search menu). */
100 titleMenu?: ReactNode
99101 topic?: ReactNode
100102 /** Page-level view switch, rendered beside the title. */
101103 tabs?: ReactNode
102104 actions?: ReactNode
103105}
104106
105export function ShellTopBar({ icon, title, topic, tabs, actions }: ShellTopBarProps) {
107export function ShellTopBar({ icon, title, titleMenu, topic, tabs, actions }: ShellTopBarProps) {
106108 return (
107109 <header className="shell-top-bar">
108110 {icon ? <span className="shell-top-icon">{icon}</span> : null}
109111 <div className="min-w-0 flex-1">
110112 <div className="flex min-w-0 items-center gap-3">
111 <h1 className="truncate text-base font-semibold">{title}</h1>
113 {titleMenu ?? <h1 className="truncate text-base font-semibold">{title}</h1>}
112114 {tabs ? <div className="flex shrink-0 items-center">{tabs}</div> : null}
113115 </div>
114116 {topic ? <p className="mt-0.5 truncate text-xs text-shell-ink-muted">{topic}</p> : null}