Skip to content

Commit ff3af06

Browse files
committed
Merge remote-tracking branch 'origin/main' into feat/crew-menu
# Conflicts: # docs/spec.md
2 parents 43c3198 + 7ec583b commit ff3af06

21 files changed

Lines changed: 573 additions & 142 deletions
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// What sits around a bot's chat besides the chat (spec 15.1): the notices
2+
// over it, about a paused crew or a bot that needs the owner, and the panel
3+
// with the bot's details.
4+
5+
import { X } from "lucide-react";
6+
import { useT } from "../../i18n";
7+
import type { Bot, Crew } from "../../lib/protocol.gen";
8+
import { Button } from "../../ui/Button";
9+
import { Callout } from "../../ui/Callout";
10+
import { SidePanel } from "../../ui/SidePanel";
11+
import { SignInButton } from "../onboarding/SignIn";
12+
import { AllowRules } from "./AllowRules";
13+
import type { stateView } from "./BotStateBadge";
14+
15+
export function Notices({
16+
bot,
17+
crew,
18+
view,
19+
}: {
20+
bot: Bot;
21+
crew: Crew;
22+
view: ReturnType<typeof stateView>;
23+
}) {
24+
const t = useT();
25+
const notices = [];
26+
if (crew.paused && !bot.paused) {
27+
notices.push(
28+
<Callout key="crew" title={t.bots.notices.crewPaused(crew.name)}>
29+
{t.bots.notices.crewPausedBody}
30+
</Callout>,
31+
);
32+
}
33+
// An approval shows in the chat itself; the rest needs a word up here.
34+
if ((view.tone === "warn" || view.tone === "danger") && bot.state !== "needs_approval") {
35+
notices.push(
36+
<Callout key="state" tone={view.tone} title={view.label}>
37+
{view.hint}
38+
{bot.state === "auth_error" && <SignInButton />}
39+
</Callout>,
40+
);
41+
}
42+
if (notices.length === 0) {
43+
return null;
44+
}
45+
return <div className="flex flex-col gap-2 border-line border-b px-5 py-3">{notices}</div>;
46+
}
47+
48+
export function Details({ bot, onClose }: { bot: Bot; onClose(): void }) {
49+
const words = useT().bots.details;
50+
return (
51+
<SidePanel label={words.title(bot.name)} name="details" defaultWidth={320}>
52+
<header className="flex h-11 shrink-0 items-center justify-between border-line border-b pr-1.5 pl-4">
53+
<h2 className="font-semibold text-sm">{words.title(bot.name)}</h2>
54+
<Button variant="ghost" size="sm" icon={X} label={words.close} onClick={onClose} />
55+
</header>
56+
<dl className="flex min-h-0 flex-col gap-4 overflow-y-auto p-4 text-sm">
57+
<div>
58+
<dt className="text-muted text-xs">{words.role}</dt>
59+
<dd className="mt-0.5 text-ink-soft">{bot.role || words.noRole}</dd>
60+
</div>
61+
<div>
62+
<dt className="text-muted text-xs">{words.folder}</dt>
63+
<dd className="mt-0.5 break-all font-mono text-xs" data-selectable>
64+
{bot.workspace}
65+
</dd>
66+
</div>
67+
<div>
68+
<dt className="text-muted text-xs">{words.process}</dt>
69+
<dd className="mt-0.5 font-mono text-xs">
70+
{bot.generation === null ? words.notStarted : words.generation(bot.generation)}
71+
</dd>
72+
</div>
73+
<div>
74+
<dt className="text-muted text-xs">{words.instructions}</dt>
75+
<dd className="mt-0.5 whitespace-pre-wrap text-ink-soft" data-selectable>
76+
{bot.instructions || words.noInstructions}
77+
</dd>
78+
</div>
79+
<AllowRules bot={bot} />
80+
</dl>
81+
</SidePanel>
82+
);
83+
}

‎app/src/features/bots/BotView.tsx‎

