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
11 changes: 6 additions & 5 deletions app/src/features/crews/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { Pause, Plus } from "lucide-react";
import { useCallback, useState } from "react";
import { memo, useCallback, 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 type { Activity, Bot, Crew } from "../../lib/protocol.gen";
import { botsOf, crewList } from "../../store/app";
import { activityOf, botsOf, crewList } from "../../store/app";
import { useApp } from "../../store/context";
import { Button } from "../../ui/Button";
import { ContextMenu, menuPoint, type Point } from "../../ui/ContextMenu";
Expand Down Expand Up @@ -84,15 +84,16 @@ function CrewEntry({ crew }: { crew: Crew }) {
);
}

function Conversation({ bot, crew }: { bot: Bot; crew: Crew }) {
/** 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 = bot.lastActivity;
const activity = useApp((state) => activityOf(state, bot.id));
return (
<li className={bot.createdAt > APP_OPENED ? "animate-rise" : undefined}>
<button
Expand Down Expand Up @@ -142,7 +143,7 @@ function Conversation({ bot, crew }: { bot: Bot; crew: Crew }) {
{actions.dialogs && createPortal(actions.dialogs, document.body)}
</li>
);
}
});

/** The conversation-list line, worded here from what the daemon sends. */
/** The line in the owner's language: the daemon sends no wording. */
Expand Down
14 changes: 13 additions & 1 deletion app/src/features/messages/FailedDeliveries.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,21 @@ import { BotAvatar } from "../bots/BotAvatar";
* were given up on purpose (spec 9.1).
*/
export function actionableDead(state: AppState): Delivery[] {
return deadDeliveries(state).filter((delivery) => state.bots[delivery.botId]);
// Asked on every change to the store; the answer changes only with these.
const { deliveries, bots } = state;
if (lastDead?.deliveries !== deliveries || lastDead.bots !== bots) {
const dead = deadDeliveries(state).filter((delivery) => bots[delivery.botId]);
lastDead = { deliveries, bots, dead };
}
return lastDead.dead;
}

let lastDead: {
deliveries: AppState["deliveries"];
bots: AppState["bots"];
dead: Delivery[];
} | null = null;

/** Title-bar button for messages that were not delivered, with a list to retry them. */
export function FailedDeliveries() {
const t = useT();
Expand Down
19 changes: 11 additions & 8 deletions app/src/features/messages/MessageRow.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { ArrowRight, ListTodo, Reply, User } from "lucide-react";
import { memo } from "react";
import { useT } from "../../i18n";
import { fromNow, when } from "../../lib/format";
import type { Bot, BotId, Message, Task } from "../../lib/protocol.gen";
import type { Bot, Message, Task } from "../../lib/protocol.gen";
import { useApp } from "../../store/context";
import { BOTLOFT_COLOR, BotAvatar } from "../bots/BotAvatar";
import { DeliveryStatus } from "./DeliveryStatus";
Expand All @@ -19,7 +20,7 @@ function BotName({ bot }: { bot: Bot | undefined }) {
);
}

function Sender({ message, bots }: { message: Message; bots: Record<BotId, Bot> }) {
function Sender({ message, from }: { message: Message; from: Bot | undefined }) {
const t = useT();
switch (message.fromKind) {
case "owner":
Expand All @@ -37,7 +38,7 @@ function Sender({ message, bots }: { message: Message; bots: Record<BotId, Bot>
</span>
);
case "bot":
return <BotName bot={message.fromBotId ? bots[message.fromBotId] : undefined} />;
return <BotName bot={from} />;
}
}

Expand All @@ -64,17 +65,19 @@ function TaskTag({ message, task }: { message: Message; task: Task | undefined }
);
}

export function MessageRow({ message }: { message: Message }) {
/** Memoized, and reads only its two bots: a crew's timeline can be long. */
export const MessageRow = memo(function MessageRow({ message }: { message: Message }) {
const t = useT();
const bots = useApp((state) => state.bots);
const from = useApp((state) => (message.fromBotId ? state.bots[message.fromBotId] : undefined));
const to = useApp((state) => state.bots[message.toBotId]);
const task = useApp((state) => (message.taskId ? state.tasks[message.taskId] : undefined));
const delivery = useApp((state) => state.deliveries[message.id]);
return (
<li className="border-line border-b px-5 py-3 last:border-b-0">
<div className="flex items-center gap-2 text-sm">
<Sender message={message} bots={bots} />
<Sender message={message} from={from} />
<ArrowRight aria-label={t.messages.row.to} size={12} className="text-muted" />
<BotName bot={bots[message.toBotId]} />
<BotName bot={to} />
<TaskTag message={message} task={task} />
<time
className="ml-auto shrink-0 text-muted text-xs"
Expand All @@ -96,4 +99,4 @@ export function MessageRow({ message }: { message: Message }) {
)}
</li>
);
}
});
5 changes: 3 additions & 2 deletions app/src/shell/alerts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { useEffect } from "react";
import { toolTitle } from "../features/chat/toolNames";
import { t } from "../i18n";
import type { Bot, BotId, BotState } from "../lib/protocol.gen";
import type { AppState } from "../store/app";
import { type AppState, activityOf } from "../store/app";
import { useAppStore, useHost } from "../store/context";
import { chime } from "./chime";
import { prefs } from "./prefs";
Expand Down Expand Up @@ -35,7 +35,8 @@ function notice(state: AppState, bot: Bot, kind: Kind): { title: string; body: s
const crew = state.crews[bot.crewId]?.name ?? "";
switch (kind) {
case "approval": {
const tool = bot.lastActivity?.kind === "approval" ? bot.lastActivity.tool : null;
const activity = activityOf(state, bot.id);
const tool = activity?.kind === "approval" ? activity.tool : null;
return {
title: words.alerts.approval(bot.name),
body: tool ? toolTitle(tool, words.tools) : words.alerts.crew(crew),
Expand Down
17 changes: 16 additions & 1 deletion app/src/shell/attention.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,27 @@ import { useApp, useHost } from "../store/context";
* routine runs that failed since the owner last opened their bot.
*/
export function attentionCount(state: AppState): number {
// Asked on every change to the store; the answer changes only with these.
const inputs = [
state.bots,
state.deliveries,
state.routines,
state.seenAt,
state.selectedBotId,
] as const;
if (last?.inputs.every((input, index) => input === inputs[index])) {
return last.count;
}
const bots = Object.values(state.bots).filter(
(bot) => bot.state === "needs_approval" || bot.state === "auth_error",
).length;
return bots + actionableDead(state).length + unseenFailures(state).length;
const count = bots + actionableDead(state).length + unseenFailures(state).length;
last = { inputs, count };
return count;
}

let last: { inputs: readonly unknown[]; count: number } | null = null;

export function useAttentionMark(): void {
const host = useHost();
const needed = useApp((state) => attentionCount(state) > 0);
Expand Down
16 changes: 15 additions & 1 deletion app/src/store/app.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
import { FakeBotloft } from "../lib/fake";
import { botsOf, createAppStore, crewList } from "./app";
import { activityOf, botsOf, createAppStore, crewList } from "./app";
import { syncStore } from "./sync";

async function synced(fake: FakeBotloft) {
Expand Down Expand Up @@ -28,6 +28,20 @@ describe("app store", () => {
expect(state.selectedCrewId).toBe(ops.id);
});

test("a chat item moves the bot's list line, not the bot", async () => {
const fake = new FakeBotloft();
const crew = fake.addCrew("Ops");
const scout = fake.addBot(crew.id, "Scout");
const { store } = await synced(fake);
const before = store.getState().bots[scout.id];

fake.chat.reply(scout.id, "Found three sources.");

const state = store.getState();
expect(state.bots[scout.id]).toBe(before);
expect(activityOf(state, scout.id)).toMatchObject({ text: "Found three sources." });
});

test("follows state changes and archiving", async () => {
const fake = new FakeBotloft();
const crew = fake.addCrew("Ops");
Expand Down
24 changes: 20 additions & 4 deletions app/src/store/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import { createStore, type StoreApi } from "zustand/vanilla";
import type { BotloftApi, ConnectionState, ServerEvent } from "../lib/api";
import type {
Activity,
Bot,
BotId,
BrowserState,
Expand Down Expand Up @@ -34,6 +35,12 @@ export interface AppState {
settings: Settings | null;
crews: Record<CrewId, Crew>;
bots: Record<BotId, Bot>;
/**
* Each bot's conversation-list line (spec 8.3). Kept apart from the bot:
* it changes with every chat item, and a new bot object re-renders
* everything that shows the bot.
*/
activity: Record<BotId, Activity | null>;
/**
* Deliveries by message (each message has one): the most recently
* updated ones and every dead one, then every change.
Expand Down Expand Up @@ -77,6 +84,11 @@ export function crewList(state: AppState): Crew[] {
return Object.values(state.crews).sort(byCreation);
}

/** The bot's conversation-list line; see [`AppState.activity`]. */
export function activityOf(state: AppState, botId: BotId): Activity | null {
return state.activity[botId] ?? null;
}

export function botsOf(state: AppState, crewId: CrewId): Bot[] {
return Object.values(state.bots)
.filter((bot) => bot.crewId === crewId)
Expand Down Expand Up @@ -105,6 +117,7 @@ export function createAppStore(api: BotloftApi): AppStore {
settings: null,
crews: {},
bots: {},
activity: {},
deliveries: {},
tasks: {},
routines: {},
Expand Down Expand Up @@ -159,7 +172,10 @@ function withCrew(state: AppState, crew: Crew): Partial<AppState> {

function withBot(state: AppState, bot: Bot): Partial<AppState> {
if (bot.archivedAt === null) {
return { bots: { ...state.bots, [bot.id]: bot } };
return {
bots: { ...state.bots, [bot.id]: bot },
activity: { ...state.activity, [bot.id]: bot.lastActivity },
};
}
const { [bot.id]: _gone, ...bots } = state.bots;
return { bots, selectedBotId: state.selectedBotId === bot.id ? null : state.selectedBotId };
Expand All @@ -178,6 +194,7 @@ function withoutBots(state: AppState, botIds: BotId[]): Partial<AppState> {
const gone = new Set(botIds);
return {
bots: kept(state.bots, (bot) => !gone.has(bot.id)),
activity: Object.fromEntries(Object.entries(state.activity).filter(([id]) => !gone.has(id))),
routines: kept(state.routines, (routine) => !gone.has(routine.botId)),
browsers: kept(state.browsers, (browser) => !gone.has(browser.botId)),
deliveries: kept(state.deliveries, (delivery) => !gone.has(delivery.botId)),
Expand Down Expand Up @@ -231,11 +248,10 @@ export function applyEvent(state: AppState, event: ServerEvent): Partial<AppStat
}
case "chat.item": {
const { item, activity } = event.params;
const bot = state.bots[item.botId];
if (!bot || !activity) {
if (!state.bots[item.botId] || !activity) {
return null;
}
return { bots: { ...state.bots, [bot.id]: { ...bot, lastActivity: activity } } };
return { activity: { ...state.activity, [item.botId]: activity } };
}
case "delivery.changed":
return { deliveries: { ...state.deliveries, [event.params.messageId]: event.params } };
Expand Down
5 changes: 4 additions & 1 deletion app/src/store/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,10 @@ export function syncStore(store: AppStore, api: BotloftApi): () => void {
});
const bots = api.call("bots.list", {}).then((list) => {
if (alive) {
store.setState({ bots: Object.fromEntries(list.map((bot) => [bot.id, bot])) });
store.setState({
bots: Object.fromEntries(list.map((bot) => [bot.id, bot])),
activity: Object.fromEntries(list.map((bot) => [bot.id, bot.lastActivity])),
});
}
});
// Recent deliveries plus every dead one: the dead need the owner.
Expand Down
Loading