diff --git a/CLAUDE.md b/CLAUDE.md index 786222c1..9b6a17cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/app/src/dev/preview.ts b/app/src/dev/preview.ts index f2c64292..6a89508c 100644 --- a/app/src/dev/preview.ts +++ b/app/src/dev/preview.ts @@ -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"; @@ -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"); diff --git a/app/src/dev/seedQuestions.ts b/app/src/dev/seedQuestions.ts new file mode 100644 index 00000000..9959d5b2 --- /dev/null +++ b/app/src/dev/seedQuestions.ts @@ -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; +} diff --git a/app/src/features/chat/BotRun.tsx b/app/src/features/chat/BotRun.tsx index 453e2310..5e844c27 100644 --- a/app/src/features/chat/BotRun.tsx +++ b/app/src/features/chat/BotRun.tsx @@ -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"; @@ -140,6 +141,12 @@ function BotRunView({ ); + case "question": + return ( + + + + ); case "turn": return ( diff --git a/app/src/features/chat/rows.test.ts b/app/src/features/chat/rows.test.ts index bf007d81..f08716a4 100644 --- a/app/src/features/chat/rows.test.ts +++ b/app/src/features/chat/rows.test.ts @@ -39,6 +39,7 @@ const inbound = (body: string, at?: number) => body, taskId: null, routineId: null, + questionId: null, attachments: [], createdAt: at ?? start, }, diff --git a/app/src/features/chat/rows.ts b/app/src/features/chat/rows.ts index 5d3e9efa..32cebe7f 100644 --- a/app/src/features/chat/rows.ts +++ b/app/src/features/chat/rows.ts @@ -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(); } @@ -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; diff --git a/app/src/features/crews/Sidebar.tsx b/app/src/features/crews/Sidebar.tsx index 89240959..23455007 100644 --- a/app/src/features/crews/Sidebar.tsx +++ b/app/src/features/crews/Sidebar.tsx @@ -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"; @@ -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"; @@ -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)); @@ -60,6 +68,7 @@ export function Sidebar() { onClick={() => setCreating(true)} /> +
    {crews.map((crew) => ( @@ -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 ( +
    + +
    + ); +} + function CrewEntry({ crew }: { crew: Crew }) { const t = useT(); const bots = useApp(useShallow((state) => botsOf(state, crew.id))); @@ -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; diff --git a/app/src/features/questions/QuestionBox.tsx b/app/src/features/questions/QuestionBox.tsx new file mode 100644 index 00000000..5633e38c --- /dev/null +++ b/app/src/features/questions/QuestionBox.tsx @@ -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 ( +
    +
    +

    {words.title}

    +

    {words.intro}

    +
    +
    + {questions.length === 0 ? ( + + ) : ( +
      + {questions.map((question) => ( + + ))} +
    + )} +
    +
    + ); +} + +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 ( +
  • + +
    +
    + {bot.name} + {crew && {words.inCrew(crew.name)}} + +
    + +
    +
  • + ); +} + +function Empty() { + const t = useT(); + const words = t.questions.box; + return ( +
    + + + + +

    {words.emptyTitle}

    +

    {words.emptyBody}

    +
    + ); +} diff --git a/app/src/features/questions/QuestionCard.tsx b/app/src/features/questions/QuestionCard.tsx new file mode 100644 index 00000000..c065d6c2 --- /dev/null +++ b/app/src/features/questions/QuestionCard.tsx @@ -0,0 +1,156 @@ +// A question a bot asked the owner (spec 23.6): the question, ready answers +// as buttons and a field for any other answer. The same card is in the +// bot's chat and in the question box. Once closed it shrinks to one line. + +import { Check, MessageCircleQuestion, Reply, X } from "lucide-react"; +import { useId, useState } from "react"; +import { useT } from "../../i18n"; +import { when } from "../../lib/format"; +import type { Question } from "../../lib/protocol.gen"; +import { useApi } from "../../store/context"; +import { Button } from "../../ui/Button"; +import { attempt } from "../../ui/toast"; +import { Markdown } from "../chat/Markdown"; + +export function QuestionCard({ question, bot }: { question: Question; bot: string }) { + if (question.status !== "open") { + return ; + } + return ; +} + +function OpenQuestion({ question, bot }: { question: Question; bot: string }) { + const t = useT(); + const q = t.questions.card; + const api = useApi(); + const [text, setText] = useState(""); + const [busy, setBusy] = useState(false); + const fieldId = useId(); + + const answer = async (value: string) => { + setBusy(true); + const sent = await attempt(q.answerFailed, () => + api.call("questions.answer", { questionId: question.id, answer: value }), + ); + setBusy(false); + if (sent) { + setText(""); + } + }; + const dismiss = async () => { + setBusy(true); + await attempt(q.dismissFailed, () => + api.call("questions.dismiss", { questionId: question.id }), + ); + setBusy(false); + }; + + return ( +
    +

    + + + + {q.asks(bot)} +

    +
    + + {question.options.length > 0 && ( +
    + {question.options.map((option) => ( + + ))} +
    + )} +
    +
    { + event.preventDefault(); + if (text.trim()) { + void answer(text); + } + }} + > + +