Skip to content

Commit f290d7e

Browse files
committed
feat(app): reorder crews by dragging or from the menu
Drag a crew's name in the sidebar, or use Move up / Move down in its menu. The order is a look of this computer, kept in localStorage; the crews page follows it. Sidebar conversation lines moved to their own file.
1 parent bed75ae commit f290d7e

11 files changed

Lines changed: 445 additions & 117 deletions

File tree

‎app/src/features/crews/CrewsOverview.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { CountBadge } from "../../ui/Badge";
1010
import { Button } from "../../ui/Button";
1111
import { BotAvatar, moodOf } from "../bots/BotAvatar";
1212
import { CrewDialog } from "./CrewDialog";
13+
import { orderedCrews, useCrewOrder } from "./crewOrder";
1314

1415
/** Faces shown on a card; the rest are counted. */
1516
const FACES = 6;
@@ -18,7 +19,7 @@ const FACES = 6;
1819
export function CrewsOverview() {
1920
const t = useT();
2021
const words = t.crews.overview;
21-
const crews = useApp(useShallow(crewList));
22+
const crews = orderedCrews(useApp(useShallow(crewList)), useCrewOrder());
2223
const botCount = useApp((state) => Object.keys(state.bots).length);
2324
const [creating, setCreating] = useState(false);
2425
return (

‎app/src/features/crews/Sidebar.tsx‎

Lines changed: 62 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,42 @@
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";
311
import { createPortal } from "react-dom";
412
import { 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";
1016
import { useApp } from "../../store/context";
11-
import { isUnread, unreadIn } from "../../store/seen";
17+
import { unreadIn } from "../../store/seen";
1218
import { CountBadge } from "../../ui/Badge";
1319
import { Button } from "../../ui/Button";
1420
import { 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";
2121
import { CrewDialog } from "./CrewDialog";
2222
import { setAllCollapsed, setCollapsed, useCollapsed, useCollapsedSet } from "./collapsed";
2323
import { useCrewActions } from "./crewActions";
24+
import { moveBy, orderedCrews, setCrewOrder, useCrewOrder } from "./crewOrder";
25+
import { Conversation } from "./SidebarConversation";
2426
import { row } from "./SidebarPages";
2527
import { StateFilters } from "./StateFilters";
2628
import { filteredBots, useStateFilter } from "./stateFilter";
29+
import { useCrewDrag } from "./useCrewDrag";
2730

2831
/** Crews as sections and their bots as conversations (spec 15.1). */
2932
export 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-
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { memo, useCallback, useState } from "react";
2+
import { createPortal } from "react-dom";
3+
import { type Messages, useT } from "../../i18n";
4+
import { when } from "../../lib/format";
5+
import { plainText } from "../../lib/plainText";
6+
import type { Activity, Bot, Crew } from "../../lib/protocol.gen";
7+
import { activityOf } from "../../store/app";
8+
import { useApp } from "../../store/context";
9+
import { isUnread } from "../../store/seen";
10+
import { ContextMenu, menuPoint, type Point } from "../../ui/ContextMenu";
11+
import { APP_OPENED } from "../../ui/motion";
12+
import { BotStateBadge, stateView } from "../bots/BotStateBadge";
13+
import { useBotActions } from "../bots/botActions";
14+
import { ChiefBadge, isChief } from "../bots/ChiefBadge";
15+
import { ListAvatar } from "../bots/ListAvatar";
16+
import { toolAction, toolTitle } from "../chat/toolNames";
17+
import { row } from "./SidebarPages";
18+
19+
/** Memoized: a change to one bot re-renders only its own line. */
20+
export const Conversation = memo(function Conversation({ bot, crew }: { bot: Bot; crew: Crew }) {
21+
const t = useT();
22+
const selected = useApp((state) => state.selectedBotId === bot.id);
23+
const selectBot = useApp((state) => state.selectBot);
24+
// A right-click opens the bot's menu, the same as in its header.
25+
const actions = useBotActions(bot, crew);
26+
const [menuAt, setMenuAt] = useState<Point | null>(null);
27+
const closeMenu = useCallback(() => setMenuAt(null), []);
28+
const activity = useApp((state) => activityOf(state, bot.id));
29+
const unread = useApp((state) => isUnread(state, bot.id));
30+
return (
31+
<li className={bot.createdAt > APP_OPENED ? "animate-rise" : undefined}>
32+
<button
33+
type="button"
34+
aria-current={selected ? "page" : undefined}
35+
aria-label={[bot.name, stateView(bot, crew.paused).label, unread && t.crews.sidebar.unread]
36+
.filter(Boolean)
37+
.join(", ")}
38+
onClick={() => selectBot(bot.id)}
39+
onContextMenu={(event) => {
40+
event.preventDefault();
41+
setMenuAt(menuPoint(event));
42+
}}
43+
className={`${row} gap-2.5 px-2.5 py-2 ${selected || menuAt ? "bg-sunken" : ""}`}
44+
>
45+
<ListAvatar bot={bot} crewPaused={crew.paused} size={32} />
46+
<span className="min-w-0 flex-1">
47+
<span className="flex items-baseline gap-2">
48+
<span className="flex min-w-0 flex-1 items-center gap-1">
49+
<span className={`truncate text-sm ${unread ? "font-semibold" : "font-medium"}`}>
50+
{bot.name}
51+
</span>
52+
{isChief(bot, crew) && <ChiefBadge crew={crew} compact />}
53+
</span>
54+
{activity && (
55+
<time
56+
className={`shrink-0 text-xs ${unread ? "font-semibold text-accent-text" : "text-muted"}`}
57+
dateTime={new Date(activity.at).toISOString()}
58+
>
59+
{when(activity.at)}
60+
</time>
61+
)}
62+
</span>
63+
<span className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs">
64+
<BotStateBadge bot={bot} crewPaused={crew.paused} compact />
65+
<span className={`min-w-0 flex-1 truncate ${unread ? "text-ink" : "text-muted"}`}>
66+
{activity ? activityText(activity, t) : bot.role || t.crews.sidebar.noMessages}
67+
</span>
68+
{unread && <span aria-hidden className="size-2.5 shrink-0 rounded-full bg-accent" />}
69+
</span>
70+
</span>
71+
</button>
72+
{menuAt && (
73+
<ContextMenu
74+
label={t.bots.header.menuOf(bot.name)}
75+
items={actions.items}
76+
at={menuAt}
77+
onClose={closeMenu}
78+
/>
79+
)}
80+
{/* Outside the list: a dialog is not part of the navigation. */}
81+
{actions.dialogs && createPortal(actions.dialogs, document.body)}
82+
</li>
83+
);
84+
});
85+
86+
/**
87+
* The conversation-list line, worded here in the owner's language from what
88+
* the daemon sends (it sends no wording). What someone wrote shows as plain
89+
* words: the chat renders its Markdown, a line would show the marks raw. A
90+
* command or a path keeps its own characters.
91+
*/
92+
function activityText(activity: Activity, t: Messages): string {
93+
const { text, tool } = activity;
94+
switch (activity.kind) {
95+
case "owner":
96+
return t.crews.sidebar.fromOwner(plainText(text));
97+
case "approval":
98+
return t.crews.sidebar.awaitingApproval(tool ? toolAction(tool, t.tools) : text);
99+
case "question":
100+
return t.questions.activity(plainText(text));
101+
case "tool": {
102+
if (!tool) {
103+
return text;
104+
}
105+
const title = toolTitle(tool, t.tools);
106+
return text ? `${title} · ${text}` : title;
107+
}
108+
default:
109+
return plainText(text);
110+
}
111+
}

‎app/src/features/crews/crewMenu.test.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ describe("a crew's right-click menu", () => {
3939
).toEqual([
4040
"New bot",
4141
"Pause crew",
42+
"Move up",
4243
"Rename",
4344
"Open work folder",
4445
"Change work folder…",

0 commit comments

Comments
 (0)