Lines changed: 63 additions & 133 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,11 @@
1-
import { X } from "lucide-react";
2-
import { useEffect, useMemo, useState } from "react";
1+
import { useEffect, useState } from "react";
32
import { useShallow } from "zustand/react/shallow";
43
import { useT } from "../../i18n";
54
import type { Bot, BotFile, Crew } from "../../lib/protocol.gen";
65
import { useWindowVisible } from "../../shell/visibility";
7-
import { type BotPanel, routinesOf } from "../../store/app";
6+
import { routinesOf } from "../../store/app";
87
import { useApp } from "../../store/context";
9-
import { Button } from "../../ui/Button";
10-
import { Callout } from "../../ui/Callout";
118
import { PanelClosing, PanelRestored } from "../../ui/panelMotion";
12-
import { SidePanel } from "../../ui/SidePanel";
139
import { type Tab, Tabs, tabId } from "../../ui/Tabs";
1410
import { useStable } from "../../ui/useStable";
1511
import { BrowserPanel } from "../browser/BrowserPanel";
@@ -18,50 +14,30 @@ import { ChatView } from "../chat/ChatView";
1814
import { FilesPanel } from "../files/FilesPanel";
1915
import { ShowFile } from "../files/showFile";
2016
import { useBotFiles } from "../files/useBotFiles";
21-
import { SignInButton } from "../onboarding/SignIn";
2217
import { BotRoutines } from "../routines/RoutineList";
2318
import { ScreensPanel } from "../screens/ScreensPanel";
2419
import { ShowScreen } from "../screens/showScreen";
2520
import { useScreens } from "../screens/useScreens";
26-
import { AllowRules } from "./AllowRules";
21+
import { Dock } from "../terminal/ComputerDock";
22+
import { ShowTerminal } from "../terminal/showTerminal";
23+
import { TerminalPanel } from "../terminal/TerminalPanel";
2724
import { BotHeader } from "./BotHeader";
25+
import { Details, Notices } from "./BotPanels";
2826
import { stateView } from "./BotStateBadge";
2927
import { useFollowBot } from "./useFollowBot";
28+
import { usePanel } from "./usePanel";
3029

3130
type Pane = "chat" | "routines";
32-
/** What the panel beside the chat shows. */
33-
type Side = BotPanel | null;
3431

