@@ -29,7 +29,9 @@ import {
2929 WebuiIconFolder ,
3030 WebuiIconMore ,
3131 WebuiIconProjectAdd ,
32- WebuiIconSessionPin ,
32+ WebuiIconSessionPinMark ,
33+ WebuiIconSessionStar ,
34+ WebuiIconSessionStarMark ,
3335 WebuiIconSessionDisclosure ,
3436 WebuiIconAgent ,
3537} from "../icons.js" ;
@@ -228,13 +230,15 @@ export function WebuiProjectList({
228230 onCreateTaskInProject,
229231 error,
230232 pinnedSessions,
233+ starredSessions,
231234 pinnedProjects,
232235 projectNames,
233236 onRenameProject,
234237 onToggleProjectPin,
235238 onArchiveProject,
236239 onRenameSession,
237240 onToggleSessionPin,
241+ onToggleSessionStar,
238242 onArchiveSession,
239243 onForkSession,
240244 onCopySession,
@@ -256,13 +260,16 @@ export function WebuiProjectList({
256260 readonly onCreateTaskInProject ?: ( project : WebuiProjectGroup ) => void ;
257261 readonly error ?: string ;
258262 readonly pinnedSessions ?: Readonly < Record < string , boolean > > ;
263+ /** Which rows the 收藏 view collects, and which carry the standing star. */
264+ readonly starredSessions ?: Readonly < Record < string , boolean > > ;
259265 readonly pinnedProjects ?: Readonly < Record < string , boolean > > ;
260266 readonly projectNames ?: Readonly < Record < string , string > > ;
261267 readonly onRenameProject ?: ( project : WebuiProjectGroup ) => void ;
262268 readonly onToggleProjectPin ?: ( project : WebuiProjectGroup ) => void ;
263269 readonly onArchiveProject ?: ( project : WebuiProjectGroup ) => void ;
264270 readonly onRenameSession ?: ( session : WebuiClientSession ) => void ;
265271 readonly onToggleSessionPin ?: ( session : WebuiClientSession ) => void ;
272+ readonly onToggleSessionStar ?: ( session : WebuiClientSession ) => void ;
266273 readonly onArchiveSession ?: ( session : WebuiClientSession ) => void ;
267274 readonly onForkSession ?: ( session : WebuiClientSession , createIsolatedWorktree : boolean ) => void ;
268275 readonly onCopySession ?: ( session : WebuiClientSession , value : "workspaceDir" | "sessionId" ) => void ;
@@ -401,6 +408,14 @@ export function WebuiProjectList({
401408 disabled : ! onToggleSessionPin ,
402409 onSelect : ( ) => onToggleSessionPin ?.( session ) ,
403410 } ,
411+ {
412+ kind : "item" ,
413+ key : "star" ,
414+ label : starredSessions ?. [ session . sessionId ] ? "取消收藏" : "收藏" ,
415+ icon : < WebuiIconSessionStar starred = { Boolean ( starredSessions ?. [ session . sessionId ] ) } /> ,
416+ disabled : ! onToggleSessionStar ,
417+ onSelect : ( ) => onToggleSessionStar ?.( session ) ,
418+ } ,
404419 {
405420 kind : "item" ,
406421 key : "rename" ,
@@ -585,12 +600,12 @@ export function WebuiProjectList({
585600 const activeView = viewProp ?? internalView ;
586601 const setActiveView = onViewChange ?? setInternalView ;
587602 const railTabs = useMemo (
588- ( ) => selectWebuiRailViewTabs ( page . sessions , activity ) ,
589- [ activity , page . sessions ] ,
603+ ( ) => selectWebuiRailViewTabs ( page . sessions , activity , starredSessions ) ,
604+ [ activity , page . sessions , starredSessions ] ,
590605 ) ;
591606 const viewSessions = useMemo (
592- ( ) => filterWebuiRailViewSessions ( page . sessions , activity , activeView ) ,
593- [ activeView , activity , page . sessions ] ,
607+ ( ) => filterWebuiRailViewSessions ( page . sessions , activity , activeView , starredSessions ) ,
608+ [ activeView , activity , page . sessions , starredSessions ] ,
594609 ) ;
595610 const activeTab = railTabs . find ( ( entry ) => entry . view === activeView ) ;
596611 const activeTabLabel = activeTab ?. label ?? "项目" ;
@@ -636,6 +651,8 @@ export function WebuiProjectList({
636651 error = { error }
637652 activity = { activity }
638653 now = { now }
654+ pinnedSessions = { pinnedSessions }
655+ starredSessions = { starredSessions }
639656 heading = { activeTabLabel }
640657 emptyLabel = { activeTab ?. emptyLabel }
641658 preserveOrder
@@ -700,6 +717,20 @@ export function WebuiProjectList({
700717 className = "webui-project-card text-left text-text_default_secondary"
701718 >
702719 < WebuiIconFolder className = "flex-shrink-0" />
720+ { pinnedProjects ?. [ project . key ] ?? project . pinned ? (
721+ /* Leading edge, not the trailing one: `.webui-project-row-actions`
722+ * is pinned to the row's right and only appears on hover, so a
723+ * mark on that side would be standing proof of nothing. */
724+ < span
725+ className = "webui-project-pin-mark"
726+ data-webui-project-pin-mark = "true"
727+ title = "已置顶"
728+ aria-label = "已置顶"
729+ role = "img"
730+ >
731+ < WebuiIconSessionPinMark />
732+ </ span >
733+ ) : null }
703734 < span className = "min-w-0 flex-1 truncate text-sm leading-5" >
704735 { projectName }
705736 </ span >
@@ -787,7 +818,13 @@ export function WebuiProjectList({
787818 < span className = "min-w-0 flex-1 truncate" >
788819 { sessionLabel ( session ) }
789820 </ span >
790- < SessionActivityMeta session = { session } activity = { activity } now = { now } />
821+ < SessionActivityMeta
822+ session = { session }
823+ activity = { activity }
824+ now = { now }
825+ pinned = { Boolean ( pinnedSessions ?. [ session . sessionId ] ) }
826+ starred = { Boolean ( starredSessions ?. [ session . sessionId ] ) }
827+ />
791828 </ a >
792829 < div className = "webui-session-row-actions" >
793830 { onToggleSessionPin ? (
@@ -802,7 +839,30 @@ export function WebuiProjectList({
802839 onToggleSessionPin ( session ) ;
803840 } }
804841 >
805- < WebuiIconSessionPin />
842+ { /* The glyph draws the ACTION, not the state: already
843+ * pinned means the click unpins, so the button offers
844+ * the slashed pin. Same component the context menu
845+ * uses, so the two cannot drift apart. */ }
846+ < WebuiIconContextPin pinned = { Boolean ( pinnedSessions ?. [ session . sessionId ] ) } />
847+ </ button >
848+ ) : null }
849+ { onToggleSessionStar ? (
850+ < button
851+ type = "button"
852+ aria-label = { `${ starredSessions ?. [ session . sessionId ] ? "取消收藏" : "收藏" } :${ sessionLabel ( session ) } ` }
853+ title = { starredSessions ?. [ session . sessionId ] ? "取消收藏" : "收藏" }
854+ className = "webui-rail-action"
855+ onClick = { ( event ) => {
856+ event . preventDefault ( ) ;
857+ event . stopPropagation ( ) ;
858+ onToggleSessionStar ( session ) ;
859+ } }
860+ >
861+ { /* Outline until starred, yellow fill after. Unlike the
862+ * pin, this glyph shows the state rather than the
863+ * action: a filled star is not read as "remove"
864+ * anywhere in the world's icon vocabulary. */ }
865+ < WebuiIconSessionStar starred = { Boolean ( starredSessions ?. [ session . sessionId ] ) } />
806866 </ button >
807867 ) : null }
808868 { onArchiveSession ? (
@@ -855,7 +915,13 @@ export function WebuiProjectList({
855915 < span className = "min-w-0 flex-1 truncate" >
856916 { sessionLabel ( child ) }
857917 </ span >
858- < SessionActivityMeta session = { child } activity = { activity } now = { now } />
918+ < SessionActivityMeta
919+ session = { child }
920+ activity = { activity }
921+ now = { now }
922+ pinned = { Boolean ( pinnedSessions ?. [ child . sessionId ] ) }
923+ starred = { Boolean ( starredSessions ?. [ child . sessionId ] ) }
924+ />
859925 </ a >
860926 < div className = "webui-session-row-actions" >
861927 < button
@@ -940,17 +1006,50 @@ function SessionActivityMeta({
9401006 session,
9411007 activity,
9421008 now,
1009+ pinned = false ,
1010+ starred = false ,
9431011} : {
9441012 readonly session : WebuiClientSession ;
9451013 readonly activity ?: WebuiSessionActivityMap ;
9461014 readonly now ?: number ;
1015+ /** Draws the standing pin mark. Independent of the live state below it. */
1016+ readonly pinned ?: boolean ;
1017+ /** Draws the standing star mark. Also independent of the live state. */
1018+ readonly starred ?: boolean ;
9471019} ) : ReactElement | null {
948- if ( ! activity && now === undefined ) return null ;
1020+ // `pinned` and `starred` join the guard rather than riding on it. A host that
1021+ // passes neither an activity map nor a clock is the rail rendered outside the
1022+ // app (SSR fixtures, and any host that does not subscribe) -- exactly the case
1023+ // where a marked row has no running/unread signal to read its state against,
1024+ // so swallowing the mark there loses the only thing the row was saying.
1025+ if ( ! activity && now === undefined && ! pinned && ! starred ) return null ;
9491026 const entry = activity ?. [ session . sessionId ] ;
9501027 const busy = entry ?. busy ;
9511028 const badge = formatWebuiUnreadBadge ( entry ?. unread ) ;
9521029 return (
9531030 < span className = "webui-rail-session-meta" >
1031+ { pinned ? (
1032+ < span
1033+ className = "webui-rail-pin-mark"
1034+ data-webui-pin-mark = "true"
1035+ title = "已置顶"
1036+ aria-label = "已置顶"
1037+ role = "img"
1038+ >
1039+ < WebuiIconSessionPinMark />
1040+ </ span >
1041+ ) : null }
1042+ { starred ? (
1043+ < span
1044+ className = "webui-rail-star-mark"
1045+ data-webui-star-mark = "true"
1046+ title = "已收藏"
1047+ aria-label = "已收藏"
1048+ role = "img"
1049+ >
1050+ < WebuiIconSessionStarMark />
1051+ </ span >
1052+ ) : null }
9541053 { badge ? (
9551054 < span
9561055 className = "webui-rail-unread-badge"
@@ -984,6 +1083,8 @@ export function WebuiSessionList({
9841083 teamModeChoices,
9851084 activity,
9861085 now,
1086+ pinnedSessions,
1087+ starredSessions,
9871088 heading,
9881089 emptyLabel,
9891090 preserveOrder,
@@ -992,11 +1093,24 @@ export function WebuiSessionList({
9921093 readonly loading : boolean ;
9931094 readonly onLoadMore ?: ( ) => void ;
9941095 readonly selectedSessionId ?: string ;
1096+ readonly onProjectSelect ?: ( workspaceDir ?: string ) => void ;
9951097 readonly error ?: string ;
9961098 readonly teamModeChoices ?: TeamModeSessionChoices ;
9971099 readonly activity ?: WebuiSessionActivityMap ;
9981100 /** Injected so the age labels re-render on a tick instead of on every event. */
9991101 readonly now ?: number ;
1102+ /**
1103+ * Which rows carry the standing pin mark. The running and unread views are
1104+ * exactly where a user needs it -- those rows are ordered by activity, so
1105+ * sort position says nothing about which one the user chose to keep.
1106+ */
1107+ readonly pinnedSessions ?: Readonly < Record < string , boolean > > ;
1108+ /**
1109+ * Which rows carry the standing star mark. 收藏 is a flat list, so a starred
1110+ * row has no project furniture to be recognised by and the mark is the only
1111+ * thing distinguishing it from an ordinary one.
1112+ */
1113+ readonly starredSessions ?: Readonly < Record < string , boolean > > ;
10001114 /**
10011115 * Overrides the section label. The rail reuses this list for its running and
10021116 * unread views, where "recent tasks" would be a lie -- the list is filtered,
@@ -1080,7 +1194,13 @@ export function WebuiSessionList({
10801194 Agent Team
10811195 </ span >
10821196 ) : null }
1083- < SessionActivityMeta session = { session } activity = { activity } now = { now } />
1197+ < SessionActivityMeta
1198+ session = { session }
1199+ activity = { activity }
1200+ now = { now }
1201+ pinned = { Boolean ( pinnedSessions ?. [ session . sessionId ] ) }
1202+ starred = { Boolean ( starredSessions ?. [ session . sessionId ] ) }
1203+ />
10841204 </ a >
10851205 { session . workspaceDir ? (
10861206 < div
0 commit comments