From 34c9733e626dcfeeb0af22510f843db299f7b7e9 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:43:02 -0300 Subject: [PATCH] fix(app): side panels no longer cover the chat; resizable; chat fills the width The chat section could not shrink below its content, so an open panel sat under it. Details and Files now share a SidePanel the owner drags (or arrows) to resize, remembered per panel, and the chat is no longer a centered 48rem column. Spec 15.1, 15.3. --- app/src/features/bots/BotView.tsx | 8 +- app/src/features/chat/ChatView.tsx | 6 +- app/src/features/files/FilesPanel.tsx | 8 +- app/src/i18n/en/common.ts | 1 + app/src/i18n/es/common.ts | 1 + app/src/i18n/pt-BR/common.ts | 1 + app/src/ui/SidePanel.tsx | 126 ++++++++++++++++++++++++++ app/src/ui/sidepanel.test.tsx | 52 +++++++++++ docs/spec.md | 4 +- 9 files changed, 192 insertions(+), 15 deletions(-) create mode 100644 app/src/ui/SidePanel.tsx create mode 100644 app/src/ui/sidepanel.test.tsx diff --git a/app/src/features/bots/BotView.tsx b/app/src/features/bots/BotView.tsx index 85f81be1..175275ea 100644 --- a/app/src/features/bots/BotView.tsx +++ b/app/src/features/bots/BotView.tsx @@ -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 { SidePanel } from "../../ui/SidePanel"; import { type Tab, Tabs, tabId } from "../../ui/Tabs"; import { ChatView } from "../chat/ChatView"; import { FilesPanel } from "../files/FilesPanel"; @@ -149,10 +150,7 @@ function Notices({ function Details({ bot, onClose }: { bot: Bot; onClose(): void }) { const words = useT().bots.details; return ( - + ); } diff --git a/app/src/features/chat/ChatView.tsx b/app/src/features/chat/ChatView.tsx index 8e8754ff..6920731c 100644 --- a/app/src/features/chat/ChatView.tsx +++ b/app/src/features/chat/ChatView.tsx @@ -102,7 +102,7 @@ export function ChatView({ bot, stopped }: { bot: Bot; stopped: boolean }) { return (
{ if (hasFiles(event)) { event.preventDefault(); @@ -131,7 +131,7 @@ export function ChatView({ bot, stopped }: { bot: Bot; stopped: boolean }) { element.scrollHeight - element.scrollTop - element.clientHeight < STICKY_PX; }} > -
+
{chat.error && ( {chat.error} @@ -167,7 +167,7 @@ export function ChatView({ bot, stopped }: { bot: Bot; stopped: boolean }) {
-
+
file.path === path); return ( - + ); } diff --git a/app/src/i18n/en/common.ts b/app/src/i18n/en/common.ts index 61fa26e9..d0d7aaea 100644 --- a/app/src/i18n/en/common.ts +++ b/app/src/i18n/en/common.ts @@ -4,6 +4,7 @@ export const common = { cancel: "Cancel", close: "Close", dismiss: "Dismiss", + resize: "Resize panel", details: "Details", tryAgain: "Try again", errors: { diff --git a/app/src/i18n/es/common.ts b/app/src/i18n/es/common.ts index 477eddc5..8177855f 100644 --- a/app/src/i18n/es/common.ts +++ b/app/src/i18n/es/common.ts @@ -3,6 +3,7 @@ import type { Messages } from "../en"; export const common: Messages["common"] = { cancel: "Cancelar", close: "Cerrar", + resize: "Cambiar el tamaño del panel", dismiss: "Descartar", details: "Detalles", tryAgain: "Reintentar", diff --git a/app/src/i18n/pt-BR/common.ts b/app/src/i18n/pt-BR/common.ts index 98ef92d7..9b4ed887 100644 --- a/app/src/i18n/pt-BR/common.ts +++ b/app/src/i18n/pt-BR/common.ts @@ -3,6 +3,7 @@ import type { Messages } from "../en"; export const common: Messages["common"] = { cancel: "Cancelar", close: "Fechar", + resize: "Redimensionar painel", dismiss: "Dispensar", details: "Detalhes", tryAgain: "Tentar de novo", diff --git a/app/src/ui/SidePanel.tsx b/app/src/ui/SidePanel.tsx new file mode 100644 index 00000000..157a2be9 --- /dev/null +++ b/app/src/ui/SidePanel.tsx @@ -0,0 +1,126 @@ +// 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. + +import { type KeyboardEvent, type PointerEvent, type ReactNode, useRef, useState } from "react"; +import { useT } from "../i18n"; + +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"; + +function stored(name: string, fallback: number): number { + try { + const value = Number(localStorage.getItem(`botloft.panel.${name}`)); + return Number.isFinite(value) && value >= MIN_PX ? Math.min(value, MAX_PX) : fallback; + } catch { + return fallback; + } +} + +function remember(name: string, width: number): void { + try { + localStorage.setItem(`botloft.panel.${name}`, String(Math.round(width))); + } catch { + // A convenience: the width just is not kept. + } +} + +const clamp = (width: number) => Math.min(MAX_PX, Math.max(MIN_PX, width)); + +export function SidePanel({ + label, + name, + defaultWidth, + children, +}: { + label: string; + /** Which panel this is, for remembering its width. */ + name: string; + /** Width in px until the owner drags it. */ + defaultWidth: number; + children: ReactNode; +}) { + const t = useT(); + const [width, setWidth] = useState(() => stored(name, defaultWidth)); + const panel = useRef(null); + const drag = useRef<{ x: number; width: number; scale: number } | null>(null); + + const set = (next: number, keep = false) => { + const clamped = clamp(next); + setWidth(clamped); + if (keep) { + remember(name, clamped); + } + }; + + const start = (event: PointerEvent) => { + const element = panel.current; + if (!element) { + return; + } + event.currentTarget.setPointerCapture(event.pointerId); + // The window may be drawn larger than its layout units (zoom). + const scale = element.offsetWidth + ? element.getBoundingClientRect().width / element.offsetWidth + : 1; + drag.current = { x: event.clientX, width: element.offsetWidth, scale: scale || 1 }; + }; + const move = (event: PointerEvent) => { + const from = drag.current; + if (from) { + // The panel is on the right: dragging left makes it wider. + set(from.width + (from.x - event.clientX) / from.scale); + } + }; + const end = () => { + if (drag.current) { + drag.current = null; + remember(name, panel.current?.offsetWidth || width); + } + }; + const onKey = (event: KeyboardEvent) => { + const step = event.shiftKey ? KEY_STEP_PX * 4 : KEY_STEP_PX; + const current = panel.current?.offsetWidth || width; + if (event.key === "ArrowLeft") { + set(current + step, true); + } else if (event.key === "ArrowRight") { + set(current - step, true); + } else { + return; + } + event.preventDefault(); + }; + + return ( + + ); +} diff --git a/app/src/ui/sidepanel.test.tsx b/app/src/ui/sidepanel.test.tsx new file mode 100644 index 00000000..1010f114 --- /dev/null +++ b/app/src/ui/sidepanel.test.tsx @@ -0,0 +1,52 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { SidePanel } from "./SidePanel"; + +beforeEach(() => localStorage.clear()); +afterEach(cleanup); + +const panel = () => ( + + content + +); +const handle = () => screen.getByRole("separator", { name: "Resize panel" }); +const width = () => Number(handle().getAttribute("aria-valuenow")); + +describe("side panel", () => { + test("arrow keys resize it, Shift steps further, and the width is kept", () => { + const { unmount } = render(panel()); + expect(width()).toBe(320); + fireEvent.keyDown(handle(), { key: "ArrowLeft" }); + expect(width()).toBe(344); + fireEvent.keyDown(handle(), { key: "ArrowLeft", shiftKey: true }); + expect(width()).toBe(440); + fireEvent.keyDown(handle(), { key: "ArrowRight" }); + expect(width()).toBe(416); + expect(localStorage.getItem("botloft.panel.test")).toBe("416"); + + unmount(); + render(panel()); + expect(width()).toBe(416); + }); + + test("it stays between its smallest and largest width, and double click resets it", () => { + render(panel()); + for (let press = 0; press < 30; press += 1) { + fireEvent.keyDown(handle(), { key: "ArrowRight", shiftKey: true }); + } + expect(width()).toBe(256); + for (let press = 0; press < 30; press += 1) { + fireEvent.keyDown(handle(), { key: "ArrowLeft", shiftKey: true }); + } + expect(width()).toBe(900); + fireEvent.doubleClick(handle()); + expect(width()).toBe(320); + }); + + test("a saved width that makes no sense is ignored", () => { + localStorage.setItem("botloft.panel.test", "12"); + render(panel()); + expect(width()).toBe(320); + }); +}); diff --git a/docs/spec.md b/docs/spec.md index f29e6815..868d0756 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -607,7 +607,7 @@ Layout, como um app de mensagens: - **Nova crew:** nome; "Para que é esta equipe?", o objetivo que vira as instruções do chefe, com a explicação de que a equipe começa com um Chefe que planeja o trabalho e sugere os bots de que precisa; "Pasta de trabalho", com "Escolher pasta…" (o seletor do Windows; sem escolha, "Uma pasta nova dentro do Botloft", a `shared\`); e "Modelo do Chefe". Criada, o app abre o chat do chefe. - **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 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`). 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 e arquivos dividem o mesmo lugar: abrir um fecha o outro. ### 15.2 Comandos Tauri @@ -658,7 +658,7 @@ Movimento: o app se mexe para parecer vivo, sem atrapalhar o trabalho. Animaçõ No chat: -- O chat é uma coluna centralizada (48rem), com o compositor na mesma largura; o dia é uma pílula no meio. +- 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. - 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.