From 43c31986f4c30d10793f1fbf1649149ea1fea91b Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:51:44 -0300 Subject: [PATCH] feat(crews): a right-click menu on each crew in the list Right-clicking a crew's name in the sidebar opens its menu without opening it: new bot, pause or resume, rename, open and change the work folder, archive and delete. The crew page's buttons and menu and this one come from the same hook, useCrewActions. --- app/src/features/crews/CrewView.tsx | 113 +++------------------ app/src/features/crews/Sidebar.tsx | 21 +++- app/src/features/crews/crewActions.tsx | 123 +++++++++++++++++++++++ app/src/features/crews/crewMenu.test.tsx | 74 ++++++++++++++ app/src/i18n/en/crews.ts | 2 + app/src/i18n/es/crews.ts | 1 + app/src/i18n/pt-BR/crews.ts | 1 + docs/spec.md | 2 +- 8 files changed, 235 insertions(+), 102 deletions(-) create mode 100644 app/src/features/crews/crewActions.tsx create mode 100644 app/src/features/crews/crewMenu.test.tsx diff --git a/app/src/features/crews/CrewView.tsx b/app/src/features/crews/CrewView.tsx index ff8e3784..9a0c5b5b 100644 --- a/app/src/features/crews/CrewView.tsx +++ b/app/src/features/crews/CrewView.tsx @@ -1,50 +1,30 @@ -import { - Archive, - Ellipsis, - Folder, - FolderInput, - FolderOpen, - Pause, - Pencil, - Play, - Plus, - Trash2, -} from "lucide-react"; +import { Ellipsis, Folder, Plus } from "lucide-react"; import { useState } from "react"; import { useShallow } from "zustand/react/shallow"; import { useT } from "../../i18n"; import type { Crew } from "../../lib/protocol.gen"; import { botsOf } from "../../store/app"; -import { useApi, useApp, useHost } from "../../store/context"; +import { useApp } from "../../store/context"; import { Button } from "../../ui/Button"; -import { Confirm } from "../../ui/Confirm"; import { Menu } from "../../ui/Menu"; import { type Tab, Tabs, tabId } from "../../ui/Tabs"; -import { attempt } from "../../ui/toast"; -import { BotDialog } from "../bots/BotDialog"; import { CallPills, useLiveCalls } from "../messages/CallPills"; import { Composer } from "../messages/Composer"; import { Timeline } from "../messages/Timeline"; import { CrewRoutines } from "../routines/RoutineList"; import { TaskList } from "../tasks/TaskList"; import { CrewBots } from "./CrewBots"; -import { CrewDialog } from "./CrewDialog"; -import { DeleteCrew } from "./DeleteCrew"; +import { useCrewActions } from "./crewActions"; -type Open = "bot" | "rename" | "archive" | "delete" | { move: string } | null; type Pane = "bots" | "timeline" | "tasks" | "routines"; export function CrewView({ crew }: { crew: Crew }) { const t = useT(); const words = t.crews.view; - const api = useApi(); - const host = useHost(); - const putCrew = useApp((state) => state.putCrew); const bots = useApp(useShallow((state) => botsOf(state, crew.id))); const calls = useLiveCalls((call) => call.crewId === crew.id, crew.id); - const [open, setOpen] = useState(null); + const actions = useCrewActions(crew); const [pane, setPane] = useState("bots"); - const close = () => setOpen(null); const tabs: Tab[] = [ { id: "bots", label: words.tabs.bots }, { id: "timeline", label: words.tabs.timeline }, @@ -52,20 +32,6 @@ export function CrewView({ crew }: { crew: Crew }) { { id: "routines", label: t.routines.tab }, ]; - const openFolder = () => attempt(words.failed.openFolder, () => host.openPath(crew.workFolder)); - const pickFolder = () => - attempt(words.failed.changeFolder, async () => { - const folder = await host.pickFolder(t.crews.dialog.pickTitle, crew.workFolder); - if (folder && folder !== crew.workFolder) { - setOpen({ move: folder }); - } - }); - - const setPaused = (paused: boolean) => - attempt(paused ? words.failed.pause : words.failed.resume, async () => - putCrew(await api.call("crews.setPaused", { crewId: crew.id, paused })), - ); - return (
@@ -79,7 +45,7 @@ export function CrewView({ crew }: { crew: Crew }) { {/* Only the icon: the path is in its tooltip, and a click opens it. */} - {crew.paused ? ( - - ) : ( - - )} - setOpen("rename") }, - { label: words.openFolder, icon: FolderOpen, onSelect: openFolder }, - { label: words.changeFolder, icon: FolderInput, onSelect: pickFolder }, - { - label: words.archive, - icon: Archive, - danger: true, - onSelect: () => setOpen("archive"), - }, - { label: words.delete, icon: Trash2, danger: true, onSelect: () => setOpen("delete") }, - ]} - /> + +
label={words.tabs.label} tabs={tabs} value={pane} onChange={setPane} /> @@ -127,7 +72,7 @@ ${words.openFolderHint}`} aria-labelledby={tabId("bots")} className="min-h-0 flex-1 overflow-y-auto p-5" > - setOpen("bot")} /> + )} {pane === "timeline" && ( @@ -163,39 +108,7 @@ ${words.openFolderHint}`} )} - {open === "bot" && } - {open === "rename" && } - {typeof open === "object" && open !== null && ( - - attempt(words.failed.changeFolder, async () => - putCrew( - await api.call("crews.setWorkFolder", { crewId: crew.id, workFolder: open.move }), - ), - ) - } - > - {words.moveBody(open.move)} - - )} - {open === "archive" && ( - - attempt(words.failed.archive, async () => - putCrew(await api.call("crews.archive", { crewId: crew.id })), - ) - } - > - {words.archiveBody(bots.length)} - - )} - {open === "delete" && } + {actions.dialogs}
); } diff --git a/app/src/features/crews/Sidebar.tsx b/app/src/features/crews/Sidebar.tsx index 74575336..ec59085c 100644 --- a/app/src/features/crews/Sidebar.tsx +++ b/app/src/features/crews/Sidebar.tsx @@ -20,6 +20,7 @@ import { ListAvatar } from "../bots/ListAvatar"; import { toolAction, toolTitle } from "../chat/toolNames"; import { CrewDialog } from "./CrewDialog"; import { setAllCollapsed, setCollapsed, useCollapsed, useCollapsedSet } from "./collapsed"; +import { useCrewActions } from "./crewActions"; import { row } from "./SidebarPages"; /** Crews as sections and their bots as conversations (spec 15.1). */ @@ -81,6 +82,10 @@ function CrewEntry({ crew }: { crew: Crew }) { const selectCrew = useApp((state) => state.selectCrew); const openBotId = useApp((state) => state.selectedBotId); const collapsed = useCollapsed(crew.id); + // A right-click opens the crew's menu, the same as on its page. + const actions = useCrewActions(crew); + const [menuAt, setMenuAt] = useState(null); + const closeMenu = useCallback(() => setMenuAt(null), []); // Folded, the open conversation still shows, and a dot tells a bot waits. const shown = collapsed ? bots.filter((bot) => bot.id === openBotId) : bots; const waiting = @@ -108,7 +113,11 @@ function CrewEntry({ crew }: { crew: Crew }) { type="button" aria-current={selected ? "page" : undefined} onClick={() => selectCrew(crew.id)} - className={`${row} h-8 gap-2 pr-2.5 pl-8 font-semibold text-sm ${selected ? "bg-sunken" : ""}`} + onContextMenu={(event) => { + event.preventDefault(); + setMenuAt(menuPoint(event)); + }} + className={`${row} h-8 gap-2 pr-2.5 pl-8 font-semibold text-sm ${selected || menuAt ? "bg-sunken" : ""}`} > {crew.name} {waiting && ( @@ -128,6 +137,16 @@ function CrewEntry({ crew }: { crew: Crew }) { )} + {menuAt && ( + + )} + {/* Outside the list: a dialog is not part of the navigation. */} + {actions.dialogs && createPortal(actions.dialogs, document.body)}
    {shown.map((bot) => ( diff --git a/app/src/features/crews/crewActions.tsx b/app/src/features/crews/crewActions.tsx new file mode 100644 index 00000000..9a64c764 --- /dev/null +++ b/app/src/features/crews/crewActions.tsx @@ -0,0 +1,123 @@ +import { + Archive, + FolderInput, + FolderOpen, + type LucideIcon, + Pause, + Pencil, + Play, + Plus, + Trash2, +} from "lucide-react"; +import { type ReactNode, useState } from "react"; +import { useShallow } from "zustand/react/shallow"; +import { useT } from "../../i18n"; +import type { Crew } from "../../lib/protocol.gen"; +import { botsOf } from "../../store/app"; +import { useApi, useApp, useHost } from "../../store/context"; +import { Confirm } from "../../ui/Confirm"; +import type { MenuItem } from "../../ui/Menu"; +import { attempt } from "../../ui/toast"; +import { BotDialog } from "../bots/BotDialog"; +import { CrewDialog } from "./CrewDialog"; +import { DeleteCrew } from "./DeleteCrew"; + +type Open = "bot" | "rename" | "archive" | "delete" | { move: string } | null; + +export interface CrewActions { + /** A new bot in the crew. */ + newBot: MenuItem; + /** Pause or resume, as the crew is now. */ + pause: MenuItem & { icon: LucideIcon }; + /** Opens the work folder in the system's file manager. */ + openFolder(): void; + /** Rename, the folder, archive and delete. */ + items: MenuItem[]; + /** The dialog an action opened, `null` while none is. */ + dialogs: ReactNode | null; +} + +/** + * What the owner can do with a crew from a menu, and the dialogs those + * actions open. The crew page and the right-click menu of the sidebar both + * come from here, so they stay the same. + */ +export function useCrewActions(crew: Crew): CrewActions { + const t = useT(); + const words = t.crews.view; + const api = useApi(); + const host = useHost(); + const putCrew = useApp((state) => state.putCrew); + const bots = useApp(useShallow((state) => botsOf(state, crew.id))); + const [open, setOpen] = useState(null); + const close = () => setOpen(null); + + const openFolder = () => attempt(words.failed.openFolder, () => host.openPath(crew.workFolder)); + const pickFolder = () => + attempt(words.failed.changeFolder, async () => { + const folder = await host.pickFolder(t.crews.dialog.pickTitle, crew.workFolder); + if (folder && folder !== crew.workFolder) { + setOpen({ move: folder }); + } + }); + const setPaused = (paused: boolean) => + attempt(paused ? words.failed.pause : words.failed.resume, async () => + putCrew(await api.call("crews.setPaused", { crewId: crew.id, paused })), + ); + + let dialogs: ReactNode | null = null; + if (open === "bot") { + dialogs = ; + } else if (open === "rename") { + dialogs = ; + } else if (open === "archive") { + dialogs = ( + + attempt(words.failed.archive, async () => + putCrew(await api.call("crews.archive", { crewId: crew.id })), + ) + } + > + {words.archiveBody(bots.length)} + + ); + } else if (open === "delete") { + dialogs = ; + } else if (open !== null) { + const { move } = open; + dialogs = ( + + attempt(words.failed.changeFolder, async () => + putCrew(await api.call("crews.setWorkFolder", { crewId: crew.id, workFolder: move })), + ) + } + > + {words.moveBody(move)} + + ); + } + + return { + newBot: { label: t.crews.newBot, icon: Plus, onSelect: () => setOpen("bot") }, + openFolder, + pause: crew.paused + ? { label: words.resume, icon: Play, onSelect: () => setPaused(false) } + : { label: words.pause, icon: Pause, onSelect: () => setPaused(true) }, + items: [ + { label: t.crews.rename, icon: Pencil, onSelect: () => setOpen("rename") }, + { label: words.openFolder, icon: FolderOpen, onSelect: openFolder }, + { label: words.changeFolder, icon: FolderInput, onSelect: pickFolder }, + { label: words.archive, icon: Archive, danger: true, onSelect: () => setOpen("archive") }, + { label: words.delete, icon: Trash2, danger: true, onSelect: () => setOpen("delete") }, + ], + dialogs, + }; +} diff --git a/app/src/features/crews/crewMenu.test.tsx b/app/src/features/crews/crewMenu.test.tsx new file mode 100644 index 00000000..3810d100 --- /dev/null +++ b/app/src/features/crews/crewMenu.test.tsx @@ -0,0 +1,74 @@ +import { cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { FakeBotloft } from "../../lib/fake"; +import { crewOpened, openBot, renderApp, sidebar } from "../../test/app"; + +afterEach(cleanup); + +async function twoCrews() { + const fake = new FakeBotloft(); + const ops = fake.addCrew("Ops"); + const site = fake.addCrew("Site"); + fake.addBot(ops.id, "Scout"); + fake.addBot(site.id, "Writer"); + const { host } = renderApp(fake); + await crewOpened("Ops"); + return { fake, host, ops, site }; +} + +/** Right-clicks the crew in the list and returns the menu that opens. */ +function rightClick(name: string) { + fireEvent.contextMenu(within(sidebar()).getByRole("button", { name: new RegExp(`^${name}`) }), { + clientX: 120, + clientY: 200, + }); + return screen.getByRole("menu", { name: `Actions for ${name}` }); +} + +const item = (menu: HTMLElement, name: string) => within(menu).getByRole("menuitem", { name }); + +describe("a crew's right-click menu", () => { + test("offers what the crew page offers, without opening the crew", async () => { + await twoCrews(); + openBot("Scout"); + const menu = rightClick("Site"); + expect( + within(menu) + .getAllByRole("menuitem") + .map((one) => one.textContent), + ).toEqual([ + "New bot", + "Pause crew", + "Rename", + "Open work folder", + "Change work folder…", + "Archive crew", + "Delete crew", + ]); + // Scout's chat stays open. + expect(screen.getByRole("list", { name: "Messages" })).toBeDefined(); + }); + + test("pauses, opens the folder, and opens the dialogs over the list", async () => { + const { fake, host, site } = await twoCrews(); + fireEvent.click(item(rightClick("Site"), "Pause crew")); + await waitFor(() => + expect(fake.calls.at(-1)).toMatchObject({ + method: "crews.setPaused", + params: { crewId: site.id, paused: true }, + }), + ); + expect(within(rightClick("Site")).getByRole("menuitem", { name: "Resume crew" })).toBeDefined(); + fireEvent.keyDown(document.activeElement ?? document.body, { key: "Escape" }); + + fireEvent.click(item(rightClick("Site"), "Open work folder")); + await waitFor(() => expect(host.opened).toEqual([site.workFolder])); + + fireEvent.click(item(rightClick("Site"), "Rename")); + expect(await screen.findByRole("dialog", { name: /Rename/ })).toBeDefined(); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + + fireEvent.click(item(rightClick("Site"), "Delete crew")); + expect(await screen.findByRole("dialog", { name: /Delete Site/ })).toBeDefined(); + }); +}); diff --git a/app/src/i18n/en/crews.ts b/app/src/i18n/en/crews.ts index ec2dab7f..d0a0e45e 100644 --- a/app/src/i18n/en/crews.ts +++ b/app/src/i18n/en/crews.ts @@ -7,6 +7,8 @@ export const crews = { rename: "Rename", paused: "Paused", sidebar: { + /** The right-click menu of a crew in the list. */ + menuOf: (crew: string) => `Actions for ${crew}`, label: "Crews", noMessages: "No messages yet", /** The conversation-list line for what the owner wrote. */ diff --git a/app/src/i18n/es/crews.ts b/app/src/i18n/es/crews.ts index e213087e..cc3dd453 100644 --- a/app/src/i18n/es/crews.ts +++ b/app/src/i18n/es/crews.ts @@ -6,6 +6,7 @@ export const crews: Messages["crews"] = { rename: "Renombrar", paused: "En pausa", sidebar: { + menuOf: (crew) => `Acciones de ${crew}`, label: "Equipos", noMessages: "Aún no hay mensajes", fromOwner: (text: string) => `Tú: ${text}`, diff --git a/app/src/i18n/pt-BR/crews.ts b/app/src/i18n/pt-BR/crews.ts index e6c0e60c..a3fb5c1c 100644 --- a/app/src/i18n/pt-BR/crews.ts +++ b/app/src/i18n/pt-BR/crews.ts @@ -6,6 +6,7 @@ export const crews: Messages["crews"] = { rename: "Renomear", paused: "Pausada", sidebar: { + menuOf: (crew) => `Ações de ${crew}`, label: "Equipes", noMessages: "Nenhuma mensagem ainda", fromOwner: (text: string) => `Você: ${text}`, diff --git a/docs/spec.md b/docs/spec.md index 0492f65a..4f313d2d 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -818,7 +818,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; um bot parado e livre, `idle`, não mostra estado, porque é o normal) e a prévia da última atividade (`lastActivity`) com a hora. O que alguém escreveu (resposta, mensagem, pergunta) aparece em texto simples, sem as marcas de Markdown que o chat desenha (`**negrito**`, títulos, listas, links); um comando ou caminho de ferramenta fica como veio. 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`). +- **Barra lateral:** crews como seções, com os bots como conversas. Cada conversa mostra avatar, nome, estado (cor, ícone e texto; um bot parado e livre, `idle`, não mostra estado, porque é o normal) e a prévia da última atividade (`lastActivity`) com a hora. O que alguém escreveu (resposta, mensagem, pergunta) aparece em texto simples, sem as marcas de Markdown que o chat desenha (`**negrito**`, títulos, listas, links); um comando ou caminho de ferramenta fica como veio. 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. Um clique direito no nome de uma crew abre o menu da crew do mesmo jeito, sem abri-la: novo bot, pausar ou retomar, renomear, abrir a pasta de trabalho, mudar a pasta, arquivar e excluir, as mesmas ações dos botões e do menu "mais" da página da crew (`useCrewActions`). 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. - **Trilho de ícones**, na beirada esquerda, antes da barra lateral: os lugares do app como ícones, com o nome no tooltip. **Início** (a casa) abre a página das crews e traz a barra lateral de volta se o dono a escondeu; **Buscar** (8.8), **Perguntas** (23.6), com o número de perguntas abertas na cor de aviso, e **Rotinas** (20.9). O lugar aberto fica marcado. O trilho fica mesmo com a barra lateral escondida (Ctrl+B), com as contagens à vista; aparece também antes da primeira crew. - **Área da conta**, no pé do trilho, como nos apps de chat: um círculo com a inicial; o tooltip diz 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, ao lado do trilho, um menu com o nome, o plano e o e-mail em cima 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.