Skip to content

Commit 8c8bcec

Browse files
authored
Merge pull request #29 from httpsphl/feat/show-file-from-chat
feat(app): a Write line in the chat opens its file in the files panel
2 parents 5c20b15 + f6c6a6b commit 8c8bcec

10 files changed

Lines changed: 151 additions & 29 deletions

File tree

‎app/src/dev/seedChats.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,12 @@ export function seedChats(fake: FakeBotloft, crew: Crew): void {
6969
["Write", "week-39.md"],
7070
] as const;
7171
for (const [name, summary] of files) {
72-
const call = chat.tool(crew.scout, name, { summary, input: JSON.stringify({ path: summary }) });
72+
const file = name === "Write" ? "C:\\Work\\summaries\\week-39.md" : null;
73+
const call = chat.tool(crew.scout, name, {
74+
summary,
75+
input: JSON.stringify({ path: summary }),
76+
file,
77+
});
7378
chat.finish(call, name === "Write" ? "Wrote 42 lines" : "ok");
7479
}
7580
at(93);

‎app/src/features/bots/BotView.tsx‎

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { Callout } from "../../ui/Callout";
1010
import { type Tab, Tabs, tabId } from "../../ui/Tabs";
1111
import { ChatView } from "../chat/ChatView";
1212
import { FilesPanel } from "../files/FilesPanel";
13+
import { ShowFile } from "../files/showFile";
1314
import { useBotFiles } from "../files/useBotFiles";
1415
import { SignInButton } from "../onboarding/SignIn";
1516
import { BotRoutines } from "../routines/RoutineList";
@@ -38,13 +39,30 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) {
3839
setSeenAt(now);
3940
setSince(now);
4041
}, [bot.id]);
42+
const [shown, setShown] = useState<string | null>(null);
43+
// biome-ignore lint/correctness/useExhaustiveDependencies: another bot's file is not this bot's
44+
useEffect(() => setShown(null), [bot.id]);
4145
const filesOpen = side === "files";
42-
const toggleFiles = () => {
46+
const seen = () => {
4347
// What was there when the panel opened or closed counts as seen.
4448
setSince(seenAt);
4549
setSeenAt(Date.now());
50+
};
51+
const toggleFiles = () => {
52+
seen();
53+
setShown(null);
4654
setSide(filesOpen ? null : "files");
4755
};
56+
// A tool line in the chat points to its file: read the list first, so the
57+
// file is in it when the panel goes to it.
58+
const showFile = async (path: string) => {
59+
if (!filesOpen) {
60+
seen();
61+
}
62+
setSide("files");
63+
await files.refresh();
64+
setShown(path);
65+
};
4866
const fresh = filesOpen ? 0 : files.files.filter((file) => file.modifiedAt > seenAt).length;
4967
const routines = useApp(useShallow((state) => routinesOf(state, bot.id)));
5068
const view = stateView(bot, crew.paused, t);
@@ -72,13 +90,24 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) {
7290
<div className="flex min-h-0 flex-1">
7391
<div role="tabpanel" aria-labelledby={tabId(pane)} className="flex min-h-0 min-w-0 flex-1">
7492
{pane === "chat" ? (
75-
<ChatView bot={bot} stopped={bot.paused || crew.paused} />
93+
<ShowFile.Provider value={showFile}>
94+
<ChatView bot={bot} stopped={bot.paused || crew.paused} />
95+
</ShowFile.Provider>
7696
) : (
7797
<BotRoutines bot={bot} />
7898
)}
7999
</div>
80100
{side === "details" && <Details bot={bot} onClose={() => setSide(null)} />}
81-
{filesOpen && <FilesPanel bot={bot} data={files} since={since} onClose={toggleFiles} />}
101+
{filesOpen && (
102+
<FilesPanel
103+
bot={bot}
104+
data={files}
105+
since={since}
106+
path={shown}
107+
onPath={setShown}
108+
onClose={toggleFiles}
109+
/>
110+
)}
82111
</div>
83112
</section>
84113
);

‎app/src/features/chat/ToolLines.tsx‎

Lines changed: 38 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
ChevronRight,
77
FilePen,
88
FileSearch,
9+
Files,
910
FileText,
1011
Globe,
1112
ListChecks,
@@ -19,10 +20,11 @@ import {
1920
Wrench,
2021
X,
2122
} from "lucide-react";
22-
import { useState } from "react";
23+
import { useContext, useState } from "react";
2324
import { useT } from "../../i18n";
2425
import type { ChatItem, ToolItem } from "../../lib/protocol.gen";
2526
import { useArrival } from "../../ui/motion";
27+
import { ShowFile } from "../files/showFile";
2628

