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: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ Em dev, use `$env:BOTLOFT_HOME = "$PWD\.dev\home"` (caminho absoluto) no daemon
- Código, identificadores, comentários, commits e README em **inglês**. Docs em `docs/` podem ser em português.
- Arquivos com no máximo **~300 linhas**. Passou disso, divida por responsabilidade.
- Erros: `thiserror` nos crates de biblioteca, `anyhow` só no binário.
- IDs são ULID com prefixo (`crw_`, `bot_`, `msg_`, `dlv_`, `tsk_`, `cht_`, `apr_`, `att_`). Tempo em ms Unix.
- IDs são ULID com prefixo (`crw_`, `bot_`, `msg_`, `dlv_`, `tsk_`, `cht_`, `apr_`, `att_`, `qst_`). Tempo em ms Unix.
- Tudo que é específico de Windows fica em `crates/botloftd/src/platform/`. O resto do daemon não chama Win32 direto.
- A UI depende só da interface `BotloftApi` (`app/src/lib/api.ts`). Testes de componente usam `FakeBotloft`.
- Nenhum texto de interface escrito direto no componente: tudo vai em `app/src/i18n/{en,pt-BR,es}/<área>.ts` e é lido com `useT()` (ou `t()` fora do React). Texto novo entra nos três idiomas no mesmo commit; o TypeScript acusa o que faltar (spec 15.6). Palavras simples, sem "daemon" nem outro jargão.
Expand Down
2 changes: 2 additions & 0 deletions app/src/dev/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { seedChief } from "./seedChief";
import { seedContext } from "./seedContext";
import { seedHands } from "./seedHands";
import { activeTab, drawPage } from "./seedPage";
import { seedQuestions } from "./seedQuestions";
import { seedRoutines } from "./seedRoutines";
import { seedScreens } from "./seedScreens";

Expand Down Expand Up @@ -123,6 +124,7 @@ function seed(fake: FakeBotloft): void {
planner: planner.id,
});
seedRoutines(fake, { scout: scout.id, analyst: analyst.id });
seedQuestions(fake, { scout: scout.id, analyst: analyst.id });
seedBrowser(fake, scout.id);
// Analyst hit the limit in the middle of a search: its browser rests.
fake.browser.open(analyst.id, "https://stats.example/flour-prices", "Flour prices · Stats");
Expand Down
24 changes: 24 additions & 0 deletions app/src/dev/seedQuestions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Dev only: questions the bots left for the owner overnight (spec 23).

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

const HOUR = 60 * 60 * 1000;

