diff --git a/app/src/features/bots/BotView.tsx b/app/src/features/bots/BotView.tsx index acc7791c..efa8603a 100644 --- a/app/src/features/bots/BotView.tsx +++ b/app/src/features/bots/BotView.tsx @@ -3,11 +3,11 @@ import { useEffect, useMemo, useState } from "react"; import { useShallow } from "zustand/react/shallow"; import { useT } from "../../i18n"; import type { Bot, Crew } from "../../lib/protocol.gen"; -import { routinesOf } from "../../store/app"; +import { type BotPanel, routinesOf } from "../../store/app"; import { useApp } from "../../store/context"; import { Button } from "../../ui/Button"; import { Callout } from "../../ui/Callout"; -import { PanelClosing } from "../../ui/panelMotion"; +import { PanelClosing, PanelRestored } from "../../ui/panelMotion"; import { SidePanel } from "../../ui/SidePanel"; import { type Tab, Tabs, tabId } from "../../ui/Tabs"; import { BrowserPanel } from "../browser/BrowserPanel"; @@ -27,7 +27,7 @@ import { useFollowBot } from "./useFollowBot"; type Pane = "chat" | "routines"; /** What the panel beside the chat shows. */ -type Side = "details" | "files" | "browser" | "screens" | null; +type Side = BotPanel | null; /** * A bot's conversation and its routines, with its details in a side panel @@ -35,7 +35,17 @@ type Side = "details" | "files" | "browser" | "screens" | null; */ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) { const t = useT(); - const [side, setSide] = useState(null); + // Kept per bot in the store, not here: the panel the owner left open is + // back when they come back to the bot, and one they closed stays closed + // (spec 15.1). + const side = useApp((state) => state.panels[bot.id] ?? null); + const keepPanel = useApp((state) => state.setPanel); + // The panel that came back is there at once; one opened after it slides. + const [restored, setRestored] = useState(side !== null); + const setSide = (panel: Side) => { + setRestored(false); + keepPanel(bot.id, panel); + }; // The panel on screen: the one open, or the last one while it slides // closed (spec 15.1). const [leaving, setLeaving] = useState(null); @@ -116,7 +126,8 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) { setTakeBrowser((count) => count + 1); } }; - // The panel follows what the bot starts doing (spec 15.1). + // The panel follows what the bot starts doing (spec 15.1), over the one + // that came back too. useFollowBot({ bot, side, @@ -166,31 +177,33 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) { )} - - {beside === "details" &&
setSide(null)} />} - {beside === "browser" && ( - setSide(null)} /> - )} - {beside === "screens" && ( - setSide(null)} - /> - )} - {beside === "files" && ( - - )} - + + + {beside === "details" &&
setSide(null)} />} + {beside === "browser" && ( + setSide(null)} /> + )} + {beside === "screens" && ( + setSide(null)} + /> + )} + {beside === "files" && ( + + )} + + ); diff --git a/app/src/features/bots/panels.test.tsx b/app/src/features/bots/panels.test.tsx new file mode 100644 index 00000000..25db6aa6 --- /dev/null +++ b/app/src/features/bots/panels.test.tsx @@ -0,0 +1,117 @@ +import { act, cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { FakeBotloft } from "../../lib/fake"; +import { crewOpened, openBot, renderApp, sidebar } from "../../test/app"; + +beforeEach(() => localStorage.clear()); +afterEach(cleanup); + +/** A crew "Ops" with @scout and @writer, idle. */ +function crew() { + const fake = new FakeBotloft(); + const ops = fake.addCrew("Ops"); + const scout = fake.addBot(ops.id, "Scout", "Finds sources"); + const writer = fake.addBot(ops.id, "Writer", "Writes the notes"); + fake.setBotState(scout.id, "idle"); + fake.setBotState(writer.id, "idle"); + return { fake, scout, writer }; +} + +async function open(name: string) { + openBot(name); + await screen.findByRole("region", { name: `Chat with ${name}` }); +} + +async function started(fake: FakeBotloft) { + renderApp(fake); + await crewOpened("Ops"); + await open("Scout"); +} + +const panel = (name: string) => screen.queryByRole("complementary", { name }); +const button = (name: RegExp) => screen.getByRole("button", { name }); + +describe("the panel beside a bot's chat", () => { + test("comes back with the bot, each bot with its own", async () => { + const { fake, scout } = crew(); + await started(fake); + fireEvent.click(button(/^Show browser/)); + await waitFor(() => expect(fake.browser.watching).toBe(scout.id)); + + await open("Writer"); + expect(panel("Scout's browser")).toBeNull(); + expect(panel("Writer's browser")).toBeNull(); + fireEvent.click(button(/^Show files/)); + expect(panel("Files from Writer")).not.toBeNull(); + + await open("Scout"); + const back = panel("Scout's browser"); + expect(back).not.toBeNull(); + // It is there at once: only a panel opened by hand or by the bot slides. + expect(back?.className).not.toContain("panel-opening"); + await waitFor(() => expect(fake.browser.watching).toBe(scout.id)); + expect(panel("Files from Scout")).toBeNull(); + + await open("Writer"); + expect(panel("Files from Writer")).not.toBeNull(); + // Another one opened now slides, from the width of the one it replaces. + fireEvent.click(button(/^Show details/)); + expect(panel("About Writer")?.className).toContain("panel-opening"); + }); + + test("closed by the owner, it stays closed when they come back", async () => { + const { fake, scout } = crew(); + await started(fake); + // The bot starts browsing: its browser opens by itself. + act(() => { + fake.browser.open(scout.id, "https://example.com/", "Example"); + }); + await waitFor(() => expect(panel("Scout's browser")).not.toBeNull()); + fireEvent.click(button(/^Hide browser/)); + + await open("Writer"); + await open("Scout"); + expect(panel("Scout's browser")).toBeNull(); + expect(button(/^Show browser/).getAttribute("aria-label")).toBe( + "Show browser: Scout is using the browser", + ); + }); + + test("one that opened by itself comes back too, and the crew's page in between changes nothing", async () => { + const { fake, scout } = crew(); + await started(fake); + act(() => { + fake.screens.draft(scout.id, "C:\\Work\\site\\home.html", "

Home"); + }); + await waitFor(() => expect(panel("Scout's screens")).not.toBeNull()); + + fireEvent.click(within(sidebar()).getByRole("button", { name: "Ops" })); + await crewOpened("Ops"); + expect(panel("Scout's screens")).toBeNull(); + await open("Scout"); + expect(panel("Scout's screens")).not.toBeNull(); + }); + + test("what the bot did while the owner was away stays behind its button, beside the panel that came back", async () => { + const { fake, scout } = crew(); + await started(fake); + fireEvent.click(button(/^Show files/)); + await open("Writer"); + act(() => { + fake.browser.open(scout.id, "https://example.com/", "Example"); + }); + + await open("Scout"); + expect(panel("Files from Scout")).not.toBeNull(); + expect(panel("Scout's browser")).toBeNull(); + expect(button(/^Show browser/).getAttribute("aria-label")).toBe( + "Show browser: Scout is using the browser", + ); + // What it starts from now on takes the place of that panel, as always. + act(() => { + fake.screens.draft(scout.id, "C:\\Work\\site\\home.html", "

Home"); + }); + await waitFor(() => expect(panel("Scout's screens")).not.toBeNull()); + expect(panel("Files from Scout")).toBeNull(); + }); +}); diff --git a/app/src/features/bots/useFollowBot.ts b/app/src/features/bots/useFollowBot.ts index bcb84263..a8d05630 100644 --- a/app/src/features/bots/useFollowBot.ts +++ b/app/src/features/bots/useFollowBot.ts @@ -4,7 +4,9 @@ // the design area (spec 22.5), whatever panel was open. Each opens once per // start; closing it leaves the button's dot. The browser in the owner's // hands is never taken away. The owner can turn this off in Settings -// (`prefs.followBot`): then only the buttons' dots say so. +// (`prefs.followBot`): then only the buttons' dots say so. Coming back to a +// bot is opening it again: the panel the owner left is the one open (the +// store keeps it), and what opens by itself here takes its place. import { useEffect, useRef } from "react"; import type { Bot } from "../../lib/protocol.gen"; diff --git a/app/src/features/messages/DeliveryStatus.tsx b/app/src/features/messages/DeliveryStatus.tsx index 1e40c229..fda9db93 100644 --- a/app/src/features/messages/DeliveryStatus.tsx +++ b/app/src/features/messages/DeliveryStatus.tsx @@ -22,8 +22,9 @@ export function DeliveryStatus({ delivery }: { delivery: Delivery }) { {text.delivered}

) : ( + // The two marks turn blue, as in chat apps; the word says it too.

- + {text.read}

); diff --git a/app/src/features/messages/messages.test.tsx b/app/src/features/messages/messages.test.tsx index ff5b5ed0..8eadf903 100644 --- a/app/src/features/messages/messages.test.tsx +++ b/app/src/features/messages/messages.test.tsx @@ -1,5 +1,6 @@ import { act, cleanup, fireEvent, screen, within } from "@testing-library/react"; import { afterEach, describe, expect, test, vi } from "vitest"; +import tokens from "../../index.css?raw"; import { FakeBotloft } from "../../lib/fake"; import { crewOpened, openTab, renderApp } from "../../test/app"; @@ -50,6 +51,34 @@ describe("messages", () => { expect(within(sentRow).getByText("Delivered")).toBeDefined(); }); + test("a message the bot has read gets its two marks in blue, with the word beside them", async () => { + const { fake, lead, writer } = crew(); + const { delivery } = fake.conversation.say({ + from: lead.id, + to: writer.id, + body: "Draft the notes", + }); + fake.conversation.deliver(delivery.id, "sent"); + renderApp(fake); + await crewOpened("Ops"); + openTab("Timeline"); + const row = (await within(messages()).findByText("Draft the notes")).closest( + "li", + ) as HTMLElement; + // Delivered is the dim line it always was. + expect(within(row).getByText("Delivered").className).toContain("text-muted"); + expect(row.querySelector(".text-read")).toBeNull(); + + act(() => { + fake.conversation.read(delivery.id); + }); + const read = within(row).getByText("Read"); + // Only the marks take the color (a token, spec 15.3); the word stays as readable as before. + expect(read.querySelector("svg")?.classList.contains("text-read")).toBe(true); + expect(read.className).toContain("text-muted"); + expect(read.className).not.toContain("text-read"); + }); + test("older messages load on demand", async () => { const { fake, lead, writer } = crew(); for (let n = 1; n <= 60; n += 1) { @@ -140,3 +169,45 @@ describe("tasks", () => { expect(screen.getByText("Done: 3 pages")).toBeDefined(); }); }); + +/** The color tokens of one theme in index.css, by name. */ +function theme(selector: string): Record { + const block = tokens.slice(tokens.indexOf(`${selector} {`)).split("}")[0] ?? ""; + return Object.fromEntries( + [...block.matchAll(/--([\w-]+):\s*(#[0-9a-f]{6})/g)].map((found) => [found[1], found[2]]), + ); +} + +/** WCAG contrast between two `#rrggbb` colors. */ +function contrast(one: string, other: string): number { + const [high = 0, low = 0] = [one, other] + .map((hex) => { + const [r = 0, g = 0, b = 0] = [1, 3, 5].map((at) => { + const value = Number.parseInt(hex.slice(at, at + 2), 16) / 255; + return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; + }) + .sort((a, b) => b - a); + return (high + 0.05) / (low + 0.05); +} + +describe("the read color", () => { + test.each([ + ["light", ":root"], + ["dark", `[data-theme="dark"]`], + ])( + "in the %s theme the marks stand out from every surface, in a blue of their own", + (_, selector) => { + const colors = theme(selector); + const read = colors.read ?? ""; + expect(read).toMatch(/^#[0-9a-f]{6}$/); + // What an icon needs (3:1), on the chat, a card and a sunken row. + for (const surface of ["canvas", "panel", "sunken"]) { + expect(contrast(read, colors[surface] ?? "")).toBeGreaterThanOrEqual(3); + } + // Lighter than the blue of a bot at work, which says something else. + expect(contrast(read, "#000000")).toBeGreaterThan(contrast(colors.work ?? "", "#000000")); + }, + ); +}); diff --git a/app/src/index.css b/app/src/index.css index 6baa10b3..0cce2368 100644 --- a/app/src/index.css +++ b/app/src/index.css @@ -28,6 +28,9 @@ --warn: #a35f00; --danger: #c2372a; --idle-state: #6f6f69; + /* The check marks of a message the bot has read: a lighter blue than + --work, still 3:1 or more on canvas, panel and sunken in both themes. */ + --read: #1787c9; /* The thin edge around a bot's avatar, so light colors hold their shape. */ --avatar-edge: rgb(0 0 0 / 0.2); /* Under menus, pickers and dialogs. */ @@ -50,6 +53,7 @@ --warn: #f0b24a; --danger: #ff6d60; --idle-state: #8c8c86; + --read: #5cc4f2; --avatar-edge: rgb(255 255 255 / 0.14); --lift: rgb(0 0 0 / 0.5); } @@ -69,6 +73,7 @@ --color-warn: var(--warn); --color-danger: var(--danger); --color-quiet: var(--idle-state); + --color-read: var(--read); --shadow-lift: 0 12px 32px -8px var(--lift), 0 2px 8px -2px var(--lift); } diff --git a/app/src/store/app.ts b/app/src/store/app.ts index eb2af0fe..eaf5ff27 100644 --- a/app/src/store/app.ts +++ b/app/src/store/app.ts @@ -21,6 +21,9 @@ import type { } from "../lib/protocol.gen"; import { viewTransition } from "../ui/motion"; +/** A panel beside a bot's chat (spec 15.1). */ +export type BotPanel = "details" | "files" | "browser" | "screens"; + export interface AppState { connection: ConnectionState; /** True once crews and bots arrived from the current connection. */ @@ -43,10 +46,16 @@ export interface AppState { browsers: Record; /** When the owner last had each bot open, for failed routine runs. */ seenAt: Record; + /** + * The panel the owner left beside each bot's chat (null: closed), so it is + * back when they come back to the bot. Kept until the app closes (spec 15.1). + */ + panels: Record; selectedCrewId: CrewId | null; selectedBotId: BotId | null; selectCrew(crewId: CrewId | null): void; selectBot(botId: BotId): void; + setPanel(botId: BotId, panel: BotPanel | null): void; /** Applies a record a call returned, before its notification arrives. */ putCrew(crew: Crew): void; putBot(bot: Bot): void; @@ -98,6 +107,7 @@ export function createAppStore(api: BotloftApi): AppStore { routines: {}, browsers: {}, seenAt: loadSeen(), + panels: {}, selectedCrewId: null, selectedBotId: null, selectCrew: (crewId) => { @@ -114,6 +124,7 @@ export function createAppStore(api: BotloftApi): AppStore { viewTransition(() => set({ selectedCrewId: bot.crewId, selectedBotId: botId, seenAt })); } }, + setPanel: (botId, panel) => set((state) => ({ panels: { ...state.panels, [botId]: panel } })), putCrew: (crew) => set((state) => withCrew(state, crew)), putBot: (bot) => set((state) => withBot(state, bot)), putRoutine: (routine) => set((state) => withRoutine(state, routine)), diff --git a/app/src/ui/panelMotion.ts b/app/src/ui/panelMotion.ts index 6ebd4ef8..4b59bf1e 100644 --- a/app/src/ui/panelMotion.ts +++ b/app/src/ui/panelMotion.ts @@ -1,7 +1,8 @@ // How a side panel slides (spec 15.1): open from the window's right edge, // or from the width of the panel it replaces, and back to the edge before -// it goes. The keyframes are in panels.css; reduced motion makes both -// instant, and a timer stands in when the animation never reports its end. +// it goes. One that comes back with its view does not slide. The keyframes +// are in panels.css; reduced motion makes both instant, and a timer stands +// in when the animation never reports its end. import { type AnimationEvent, @@ -22,6 +23,12 @@ const SWITCH_MS = 120; /** Given to a panel while it slides closed; `closed` lets it go. */ export const PanelClosing = createContext<{ closed(): void } | null>(null); +/** + * True for a panel that comes back with its view, as the owner left it: it + * is there at once, without sliding open. + */ +export const PanelRestored = createContext(false); + /** The panel that just went away without sliding: another replaces it. */ let replaced: { width: number; at: number } | null = null; @@ -30,7 +37,8 @@ export type Motion = "opening" | "open" | "closing"; export function usePanelMotion(panel: RefObject, width: number) { const exit = useContext(PanelClosing); const closing = exit !== null; - const [motion, setMotion] = useState("opening"); + const restored = useContext(PanelRestored); + const [motion, setMotion] = useState(restored ? "open" : "opening"); const [from, setFrom] = useState(0); const now = useRef({ motion, width, exit }); now.current = { motion, width, exit }; diff --git a/app/src/ui/sidepanel.test.tsx b/app/src/ui/sidepanel.test.tsx index 0d3d826c..b59e5dc3 100644 --- a/app/src/ui/sidepanel.test.tsx +++ b/app/src/ui/sidepanel.test.tsx @@ -1,6 +1,6 @@ import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { PanelClosing } from "./panelMotion"; +import { PanelClosing, PanelRestored } from "./panelMotion"; import { SidePanel } from "./SidePanel"; beforeEach(() => localStorage.clear()); @@ -73,6 +73,23 @@ describe("side panel motion", () => { expect(aside.className).not.toContain("panel-opening"); }); + test("one that comes back with its view is open at once, and still slides closed", () => { + const closed = vi.fn(); + const back = (closing: { closed(): void } | null) => ( + + {panel()} + + ); + const view = render(back(null)); + const aside = view.container.querySelector("aside") as HTMLElement; + expect(aside.className).not.toContain("panel-opening"); + expect(screen.getByRole("complementary", { name: "Details" })).toBeDefined(); + view.rerender(back({ closed })); + expect(aside.className).toContain("panel-closing"); + ends(aside, "panel-close"); + expect(closed).toHaveBeenCalled(); + }); + test("while closing it leaves the page to the rest, and goes when the slide ends", () => { const closed = vi.fn(); const { container } = render( diff --git a/app/vite.config.ts b/app/vite.config.ts index cfa8eb7d..2c3441a1 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -39,7 +39,8 @@ export default defineConfig(({ mode }) => ({ test: { environment: "jsdom", include: ["src/**/*.test.{ts,tsx}"], - // The mascot test reads the flame frames as text; other CSS stays empty. - css: { include: [/mascot-flame\.css/] }, + // The mascot test reads the flame frames as text, and the messages test + // the color tokens; other CSS stays empty. + css: { include: [/mascot-flame\.css/, /src\/index\.css/] }, }, })); diff --git a/docs/spec.md b/docs/spec.md index 1be68ac7..24cbe167 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -336,7 +336,7 @@ Itens do chat são dado pessoal como o corpo das messages: nunca vão para o log 4. Se o bot não está em `idle`/`busy`/`needs_approval`: volta para `pending` com `next_attempt_at` em 5 s, **sem** contar tentativa. Bot ou crew arquivados: a delivery vira `dead` na hora. 5. Monta a mensagem (9.2) na hora do envio e escreve uma linha no stdin do processo, com timeout de 10 s. 6. Escrita aceita: `sent`, com a generation do processo. Falha de escrita (processo saindo): volta para `pending` sem contar tentativa. -7. Quando o Claude Code começa o turno daquela mensagem, ele a devolve no stdout com o mesmo `uuid` (`--replay-user-messages`), e a delivery ganha `read_at`. O app mostra isso como "lida". +7. Quando o Claude Code começa o turno daquela mensagem, ele a devolve no stdout com o mesmo `uuid` (`--replay-user-messages`), e a delivery ganha `read_at`. O app mostra isso como "lida", com os dois tiques em azul-claro (15.3). 8. **O processo morreu antes de ler:** a delivery ainda está `sent` sem `read_at` e com a generation que acabou. Ela volta para `pending` com `attempts += 1` e o backoff de `retry_backoff_initial_ms * 2^(attempts-1)`, limitado a `retry_backoff_max_ms`. Ao chegar em `max_attempts` vira `dead`: uma mensagem que derruba o processo toda vez não fica em laço. 9. Lease vencido (daemon caiu no meio): volta a `pending` no boot e a cada ciclo. 10. `deliveries.retry` devolve uma delivery `dead` para `pending`, com as tentativas zeradas. @@ -633,7 +633,7 @@ Layout, como um app de mensagens: - **Chefe:** uma coroa ao lado do nome na barra lateral e nos cartões da crew, e "Chefe" no cabeçalho do bot, com a dica "Lidera : planeja o trabalho e sugere bots novos". O menu do bot tem "Tornar chefe da equipe" ou "Deixar de ser chefe". - **Sugestão de bot** (10.2): um cartão no chat do chefe, " sugere um bot novo", com o porquê e os campos editáveis Nome, Modelo, Função e Instruções, a linha de que o bot começa na hora e recebe o trabalho do chefe, um campo para dizer ao chefe por que não, e os botões Criar bot e Agora não. Respondido, vira uma linha ("Você criou Designer", "Você recusou Designer") que abre o que foi sugerido. - **Detalhes do bot** (pasta, instruções, sessão) ficam num painel, fora do caminho da conversa. Detalhes e arquivos são painéis laterais que o dono redimensiona arrastando a borda esquerda (ou com as setas do teclado, Shift anda mais; duplo clique volta ao padrão): 256 a 900 px, sempre deixando ao chat ao menos 22rem, e a largura de cada um fica no `localStorage` (`botloft.panel.details`, `botloft.panel.files`, `botloft.panel.browser`, `botloft.panel.screens`). O chat encolhe com eles e nunca passa por baixo nem por cima do painel. -- **Arquivos do bot** (8.4): um botão "Mostrar arquivos" no cabeçalho abre, à direita do chat, um painel com o que o bot fez, do mais novo ao mais antigo (ícone pelo tipo, nome, pasta, tamanho, "há 5 min"). Um clique mostra o arquivo no próprio painel: imagem, PDF, markdown e texto aparecem; o resto diz que não tem prévia. "Abrir" usa o programa que o Windows escolheu e "Mostrar na pasta" abre o Explorer com o arquivo marcado. A lista é lida ao abrir o bot, a cada 4 s enquanto ele trabalha e de novo quando ele para. Com o painel fechado, o botão ganha um contador dos arquivos que apareceram desde a última vez que o dono olhou; abertos, esses levam a etiqueta "Novo". No chat, a linha de uma ferramenta que mudou um arquivo (`Write`, `Edit`...; item `tool` com `file`, 8.2) e não falhou ganha o botão "Ver em arquivos", que abre o painel direto na prévia desse arquivo; se ele não existe mais, o painel diz. Detalhes, arquivos, o navegador (21.8) e as telas (22.5) dividem o mesmo lugar: abrir um fecha o outro. O painel abre deslizando da borda direita (460 ms, sem pressa no começo) e fecha voltando para ela (360 ms) antes de sair, com o conteúdo na largura final o tempo todo e o chat acompanhando; quem troca de painel começa da largura do anterior. Fechando, o painel já não recebe clique nem leitor de tela. Com movimento reduzido, abre e fecha de uma vez. O painel segue o que o bot começa a fazer, para o dono ver acontecer: o navegador abre quando o bot sobe o navegador ou pede uma mão (21.8), as telas quando ele começa a escrever uma (22.5), no lugar do painel que estiver aberto e uma vez por começo, a menos que o dono tenha desligado isso nas Configurações. Fechado pelo dono, fica fechado, com o ponto no botão. O navegador nas mãos do dono (21.10) nunca é trocado. +- **Arquivos do bot** (8.4): um botão "Mostrar arquivos" no cabeçalho abre, à direita do chat, um painel com o que o bot fez, do mais novo ao mais antigo (ícone pelo tipo, nome, pasta, tamanho, "há 5 min"). Um clique mostra o arquivo no próprio painel: imagem, PDF, markdown e texto aparecem; o resto diz que não tem prévia. "Abrir" usa o programa que o Windows escolheu e "Mostrar na pasta" abre o Explorer com o arquivo marcado. A lista é lida ao abrir o bot, a cada 4 s enquanto ele trabalha e de novo quando ele para. Com o painel fechado, o botão ganha um contador dos arquivos que apareceram desde a última vez que o dono olhou; abertos, esses levam a etiqueta "Novo". No chat, a linha de uma ferramenta que mudou um arquivo (`Write`, `Edit`...; item `tool` com `file`, 8.2) e não falhou ganha o botão "Ver em arquivos", que abre o painel direto na prévia desse arquivo; se ele não existe mais, o painel diz. Detalhes, arquivos, o navegador (21.8) e as telas (22.5) dividem o mesmo lugar: abrir um fecha o outro. O painel abre deslizando da borda direita (460 ms, sem pressa no começo) e fecha voltando para ela (360 ms) antes de sair, com o conteúdo na largura final o tempo todo e o chat acompanhando; quem troca de painel começa da largura do anterior. Fechando, o painel já não recebe clique nem leitor de tela. Com movimento reduzido, abre e fecha de uma vez. O painel segue o que o bot começa a fazer, para o dono ver acontecer: o navegador abre quando o bot sobe o navegador ou pede uma mão (21.8), as telas quando ele começa a escrever uma (22.5), no lugar do painel que estiver aberto e uma vez por começo, a menos que o dono tenha desligado isso nas Configurações. Fechado pelo dono, fica fechado, com o ponto no botão. O navegador nas mãos do dono (21.10) nunca é trocado. O painel volta com o bot: o app guarda, por bot e até fechar (no store, não no `localStorage`), o painel que ficou ao lado do chat, aberto pelo dono ou pelo bot, e trocar de bot ou ir à página da crew e voltar o mostra de novo, já aberto, sem deslizar. Um painel que o dono fechou volta fechado. Só o painel é guardado: o arquivo ou a tela em foco dentro dele e o navegador nas mãos do dono, que é devolvido ao trocar de bot (21.10), não. Voltar conta como abrir o bot: um navegador que o bot abriu nesse meio-tempo fica atrás do botão, e o que abre sozinho ao abrir um bot (um pedido de mão ainda sem resposta, uma tela que ele ainda escreve) e o que ele começa dali em diante entram no lugar do painel que voltou. ### 15.2 Comandos Tauri @@ -693,6 +693,7 @@ No chat: - O chat ocupa toda a largura que sobra, alinhado à esquerda, com o compositor na mesma largura (sem coluna centralizada, que deixava vazio dos dois lados); o dia é uma pílula no meio. - O dono fala em balões à direita; o bot, à esquerda, com markdown. +- Embaixo da mensagem do dono, e em cada linha da timeline da crew, fica onde ela está (9.1), com ícone e texto: esperando, entregando, "Entregue" com um tique e, quando o bot começou a trabalhar nela, "Lida" com dois tiques. Os dois tiques de "Lida" ficam em azul-claro, como nos apps de mensagem, para o dono ver de relance que o bot leu; a palavra continua na cor apagada das outras linhas, e é ela que diz o estado. A cor é o token `--read` (`#1787c9` no tema claro, `#5cc4f2` no escuro): mais clara que o `--work`, que é de bot trabalhando e de mensagem sendo entregue, e com contraste de ao menos 3:1 sobre `canvas`, `panel` e `sunken` nos dois temas, o que um teste confere. O azul dos apps de mensagem (`#53bdeb`) não passa de 2,2:1 no tema claro, por isso o claro usa um tom mais fechado. - Mensagens de outros bots aparecem à esquerda, com o avatar e o nome de quem mandou. - O que o bot faz com as ferramentas aparece em linhas compactas (ícone, ferramenta, resumo e estado), agrupadas por turno, que abrem para mostrar entrada e saída. - Pedido de aprovação é um cartão com o que o bot quer fazer e os botões Permitir e Negar.