From accb57b2d9b7226dd48ffd2abaa317cd2d87d4b8 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:23:25 -0300 Subject: [PATCH 1/4] feat(app): side panels slide open from the right edge The panel widens from the window's edge in 280 ms while its content, already at its full width, comes in with it, so nothing reflows and the chat narrows smoothly instead of jumping. The motion lives in its own file (panels.css); reduced motion makes it instant. --- app/src/index.css | 1 + app/src/panels.css | 16 ++++++++++++++++ app/src/ui/SidePanel.tsx | 37 +++++++++++++++++++++++++++++++++---- 3 files changed, 50 insertions(+), 4 deletions(-) create mode 100644 app/src/panels.css diff --git a/app/src/index.css b/app/src/index.css index 15f8c23f..65c06f55 100644 --- a/app/src/index.css +++ b/app/src/index.css @@ -7,6 +7,7 @@ @import "./motion.css"; @import "./mascot.css"; @import "./mascot-flame.css"; +@import "./panels.css"; /* Colors are tokens so the light and dark themes swap in one place (spec 15.3). State colors always go with an icon and a label. */ diff --git a/app/src/panels.css b/app/src/panels.css new file mode 100644 index 00000000..6f39cfb2 --- /dev/null +++ b/app/src/panels.css @@ -0,0 +1,16 @@ +/* Side panels slide open (spec 15.1): the panel widens from the window's + right edge while its content, already at its full width, comes in with + it, and the chat narrows smoothly instead of jumping. Reduced motion + makes it instant (motion.css). */ + +@keyframes panel-open { + from { + width: 0; + min-width: 0; + } +} + +.panel-opening { + overflow: hidden; + animation: panel-open 280ms var(--ease-out) both; +} diff --git a/app/src/ui/SidePanel.tsx b/app/src/ui/SidePanel.tsx index 6a6383a3..90e18716 100644 --- a/app/src/ui/SidePanel.tsx +++ b/app/src/ui/SidePanel.tsx @@ -1,8 +1,17 @@ // A panel beside the main area whose width the owner sets by dragging its // left edge (or with the arrow keys), remembered per panel. It never takes -// more than the main area can spare, so the chat keeps its room. +// more than the main area can spare, so the chat keeps its room. It slides +// open from the right edge (panels.css). -import { type KeyboardEvent, type PointerEvent, type ReactNode, useRef, useState } from "react"; +import { + type AnimationEvent, + type KeyboardEvent, + type PointerEvent, + type ReactNode, + useEffect, + useRef, + useState, +} from "react"; import { useT } from "../i18n"; const MIN_PX = 256; @@ -10,6 +19,8 @@ const MAX_PX = 900; const KEY_STEP_PX = 24; /** What the main area keeps whatever the panel's width, in rem. */ const KEEP_FOR_MAIN = "22rem"; +/** How long opening takes, with room for the animation to report its end. */ +const OPENING_MS = 400; function stored(name: string, fallback: number): number { try { @@ -50,6 +61,18 @@ export function SidePanel({ const [width, setWidth] = useState(() => stored(name, defaultWidth)); const panel = useRef(null); const drag = useRef<{ x: number; width: number; scale: number } | null>(null); + // While it slides open, the content keeps its full width and is cut at + // the moving edge, so it comes in without reflowing. + const [opening, setOpening] = useState(true); + useEffect(() => { + const done = setTimeout(() => setOpening(false), OPENING_MS); + return () => clearTimeout(done); + }, []); + const opened = (event: AnimationEvent) => { + if (event.target === event.currentTarget && event.animationName === "panel-open") { + setOpening(false); + } + }; const set = (next: number, keep = false) => { const clamped = clamp(next); @@ -106,7 +129,8 @@ export function SidePanel({ maxWidth: `calc(100% - ${KEEP_FOR_MAIN})`, minWidth: MIN_PX, }} - className="relative flex shrink-0 flex-col border-line border-l bg-panel" + className={`relative flex shrink-0 flex-col border-line border-l bg-panel ${opening ? "panel-opening" : ""}`} + onAnimationEnd={opened} > {/* biome-ignore lint/a11y/useSemanticElements: a separator that can be dragged and focused is a widget, not an
*/}
- {children} +
+ {children} +
); } From 251bd2dcad8bb622a4355456b74744ded039499a Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:23:25 -0300 Subject: [PATCH 2/4] feat(app): the panel beside the chat follows what the bot starts doing The browser opens by itself when the bot's browser starts or it asks for a hand, and the design area when it starts writing a screen, in place of whatever panel was open, once per start, so the owner sees it happen instead of a dot they may miss. A browser already open when the bot is opened stays behind its button, a panel the owner closes stays closed, and the browser in the owner's hands is never taken away. Spec 15.1, 21.8 and 22.5. --- app/src/features/bots/BotView.tsx | 31 ++++------- app/src/features/bots/useFollowBot.ts | 65 +++++++++++++++++++++++ app/src/features/browser/browser.test.tsx | 25 +++++++-- app/src/features/browser/hands.test.tsx | 3 ++ app/src/features/screens/screens.test.tsx | 32 ++++++++++- docs/spec.md | 6 +-- 6 files changed, 132 insertions(+), 30 deletions(-) create mode 100644 app/src/features/bots/useFollowBot.ts diff --git a/app/src/features/bots/BotView.tsx b/app/src/features/bots/BotView.tsx index 968c4de0..5b8de4b7 100644 --- a/app/src/features/bots/BotView.tsx +++ b/app/src/features/bots/BotView.tsx @@ -1,5 +1,5 @@ import { X } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { useShallow } from "zustand/react/shallow"; import { useT } from "../../i18n"; import type { Bot, Crew } from "../../lib/protocol.gen"; @@ -22,6 +22,7 @@ import { ShowScreen } from "../screens/showScreen"; import { useScreens } from "../screens/useScreens"; import { BotHeader } from "./BotHeader"; import { stateView } from "./BotStateBadge"; +import { useFollowBot } from "./useFollowBot"; type Pane = "chat" | "routines"; /** What the panel beside the chat shows. */ @@ -90,25 +91,8 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) { setScreenPath(path); void screens.refresh(); }; - // The design area opens by itself when the bot starts drawing a screen, - // once per screen, if nothing else is open beside the chat (spec 22.5). - const drawn = useRef(new Set()); - // biome-ignore lint/correctness/useExhaustiveDependencies: another bot starts with its own screens - useEffect(() => { - drawn.current = new Set(); - setScreenPath(null); - }, [bot.id]); - // biome-ignore lint/correctness/useExhaustiveDependencies: only a new screen being written opens it - useEffect(() => { - const path = screens.writing?.toLowerCase(); - if (path && !drawn.current.has(path)) { - drawn.current.add(path); - if (side === null) { - setSide("screens"); - setScreenPath(null); - } - } - }, [screens.writing]); + // biome-ignore lint/correctness/useExhaustiveDependencies: another bot's screen is not this bot's + useEffect(() => setScreenPath(null), [bot.id]); const showBrowser = (options?: { take?: boolean }) => { if (filesOpen) { seen(); @@ -118,6 +102,13 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) { setTakeBrowser((count) => count + 1); } }; + // The panel follows what the bot starts doing (spec 15.1). + useFollowBot({ + bot, + side, + writing: screens.writing, + open: (panel) => (panel === "browser" ? showBrowser() : showScreen(null)), + }); const view = stateView(bot, crew.paused, t); const tabs: Tab[] = [ { id: "chat", label: t.routines.chatTab }, diff --git a/app/src/features/bots/useFollowBot.ts b/app/src/features/bots/useFollowBot.ts new file mode 100644 index 00000000..0ae6a9b7 --- /dev/null +++ b/app/src/features/bots/useFollowBot.ts @@ -0,0 +1,65 @@ +// The panel beside the chat follows what the bot starts doing (spec 15.1), +// so the owner sees it happen: its browser starting or a request for a +// hand opens the browser (spec 21.8), and a screen it starts writing opens +// 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. + +import { useEffect, useRef } from "react"; +import type { Bot } from "../../lib/protocol.gen"; +import { useApp } from "../../store/context"; + +export type Followed = "browser" | "screens"; + +export function useFollowBot({ + bot, + side, + writing, + open, +}: { + bot: Pick; + /** The panel open now, if any. */ + side: string | null; + /** The screen the bot is writing now, if any. */ + writing: string | null; + open(panel: Followed): void; +}): void { + const browser = useApp((state) => state.browsers[bot.id]); + const browsing = browser?.status === "open" || browser?.status === "starting"; + const asking = Boolean(browser?.ask); + const held = browser?.control === "owner"; + // What was going on when the bot was opened: a browser left open stays + // behind its button, a request for a hand does not. + const before = useRef<{ browsing: boolean; asking: boolean } | null>(null); + const drawn = useRef(new Set()); + + // biome-ignore lint/correctness/useExhaustiveDependencies: another bot starts afresh + useEffect(() => { + before.current = null; + drawn.current = new Set(); + }, [bot.id]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: only a change in what the bot does opens it + useEffect(() => { + const was = before.current ?? { browsing, asking: false }; + before.current = { browsing, asking }; + if ((asking && !was.asking) || (browsing && !was.browsing)) { + if (side !== "browser") { + open("browser"); + } + } + }, [bot.id, browsing, asking]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: only a new screen being written opens it + useEffect(() => { + const path = writing?.toLowerCase(); + if (!path || drawn.current.has(path)) { + return; + } + drawn.current.add(path); + if (side === "screens" || (held && side === "browser")) { + return; + } + open("screens"); + }, [bot.id, writing]); +} diff --git a/app/src/features/browser/browser.test.tsx b/app/src/features/browser/browser.test.tsx index 86abd473..bf8501e6 100644 --- a/app/src/features/browser/browser.test.tsx +++ b/app/src/features/browser/browser.test.tsx @@ -68,18 +68,33 @@ describe("browser panel", () => { expect(within(panel()).queryByText("Opened elsewhere.com")).toBeNull(); }); - test("the button marks a bot that is browsing while the panel is closed", async () => { + test("opens by itself when the bot starts browsing, and the button marks it once closed", async () => { const { fake, scout } = crew(); await openScout(fake); + fireEvent.click(screen.getByRole("button", { name: "Show files" })); expect(toggle().getAttribute("aria-label")).toBe("Show browser"); act(() => { fake.browser.open(scout.id, "https://example.com/", "Example"); }); - await waitFor(() => - expect(toggle().getAttribute("aria-label")).toBe("Show browser: Scout is using the browser"), - ); + // It takes the place of the files, so the owner sees the bot browse. + expect(await screen.findByRole("complementary", { name: "Scout's browser" })).toBeDefined(); + expect(screen.queryByRole("complementary", { name: "Files from Scout" })).toBeNull(); + fireEvent.click(toggle()); - expect(toggle().getAttribute("aria-label")).toBe("Hide browser"); + expect(toggle().getAttribute("aria-label")).toBe("Show browser: Scout is using the browser"); + // Going on browsing does not open it again. + act(() => { + fake.browser.open(scout.id, "https://example.com/more", "More"); + }); + expect(screen.queryByRole("complementary", { name: "Scout's browser" })).toBeNull(); + }); + + test("a browser already open when the bot is opened stays behind its button", async () => { + const { fake, scout } = crew(); + fake.browser.open(scout.id, "https://example.com/", "Example"); + await openScout(fake); + expect(toggle().getAttribute("aria-label")).toBe("Show browser: Scout is using the browser"); + expect(screen.queryByRole("complementary", { name: "Scout's browser" })).toBeNull(); }); test("a closed browser keeps its last page, faded; one that failed says why", async () => { diff --git a/app/src/features/browser/hands.test.tsx b/app/src/features/browser/hands.test.tsx index b756ae1c..2d976515 100644 --- a/app/src/features/browser/hands.test.tsx +++ b/app/src/features/browser/hands.test.tsx @@ -101,6 +101,9 @@ describe("the owner's hands in the browser", () => { const { fake, scout } = crew(); fake.browser.ask(scout.id, "Sign in to your GitHub account"); await openScout(fake); + // The bot needs a hand: its browser opens by itself. + expect(await screen.findByRole("complementary", { name: "Scout's browser" })).toBeDefined(); + fireEvent.click(screen.getByRole("button", { name: "Hide browser" })); expect( screen.getByRole("button", { name: "Show browser: Scout needs you in the browser" }), ).toBeDefined(); diff --git a/app/src/features/screens/screens.test.tsx b/app/src/features/screens/screens.test.tsx index 3910e7b9..74a501cd 100644 --- a/app/src/features/screens/screens.test.tsx +++ b/app/src/features/screens/screens.test.tsx @@ -103,17 +103,45 @@ describe("design area", () => { expect(within(panel()).getByText("landing.html")).toBeDefined(); }); - test("with another panel open, the button marks the drawing instead", async () => { + test("a screen being written takes the place of another panel, once", async () => { const { fake, scout } = crew(); await openScout(fake); fireEvent.click(screen.getByRole("button", { name: "Show files" })); act(() => { fake.screens.draft(scout.id, "C:\\Work\\a.html", "

