From ac76a827e0049f17f8932a71b8afae71677bcbcb Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:31:57 -0300 Subject: [PATCH 1/3] perf(app): slide the side panels and the bots list with transforms Both animated their width, so the chat was laid out again on every frame. With a long chat that took 4-7 ms a frame, over the 5.5 ms a 180 Hz screen gives, and frames came unevenly. Now the room changes at once and only a transform moves: the list and the panels slide over the chat, at 180 fps and about 1.8 ms a frame on the same chat. Spec 15.1 said the chat narrowed along with the panel; it now says the chat takes its new width at once. --- app/src/features/bots/BotView.tsx | 3 ++- app/src/panels.css | 25 +++++++++++++------------ app/src/shell/sidebarToggle.tsx | 9 ++------- app/src/sidebar.css | 28 ++++++++++++++++++++++++---- app/src/ui/SidePanel.tsx | 10 +--------- docs/spec.md | 4 ++-- 6 files changed, 44 insertions(+), 35 deletions(-) diff --git a/app/src/features/bots/BotView.tsx b/app/src/features/bots/BotView.tsx index feb305a5..382a76f8 100644 --- a/app/src/features/bots/BotView.tsx +++ b/app/src/features/bots/BotView.tsx @@ -181,7 +181,8 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) { /> label={bot.name} tabs={tabs} value={pane} onChange={setPane} /> -
+ {/* A panel slides out over the chat, and is cut at the window's edge. */} +
{pane === "chat" ? ( diff --git a/app/src/panels.css b/app/src/panels.css index bfdfe473..84342bdb 100644 --- a/app/src/panels.css +++ b/app/src/panels.css @@ -1,31 +1,32 @@ -/* 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). */ +/* Side panels slide (spec 15.1, panelMotion.ts): the chat takes its new + width at once, and the panel, already at its full width, comes in from + the window's right edge, or from the width of the panel it replaces. + Closing, it leaves the chat its room at once and slides back out over + it. Only a transform moves, so the chat is laid out once, not on every + frame. Reduced motion makes both instant (motion.css). */ @keyframes panel-open { from { - width: var(--panel-from, 0px); - min-width: var(--panel-from, 0px); + transform: translateX(max(0px, calc(100% - var(--panel-from, 0px)))); } } @keyframes panel-close { to { - width: 0; - min-width: 0; - border-left-width: 0; + transform: translateX(100%); } } .panel-opening { - overflow: hidden; animation: panel-open 460ms cubic-bezier(0.4, 0, 0.2, 1) both; } +/* Out of the flow, on the right edge of the chat and its panel. */ .panel-closing { - overflow: hidden; + position: absolute; + inset-block: 0; + right: 0; + z-index: 20; pointer-events: none; animation: panel-close 360ms cubic-bezier(0.4, 0, 0.6, 1) both; } diff --git a/app/src/shell/sidebarToggle.tsx b/app/src/shell/sidebarToggle.tsx index 1db82bd3..92d0625a 100644 --- a/app/src/shell/sidebarToggle.tsx +++ b/app/src/shell/sidebarToggle.tsx @@ -49,13 +49,8 @@ export function SidebarSlot({ children }: { children: ReactNode }) { const open = usePref(prefs.sidebar); useSidebarKey(); return ( -
- {children} +
+
{children}
); } diff --git a/app/src/sidebar.css b/app/src/sidebar.css index 08f0c168..656624f5 100644 --- a/app/src/sidebar.css +++ b/app/src/sidebar.css @@ -1,12 +1,32 @@ -/* Hiding the crews and bots (spec 15.1, sidebarToggle.tsx): the list keeps - its width and slides out under the left edge while the chat widens. - Reduced motion makes it instant (motion.css). */ +/* Hiding the crews and bots (spec 15.1, sidebarToggle.tsx): the room for + the list changes at once, and the list slides out under the left edge + (or back in) above the chat. Only a transform moves, so the chat is laid + out once, not on every frame. Reduced motion makes it instant + (motion.css). */ .sidebar-slot { + position: relative; width: 18rem; - transition: width 320ms cubic-bezier(0.4, 0, 0.2, 1); } .sidebar-slot[data-open="false"] { width: 0; } + +.sidebar-slide { + position: absolute; + inset-block: 0; + left: 0; + z-index: 20; + display: flex; + transition: transform 320ms cubic-bezier(0.4, 0, 0.2, 1); +} + +/* Hidden once it is out of sight, so it is not drawn off screen. */ +.sidebar-slot[data-open="false"] > .sidebar-slide { + transform: translateX(-100%); + visibility: hidden; + transition: + transform 320ms cubic-bezier(0.4, 0, 0.2, 1), + visibility 0s 320ms; +} diff --git a/app/src/ui/SidePanel.tsx b/app/src/ui/SidePanel.tsx index dc8fc775..51990430 100644 --- a/app/src/ui/SidePanel.tsx +++ b/app/src/ui/SidePanel.tsx @@ -60,9 +60,6 @@ export function SidePanel({ const panel = useRef(null); const drag = useRef<{ x: number; width: number; scale: number } | null>(null); 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); @@ -149,12 +146,7 @@ export function SidePanel({ >
-
- {children} -
+
{children}
); } diff --git a/docs/spec.md b/docs/spec.md index c18a355d..23d57085 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -797,7 +797,7 @@ O chat e as mensagens de uma equipe guardam todas as páginas já carregadas. As Layout, como um app de mensagens: -- **Barra lateral:** crews como seções, com os bots como conversas. Cada conversa mostra avatar, nome, estado (cor, ícone e texto) e a prévia da última atividade (`lastActivity`) com a hora. Um bot que respondeu desde a última vez que o dono olhou o chat dele fica marcado como não lido, como num app de mensagens: nome em negrito, prévia e hora em destaque e um ponto na cor de destaque no fim da linha, até o dono abrir o chat. Vale toda resposta (`lastReplyAt`, e cada item `reply` que chega), mesmo com outra coisa depois dela na conversa. Com o chat aberto e a janela à vista, o que chega já conta como visto; com a janela escondida, conta quando ela volta. O app guarda no `localStorage` quando o dono olhou cada bot (`botloft.seen`) e, na primeira vez, a hora de começar a marcar (`botloft.seenSince`): respostas de antes contam como vistas, para uma atualização não marcar todos os bots de uma vez. O dono também marca um bot como não lido pelo menu dele ("Marcar como não lida"; num bot marcado, "Marcar como lida"), para lembrar de voltar à conversa: a marca fica no `localStorage` (`botloft.unread`) até ele abrir o chat, e marcar o chat aberto o fecha e volta à página da crew. Ao lado do nome de cada crew, um número na cor de destaque diz quantos bots dela estão não lidos. Ela aparece desde a conexão, também na tela de boas-vindas antes da primeira crew. Um clique direito numa conversa (ou a tecla de menu do teclado) abre o menu do bot onde o ponteiro está, sem abrir o bot: são as mesmas ações do menu "mais" do cabeçalho (editar, tornar chefe ou deixar de ser, reiniciar com uma conversa nova, abrir a pasta, marcar como não lida ou lida, arquivar, excluir), vindas do mesmo lugar no código (`useBotActions`), para os dois nunca divergirem. O menu fica inteiro dentro da janela, anda com as setas e fecha com Esc, com um clique fora ou quando a lista rola. Um botão na barra de título, ao lado do nome do Botloft, esconde a barra lateral (ou Ctrl+B), para numa tela pequena sobrarem só o chat e o painel do bot: ela desliza para a esquerda e o resto se alarga. A escolha fica no `localStorage` (`botloft.sidebar`). Escondida, um ponto no botão avisa quando algo espera o dono (o mesmo que marca o ícone na barra de tarefas, 15.2) ou, parado e na cor de destaque, quando algum bot tem resposta não lida. Na tela de boas-vindas, antes da primeira crew, ela não se esconde. Uma seta ao lado do nome de cada crew a recolhe: ficam só o nome, o número de não lidos e, se um bot dela espera o dono, um ponto na cor de aviso; a conversa aberta continua à vista. Um botão no topo recolhe ou expande todas. As crews recolhidas ficam no `localStorage` (`botloft.collapsedCrews`). +- **Barra lateral:** crews como seções, com os bots como conversas. Cada conversa mostra avatar, nome, estado (cor, ícone e texto) e a prévia da última atividade (`lastActivity`) com a hora. Um bot que respondeu desde a última vez que o dono olhou o chat dele fica marcado como não lido, como num app de mensagens: nome em negrito, prévia e hora em destaque e um ponto na cor de destaque no fim da linha, até o dono abrir o chat. Vale toda resposta (`lastReplyAt`, e cada item `reply` que chega), mesmo com outra coisa depois dela na conversa. Com o chat aberto e a janela à vista, o que chega já conta como visto; com a janela escondida, conta quando ela volta. O app guarda no `localStorage` quando o dono olhou cada bot (`botloft.seen`) e, na primeira vez, a hora de começar a marcar (`botloft.seenSince`): respostas de antes contam como vistas, para uma atualização não marcar todos os bots de uma vez. O dono também marca um bot como não lido pelo menu dele ("Marcar como não lida"; num bot marcado, "Marcar como lida"), para lembrar de voltar à conversa: a marca fica no `localStorage` (`botloft.unread`) até ele abrir o chat, e marcar o chat aberto o fecha e volta à página da crew. Ao lado do nome de cada crew, um número na cor de destaque diz quantos bots dela estão não lidos. Ela aparece desde a conexão, também na tela de boas-vindas antes da primeira crew. Um clique direito numa conversa (ou a tecla de menu do teclado) abre o menu do bot onde o ponteiro está, sem abrir o bot: são as mesmas ações do menu "mais" do cabeçalho (editar, tornar chefe ou deixar de ser, reiniciar com uma conversa nova, abrir a pasta, marcar como não lida ou lida, arquivar, excluir), vindas do mesmo lugar no código (`useBotActions`), para os dois nunca divergirem. O menu fica inteiro dentro da janela, anda com as setas e fecha com Esc, com um clique fora ou quando a lista rola. Um botão na barra de título, ao lado do nome do Botloft, esconde a barra lateral (ou Ctrl+B), para numa tela pequena sobrarem só o chat e o painel do bot: o resto se alarga de uma vez e ela desliza para a esquerda por cima dele (só `transform`, para a conversa não ser refeita a cada quadro). A escolha fica no `localStorage` (`botloft.sidebar`). Escondida, um ponto no botão avisa quando algo espera o dono (o mesmo que marca o ícone na barra de tarefas, 15.2) ou, parado e na cor de destaque, quando algum bot tem resposta não lida. Na tela de boas-vindas, antes da primeira crew, ela não se esconde. Uma seta ao lado do nome de cada crew a recolhe: ficam só o nome, o número de não lidos e, se um bot dela espera o dono, um ponto na cor de aviso; a conversa aberta continua à vista. Um botão no topo recolhe ou expande todas. As crews recolhidas ficam no `localStorage` (`botloft.collapsedCrews`). - **Página das crews:** clicar em "Equipes", no topo da barra lateral, abre no meio da janela todas as crews como cartões: nome, número de bots, os rostos dos primeiros seis (parados, para a página não redesenhar cada chama), quantos bots trabalham e quantos esperam o dono, ou "Pausada". Um cartão abre a crew. É também o que aparece quando nenhuma crew está escolhida (a crew aberta foi arquivada ou excluída), e ela continua aberta depois de uma reconexão; só a primeira carga abre a primeira crew. - **Área da conta**, no pé da barra lateral, como nos apps de chat: um círculo com a inicial, o nome do dono (do Windows) e o plano do Claude ("Plano Max"; sem plano, a organização ou o e-mail; desconectado, "Sem conta do Claude conectada"). Um clique abre um menu para cima com o e-mail e: **Uso** (as janelas de uso do plano, 8.1, com a parte usada e quando renovam; antes da primeira resposta de um bot, um aviso de que ainda não há dados), **Configurações** (abaixo), **Idioma** (submenu ao lado, com a escolha na hora), **Novidades** (a página de releases no navegador) e **Ajuda** (o README no navegador). Tema, tamanho e idioma saíram da barra de título; ela só mostra o idioma nas telas de preparo, que ainda não têm barra lateral. - **Configurações**, um diálogo com as partes listadas à esquerda (abas verticais): **Geral**, **Conversa**, **Avisos**, **Aparência**, **Arquivados** e **Sobre**. As escolhas que são só do app ficam no `localStorage` (`botloft.whenClosed`, `botloft.tray`, `botloft.openAtSignIn`, `botloft.notifyNeeds`, `botloft.notifyDone`, `botloft.markReplies`, `botloft.sound`, `botloft.appSounds`, `botloft.enterSends`, `botloft.followBot`, `botloft.lessMotion`), como o tema. Em Geral, primeiro o **Idioma**, numa lista suspensa (um botão com a escolha que abre a lista embaixo, no visual dos menus, com as setas, Enter e Esc, que fecha só a lista); depois **Em segundo plano**, três chaves, cada uma com uma linha que diz o que a escolha atual faz: "Continuar trabalhando depois de fechar o Botloft" (ligada, os bots seguem depois que a janela fecha; desligada, fechar a janela a esconde, chama `daemon_stop` e fecha, e os bots continuam de onde pararam quando o app abre de novo, com "Iniciando seus bots…" no lugar de "Preparando o Botloft…"; a escolha é do app), "Mostrar o Botloft perto do relógio" (só com a anterior ligada; abaixo), "Iniciar com o Windows" (`startWithWindows`, 14), "Abrir a janela ao entrar no Windows" (só com a anterior ligada; desligada, o Botloft começa perto do relógio, ou só abre quando o dono abrir) e "Não deixar o computador dormir enquanto os bots trabalham" (`keepAwake`, com a linha de que a tampa e o Suspender ainda põem o computador para dormir). As duas do daemon vêm de `settings.get` ao abrir, mudam na hora na tela e voltam atrás, com um aviso, se `settings.update` falhar. Em Conversa, "Enter envia a mensagem" (desligada, Enter quebra a linha e Ctrl+Enter envia, e a dica do compositor diz isso) e "Abrir o navegador e as telas quando um bot começar a usar" (desligada, só o ponto no botão avisa), e quanto tempo um pedido de permissão espera pelo dono (15 min, 30 min, 1 h, 2 h, 4 h ou 8 h; um valor posto à mão no `config.toml` entra na lista), com a linha de que sem resposta o pedido é negado (10.1). Em Avisos, "Quando um bot precisar de você" (ligada), "Quando um bot terminar" (desligada), "Marcar o ícone quando um bot responder" (ligada, `botloft.markReplies`: a marca do ícone na barra de tarefas, 15.2, aparece também enquanto algum bot está não lido; só no Windows, onde essa marca existe) e "Tocar um som" (ligada), com a linha de que, com a janela fechada, os avisos só chegam com o Botloft perto do relógio quando ele não está. Em Aparência, tema, tamanho e "Menos animações", que marca a raiz com `data-motion="less"` e para tudo como o Windows faria (15.3). Em **Arquivados**, uma linha que explica que os bots e as equipes arquivados estão parados e fora da vista, mas o Botloft ainda guarda as conversas deles, e a lista, lida de `archive.list` ao abrir a parte: primeiro as equipes ("Equipe · 2 bots · arquivada há 3 dias"), depois os bots arquivados de equipes ativas ("Bot de Ops · arquivado há 3 dias"); um bot de uma equipe arquivada sai junto com ela e não aparece sozinho. Cada linha tem um botão Excluir, que abre a mesma confirmação dos menus (7.6), dizendo que o bot ou a equipe "sai do Botloft de vez" em vez de "para agora", porque já estava parado. Sem nada arquivado, a parte diz isso. Não há como desarquivar. Em **Sobre**: as versões do Botloft e do Claude Code, o e-mail e "Procurar atualizações", que consulta o feed na hora (15.5) e diz que esta é a versão mais recente ou mostra "Ver atualização", que abre o diálogo de atualizar. A linha do Botloft na tela de boas-vindas segue as mesmas escolhas. @@ -816,7 +816,7 @@ Layout, como um app de mensagens: - **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. - **Pedido de permissão** (10.1): um cartão no chat, " quer " ("Scout quer rodar um comando"), com Permitir, Negar e um campo para dizer ao bot por que não. Num comando, o texto principal é a explicação do bot, em letra comum; embaixo, menor, " escreveu isto. O que roda de verdade é o comando abaixo.", e "Ver o comando", uma seta que abre o comando inteiro, em letra de código e com as quebras de linha. Sem explicação, o cartão diz " não disse para que serve este comando. Na dúvida, negue e pergunte." e já vem com o comando aberto. Um comando grande demais para guardar aparece até onde coube, com o aviso de que está cortado. Nas outras ferramentas, o resumo (o arquivo, a URL) e a entrada inteira em "Detalhes". Quando o pedido tem `always` (10.1), entre Permitir e Negar fica um terceiro botão que diz o que cobre: "Permitir sempre este comando", "Permitir sempre em example.com", "Permitir sempre neste arquivo" ou só "Permitir sempre"; a dica dele avisa que o bot não vai pedir aquilo de novo e que dá para desfazer nos detalhes. Respondido, o cartão vira uma linha ("Permitido: rodar um comando") com a explicação ou, sem ela, o comando. A linha da ferramenta acima do cartão segue a mesma ideia: "Rodar um comando" e a explicação do bot (sem ela, o comando), e um clique abre o comando e a saída; a lista de conversas mostra o mesmo texto. - **Detalhes do bot** (pasta, instruções, sessão) ficam num painel, fora do caminho da conversa. No fim dele, "Permitido sem perguntar" lista as regras do bot (10.1): a ação no idioma do dono ("Rodar um comando") e, embaixo, o comando, o site ou o arquivo, cada uma com um X que faz o bot voltar a perguntar. A lista é lida ao abrir o painel e segue `bot.rules`; vazia, diz que "Permitir sempre" coloca as coisas ali. 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 e cor pelo tipo, nome, pasta, tamanho, "há 5 min"). Cada tipo tem seu ícone num quadrado da sua cor, para Word, planilha e PDF se distinguirem de longe: PDF vermelho, Word azul, planilha verde, apresentação laranja, imagem roxa, áudio e vídeo rosa, compactado âmbar, código azul-petróleo, texto e o resto cinza. O tipo vem primeiro da extensão (os `.doc`, `.xls`, `.ppt` e OpenDocument chegam como `application/octet-stream`) e depois do media type. 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 com o painel aberto e a janela à vista, e de novo quando ele para: cada leitura percorre as pastas do bot, então ninguém paga por ela sem olhar a lista. Com o painel fechado, o contador abaixo se atualiza quando o turno acaba. 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. Um `share_file` que deu certo (8.4) não vira linha: cada arquivo vira um cartão no chat, com o quadrado do tipo (ou a miniatura de uma imagem de até 8 MB, lida com `files.read`), nome, extensão e tamanho, "Abrir" e "Salvar como…" (`save_file_as`, 15.2). Um clique no arquivo abre a prévia no painel. Num chat estreito, o botão de salvar fica só com o ícone. Se a resposta da tool não puder ser lida, ou se a chamada falhou, fica a linha da ferramenta, com o erro. 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. +- **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 e cor pelo tipo, nome, pasta, tamanho, "há 5 min"). Cada tipo tem seu ícone num quadrado da sua cor, para Word, planilha e PDF se distinguirem de longe: PDF vermelho, Word azul, planilha verde, apresentação laranja, imagem roxa, áudio e vídeo rosa, compactado âmbar, código azul-petróleo, texto e o resto cinza. O tipo vem primeiro da extensão (os `.doc`, `.xls`, `.ppt` e OpenDocument chegam como `application/octet-stream`) e depois do media type. 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 com o painel aberto e a janela à vista, e de novo quando ele para: cada leitura percorre as pastas do bot, então ninguém paga por ela sem olhar a lista. Com o painel fechado, o contador abaixo se atualiza quando o turno acaba. 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. Um `share_file` que deu certo (8.4) não vira linha: cada arquivo vira um cartão no chat, com o quadrado do tipo (ou a miniatura de uma imagem de até 8 MB, lida com `files.read`), nome, extensão e tamanho, "Abrir" e "Salvar como…" (`save_file_as`, 15.2). Um clique no arquivo abre a prévia no painel. Num chat estreito, o botão de salvar fica só com o ícone. Se a resposta da tool não puder ser lida, ou se a chamada falhou, fica a linha da ferramenta, com o erro. 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; o chat toma a largura nova de uma vez, e fechando o painel sai deslizando por cima dele. Só um `transform` se move, para a conversa não ser refeita a cada quadro (a 180 Hz, cada quadro tem 5,5 ms). 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 From 2c839ae86719c42e339790b7644a6ad985e130d5 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:31:57 -0300 Subject: [PATCH 2/3] fix(app): slide a panel open in strict mode too React's strict mode mounts a panel twice in dev. The first mount left its own width as "the panel being replaced", so the second started from that width and did not slide. A panel no longer counts as replacing itself. --- app/src/ui/panelMotion.ts | 11 +++++++---- app/src/ui/sidepanel.test.tsx | 8 ++++++++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/app/src/ui/panelMotion.ts b/app/src/ui/panelMotion.ts index 4b59bf1e..7a6ca5c0 100644 --- a/app/src/ui/panelMotion.ts +++ b/app/src/ui/panelMotion.ts @@ -29,8 +29,11 @@ export const PanelClosing = createContext<{ closed(): void } | null>(null); */ export const PanelRestored = createContext(false); -/** The panel that just went away without sliding: another replaces it. */ -let replaced: { width: number; at: number } | null = null; +/** + * The panel that just went away without sliding: another replaces it. Not + * the same one mounted again (React's strict mode in dev does that). + */ +let replaced: { width: number; at: number; panel: RefObject } | null = null; export type Motion = "opening" | "open" | "closing"; @@ -46,7 +49,7 @@ export function usePanelMotion(panel: RefObject, width: numb // Before the first paint: start from the panel this one replaces, and // leave this one's width for the next when it goes the same way. useLayoutEffect(() => { - if (replaced && performance.now() - replaced.at < SWITCH_MS) { + if (replaced && replaced.panel !== panel && performance.now() - replaced.at < SWITCH_MS) { setFrom(replaced.width); } replaced = null; @@ -54,7 +57,7 @@ export function usePanelMotion(panel: RefObject, width: numb return () => { if (now.current.motion !== "closing") { const shown = element.current?.offsetWidth || now.current.width; - replaced = { width: shown, at: performance.now() }; + replaced = { width: shown, at: performance.now(), panel: element }; } }; }, [panel]); diff --git a/app/src/ui/sidepanel.test.tsx b/app/src/ui/sidepanel.test.tsx index b59e5dc3..c121fc58 100644 --- a/app/src/ui/sidepanel.test.tsx +++ b/app/src/ui/sidepanel.test.tsx @@ -1,4 +1,5 @@ import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { StrictMode } from "react"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { PanelClosing, PanelRestored } from "./panelMotion"; import { SidePanel } from "./SidePanel"; @@ -103,6 +104,13 @@ describe("side panel motion", () => { expect(closed).toHaveBeenCalled(); }); + test("mounted again in strict mode, it still slides from the edge", () => { + const { container } = render({panel()}); + const aside = container.querySelector("aside") as HTMLElement; + expect(aside.className).toContain("panel-opening"); + expect(aside.style.getPropertyValue("--panel-from")).toBe("0px"); + }); + test("a panel replacing another starts from that one's width", () => { localStorage.setItem("botloft.panel.first", "480"); const view = render( From 26d61f4274d0238834049a0be3e41e548dc86e66 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:31:58 -0300 Subject: [PATCH 3/3] feat(app): fade dialogs out when they close A dialog popped in but vanished at once, since its owner unmounts it. It now leaves a still copy of itself that fades and shrinks a little in 150 ms, whatever closed it (X, Escape, Cancel, Save), with what was typed still in its fields. Nothing changes for the dialogs that use it. --- app/src/dialog.css | 25 ++++++++++++++++ app/src/index.css | 1 + app/src/ui/Dialog.tsx | 18 ++++++++++-- app/src/ui/dialog.test.tsx | 59 ++++++++++++++++++++++++++++++++++++++ app/src/ui/dialogExit.ts | 48 +++++++++++++++++++++++++++++++ docs/spec.md | 2 +- 6 files changed, 149 insertions(+), 4 deletions(-) create mode 100644 app/src/dialog.css create mode 100644 app/src/ui/dialog.test.tsx create mode 100644 app/src/ui/dialogExit.ts diff --git a/app/src/dialog.css b/app/src/dialog.css new file mode 100644 index 00000000..3b01a5cf --- /dev/null +++ b/app/src/dialog.css @@ -0,0 +1,25 @@ +/* A dialog closing (ui/dialogExit.ts): its still copy fades out with the + backdrop, shrinking a little, the way it came in backwards and faster. + Reduced motion makes it instant (motion.css, motion-less.css). */ + +.dialog-exit { + pointer-events: none; + animation: dialog-fade-out 150ms ease-in both; +} + +.dialog-exit > [role="dialog"] { + animation: dialog-pop-out 150ms ease-in both; +} + +@keyframes dialog-fade-out { + to { + opacity: 0; + } +} + +@keyframes dialog-pop-out { + to { + opacity: 0; + transform: scale(0.97) translateY(4px); + } +} diff --git a/app/src/index.css b/app/src/index.css index 978f5a12..9c7ebeb1 100644 --- a/app/src/index.css +++ b/app/src/index.css @@ -9,6 +9,7 @@ @import "./mascot-flame.css"; @import "./panels.css"; @import "./sidebar.css"; +@import "./dialog.css"; @import "./effort.css"; @import "./color.css"; @import "./files.css"; diff --git a/app/src/ui/Dialog.tsx b/app/src/ui/Dialog.tsx index 9c7143be..fc98d8e0 100644 --- a/app/src/ui/Dialog.tsx +++ b/app/src/ui/Dialog.tsx @@ -1,7 +1,8 @@ import { X } from "lucide-react"; -import { type ReactNode, useEffect, useId } from "react"; +import { type ReactNode, useEffect, useId, useLayoutEffect, useRef } from "react"; import { useT } from "../i18n"; import { Button } from "./Button"; +import { exitDialog } from "./dialogExit"; interface DialogProps { title: string; @@ -12,10 +13,18 @@ interface DialogProps { width?: "md" | "lg"; } -/** A modal panel. Escape and the close button call `onClose`. */ +/** + * A modal panel. Escape and the close button call `onClose`. It pops in, + * and fades out however its owner closes it (dialogExit.ts). + */ export function Dialog({ title, onClose, children, footer, width = "md" }: DialogProps) { const t = useT(); const titleId = useId(); + const backdrop = useRef(null); + useLayoutEffect(() => { + const node = backdrop.current; + return () => exitDialog(node); + }, []); useEffect(() => { const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") { @@ -28,7 +37,10 @@ export function Dialog({ title, onClose, children, footer, width = "md" }: Dialo }, [onClose]); return ( -
+
Animation[] }; +beforeEach(() => { + proto.getAnimations = () => []; +}); +afterEach(() => { + cleanup(); + delete proto.getAnimations; + for (const copy of document.querySelectorAll(".dialog-exit")) { + copy.remove(); + } +}); + +function Owner() { + const [open, setOpen] = useState(true); + return open ? ( + setOpen(false)}> + + + ) : null; +} + +const settle = () => act(() => Promise.resolve()); + +describe("dialog motion", () => { + test("closing leaves a still copy that fades out, then goes", async () => { + render(); + fireEvent.change(screen.getByRole("textbox", { name: "Name" }), { + target: { value: "Scout 2" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Close" })); + await settle(); + expect(screen.queryByRole("dialog")).toBeNull(); + const copy = document.querySelector(".dialog-exit") as HTMLElement; + expect(copy.getAttribute("aria-hidden")).toBe("true"); + expect(copy.inert).toBe(true); + // What the owner typed stays while it fades. + expect(copy.querySelector("input")?.value).toBe("Scout 2"); + const end = new Event("animationend"); + fireEvent(copy, end); + expect(document.querySelector(".dialog-exit")).toBeNull(); + }); + + test("being mounted again in strict mode leaves no copy", async () => { + render( + + + , + ); + await settle(); + expect(screen.getByRole("dialog")).toBeDefined(); + expect(document.querySelector(".dialog-exit")).toBeNull(); + }); +}); diff --git a/app/src/ui/dialogExit.ts b/app/src/ui/dialogExit.ts new file mode 100644 index 00000000..8de722cd --- /dev/null +++ b/app/src/ui/dialogExit.ts @@ -0,0 +1,48 @@ +// A dialog leaves the page at once when its owner closes it, whatever +// closed it (its X, Escape, Cancel, Save). So it does not just vanish, a +// still copy of it stays a moment and fades out (dialog.css, spec 15.3). + +/** Longest the copy stays, if its animation never reports its end. */ +const EXIT_MS = 400; + +/** + * Leaves a fading copy of `node`, a dialog's backdrop, in its place, once + * it is out of the page. One still in it was only mounted again (React's + * strict mode in dev). + */ +export function exitDialog(node: HTMLElement | null): void { + // Where nothing animates (tests), there is nothing to wait for. + if (!node || typeof node.getAnimations !== "function") { + return; + } + queueMicrotask(() => { + if (!node.isConnected) { + leave(node); + } + }); +} + +function leave(node: HTMLElement): void { + const copy = node.cloneNode(true) as HTMLElement; + // What the owner typed lives in the fields, not in their markup. + const fields = node.querySelectorAll( + "input, textarea, select", + ); + const copies = copy.querySelectorAll( + "input, textarea, select", + ); + fields.forEach((field, index) => { + const twin = copies[index]; + if (twin) { + twin.value = field.value; + } + }); + copy.removeAttribute("id"); + copy.setAttribute("aria-hidden", "true"); + copy.inert = true; + copy.classList.add("dialog-exit"); + document.body.append(copy); + const remove = () => copy.remove(); + copy.addEventListener("animationend", (event) => event.target === copy && remove()); + setTimeout(remove, EXIT_MS); +} diff --git a/docs/spec.md b/docs/spec.md index 23d57085..5271c28b 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -875,7 +875,7 @@ Movimento: o app se mexe para parecer vivo, sem atrapalhar o trabalho. Animaçõ - **Chegadas:** o que chega enquanto o dono olha entra subindo e aparecendo: mensagens, ferramentas, pedidos, avisos e bots novos na barra lateral. O que já estava lá quando ele abriu o chat (ou o app) aparece parado, e páginas antigas carregadas em cima não se mexem. A resposta já aparece enquanto é escrita, então não anima de novo ao terminar. - **Trabalhando:** os três pontos pulam em sequência; o texto sendo escrito tem um cursor piscando no fim; a mão de "Precisa de aprovação" acena; um pedido pendente pulsa uma vez ao aparecer. No estado do bot, o ponto de "Disponível" solta dois anéis, um depois do outro, e na linha de "Trabalhando" corre um trecho aceso (`StateIcons.tsx`); com menos movimento, a linha fica inteira acesa. - **Trocar de bot ou de crew:** o painel principal some rápido e o novo sobe (View Transitions do webview); a barra lateral fica onde está. -- **Superfícies:** menus e seletores abrem a partir de onde estão presos; diálogos aparecem com o fundo escurecendo; avisos entram deslizando; `
` abre deslizando; a linha da aba selecionada desliza até a nova; as barras de uso enchem ao abrir; botões afundam um pouco ao clicar; o mascote da tela de boas-vindas e do chat vazio flutua. +- **Superfícies:** menus e seletores abrem a partir de onde estão presos; diálogos aparecem com o fundo escurecendo e, fechados de qualquer jeito (X, Esc, Cancelar, Salvar), somem esmaecendo e encolhendo um pouco (150 ms); avisos entram deslizando; `
` abre deslizando; a linha da aba selecionada desliza até a nova; as barras de uso enchem ao abrir; botões afundam um pouco ao clicar; o mascote da tela de boas-vindas e do chat vazio flutua. No chat: