feat(web): refresh the workspace shell, chat and sign-in
Rework the workspace shell and layout (sidebar navigation, board and repo layouts, repo aside), keep chat transcripts and voice tiles in step with the new run data, and polish the login and two-factor screens.
ZhenYi committed
This change is very large, so some diffs are not shown. Clone the repository to see the full change.
Modified web/src/features/chat/ui/voice/voice-tiles.tsx web/src/features/chat/ui/voice/voice-tiles.tsx +27 −1 View web/src/features/chat/ui/voice/voice-tiles.tsx
@@ -58,6 +58,10 @@
5858/** Microphone reference per member, so a camera tile can still show "muted". */
5959export type MicRefIndex = Map<string, TrackReferenceOrPlaceholder>
6060
61/** 新窗口的入场方向:每个成员第一次出现时随机挑一个。 */
62const ENTER_DIRECTIONS = ['left', 'right', 'top', 'bottom'] as const
63type TileEnterDirection = (typeof ENTER_DIRECTIONS)[number]
64
6165function isShare(ref: TrackReferenceOrPlaceholder): boolean {
6266 return ref.source === Track.Source.ScreenShare
6367}
@@ -218,16 +222,21 @@
218222 onKick,
219223 layout,
220224 micRefs,
225 enterDirOf,
221226}: Omit<BoardProps, 'layout'> & {
222227 layout: VoiceTileLayout
223228 trackRef?: TrackReferenceOrPlaceholder
224229 micRefs: MicRefIndex
230 enterDirOf: (identity: string) => TileEnterDirection | null
225231}): ReactElement | null {
226232 const { t } = useT()
227233 const contextRef = useMaybeTrackRefContext()
228234 const trackRef = explicitRef ?? contextRef
235 const identity = trackRef?.participant.identity ?? ''
236 // 入场方向来自 Board 的幂等方向表;首次挂载取一次,上下文晚到也会在这里补上。
237 const [claimed] = useState<TileEnterDirection | null>(() => enterDirOf(identity))
238 const enter = claimed ?? enterDirOf(identity)
229239 if (!trackRef) return null
230 const identity = trackRef.participant.identity
231240 const micRef = micRefs.get(identity)
232241 const box = layout.boxOf(identity)
233242 const dragging = layout.draggingKey === identity
@@ -236,6 +245,7 @@
236245 data-tile={identity}
237246 className={[
238247 'voice-window',
248 enter ? `voice-enter-${enter}` : '',
239249 box ? 'is-floating' : '',
240250 dragging ? 'is-dragging' : '',
241251 ]
@@ -362,6 +372,21 @@
362372 }, [tracks])
363373 const boardRefs = useMemo(() => [...faceRefs, ...shareRefs], [faceRefs, shareRefs])
364374
375 /**
376 * 成员 → 入场方向。用 Map 而不是一次性集合:StrictMode 会把渲染与初始化器
377 * 各跑两遍,幂等取值才能既保证方向唯一、又不会被第一次调用“吃掉”。
378 */
379 const enterDirsRef = useRef(new Map<string, TileEnterDirection>())
380 const enterDirOf = (identity: string): TileEnterDirection | null => {
381 if (!identity) return null
382 const dirs = enterDirsRef.current
383 const existing = dirs.get(identity)
384 if (existing) return existing
385 const dir = ENTER_DIRECTIONS[Math.floor(Math.random() * ENTER_DIRECTIONS.length)]
386 dirs.set(identity, dir)
387 return dir
388 }
389
365390 const focused = pinnedTracks[0]
366391 const autoFocused = useRef<TrackReferenceOrPlaceholder | null>(null)
367392 /**
@@ -413,6 +438,7 @@
413438 onMute,
414439 onKick,
415440 micRefs,
441 enterDirOf,
416442 layout: focused ? { ...layout, boxOf: () => null } : layout,
417443 }
418444
Modified web/src/features/chat/ui/voice/voice.css web/src/features/chat/ui/voice/voice.css +25 −0 View web/src/features/chat/ui/voice/voice.css
@@ -556,6 +556,31 @@
556556}
557557.voice-window.is-dragging { z-index: 3; cursor: grabbing; }
558558/*
559 * 新成员入场:第一次出现的窗口从随机方向滑入,而不是凭空出现。方向类只在
560 * 成员首次出场时挂上(voice-tiles.tsx 的认领表),重排/重挂载不会重播;
561 * 全局的 prefers-reduced-motion 规则会把动画时长清零。
562 */
563@keyframes voice-window-enter-left {
564 from { opacity: 0; transform: translateX(-26px); }
565 to { opacity: 1; transform: translateX(0); }
566}
567@keyframes voice-window-enter-right {
568 from { opacity: 0; transform: translateX(26px); }
569 to { opacity: 1; transform: translateX(0); }
570}
571@keyframes voice-window-enter-top {
572 from { opacity: 0; transform: translateY(-22px); }
573 to { opacity: 1; transform: translateY(0); }
574}
575@keyframes voice-window-enter-bottom {
576 from { opacity: 0; transform: translateY(22px); }
577 to { opacity: 1; transform: translateY(0); }
578}
579.voice-enter-left { animation: voice-window-enter-left 280ms cubic-bezier(0, 0, 0.2, 1) both; }
580.voice-enter-right { animation: voice-window-enter-right 280ms cubic-bezier(0, 0, 0.2, 1) both; }
581.voice-enter-top { animation: voice-window-enter-top 280ms cubic-bezier(0, 0, 0.2, 1) both; }
582.voice-enter-bottom { animation: voice-window-enter-bottom 280ms cubic-bezier(0, 0, 0.2, 1) both; }
583/*
559584 * 别人的移动由 JS 逐帧补间(voice-tile-layout 的 REMOTE_GLIDE_MS):这里刻意
560585 * 不留 CSS 过渡,一是避免两层动画叠在一起,二是本机 prefers-reduced-motion 会
561586 * 把过渡时长清零,补间必须走 JS 才在哪儿都生效。自己的拖拽则完全不过渡。
Modified web/src/features/chat/ui/chat-room.tsx web/src/features/chat/ui/chat-room.tsx +21 −16 View web/src/features/chat/ui/chat-room.tsx
@@ -225,6 +225,9 @@
225225 // list is webhook-backed, so the room re-reads it whenever a chat event says
226226 // the room changed while a call is live.
227227 const voiceState = useVoiceSession()
228 // 弹幕只在通话中发送:未加入(或连接失败)时,输入栏不该出现。
229 const inVoiceCall =
230 voiceState.roomId === roomId && voiceState.status !== 'idle' && voiceState.status !== 'failed'
228231 const voiceParticipants = useAsync(
229232 () => (voice ? listVoiceParticipants(roomId).catch(() => [] as VoiceParticipant[]) : Promise.resolve([] as VoiceParticipant[])),
230233 // The call status is a dependency: joining or leaving changes who is in
@@ -648,23 +651,25 @@
648651 nameOf={nameOf}
649652 viewerId={viewerId}
650653 canModerate={canModerateRoom}
651 />
652 <VoiceDanmakuInput
653 value={composer.text}
654 onChange={composer.setText}
655 disabled={stream.state.accessRevoked}
656 notice={
657 composer.blocked ??
658 (stream.slowModeRemaining > 0
659 ? t('chat.room.slowModeShort', { seconds: stream.slowModeRemaining })
660 : null)
661 }
662 onSend={() => {
663 void composer.submit().then(ok => {
664 if (ok) onStateChange({ replyToId: undefined, editId: undefined })
665 })
666 }}
667654 />
655 {inVoiceCall ? (
656 <VoiceDanmakuInput
657 value={composer.text}
658 onChange={composer.setText}
659 disabled={stream.state.accessRevoked}
660 notice={
661 composer.blocked ??
662 (stream.slowModeRemaining > 0
663 ? t('chat.room.slowModeShort', { seconds: stream.slowModeRemaining })
664 : null)
665 }
666 onSend={() => {
667 void composer.submit().then(ok => {
668 if (ok) onStateChange({ replyToId: undefined, editId: undefined })
669 })
670 }}
671 />
672 ) : null}
668673 </>
669674 ) : (
670675 <Transcript
Modified web/src/features/chat/ui/parts.tsx web/src/features/chat/ui/parts.tsx +6 −0 View web/src/features/chat/ui/parts.tsx
@@ -519,10 +519,13 @@
519519 message,
520520 viewerId,
521521 compact = false,
522 suffix = null,
522523}: {
523524 message: ChatMessage
524525 viewerId?: string
525526 compact?: boolean
527 /** 追加在正文最后一段文本末尾的行内内容(“已编辑”等标记)。 */
528 suffix?: ReactNode
526529}) {
527530 const mentionIds = useMemo(() => new Set(message.mentions ?? []), [message.mentions])
528531 const isSelf = Boolean(viewerId && mentionIds.has(viewerId)) || message.mention_everyone
@@ -537,6 +540,7 @@
537540 }
538541 return map
539542 }, [message])
543 const lastTextIndex = tokens.reduce((acc, token, index) => (token.type === 'text' ? index : acc), -1)
540544 return (
541545 <div className={`chat-message-body min-w-0 break-words ${compact ? 'text-[13px]' : 'text-[15px]'} leading-[1.5]`}>
542546 {tokens.map((token, index) =>
@@ -553,9 +557,11 @@
553557 ) : (
554558 <div key={`t${index}`} className="whitespace-pre-wrap break-words">
555559 {renderInline(token.value, mentionIds, isSelf, embeds)}
560 {suffix && index === lastTextIndex ? <span className="ml-1.5">{suffix}</span> : null}
556561 </div>
557562 ),
558563 )}
564 {suffix && lastTextIndex < 0 ? <span className="ml-1.5">{suffix}</span> : null}
559565 </div>
560566 )
561567}
Modified web/src/features/chat/ui/transcript.tsx web/src/features/chat/ui/transcript.tsx +11 −7 View web/src/features/chat/ui/transcript.tsx
@@ -448,18 +448,22 @@
448448 <p className="text-[14px] italic text-shell-ink-muted">{t('chat.transcript.thisDeleted')}</p>
449449 ) : (
450450 <>
451 <MessageBody message={message} viewerId={viewerId} />
451 <MessageBody
452 message={message}
453 viewerId={viewerId}
454 suffix={
455 message.edited_at ? (
456 <span className="text-[10.5px] text-shell-ink-muted" title={fullStamp(message.edited_at)}>
457 {t('chat.message.edited')}
458 </span>
459 ) : null
460 }
461 />
452462 {message.attachments.length > 0 ? (
453463 <AttachmentGrid attachments={message.attachments} onOpen={onPreview} />
454464 ) : null}
455465 </>
456466 )}
457
458 {message.edited_at && !editing ? (
459 <span className="text-[10.5px] text-shell-ink-muted" title={fullStamp(message.edited_at)}>
460 {t('chat.message.edited')}
461 </span>
462 ) : null}
463467
464468 {message.reactions && message.reactions.length > 0 ? (
465469 <div className="mt-1 flex flex-wrap gap-1">
Modified web/src/pages/login/ui/login-page.tsx web/src/pages/login/ui/login-page.tsx +14 −3 View web/src/pages/login/ui/login-page.tsx
@@ -1,5 +1,5 @@
11import { useState, type FormEvent } from 'react'
2import { Link, useNavigate } from 'react-router-dom'
2import { Link, useNavigate, useSearchParams } from 'react-router-dom'
33import { ArrowRight, ArrowUpRight, AtSign, Lock, Mail } from 'lucide-react'
44import { useT } from '@/shared/i18n'
55import { Alert, Button, Checkbox, LabeledDivider, RevealToggle, TextField } from '@/shared/ui'
@@ -17,6 +17,7 @@
1717export function LoginPage() {
1818 const { t } = useT()
1919 const navigate = useNavigate()
20 const [searchParams] = useSearchParams()
2021 const [identifier, setIdentifier] = useState('')
2122 const [password, setPassword] = useState('')
2223 const [revealed, setRevealed] = useState(false)
@@ -33,12 +34,12 @@
3334 const powAnswer = await getPowAnswer()
3435 const result = await login({ identifier, password, pow_answer: powAnswer, remember })
3536 if (result?.needs_2fa) {
36 navigate('/auth/2fa', { state: { challenge: result.challenge ?? null } })
37 navigate('/auth/2fa', { state: { challenge: result.challenge ?? null, redirect: nextPath(searchParams.get('redirect')) } })
3738 } else {
3839 // Bind the local-first store to this account *before* the shell
3940 // hydrates: signing in as someone else wipes the previous cache.
4041 if (result?.user.user_id) await setCacheOwner(result.user.user_id)
41 navigate('/app')
42 navigate(nextPath(searchParams.get('redirect')) ?? '/app')
4243 }
4344 } catch (err) {
4445 setError(toUserError(err))
@@ -117,3 +118,13 @@
117118 </AuthShell>
118119 )
119120}
121
122/**
123 * Post-login destination from ?redirect= (invite links round-trip through
124 * the sign-in form this way). Only same-origin absolute paths are honoured —
125 * an absolute URL or protocol-relative //host would be an open redirect.
126 */
127function nextPath(raw: string | null): string | null {
128 if (!raw || !raw.startsWith('/') || raw.startsWith('//')) return null
129 return raw
130}
Modified web/src/pages/two-factor/ui/two-factor-page.tsx web/src/pages/two-factor/ui/two-factor-page.tsx +2 −2 View web/src/pages/two-factor/ui/two-factor-page.tsx
@@ -18,7 +18,7 @@
1818 const { t } = useT()
1919 const navigate = useNavigate()
2020 const location = useLocation()
21 const state = (location.state ?? {}) as { challenge?: string | null; recovery?: boolean }
21 const state = (location.state ?? {}) as { challenge?: string | null; recovery?: boolean; redirect?: string | null }
2222 const challenge = state.challenge ?? null
2323
2424 const [recoveryMode, setRecoveryMode] = useState(Boolean(state.recovery))
@@ -44,7 +44,7 @@
4444 const result = await login2fa({ challenge, code })
4545 // Bind the local-first store to this account before the shell hydrates.
4646 if (result?.user.user_id) await setCacheOwner(result.user.user_id)
47 navigate('/')
47 navigate(state.redirect ?? '/')
4848 } catch (err) {
4949 setError(toUserError(err))
5050 } finally {
Modified web/src/pages/workspace/model/board-location.ts web/src/pages/workspace/model/board-location.ts +38 −22 View web/src/pages/workspace/model/board-location.ts
@@ -1,5 +1,6 @@
11import { useCallback, useMemo } from 'react'
22import { useSearchParams } from 'react-router-dom'
3import { PRIORITIES } from '@/entities/issue'
34import type { IssueBoardView, IssuePriority, IssueSort, IssueState } from '@/entities/issue'
45
56/**
@@ -31,22 +32,22 @@
3132 view: 'board',
3233 state: 'open',
3334 q: '',
34 priority: '',
35 labelId: '',
36 milestoneId: '',
37 assigneeId: '',
35 priorities: [],
36 labelIds: [],
37 milestoneIds: [],
38 assigneeIds: [],
3839 sort: 'created',
3940 page: 1,
4041 issue: '',
4142}
4243
4344/** Changing any of these resets paging to page 1. */
44const FILTER_KEYS = ['view', 'state', 'q', 'priority', 'labelId', 'milestoneId', 'assigneeId', 'sort'] as const
45const FILTER_KEYS = ['view', 'state', 'q', 'priorities', 'labelIds', 'milestoneIds', 'assigneeIds', 'sort'] as const
4546export type BoardFilterKey = (typeof FILTER_KEYS)[number]
4647
4748const VIEWS = ['board', 'list'] as const
4849const STATES: readonly (IssueState | 'all')[] = ['open', 'closed', 'all']
49const PRIORITIES: readonly IssuePriority[] = ['none', 'low', 'medium', 'high', 'urgent']
50const PRIORITY_IDS: readonly IssuePriority[] = PRIORITIES.map(meta => meta.id)
5051const SORTS: readonly IssueSort[] = ['created', 'updated', 'due', 'priority', 'number']
5152
5253const OWNED_KEYS = ['view', 'state', 'q', 'priority', 'label', 'milestone', 'assignee', 'sort', 'page', 'issue']
@@ -55,6 +56,21 @@
5556 return allowed.includes((raw ?? '') as T) ? (raw as T) : fallback
5657}
5758
59/** `a,b` → `['a','b']`, dropping blanks; `allowed` (when given) drops junk. */
60function readList(raw: string | null, allowed?: readonly string[]): string[] {
61 if (!raw) return []
62 const parts = raw
63 .split(',')
64 .map(part => part.trim())
65 .filter(Boolean)
66 return allowed ? parts.filter(part => allowed.includes(part)) : parts
67}
68
69/** The inverse of {@link readList}; encoded as one comma-separated value. */
70function writeList(next: URLSearchParams, key: string, values: readonly string[]) {
71 if (values.length > 0) next.set(key, values.join(','))
72}
73
5874/**
5975 * Parse the URL into board state; unknown values fall back to their default.
6076 * `defaultView` is the account's saved 问题集 view, used only while the URL
@@ -69,10 +85,10 @@
6985 view: pick(params.get('view'), VIEWS, defaultView),
7086 state: pick(params.get('state'), STATES, 'open'),
7187 q: params.get('q') ?? '',
72 priority: pick(params.get('priority'), PRIORITIES, '') as IssuePriority | '',
73 labelId: params.get('label') ?? '',
74 milestoneId: params.get('milestone') ?? '',
75 assigneeId: params.get('assignee') ?? '',
88 priorities: readList(params.get('priority'), PRIORITY_IDS) as IssuePriority[],
89 labelIds: readList(params.get('label')),
90 milestoneIds: readList(params.get('milestone')),
91 assigneeIds: readList(params.get('assignee')),
7692 sort: pick(params.get('sort'), SORTS, 'created'),
7793 page: Number.isFinite(rawPage) && rawPage > 0 ? rawPage : 1,
7894 issue: params.get('issue') ?? '',
@@ -90,10 +106,10 @@
90106 if (state.view !== defaultView) next.set('view', state.view)
91107 if (state.state !== 'open') next.set('state', state.state)
92108 if (state.q) next.set('q', state.q)
93 if (state.priority) next.set('priority', state.priority)
94 if (state.labelId) next.set('label', state.labelId)
95 if (state.milestoneId) next.set('milestone', state.milestoneId)
96 if (state.assigneeId) next.set('assignee', state.assigneeId)
109 writeList(next, 'priority', state.priorities)
110 writeList(next, 'label', state.labelIds)
111 writeList(next, 'milestone', state.milestoneIds)
112 writeList(next, 'assignee', state.assigneeIds)
97113 if (state.sort !== 'created') next.set('sort', state.sort)
98114 if (state.page > 1) next.set('page', String(state.page))
99115 if (state.issue) next.set('issue', state.issue)
@@ -174,10 +190,10 @@
174190 patch({
175191 state: 'open',
176192 q: '',
177 priority: '',
178 labelId: '',
179 milestoneId: '',
180 assigneeId: '',
193 priorities: [],
194 labelIds: [],
195 milestoneIds: [],
196 assigneeIds: [],
181197 sort: 'created',
182198 }),
183199 [patch],
@@ -197,10 +213,10 @@
197213 const filtered =
198214 view.state !== 'open' ||
199215 Boolean(view.q) ||
200 Boolean(view.priority) ||
201 Boolean(view.labelId) ||
202 Boolean(view.milestoneId) ||
203 Boolean(view.assigneeId) ||
216 view.priorities.length > 0 ||
217 view.labelIds.length > 0 ||
218 view.milestoneIds.length > 0 ||
219 view.assigneeIds.length > 0 ||
204220 view.sort !== 'created'
205221
206222 return {
Modified web/src/pages/workspace/model/route.ts web/src/pages/workspace/model/route.ts +2 −1 View web/src/pages/workspace/model/route.ts
@@ -1,5 +1,5 @@
11/** Main-column variant, taken from the route's static section segment. */
2export type ShellMode = 'chat' | 'board' | 'repo' | 'members' | 'notify' | 'explore'
2export type ShellMode = 'chat' | 'board' | 'agent' | 'repo' | 'members' | 'notify' | 'explore'
33
44/** Repo page tabs, rendered by `RepoLayout`'s default tab strip. */
55export type RepoTabId =
@@ -19,5 +19,6 @@
1919 spaceName: string
2020 roomId?: string
2121 boardId?: string
22 agentCollectionId?: string
2223 repoName?: string
2324}
Modified web/src/pages/workspace/model/shell-api.ts web/src/pages/workspace/model/shell-api.ts +10 −0 View web/src/pages/workspace/model/shell-api.ts
@@ -9,6 +9,7 @@
99import { writeCache } from '@/shared/storage'
1010import { fetchSessionUser } from '@/features/auth'
1111import type {
12 AgentCollectionItem,
1213 BoardItem,
1314 DmItem,
1415 Me,
@@ -64,6 +65,15 @@
6465 }) as unknown as PromiseLike<ApiEnvelope<{ items: BoardItem[] }>>,
6566 ).then(p => p?.items ?? [])
6667
68// `list_agent_collections` is typed against the generic `Page` schema; items are `AgentCollectionDto`.
69export const fetchSpaceAgentCollections = (spaceName: string) =>
70 unwrapApi<{ items: AgentCollectionItem[] }>(
71 Apis.agents.list_agent_collections({
72 pathParams: { space_name: spaceName },
73 params: { limit: 50 },
74 }) as unknown as PromiseLike<ApiEnvelope<{ items: AgentCollectionItem[] }>>,
75 ).then(p => p?.items ?? [])
76
6777/** First page of tag names for the repo toolbar chip. */
6878export const fetchTags = (spaceName: string, repoName: string) =>
6979 unwrapApi<{ items: TagItem[] }>(
Modified web/src/pages/workspace/model/workspace-data.ts web/src/pages/workspace/model/workspace-data.ts +10 −1 View web/src/pages/workspace/model/workspace-data.ts
@@ -3,6 +3,7 @@
33 dmsKey,
44 meKey,
55 repoKey,
6 spaceAgentCollectionsKey,
67 spaceBoardsKey,
78 spaceDirKey,
89 spaceReposKey,
@@ -17,6 +18,7 @@
1718 fetchDms,
1819 fetchMe,
1920 fetchMySpaces,
21 fetchSpaceAgentCollections,
2022 fetchSpaceBoards,
2123 fetchSpaceRepos,
2224 fetchSpaceRooms,
@@ -33,7 +35,7 @@
3335 * the network revalidation runs, and the fresh response replaces it live.
3436 */
3537export function useWorkspaceData(
36 { spaceName, roomId, boardId, repoName, mode }: ShellRoute,
38 { spaceName, roomId, boardId, agentCollectionId, repoName, mode }: ShellRoute,
3739 reloadKey = 0,
3840 listsReloadKey = 0,
3941 meReloadKey = 0,
@@ -64,6 +66,11 @@
6466 [spaceName, listsReloadKey],
6567 { cacheKey: scoped ? spaceBoardsKey(spaceName) : null },
6668 )
69 const agentCollections = useAsync(
70 () => (scoped ? fetchSpaceAgentCollections(spaceName) : Promise.resolve([])),
71 [spaceName, listsReloadKey],
72 { cacheKey: scoped ? spaceAgentCollectionsKey(spaceName) : null },
73 )
6774 const repoRef = mode === 'repo' && repoName ? `${spaceName}/${repoName}` : ''
6875 const tags = useAsync(
6976 () => (repoRef ? fetchTags(spaceName, repoName!) : Promise.resolve([])),
@@ -97,12 +104,14 @@
97104 dms,
98105 repos,
99106 boards,
107 agentCollections,
100108 tags,
101109 repoRef,
102110 currentSpace: spaces.data?.find(({ space }) => space.name === spaceName)?.space,
103111 room: rooms.data?.find(r => r.id === roomId),
104112 dm: dms.data?.find(d => d.id === roomId),
105113 board: boards.data?.find(b => b.id === boardId),
114 agentCollection: agentCollections.data?.find(c => c.id === agentCollectionId),
106115 repo: repos.data?.find(r => r.name === repoName),
107116 }
108117}
Modified web/src/pages/workspace/model/workspace-route.ts web/src/pages/workspace/model/workspace-route.ts +8 −5 View web/src/pages/workspace/model/workspace-route.ts
@@ -29,11 +29,13 @@
2929 ? 'notify'
3030 : section === 'board'
3131 ? 'board'
32 : section === 'members'
33 ? 'members'
34 : section === 'repo'
35 ? 'repo'
36 : 'chat'
32 : section === 'agent'
33 ? 'agent'
34 : section === 'members'
35 ? 'members'
36 : section === 'repo'
37 ? 'repo'
38 : 'chat'
3739 // `/app/@me…` routes carry no :spaceName param; name the personal scope so
3840 // data loading and the rail can treat it like any other scope.
3941 const personal = pathname === '/app/@me' || pathname.startsWith('/app/@me/')
@@ -43,6 +45,7 @@
4345 spaceName: explore ? '' : decode(rawScope) ?? (personal ? '@me' : ''),
4446 roomId: section === 'chat' ? decode(rawId) : undefined,
4547 boardId: section === 'board' ? decode(rawId) : undefined,
48 agentCollectionId: section === 'agent' ? decode(rawId) : undefined,
4649 repoName: section === 'repo' ? decode(rawId) : undefined,
4750 }
4851}
Modified web/src/pages/workspace/model/workspace.ts web/src/pages/workspace/model/workspace.ts +2 −0 View web/src/pages/workspace/model/workspace.ts
@@ -4,6 +4,7 @@
44 */
55import type { DmBrief, RoomBrief } from '@/entities/chat'
66import type {
7 AppResponseData_AgentCollectionDto,
78 AppResponseData_BoardDto,
89 AppResponseData_MeResp,
910 AppResponseData_InviteDto,
@@ -23,6 +24,7 @@
2324 NonNullable<AppResponseData_Page_RepoSummaryDto['data']>['items']
2425>[number]
2526export type BoardItem = NonNullable<AppResponseData_BoardDto['data']>
27export type AgentCollectionItem = NonNullable<AppResponseData_AgentCollectionDto['data']>
2628export type Me = NonNullable<AppResponseData_MeResp['data']>
2729export type SpaceDto = NonNullable<AppResponseData_SpaceDto['data']>
2830export type Invite = NonNullable<AppResponseData_InviteDto['data']>
Modified web/src/pages/workspace/ui/board/board-view.tsx web/src/pages/workspace/ui/board/board-view.tsx +12 −9 View web/src/pages/workspace/ui/board/board-view.tsx
@@ -5,6 +5,7 @@
55 BoardActions,
66 BoardFilters,
77 BoardSkeleton,
8 BoardViewTabs,
89 IssueBoardBody,
910 IssueDetail,
1011 IssueFormModal,
@@ -20,7 +21,7 @@
2021 IssueQuery,
2122 UserDto,
2223} from '@/entities/issue'
23import { normalizeBoardView } from '@/entities/issue'
24import { issueRef, normalizeBoardView } from '@/entities/issue'
2425import { useBoardViewLocation } from '../../model/board-location'
2526import { fetchPreferences, updatePreferences } from '../../model/shell-api'
2627import { BoardLayout } from '../layout/board-layout'
@@ -96,10 +97,10 @@
9697 () => ({
9798 state: view.state,
9899 q: view.q || undefined,
99 priority: view.priority || undefined,
100 labelId: view.labelId || undefined,
101 milestoneId: view.milestoneId || undefined,
102 assigneeId: view.assigneeId || undefined,
100 priorities: view.priorities.length > 0 ? view.priorities : undefined,
101 labelIds: view.labelIds.length > 0 ? view.labelIds : undefined,
102 milestoneIds: view.milestoneIds.length > 0 ? view.milestoneIds : undefined,
103 assigneeIds: view.assigneeIds.length > 0 ? view.assigneeIds : undefined,
103104 sort: view.sort,
104105 limit: ISSUE_PAGE_SIZE,
105106 offset: (view.page - 1) * ISSUE_PAGE_SIZE,
@@ -175,9 +176,12 @@
175176 title={board?.name ?? t('shell.board.title')}
176177 topic={board?.description ?? undefined}
177178 variant={view.issue ? 'detail' : 'columns'}
179 tabs={<BoardViewTabs value={view.view} onChange={loc.setView} />}
180 actions={<BoardActions onCreate={() => setCreating({})} />}
178181 toolbar={
179182 <BoardFilters
180183 view={view}
184 total={data.issues.data?.total ?? 0}
181185 filtered={loc.filtered}
182186 labels={data.labels.data?.items ?? []}
183187 labelsFailed={Boolean(data.labels.error)}
@@ -185,11 +189,10 @@
185189 members={members}
186190 onFilter={setFilter}
187191 onClear={loc.clearFilters}
192 onCreate={() => setCreating({})}
193 onManage={() => setManaging(true)}
188194 />
189195 }
190 toolbarActions={
191 <BoardActions onCreate={() => setCreating({})} onManage={() => setManaging(true)} />
192 }
193196 >
194197 {view.issue ? (
195198 <IssueDetail
@@ -236,7 +239,7 @@
236239 setEditing(null)
237240 onChanged()
238241 // A new issue opens immediately: the author must see what they wrote.
239 if (!editing) loc.openIssue(String(issue.number))
242 if (!editing) loc.openIssue(issueRef(board, issue.number))
240243 }}
241244 />
242245
Modified web/src/pages/workspace/ui/layout/board-layout.tsx web/src/pages/workspace/ui/layout/board-layout.tsx +25 −19 View web/src/pages/workspace/ui/layout/board-layout.tsx
@@ -12,6 +12,8 @@
1212export interface BoardLayoutProps extends Pick<AppShellProps, 'rail' | 'sideHeader' | 'navPinned' | 'nav' | 'sideFooter'> {
1313 title?: ReactNode
1414 topic?: ReactNode
15 /** Page-level view switch (看板 / 列表), rendered beside the title. */
16 tabs?: ReactNode
1517 actions?: ReactNode
1618 /** Toolbar left segment (filter chips). Defaults to skeleton chips. */
1719 toolbar?: ReactNode
@@ -30,6 +32,7 @@
3032export function BoardLayout({
3133 title,
3234 topic,
35 tabs,
3336 actions,
3437 toolbar,
3538 toolbarActions,
@@ -44,6 +47,7 @@
4447 icon={<SquareKanban className="h-[18px] w-[18px] shrink-0 text-shell-ink-muted" />}
4548 title={title}
4649 topic={topic}
50 tabs={tabs}
4751 actions={actions}
4852 />
4953 <div className="board-toolbar flex min-h-14 shrink-0 flex-wrap items-center gap-2 px-4 py-2">
@@ -54,25 +58,27 @@
5458 <span className="h-7 w-24 rounded-md bg-shell-field" />
5559 </>
5660 )}
57 <div className="board-toolbar-actions ml-auto flex items-center gap-3">
58 {toolbarActions ?? (
59 <>
60 <div className="flex -space-x-1.5">
61 {['#ca6a8f', '#bd923d', '#4f9874', '#66717d'].map(c => (
62 <span
63 key={c}
64 className="h-6 w-6 rounded-full ring-2 ring-shell-surface"
65 style={{ backgroundColor: c }}
66 />
67 ))}
68 </div>
69 <span className="flex h-[30px] items-center gap-1 rounded-md bg-shell-action px-3 text-[13px] font-semibold text-white">
70 <Plus className="h-3.5 w-3.5" />
71 {t('shell.board.newIssue')}
72 </span>
73 </>
74 )}
75 </div>
61 {toolbarActions || !toolbar ? (
62 <div className="board-toolbar-actions ml-auto flex items-center gap-3">
63 {toolbarActions ?? (
64 <>
65 <div className="flex -space-x-1.5">
66 {['#ca6a8f', '#bd923d', '#4f9874', '#66717d'].map(c => (
67 <span
68 key={c}
69 className="h-6 w-6 rounded-full ring-2 ring-shell-surface"
70 style={{ backgroundColor: c }}
71 />
72 ))}
73 </div>
74 <span className="flex h-[30px] items-center gap-1 rounded-md bg-shell-action px-3 text-[13px] font-semibold text-white">
75 <Plus className="h-3.5 w-3.5" />
76 {t('shell.board.newIssue')}
77 </span>
78 </>
79 )}
80 </div>
81 ) : null}
7682 </div>
7783 {variant === 'detail' ? (
7884 <div className="min-h-0 flex-1 overflow-y-auto px-4 pb-6 pt-1">{children}</div>
Modified web/src/pages/workspace/ui/layout/repo-layout.tsx web/src/pages/workspace/ui/layout/repo-layout.tsx +71 −12 View web/src/pages/workspace/ui/layout/repo-layout.tsx
@@ -1,4 +1,4 @@
1import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
1import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react'
22import { ChevronDown, File, Folder, FolderGit2, GitBranch, Info, MoreHorizontal, Tag } from 'lucide-react'
33import { useT, type MessageKey } from '@/shared/i18n'
44import { AppShell, ShellTopBar, type AppShellProps } from '@/shared/ui'
@@ -23,6 +23,10 @@
2323 activeTab?: RepoTabId
2424 /** Tab switch handler for the default tabs. */
2525 onTabChange?: (tab: RepoTabId) => void
26 /** Repository administration (the 设置 tab). Server-side every repo
27 * settings endpoint is space-owner / repo-admin only, so the page passes
28 * false when the viewer is neither. Defaults to the least privilege. */
29 canAdmin?: boolean
2630 /** Interactive branch/tag picker replacing the static ref chips. */
2731 refsSlot?: ReactNode
2832 /** Right segment of the repo toolbar (clone box). */
@@ -35,8 +39,21 @@
3539 children?: ReactNode
3640 /** 280px info aside. */
3741 aside?: ReactNode
42 /** 仅代码页签展示右侧仓库概览;false 时整栏与工具栏开关都不渲染。 */
43 showAside?: boolean
44 /** 仓库设置里的页签开关与顺序(缺省 = 全部可见、站点默认顺序)。 */
45 tabOptions?: RepoTabOptions
3846}
3947
48/** 仓库设置可配置的页签:开关到底是否可见、以及中间页签的顺序。 */
49export interface RepoTabOptions {
50 allowPullRequests: boolean
51 pipelinesVisible: boolean
52 aiVisible: boolean
53 /** 中间页签顺序(提交/合并请求/分支/流水线/发布/AI 总结);空 = 默认。 */
54 order: readonly string[]
55}
56
4057export function RepoLayout({
4158 title = 'gitdata / synchost',
4259 topic,
@@ -44,12 +61,15 @@
4461 tabs,
4562 activeTab = 'code',
4663 onTabChange,
64 canAdmin = false,
4765 refsSlot,
4866 clone,
4967 branch,
5068 ref,
5169 children,
5270 aside,
71 showAside = true,
72 tabOptions,
5373 ...shell
5474}: RepoLayoutProps) {
5575 const { t } = useT()
@@ -78,9 +98,11 @@
7898 </>
7999 )}
80100 </div>
81 <div className="repo-toolbar-tabs">{tabs ?? <DefaultTabs active={activeTab} onChange={onTabChange} />}</div>
101 <div className="repo-toolbar-tabs">{tabs ?? <DefaultTabs active={activeTab} onChange={onTabChange} canAdmin={canAdmin} tabOptions={tabOptions} />}</div>
82102 <div className={clone ? 'repo-toolbar-clone' : 'repo-toolbar-clone empty'}>
83 <button type="button" className="repo-overview-toggle workspace-icon-button" title={t('shell.repo.overview')} aria-label={t('shell.repo.overview')} aria-expanded={overviewOpen} onClick={() => setOverviewOpen(value => !value)}><Info size={18} /></button>
103 {showAside ? (
104 <button type="button" className="repo-overview-toggle workspace-icon-button" title={t('shell.repo.overview')} aria-label={t('shell.repo.overview')} aria-expanded={overviewOpen} onClick={() => setOverviewOpen(value => !value)}><Info size={18} /></button>
105 ) : null}
84106 {clone}
85107 </div>
86108 </div>
@@ -88,7 +110,9 @@
88110 <section data-repo-scroll="" className="min-w-0 flex-1 overflow-y-auto overflow-x-hidden pt-2">
89111 {children ?? <FilesPlaceholder />}
90112 </section>
91 <aside className="w-[280px] shrink-0 space-y-3.5 overflow-y-auto pt-2">{aside ?? <RepoAsidePlaceholder />}</aside>
113 {showAside ? (
114 <aside className="w-[280px] shrink-0 space-y-3.5 overflow-y-auto pt-2">{aside ?? <RepoAsidePlaceholder />}</aside>
115 ) : null}
92116 </div>
93117 </AppShell>
94118 )
@@ -108,6 +132,22 @@
108132/** The PR detail route (`?tab=pull`) belongs to the 合并请求 tab. */
109133const tabGroup = (id: RepoTabId): RepoTabId => (id === 'pull' ? 'pulls' : id)
110134
135const TAB_LABEL_KEYS = new Map<RepoTabId, MessageKey>(tabLabels)
136
137/** 可由仓库设置重排序的中间页签(代码固定最前、设置固定最后)。 */
138const REORDERABLE_TABS: RepoTabId[] = ['commits', 'pulls', 'branches', 'pipelines', 'releases', 'ai']
139
140/** 归一化仓库设置里的顺序:未知/重复项忽略,缺失项按默认顺序补齐。 */
141function orderTabs(order: readonly string[] | undefined): RepoTabId[] {
142 const out: RepoTabId[] = []
143 for (const id of order ?? []) {
144 const tab = id as RepoTabId
145 if (REORDERABLE_TABS.includes(tab) && !out.includes(tab)) out.push(tab)
146 }
147 for (const id of REORDERABLE_TABS) if (!out.includes(id)) out.push(id)
148 return out
149}
150
111151/** One label's chrome; the probe reuses it so measured widths match the real
112152 * buttons exactly (the active variant only swaps colours and a shadow). */
113153const TAB_ITEM_CLASS =
@@ -133,26 +173,45 @@
133173function DefaultTabs({
134174 active,
135175 onChange,
176 canAdmin,
177 tabOptions,
136178}: {
137179 active: NonNullable<RepoLayoutProps['activeTab']>
138180 onChange?: (tab: RepoTabId) => void
181 canAdmin: boolean
182 tabOptions?: RepoTabOptions
139183}) {
140184 const { t, locale } = useT()
141185 const stripRef = useRef<HTMLElement>(null)
142186 const probeRef = useRef<HTMLElement>(null)
143187 const menuRef = useRef<HTMLDivElement>(null)
144188 const triggerRef = useRef<HTMLButtonElement>(null)
145 const [fit, setFit] = useState(tabLabels.length)
189 // 页签条 = 代码(固定)+ 仓库设置里开关/排序过的中间页签 + 设置(按权限裁剪)。
190 const labels = useMemo<Array<[RepoTabId, MessageKey]>>(() => {
191 const middle = orderTabs(tabOptions?.order).filter(id =>
192 id === 'pulls'
193 ? (tabOptions?.allowPullRequests ?? true)
194 : id === 'pipelines'
195 ? (tabOptions?.pipelinesVisible ?? true)
196 : id === 'ai'
197 ? (tabOptions?.aiVisible ?? true)
198 : true,
199 )
200 const ids: RepoTabId[] = ['code', ...middle, ...(canAdmin ? (['settings'] as RepoTabId[]) : [])]
201 return ids.map(id => [id, TAB_LABEL_KEYS.get(id) ?? 'shell.repo.tabs.code'])
202 }, [tabOptions, canAdmin])
203 const labelsKey = labels.map(([id]) => id).join(',')
204 const [fit, setFit] = useState(labels.length)
146205 const [menuOpen, setMenuOpen] = useState(false)
147206
148 const ids = tabLabels.map(([id]) => id)
207 const ids = labels.map(([id]) => id)
149208 const activeIndex = Math.max(
150209 0,
151 tabLabels.findIndex(([id]) => tabGroup(active) === id),
210 labels.findIndex(([id]) => tabGroup(active) === id),
152211 )
153212 const shown = fit >= ids.length ? ids : ids.slice(0, fit)
154213 if (fit < ids.length && activeIndex >= fit) shown[fit - 1] = ids[activeIndex]
155 const hidden = tabLabels.filter(([id]) => !shown.includes(id))
214 const hidden = labels.filter(([id]) => !shown.includes(id))
156215 const overflow = menuOpen && hidden.length > 0
157216
158217 useLayoutEffect(() => {
@@ -200,7 +259,7 @@
200259 // Label widths follow the locale, and webfonts land after first paint.
201260 document.fonts?.ready.then(measure).catch(() => {})
202261 return () => observer.disconnect()
203 }, [onChange, locale])
262 }, [onChange, locale, labelsKey])
204263
205264 useEffect(() => {
206265 if (!menuOpen) return
@@ -223,7 +282,7 @@
223282 if (!onChange) {
224283 return (
225284 <nav className="flex h-9 w-fit items-center gap-1 rounded-[11px] border border-shell-hairline bg-shell-field p-[3px]">
226 {tabLabels.map(([id, key]) => (
285 {labels.map(([id, key]) => (
227286 <span key={id} className={tabItemClass(tabGroup(active) === id)}>
228287 {t(key)}
229288 </span>
@@ -242,13 +301,13 @@
242301 aria-hidden="true"
243302 className="pointer-events-none fixed -left-[9999px] top-0 flex items-center gap-1 p-[3px] opacity-0"
244303 >
245 {tabLabels.map(([id, key]) => (
304 {labels.map(([id, key]) => (
246305 <span key={id} className={`${TAB_ITEM_CLASS} text-shell-ink-muted`}>
247306 {t(key)}
248307 </span>
249308 ))}
250309 </nav>
251 {tabLabels
310 {labels
252311 .filter(([id]) => shown.includes(id))
253312 .map(([id, key]) => (
254313 <button
Modified web/src/pages/workspace/ui/repo/repo-aside.tsx web/src/pages/workspace/ui/repo/repo-aside.tsx +158 −54 View web/src/pages/workspace/ui/repo/repo-aside.tsx
@@ -1,12 +1,17 @@
11import { useState } from 'react'
2import { ExternalLink, GitBranch, GitCommitHorizontal, Star, Tag } from 'lucide-react'
3import { useT, type MessageKey } from '@/shared/i18n'
4import { formatBytes } from '@/shared/lib'
2import { ExternalLink, Eye, GitCommitHorizontal, Scale, Star, Tag, Users } from 'lucide-react'
3import { useT } from '@/shared/i18n'
4import { InitialAvatar } from '@/shared/ui'
5import { fmtRelative, repoKey, shortSha, useAsync } from '@/shared/lib'
56import {
67 htmlFragmentUrl,
8 listCommitsPage,
9 listReleases,
10 setRepoWatch,
711 starRepo,
812 unstarRepo,
913 useHtmlFragment,
14 type CommitRow,
1015 type RepoDetail,
1116} from '@/entities/repo'
1217
@@ -17,24 +22,50 @@
1722 mode: 'html' | 'json'
1823 rev: string
1924 onChanged: () => void
25 onOpenCommits: () => void
26 onOpenReleases: () => void
2027}
2128
22/** 280px info column: about block, star action and language breakdown. */
23/** Headline counts shown under the description; order matches the design. */
24function repoStats(repo: RepoAsideProps['repo']): ReadonlyArray<{ key: MessageKey; count: number; Icon: typeof GitBranch }> {
25 return [
26 { key: 'shell.repo.statBranches', count: repo.branches_count, Icon: GitBranch },
27 { key: 'shell.repo.statTags', count: repo.tags_count, Icon: Tag },
28 { key: 'shell.repo.statCommits', count: repo.commits_count, Icon: GitCommitHorizontal },
29 ]
29/** Distinct authors of the recent window, newest first, capped for the stack. */
30function uniqueAuthors(commits: CommitRow[]): string[] {
31 const seen = new Map<string, string>()
32 for (const commit of commits) {
33 const key = commit.author_name || commit.author_email
34 if (!key || seen.has(key)) continue
35 seen.set(key, commit.author_name || key)
36 if (seen.size >= 5) break
37 }
38 return [...seen.values()]
3039}
3140
32export function RepoAside({ space, repoName, repo, mode, rev, onChanged }: RepoAsideProps) {
41export function RepoAside({ space, repoName, repo, mode, rev, onChanged, onOpenCommits, onOpenReleases }: RepoAsideProps) {
3342 const { t } = useT()
3443 const [starBusy, setStarBusy] = useState(false)
3544 const [starError, setStarError] = useState<string | null>(null)
45 const [watchBusy, setWatchBusy] = useState(false)
46 const [watchError, setWatchError] = useState<string | null>(null)
3647 const starred = repo.me?.starred ?? false
48 const watching = repo.me?.watch_mode === 'watching'
3749 const website = repo.website
50 const headRev = rev || repo.default_branch
51
52 // Decision-useful facts only: what shipped last, what moved last, who is
53 // behind it. Branch/tag/commit counts already live in the toolbar.
54 const releases = useAsync(
55 () => listReleases(space, repoName, { limit: 10 }).catch(() => null),
56 [space, repoName],
57 { cacheKey: repoKey(space, repoName, 'aside:releases') },
58 )
59 const commits = useAsync(
60 () => listCommitsPage(space, repoName, { revision: headRev, skip: 0, limit: 30 }).catch(() => null),
61 [space, repoName, headRev],
62 { cacheKey: repoKey(space, repoName, `aside:commits:${headRev}`) },
63 )
64 const latestRelease = (releases.data?.items ?? [])
65 .filter(item => !item.is_draft)
66 .sort((a, b) => (a.created_at < b.created_at ? 1 : -1))[0]
67 const latestCommit = commits.data?.commits?.[0]
68 const authors = uniqueAuthors(commits.data?.commits ?? [])
3869
3970 const languagesFragment = useHtmlFragment(
4071 mode === 'html' && repo.commits_count > 0 && rev
@@ -58,64 +89,137 @@
5889 }
5990 }
6091
92 async function toggleWatch() {
93 if (watchBusy) return
94 setWatchBusy(true)
95 setWatchError(null)
96 try {
97 await setRepoWatch(space, repoName, watching ? 'unwatched' : 'watching')
98 onChanged()
99 } catch {
100 setWatchError(t('shell.repo.watchFailed'))
101 } finally {
102 setWatchBusy(false)
103 }
104 }
105
61106 return (
62107 <div className="repo-overview space-y-4">
63108 <section className="repo-about-card rounded-xl bg-shell-surface p-5">
64 <h3 className="mb-3 text-[14px] font-semibold">
65 {t('shell.repo.overview')}
66 </h3>
67 <p className="mb-4 break-words text-[13px] leading-6 text-shell-ink-muted">{repo.description || t('shell.repo.descriptionFallback')}</p>
68 <dl className="space-y-3 text-[12px] text-shell-ink-muted">
69 {website ? (
70 <div>
71 <dt className="sr-only">{t('shell.repo.website')}</dt>
72 <dd>
109 <h3 className="mb-3 text-[14px] font-semibold">{t('shell.repo.overview')}</h3>
110 <p className="mb-4 break-words text-[13px] leading-6 text-shell-ink-muted">
111 {repo.description || t('shell.repo.descriptionFallback')}
112 </p>
113 {website ? (
114 <button
115 type="button"
116 title={t('shell.repo.openWebsite', { url: website })}
117 onClick={() => window.open(website, '_blank', 'noopener,noreferrer')}
118 className="mb-4 flex w-full items-start gap-1.5 break-all text-left text-[12.5px] text-shell-ink-muted underline decoration-shell-hairline underline-offset-4 hover:text-shell-ink hover:decoration-shell-ink-muted"
119 >
120 <ExternalLink className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden />
121 {website}
122 </button>
123 ) : null}
124 <dl className="repo-overview-facts border-t border-shell-hairline pt-3 text-[12.5px]">
125 <div className="repo-fact">
126 <Tag className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />
127 <dt className="text-shell-ink-muted">{t('shell.repo.latestRelease')}</dt>
128 <dd className="ml-auto min-w-0">
129 {latestRelease ? (
130 <button
131 type="button"
132 onClick={onOpenReleases}
133 title={latestRelease.title || latestRelease.tag_name}
134 className="repo-fact-value"
135 >
136 <span className="font-mono">{latestRelease.tag_name}</span>
137 <span className="text-shell-ink-muted"> · {fmtRelative(latestRelease.created_at)}</span>
138 </button>
139 ) : (
140 <span className="text-shell-ink-muted">{t('shell.repo.noRelease')}</span>
141 )}
142 </dd>
143 </div>
144 <div className="repo-fact">
145 <GitCommitHorizontal className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />
146 <dt className="text-shell-ink-muted">{t('shell.repo.latestCommit')}</dt>
147 <dd className="ml-auto min-w-0">
148 {latestCommit ? (
73149 <button
74150 type="button"
75 title={t('shell.repo.openWebsite', { url: website })}
76 onClick={() => window.open(website, '_blank', 'noopener,noreferrer')}
77 className="flex items-start gap-1.5 break-all text-shell-ink-muted underline decoration-shell-hairline underline-offset-4 hover:text-shell-ink hover:decoration-shell-ink-muted"
151 onClick={onOpenCommits}
152 title={latestCommit.message}
153 className="repo-fact-value"
78154 >
79 <ExternalLink className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden />
80 {website}
155 <span className="font-mono">{shortSha(latestCommit.id)}</span>
156 <span className="text-shell-ink-muted"> · {fmtRelative(latestCommit.author_time)}</span>
81157 </button>
158 ) : (
159 <span className="text-shell-ink-muted">—</span>
160 )}
161 </dd>
162 </div>
163 {authors.length ? (
164 <div className="repo-fact">
165 <Users className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />
166 <dt className="text-shell-ink-muted">{t('shell.repo.contributors')}</dt>
167 <dd className="ml-auto flex items-center -space-x-1">
168 {authors.map(name => (
169 <InitialAvatar key={name} name={name} size={18} />
170 ))}
82171 </dd>
83172 </div>
84173 ) : null}
85 <div className="flex justify-between gap-3"><dt>{t('shell.repo.size')}</dt><dd className="font-medium tabular-nums text-shell-ink">{formatBytes(repo.git_size)}</dd></div>
86174 {repo.license_name ? (
87 <div className="flex justify-between gap-3"><dt>{t('shell.repo.license')}</dt><dd className="text-right text-shell-ink">{repo.license_name}</dd></div>
175 <div className="repo-fact">
176 <Scale className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />
177 <dt className="text-shell-ink-muted">{t('shell.repo.license')}</dt>
178 <dd className="ml-auto min-w-0 truncate text-shell-ink" title={repo.license_name}>
179 {repo.license_name}
180 </dd>
181 </div>
88182 ) : null}
89183 </dl>
90 <div className="repo-stat-grid mt-5 grid grid-cols-3 gap-2 border-t border-shell-hairline pt-4">
91 {repoStats(repo).map(({ key, count, Icon }) => (
92 <div key={key} className="min-w-0 text-center">
93 <p className="text-[17px] font-semibold tabular-nums">{count}</p>
94 <p className="mt-1 flex items-center justify-center gap-1 text-[11px] text-shell-ink-muted"><Icon className="h-3 w-3" aria-hidden />{t(key)}</p>
95 </div>
96 ))}
184 <div className="mt-4 grid grid-cols-2 gap-2">
185 <button
186 type="button"
187 disabled={starBusy}
188 aria-pressed={starred}
189 onClick={toggleStar}
190 className={`repo-action-button ${starred ? 'is-on' : ''}`}
191 >
192 <Star className="h-3.5 w-3.5" fill={starred ? 'currentColor' : 'none'} aria-hidden />
193 {starBusy
194 ? t('shell.repo.starUpdating')
195 : starred
196 ? t('shell.repo.starred', { count: repo.stars_count })
197 : t('shell.repo.star', { count: repo.stars_count })}
198 </button>
199 <button
200 type="button"
201 disabled={watchBusy}
202 aria-pressed={watching}
203 onClick={toggleWatch}
204 className={`repo-action-button ${watching ? 'is-on' : ''}`}
205 >
206 <Eye className="h-3.5 w-3.5" aria-hidden />
207 {watchBusy
208 ? t('shell.repo.starUpdating')
209 : watching
210 ? t('shell.repo.watching', { count: repo.watchers_count })
211 : t('shell.repo.watch', { count: repo.watchers_count })}
212 </button>
97213 </div>
98 <button
99 type="button"
100 disabled={starBusy}
101 aria-pressed={starred}
102 onClick={toggleStar}
103 className={`mt-5 flex h-10 w-full items-center justify-center gap-2 rounded-lg border text-[13px] font-medium transition-colors disabled:opacity-60 ${
104 starred
105 ? 'border-shell-tile bg-shell-active text-shell-ink'
106 : 'border-shell-hairline bg-shell-field hover:border-shell-tile hover:text-shell-ink'
107 }`}
108 >
109 <Star className="h-3.5 w-3.5" fill={starred ? 'currentColor' : 'none'} aria-hidden />
110 {starBusy ? t('shell.repo.starUpdating') : starred ? t('shell.repo.starred', { count: repo.stars_count }) : t('shell.repo.star', { count: repo.stars_count })}
111 </button>
112 {starError ? <p className="mt-2 text-[12px] text-shell-ink-muted" role="alert">{starError}</p> : null}
214 {starError || watchError ? (
215 <p className="mt-2 text-[12px] text-shell-ink-muted" role="alert">
216 {starError ?? watchError}
217 </p>
218 ) : null}
113219 </section>
114220 {languagesHtml ? (
115221 <section className="rounded-xl bg-shell-surface p-5">
116 <h3 className="mb-3 text-[14px] font-semibold">
117 {t('shell.repo.languages')}
118 </h3>
222 <h3 className="mb-3 text-[14px] font-semibold">{t('shell.repo.languages')}</h3>
119223 <div className="repo-embed" dangerouslySetInnerHTML={{ __html: languagesHtml }} />
120224 </section>
121225 ) : repo.primary_language ? (
@@ -125,4 +229,4 @@
125229 ) : null}
126230 </div>
127231 )
128}
232}
No newline at end of file
Modified web/src/pages/workspace/ui/repo/repo-polish.css web/src/pages/workspace/ui/repo/repo-polish.css +34 −6 View web/src/pages/workspace/ui/repo/repo-polish.css
@@ -31,9 +31,19 @@
3131.repo-code-header-path button { white-space: nowrap; }
3232.repo-directory-header { padding: 9px 12px; border-bottom: 1px solid var(--shell-hairline); background: color-mix(in srgb, var(--shell-field) 45%, var(--shell-surface)); }
3333.repo-tree-card .repo-embed { border-radius: 0; }
34/* 文件树与卡片齐平:表格自身的边框/圆角会和卡片边框叠成双层,去掉后表头
35 底色可以铺满整个宽度,左右不再留 12px 的外边距。 */
36.repo-tree-card .repo-embed [data-component='git-tree'] {
37 border: 0;
38 border-radius: 0;
39}
40/* 最后一行的分隔线贴着卡片下边框,会把一条线画成两条:留下卡片那条就够。 */
41.repo-tree-card .repo-embed [data-component='git-tree'] tbody tr:last-child {
42 border-bottom: 0;
43}
3444.repo-breadcrumbs button { border-radius: 6px; padding: 3px 5px; transition: color 160ms, background 160ms; }
3545.repo-breadcrumbs button:hover { color: var(--shell-ink); background: var(--shell-field); }
36.repo-tree-card { display: flex; flex-direction: column; gap: 2px; padding-bottom: 4px; overflow: hidden; border: 1px solid var(--shell-hairline); border-radius: 14px; background: var(--shell-surface); box-shadow: 0 1px 2px rgb(24 28 31 / 2%); }
46.repo-tree-card { display: flex; flex-direction: column; gap: 2px; overflow: hidden; border: 1px solid var(--shell-hairline); border-radius: 14px; background: var(--shell-surface); box-shadow: 0 1px 2px rgb(24 28 31 / 2%); }
3747.repo-file-table th { padding: 12px 16px; background: var(--shell-field); font-size: 11px; }
3848.repo-file-table td { padding: 11px 16px; }
3949.repo-file-table tbody tr { transition: background 150ms; }
@@ -61,12 +71,30 @@
6171.repo-ref-popover [role='option'] { min-height: 36px; border-radius: 8px; }
6272.repo-ref-popover [role='option']:hover, .repo-ref-popover [aria-selected='true'] { background: var(--shell-field); }
6373.repo-about-card { border-radius: 14px; }
64.repo-stat-grid > div { padding: 6px 2px; border-radius: 8px; transition: background 150ms; }
65.repo-stat-grid > div:hover { background: var(--shell-field); }
66.repo-commit-list, .repo-branch-list { box-shadow: 0 1px 2px rgb(24 28 31 / 2%); }
67.repo-commit-row, .repo-branch-row { transition: background 150ms; }
68.repo-commit-row:hover, .repo-branch-row:hover { background: color-mix(in srgb, var(--shell-field) 80%, transparent); }
74.repo-overview-facts { display: flex; flex-direction: column; gap: 2px; }
75.repo-fact { display: flex; min-height: 30px; align-items: center; gap: 8px; }
76.repo-fact dt { white-space: nowrap; }
77.repo-fact-value { margin-right: -7px; border-radius: 7px; padding: 4px 7px; color: var(--shell-ink); transition: background 150ms; }
78.repo-fact-value:hover { background: var(--shell-field); }
79.repo-action-button { display: flex; height: 36px; align-items: center; justify-content: center; gap: 6px; border: 1px solid var(--shell-hairline); border-radius: 9px; background: var(--shell-field); font-size: 12.5px; font-weight: 500; transition: color 160ms, border-color 160ms, background 160ms; }
80.repo-action-button:hover:not(:disabled) { border-color: var(--shell-tile); color: var(--shell-ink); }
81.repo-action-button.is-on { border-color: var(--shell-tile); background: var(--shell-active); }
82.repo-action-button:disabled { opacity: 60%; }
83.repo-commit-bar { min-height: 42px; padding: 7px 12px; border-bottom: 1px solid var(--shell-hairline); }
84.repo-commit-bar-main { margin-left: -6px; min-height: 28px; border-radius: 7px; padding: 2px 6px; transition: background 150ms; }
85.repo-commit-bar-main:hover { background: var(--shell-field); }
86.repo-commit-history { border-radius: 7px; padding: 4px 8px; color: var(--shell-ink-muted); font-size: 12.5px; transition: color 150ms, background 150ms; }
87.repo-commit-history:hover { color: var(--shell-ink); background: var(--shell-field); }
88.repo-branch-list { box-shadow: 0 1px 2px rgb(24 28 31 / 2%); }
89.repo-branch-row { transition: background 150ms; }
90.repo-branch-row:hover { background: color-mix(in srgb, var(--shell-field) 80%, transparent); }
91.repo-commit-list { display: flex; flex-direction: column; gap: 8px; }
92.repo-commit-day { color: var(--shell-ink-muted); font-size: 12px; font-weight: 600; letter-spacing: .01em; }
93.repo-commit-day-rule { height: 1px; min-width: 24px; flex: 1 1 auto; background: var(--shell-hairline); }
94.repo-commit-row { border: 1px solid var(--shell-hairline); border-radius: 12px; background: var(--shell-surface); box-shadow: 0 1px 2px rgb(24 28 31 / 2%); transition: border-color 150ms, box-shadow 150ms; }
95.repo-commit-row:hover { border-color: var(--shell-tile); box-shadow: 0 2px 8px rgb(24 28 31 / 6%); }
6996.repo-commit-subject { text-underline-offset: 3px; }
97.repo-commit-tag { display: inline-flex; align-items: center; gap: 4px; border: 1px solid var(--shell-hairline); border-radius: 999px; background: var(--shell-field); padding: 1px 8px; font-family: var(--font-mono, ui-monospace, monospace); font-size: 11px; line-height: 1.6; }
7098.repo-branch-search input:focus, .repo-settings-input:focus { box-shadow: 0 0 0 3px rgb(35 39 43 / 5%); }
7199.repo-delete-button:not(:disabled) { color: #a34343; }
72100.repo-delete-button:not(:disabled):hover { border-color: #c96868; background: #f9eeee; color: #8b3232; }
Modified web/src/pages/workspace/ui/repo/repo-view.tsx web/src/pages/workspace/ui/repo/repo-view.tsx +91 −25 View web/src/pages/workspace/ui/repo/repo-view.tsx
@@ -10,6 +10,7 @@
1010import { useT } from '@/shared/i18n'
1111import { useAsync, repoKey } from '@/shared/lib'
1212import { useRepoViewLocation } from '../../model/repo-location'
13import type { MySpace } from '@/features/space-management'
1314import { BranchesTab } from '@/features/repo-branches'
1415import { CodeTab } from '@/features/repo-browse'
1516import { CommitsTab, CommitView } from '@/features/repo-history'
@@ -26,6 +27,11 @@
2627export interface RepoViewProps {
2728 spaceName: string
2829 repoName: string
30 /** The viewer's /me/spaces row for this space — carries the space owner
31 * id; repo settings are owner-only server-side, so the tab needs it too. */
32 mySpace?: MySpace
33 /** Signed-in account id, compared against the space owner. */
34 viewerId?: string
2935 /** AppShell slots owned by the workspace frame (rail / nav / footer). */
3036 shell: { rail?: React.ReactNode; sideHeader?: React.ReactNode; navPinned?: React.ReactNode; nav?: React.ReactNode; sideFooter?: React.ReactNode }
3137}
@@ -45,7 +51,7 @@
4551 * HTML fragments render tree/blob/readme when the endpoint accepts our auth
4652 * (public repos); private repos fall back to the JSON API.
4753 */
48export function RepoView({ spaceName, repoName, shell }: RepoViewProps) {
54export function RepoView({ spaceName, repoName, mySpace, viewerId, shell }: RepoViewProps) {
4955 const { t } = useT()
5056 const [tick, setTick] = useState(0)
5157 const repoState = useAsync(() => fetchRepo(spaceName, repoName), [spaceName, repoName, tick], {
@@ -74,7 +80,25 @@
7480 const [mode, setMode] = useState<RenderMode>('html')
7581 // Clone addresses come from the backend — never guessed from window.location.
7682 const clones = useMemo(() => (repo ? cloneTargets(repo) : []), [repo])
83 // 仓库的写操作(分支/标签/发布/流水线/合并请求)与设置都是 owner / 仓库协作者
84 // 专属:服务端一律 owner 或 collab Write·Admin,协作成员身份 Web 端拿不到,
85 // 所以按空间权限上下文收敛到所有者。
86 const ownerUid = mySpace?.space.owner?.user_id ?? (mySpace?.space as unknown as { owner_uid?: string } | undefined)?.owner_uid
87 const canManageRepo = Boolean(ownerUid && viewerId && ownerUid === viewerId)
88 // 「公开展示」关闭的页签仅空间成员(含 owner)可见;允许 PR 关闭则对所有人隐藏。
89 const isSpaceMember = Boolean(mySpace)
7790
91 // PR 详情面板的展开/收回动画:打开时向下展开,关闭时向上收拢 300ms 后再卸载,
92 // 因此面板在关闭动画播放期间保持挂载(`detailRef` 在动画结束后才清空)。
93 const detailOpen = tab === 'pull' && Boolean(pullRef)
94 const [detailRef, setDetailRef] = useState<string | null>(null)
95 if (detailOpen && pullRef && detailRef !== pullRef) setDetailRef(pullRef)
96 useEffect(() => {
97 if (detailOpen || detailRef == null) return
98 const timer = setTimeout(() => setDetailRef(null), 320)
99 return () => clearTimeout(timer)
100 }, [detailOpen, detailRef])
101
78102 // The repo DTO only contributes its default revision. A fresh mount keeps the
79103 // deep-linked view as-is (only filling in ?rev= when it is missing); switching
80104 // repository behind the same route component rewrites the params, because
@@ -109,9 +133,26 @@
109133 }
110134 }, [repo, spaceName, repoName])
111135
136 // 无管理权限时设置页不渲染:深链或历史记录落到 ?tab=settings 的一律静默
137 // 替换为代码页,URL 里不留一个打不开的入口。
138 useEffect(() => {
139 if (!repo || canManageRepo || tab !== 'settings') return
140 patch({ tab: 'code' }, { replace: true })
141 }, [repo, canManageRepo, tab, patch])
142
143 // 被仓库设置关闭的页签同样不允许深链停留:静默落到代码页,URL 里不留
144 // 一个页签条上不存在的入口。
145 useEffect(() => {
146 if (!repo) return
147 const prHidden = !repo.allow_pull_requests && (tab === 'pulls' || tab === 'pull')
148 const pipelinesHidden = !(repo.pipelines_public || isSpaceMember || canManageRepo) && tab === 'pipelines'
149 const aiHidden = !(repo.ai_summary_public || isSpaceMember || canManageRepo) && tab === 'ai'
150 if (prHidden || pipelinesHidden || aiHidden) patch({ tab: 'code' }, { replace: true })
151 }, [repo, tab, isSpaceMember, canManageRepo, patch])
152
112153 if (repoState.loading && !repo) {
113154 return (
114 <RepoLayout {...shell} title={`${spaceName} / ${repoName}`}>
155 <RepoLayout {...shell} title={`${spaceName} / ${repoName}`} canAdmin={canManageRepo}>
115156 <div className="flex h-full items-center justify-center text-[13px] text-shell-ink-muted">
116157 {t('shell.repo.loading')}
117158 </div>
@@ -122,7 +163,7 @@
122163 if (!repo) {
123164 const failure = repoState.error as { code?: number; message?: string } | undefined
124165 return (
125 <RepoLayout {...shell} title={`${spaceName} / ${repoName}`}>
166 <RepoLayout {...shell} title={`${spaceName} / ${repoName}`} canAdmin={canManageRepo}>
126167 <div className="flex h-full flex-col items-center justify-center gap-3 text-center">
127168 <p className="text-[15px] font-semibold">
128169 {failure?.code === 404 ? t('shell.repo.notFound') : (failure?.message || t('shell.repo.loadFailed'))}
@@ -161,12 +202,22 @@
161202 }
162203 activeTab={tab}
163204 onTabChange={setTab}
205 canAdmin={canManageRepo}
206 showAside={tab === 'code'}
207 tabOptions={{
208 allowPullRequests: repo.allow_pull_requests,
209 pipelinesVisible: repo.pipelines_public || isSpaceMember || canManageRepo,
210 aiVisible: repo.ai_summary_public || isSpaceMember || canManageRepo,
211 order: repo.tab_order,
212 }}
164213 aside={
165214 <RepoAside
166215 space={spaceName}
167216 repoName={repoName}
168217 repo={repo}
169218 onChanged={() => setTick(t => t + 1)}
219 onOpenCommits={() => setTab('commits')}
220 onOpenReleases={() => setTab('releases')}
170221 mode={mode}
171222 rev={rev}
172223 />
@@ -225,25 +276,39 @@
225276 )
226277 ) : null}
227278 {tab === 'pulls' || tab === 'pull' ? (
228 <PullsTab
229 space={spaceName}
230 repo={repoName}
231 repoId={repo.id}
232 defaultBranch={repo.default_branch}
233 onOpenPull={ref => patch({ tab: 'pull', pull: ref, ptab: 'conversation' })}
234 />
235 ) : null}
236 {tab === 'pull' && pullRef ? (
237 <PullDetail
238 key={pullRef}
239 space={spaceName}
240 repo={repoName}
241 pullRef={pullRef}
242 ptab={ptab}
243 onPtabChange={next => patch({ ptab: next })}
244 onBackToList={() => patch({ tab: 'pulls', pull: '' })}
245 onOpenCommit={sha => patch({ tab: 'commits', commit: sha })}
246 />
279 <>
280 <PullsTab
281 space={spaceName}
282 repo={repoName}
283 repoId={repo.id}
284 canWrite={canManageRepo}
285 defaultBranch={repo.default_branch}
286 activePull={pullRef ?? null}
287 onOpenPull={ref => patch({ tab: 'pull', pull: ref, ptab: 'conversation' })}
288 onClosePull={() => patch({ tab: 'pulls', pull: '' })}
289 />
290 <div
291 className={`grid transition-[grid-template-rows,opacity] duration-300 ease-out ${
292 detailOpen ? 'grid-rows-[1fr] opacity-100' : 'pointer-events-none grid-rows-[0fr] opacity-0'
293 }`}
294 >
295 <div className="min-h-0 overflow-hidden">
296 {detailRef ? (
297 <PullDetail
298 key={detailRef}
299 canWrite={canManageRepo}
300 space={spaceName}
301 repo={repoName}
302 pullRef={detailRef}
303 ptab={ptab}
304 onPtabChange={next => patch({ ptab: next })}
305 onBackToList={() => patch({ tab: 'pulls', pull: '' })}
306 onOpenCommit={sha => patch({ tab: 'commits', commit: sha })}
307 />
308 ) : null}
309 </div>
310 </div>
311 </>
247312 ) : null}
248313 {tab === 'branches' ? (
249314 <BranchesTab
@@ -251,6 +316,7 @@
251316 repo={repoName}
252317 rev={rev}
253318 defaultBranch={repo.default_branch}
319 canWrite={canManageRepo}
254320 onCheckout={setRev}
255321 onBrowse={browseRev}
256322 onChanged={() => setTick(t => t + 1)}
@@ -261,6 +327,7 @@
261327 space={spaceName}
262328 repo={repoName}
263329 defaultBranch={repo.default_branch}
330 canWrite={canManageRepo}
264331 runId={run}
265332 onSelectRun={runId => patch({ run: runId })}
266333 />
@@ -272,11 +339,10 @@
272339 repo={repoName}
273340 selected={release}
274341 onSelect={tag => patch({ release: tag })}
275 // 仓库 DTO 不含权限位:始终展示写入口,由服务端鉴权并提示 403。
276 canWrite={true}
342 canWrite={canManageRepo}
277343 />
278344 ) : null}
279 {tab === 'settings' ? (
345 {tab === 'settings' && canManageRepo ? (
280346 <SettingsTab
281347 space={spaceName}
282348 repo={repoName}
Modified web/src/pages/workspace/ui/sidebar-nav.tsx web/src/pages/workspace/ui/sidebar-nav.tsx +36 −3 View web/src/pages/workspace/ui/sidebar-nav.tsx
@@ -2,6 +2,7 @@
22import { Link } from 'react-router-dom'
33import {
44 AtSign,
5 Bot,
56 ChevronDown,
67 ChevronRight,
78 File,
@@ -22,7 +23,7 @@
2223 prefetchRepoView,
2324 prefetchRoomView,
2425} from '../model/prefetch'
25import type { BoardItem, DirNode, DmItem, RepoItem, RoomBrief } from '../model/workspace'
26import type { AgentCollectionItem, BoardItem, DirNode, DmItem, RepoItem, RoomBrief } from '../model/workspace'
2627
2728export interface SidebarNavProps {
2829 spaceName: string
@@ -32,8 +33,10 @@
3233 dms: DmItem[]
3334 repos: RepoItem[]
3435 boards: BoardItem[]
36 agents: AgentCollectionItem[]
3537 activeRoomId?: string
3638 activeBoardId?: string
39 activeAgentCollectionId?: string
3740 activeRepoName?: string
3841 /** Both move endpoints require the space's `manage_channels` permission. */
3942 canOrganize?: boolean
@@ -42,7 +45,7 @@
4245}
4346
4447/** What a row is, after mapping the directory tree onto the space's assets. */
45type RowKind = 'folder' | 'repo' | 'board' | 'file' | 'room' | 'dm'
48type RowKind = 'folder' | 'repo' | 'board' | 'agent' | 'file' | 'room' | 'dm'
4649
4750/** One row of the view model, positioned inside its (effective) parent level. */
4851interface View {
@@ -88,8 +91,10 @@
8891 dms,
8992 repos,
9093 boards,
94 agents,
9195 activeRoomId,
9296 activeBoardId,
97 activeAgentCollectionId,
9398 activeRepoName,
9499 canOrganize = false,
95100 onChanged,
@@ -161,6 +166,7 @@
161166 const dirIds = new Set(flat.map(({ node }) => node.id))
162167 const repoByName = new Map(repos.map(repo => [repo.name.toLowerCase(), repo]))
163168 const boardByDir = new Map(boards.map(board => [board.dir_id, board]))
169 const agentByDir = new Map(agents.map(agent => [agent.dir_id, agent]))
164170 const visibleRooms = rooms.filter(
165171 room => room.kind !== 'Category' && room.kind !== 'Thread' && !room.hidden,
166172 )
@@ -174,6 +180,7 @@
174180
175181 const coveredRepoIds = new Set<string>()
176182 const coveredBoardIds = new Set<string>()
183 const coveredAgentIds = new Set<string>()
177184 for (const { node, parent } of flat) {
178185 const key = `d:${node.id}`
179186 const edit = edits[key]
@@ -183,6 +190,7 @@
183190 let href: string | null = null
184191 let unread = 0
185192 const board = boardByDir.get(node.id)
193 const agent = agentByDir.get(node.id)
186194 const repo = repoByName.get(node.name.toLowerCase())
187195 if (node.kind === 'Dir') kind = 'folder'
188196 else if (board) {
@@ -190,6 +198,11 @@
190198 href = `/app/${spacePath}/board/${encodeURIComponent(board.id)}`
191199 unread = board.open_issues_count
192200 coveredBoardIds.add(board.id)
201 } else if (agent) {
202 kind = 'agent'
203 href = `/app/${spacePath}/agent/${encodeURIComponent(agent.id)}`
204 unread = agent.agent_count
205 coveredAgentIds.add(agent.id)
193206 } else if (repo) {
194207 kind = 'repo'
195208 href = `/app/${spacePath}/repo/${encodeURIComponent(repo.name)}`
@@ -207,7 +220,7 @@
207220 unread,
208221 favorite: false,
209222 voice: false,
210 assetId: board?.id,
223 assetId: board?.id ?? agent?.id,
211224 })
212225 }
213226 for (const room of visibleRooms) {
@@ -280,6 +293,24 @@
280293 })),
281294 )
282295 appendTail(
296 agents
297 .filter(agent => !coveredAgentIds.has(agent.id))
298 .map(agent => ({
299 key: `x:agent-${agent.id}`,
300 type: 'dir' as const,
301 id: '',
302 kind: 'agent' as RowKind,
303 label: agent.name,
304 href: `/app/${spacePath}/agent/${encodeURIComponent(agent.id)}`,
305 parentId: null,
306 position: 0,
307 unread: agent.agent_count,
308 favorite: false,
309 voice: false,
310 assetId: agent.id,
311 })),
312 )
313 appendTail(
283314 dms.map(dm => ({
284315 key: `m:${dm.id}`,
285316 // No move endpoint exists for a DM, and `id` stays empty so `movable`
@@ -475,6 +506,7 @@
475506 active={
476507 (view.kind === 'room' && view.id === activeRoomId) ||
477508 (view.kind === 'board' && Boolean(activeBoardId) && view.assetId === activeBoardId) ||
509 (view.kind === 'agent' && Boolean(activeAgentCollectionId) && view.assetId === activeAgentCollectionId) ||
478510 (view.kind === 'repo' && view.label === activeRepoName)
479511 }
480512 dragging={dragKey === view.key}
@@ -553,6 +585,7 @@
553585 dm: <AtSign className="h-4 w-4" />,
554586 repo: <FolderGit2 className="h-4 w-4" />,
555587 board: <SquareKanban className="h-4 w-4" />,
588 agent: <Bot className="h-4 w-4" />,
556589 file: <File className="h-4 w-4 shrink-0 text-shell-ink-muted" aria-hidden />,
557590}
558591
Modified web/src/pages/workspace/ui/workspace-shell.tsx web/src/pages/workspace/ui/workspace-shell.tsx +37 −5 View web/src/pages/workspace/ui/workspace-shell.tsx
@@ -2,6 +2,7 @@
22import { Navigate, useNavigate, useSearchParams } from 'react-router-dom'
33import { Loader2 } from 'lucide-react'
44import { BoardView } from './board/board-view'
5import { AgentView } from './agent/agent-view'
56import { ChatLayout } from './layout/chat-layout'
67import { ChatView } from './chat/chat-view'
78import { VoiceCallBar } from '@/features/chat'
@@ -36,7 +37,7 @@
3637 // notification points here); the param is consumed on open so a later
3738 // manual open starts from the default section again.
3839 const [searchParams, setSearchParams] = useSearchParams()
39 const { mode, spaceName, roomId, boardId, repoName } = route
40 const { mode, spaceName, roomId, boardId, agentCollectionId, repoName } = route
4041 const exploring = mode === 'explore'
4142 const [creatingSpace, setCreatingSpace] = useState(false)
4243 const [spaceReload, setSpaceReload] = useState(0)
@@ -49,8 +50,8 @@
4950 const [settingsSection, setSettingsSection] = useState<string | undefined>(undefined)
5051 const badgeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
5152 const {
52 me, spaces, rooms, dirs, dms, repos, boards,
53 room, dm, board,
53 me, spaces, rooms, dirs, dms, repos, boards, agentCollections,
54 room, dm, board, agentCollection,
5455 } = useWorkspaceData(route, spaceReload, listsReload, meReload)
5556 const requestedSection = searchParams.get('settings')
5657 const consumeSectionParam = useCallback(() => {
@@ -127,12 +128,14 @@
127128 ? t('shell.titleRepo', { repo: repoName, space: spaceName })
128129 : mode === 'board' && boardId
129130 ? t('shell.titleBoard', { board: board?.name || boardId, space: spaceName })
131 : mode === 'agent' && agentCollectionId
132 ? t('shell.titleAgent', { collection: agentCollection?.name || agentCollectionId, space: spaceName })
130133 : roomId
131134 ? t('shell.titleChat', { room: room?.name || dm?.name || roomId, space: spaceName })
132135 : spaceName && spaceName !== '@me'
133136 ? t('shell.titleSpace', { space: spaceName })
134137 : base
135 }, [t, mode, spaceName, repoName, boardId, board?.name, roomId, room?.name, dm?.name])
138 }, [t, mode, spaceName, repoName, boardId, board?.name, agentCollectionId, agentCollection?.name, roomId, room?.name, dm?.name])
136139
137140 if (me.error instanceof ApiFailure && me.error.code === 401) {
138141 return <Navigate to="/auth/login" replace />
@@ -205,8 +208,10 @@
205208 dms={dms.data ?? []}
206209 repos={repos.data ?? []}
207210 boards={boards.data ?? []}
211 agents={agentCollections.data ?? []}
208212 activeRoomId={roomId}
209213 activeBoardId={boardId}
214 activeAgentCollectionId={agentCollectionId}
210215 activeRepoName={repoName}
211216 canOrganize={canOrganize}
212217 onChanged={() => setListsReload(key => key + 1)}
@@ -314,10 +319,37 @@
314319 )
315320 }
316321
322 if (mode === 'agent') {
323 return (
324 <>
325 <AgentView
326 spaceName={spaceName}
327 collectionId={agentCollectionId ?? ''}
328 collectionHint={agentCollection}
329 shell={shell}
330 onChanged={() => setListsReload(key => key + 1)}
331 />
332 {createSpaceModal}
333 {settingsModal}
334 {spaceSettingsModal}
335 <NotifyToaster />
336
337 <VoiceCallBar />
338 <GlobalSearchCommand scope={searchScope} />
339 </>
340 )
341 }
342
317343 if (mode === 'repo') {
318344 return (
319345 <>
320 <RepoView spaceName={spaceName} repoName={repoName ?? ''} shell={shell} />
346 <RepoView
347 spaceName={spaceName}
348 repoName={repoName ?? ''}
349 mySpace={currentMySpace}
350 viewerId={me.data?.user.user_id}
351 shell={shell}
352 />
321353 {createSpaceModal}
322354 {settingsModal}
323355 {spaceSettingsModal}
Modified web/src/shared/i18n/messages/en/shell.ts web/src/shared/i18n/messages/en/shell.ts +10 −6 View web/src/shared/i18n/messages/en/shell.ts
@@ -30,6 +30,7 @@
3030 'shell.titleSpace': '{space} · GitDataAI',
3131 'shell.titleRepo': '{repo} · {space} · GitDataAI',
3232 'shell.titleBoard': '{board} · {space} · GitDataAI',
33 'shell.titleAgent': '{collection} · {space} · GitDataAI',
3334 'shell.titleChat': '{room} · {space} · GitDataAI',
3435 'shell.board.title': 'Issue boards',
3536 'shell.board.pick': 'Pick an issue board from the left.',
@@ -69,14 +70,17 @@
6970 'shell.repo.descriptionFallback': 'This repository\'s story starts with the code.',
7071 'shell.repo.website': 'Website',
7172 'shell.repo.openWebsite': 'Open {url} in a new window',
72 'shell.repo.size': 'Repository size',
7373 'shell.repo.license': 'License',
74 'shell.repo.statBranches': 'Branches',
75 'shell.repo.statTags': 'Tags',
76 'shell.repo.statCommits': 'Commits',
7774 'shell.repo.starUpdating': 'Updating…',
78 'shell.repo.starred': 'Starred · {count}',
79 'shell.repo.star': 'Star repository · {count}',
75 'shell.repo.starred': 'Starred {count}',
76 'shell.repo.star': 'Star {count}',
77 'shell.repo.watch': 'Watch {count}',
78 'shell.repo.watching': 'Watching {count}',
79 'shell.repo.watchFailed': 'Could not update the watch right now. Please retry.',
80 'shell.repo.latestRelease': 'Latest release',
81 'shell.repo.noRelease': 'No releases yet',
82 'shell.repo.latestCommit': 'Latest commit',
83 'shell.repo.contributors': 'Contributors',
8084 'shell.repo.languages': 'Languages',
8185 'shell.repo.primaryLanguage': 'Primary language: {language}',
8286 'shell.rail.home': 'Home',
Modified web/src/shared/i18n/messages/zh-CN/shell.ts web/src/shared/i18n/messages/zh-CN/shell.ts +10 −6 View web/src/shared/i18n/messages/zh-CN/shell.ts
@@ -30,6 +30,7 @@
3030 'shell.titleSpace': '{space} · GitDataAI',
3131 'shell.titleRepo': '{repo} · {space} · GitDataAI',
3232 'shell.titleBoard': '{board} · {space} · GitDataAI',
33 'shell.titleAgent': '{collection} · {space} · GitDataAI',
3334 'shell.titleChat': '{room} · {space} · GitDataAI',
3435 'shell.board.title': '问题集',
3536 'shell.board.pick': '从左侧选择一个问题集。',
@@ -69,14 +70,17 @@
6970 'shell.repo.descriptionFallback': '这个仓库的故事,正在从代码开始。',
7071 'shell.repo.website': '网站',
7172 'shell.repo.openWebsite': '在新窗口打开 {url}',
72 'shell.repo.size': '仓库大小',
7373 'shell.repo.license': '许可证',
74 'shell.repo.statBranches': '分支',
75 'shell.repo.statTags': '标签',
76 'shell.repo.statCommits': '提交',
7774 'shell.repo.starUpdating': '更新中…',
78 'shell.repo.starred': '已收藏 · {count}',
79 'shell.repo.star': '收藏仓库 · {count}',
75 'shell.repo.starred': '已收藏 {count}',
76 'shell.repo.star': '收藏 {count}',
77 'shell.repo.watch': '关注 {count}',
78 'shell.repo.watching': '已关注 {count}',
79 'shell.repo.watchFailed': '暂时无法更新关注,请稍后重试。',
80 'shell.repo.latestRelease': '最近发布',
81 'shell.repo.noRelease': '暂无发布',
82 'shell.repo.latestCommit': '最近提交',
83 'shell.repo.contributors': '贡献者',
8084 'shell.repo.languages': '开发语言',
8185 'shell.repo.primaryLanguage': '主要语言:{language}',
8286 'shell.rail.home': '主页',
Modified web/src/shared/i18n/format.ts web/src/shared/i18n/format.ts +16 −0 View web/src/shared/i18n/format.ts
@@ -46,6 +46,14 @@
4646 }).format(date)
4747}
4848
49/** Wall-clock time of day for chat and activity rows (locale 12h/24h). */
50export function formatTime(value: number | null | undefined): string {
51 if (value === null || value === undefined) return DASH
52 const date = new Date(value * 1000)
53 if (Number.isNaN(date.getTime())) return DASH
54 return new Intl.DateTimeFormat(getLocale(), { hour: '2-digit', minute: '2-digit' }).format(date)
55}
56
4957/** Compact relative stamp for commit and comment rows. */
5058export function formatRelative(value: number | string | null | undefined): string {
5159 const epoch =
@@ -73,6 +81,14 @@
7381 return new Intl.RelativeTimeFormat(getLocale(), { numeric: 'auto' }).format(amount, unit)
7482}
7583
84/** Long calendar-day label for grouped lists (commit history day headers). */
85export function formatDay(value: string | number | null | undefined): string {
86 if (value === null || value === undefined) return DASH
87 const date = typeof value === 'number' ? new Date(value * 1000) : parseDateValue(value)
88 if (Number.isNaN(date.getTime())) return typeof value === 'number' ? DASH : value
89 return new Intl.DateTimeFormat(getLocale(), { dateStyle: 'long' }).format(date)
90}
91
7692/** Medium date (no time) for settings rows and metadata blocks. */
7793export function formatDate(value: string | null | undefined, locale?: Locale): string {
7894 if (!value) return DASH
Modified web/src/shared/i18n/index.ts web/src/shared/i18n/index.ts +1 −0 View web/src/shared/i18n/index.ts
@@ -22,5 +22,6 @@
2222 formatDate,
2323 formatDateTime,
2424 formatRelative,
25 formatTime,
2526 shortSha,
2627} from './format'
Modified web/src/shared/lib/cache-keys.ts web/src/shared/lib/cache-keys.ts +1 −0 View web/src/shared/lib/cache-keys.ts
@@ -36,6 +36,7 @@
3636 */
3737export const spaceReposPickKey = (space: string) => `space:${space}:repos:pick`
3838export const spaceBoardsKey = (space: string) => `space:${space}:boards`
39export const spaceAgentCollectionsKey = (space: string) => `space:${space}:agent-collections`
3940export const spaceEmojisKey = (space: string) => `space:${space}:emojis`
4041
4142/** features/space-management rows (limit 200). */
Modified web/src/shared/lib/format.ts web/src/shared/lib/format.ts +1 −1 View web/src/shared/lib/format.ts
@@ -6,4 +6,4 @@
66 * public API) intact for the call sites that already import them.
77 */
88export { formatBytes, formatDate, formatDateTime, formatRelative, shortSha } from '@/shared/i18n/format'
9export { formatDateTime as fmtTime, formatRelative as fmtRelative } from '@/shared/i18n/format'
9export { formatDay as fmtDay, formatDateTime as fmtTime, formatRelative as fmtRelative } from '@/shared/i18n/format'
Modified web/src/shared/lib/index.ts web/src/shared/lib/index.ts +1 −1 View web/src/shared/lib/index.ts
@@ -36,6 +36,6 @@
3636export { useAsync, type AsyncCacheOptions, type AsyncState } from './use-async'
3737export { prefetch, runShared, PREFETCH_FRESH_MS, type PrefetchOptions } from './prefetch'
3838export * from './cache-keys'
39export { formatBytes, shortSha, fmtTime, fmtRelative } from './format'
39export { formatBytes, shortSha, fmtDay, fmtTime, fmtRelative } from './format'
4040
4141export * from './api-envelope'
Modified web/src/shared/ui/app-shell.tsx web/src/shared/ui/app-shell.tsx +7 −2 View web/src/shared/ui/app-shell.tsx
@@ -97,15 +97,20 @@
9797 icon?: ReactNode
9898 title: ReactNode
9999 topic?: ReactNode
100 /** Page-level view switch, rendered beside the title. */
101 tabs?: ReactNode
100102 actions?: ReactNode
101103}
102104
103export function ShellTopBar({ icon, title, topic, actions }: ShellTopBarProps) {
105export function ShellTopBar({ icon, title, topic, tabs, actions }: ShellTopBarProps) {
104106 return (
105107 <header className="shell-top-bar">
106108 {icon ? <span className="shell-top-icon">{icon}</span> : null}
107109 <div className="min-w-0 flex-1">
108 <h1 className="truncate text-base font-semibold">{title}</h1>
110 <div className="flex min-w-0 items-center gap-3">
111 <h1 className="truncate text-base font-semibold">{title}</h1>
112 {tabs ? <div className="flex shrink-0 items-center">{tabs}</div> : null}
113 </div>
109114 {topic ? <p className="mt-0.5 truncate text-xs text-shell-ink-muted">{topic}</p> : null}
110115 </div>
111116 {actions ? <div className="shell-top-actions">{actions}</div> : null}
Modified web/src/shared/ui/markdown.tsx web/src/shared/ui/markdown.tsx +15 −4 View web/src/shared/ui/markdown.tsx
@@ -15,20 +15,31 @@
1515 children,
1616 className = '',
1717 compact = false,
18 inline = false,
1819}: {
1920 children: string
2021 className?: string
2122 /** 行内发现等小字号场景。 */
2223 compact?: boolean
24 /** 只要行内结果(标题、单行字段):无 `<p>` 包裹,渲染成 `<span>`。 */
25 inline?: boolean
2326}) {
2427 const html = useMemo(
25 () => DOMPurify.sanitize(marked.parse(children, { async: false })),
26 [children],
28 () => {
29 // `async: false` is what makes both calls synchronous; the cast only
30 // undoes TypeScript's widening of the option literal to `boolean`.
31 const raw = (
32 inline ? marked.parseInline(children) : marked.parse(children, { async: false })
33 ) as string
34 return DOMPurify.sanitize(raw)
35 },
36 [children, inline],
2737 )
38 const Tag = inline ? 'span' : 'div'
2839 return (
29 <div
40 <Tag
3041 className={`gitdata-markdown ${compact ? 'gitdata-markdown-compact' : ''} ${className}`}
3142 dangerouslySetInnerHTML={{ __html: html }}
3243 />
3344 )
34}
No newline at end of file
45}
Modified web/openapi.json web/openapi.json View web/openapi.json
Diff too large to display.