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
2 changes: 2 additions & 0 deletions app/src/dev/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { seedChief } from "./seedChief";
import { seedContext } from "./seedContext";
import { seedDesktop } from "./seedDesktop";
import { seedHands } from "./seedHands";
import { seedKeys } from "./seedKeys";
import { activeTab, drawPage } from "./seedPage";
import { seedPlan } from "./seedPlan";
import { seedQuestions } from "./seedQuestions";
Expand Down Expand Up @@ -128,6 +129,7 @@ function seed(fake: FakeBotloft): void {
});
seedRoutines(fake, { scout: scout.id, analyst: analyst.id });
seedQuestions(fake, { scout: scout.id, analyst: analyst.id });
seedKeys(fake, analyst.id, scout.id);
seedPlan(fake, writer.id);
seedBrowser(fake, scout.id);
// Analyst hit the limit in the middle of a search: its browser rests.
Expand Down
49 changes: 49 additions & 0 deletions app/src/dev/seedKeys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Dev only: an agent's keys (spec 32), one kept and one that came from a
// backup without its value, the agent asking for another, and keys pasted
// in the conversations.

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

export function seedKeys(fake: FakeBotloft, analyst: BotId, scout: BotId): void {
const now = Date.now();
fake.now = now - 3 * 24 * 60 * 60 * 1000;
void fake.call("keys.set", {
botId: analyst,
name: "SHEETS_API_KEY",
value: "dev-sheets-0000000000",
description: "Google Sheets, read only",
});
void fake.call("keys.set", { botId: analyst, name: "STRIPE_KEY", value: "dev-stripe-000000" });
const restored = fake.keys.saved.get(analyst)?.get("STRIPE_KEY");
if (restored) {
restored.missing = true;
}
fake.now = now - 5 * 60 * 1000;
fake.chat.tool(analyst, "mcp__botloft__ask_key", { status: "done" });
fake.keys.ask(
analyst,
"GITHUB_TOKEN",
"To open the pull request with the new price table. A token with access to the bakery-site repository is enough: GitHub, Settings, Developer settings, Fine-grained tokens.",
);
fake.now = now;
// Settings, Security: two keys pasted before they were kept apart.
fake.keys.pasted.push(
{
id: "dev_github",
value: "ghp_dev0000000000000000000000",
preview: "ghp_…0000",
botIds: [analyst, scout],
places: 3,
lastAt: now - 2 * 24 * 60 * 60 * 1000,
},
{
id: "dev_openai",
value: "sk-dev00000000000000000000",
preview: "sk-…0000",
botIds: [scout],
places: 1,
lastAt: now - 9 * 24 * 60 * 60 * 1000,
},
);
}
5 changes: 5 additions & 0 deletions app/src/features/account/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,14 @@ import {
Palette,
Plug,
Search,
ShieldCheck,
SlidersHorizontal,
Smartphone,
} from "lucide-react";
import { useId, useMemo, useState } from "react";
import { useT } from "../../i18n";
import { ConnectedToolsSettings } from "../connections/ConnectedToolsSettings";
import { PastedKeys } from "../keys/PastedKeys";
import { AboutSettings } from "./AboutSettings";
import { AccountSettings } from "./AccountSettings";
import { AlertsSettings } from "./AlertsSettings";
Expand Down Expand Up @@ -51,6 +53,7 @@ const GROUPS: { id: GroupId; pages: { id: Page; icon: LucideIcon }[] }[] = [
id: "groupData",
pages: [
{ id: "backup", icon: DatabaseBackup },
{ id: "security", icon: ShieldCheck },
{ id: "archived", icon: Archive },
],
},
Expand All @@ -71,6 +74,7 @@ export function SettingsPage() {
account: s.account,
tools: s.tools,
backup: s.backup,
security: s.security,
archived: s.archived,
about: s.about,
};
Expand Down Expand Up @@ -163,6 +167,7 @@ export function SettingsPage() {
{page === "account" && <AccountSettings />}
{page === "tools" && <ConnectedToolsSettings />}
{page === "backup" && <BackupSettings goToAccount={() => open("account")} />}
{page === "security" && <PastedKeys />}
{page === "archived" && <ArchivedSettings />}
{page === "about" && <AboutSettings />}
</>
Expand Down
1 change: 1 addition & 0 deletions app/src/features/account/settings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,7 @@ describe("the page of settings", () => {
"Account and phone",
"Connected tools",
"Backup",
"Security",
"Archived",
"About",
]);
Expand Down
2 changes: 2 additions & 0 deletions app/src/features/account/settingsIndex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export type Page =
| "account"
| "tools"
| "backup"
| "security"
| "archived"
| "about";

Expand Down Expand Up @@ -46,6 +47,7 @@ export function settingsIndex(t: ReturnType<typeof useT>): Entry[] {
...entry("account", t.account.cloud.title, t.account.mobile.title, t.account.mobile.connect),
...entry("tools", s.tools),
...entry("backup", t.account.backup.exportTitle, t.account.backup.importTitle, s.copiesTitle),
...entry("security", t.keys.scan.title, t.keys.scan.run),
...entry("archived", s.archived),
...entry("about", s.about, s.checkUpdates),
];
Expand Down
2 changes: 2 additions & 0 deletions app/src/features/bots/BotPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { Callout } from "../../ui/Callout";
import { SidePanel } from "../../ui/SidePanel";
import { BotTools } from "../connections/BotTools";
import { DesktopGrants } from "../desktop/DesktopGrants";
import { AgentKeys } from "../keys/AgentKeys";
import { SignInButton } from "../onboarding/SignIn";
import { AllowRules } from "./AllowRules";
import type { stateView } from "./BotStateBadge";
Expand Down Expand Up @@ -95,6 +96,7 @@ export function Details({ bot, onClose }: { bot: Bot; onClose(): void }) {
</div>
<AllowRules bot={bot} />
<CrewAccessList bot={bot} />
<AgentKeys bot={bot} />
<BotTools bot={bot} />
<DesktopGrants bot={bot} />
</dl>
Expand Down
7 changes: 7 additions & 0 deletions app/src/features/chat/BotRun.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { useArrival } from "../../ui/motion";
import { BotAvatar, moodOf } from "../bots/BotAvatar";
import { QuestionCard } from "../questions/QuestionCard";
import { ApprovalCard } from "./ApprovalCard";
import { KeyRequestCard } from "./KeyRequestCard";
import { Markdown } from "./Markdown";
import { MemoryNote } from "./MemoryNote";
import { memoryOf, memoryPlaces } from "./memory";
Expand Down Expand Up @@ -182,6 +183,12 @@ function BotRunView({
<QuestionCard question={head.body.question} bot={bot.name} />
</Arriving>
);
case "key_request":
return (
<Arriving key={head.id} at={head.createdAt}>
<KeyRequestCard id={head.id} request={head.body} bot={bot} />
</Arriving>
);
case "turn":
return (
<Arriving key={head.id} at={head.createdAt}>
Expand Down
11 changes: 11 additions & 0 deletions app/src/features/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { type Bot, FIELD_LIMITS } from "../../lib/protocol.gen";
import { prefs, usePref } from "../../shell/prefs";
import { playSound } from "../../shell/sounds";
import { useApi } from "../../store/context";
import { pastedKeys } from "../keys/pasted";
import { AgentModelPicker } from "./AgentModelPicker";
import { ContextMeter } from "./ContextMeter";
import { draftOf, keepDraft } from "./drafts";
Expand Down Expand Up @@ -85,7 +86,10 @@ export const ChatComposer = memo(function ChatComposer({
const setText = (value: string) => {
setShown(value);
keepDraft(botId, value);
setKept(null);
};
/** Saved keys the owner pasted in the last message, sent by name (spec 32.6). */
const [kept, setKept] = useState<string[] | null>(null);
const [busy, setBusy] = useState(false);
/** A new mode, model or effort, or a compaction, that waits for the bot to finish what it is doing. */
const [later, setLater] = useState<string | null>(null);
Expand Down Expand Up @@ -144,6 +148,8 @@ export const ChatComposer = memo(function ChatComposer({
}
});
setText("");
const pasted = pastedKeys(text, message.body);
setKept(pasted.length > 0 ? pasted : null);
files.clear();
onCancelReply?.();
playSound("sent");
Expand Down Expand Up @@ -186,6 +192,11 @@ export const ChatComposer = memo(function ChatComposer({
{stopped && (
<p className="mb-1.5 px-2 text-muted text-xs">{t.chat.composer.paused(bot.name)}</p>
)}
{kept && (
<p role="status" className="mb-1.5 animate-fade px-2 text-warn text-xs">
{t.keys.pasted(kept, bot.name)}
</p>
)}
{later && (
<p role="status" className="mb-1.5 animate-fade px-2 text-muted text-xs">
{later}
Expand Down
79 changes: 79 additions & 0 deletions app/src/features/chat/KeyRequestCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// An agent asking the owner for a key (spec 32.5): the name, what it is for
// and the form that keeps it out of the chat. Once answered it shrinks to
// one line that names the key, never its value.

import { Check, KeyRound, X } from "lucide-react";
import { useState } from "react";
import { useT } from "../../i18n";
import type { Bot, ChatItemId, KeyRequestItem } from "../../lib/protocol.gen";
import { useApi } from "../../store/context";
import { Button } from "../../ui/Button";
import { SectionCard, SettledLine } from "../../ui/ChatCard";
import { attempt } from "../../ui/toast";
import { KeyForm } from "../keys/KeyForm";

export function KeyRequestCard({
id,
request,
bot,
}: {
id: ChatItemId;
request: KeyRequestItem;
bot: Bot;
}) {
const t = useT().keys.card;
if (request.status === "saved") {
return <SettledLine icon={Check} tone="ok" text={t.saved(request.name)} />;
}
if (request.status === "dismissed") {
return <SettledLine icon={X} tone="quiet" text={t.dismissed(request.name)} />;
}
return <OpenRequest id={id} request={request} bot={bot} />;
}

function OpenRequest({ id, request, bot }: { id: ChatItemId; request: KeyRequestItem; bot: Bot }) {
const t = useT().keys.card;
const api = useApi();
const [busy, setBusy] = useState(false);
const notNow = async () => {
setBusy(true);
await attempt(t.dismissFailed, () => api.call("keys.dismiss", { requestId: id }));
setBusy(false);
};
return (
<SectionCard
label={t.asks(bot.name)}
icon={KeyRound}
tone="warn"
footer={
<KeyForm
bot={bot}
name={request.name}
requestId={id}
// The card closes when the daemon rewrites the request.
onSaved={() => {}}
actions={
<Button
type="button"
variant="ghost"
icon={X}
disabled={busy}
title={t.notNowHint(bot.name)}
onClick={notNow}
>
{t.notNow}
</Button>
}
/>
}
>
<div className="flex flex-col gap-1.5 px-4 py-3 text-sm" data-selectable>
<p className="font-mono font-semibold">{request.name}</p>
<p className="whitespace-pre-wrap text-ink-soft">
<span className="sr-only">{t.why}: </span>
{request.why}
</p>
</div>
</SectionCard>
);
}
10 changes: 7 additions & 3 deletions app/src/features/chat/rows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,14 @@ export type Row =
| { kind: "notice"; key: string; item: ChatItem; notice: NoticeItem };

/**
* Tools whose card says it all: the bot asking the owner (spec 23.2) and
* the plan it shows before long work (spec 10.5).
* Tools whose card says it all: the bot asking the owner (spec 23.2) or
* for a key (spec 32.5), and the plan it shows before long work (spec 10.5).
*/
const CARD_TOOLS = new Set(["mcp__botloft__ask_owner", "mcp__botloft__show_plan"]);
const CARD_TOOLS = new Set([
"mcp__botloft__ask_owner",
"mcp__botloft__ask_key",
"mcp__botloft__show_plan",
]);

/** A call that worked and has its own card, which stands for it. */
function isAskLine(item: ChatItem): boolean {
Expand Down
Loading
Loading