@@ -58,7 +56,7 @@ export function FilesPanel({
) : (
)}
-
+
);
}
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.