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
7 changes: 6 additions & 1 deletion app/src/dev/seedChats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
35 changes: 32 additions & 3 deletions app/src/features/bots/BotView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -38,13 +39,30 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) {
setSeenAt(now);
setSince(now);
}, [bot.id]);
const [shown, setShown] = useState<string | null>(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);
Expand Down Expand Up @@ -72,13 +90,24 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) {
<div className="flex min-h-0 flex-1">
<div role="tabpanel" aria-labelledby={tabId(pane)} className="flex min-h-0 min-w-0 flex-1">
{pane === "chat" ? (
<ChatView bot={bot} stopped={bot.paused || crew.paused} />
<ShowFile.Provider value={showFile}>
<ChatView bot={bot} stopped={bot.paused || crew.paused} />
</ShowFile.Provider>
) : (
<BotRoutines bot={bot} />
)}
</div>
{side === "details" && <Details bot={bot} onClose={() => setSide(null)} />}
{filesOpen && <FilesPanel bot={bot} data={files} since={since} onClose={toggleFiles} />}
{filesOpen && (
<FilesPanel
bot={bot}
data={files}
since={since}
path={shown}
onPath={setShown}
onClose={toggleFiles}
/>
)}
</div>
</section>
);
Expand Down
57 changes: 38 additions & 19 deletions app/src/features/chat/ToolLines.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
ChevronRight,
FilePen,
FileSearch,
Files,
FileText,
Globe,
ListChecks,
Expand All @@ -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<string, LucideIcon> = {
Bash: SquareTerminal,
Expand Down Expand Up @@ -79,31 +81,48 @@ 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;
const block =
"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 (
<li className={arrival}>
<button
type="button"
aria-expanded={open}
onClick={() => setOpen(!open)}
className="group flex w-full min-w-0 items-center gap-2 rounded-lg px-1.5 py-1 text-left text-sm transition-colors hover:bg-sunken"
>
<Icon aria-hidden size={14} className="shrink-0 text-muted" />
<span className="shrink-0 font-medium">{label}</span>
<span className="min-w-0 flex-1 truncate font-mono text-muted text-xs" title={tool.summary}>
{tool.summary}
</span>
<Status status={tool.status} />
<ChevronRight
aria-hidden
size={13}
className={`shrink-0 text-muted transition-transform ${open ? "rotate-90" : ""}`}
/>
</button>
<div className="flex items-center gap-1">
<button
type="button"
aria-expanded={open}
onClick={() => setOpen(!open)}
className="group flex min-w-0 flex-1 items-center gap-2 rounded-lg px-1.5 py-1 text-left text-sm transition-colors hover:bg-sunken"
>
<Icon aria-hidden size={14} className="shrink-0 text-muted" />
<span className="shrink-0 font-medium">{label}</span>
<span
className="min-w-0 flex-1 truncate font-mono text-muted text-xs"
title={tool.summary}
>
{tool.summary}
</span>
<Status status={tool.status} />
<ChevronRight
aria-hidden
size={13}
className={`shrink-0 text-muted transition-transform ${open ? "rotate-90" : ""}`}
/>
</button>
{showFile && tool.file && tool.status !== "failed" && (
<button
type="button"
title={t.files.showInPanel}
aria-label={`${t.files.showInPanel}: ${tool.summary}`}
onClick={() => showFile(tool.file as string)}
className="grid size-6 shrink-0 place-items-center rounded-md text-muted transition-colors hover:bg-sunken hover:text-ink"
>
<Files aria-hidden size={14} />
</button>
)}
</div>
{open && (
<div className="mt-1 mb-2 ml-7 flex animate-rise flex-col gap-1.5" data-selectable>
<p className="text-muted text-xs">{t.chat.tools.input}</p>
Expand Down
30 changes: 25 additions & 5 deletions app/src/features/files/FilesPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<string | null>(null);
const chosen = data.files.find((file) => file.path === path);

return (
Expand All @@ -49,14 +52,31 @@ export function FilesPanel({
</div>
</header>
{chosen ? (
<FilePreview bot={bot} file={chosen} onBack={() => setPath(null)} />
<FilePreview bot={bot} file={chosen} onBack={() => onPath(null)} />
) : path !== null ? (
<Missing onBack={() => onPath(null)} />
) : (
<List bot={bot} data={data} since={since} onOpen={setPath} />
<List bot={bot} data={data} since={since} onOpen={onPath} />
)}
</aside>
);
}

/** A file the chat pointed to that the folders no longer have. */
function Missing({ onBack }: { onBack(): void }) {
const t = useT().files;
return (
<div className="flex flex-col items-start gap-3 p-4">
<Button variant="ghost" size="sm" icon={ArrowLeft} onClick={onBack}>
{t.back}
</Button>
<p role="alert" className="text-ink-soft text-sm">
{t.preview.gone}
</p>
</div>
);
}

function List({
bot,
data,
Expand Down
40 changes: 40 additions & 0 deletions app/src/features/files/files.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
6 changes: 6 additions & 0 deletions app/src/features/files/showFile.ts
Original file line number Diff line number Diff line change
@@ -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);
1 change: 1 addition & 0 deletions app/src/i18n/en/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions app/src/i18n/es/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions app/src/i18n/pt-BR/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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.
- **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

Expand Down
Loading