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.