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
113 changes: 13 additions & 100 deletions app/src/features/crews/CrewView.tsx
Original file line number Diff line number Diff line change
@@ -1,71 +1,37 @@
import {
Archive,
Ellipsis,
Folder,
FolderInput,
FolderOpen,
Pause,
Pencil,
Play,
Plus,
Trash2,
} from "lucide-react";
import { Ellipsis, Folder, Plus } from "lucide-react";
import { useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { useT } from "../../i18n";
import type { Crew } from "../../lib/protocol.gen";
import { botsOf } from "../../store/app";
import { useApi, useApp, useHost } from "../../store/context";
import { useApp } from "../../store/context";
import { Button } from "../../ui/Button";
import { Confirm } from "../../ui/Confirm";
import { Menu } from "../../ui/Menu";
import { type Tab, Tabs, tabId } from "../../ui/Tabs";
import { attempt } from "../../ui/toast";
import { BotDialog } from "../bots/BotDialog";
import { CallPills, useLiveCalls } from "../messages/CallPills";
import { Composer } from "../messages/Composer";
import { Timeline } from "../messages/Timeline";
import { CrewRoutines } from "../routines/RoutineList";
import { TaskList } from "../tasks/TaskList";
import { CrewBots } from "./CrewBots";
import { CrewDialog } from "./CrewDialog";
import { DeleteCrew } from "./DeleteCrew";
import { useCrewActions } from "./crewActions";

type Open = "bot" | "rename" | "archive" | "delete" | { move: string } | null;
type Pane = "bots" | "timeline" | "tasks" | "routines";

export function CrewView({ crew }: { crew: Crew }) {
const t = useT();
const words = t.crews.view;
const api = useApi();
const host = useHost();
const putCrew = useApp((state) => state.putCrew);
const bots = useApp(useShallow((state) => botsOf(state, crew.id)));
const calls = useLiveCalls((call) => call.crewId === crew.id, crew.id);
const [open, setOpen] = useState<Open>(null);
const actions = useCrewActions(crew);
const [pane, setPane] = useState<Pane>("bots");
const close = () => setOpen(null);
const tabs: Tab<Pane>[] = [
{ id: "bots", label: words.tabs.bots },
{ id: "timeline", label: words.tabs.timeline },
{ id: "tasks", label: words.tabs.tasks },
{ id: "routines", label: t.routines.tab },
];

const openFolder = () => attempt(words.failed.openFolder, () => host.openPath(crew.workFolder));
const pickFolder = () =>
attempt(words.failed.changeFolder, async () => {
const folder = await host.pickFolder(t.crews.dialog.pickTitle, crew.workFolder);
if (folder && folder !== crew.workFolder) {
setOpen({ move: folder });
}
});

const setPaused = (paused: boolean) =>
attempt(paused ? words.failed.pause : words.failed.resume, async () =>
putCrew(await api.call("crews.setPaused", { crewId: crew.id, paused })),
);

return (
<section aria-label={crew.name} className="flex min-h-0 flex-1 flex-col">
<header className="flex items-center gap-3 border-line border-b px-5 py-3.5">
Expand All @@ -79,7 +45,7 @@ export function CrewView({ crew }: { crew: Crew }) {
{/* Only the icon: the path is in its tooltip, and a click opens it. */}
<button
type="button"
onClick={openFolder}
onClick={actions.openFolder}
title={`${words.folder(crew.workFolder)}
${words.openFolderHint}`}
aria-label={`${words.openFolder}: ${crew.workFolder}`}
Expand All @@ -90,34 +56,13 @@ ${words.openFolderHint}`}
</p>
</div>
<CallPills calls={calls} className="max-w-[45%] justify-end" />
<Button variant="primary" icon={Plus} onClick={() => setOpen("bot")}>
{t.crews.newBot}
<Button variant="primary" icon={Plus} onClick={actions.newBot.onSelect}>
{actions.newBot.label}
</Button>
{crew.paused ? (
<Button icon={Play} onClick={() => setPaused(false)}>
{words.resume}
</Button>
) : (
<Button icon={Pause} onClick={() => setPaused(true)}>
{words.pause}
</Button>
)}
<Menu
label={words.moreActions}
icon={Ellipsis}
items={[
{ label: t.crews.rename, icon: Pencil, onSelect: () => setOpen("rename") },
{ label: words.openFolder, icon: FolderOpen, onSelect: openFolder },
{ label: words.changeFolder, icon: FolderInput, onSelect: pickFolder },
{
label: words.archive,
icon: Archive,
danger: true,
onSelect: () => setOpen("archive"),
},
{ label: words.delete, icon: Trash2, danger: true, onSelect: () => setOpen("delete") },
]}
/>
<Button icon={actions.pause.icon} onClick={actions.pause.onSelect}>
{actions.pause.label}
</Button>
<Menu label={words.moreActions} icon={Ellipsis} items={actions.items} />
</header>

<Tabs<Pane> label={words.tabs.label} tabs={tabs} value={pane} onChange={setPane} />
Expand All @@ -127,7 +72,7 @@ ${words.openFolderHint}`}
aria-labelledby={tabId("bots")}
className="min-h-0 flex-1 overflow-y-auto p-5"
>
<CrewBots crew={crew} bots={bots} onNewBot={() => setOpen("bot")} />
<CrewBots crew={crew} bots={bots} onNewBot={actions.newBot.onSelect} />
</div>
)}
{pane === "timeline" && (
Expand Down Expand Up @@ -163,39 +108,7 @@ ${words.openFolderHint}`}
</div>
)}

{open === "bot" && <BotDialog crewId={crew.id} onClose={close} />}
{open === "rename" && <CrewDialog crew={crew} onClose={close} />}
{typeof open === "object" && open !== null && (
<Confirm
title={words.moveTitle(crew.name)}
confirmLabel={words.move}
onClose={close}
onConfirm={() =>
attempt(words.failed.changeFolder, async () =>
putCrew(
await api.call("crews.setWorkFolder", { crewId: crew.id, workFolder: open.move }),
),
)
}
>
{words.moveBody(open.move)}
</Confirm>
)}
{open === "archive" && (
<Confirm
title={words.archiveTitle(crew.name)}
confirmLabel={words.archive}
onClose={close}
onConfirm={() =>
attempt(words.failed.archive, async () =>
putCrew(await api.call("crews.archive", { crewId: crew.id })),
)
}
>
{words.archiveBody(bots.length)}
</Confirm>
)}
{open === "delete" && <DeleteCrew crew={crew} bots={bots.length} onClose={close} />}
{actions.dialogs}
</section>
);
}
21 changes: 20 additions & 1 deletion app/src/features/crews/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ 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 { row } from "./SidebarPages";

/** Crews as sections and their bots as conversations (spec 15.1). */
Expand Down Expand Up @@ -81,6 +82,10 @@ function CrewEntry({ crew }: { crew: Crew }) {
const selectCrew = useApp((state) => state.selectCrew);
const openBotId = useApp((state) => state.selectedBotId);
const collapsed = useCollapsed(crew.id);
// A right-click opens the crew's menu, the same as on its page.
const actions = useCrewActions(crew);
const [menuAt, setMenuAt] = useState<Point | null>(null);
const closeMenu = useCallback(() => setMenuAt(null), []);
// Folded, the open conversation still shows, and a dot tells a bot waits.
const shown = collapsed ? bots.filter((bot) => bot.id === openBotId) : bots;
const waiting =
Expand Down Expand Up @@ -108,7 +113,11 @@ function CrewEntry({ crew }: { crew: Crew }) {
type="button"
aria-current={selected ? "page" : undefined}
onClick={() => selectCrew(crew.id)}
className={`${row} h-8 gap-2 pr-2.5 pl-8 font-semibold text-sm ${selected ? "bg-sunken" : ""}`}
onContextMenu={(event) => {
event.preventDefault();
setMenuAt(menuPoint(event));
}}
className={`${row} h-8 gap-2 pr-2.5 pl-8 font-semibold text-sm ${selected || menuAt ? "bg-sunken" : ""}`}
>
<span className="min-w-0 flex-1 truncate">{crew.name}</span>
{waiting && (
Expand All @@ -128,6 +137,16 @@ function CrewEntry({ crew }: { crew: Crew }) {
)}
</button>
</div>
{menuAt && (
<ContextMenu
label={words.menuOf(crew.name)}
items={[actions.newBot, actions.pause, ...actions.items]}
at={menuAt}
onClose={closeMenu}
/>
)}
{/* Outside the list: a dialog is not part of the navigation. */}
{actions.dialogs && createPortal(actions.dialogs, document.body)}
<ul>
{shown.map((bot) => (
<Conversation key={bot.id} bot={bot} crew={crew} />
Expand Down
123 changes: 123 additions & 0 deletions app/src/features/crews/crewActions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import {
Archive,
FolderInput,
FolderOpen,
type LucideIcon,
Pause,
Pencil,
Play,
Plus,
Trash2,
} from "lucide-react";
import { type ReactNode, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { useT } from "../../i18n";
import type { Crew } from "../../lib/protocol.gen";
import { botsOf } from "../../store/app";
import { useApi, useApp, useHost } from "../../store/context";
import { Confirm } from "../../ui/Confirm";
import type { MenuItem } from "../../ui/Menu";
import { attempt } from "../../ui/toast";
import { BotDialog } from "../bots/BotDialog";
import { CrewDialog } from "./CrewDialog";
import { DeleteCrew } from "./DeleteCrew";

type Open = "bot" | "rename" | "archive" | "delete" | { move: string } | null;

export interface CrewActions {
/** A new bot in the crew. */
newBot: MenuItem;
/** Pause or resume, as the crew is now. */
pause: MenuItem & { icon: LucideIcon };
/** Opens the work folder in the system's file manager. */
openFolder(): void;
/** Rename, the folder, archive and delete. */
items: MenuItem[];
/** The dialog an action opened, `null` while none is. */
dialogs: ReactNode | null;
}

/**
* What the owner can do with a crew from a menu, and the dialogs those
* actions open. The crew page and the right-click menu of the sidebar both
* come from here, so they stay the same.
*/
export function useCrewActions(crew: Crew): CrewActions {
const t = useT();
const words = t.crews.view;
const api = useApi();
const host = useHost();
const putCrew = useApp((state) => state.putCrew);
const bots = useApp(useShallow((state) => botsOf(state, crew.id)));
const [open, setOpen] = useState<Open>(null);
const close = () => setOpen(null);

const openFolder = () => attempt(words.failed.openFolder, () => host.openPath(crew.workFolder));
const pickFolder = () =>
attempt(words.failed.changeFolder, async () => {
const folder = await host.pickFolder(t.crews.dialog.pickTitle, crew.workFolder);
if (folder && folder !== crew.workFolder) {
setOpen({ move: folder });
}
});
const setPaused = (paused: boolean) =>
attempt(paused ? words.failed.pause : words.failed.resume, async () =>
putCrew(await api.call("crews.setPaused", { crewId: crew.id, paused })),
);

let dialogs: ReactNode | null = null;
if (open === "bot") {
dialogs = <BotDialog crewId={crew.id} onClose={close} />;
} else if (open === "rename") {
dialogs = <CrewDialog crew={crew} onClose={close} />;
} else if (open === "archive") {
dialogs = (
<Confirm
title={words.archiveTitle(crew.name)}
confirmLabel={words.archive}
onClose={close}
onConfirm={() =>
attempt(words.failed.archive, async () =>
putCrew(await api.call("crews.archive", { crewId: crew.id })),
)
}
>
{words.archiveBody(bots.length)}
</Confirm>
);
} else if (open === "delete") {
dialogs = <DeleteCrew crew={crew} bots={bots.length} onClose={close} />;
} else if (open !== null) {
const { move } = open;
dialogs = (
<Confirm
title={words.moveTitle(crew.name)}
confirmLabel={words.move}
onClose={close}
onConfirm={() =>
attempt(words.failed.changeFolder, async () =>
putCrew(await api.call("crews.setWorkFolder", { crewId: crew.id, workFolder: move })),
)
}
>
{words.moveBody(move)}
</Confirm>
);
}

return {
newBot: { label: t.crews.newBot, icon: Plus, onSelect: () => setOpen("bot") },
openFolder,
pause: crew.paused
? { label: words.resume, icon: Play, onSelect: () => setPaused(false) }
: { label: words.pause, icon: Pause, onSelect: () => setPaused(true) },
items: [
{ label: t.crews.rename, icon: Pencil, onSelect: () => setOpen("rename") },
{ label: words.openFolder, icon: FolderOpen, onSelect: openFolder },
{ label: words.changeFolder, icon: FolderInput, onSelect: pickFolder },
{ label: words.archive, icon: Archive, danger: true, onSelect: () => setOpen("archive") },
{ label: words.delete, icon: Trash2, danger: true, onSelect: () => setOpen("delete") },
],
dialogs,
};
}
Loading
Loading