1- import { ChevronDown , ChevronsDownUp , ChevronsUpDown , Pause , Plus } from "lucide-react" ;
2- import { memo , useCallback , useState } from "react" ;
1+ import {
2+ ArrowDown ,
3+ ArrowUp ,
4+ ChevronDown ,
5+ ChevronsDownUp ,
6+ ChevronsUpDown ,
7+ Pause ,
8+ Plus ,
9+ } from "lucide-react" ;
10+ import { useCallback , useRef , useState } from "react" ;
311import { createPortal } from "react-dom" ;
412import { useShallow } from "zustand/react/shallow" ;
5- import { type Messages , useT } from "../../i18n" ;
6- import { when } from "../../lib/format" ;
7- import { plainText } from "../../lib/plainText" ;
8- import type { Activity , Bot , Crew } from "../../lib/protocol.gen" ;
9- import { activityOf , crewList } from "../../store/app" ;
13+ import { useT } from "../../i18n" ;
14+ import type { Crew , CrewId } from "../../lib/protocol.gen" ;
15+ import { crewList } from "../../store/app" ;
1016import { useApp } from "../../store/context" ;
11- import { isUnread , unreadIn } from "../../store/seen" ;
17+ import { unreadIn } from "../../store/seen" ;
1218import { CountBadge } from "../../ui/Badge" ;
1319import { Button } from "../../ui/Button" ;
1420import { ContextMenu , menuPoint , type Point } from "../../ui/ContextMenu" ;
15- import { APP_OPENED } from "../../ui/motion" ;
16- import { BotStateBadge , stateView } from "../bots/BotStateBadge" ;
17- import { useBotActions } from "../bots/botActions" ;
18- import { ChiefBadge , isChief } from "../bots/ChiefBadge" ;
19- import { ListAvatar } from "../bots/ListAvatar" ;
20- import { toolAction , toolTitle } from "../chat/toolNames" ;
2121import { CrewDialog } from "./CrewDialog" ;
2222import { setAllCollapsed , setCollapsed , useCollapsed , useCollapsedSet } from "./collapsed" ;
2323import { useCrewActions } from "./crewActions" ;
24+ import { moveBy , orderedCrews , setCrewOrder , useCrewOrder } from "./crewOrder" ;
25+ import { Conversation } from "./SidebarConversation" ;
2426import { row } from "./SidebarPages" ;
2527import { StateFilters } from "./StateFilters" ;
2628import { filteredBots , useStateFilter } from "./stateFilter" ;
29+ import { useCrewDrag } from "./useCrewDrag" ;
2730
2831/** Crews as sections and their bots as conversations (spec 15.1). */
2932export function Sidebar ( ) {
3033 const t = useT ( ) ;
3134 const words = t . crews . sidebar ;
32- const crews = useApp ( useShallow ( crewList ) ) ;
35+ const order = useCrewOrder ( ) ;
36+ const crews = orderedCrews ( useApp ( useShallow ( crewList ) ) , order ) ;
37+ const ids = crews . map ( ( crew ) => crew . id ) ;
38+ const list = useRef < HTMLUListElement > ( null ) ;
39+ const drag = useCrewDrag ( ids , list ) ;
3340 const overview = useApp ( ( state ) => state . selectedCrewId === null && ! state . page ) ;
3441 const selectCrew = useApp ( ( state ) => state . selectCrew ) ;
3542 const collapsed = useCollapsedSet ( ) ;
@@ -66,9 +73,9 @@ export function Sidebar() {
6673 />
6774 </ div >
6875 { crews . length > 0 && < StateFilters /> }
69- < ul className = "min-h-0 flex-1 overflow-y-auto px-2 pb-3" >
76+ < ul ref = { list } className = "min-h-0 flex-1 overflow-y-auto px-2 pb-3" >
7077 { crews . map ( ( crew ) => (
71- < CrewEntry key = { crew . id } crew = { crew } />
78+ < CrewEntry key = { crew . id } crew = { crew } ids = { ids } drag = { drag } />
7279 ) ) }
7380 </ ul >
7481 </ nav >
@@ -77,7 +84,15 @@ export function Sidebar() {
7784 ) ;
7885}
7986
80- function CrewEntry ( { crew } : { crew : Crew } ) {
87+ function CrewEntry ( {
88+ crew,
89+ ids,
90+ drag,
91+ } : {
92+ crew : Crew ;
93+ ids : CrewId [ ] ;
94+ drag : ReturnType < typeof useCrewDrag > ;
95+ } ) {
8196 const t = useT ( ) ;
8297 const filter = useStateFilter ( ) ;
8398 const bots = useApp ( useShallow ( ( state ) => filteredBots ( state , crew . id , filter ) ) ) ;
@@ -95,13 +110,34 @@ function CrewEntry({ crew }: { crew: Crew }) {
95110 const waiting =
96111 collapsed && bots . some ( ( bot ) => bot . state === "needs_approval" || bot . state === "auth_error" ) ;
97112 const words = t . crews . sidebar ;
113+ // A line shows where the dragged crew would land.
114+ const spot = drag . over ?. id === crew . id && drag . dragging !== crew . id ? drag . over : null ;
115+ const dropEdge = spot
116+ ? `border-accent border-solid ${ spot . after ? "border-b-2" : "border-t-2" } `
117+ : "" ;
118+ const index = ids . indexOf ( crew . id ) ;
119+ const moves = [
120+ index > 0 && {
121+ label : words . moveUp ,
122+ icon : ArrowUp ,
123+ onSelect : ( ) => setCrewOrder ( moveBy ( ids , crew . id , - 1 ) ) ,
124+ } ,
125+ index < ids . length - 1 && {
126+ label : words . moveDown ,
127+ icon : ArrowDown ,
128+ onSelect : ( ) => setCrewOrder ( moveBy ( ids , crew . id , 1 ) ) ,
129+ } ,
130+ ] . filter ( ( item ) => item !== false ) ;
98131
99132 // Filtering, a crew with nothing to show steps aside.
100133 if ( filter !== "all" && bots . length === 0 ) {
101134 return null ;
102135 }
103136 return (
104- < li className = "mt-2" >
137+ < li
138+ data-crew-id = { crew . id }
139+ className = { `mt-2 ${ drag . dragging === crew . id ? "opacity-50" : "" } ${ dropEdge } ` }
140+ >
105141 < div className = "relative flex items-center" >
106142 < button
107143 type = "button"
@@ -120,7 +156,12 @@ function CrewEntry({ crew }: { crew: Crew }) {
120156 < button
121157 type = "button"
122158 aria-current = { selected ? "page" : undefined }
123- onClick = { ( ) => selectCrew ( crew . id ) }
159+ onPointerDown = { ( event ) => drag . start ( crew . id , event ) }
160+ onClick = { ( ) => {
161+ if ( ! drag . wasDrag ( ) ) {
162+ selectCrew ( crew . id ) ;
163+ }
164+ } }
124165 onContextMenu = { ( event ) => {
125166 event . preventDefault ( ) ;
126167 setMenuAt ( menuPoint ( event ) ) ;
@@ -156,7 +197,7 @@ function CrewEntry({ crew }: { crew: Crew }) {
156197 { menuAt && (
157198 < ContextMenu
158199 label = { words . menuOf ( crew . name ) }
159- items = { [ actions . newBot , actions . pause , ...actions . items ] }
200+ items = { [ actions . newBot , actions . pause , ...moves , ... actions . items ] }
160201 at = { menuAt }
161202 onClose = { closeMenu }
162203 />
@@ -171,97 +212,3 @@ function CrewEntry({ crew }: { crew: Crew }) {
171212 </ li >
172213 ) ;
173214}
174-
175- /** Memoized: a change to one bot re-renders only its own line. */
176- const Conversation = memo ( function Conversation ( { bot, crew } : { bot : Bot ; crew : Crew } ) {
177- const t = useT ( ) ;
178- const selected = useApp ( ( state ) => state . selectedBotId === bot . id ) ;
179- const selectBot = useApp ( ( state ) => state . selectBot ) ;
180- // A right-click opens the bot's menu, the same as in its header.
181- const actions = useBotActions ( bot , crew ) ;
182- const [ menuAt , setMenuAt ] = useState < Point | null > ( null ) ;
183- const closeMenu = useCallback ( ( ) => setMenuAt ( null ) , [ ] ) ;
184- const activity = useApp ( ( state ) => activityOf ( state , bot . id ) ) ;
185- const unread = useApp ( ( state ) => isUnread ( state , bot . id ) ) ;
186- return (
187- < li className = { bot . createdAt > APP_OPENED ? "animate-rise" : undefined } >
188- < button
189- type = "button"
190- aria-current = { selected ? "page" : undefined }
191- aria-label = { [ bot . name , stateView ( bot , crew . paused ) . label , unread && t . crews . sidebar . unread ]
192- . filter ( Boolean )
193- . join ( ", " ) }
194- onClick = { ( ) => selectBot ( bot . id ) }
195- onContextMenu = { ( event ) => {
196- event . preventDefault ( ) ;
197- setMenuAt ( menuPoint ( event ) ) ;
198- } }
199- className = { `${ row } gap-2.5 px-2.5 py-2 ${ selected || menuAt ? "bg-sunken" : "" } ` }
200- >
201- < ListAvatar bot = { bot } crewPaused = { crew . paused } size = { 32 } />
202- < span className = "min-w-0 flex-1" >
203- < span className = "flex items-baseline gap-2" >
204- < span className = "flex min-w-0 flex-1 items-center gap-1" >
205- < span className = { `truncate text-sm ${ unread ? "font-semibold" : "font-medium" } ` } >
206- { bot . name }
207- </ span >
208- { isChief ( bot , crew ) && < ChiefBadge crew = { crew } compact /> }
209- </ span >
210- { activity && (
211- < time
212- className = { `shrink-0 text-xs ${ unread ? "font-semibold text-accent-text" : "text-muted" } ` }
213- dateTime = { new Date ( activity . at ) . toISOString ( ) }
214- >
215- { when ( activity . at ) }
216- </ time >
217- ) }
218- </ span >
219- < span className = "mt-0.5 flex min-w-0 items-center gap-1.5 text-xs" >
220- < BotStateBadge bot = { bot } crewPaused = { crew . paused } compact />
221- < span className = { `min-w-0 flex-1 truncate ${ unread ? "text-ink" : "text-muted" } ` } >
222- { activity ? activityText ( activity , t ) : bot . role || t . crews . sidebar . noMessages }
223- </ span >
224- { unread && < span aria-hidden className = "size-2.5 shrink-0 rounded-full bg-accent" /> }
225- </ span >
226- </ span >
227- </ button >
228- { menuAt && (
229- < ContextMenu
230- label = { t . bots . header . menuOf ( bot . name ) }
231- items = { actions . items }
232- at = { menuAt }
233- onClose = { closeMenu }
234- />
235- ) }
236- { /* Outside the list: a dialog is not part of the navigation. */ }
237- { actions . dialogs && createPortal ( actions . dialogs , document . body ) }
238- </ li >
239- ) ;
240- } ) ;
241-
242- /**
243- * The conversation-list line, worded here in the owner's language from what
244- * the daemon sends (it sends no wording). What someone wrote shows as plain
245- * words: the chat renders its Markdown, a line would show the marks raw. A
246- * command or a path keeps its own characters.
247- */
248- function activityText ( activity : Activity , t : Messages ) : string {
249- const { text, tool } = activity ;
250- switch ( activity . kind ) {
251- case "owner" :
252- return t . crews . sidebar . fromOwner ( plainText ( text ) ) ;
253- case "approval" :
254- return t . crews . sidebar . awaitingApproval ( tool ? toolAction ( tool , t . tools ) : text ) ;
255- case "question" :
256- return t . questions . activity ( plainText ( text ) ) ;
257- case "tool" : {
258- if ( ! tool ) {
259- return text ;
260- }
261- const title = toolTitle ( tool , t . tools ) ;
262- return text ? `${ title } · ${ text } ` : title ;
263- }
264- default :
265- return plainText ( text ) ;
266- }
267- }
0 commit comments