refactor(workspace): mount the shell once as a layout route
/app/* were ten sibling routes that each rendered WorkspaceShell, so moving between a chat room and a repository matched a different route object and React rebuilt the whole shell: rail, channel list and community banner refetched, and the banner visibly flashed its gradient while the image came back. /app is now one layout route whose children declare only the valid URLs (404s stay exact) and carry no element. Because useParams() is empty under a pathless child, the shell parses every dynamic segment off the pathname itself, with the id belonging to whichever section the URL names; ShellParams goes away with it. The shell also passes the current location to the palette as the default scope introduced by the previous search commit - repository, channel or board when there is one, space otherwise, nothing on explore and @me.
ZhenYi committed
Modified web/src/app/routes/router.tsx web/src/app/routes/router.tsx +26 −10 View web/src/app/routes/router.tsx
@@ -11,20 +11,36 @@
1111/**
1212 * Route configuration lives in the app layer: it is the only place allowed to
1313 * reach into `pages`. Every route points at a page slice's public API.
14 *
15 * The workspace is ONE layout route with path-only children, not a list of
16 * sibling routes that each render `WorkspaceShell`. Siblings remount: moving
17 * between `/app/:space/chat/:id` and `/app/:space/repo/:name` matches a
18 * different route object, so React tore the shell down and built it again —
19 * rail, channel list and community banner all refetched, and the banner
20 * visibly flashed its gradient while the image came back. Under a layout route
21 * the shell element stays mounted and only the match changes. The shell picks
22 * its own content column from the parsed pathname, so the children carry no
23 * element: they exist purely to keep the URL set (and therefore 404s) exact.
1424 */
1525export const router = createBrowserRouter([
1626 { path: '/', element: <Navigate to="/app" replace /> },
1727 { path: '/app', element: <Navigate to="/app/@me" replace /> },
18 { path: '/app/explore', element: <WorkspaceShell /> },
19 { path: '/app/@me', element: <WorkspaceShell /> },
20 { path: '/app/@me/notify', element: <WorkspaceShell /> },
21 { path: '/app/@me/chat/:roomId?', element: <WorkspaceShell /> },
22 { path: '/app/@me/board/:boardId?', element: <WorkspaceShell /> },
23 { path: '/app/@me/repo/:repoName?', element: <WorkspaceShell /> },
24 { path: '/app/:spaceName', element: <WorkspaceShell /> },
25 { path: '/app/:spaceName/chat/:roomId?', element: <WorkspaceShell /> },
26 { path: '/app/:spaceName/board/:boardId?', element: <WorkspaceShell /> },
27 { path: '/app/:spaceName/repo/:repoName?', element: <WorkspaceShell /> },
28 {
29 path: '/app',
30 element: <WorkspaceShell />,
31 children: [
32 { path: 'explore' },
33 { path: '@me' },
34 { path: '@me/notify' },
35 { path: '@me/chat/:roomId?' },
36 { path: '@me/board/:boardId?' },
37 { path: '@me/repo/:repoName?' },
38 { path: ':spaceName' },
39 { path: ':spaceName/chat/:roomId?' },
40 { path: ':spaceName/board/:boardId?' },
41 { path: ':spaceName/repo/:repoName?' },
42 ],
43 },
2844 { path: '/auth', element: <Navigate to="/auth/login" replace /> },
2945 { path: '/auth/login', element: <LoginPage /> },
3046 { path: '/auth/signup', element: <SignUpPage /> },
Modified web/src/pages/workspace/model/route.ts web/src/pages/workspace/model/route.ts +0 −9 View web/src/pages/workspace/model/route.ts
@@ -13,14 +13,6 @@
1313 | 'ai'
1414 | 'settings'
1515
16/** Dynamic segments of `/app/:spaceName/{chat|board|repo}/:id?`. */
17export interface ShellParams extends Record<string, string | undefined> {
18 spaceName?: string
19 roomId?: string
20 boardId?: string
21 repoName?: string
22}
23
2416/** Parsed route consumed by the workspace page's data loading. */
2517export interface ShellRoute {
2618 mode: ShellMode
@@ -29,4 +21,3 @@
2921 boardId?: string
3022 repoName?: string
3123}
32
Modified web/src/pages/workspace/model/workspace-route.ts web/src/pages/workspace/model/workspace-route.ts +19 −10 View web/src/pages/workspace/model/workspace-route.ts
@@ -1,19 +1,28 @@
1import { useLocation, useParams } from 'react-router-dom'
2import type { ShellMode, ShellParams, ShellRoute } from './route'
1import { useLocation } from 'react-router-dom'
2import type { ShellMode, ShellRoute } from './route'
33
44/**
55 * URL shape is `/app/{space_name}/{chat|board|repo}/{id?}` (plus the personal
66 * `/app/@me/notify` inbox). The section is a static route segment, so it is
7 * read off the pathname instead of the params.
7 * read off the pathname, and so is every dynamic segment: the workspace is a
8 * single layout route (see `app/routes/router.tsx`) whose children only declare
9 * which URLs are valid, so `useParams()` here would always be empty. Parsing the
10 * pathname is what lets the shell survive a section switch without remounting.
811 */
912export function useShellRoute(): ShellRoute {
1013 const { pathname } = useLocation()
11 const params = useParams<ShellParams>()
1214 const segments = pathname.split('/')
15 const rawScope = segments[2] ?? ''
16 const rawSection = segments[3] ?? ''
17 // The id belongs to whichever section is in the URL; a section-less space
18 // root (`/app/minimax`) has none, exactly like its optional route segment.
19 const rawId = segments[4] || undefined
20 const decode = (value: string | undefined) =>
21 value === undefined ? undefined : decodeURIComponent(value)
1322 // `/app/explore` is a static sibling of `/app/:spaceName`, so it has no
1423 // section segment: the second path segment itself is the marker.
15 const explore = segments[2] === 'explore'
16 const section = decodeURIComponent(segments[3] ?? '')
24 const explore = rawScope === 'explore'
25 const section = decodeURIComponent(rawSection)
1726 const mode: ShellMode = explore
1827 ? 'explore'
1928 : section === 'notify'
@@ -29,9 +38,9 @@
2938
3039 return {
3140 mode,
32 spaceName: explore ? '' : params.spaceName ?? (personal ? '@me' : ''),
33 roomId: params.roomId,
34 boardId: params.boardId,
35 repoName: params.repoName,
41 spaceName: explore ? '' : decode(rawScope) ?? (personal ? '@me' : ''),
42 roomId: section === 'chat' ? decode(rawId) : undefined,
43 boardId: section === 'board' ? decode(rawId) : undefined,
44 repoName: section === 'repo' ? decode(rawId) : undefined,
3645 }
3746}
Modified web/src/pages/workspace/ui/workspace-shell.tsx web/src/pages/workspace/ui/workspace-shell.tsx +29 −7 View web/src/pages/workspace/ui/workspace-shell.tsx
@@ -19,7 +19,7 @@
1919import { SpaceRail } from './space-rail'
2020import { UserPanel } from './user-panel'
2121import { UserSettingsModal } from '@/features/user-settings'
22import { GlobalSearchCommand } from '@/features/search'
22import { GlobalSearchCommand, type SearchScope } from '@/features/search'
2323import { ExploreView } from '@/features/space-discovery'
2424
2525/**
@@ -145,6 +145,27 @@
145145
146146 const personal = spaceName === '@me'
147147 const canOrganize = currentMySpace?.permissions?.includes('manage_channels') ?? false
148 /**
149 * 全局搜索默认继承的当前位置。只带「现在真正在看的那一层」:仓库页限定到
150 * 仓库,频道页限定到频道,看板页限定到看板——空间本身不作为约束,因为在
151 * 空间首页时用户想要的通常是「在这个空间里找」,而它已经由 `spaceName` 表达;
152 * `@me` / explore 这类没有空间作用域的页面则不传。
153 */
154 const searchScope: SearchScope | undefined = exploring
155 ? undefined
156 : repoName
157 ? {
158 spaceName,
159 repoName,
160 label: `${spaceName}/${repoName}`,
161 }
162 : roomId
163 ? { spaceName, roomId, label: room?.name || dm?.name || roomId }
164 : boardId
165 ? { spaceName, boardId, label: board?.name || boardId }
166 : spaceName && spaceName !== '@me'
167 ? { spaceName, label: spaceName }
168 : undefined
148169 const shell = {
149170 // Always the real rail: fabricated placeholder tiles must not flash
150171 // while `GET /me/spaces` is still in flight.
@@ -158,6 +179,7 @@
158179 ),
159180 sideHeader: currentMySpace ? (
160181 <SpaceHeader
182 key={`${currentMySpace.space.id}:${spaceReload}`}
161183 mySpace={currentMySpace}
162184 viewerId={me.data?.user.user_id}
163185 onListsChanged={() => setListsReload(key => key + 1)}
@@ -245,7 +267,7 @@
245267 <NotifyToaster />
246268
247269 <VoiceCallBar />
248 <GlobalSearchCommand />
270 <GlobalSearchCommand scope={searchScope} />
249271 </>
250272 )
251273 }
@@ -261,7 +283,7 @@
261283 <NotifyToaster />
262284
263285 <VoiceCallBar />
264 <GlobalSearchCommand />
286 <GlobalSearchCommand scope={searchScope} />
265287 </>
266288 )
267289 }
@@ -283,7 +305,7 @@
283305 <NotifyToaster />
284306
285307 <VoiceCallBar />
286 <GlobalSearchCommand />
308 <GlobalSearchCommand scope={searchScope} />
287309 </>
288310 )
289311 }
@@ -298,7 +320,7 @@
298320 <NotifyToaster />
299321
300322 <VoiceCallBar />
301 <GlobalSearchCommand />
323 <GlobalSearchCommand scope={searchScope} />
302324 </>
303325 )
304326 }
@@ -322,7 +344,7 @@
322344 <NotifyToaster />
323345
324346 <VoiceCallBar />
325 <GlobalSearchCommand />
347 <GlobalSearchCommand scope={searchScope} />
326348 </>
327349 )
328350 }
@@ -339,7 +361,7 @@
339361 <NotifyToaster />
340362
341363 <VoiceCallBar />
342 <GlobalSearchCommand />
364 <GlobalSearchCommand scope={searchScope} />
343365 </>
344366 )
345367}