3532
/**
3633
* A bot's conversation and its routines, with its details in a side panel
3734
* (spec 15.1, 20.9).
3835
*/
3936
export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) {
4037
const t = useT();
41-
// Kept per bot in the store, not here: the panel the owner left open is
42-
// back when they come back to the bot, and one they closed stays closed
43-
// (spec 15.1).
44-
const side = useApp((state) => state.panels[bot.id] ?? null);
45-
const keepPanel = useApp((state) => state.setPanel);
46-
// The panel that came back is there at once; one opened after it slides.
47-
const [restored, setRestored] = useState(side !== null);
48-
const setSide = (panel: Side) => {
49-
setRestored(false);
50-
keepPanel(bot.id, panel);
51-
};
52-
// The panel on screen: the one open, or the last one while it slides
53-
// closed (spec 15.1).
54-
const [leaving, setLeaving] = useState<Side>(null);
55-
useEffect(() => {
56-
if (side !== null) {
57-
setLeaving(side);
58-
}
59-
}, [side]);
60-
const beside = side ?? leaving;
61-
const closing = useMemo(
62-
() => (side === null && leaving !== null ? { closed: () => setLeaving(null) } : null),
63-
[side, leaving],
64-
);
38+
// Kept per bot in the store: the panel the owner left open is back when
39+
// they come back to the bot, and one they closed stays closed (spec 15.1).
40+
const { side, setSide, beside, restored, closing } = usePanel(bot.id);
6541
const [pane, setPane] = useState<Pane>("chat");
6642
const files = useBotFiles(bot, side === "files");
6743
// What the owner has seen: files newer than this are new to them.
@@ -152,6 +128,25 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) {
152128
open: (panel) => (panel === "browser" ? showBrowser() : showScreen(null)),
153129
close: (panel) => side === panel && setSide(null),
154130
});
131+
const showTerminal = useStable(() => {
132+
if (filesOpen) {
133+
seen();
134+
}
135+
setSide("terminal");
136+
});
137+
// The dock of the bot's computer: browser, terminal and files.
138+
const dock = {
139+
open: side,
140+
pick: (place: "browser" | "terminal" | "files") => {
141+
if (place === "browser") {
142+
showBrowser();
143+
} else if (place === "terminal") {
144+
showTerminal();
145+
} else if (!filesOpen) {
146+
toggleFiles();
147+
}
148+
},
149+
};
155150
const view = stateView(bot, crew.paused, t);
156151
const tabs: Tab<Pane>[] = [
157152
{ id: "chat", label: t.routines.chatTab },
@@ -188,113 +183,48 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) {
188183
<ShowFile.Provider value={showFile}>
189184
<ShowBrowser.Provider value={showBrowser}>
190185
<ShowScreen.Provider value={showScreen}>
191-
<ChatView bot={bot} stopped={bot.paused || crew.paused} />
186+
<ShowTerminal.Provider value={showTerminal}>
187+
<ChatView bot={bot} stopped={bot.paused || crew.paused} />
188+
</ShowTerminal.Provider>
192189
</ShowScreen.Provider>
193190
</ShowBrowser.Provider>
194191
</ShowFile.Provider>
195192
) : (
196193
<BotRoutines bot={bot} />
197194
)}
198195
</div>
199-
<PanelRestored.Provider value={restored}>
200-
<PanelClosing.Provider value={closing}>
201-
{beside === "details" && <Details bot={bot} onClose={() => setSide(null)} />}
202-
{beside === "browser" && (
203-
<BrowserPanel bot={bot} take={takeBrowser} onClose={() => setSide(null)} />
204-
)}
205-
{beside === "screens" && (
206-
<ScreensPanel
207-
bot={bot}
208-
data={screens}
209-
path={screenPath}
210-
onPath={setScreenPath}
211-
onClose={() => setSide(null)}
212-
/>
213-
)}
214-
{beside === "files" && (
215-
<FilesPanel
216-
bot={bot}
217-
data={files}
218-
since={since}
219-
path={shown}
220-
described={described}
221-
onPath={setShown}
222-
onClose={toggleFiles}
223-
/>
224-
)}
225-
</PanelClosing.Provider>
226-
</PanelRestored.Provider>
196+
<Dock.Provider value={dock}>
197+
<PanelRestored.Provider value={restored}>
198+
<PanelClosing.Provider value={closing}>
199+
{beside === "details" && <Details bot={bot} onClose={() => setSide(null)} />}
200+
{beside === "browser" && (
201+
<BrowserPanel bot={bot} take={takeBrowser} onClose={() => setSide(null)} />
202+
)}
203+
{beside === "screens" && (
204+
<ScreensPanel
205+
bot={bot}
206+
data={screens}
207+
path={screenPath}
208+
onPath={setScreenPath}
209+
onClose={() => setSide(null)}
210+
/>
211+
)}
212+
{beside === "files" && (
213+
<FilesPanel
214+
bot={bot}
215+
data={files}
216+
since={since}
217+
path={shown}
218+
described={described}
219+
onPath={setShown}
220+
onClose={toggleFiles}
221+
/>
222+
)}
223+
{beside === "terminal" && <TerminalPanel bot={bot} onClose={() => setSide(null)} />}
224+
</PanelClosing.Provider>
225+
</PanelRestored.Provider>
226+
</Dock.Provider>
227227
</div>
228228
</section>
229229
);
230230
}
231-
232-
function Notices({
233-
bot,
234-
crew,
235-
view,
236-
}: {
237-
bot: Bot;
238-
crew: Crew;
239-
view: ReturnType<typeof stateView>;
240-
}) {
241-
const t = useT();
242-
const notices = [];
243-
if (crew.paused && !bot.paused) {
244-
notices.push(
245-
<Callout key="crew" title={t.bots.notices.crewPaused(crew.name)}>
246-
{t.bots.notices.crewPausedBody}
247-
</Callout>,
248-
);
249-
}
250-
// An approval shows in the chat itself; the rest needs a word up here.
251-
if ((view.tone === "warn" || view.tone === "danger") && bot.state !== "needs_approval") {
252-
notices.push(
253-
<Callout key="state" tone={view.tone} title={view.label}>
254-
{view.hint}
255-
{bot.state === "auth_error" && <SignInButton />}
256-
</Callout>,
257-
);
258-
}
259-
if (notices.length === 0) {
260-
return null;
261-
}
262-
return <div className="flex flex-col gap-2 border-line border-b px-5 py-3">{notices}</div>;
263-
}
264-
265-
function Details({ bot, onClose }: { bot: Bot; onClose(): void }) {
266-
const words = useT().bots.details;
267-
return (
268-
<SidePanel label={words.title(bot.name)} name="details" defaultWidth={320}>
269-
<header className="flex h-11 shrink-0 items-center justify-between border-line border-b pr-1.5 pl-4">
270-
<h2 className="font-semibold text-sm">{words.title(bot.name)}</h2>
271-
<Button variant="ghost" size="sm" icon={X} label={words.close} onClick={onClose} />
272-
</header>
273-
<dl className="flex min-h-0 flex-col gap-4 overflow-y-auto p-4 text-sm">
274-
<div>
275-
<dt className="text-muted text-xs">{words.role}</dt>
276-
<dd className="mt-0.5 text-ink-soft">{bot.role || words.noRole}</dd>
277-
</div>
278-
<div>
279-
<dt className="text-muted text-xs">{words.folder}</dt>
280-
<dd className="mt-0.5 break-all font-mono text-xs" data-selectable>
281-
{bot.workspace}
282-
</dd>
283-
</div>
284-
<div>
285-
<dt className="text-muted text-xs">{words.process}</dt>
286-
<dd className="mt-0.5 font-mono text-xs">
287-
{bot.generation === null ? words.notStarted : words.generation(bot.generation)}
288-
</dd>
289-
</div>
290-
<div>
291-
<dt className="text-muted text-xs">{words.instructions}</dt>
292-
<dd className="mt-0.5 whitespace-pre-wrap text-ink-soft" data-selectable>
293-
{bot.instructions || words.noInstructions}
294-
</dd>
295-
</div>
296-
<AllowRules bot={bot} />
297-
</dl>
298-
</SidePanel>
299-
);
300-
}

