From 7a79e3b9ff93f37fac39982acd4c51d39e6dad19 Mon Sep 17 00:00:00 2001
From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com>
Date: Wed, 30 Sep 2026 10:23:58 -0300
Subject: [PATCH 1/2] feat(messages): show read marks in light blue
A message the bot has read showed its two check marks in the same dim
color as "Delivered", so the two were hard to tell apart at a glance.
The marks now take a new `--read` token, a lighter blue than `--work`
(which already means a bot at work and a message being delivered). The
word "Read" keeps the muted color of the other delivery lines: it is the
word that states the state, the color only helps (spec 15.3).
The blue chat apps use is too faint on the light theme (about 2:1), so
the light theme gets a deeper tone. A test reads the tokens from
index.css and checks 3:1 against canvas, panel and sunken in both
themes.
---
app/src/features/messages/DeliveryStatus.tsx | 3 +-
app/src/features/messages/messages.test.tsx | 71 ++++++++++++++++++++
app/src/index.css | 5 ++
app/vite.config.ts | 5 +-
docs/spec.md | 3 +-
5 files changed, 83 insertions(+), 4 deletions(-)
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/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..aba1182d 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.
@@ -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.
From 429b5f95835c9e2a670b2ab5048099e6511784a2 Mon Sep 17 00:00:00 2001
From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com>
Date: Wed, 30 Sep 2026 10:23:59 -0300
Subject: [PATCH 2/2] feat(bots): bring the side panel back with the bot
The panel beside a bot's chat was local state of the bot's view, so
switching to another bot and back lost it, and the owner had to open
the browser again every time. The open panel is now kept per bot in the
app store until the app closes, closed included: a panel the owner
closed comes back closed.
The panel that comes back is there at once, without sliding open
(`PanelRestored`). Following the bot is unchanged and treats coming back
as opening the bot: a browser the bot opened meanwhile stays behind its
button, and what opens by itself on opening a bot, or starts after it,
takes the place of the panel that came back. Spec 15.1 says so.
---
app/src/features/bots/BotView.tsx | 73 +++++++++-------
app/src/features/bots/panels.test.tsx | 117 ++++++++++++++++++++++++++
app/src/features/bots/useFollowBot.ts | 4 +-
app/src/store/app.ts | 11 +++
app/src/ui/panelMotion.ts | 14 ++-
app/src/ui/sidepanel.test.tsx | 19 ++++-
docs/spec.md | 2 +-
7 files changed, 204 insertions(+), 36 deletions(-)
create mode 100644 app/src/features/bots/panels.test.tsx
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/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/docs/spec.md b/docs/spec.md
index aba1182d..24cbe167 100644
--- a/docs/spec.md
+++ b/docs/spec.md
@@ -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