Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
092b927
Merge branch 'feat/bot-context-menu' into feat/delete-in-app
httpsphl Sep 30, 2026
d11497f
feat(app): delete a bot or a crew from its menu
httpsphl Sep 30, 2026
f320f6b
feat(daemon): archive.list, what the owner archived
httpsphl Sep 30, 2026
597a72f
feat(app): archived bots and crews in Settings, each with Delete
httpsphl Sep 30, 2026
97da749
feat(platform): move a folder to the Recycle Bin, never deleting it f…
httpsphl Sep 30, 2026
f7ec558
feat(daemon): send a deleted bot's or crew's folder to the Recycle Bi…
httpsphl Sep 30, 2026
617da0f
feat(app): a box in the delete confirmation to send the folder to the…
httpsphl Sep 30, 2026
61490b5
test(platform): the Recycle Bin tests in a file of their own
httpsphl Sep 30, 2026
6d01325
docs(spec): why the Recycle Bin move has a thread of its own
httpsphl Sep 30, 2026
0f20b9f
test(platform): look in the Recycle Bin for the long form of the path
httpsphl Sep 30, 2026
ad58bbc
feat(daemon): a command carries what the bot says it is for
httpsphl Sep 30, 2026
755b86c
feat(app): a command request leads with the bot's explanation
httpsphl Sep 30, 2026
e7d1263
feat(daemon): effort per bot, the context in use and compacting the c…
httpsphl Sep 30, 2026
84f3b74
feat(app): an effort slider and the conversation's space below the chat
httpsphl Sep 30, 2026
d436993
Merge branch 'main' into feat/delete-recycle
httpsphl Sep 30, 2026
07a4b05
Merge main and the delete stack into feat/bot-effort-context
httpsphl Sep 30, 2026
86c7315
Merge main, the delete stack and feat/bot-effort-context into feat/co…
httpsphl Sep 30, 2026
233adfe
Merge main, the delete stack, #63 and #61 into feat/effort-context-app
httpsphl Sep 30, 2026
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions app/src/dev/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { PROTOCOL_VERSION } from "../lib/protocol.gen";
import { seedBrowser } from "./seedBrowser";
import { seedChats } from "./seedChats";
import { seedChief } from "./seedChief";
import { seedContext } from "./seedContext";
import { seedHands } from "./seedHands";
import { seedRoutines } from "./seedRoutines";
import { seedScreens } from "./seedScreens";
Expand Down Expand Up @@ -113,6 +114,13 @@ function seed(fake: FakeBotloft): void {
analyst: analyst.id,
planner: planner.id,
});
seedContext(fake, {
scout: scout.id,
writer: writer.id,
reviewer: reviewer.id,
analyst: analyst.id,
planner: planner.id,
});
seedRoutines(fake, { scout: scout.id, analyst: analyst.id });
seedBrowser(fake, scout.id);
seedScreens(fake, research.id);
Expand Down
28 changes: 23 additions & 5 deletions app/src/dev/seedChats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,18 @@ The report is rebuilt on every visit, and Monday is when everyone opens it.
**Not changing:** the report's layout or its sources.`;

/** The kind of command an owner cannot read at a glance. */
const CHECK_LINKS = `cd "C:/Work/Research" && python - <<'EOF'
import re, urllib.request
text = open("week-39-report.md", encoding="utf-8").read()
for url in sorted(set(re.findall(r"https?://[^\\s)]+", text))):
try:
urllib.request.urlopen(url, timeout=10)
except Exception as error:
print("broken:", url, error)
EOF`;