"); }); + expect(await within(panel()).findByRole("list", { name: "Screens" })).toBeDefined(); + expect(screen.queryByRole("complementary", { name: "Files from Scout" })).toBeNull(); + + // Closed, it stays closed while that screen is written; the button marks it. + fireEvent.click(toggle()); + act(() => { + fake.screens.draft(scout.id, "C:\\Work\\a.html", "

More"); + }); + await waitFor(() => + expect(toggle().getAttribute("aria-label")).toBe("Show screens: Scout is drawing a screen"), + ); + expect(screen.queryByRole("complementary", { name: "Scout's screens" })).toBeNull(); + }); + + test("the browser in the owner's hands stays when a screen starts", async () => { + const { fake, scout } = crew(); + fake.browser.open(scout.id, "https://example.com/", "Example"); + await openScout(fake); + fireEvent.click(screen.getByRole("button", { name: /^Show browser/ })); + await waitFor(() => expect(fake.browser.watching).toBe(scout.id)); + act(() => { + fake.browser.frame(scout.id, "AAAA"); + }); + fireEvent.click(await screen.findByRole("button", { name: "Take control" })); + expect(await screen.findByText("You are in control")).toBeDefined(); + act(() => { + fake.screens.draft(scout.id, "C:\\Work\\a.html", "

