Skip to content

Commit a53700a

Browse files
authored
Merge pull request #125 from httpsphl/feat/share-file
feat: bots share files as cards in the chat
2 parents 7440dca + ddff1bb commit a53700a

32 files changed

Lines changed: 707 additions & 40 deletions

File tree

‎app/src-tauri/src/lib.rs‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
mod claude;
55
mod daemon;
66
mod open;
7+
mod save;
78
mod sign_in;
89
#[cfg(unix)]
910
mod terminal;
@@ -118,6 +119,13 @@ async fn reveal_file(path: String) -> Result<(), String> {
118119
.await
119120
}
120121

122+
/// Saves a copy of a bot's file where the owner picks with the system's
123+
/// Save dialog. Returns false when they cancel.
124+
#[tauri::command]
125+
async fn save_file_as(app: tauri::AppHandle, path: String) -> Result<bool, String> {
126+
blocking(move || save::copy_as(&app, Path::new(&path))).await
127+
}
128+
121129
/// Opens Botloft when the owner signs in to Windows, or stops doing so.
122130
#[tauri::command]
123131
async fn open_at_sign_in(on: bool) -> Result<(), String> {
@@ -184,6 +192,7 @@ pub fn run() {
184192
open_path,
185193
open_file,
186194
reveal_file,
195+
save_file_as,
187196
open_url,
188197
open_at_sign_in,
189198
launched_at_sign_in

‎app/src-tauri/src/save.rs‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
//! Saving a copy of a bot's file where the owner picks (spec 15.2). The
2+
//! system's Save dialog chooses the place, so a page can never write a file
3+
//! anywhere the owner did not pick.
4+
5+
use std::path::Path;
6+
7+
use tauri::{AppHandle, Manager as _};
8+
use tauri_plugin_dialog::DialogExt as _;
9+
10+
/// Asks where to save a copy of `path` and copies it there. `Ok(false)`
11+
/// when the owner cancels.
12+
pub fn copy_as(app: &AppHandle, path: &Path) -> Result<bool, String> {
13+
if !path.is_absolute() || !path.is_file() {
14+
return Err(format!("{} is not a file", path.display()));
15+
}
16+
let name = path
17+
.file_name()
18+
.map(|name| name.to_string_lossy().into_owned())
19+
.unwrap_or_default();
20+
let mut dialog = app.dialog().file().set_file_name(&name);
21+
if let Some(extension) = path.extension().and_then(|extension| extension.to_str()) {
22+
dialog = dialog.add_filter(extension.to_uppercase(), &[extension]);
23+
}
24+
if let Some(window) = app.get_webview_window("main") {
25+
dialog = dialog.set_parent(&window);
26+
}
27+
let Some(chosen) = dialog.blocking_save_file() else {
28+
return Ok(false);
29+
};
30+
let target = chosen.into_path().map_err(|err| err.to_string())?;
31+
// The system dialog already asked before replacing a file; copying a
32+
// file onto itself would empty it.
33+
if same_file(path, &target) {
34+
return Ok(true);
35+
}
36+
std::fs::copy(path, &target)
37+
.map(drop)
38+
.map_err(|err| format!("could not save {}: {err}", target.display()))?;
39+
Ok(true)
40+
}
41+
42+
fn same_file(a: &Path, b: &Path) -> bool {
43+
match (a.canonicalize(), b.canonicalize()) {
44+
(Ok(a), Ok(b)) => a == b,
45+
_ => false,
46+
}
47+
}

‎app/src/dev/seedChats.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,11 +97,19 @@ export function seedChats(fake: FakeBotloft, crew: Crew): void {
9797
writtenByBot: true,
9898
at: fake.now,
9999
});
100-
made.add(crew.scout, "papers.csv", {
100+
const papers = made.add(crew.scout, "papers.csv", {
101101
text: "paper,pages,deep_read\nRetrieval at scale,14,yes\nAgents that plan,22,yes\nSmall evals,9,maybe\n",
102102
at: fake.now + MINUTE,
103103
});
104-
made.add(crew.scout, "week-39-report.docx", { text: "docx", at: fake.now + 2 * MINUTE });
104+
const report = made.add(crew.scout, "week-39-report.docx", {
105+
text: "docx",
106+
at: fake.now + 2 * MINUTE,
107+
});
108+
const shared = chat.tool(crew.scout, "mcp__botloft__share_file", {
109+
summary: "week-39-report.docx, papers.csv",
110+
input: JSON.stringify({ files: [report.path, papers.path] }),
111+
});
112+
chat.finish(shared, JSON.stringify({ shown: [report, papers] }));
105113
at(92);
106114
chat.reply(crew.scout, SUMMARY);
107115
const handoff = chat.tool(crew.scout, "mcp__botloft__send_message", {

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { X } from "lucide-react";
22
import { useEffect, useMemo, useState } from "react";
33
import { useShallow } from "zustand/react/shallow";
44
import { useT } from "../../i18n";
5-
import type { Bot, Crew } from "../../lib/protocol.gen";
5+
import type { Bot, BotFile, Crew } from "../../lib/protocol.gen";
66
import { useWindowVisible } from "../../shell/visibility";
77
import { type BotPanel, routinesOf } from "../../store/app";
88
import { useApp } from "../../store/context";
@@ -74,6 +74,8 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) {
7474
setSince(now);
7575
}, [bot.id]);
7676
const [shown, setShown] = useState<string | null>(null);
77+
// A file shared in the chat, for when the panel's list leaves it out.
78+
const [described, setDescribed] = useState<BotFile | null>(null);
7779
// biome-ignore lint/correctness/useExhaustiveDependencies: another bot's file is not this bot's
7880
useEffect(() => setShown(null), [bot.id]);
7981
const filesOpen = side === "files";
@@ -90,12 +92,13 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) {
9092
// A tool line in the chat points to its file: read the list first, so the
9193
// file is in it when the panel goes to it.
9294
// Stable, so the chat does not re-render when this view does.
93-
const showFile = useStable(async (path: string) => {
95+
const showFile = useStable(async (path: string, file?: BotFile) => {
9496
if (!filesOpen) {
9597
seen();
9698
}
9799
setSide("files");
98100
await files.refresh();
101+
setDescribed(file ?? null);
99102
setShown(path);
100103
});
101104
const fresh = filesOpen ? 0 : files.files.filter((file) => file.modifiedAt > seenAt).length;
@@ -213,6 +216,7 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) {
213216
data={files}
214217
since={since}
215218
path={shown}
219+
described={described}
216220
onPath={setShown}
217221
onClose={toggleFiles}
218222
/>

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import { BotAvatar, moodOf } from "../bots/BotAvatar";
1212
import { ApprovalCard } from "./ApprovalCard";
1313
import { Markdown } from "./Markdown";
1414
import { runParts, splitDraft } from "./rows";
15+
import { SharedFiles } from "./SharedFiles";
16+
import { isSharedFiles } from "./shared";
1517
import { ToolLines } from "./ToolLines";
1618

1719
export interface Live {
@@ -124,7 +126,11 @@ function BotRunView({
124126
const head = part[0] as ChatItem;
125127
switch (head.body.kind) {
126128
case "tool":
127-
return <ToolLines key={head.id} items={part} />;
129+
return isSharedFiles(head) ? (
130+
<SharedFiles key={head.id} item={head} botId={bot.id} bot={bot.name} />
131+
) : (
132+
<ToolLines key={head.id} items={part} />
133+
);
128134
case "reply":
129135
// It was already on screen as it was written.
130136
return <Markdown key={head.id} text={head.body.text} />;
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
// Files the bot shared with `share_file` (spec 10, 15.3): one card each,
2+
// with the file's tile or picture, its name, kind and size, and buttons to
3+
// preview it, open it and save a copy anywhere.
4+
5+
import { Download, ExternalLink } from "lucide-react";
6+
import { useContext } from "react";
7+
import { useT } from "../../i18n";
8+
import { fileSize } from "../../lib/format";
9+
import type { BotFile, ChatItem } from "../../lib/protocol.gen";
10+
import { useHost } from "../../store/context";
11+
import { Button } from "../../ui/Button";
12+
import { useArrival } from "../../ui/motion";
13+
import { attempt } from "../../ui/toast";
14+
import { fileKind, kindIcon } from "../files/kinds";
15+
import { ShowFile } from "../files/showFile";
16+
import { useFileImage } from "./images";
17+
import { sharedFiles } from "./shared";
18+
19+
/** "PDF" from report.pdf; nothing for a name without one. */
20+
function extension(name: string): string {
21+
const dot = name.lastIndexOf(".");
22+
return dot > 0 ? name.slice(dot + 1).toUpperCase() : "";
23+
}
24+
25+
function Picture({ botId, file }: { botId: string; file: BotFile }) {
26+
const source = useFileImage(botId, file);
27+
if (source.kind !== "ready") {
28+
return null;
29+
}
30+
return (
31+
<img
32+
src={source.url}
33+
alt={file.name}
34+
className="block max-h-56 w-full bg-sunken object-contain"
35+
/>
36+
);
37+
}
38+
39+
function FileCard({ botId, file }: { botId: string; file: BotFile }) {
40+
const t = useT().files;
41+
const host = useHost();
42+
const showFile = useContext(ShowFile);
43+
const kind = fileKind(file.mediaType, file.name);
44+
const Icon = kindIcon(kind);
45+
const what = [extension(file.name), fileSize(file.size)].filter(Boolean).join(" · ");
46+
const about = (
47+
<>
48+
<span aria-hidden className="file-tile" data-kind={kind}>
49+
<Icon size={18} />
50+
</span>
51+
<span className="min-w-0 flex-1">
52+
<span className="block truncate font-medium text-sm" title={file.path}>
53+
{file.name}
54+
</span>
55+
<span className="block truncate text-muted text-xs">{what}</span>
56+
</span>
57+
</>
58+
);
59+
return (
60+
<div className="@container flex w-96 max-w-full flex-col overflow-hidden rounded-xl border border-line bg-panel shadow-sm">
61+
<Picture botId={botId} file={file} />
62+
<div className="flex items-center gap-1 p-1.5">
63+
{/* Where there is a files panel, the file itself opens its preview. */}
64+
{showFile ? (
65+
<button
66+
type="button"
67+
aria-label={`${t.previewOne}: ${file.name}`}
68+
title={t.previewOne}
69+
onClick={() => showFile(file.path, file)}
70+
className="flex min-w-0 flex-1 items-center gap-2.5 rounded-lg p-1 text-left transition-colors hover:bg-sunken"
71+
>
72+
{about}
73+
</button>
74+
) : (
75+
<div className="flex min-w-0 flex-1 items-center gap-2.5 p-1">{about}</div>
76+
)}
77+
<Button
78+
variant="ghost"
79+
size="sm"
80+
icon={ExternalLink}
81+
label={t.open}
82+
onClick={() => attempt(t.failed.open, () => host.openFile(file.path))}
83+
/>
84+
{/* A narrow card keeps the name and drops the words on the button. */}
85+
<Button
86+
size="sm"
87+
icon={Download}
88+
aria-label={t.save}
89+
title={t.saveOne(file.name)}
90+
onClick={() => attempt(t.failed.save, () => host.saveFileAs(file.path))}
91+
>
92+
<span className="hidden @[22rem]:inline">{t.save}</span>
93+
</Button>
94+
</div>
95+
</div>
96+
);
97+
}
98+
99+
export function SharedFiles({ item, botId, bot }: { item: ChatItem; botId: string; bot: string }) {
100+
const t = useT().files;
101+
const arrival = useArrival(item.createdAt);
102+
if (item.body.kind !== "tool") {
103+
return null;
104+
}
105+
const files = sharedFiles(item.body);
106+
if (files.length === 0) {
107+
return null;
108+
}
109+
return (
110+
<ul aria-label={t.shared(bot)} className={`flex flex-wrap gap-2 ${arrival}`}>
111+
{files.map((file) => (
112+
<li key={file.path} className="max-w-full">
113+
<FileCard botId={botId} file={file} />
114+
</li>
115+
))}
116+
</ul>
117+
);
118+
}

‎app/src/features/chat/images.ts‎

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
// Images the owner attached, as data URLs for thumbnails. What the app sent
22
// in this session is known already; the rest is read back from the bot's
3-
// folder with `attachments.read` (spec 9.5). The CSP allows `data:` images
4-
// only, so no blob URLs.
3+
// folder with `attachments.read` (spec 9.5). Images a bot shares are read
4+
// with `files.read` (spec 8.4). The CSP allows `data:` images only, so no
5+
// blob URLs.
56

67
import { useEffect, useState } from "react";
7-
import type { Attachment } from "../../lib/protocol.gen";
8+
import type { Attachment, BotFile } from "../../lib/protocol.gen";
89
import { useApi } from "../../store/context";
910

1011
/** Types the webview shows safely as `<img>`. */
@@ -23,10 +24,10 @@ export function dataUrl(mediaType: string, base64: string): string {
2324
return `data:${mediaType};base64,${base64}`;
2425
}
2526

26-
/** Remembers an image the app has the bytes of. */
27-
export function rememberImage(attachmentId: string, url: string): void {
28-
cache.delete(attachmentId);
29-
cache.set(attachmentId, url);
27+
/** Remembers an image the app has the bytes of, by attachment id or file. */
28+
export function rememberImage(key: string, url: string): void {
29+
cache.delete(key);
30+
cache.set(key, url);
3031
while (cache.size > CACHE_MAX) {
3132
const oldest = cache.keys().next().value;
3233
if (oldest === undefined) {
@@ -76,3 +77,39 @@ export function useImage(attachment: Attachment): ImageSource {
7677

7778
return source;
7879
}
80+
81+
/** The thumbnail of a file a bot shared, read again when the file changes. */
82+
export function useFileImage(botId: string, file: BotFile): ImageSource {
83+
const api = useApi();
84+
const shown = isImage(file.mediaType) && file.size <= THUMBNAIL_MAX_BYTES;
85+
const key = `file:${file.path}:${file.modifiedAt}`;
86+
const [source, setSource] = useState<ImageSource>(() => {
87+
const known = cache.get(key);
88+
if (known) {
89+
return { kind: "ready", url: known };
90+
}
91+
return shown ? { kind: "loading" } : { kind: "none" };
92+
});
93+
94+
useEffect(() => {
95+
if (!shown || cache.has(key)) {
96+
return;
97+
}
98+
let alive = true;
99+
api.call("files.read", { botId, path: file.path }).then(
100+
(read) => {
101+
const url = dataUrl(read.mediaType, read.data);
102+
rememberImage(key, url);
103+
if (alive) {
104+
setSource({ kind: "ready", url });
105+
}
106+
},
107+
() => alive && setSource({ kind: "missing" }),
108+
);
109+
return () => {
110+
alive = false;
111+
};
112+
}, [api, botId, file.path, key, shown]);
113+
114+
return source;
115+
}

‎app/src/features/chat/rows.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// line starts every day.
44

55
import type { ChatItem, Message, NoticeItem } from "../../lib/protocol.gen";
6+
import { isSharedFiles } from "./shared";
67

78
export type Row =
89
| { kind: "day"; key: string; at: number }
@@ -48,12 +49,20 @@ export function chatRows(items: ChatItem[]): Row[] {
4849
return rows;
4950
}
5051

51-
/** Splits a run into replies, approvals and turns alone, and tool calls together. */
52+
/** Tool calls that run together as lines; shared files are cards of their own. */
53+
function isLine(item: ChatItem | undefined): boolean {
54+
return item?.body.kind === "tool" && !isSharedFiles(item);
55+
}
56+
57+
/**
58+
* Splits a run into replies, approvals, shared files and turns alone, and
59+
* tool calls together.
60+
*/
5261
export function runParts(items: ChatItem[]): ChatItem[][] {
5362
const parts: ChatItem[][] = [];
5463
for (const item of items) {
5564
const last = parts.at(-1);
56-
if (item.body.kind === "tool" && last?.[0]?.body.kind === "tool") {
65+
if (last && isLine(item) && isLine(last[0])) {
5766
last.push(item);
5867
} else {
5968
parts.push([item]);

0 commit comments

Comments
 (0)