diff --git a/app/src/dev/seedChats.ts b/app/src/dev/seedChats.ts index 6be88bbf..52b2ae98 100644 --- a/app/src/dev/seedChats.ts +++ b/app/src/dev/seedChats.ts @@ -69,7 +69,12 @@ export function seedChats(fake: FakeBotloft, crew: Crew): void { ["Write", "week-39.md"], ] as const; for (const [name, summary] of files) { - const call = chat.tool(crew.scout, name, { summary, input: JSON.stringify({ path: summary }) }); + const file = name === "Write" ? "C:\\Work\\summaries\\week-39.md" : null; + const call = chat.tool(crew.scout, name, { + summary, + input: JSON.stringify({ path: summary }), + file, + }); chat.finish(call, name === "Write" ? "Wrote 42 lines" : "ok"); } at(93); diff --git a/app/src/features/bots/BotView.tsx b/app/src/features/bots/BotView.tsx index 77dc4451..85f81be1 100644 --- a/app/src/features/bots/BotView.tsx +++ b/app/src/features/bots/BotView.tsx @@ -10,6 +10,7 @@ import { Callout } from "../../ui/Callout"; import { type Tab, Tabs, tabId } from "../../ui/Tabs"; import { ChatView } from "../chat/ChatView"; import { FilesPanel } from "../files/FilesPanel"; +import { ShowFile } from "../files/showFile"; import { useBotFiles } from "../files/useBotFiles"; import { SignInButton } from "../onboarding/SignIn"; import { BotRoutines } from "../routines/RoutineList"; @@ -38,13 +39,30 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) { setSeenAt(now); setSince(now); }, [bot.id]); + const [shown, setShown] = useState(null); + // biome-ignore lint/correctness/useExhaustiveDependencies: another bot's file is not this bot's + useEffect(() => setShown(null), [bot.id]); const filesOpen = side === "files"; - const toggleFiles = () => { + const seen = () => { // What was there when the panel opened or closed counts as seen. setSince(seenAt); setSeenAt(Date.now()); + }; + const toggleFiles = () => { + seen(); + setShown(null); setSide(filesOpen ? null : "files"); }; + // A tool line in the chat points to its file: read the list first, so the + // file is in it when the panel goes to it. + const showFile = async (path: string) => { + if (!filesOpen) { + seen(); + } + setSide("files"); + await files.refresh(); + setShown(path); + }; const fresh = filesOpen ? 0 : files.files.filter((file) => file.modifiedAt > seenAt).length; const routines = useApp(useShallow((state) => routinesOf(state, bot.id))); const view = stateView(bot, crew.paused, t); @@ -72,13 +90,24 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) {
{pane === "chat" ? ( - + + + ) : ( )}
{side === "details" &&
setSide(null)} />} - {filesOpen && } + {filesOpen && ( + + )}
); diff --git a/app/src/features/chat/ToolLines.tsx b/app/src/features/chat/ToolLines.tsx index dcc8a53a..74cd853d 100644 --- a/app/src/features/chat/ToolLines.tsx +++ b/app/src/features/chat/ToolLines.tsx @@ -6,6 +6,7 @@ import { ChevronRight, FilePen, FileSearch, + Files, FileText, Globe, ListChecks, @@ -19,10 +20,11 @@ import { Wrench, X, } from "lucide-react"; -import { useState } from "react"; +import { useContext, useState } from "react"; import { useT } from "../../i18n"; import type { ChatItem, ToolItem } from "../../lib/protocol.gen"; import { useArrival } from "../../ui/motion"; +import { ShowFile } from "../files/showFile"; const ICONS: Record = { Bash: SquareTerminal, @@ -79,6 +81,7 @@ function Status({ status }: { status: ToolItem["status"] }) { function ToolLine({ tool, createdAt }: { tool: ToolItem; createdAt: number }) { const t = useT(); const [open, setOpen] = useState(false); + const showFile = useContext(ShowFile); const arrival = useArrival(createdAt); const label = toolLabel(tool.name); const Icon = ICONS[label] ?? Wrench; @@ -86,24 +89,40 @@ function ToolLine({ tool, createdAt }: { tool: ToolItem; createdAt: number }) { "max-h-64 overflow-auto whitespace-pre-wrap break-all rounded-lg border border-line bg-sunken px-2.5 py-1.5 font-mono text-xs leading-relaxed"; return (
  • - +
    + + {showFile && tool.file && tool.status !== "failed" && ( + + )} +
    {open && (

    {t.chat.tools.input}

    diff --git a/app/src/features/files/FilesPanel.tsx b/app/src/features/files/FilesPanel.tsx index e8c4515c..050714df 100644 --- a/app/src/features/files/FilesPanel.tsx +++ b/app/src/features/files/FilesPanel.tsx @@ -2,8 +2,7 @@ // first, the ones that showed up since the owner last looked marked new. A // click shows the file in the panel itself. -import { RefreshCw, X } from "lucide-react"; -import { useState } from "react"; +import { ArrowLeft, RefreshCw, X } from "lucide-react"; import { useT } from "../../i18n"; import { fileSize, fromNow } from "../../lib/format"; import type { Bot, BotFile } from "../../lib/protocol.gen"; @@ -18,16 +17,20 @@ export function FilesPanel({ bot, data, since, + path, + onPath, onClose, }: { bot: Bot; data: BotFiles; /** Files modified after this (Unix ms) are marked new. */ since: number; + /** The file shown, or null for the list. */ + path: string | null; + onPath(path: string | null): void; onClose(): void; }) { const t = useT().files; - const [path, setPath] = useState(null); const chosen = data.files.find((file) => file.path === path); return ( @@ -49,14 +52,31 @@ export function FilesPanel({
    {chosen ? ( - setPath(null)} /> + onPath(null)} /> + ) : path !== null ? ( + onPath(null)} /> ) : ( - + )} ); } +/** A file the chat pointed to that the folders no longer have. */ +function Missing({ onBack }: { onBack(): void }) { + const t = useT().files; + return ( +
    + +

    + {t.preview.gone} +

    +
    + ); +} + function List({ bot, data, diff --git a/app/src/features/files/files.test.tsx b/app/src/features/files/files.test.tsx index 51c7e372..4e339844 100644 --- a/app/src/features/files/files.test.tsx +++ b/app/src/features/files/files.test.tsx @@ -98,4 +98,44 @@ describe("files panel", () => { fireEvent.click(toggle()); expect(toggle().getAttribute("aria-label")).toBe("Show files"); }); + + test("a Write line in the chat opens its file in the panel", async () => { + const { fake, scout } = crew(); + const file = fake.files.add(scout.id, "notes.md", { + text: "# Field notes", + writtenByBot: true, + }); + fake.chat.tool(scout.id, "Write", { + summary: "notes.md", + file: file.path, + status: "done", + }); + fake.chat.tool(scout.id, "Write", { + summary: "broken.md", + file: `${file.path}.broken`, + status: "failed", + }); + fake.chat.tool(scout.id, "Bash", { summary: "ls", status: "done" }); + await openScout(fake); + expect(screen.queryByRole("complementary", { name: "Files from Scout" })).toBeNull(); + // Only a call that changed a file, and worked, has the button. + expect(screen.getAllByRole("button", { name: /Show in files/ })).toHaveLength(1); + + fireEvent.click(screen.getByRole("button", { name: "Show in files: notes.md" })); + expect(await within(panel()).findByRole("heading", { name: "Field notes" })).toBeDefined(); + fireEvent.click(within(panel()).getByRole("button", { name: "All files" })); + expect(within(panel()).getByRole("list", { name: "Files" })).toBeDefined(); + }); + + test("a file the chat points to that is gone says so", async () => { + const { fake, scout } = crew(); + fake.chat.tool(scout.id, "Write", { + summary: "gone.md", + file: "C:Workgone.md", + status: "done", + }); + await openScout(fake); + fireEvent.click(screen.getByRole("button", { name: "Show in files: gone.md" })); + expect(await within(panel()).findByText("This file is no longer there.")).toBeDefined(); + }); }); diff --git a/app/src/features/files/showFile.ts b/app/src/features/files/showFile.ts new file mode 100644 index 00000000..25c0dae1 --- /dev/null +++ b/app/src/features/files/showFile.ts @@ -0,0 +1,6 @@ +// How a tool line in the chat asks for its file to be shown in the files +// panel (spec 15.1). Absent where there is no panel, so no button shows. + +import { createContext } from "react"; + +export const ShowFile = createContext<((path: string) => void) | null>(null); diff --git a/app/src/i18n/en/files.ts b/app/src/i18n/en/files.ts index a53eb3f1..12c6a0e4 100644 --- a/app/src/i18n/en/files.ts +++ b/app/src/i18n/en/files.ts @@ -4,6 +4,7 @@ export const files = { heading: "Files", panel: (bot: string) => `Files from ${bot}`, show: "Show files", + showInPanel: "Show in files", hide: "Hide files", close: "Close", refresh: "Refresh", diff --git a/app/src/i18n/es/files.ts b/app/src/i18n/es/files.ts index bb54a6ae..df10fb94 100644 --- a/app/src/i18n/es/files.ts +++ b/app/src/i18n/es/files.ts @@ -6,6 +6,7 @@ export const files: Messages["files"] = { heading: "Archivos", panel: (bot) => `Archivos de ${bot}`, show: "Mostrar archivos", + showInPanel: "Ver en archivos", hide: "Ocultar archivos", close: "Cerrar", refresh: "Actualizar", diff --git a/app/src/i18n/pt-BR/files.ts b/app/src/i18n/pt-BR/files.ts index d08230be..e719d9cf 100644 --- a/app/src/i18n/pt-BR/files.ts +++ b/app/src/i18n/pt-BR/files.ts @@ -6,6 +6,7 @@ export const files: Messages["files"] = { heading: "Arquivos", panel: (bot) => `Arquivos de ${bot}`, show: "Mostrar arquivos", + showInPanel: "Ver em arquivos", hide: "Ocultar arquivos", close: "Fechar", refresh: "Atualizar", diff --git a/docs/spec.md b/docs/spec.md index 80df2a09..f29e6815 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -608,7 +608,7 @@ Layout, como um app de mensagens: - **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. -- **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". Detalhes e arquivos dividem o mesmo lugar: abrir um fecha o outro. +- **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