"); + }); await waitFor(() => expect(toggle().getAttribute("aria-label")).toBe("Show screens: Scout is drawing a screen"), ); - expect(screen.getByRole("complementary", { name: "Files from Scout" })).toBeDefined(); + expect(screen.getByRole("complementary", { name: "Scout's browser" })).toBeDefined(); }); test("a Write of an HTML file in the chat opens its screen", async () => { diff --git a/docs/spec.md b/docs/spec.md index 6516f9c4..c1307d54 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -622,7 +622,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. +- **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 (280 ms), com o conteúdo já na largura final e o chat encolhendo junto; com movimento reduzido, abre 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. Fechado pelo dono, fica fechado, com o ponto no botão. O navegador nas mãos do dono (21.10) nunca é trocado. ### 15.2 Comandos Tauri @@ -1027,7 +1027,7 @@ Esqueceu a senha? [e5 link "Recuperar acesso"] ### 21.8 App (N2) -- No cabeçalho do bot, o botão **Navegador** (globo), ao lado de Arquivos. Com quadros chegando, o título do painel ganha a etiqueta "Ao vivo". Com o navegador aberto e o painel fechado, o botão ganha um ponto que pulsa. +- No cabeçalho do bot, o botão **Navegador** (globo), ao lado de Arquivos. Com quadros chegando, o título do painel ganha a etiqueta "Ao vivo". Com o navegador aberto e o painel fechado, o botão ganha um ponto que pulsa. O painel abre sozinho quando o navegador do bot sobe e quando ele pede uma mão (21.10); um navegador que já estava aberto quando o dono abriu o bot fica atrás do botão (15.1). - O painel divide o lugar com detalhes e arquivos (15.1) e é redimensionável como eles (`botloft.panel.browser`, 560 px de início), com um botão para ocupar todo o espaço que o chat pode ceder. - Em cima, a barra do endereço: carregando ou não, o título e o endereço (só leitura, selecionável) e "Abrir no meu navegador" (`open_url`, 15.2). Embaixo, a tela ao vivo, na largura do painel, com o cursor do bot (a seta, na cor dele) indo até cada ponto de `browser.action`, um anel no clique e uma legenda curta do que ele fez ("Clicou em Entrar", "Escreveu em E-mail", "Abriu exemplo.com"). - Sem navegador aberto: o mascote e " ainda não abriu o navegador", com a explicação de que tudo que ele fizer num site aparece ali ao vivo. Depois de fechado, o último quadro fica apagado com "Navegador fechado". Em `failed`, o motivo em "Details". @@ -1135,7 +1135,7 @@ Uma área de design ao lado do chat: cada página HTML que o bot faz aparece com ### 22.5 App -- No cabeçalho do bot, o botão **Telas**, ao lado do Navegador. Com uma tela sendo escrita e o painel fechado, o botão ganha um ponto que pulsa; se nenhum painel está aberto, o de telas abre sozinho quando começa uma escrita, uma vez por tela. +- No cabeçalho do bot, o botão **Telas**, ao lado do Navegador. Com uma tela sendo escrita e o painel fechado, o botão ganha um ponto que pulsa; o painel de telas abre sozinho quando começa uma escrita, no lugar de outro painel aberto, menos o navegador nas mãos do dono, uma vez por tela (15.1). - O painel divide o lugar com detalhes, arquivos e navegador (15.1), redimensionável (`botloft.panel.screens`, 640 px de início) e com o botão de alargar. - **Visão geral:** um quadro com fundo pontilhado, como nos apps de design, com as telas lado a lado, cada uma com o nome em cima e o tamanho do aparelho, reduzidas pelo zoom do quadro (menos, mais e ajustar; `botloft.screens.zoom`). A que está sendo escrita ganha a borda na cor do bot, o mascote trabalhando e "Escrevendo…", e o quadro rola até ela. Cada versão nova carrega por trás e troca quando pronta, sem piscar. Nessa visão as telas não recebem clique: um clique abre a tela. - **Uma tela:** na largura do painel e de verdade (rolar, clicar, preencher), com Computador, Tablet e Celular (`botloft.screens.devices`, por arquivo), "Abrir" (`open_file`, 15.2, no navegador do Windows), "Mostrar na pasta" e a volta para todas. From b4aae914852594fa6f6a99ecd5d012a917d2a11f Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:23:26 -0300 Subject: [PATCH 3/4] test(app): room for the sixty-message timeline test It draws sixty messages twice: about 2 s alone, but past the default 5 s when the whole suite runs at once, which made it fail now and then. --- app/src/features/messages/messages.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/features/messages/messages.test.tsx b/app/src/features/messages/messages.test.tsx index 72ed6702..ff5b5ed0 100644 --- a/app/src/features/messages/messages.test.tsx +++ b/app/src/features/messages/messages.test.tsx @@ -64,7 +64,9 @@ describe("messages", () => { expect(await within(messages()).findByText("note 1")).toBeDefined(); expect(within(messages()).getAllByRole("listitem")).toHaveLength(60); expect(screen.queryByRole("button", { name: "Load older messages" })).toBeNull(); - }); + // Sixty messages drawn twice: about 2 s alone, past the default 5 s + // when the whole suite runs at once. + }, 15_000); test("a message that was not delivered can be retried from the title bar", async () => { const { fake, lead, writer } = crew(); From e52b00f14d9d549a1fd0fe28cfc32e4fed3c9beb Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:34:11 -0300 Subject: [PATCH 4/4] feat(app): side panels open unhurried and slide closed too Opening takes 460 ms on a curve that does not rush the start, as 280 ms front-loaded felt abrupt. Closing now runs the panel back to the edge in 360 ms before it goes: the bot view keeps the last panel on screen while it slides, out of reach of clicks and screen readers. A panel replacing another starts from that one's width, so a switch is one smooth move. The motion lives in panelMotion.ts and panels.css. --- app/src/features/bots/BotView.tsx | 62 +++++++++++++++--------- app/src/panels.css | 25 ++++++++-- app/src/ui/SidePanel.tsx | 47 +++++++++---------- app/src/ui/panelMotion.ts | 78 +++++++++++++++++++++++++++++++ app/src/ui/sidepanel.test.tsx | 56 +++++++++++++++++++++- docs/spec.md | 2 +- 6 files changed, 215 insertions(+), 55 deletions(-) create mode 100644 app/src/ui/panelMotion.ts diff --git a/app/src/features/bots/BotView.tsx b/app/src/features/bots/BotView.tsx index 5b8de4b7..acc7791c 100644 --- a/app/src/features/bots/BotView.tsx +++ b/app/src/features/bots/BotView.tsx @@ -1,5 +1,5 @@ import { X } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useShallow } from "zustand/react/shallow"; import { useT } from "../../i18n"; import type { Bot, Crew } from "../../lib/protocol.gen"; @@ -7,6 +7,7 @@ import { 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 { SidePanel } from "../../ui/SidePanel"; import { type Tab, Tabs, tabId } from "../../ui/Tabs"; import { BrowserPanel } from "../browser/BrowserPanel"; @@ -35,6 +36,19 @@ type Side = "details" | "files" | "browser" | "screens" | null; export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) { const t = useT(); const [side, setSide] = useState(null); + // The panel on screen: the one open, or the last one while it slides + // closed (spec 15.1). + const [leaving, setLeaving] = useState(null); + useEffect(() => { + if (side !== null) { + setLeaving(side); + } + }, [side]); + const beside = side ?? leaving; + const closing = useMemo( + () => (side === null && leaving !== null ? { closed: () => setLeaving(null) } : null), + [side, leaving], + ); const [pane, setPane] = useState("chat"); const files = useBotFiles(bot); // What the owner has seen: files newer than this are new to them. @@ -152,27 +166,31 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) { )} - {side === "details" &&

setSide(null)} />} - {browserOpen && setSide(null)} />} - {screensOpen && ( - setSide(null)} - /> - )} - {filesOpen && ( - - )} + + {beside === "details" &&
setSide(null)} />} + {beside === "browser" && ( + setSide(null)} /> + )} + {beside === "screens" && ( + setSide(null)} + /> + )} + {beside === "files" && ( + + )} + ); diff --git a/app/src/panels.css b/app/src/panels.css index 6f39cfb2..bfdfe473 100644 --- a/app/src/panels.css +++ b/app/src/panels.css @@ -1,16 +1,31 @@ -/* Side panels slide open (spec 15.1): the panel widens from the window's - right edge while its content, already at its full width, comes in with - it, and the chat narrows smoothly instead of jumping. Reduced motion - makes it instant (motion.css). */ +/* Side panels slide (spec 15.1, panelMotion.ts): the panel widens from the + window's right edge, or from the width of the panel it replaces, while + its content, already at its full width, comes in with it, and the chat + narrows smoothly instead of jumping. Closing runs it back to the edge. + Reduced motion makes both instant (motion.css). */ @keyframes panel-open { from { + width: var(--panel-from, 0px); + min-width: var(--panel-from, 0px); + } +} + +@keyframes panel-close { + to { width: 0; min-width: 0; + border-left-width: 0; } } .panel-opening { overflow: hidden; - animation: panel-open 280ms var(--ease-out) both; + animation: panel-open 460ms cubic-bezier(0.4, 0, 0.2, 1) both; +} + +.panel-closing { + overflow: hidden; + pointer-events: none; + animation: panel-close 360ms cubic-bezier(0.4, 0, 0.6, 1) both; } diff --git a/app/src/ui/SidePanel.tsx b/app/src/ui/SidePanel.tsx index 90e18716..dc8fc775 100644 --- a/app/src/ui/SidePanel.tsx +++ b/app/src/ui/SidePanel.tsx @@ -1,26 +1,24 @@ // A panel beside the main area whose width the owner sets by dragging its // left edge (or with the arrow keys), remembered per panel. It never takes // more than the main area can spare, so the chat keeps its room. It slides -// open from the right edge (panels.css). +// open and closed at the right edge (panelMotion.ts). import { - type AnimationEvent, + type CSSProperties, type KeyboardEvent, type PointerEvent, type ReactNode, - useEffect, useRef, useState, } from "react"; import { useT } from "../i18n"; +import { usePanelMotion } from "./panelMotion"; const MIN_PX = 256; const MAX_PX = 900; const KEY_STEP_PX = 24; /** What the main area keeps whatever the panel's width, in rem. */ const KEEP_FOR_MAIN = "22rem"; -/** How long opening takes, with room for the animation to report its end. */ -const OPENING_MS = 400; function stored(name: string, fallback: number): number { try { @@ -61,18 +59,10 @@ export function SidePanel({ const [width, setWidth] = useState(() => stored(name, defaultWidth)); const panel = useRef(null); const drag = useRef<{ x: number; width: number; scale: number } | null>(null); - // While it slides open, the content keeps its full width and is cut at - // the moving edge, so it comes in without reflowing. - const [opening, setOpening] = useState(true); - useEffect(() => { - const done = setTimeout(() => setOpening(false), OPENING_MS); - return () => clearTimeout(done); - }, []); - const opened = (event: AnimationEvent) => { - if (event.target === event.currentTarget && event.animationName === "panel-open") { - setOpening(false); - } - }; + const { motion, from, ended } = usePanelMotion(panel, width); + // While it slides, the content keeps its full width and is cut at the + // moving edge, so it comes and goes without reflowing. + const sliding = motion !== "open"; const set = (next: number, keep = false) => { const clamped = clamp(next); @@ -124,13 +114,20 @@ export function SidePanel({