Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 3 additions & 5 deletions app/src/features/bots/BotView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -149,10 +150,7 @@ function Notices({
function Details({ bot, onClose }: { bot: Bot; onClose(): void }) {
const words = useT().bots.details;
return (
<aside
aria-label={words.title(bot.name)}
className="flex w-80 shrink-0 flex-col border-line border-l bg-panel"
>
<SidePanel label={words.title(bot.name)} name="details" defaultWidth={320}>
<header className="flex h-11 shrink-0 items-center justify-between border-line border-b pr-1.5 pl-4">
<h2 className="font-semibold text-sm">{words.title(bot.name)}</h2>
<Button variant="ghost" size="sm" icon={X} label={words.close} onClick={onClose} />
Expand Down Expand Up @@ -181,6 +179,6 @@ function Details({ bot, onClose }: { bot: Bot; onClose(): void }) {
</dd>
</div>
</dl>
</aside>
</SidePanel>
);
}
6 changes: 3 additions & 3 deletions app/src/features/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export function ChatView({ bot, stopped }: { bot: Bot; stopped: boolean }) {
return (
<section
aria-label={t.chat.view.label(bot.name)}
className="relative flex min-h-0 flex-1 flex-col"
className="relative flex min-h-0 min-w-0 flex-1 flex-col"
onDragOver={(event) => {
if (hasFiles(event)) {
event.preventDefault();
Expand Down Expand Up @@ -131,7 +131,7 @@ export function ChatView({ bot, stopped }: { bot: Bot; stopped: boolean }) {
element.scrollHeight - element.scrollTop - element.clientHeight < STICKY_PX;
}}
>
<div className="mx-auto flex max-w-3xl flex-col px-5 pt-5 pb-3">
<div className="flex flex-col px-5 pt-5 pb-3">
{chat.error && (
<Callout tone="danger" title={t.chat.view.loadFailed}>
{chat.error}
Expand Down Expand Up @@ -167,7 +167,7 @@ export function ChatView({ bot, stopped }: { bot: Bot; stopped: boolean }) {
</SeenSince.Provider>
</div>
</div>
<div className="mx-auto w-full max-w-3xl">
<div className="w-full">
<ChatComposer
bot={bot}
files={files}
Expand Down
8 changes: 3 additions & 5 deletions app/src/features/files/FilesPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { fileSize, fromNow } from "../../lib/format";
import type { Bot, BotFile } from "../../lib/protocol.gen";
import { Button } from "../../ui/Button";
import { Callout } from "../../ui/Callout";
import { SidePanel } from "../../ui/SidePanel";
import { BotAvatar } from "../bots/BotAvatar";
import { FilePreview } from "./FilePreview";
import { fileIcon } from "./kinds";
Expand All @@ -34,10 +35,7 @@ export function FilesPanel({
const chosen = data.files.find((file) => file.path === path);

return (
<aside
aria-label={t.panel(bot.name)}
className="flex w-[26rem] shrink-0 flex-col border-line border-l bg-panel"
>
<SidePanel label={t.panel(bot.name)} name="files" defaultWidth={416}>
<header className="flex h-11 shrink-0 items-center justify-between border-line border-b pr-1.5 pl-4">
<h2 className="font-semibold text-sm">{t.heading}</h2>
<div className="flex items-center gap-0.5">
Expand All @@ -58,7 +56,7 @@ export function FilesPanel({
) : (
<List bot={bot} data={data} since={since} onOpen={onPath} />
)}
</aside>
</SidePanel>
);
}

Expand Down
1 change: 1 addition & 0 deletions app/src/i18n/en/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export const common = {
cancel: "Cancel",
close: "Close",
dismiss: "Dismiss",
resize: "Resize panel",
details: "Details",
tryAgain: "Try again",
errors: {
Expand Down
1 change: 1 addition & 0 deletions app/src/i18n/es/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions app/src/i18n/pt-BR/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
126 changes: 126 additions & 0 deletions app/src/ui/SidePanel.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>(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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 (
<aside
ref={panel}
aria-label={label}
style={{ width, maxWidth: `calc(100% - ${KEEP_FOR_MAIN})`, minWidth: MIN_PX }}
className="relative flex shrink-0 flex-col border-line border-l bg-panel"
>
{/* biome-ignore lint/a11y/useSemanticElements: a separator that can be dragged and focused is a widget, not an <hr> */}
<div
role="separator"
aria-orientation="vertical"
aria-label={t.common.resize}
aria-valuemin={MIN_PX}
aria-valuemax={MAX_PX}
aria-valuenow={Math.round(width)}
tabIndex={0}
onPointerDown={start}
onPointerMove={move}
onPointerUp={end}
onPointerCancel={end}
onDoubleClick={() => set(defaultWidth, true)}
onKeyDown={onKey}
className="group absolute inset-y-0 -left-1 z-10 w-2 cursor-col-resize touch-none focus-visible:outline-none"
>
<span className="absolute inset-y-0 left-[3px] w-0.5 bg-transparent transition-colors group-hover:bg-line-strong group-focus-visible:bg-work group-active:bg-work" />
</div>
{children}
</aside>
);
}
52 changes: 52 additions & 0 deletions app/src/ui/sidepanel.test.tsx
Original file line number Diff line number Diff line change
@@ -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 = () => (
<SidePanel label="Details" name="test" defaultWidth={320}>
content
</SidePanel>
);
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);
});
});
4 changes: 2 additions & 2 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <crew>: 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, "<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
Expand Down Expand Up @@ -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.
Expand Down
Loading