2729
const ICONS: Record<string, LucideIcon> = {
2830
Bash: SquareTerminal,
@@ -79,31 +81,48 @@ function Status({ status }: { status: ToolItem["status"] }) {
7981
function ToolLine({ tool, createdAt }: { tool: ToolItem; createdAt: number }) {
8082
const t = useT();
8183
const [open, setOpen] = useState(false);
84+
const showFile = useContext(ShowFile);
8285
const arrival = useArrival(createdAt);
8386
const label = toolLabel(tool.name);
8487
const Icon = ICONS[label] ?? Wrench;
8588
const block =
8689
"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";
8790
return (
8891
<li className={arrival}>
89-
<button
90-
type="button"
91-
aria-expanded={open}
92-
onClick={() => setOpen(!open)}
93-
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"
94-
>
95-
<Icon aria-hidden size={14} className="shrink-0 text-muted" />
96-
<span className="shrink-0 font-medium">{label}</span>
97-
<span className="min-w-0 flex-1 truncate font-mono text-muted text-xs" title={tool.summary}>
98-
{tool.summary}
99-
</span>
100-
<Status status={tool.status} />
101-
<ChevronRight
102-
aria-hidden
103-
size={13}
104-
className={`shrink-0 text-muted transition-transform ${open ? "rotate-90" : ""}`}
105-
/>
106-
</button>
92+
<div className="flex items-center gap-1">
93+
<button
94+
type="button"
95+
aria-expanded={open}
96+
onClick={() => setOpen(!open)}
97+
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"
98+
>
99+
<Icon aria-hidden size={14} className="shrink-0 text-muted" />
100+
<span className="shrink-0 font-medium">{label}</span>
101+
<span
102+
className="min-w-0 flex-1 truncate font-mono text-muted text-xs"
103+
title={tool.summary}
104+
>
105+
{tool.summary}
106+
</span>
107+
<Status status={tool.status} />
108+
<ChevronRight
109+
aria-hidden
110+
size={13}
111+
className={`shrink-0 text-muted transition-transform ${open ? "rotate-90" : ""}`}
112+
/>
113+
</button>
114+
{showFile && tool.file && tool.status !== "failed" && (
115+
<button
116+
type="button"
117+
title={t.files.showInPanel}
118+
aria-label={`${t.files.showInPanel}: ${tool.summary}`}
119+
onClick={() => showFile(tool.file as string)}
120+
className="grid size-6 shrink-0 place-items-center rounded-md text-muted transition-colors hover:bg-sunken hover:text-ink"
121+
>
122+
<Files aria-hidden size={14} />
123+
</button>
124+
)}
125+
</div>
107126
{open && (
108127
<div className="mt-1 mb-2 ml-7 flex animate-rise flex-col gap-1.5" data-selectable>
109128
<p className="text-muted text-xs">{t.chat.tools.input}</p>

‎app/src/features/files/FilesPanel.tsx‎

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@
22
// first, the ones that showed up since the owner last looked marked new. A
33
// click shows the file in the panel itself.
44

5-
import { RefreshCw, X } from "lucide-react";
6-
import { useState } from "react";
5+
import { ArrowLeft, RefreshCw, X } from "lucide-react";
76
import { useT } from "../../i18n";
87
import { fileSize, fromNow } from "../../lib/format";
98
import type { Bot, BotFile } from "../../lib/protocol.gen";
@@ -18,16 +17,20 @@ export function FilesPanel({
1817
bot,
1918
data,
2019
since,
20+
path,
21+
onPath,
2122
onClose,
2223
}: {
2324
bot: Bot;
2425
data: BotFiles;
2526
/** Files modified after this (Unix ms) are marked new. */
2627
since: number;
28+
/** The file shown, or null for the list. */
29+
path: string | null;
30+
onPath(path: string | null): void;
2731
onClose(): void;
2832
}) {
2933
const t = useT().files;
30-
const [path, setPath] = useState<string | null>(null);
3134
const chosen = data.files.find((file) => file.path === path);
3235

3336
return (
@@ -49,14 +52,31 @@ export function FilesPanel({
4952
</div>
5053
</header>
5154
{chosen ? (
52-
<FilePreview bot={bot} file={chosen} onBack={() => setPath(null)} />
55+
<FilePreview bot={bot} file={chosen} onBack={() => onPath(null)} />
56+
) : path !== null ? (
57+
<Missing onBack={() => onPath(null)} />
5358
) : (
54-
<List bot={bot} data={data} since={since} onOpen={setPath} />
59+
<List bot={bot} data={data} since={since} onOpen={onPath} />
5560
)}
5661
</aside>
5762
);
5863
}
5964

65+
/** A file the chat pointed to that the folders no longer have. */
66+
function Missing({ onBack }: { onBack(): void }) {
67+
const t = useT().files;
68+
return (
69+
<div className="flex flex-col items-start gap-3 p-4">
70+
<Button variant="ghost" size="sm" icon={ArrowLeft} onClick={onBack}>
71+
{t.back}
72+
</Button>
73+
<p role="alert" className="text-ink-soft text-sm">
74+
{t.preview.gone}
75+
</p>
76+
</div>
77+
);
78+
}
79+
6080
function List({
6181
bot,
6282
data,

‎app/src/features/files/files.test.tsx‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,4 +98,44 @@ describe("files panel", () => {
9898
fireEvent.click(toggle());
9999
expect(toggle().getAttribute("aria-label")).toBe("Show files");
100100
});
101+
102+
test("a Write line in the chat opens its file in the panel", async () => {
103+
const { fake, scout } = crew();
104+
const file = fake.files.add(scout.id, "notes.md", {
105+
text: "# Field notes",
106+
writtenByBot: true,
107+
});
108+
fake.chat.tool(scout.id, "Write", {
109+
summary: "notes.md",
110+
file: file.path,
111+
status: "done",
112+
});
113+
fake.chat.tool(scout.id, "Write", {
114+
summary: "broken.md",
115+
file: `${file.path}.broken`,
116+
status: "failed",
117+
});
118+
fake.chat.tool(scout.id, "Bash", { summary: "ls", status: "done" });
119+
await openScout(fake);
120+
expect(screen.queryByRole("complementary", { name: "Files from Scout" })).toBeNull();
121+
// Only a call that changed a file, and worked, has the button.
122+
expect(screen.getAllByRole("button", { name: /Show in files/ })).toHaveLength(1);
123+
124+
fireEvent.click(screen.getByRole("button", { name: "Show in files: notes.md" }));
125+
expect(await within(panel()).findByRole("heading", { name: "Field notes" })).toBeDefined();
126+
fireEvent.click(within(panel()).getByRole("button", { name: "All files" }));
127+
expect(within(panel()).getByRole("list", { name: "Files" })).toBeDefined();
128+
});
129+
130+
test("a file the chat points to that is gone says so", async () => {
131+
const { fake, scout } = crew();
132+
fake.chat.tool(scout.id, "Write", {
133+
summary: "gone.md",
134+
file: "C:Workgone.md",
135+
status: "done",
136+
});
137+
await openScout(fake);
138+
fireEvent.click(screen.getByRole("button", { name: "Show in files: gone.md" }));
139+
expect(await within(panel()).findByText("This file is no longer there.")).toBeDefined();
140+
});
101141
});

‎app/src/features/files/showFile.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
// How a tool line in the chat asks for its file to be shown in the files
2+
// panel (spec 15.1). Absent where there is no panel, so no button shows.
3+
4+
import { createContext } from "react";
5+
6+
export const ShowFile = createContext<((path: string) => void) | null>(null);

‎app/src/i18n/en/files.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export const files = {
44
heading: "Files",
55
panel: (bot: string) => `Files from ${bot}`,
66
show: "Show files",
7+
showInPanel: "Show in files",
78
hide: "Hide files",
89
close: "Close",
910
refresh: "Refresh",

‎app/src/i18n/es/files.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export const files: Messages["files"] = {
66
heading: "Archivos",
77
panel: (bot) => `Archivos de ${bot}`,
88
show: "Mostrar archivos",
9+
showInPanel: "Ver en archivos",
910
hide: "Ocultar archivos",
1011
close: "Cerrar",
1112
refresh: "Actualizar",

‎app/src/i18n/pt-BR/files.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export const files: Messages["files"] = {
66
heading: "Arquivos",
77
panel: (bot) => `Arquivos de ${bot}`,
88
show: "Mostrar arquivos",
9+
showInPanel: "Ver em arquivos",
910
hide: "Ocultar arquivos",
1011
close: "Fechar",
1112
refresh: "Atualizar",

‎docs/spec.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -608,7 +608,7 @@ Layout, como um app de mensagens:
608608
- **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".
609609
- **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.
610610
- **Detalhes do bot** (pasta, instruções, sessão) ficam num painel, fora do caminho da conversa.
611-
- **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.
611+
- **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.
612612

613613
### 15.2 Comandos Tauri
614614

0 commit comments

Comments
 (0)