interface Crew {
scout: BotId;
writer: BotId;
Expand Down Expand Up @@ -126,12 +138,18 @@ export function seedChats(fake: FakeBotloft, crew: Crew): void {
talk.read(check.delivery.id);
at(39);
chat.reply(crew.reviewer, "I'll run the link checker first, then compare each figure.");
const command = JSON.stringify({
command: "npm run check-links",
description: "Check the links",
// A command with what the bot says it is for, and one without (spec 10.1).
const why = "Opens every link in the report draft and lists the ones that no longer work";
const links = JSON.stringify({ command: CHECK_LINKS, description: why });
const flat = CHECK_LINKS.split(/\s+/).join(" ").slice(0, 160);
chat.tool(crew.reviewer, "Bash", { summary: flat, explanation: why, input: links });
chat.ask(crew.reviewer, "Bash", flat, links, why);
const figures = JSON.stringify({ command: "npm run compare-figures -- --section 2" });
chat.tool(crew.reviewer, "Bash", {
summary: "npm run compare-figures -- --section 2",
input: figures,
});
chat.tool(crew.reviewer, "Bash", { summary: "Check the links", input: command });
chat.ask(crew.reviewer, "Bash", "npm run check-links", command);
chat.ask(crew.reviewer, "Bash", "npm run compare-figures -- --section 2", figures);
at(12);
const late = talk.say({
from: crew.scout,
Expand Down
48 changes: 48 additions & 0 deletions app/src/dev/seedContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// The preview's effort and conversation space (spec 7.4, 8.6): what Claude
// Code would have reported for each bot, and a compaction that ends a
// moment after it is asked for.

import type { FakeBotloft } from "../lib/fake";
import type { BotId, ContextUsage } from "../lib/protocol.gen";

const COMPACTION_MS = 2500;

const holds = (usedTokens: number, windowTokens: number): ContextUsage => ({
usedTokens,
windowTokens,
autoCompactTokens: windowTokens - 33_000,
compacting: false,
updatedAt: Date.now(),
});

export function seedContext(
fake: FakeBotloft,
bots: { scout: BotId; writer: BotId; reviewer: BotId; analyst: BotId; planner: BotId },
): void {
for (const id of [bots.scout, bots.writer, bots.reviewer, bots.planner]) {
fake.bot(id).effortDefault = "medium";
}
// Haiku takes no effort level.
fake.bot(bots.analyst).effortDefault = "none";
fake.bot(bots.reviewer).effort = "high";
fake.setContext(bots.scout, holds(556_000, 1_000_000));
fake.setContext(bots.writer, holds(912_400, 1_000_000));
fake.setContext(bots.reviewer, holds(61_800, 1_000_000));
fake.setContext(bots.analyst, holds(24_300, 200_000));

fake.subscribe((event) => {
if (event.name !== "bot.context" || !event.params.context?.compacting) {
return;
}
const { botId, context } = event.params;
setTimeout(() => {
fake.chat.add(botId, {
kind: "notice",
level: "info",
code: "compacted",
text: "The conversation was compacted: earlier messages are now a summary.",
});
fake.setContext(botId, holds(24_000, context.windowTokens));
}, COMPACTION_MS);
});
}
44 changes: 44 additions & 0 deletions app/src/effort.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/* The effort slider below the chat (spec 15.1): five stops from faster to
smarter on a thin track, with a round thumb. */

.effort-slider {
appearance: none;
display: block;
width: 100%;
height: 1.25rem;
margin: 0;
background: transparent;
cursor: pointer;
}

.effort-slider::-webkit-slider-runnable-track {
height: 4px;
border-radius: 999px;
background: var(--line-strong);
}

.effort-slider::-webkit-slider-thumb {
appearance: none;
width: 16px;
height: 16px;
margin-top: -6px;
border: 2px solid var(--panel);
border-radius: 999px;
background: var(--ink);
box-shadow: 0 1px 3px var(--lift);
transition: transform 120ms ease-out;
}

.effort-slider:active::-webkit-slider-thumb {
transform: scale(1.15);
}

/* No level picked and the model's own not known yet: the thumb only waits. */
.effort-slider[data-unset]::-webkit-slider-thumb {
background: var(--muted);
}

.effort-slider:disabled {
cursor: default;
opacity: 0.45;
}
140 changes: 140 additions & 0 deletions app/src/features/account/ArchivedSettings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// Settings, "Archived": the bots and crews the owner archived, which the
// rest of the app does not show, with a way to delete each for good
// (spec 7.6).

import { Users } from "lucide-react";
import { type ReactNode, useEffect, useState } from "react";
import { useT } from "../../i18n";
import { errorText } from "../../lib/api";
import { fromNow } from "../../lib/format";
import type { Archive, Bot, Crew } from "../../lib/protocol.gen";
import { useApi, useApp } from "../../store/context";
import { Button } from "../../ui/Button";
import { Callout } from "../../ui/Callout";
import { BotAvatar } from "../bots/BotAvatar";
import { DeleteBot } from "../bots/DeleteBot";
import { DeleteCrew } from "../crews/DeleteCrew";
import { Section } from "./settingsParts";

type Asking = { bot: Bot } | { crew: Crew } | null;

export function ArchivedSettings() {
const s = useT().account.settings;
const api = useApi();
const active = useApp((state) => state.crews);
const [archive, setArchive] = useState<Archive | null>(null);
const [failed, setFailed] = useState<string | null>(null);
const [asking, setAsking] = useState<Asking>(null);

useEffect(() => {
let alive = true;
api.call("archive.list").then(
(list) => alive && setArchive(list),
(error) => alive && setFailed(errorText(error)),
);
return () => {
alive = false;
};
}, [api]);

if (failed) {
return (
<Section title={s.archived}>
<Callout tone="danger" title={s.archivedLoadFailed}>
{failed}
</Callout>
</Section>
);
}
if (!archive) {
return <Section title={s.archived}>{null}</Section>;
}

// A bot of an archived crew goes with its crew; the others are listed.
const botsOf = (crew: Crew) => archive.bots.filter((bot) => bot.crewId === crew.id);
const bots = archive.bots.filter((bot) => !archive.crews.some((crew) => crew.id === bot.crewId));
const forget = (gone: Asking) =>
setArchive({
crews: archive.crews.filter((crew) => !(gone && "crew" in gone && gone.crew.id === crew.id)),
bots: archive.bots.filter(
(bot) =>
!(gone && "bot" in gone && gone.bot.id === bot.id) &&
!(gone && "crew" in gone && gone.crew.id === bot.crewId),
),
});

return (
<Section title={s.archived}>
<p className="text-ink-soft text-sm leading-relaxed">{s.archivedIntro}</p>
{archive.crews.length + bots.length === 0 ? (
<p className="text-muted text-sm">{s.archivedEmpty}</p>
) : (
<ul aria-label={s.archived} className="flex flex-col divide-y divide-line">
{archive.crews.map((crew) => (
<Row
key={crew.id}
icon={
<span className="grid size-7 place-items-center rounded-full bg-sunken text-muted">
<Users aria-hidden size={14} />
</span>
}
name={crew.name}
line={s.archivedCrew(botsOf(crew).length, fromNow(crew.archivedAt ?? 0))}
onDelete={() => setAsking({ crew })}
/>
))}
{bots.map((bot) => (
<Row
key={bot.id}
icon={<BotAvatar color={bot.color} size={28} />}
name={bot.name}
line={s.archivedBot(active[bot.crewId]?.name ?? "", fromNow(bot.archivedAt ?? 0))}
onDelete={() => setAsking({ bot })}
/>
))}
</ul>
)}
{asking && "bot" in asking && (
<DeleteBot
bot={asking.bot}
onClose={() => setAsking(null)}
onDeleted={() => forget(asking)}
/>
)}
{asking && "crew" in asking && (
<DeleteCrew
crew={asking.crew}
bots={botsOf(asking.crew).length}
onClose={() => setAsking(null)}
onDeleted={() => forget(asking)}
/>
)}
</Section>
);
}

function Row({
icon,
name,
line,
onDelete,
}: {
icon: ReactNode;
name: string;
line: string;
onDelete(): void;
}) {
const s = useT().account.settings;
return (
<li className="flex items-center gap-3 py-2">
{icon}
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-sm">{name}</p>
<p className="truncate text-muted text-xs">{line}</p>
</div>
<Button variant="danger" size="sm" label={s.deleteArchivedOne(name)} onClick={onDelete}>
{s.deleteArchived}
</Button>
</li>
);
}
9 changes: 7 additions & 2 deletions app/src/features/account/SettingsDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
// Settings (spec 15.1), in parts listed on the left: general (what
// Botloft does in the background, the language), the chat, notifications,
// the look, and the versions for when something needs reporting.
// the look, what was archived, and the versions for when something needs
// reporting.

import {
Archive,
Bell,
Info,
type LucideIcon,
Expand All @@ -16,16 +18,18 @@ import { Dialog } from "../../ui/Dialog";
import { AboutSettings } from "./AboutSettings";
import { AlertsSettings } from "./AlertsSettings";
import { AppearanceSettings } from "./AppearanceSettings";
import { ArchivedSettings } from "./ArchivedSettings";
import { ChatSettings } from "./ChatSettings";
import { GeneralSettings } from "./GeneralSettings";

type Page = "general" | "chat" | "alerts" | "appearance" | "about";
type Page = "general" | "chat" | "alerts" | "appearance" | "archived" | "about";

const PAGES: { id: Page; icon: LucideIcon }[] = [
{ id: "general", icon: SlidersHorizontal },
{ id: "chat", icon: MessageSquare },
{ id: "alerts", icon: Bell },
{ id: "appearance", icon: Palette },
{ id: "archived", icon: Archive },
{ id: "about", icon: Info },
];

Expand Down Expand Up @@ -74,6 +78,7 @@ export function SettingsDialog({ onClose }: { onClose(): void }) {
{page === "chat" && <ChatSettings />}
{page === "alerts" && <AlertsSettings />}
{page === "appearance" && <AppearanceSettings />}
{page === "archived" && <ArchivedSettings />}
{page === "about" && <AboutSettings />}
</div>
</div>
Expand Down
Loading
Loading