export function seedQuestions(fake: FakeBotloft, crew: { scout: BotId; analyst: BotId }): void {
const now = Date.now();
fake.now = now - 7 * HOUR;
fake.chat.tool(crew.analyst, "mcp__botloft__ask_owner", { status: "done" });
fake.questions.ask(
crew.analyst,
"Two sources disagree on last year's **flour prices** (12% and 19% up). Which one should the report trust?\n\nThe 19% figure comes from a smaller survey.",
);
fake.now = now - 20 * 60 * 1000;
fake.chat.tool(crew.scout, "mcp__botloft__ask_owner", { status: "done" });
fake.questions.ask(
crew.scout,
"Should next week's summary cover only papers, or blog posts too?",
["Only papers", "Papers and blog posts", "Skip next week"],
);
fake.now = now;
}
7 changes: 7 additions & 0 deletions app/src/features/chat/BotRun.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { duration, tokens, usedTokens, when } from "../../lib/format";
import type { Bot, ChatItem, TurnItem } from "../../lib/protocol.gen";
import { useArrival } from "../../ui/motion";
import { BotAvatar, moodOf } from "../bots/BotAvatar";
import { QuestionCard } from "../questions/QuestionCard";
import { ApprovalCard } from "./ApprovalCard";
import { Markdown } from "./Markdown";
import { runParts, splitDraft } from "./rows";
Expand Down Expand Up @@ -140,6 +141,12 @@ function BotRunView({
<ApprovalCard approval={head.body} bot={bot} />
</Arriving>
);
case "question":
return (
<Arriving key={head.id} at={head.createdAt}>
<QuestionCard question={head.body.question} bot={bot.name} />
</Arriving>
);
case "turn":
return (
<Arriving key={head.id} at={head.createdAt}>
Expand Down
1 change: 1 addition & 0 deletions app/src/features/chat/rows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const inbound = (body: string, at?: number) =>
body,
taskId: null,
routineId: null,
questionId: null,
attachments: [],
createdAt: at ?? start,
},
Expand Down
11 changes: 11 additions & 0 deletions app/src/features/chat/rows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ export type Row =
| { kind: "run"; key: string; items: ChatItem[] }
| { kind: "notice"; key: string; item: ChatItem; notice: NoticeItem };

/** The bot asking the owner (spec 23.2): its question card says it all. */
const ASK_TOOL = "mcp__botloft__ask_owner";

/** A call to ask the owner that worked; the card stands for it. */
function isAskLine(item: ChatItem): boolean {
return item.body.kind === "tool" && item.body.name === ASK_TOOL && item.body.status !== "failed";
}

function dayOf(ms: number): string {
return new Date(ms).toDateString();
}
Expand All @@ -22,6 +30,9 @@ export function chatRows(items: ChatItem[]): Row[] {
let day: string | null = null;
let run: ChatItem[] | null = null;
for (const item of items) {
if (isAskLine(item)) {
continue;
}
const today = dayOf(item.createdAt);
if (today !== day) {
day = today;
Expand Down
48 changes: 46 additions & 2 deletions app/src/features/crews/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { ChevronDown, ChevronsDownUp, ChevronsUpDown, Pause, Plus } from "lucide-react";
import {
ChevronDown,
ChevronsDownUp,
ChevronsUpDown,
MessageCircleQuestion,
Pause,
Plus,
} from "lucide-react";
import { memo, useCallback, useState } from "react";
import { createPortal } from "react-dom";
import { useShallow } from "zustand/react/shallow";
Expand All @@ -7,6 +14,7 @@ import { when } from "../../lib/format";
import type { Activity, Bot, Crew } from "../../lib/protocol.gen";
import { activityOf, botsOf, crewList } from "../../store/app";
import { useApp } from "../../store/context";
import { openQuestions } from "../../store/questions";
import { isUnread, unreadIn } from "../../store/seen";
import { Button } from "../../ui/Button";
import { ContextMenu, menuPoint, type Point } from "../../ui/ContextMenu";
Expand All @@ -25,7 +33,7 @@ export function Sidebar() {
const t = useT();
const words = t.crews.sidebar;
const crews = useApp(useShallow(crewList));
const overview = useApp((state) => state.selectedCrewId === null);
const overview = useApp((state) => state.selectedCrewId === null && !state.questionBox);
const selectCrew = useApp((state) => state.selectCrew);
const collapsed = useCollapsedSet();
const allFolded = crews.length > 0 && crews.every((crew) => collapsed.has(crew.id));
Expand Down Expand Up @@ -60,6 +68,7 @@ export function Sidebar() {
onClick={() => setCreating(true)}
/>
</div>
<QuestionsEntry />
<ul className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
{crews.map((crew) => (
<CrewEntry key={crew.id} crew={crew} />
Expand All @@ -75,6 +84,39 @@ export function Sidebar() {
const row =
"relative flex w-full items-center rounded-lg text-left transition-colors duration-150 hover:bg-sunken";

/** The question box (spec 23.6), with how many questions wait. */
function QuestionsEntry() {
const t = useT();
const words = t.questions.box;
const count = useApp((state) => openQuestions(state).length);
const open = useApp((state) => state.questionBox);
const openBox = useApp((state) => state.openQuestionBox);
return (
<div className="shrink-0 px-2">
<button
type="button"
aria-current={open ? "page" : undefined}
title={words.open(count)}
onClick={openBox}
className={`${row} gap-2.5 px-2.5 py-1.5 text-sm ${open ? "bg-sunken text-ink" : "text-ink-soft"}`}
>
<MessageCircleQuestion
aria-hidden
size={16}
className={count > 0 ? "text-warn" : "text-muted"}
/>
<span className="min-w-0 flex-1 truncate font-medium">{words.label}</span>
{count > 0 && (
<span className="grid h-[18px] min-w-[18px] place-items-center rounded-full bg-warn px-1 font-semibold text-[11px] text-canvas tabular-nums">
<span aria-hidden>{count}</span>
<span className="sr-only">{words.open(count)}</span>
</span>
)}
</button>
</div>
);
}

function CrewEntry({ crew }: { crew: Crew }) {
const t = useT();
const bots = useApp(useShallow((state) => botsOf(state, crew.id)));
Expand Down Expand Up @@ -218,6 +260,8 @@ function activityText(activity: Activity, t: Messages): string {
return t.crews.sidebar.fromOwner(text);
case "approval":
return t.crews.sidebar.awaitingApproval(tool ? toolAction(tool, t.tools) : text);
case "question":
return t.questions.activity(text);
case "tool": {
if (!tool) {
return text;
Expand Down
93 changes: 93 additions & 0 deletions app/src/features/questions/QuestionBox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// The question box (spec 23.6): every question the bots asked and the owner
// has not answered, newest first, each with its card to answer right here.

import { MessageCircleQuestion, MessageSquare } from "lucide-react";
import { useT } from "../../i18n";
import { when } from "../../lib/format";
import { AVATAR_PALETTE, type Question } from "../../lib/protocol.gen";
import { useApp } from "../../store/context";
import { openQuestions } from "../../store/questions";
import { Button } from "../../ui/Button";
import { BotAvatar } from "../bots/BotAvatar";
import { QuestionCard } from "./QuestionCard";

export function QuestionBox() {
const t = useT();
const words = t.questions.box;
const questions = useApp(openQuestions);
return (
<section aria-label={words.label} className="flex min-h-0 flex-1 flex-col">
<header className="border-line border-b px-5 py-3.5">
<h1 className="truncate font-semibold text-xl tracking-tight">{words.title}</h1>
<p className="mt-0.5 text-muted text-sm">{words.intro}</p>
</header>
<div className="min-h-0 flex-1 overflow-y-auto p-5">
{questions.length === 0 ? (
<Empty />
) : (
<ul className="mx-auto flex max-w-3xl flex-col gap-6">
{questions.map((question) => (
<Entry key={question.id} question={question} />
))}
</ul>
)}
</div>
</section>
);
}

function Entry({ question }: { question: Question }) {
const t = useT();
const words = t.questions.box;
const bot = useApp((state) => state.bots[question.botId]);
const crew = useApp((state) => state.crews[question.crewId]);
const selectBot = useApp((state) => state.selectBot);
if (!bot) {
return null;
}
return (
<li className="flex gap-3">
<BotAvatar color={bot.color} size={28} still />
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex items-center gap-2 text-sm">
<span className="font-semibold">{bot.name}</span>
{crew && <span className="truncate text-muted text-xs">{words.inCrew(crew.name)}</span>}
<time
className="text-muted text-xs"
dateTime={new Date(question.createdAt).toISOString()}
>
{when(question.createdAt)}
</time>
<Button
variant="ghost"
size="sm"
icon={MessageSquare}
label={words.openChat(bot.name)}
className="ml-auto"
onClick={() => selectBot(bot.id)}
/>
</div>
<QuestionCard question={question} bot={bot.name} />
</div>
</li>
);
}

function Empty() {
const t = useT();
const words = t.questions.box;
return (
<div className="mx-auto mt-10 flex max-w-80 flex-col items-center gap-3 rounded-2xl bg-panel/80 px-6 py-10 text-center">
<span className="relative">
<BotAvatar color={AVATAR_PALETTE[0]} size={44} mood="idle" />
<MessageCircleQuestion
aria-hidden
size={18}
className="absolute -right-2 -bottom-1 rounded-full bg-panel p-0.5 text-muted"
/>
</span>
<p className="font-semibold text-sm">{words.emptyTitle}</p>
<p className="text-muted text-sm">{words.emptyBody}</p>
</div>
);
}
Loading
Loading