‎app/src/features/bots/usePanel.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// The panel beside a bot's chat (spec 15.1): which one is open, kept per
2+
// bot in the store so it comes back with the bot, and the one sliding
3+
// closed while it leaves.
4+
5+
import { useEffect, useMemo, useState } from "react";
6+
import type { BotPanel } from "../../store/app";
7+
import { useApp } from "../../store/context";
8+
9+
export type Side = BotPanel | null;
10+
11+
export function usePanel(botId: string) {
12+
const side = useApp((state) => state.panels[botId] ?? null);
13+
const keepPanel = useApp((state) => state.setPanel);
14+
// The panel that came back is there at once; one opened after it slides.
15+
const [restored, setRestored] = useState(side !== null);
16+
const setSide = (panel: Side) => {
17+
setRestored(false);
18+
keepPanel(botId, panel);
19+
};
20+
// The panel on screen: the one open, or the last one while it slides
21+
// closed.
22+
const [leaving, setLeaving] = useState<Side>(null);
23+
useEffect(() => {
24+
if (side !== null) {
25+
setLeaving(side);
26+
}
27+
}, [side]);
28+
const closing = useMemo(
29+
() => (side === null && leaving !== null ? { closed: () => setLeaving(null) } : null),
30+
[side, leaving],
31+
);
32+
return { side, setSide, beside: side ?? leaving, restored, closing };
33+
}

0 commit comments

Comments
 (0)