Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion app/src/features/crews/CrewsOverview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { CountBadge } from "../../ui/Badge";
import { Button } from "../../ui/Button";
import { BotAvatar, moodOf } from "../bots/BotAvatar";
import { CrewDialog } from "./CrewDialog";
import { orderedCrews, useCrewOrder } from "./crewOrder";

/** Faces shown on a card; the rest are counted. */
const FACES = 6;
Expand All @@ -18,7 +19,7 @@ const FACES = 6;
export function CrewsOverview() {
const t = useT();
const words = t.crews.overview;
const crews = useApp(useShallow(crewList));
const crews = orderedCrews(useApp(useShallow(crewList)), useCrewOrder());
const botCount = useApp((state) => Object.keys(state.bots).length);
const [creating, setCreating] = useState(false);
return (
Expand Down
177 changes: 62 additions & 115 deletions app/src/features/crews/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,35 +1,42 @@
import { ChevronDown, ChevronsDownUp, ChevronsUpDown, Pause, Plus } from "lucide-react";
import { memo, useCallback, useState } from "react";
import {
ArrowDown,
ArrowUp,
ChevronDown,
ChevronsDownUp,
ChevronsUpDown,
Pause,
Plus,
} from "lucide-react";
import { useCallback, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useShallow } from "zustand/react/shallow";
import { type Messages, useT } from "../../i18n";
import { when } from "../../lib/format";
import { plainText } from "../../lib/plainText";
import type { Activity, Bot, Crew } from "../../lib/protocol.gen";
import { activityOf, crewList } from "../../store/app";
import { useT } from "../../i18n";
import type { Crew, CrewId } from "../../lib/protocol.gen";
import { crewList } from "../../store/app";
import { useApp } from "../../store/context";
import { isUnread, unreadIn } from "../../store/seen";
import { unreadIn } from "../../store/seen";
import { CountBadge } from "../../ui/Badge";
import { Button } from "../../ui/Button";
import { ContextMenu, menuPoint, type Point } from "../../ui/ContextMenu";
import { APP_OPENED } from "../../ui/motion";
import { BotStateBadge, stateView } from "../bots/BotStateBadge";
import { useBotActions } from "../bots/botActions";
import { ChiefBadge, isChief } from "../bots/ChiefBadge";
import { ListAvatar } from "../bots/ListAvatar";
import { toolAction, toolTitle } from "../chat/toolNames";
import { CrewDialog } from "./CrewDialog";
import { setAllCollapsed, setCollapsed, useCollapsed, useCollapsedSet } from "./collapsed";
import { useCrewActions } from "./crewActions";
import { moveBy, orderedCrews, setCrewOrder, useCrewOrder } from "./crewOrder";
import { Conversation } from "./SidebarConversation";
import { row } from "./SidebarPages";
import { StateFilters } from "./StateFilters";
import { filteredBots, useStateFilter } from "./stateFilter";
import { useCrewDrag } from "./useCrewDrag";

/** Crews as sections and their bots as conversations (spec 15.1). */
export function Sidebar() {
const t = useT();
const words = t.crews.sidebar;
const crews = useApp(useShallow(crewList));
const order = useCrewOrder();
const crews = orderedCrews(useApp(useShallow(crewList)), order);
const ids = crews.map((crew) => crew.id);
const list = useRef<HTMLUListElement>(null);
const drag = useCrewDrag(ids, list);
const overview = useApp((state) => state.selectedCrewId === null && !state.page);
const selectCrew = useApp((state) => state.selectCrew);
const collapsed = useCollapsedSet();
Expand Down Expand Up @@ -66,9 +73,9 @@ export function Sidebar() {
/>
</div>
{crews.length > 0 && <StateFilters />}
<ul className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
<ul ref={list} className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
{crews.map((crew) => (
<CrewEntry key={crew.id} crew={crew} />
<CrewEntry key={crew.id} crew={crew} ids={ids} drag={drag} />
))}
</ul>
</nav>
Expand All @@ -77,7 +84,15 @@ export function Sidebar() {
);
}

function CrewEntry({ crew }: { crew: Crew }) {
function CrewEntry({
crew,
ids,
drag,
}: {
crew: Crew;
ids: CrewId[];
drag: ReturnType<typeof useCrewDrag>;
}) {
const t = useT();
const filter = useStateFilter();
const bots = useApp(useShallow((state) => filteredBots(state, crew.id, filter)));
Expand All @@ -95,13 +110,34 @@ function CrewEntry({ crew }: { crew: Crew }) {
const waiting =
collapsed && bots.some((bot) => bot.state === "needs_approval" || bot.state === "auth_error");
const words = t.crews.sidebar;
// A line shows where the dragged crew would land.
const spot = drag.over?.id === crew.id && drag.dragging !== crew.id ? drag.over : null;
const dropEdge = spot
? `border-accent border-solid ${spot.after ? "border-b-2" : "border-t-2"}`
: "";
const index = ids.indexOf(crew.id);
const moves = [
index > 0 && {
label: words.moveUp,
icon: ArrowUp,
onSelect: () => setCrewOrder(moveBy(ids, crew.id, -1)),
},
index < ids.length - 1 && {
label: words.moveDown,
icon: ArrowDown,
onSelect: () => setCrewOrder(moveBy(ids, crew.id, 1)),
},
].filter((item) => item !== false);

// Filtering, a crew with nothing to show steps aside.
if (filter !== "all" && bots.length === 0) {
return null;
}
return (
<li className="mt-2">
<li
data-crew-id={crew.id}
className={`mt-2 ${drag.dragging === crew.id ? "opacity-50" : ""} ${dropEdge}`}
>
<div className="relative flex items-center">
<button
type="button"
Expand All @@ -120,7 +156,12 @@ function CrewEntry({ crew }: { crew: Crew }) {
<button
type="button"
aria-current={selected ? "page" : undefined}
onClick={() => selectCrew(crew.id)}
onPointerDown={(event) => drag.start(crew.id, event)}
onClick={() => {
if (!drag.wasDrag()) {
selectCrew(crew.id);
}
}}
onContextMenu={(event) => {
event.preventDefault();
setMenuAt(menuPoint(event));
Expand Down Expand Up @@ -156,7 +197,7 @@ function CrewEntry({ crew }: { crew: Crew }) {
{menuAt && (
<ContextMenu
label={words.menuOf(crew.name)}
items={[actions.newBot, actions.pause, ...actions.items]}
items={[actions.newBot, actions.pause, ...moves, ...actions.items]}
at={menuAt}
onClose={closeMenu}
/>
Expand All @@ -171,97 +212,3 @@ function CrewEntry({ crew }: { crew: Crew }) {
</li>
);
}

/** Memoized: a change to one bot re-renders only its own line. */
const Conversation = memo(function Conversation({ bot, crew }: { bot: Bot; crew: Crew }) {
const t = useT();
const selected = useApp((state) => state.selectedBotId === bot.id);
const selectBot = useApp((state) => state.selectBot);
// A right-click opens the bot's menu, the same as in its header.
const actions = useBotActions(bot, crew);
const [menuAt, setMenuAt] = useState<Point | null>(null);
const closeMenu = useCallback(() => setMenuAt(null), []);
const activity = useApp((state) => activityOf(state, bot.id));
const unread = useApp((state) => isUnread(state, bot.id));
return (
<li className={bot.createdAt > APP_OPENED ? "animate-rise" : undefined}>
<button
type="button"
aria-current={selected ? "page" : undefined}
aria-label={[bot.name, stateView(bot, crew.paused).label, unread && t.crews.sidebar.unread]
.filter(Boolean)
.join(", ")}
onClick={() => selectBot(bot.id)}
onContextMenu={(event) => {
event.preventDefault();
setMenuAt(menuPoint(event));
}}
className={`${row} gap-2.5 px-2.5 py-2 ${selected || menuAt ? "bg-sunken" : ""}`}
>
<ListAvatar bot={bot} crewPaused={crew.paused} size={32} />
<span className="min-w-0 flex-1">
<span className="flex items-baseline gap-2">
<span className="flex min-w-0 flex-1 items-center gap-1">
<span className={`truncate text-sm ${unread ? "font-semibold" : "font-medium"}`}>
{bot.name}
</span>
{isChief(bot, crew) && <ChiefBadge crew={crew} compact />}
</span>
{activity && (
<time
className={`shrink-0 text-xs ${unread ? "font-semibold text-accent-text" : "text-muted"}`}
dateTime={new Date(activity.at).toISOString()}
>
{when(activity.at)}
</time>
)}
</span>
<span className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs">
<BotStateBadge bot={bot} crewPaused={crew.paused} compact />
<span className={`min-w-0 flex-1 truncate ${unread ? "text-ink" : "text-muted"}`}>
{activity ? activityText(activity, t) : bot.role || t.crews.sidebar.noMessages}
</span>
{unread && <span aria-hidden className="size-2.5 shrink-0 rounded-full bg-accent" />}
</span>
</span>
</button>
{menuAt && (
<ContextMenu
label={t.bots.header.menuOf(bot.name)}
items={actions.items}
at={menuAt}
onClose={closeMenu}
/>
)}
{/* Outside the list: a dialog is not part of the navigation. */}
{actions.dialogs && createPortal(actions.dialogs, document.body)}
</li>
);
});

/**
* The conversation-list line, worded here in the owner's language from what
* the daemon sends (it sends no wording). What someone wrote shows as plain
* words: the chat renders its Markdown, a line would show the marks raw. A
* command or a path keeps its own characters.
*/
function activityText(activity: Activity, t: Messages): string {
const { text, tool } = activity;
switch (activity.kind) {
case "owner":
return t.crews.sidebar.fromOwner(plainText(text));
case "approval":
return t.crews.sidebar.awaitingApproval(tool ? toolAction(tool, t.tools) : text);
case "question":
return t.questions.activity(plainText(text));
case "tool": {
if (!tool) {
return text;
}
const title = toolTitle(tool, t.tools);
return text ? `${title} · ${text}` : title;
}
default:
return plainText(text);
}
}
111 changes: 111 additions & 0 deletions app/src/features/crews/SidebarConversation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { memo, useCallback, useState } from "react";
import { createPortal } from "react-dom";
import { type Messages, useT } from "../../i18n";
import { when } from "../../lib/format";
import { plainText } from "../../lib/plainText";
import type { Activity, Bot, Crew } from "../../lib/protocol.gen";
import { activityOf } from "../../store/app";
import { useApp } from "../../store/context";
import { isUnread } from "../../store/seen";
import { ContextMenu, menuPoint, type Point } from "../../ui/ContextMenu";
import { APP_OPENED } from "../../ui/motion";
import { BotStateBadge, stateView } from "../bots/BotStateBadge";
import { useBotActions } from "../bots/botActions";
import { ChiefBadge, isChief } from "../bots/ChiefBadge";
import { ListAvatar } from "../bots/ListAvatar";
import { toolAction, toolTitle } from "../chat/toolNames";
import { row } from "./SidebarPages";

/** Memoized: a change to one bot re-renders only its own line. */
export const Conversation = memo(function Conversation({ bot, crew }: { bot: Bot; crew: Crew }) {
const t = useT();
const selected = useApp((state) => state.selectedBotId === bot.id);
const selectBot = useApp((state) => state.selectBot);
// A right-click opens the bot's menu, the same as in its header.
const actions = useBotActions(bot, crew);
const [menuAt, setMenuAt] = useState<Point | null>(null);
const closeMenu = useCallback(() => setMenuAt(null), []);
const activity = useApp((state) => activityOf(state, bot.id));
const unread = useApp((state) => isUnread(state, bot.id));
return (
<li className={bot.createdAt > APP_OPENED ? "animate-rise" : undefined}>
<button
type="button"
aria-current={selected ? "page" : undefined}
aria-label={[bot.name, stateView(bot, crew.paused).label, unread && t.crews.sidebar.unread]
.filter(Boolean)
.join(", ")}
onClick={() => selectBot(bot.id)}
onContextMenu={(event) => {
event.preventDefault();
setMenuAt(menuPoint(event));
}}
className={`${row} gap-2.5 px-2.5 py-2 ${selected || menuAt ? "bg-sunken" : ""}`}
>
<ListAvatar bot={bot} crewPaused={crew.paused} size={32} />
<span className="min-w-0 flex-1">
<span className="flex items-baseline gap-2">
<span className="flex min-w-0 flex-1 items-center gap-1">
<span className={`truncate text-sm ${unread ? "font-semibold" : "font-medium"}`}>
{bot.name}
</span>
{isChief(bot, crew) && <ChiefBadge crew={crew} compact />}
</span>
{activity && (
<time
className={`shrink-0 text-xs ${unread ? "font-semibold text-accent-text" : "text-muted"}`}
dateTime={new Date(activity.at).toISOString()}
>
{when(activity.at)}
</time>
)}
</span>
<span className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs">
<BotStateBadge bot={bot} crewPaused={crew.paused} compact />
<span className={`min-w-0 flex-1 truncate ${unread ? "text-ink" : "text-muted"}`}>
{activity ? activityText(activity, t) : bot.role || t.crews.sidebar.noMessages}
</span>
{unread && <span aria-hidden className="size-2.5 shrink-0 rounded-full bg-accent" />}
</span>
</span>
</button>
{menuAt && (
<ContextMenu
label={t.bots.header.menuOf(bot.name)}
items={actions.items}
at={menuAt}
onClose={closeMenu}
/>
)}
{/* Outside the list: a dialog is not part of the navigation. */}
{actions.dialogs && createPortal(actions.dialogs, document.body)}
</li>
);
});

/**
* The conversation-list line, worded here in the owner's language from what
* the daemon sends (it sends no wording). What someone wrote shows as plain
* words: the chat renders its Markdown, a line would show the marks raw. A
* command or a path keeps its own characters.
*/
function activityText(activity: Activity, t: Messages): string {
const { text, tool } = activity;
switch (activity.kind) {
case "owner":
return t.crews.sidebar.fromOwner(plainText(text));
case "approval":
return t.crews.sidebar.awaitingApproval(tool ? toolAction(tool, t.tools) : text);
case "question":
return t.questions.activity(plainText(text));
case "tool": {
if (!tool) {
return text;
}
const title = toolTitle(tool, t.tools);
return text ? `${title} · ${text}` : title;
}
default:
return plainText(text);
}
}
1 change: 1 addition & 0 deletions app/src/features/crews/crewMenu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ describe("a crew's right-click menu", () => {
).toEqual([
"New bot",
"Pause crew",
"Move up",
"Rename",
"Open work folder",
"Change work folder…",
Expand Down
Loading
Loading