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
9 changes: 9 additions & 0 deletions app/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
mod claude;
mod daemon;
mod open;
mod save;
mod sign_in;
#[cfg(unix)]
mod terminal;
Expand Down Expand Up @@ -118,6 +119,13 @@ async fn reveal_file(path: String) -> Result<(), String> {
.await
}

/// Saves a copy of a bot's file where the owner picks with the system's
/// Save dialog. Returns false when they cancel.
#[tauri::command]
async fn save_file_as(app: tauri::AppHandle, path: String) -> Result<bool, String> {
blocking(move || save::copy_as(&app, Path::new(&path))).await
}

/// Opens Botloft when the owner signs in to Windows, or stops doing so.
#[tauri::command]
async fn open_at_sign_in(on: bool) -> Result<(), String> {
Expand Down Expand Up @@ -184,6 +192,7 @@ pub fn run() {
open_path,
open_file,
reveal_file,
save_file_as,
open_url,
open_at_sign_in,
launched_at_sign_in
Expand Down
47 changes: 47 additions & 0 deletions app/src-tauri/src/save.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
//! Saving a copy of a bot's file where the owner picks (spec 15.2). The
//! system's Save dialog chooses the place, so a page can never write a file
//! anywhere the owner did not pick.

use std::path::Path;

use tauri::{AppHandle, Manager as _};
use tauri_plugin_dialog::DialogExt as _;

/// Asks where to save a copy of `path` and copies it there. `Ok(false)`
/// when the owner cancels.
pub fn copy_as(app: &AppHandle, path: &Path) -> Result<bool, String> {
if !path.is_absolute() || !path.is_file() {
return Err(format!("{} is not a file", path.display()));
}
let name = path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default();
let mut dialog = app.dialog().file().set_file_name(&name);
if let Some(extension) = path.extension().and_then(|extension| extension.to_str()) {
dialog = dialog.add_filter(extension.to_uppercase(), &[extension]);
}
if let Some(window) = app.get_webview_window("main") {
dialog = dialog.set_parent(&window);
}
let Some(chosen) = dialog.blocking_save_file() else {
return Ok(false);
};
let target = chosen.into_path().map_err(|err| err.to_string())?;
// The system dialog already asked before replacing a file; copying a
// file onto itself would empty it.
if same_file(path, &target) {
return Ok(true);
}
std::fs::copy(path, &target)
.map(drop)
.map_err(|err| format!("could not save {}: {err}", target.display()))?;
Ok(true)
}

fn same_file(a: &Path, b: &Path) -> bool {
match (a.canonicalize(), b.canonicalize()) {
(Ok(a), Ok(b)) => a == b,
_ => false,
}
}
12 changes: 10 additions & 2 deletions app/src/dev/seedChats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,11 +97,19 @@ export function seedChats(fake: FakeBotloft, crew: Crew): void {
writtenByBot: true,
at: fake.now,
});
made.add(crew.scout, "papers.csv", {
const papers = made.add(crew.scout, "papers.csv", {
text: "paper,pages,deep_read\nRetrieval at scale,14,yes\nAgents that plan,22,yes\nSmall evals,9,maybe\n",
at: fake.now + MINUTE,
});
made.add(crew.scout, "week-39-report.docx", { text: "docx", at: fake.now + 2 * MINUTE });
const report = made.add(crew.scout, "week-39-report.docx", {
text: "docx",
at: fake.now + 2 * MINUTE,
});
const shared = chat.tool(crew.scout, "mcp__botloft__share_file", {
summary: "week-39-report.docx, papers.csv",
input: JSON.stringify({ files: [report.path, papers.path] }),
});
chat.finish(shared, JSON.stringify({ shown: [report, papers] }));
at(92);
chat.reply(crew.scout, SUMMARY);
const handoff = chat.tool(crew.scout, "mcp__botloft__send_message", {
Expand Down
8 changes: 6 additions & 2 deletions app/src/features/bots/BotView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { X } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { useT } from "../../i18n";
import type { Bot, Crew } from "../../lib/protocol.gen";
import type { Bot, BotFile, Crew } from "../../lib/protocol.gen";
import { useWindowVisible } from "../../shell/visibility";
import { type BotPanel, routinesOf } from "../../store/app";
import { useApp } from "../../store/context";
Expand Down Expand Up @@ -74,6 +74,8 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) {
setSince(now);
}, [bot.id]);
const [shown, setShown] = useState<string | null>(null);
// A file shared in the chat, for when the panel's list leaves it out.
const [described, setDescribed] = useState<BotFile | 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";
Expand All @@ -90,12 +92,13 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) {
// 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.
// Stable, so the chat does not re-render when this view does.
const showFile = useStable(async (path: string) => {
const showFile = useStable(async (path: string, file?: BotFile) => {
if (!filesOpen) {
seen();
}
setSide("files");
await files.refresh();
setDescribed(file ?? null);
setShown(path);
});
const fresh = filesOpen ? 0 : files.files.filter((file) => file.modifiedAt > seenAt).length;
Expand Down Expand Up @@ -213,6 +216,7 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) {
data={files}
since={since}
path={shown}
described={described}
onPath={setShown}
onClose={toggleFiles}
/>
Expand Down
8 changes: 7 additions & 1 deletion app/src/features/chat/BotRun.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { BotAvatar, moodOf } from "../bots/BotAvatar";
import { ApprovalCard } from "./ApprovalCard";
import { Markdown } from "./Markdown";
import { runParts, splitDraft } from "./rows";
import { SharedFiles } from "./SharedFiles";
import { isSharedFiles } from "./shared";
import { ToolLines } from "./ToolLines";

export interface Live {
Expand Down Expand Up @@ -124,7 +126,11 @@ function BotRunView({
const head = part[0] as ChatItem;
switch (head.body.kind) {
case "tool":
return <ToolLines key={head.id} items={part} />;
return isSharedFiles(head) ? (
<SharedFiles key={head.id} item={head} botId={bot.id} bot={bot.name} />
) : (
<ToolLines key={head.id} items={part} />
);
case "reply":
// It was already on screen as it was written.
return <Markdown key={head.id} text={head.body.text} />;
Expand Down
118 changes: 118 additions & 0 deletions app/src/features/chat/SharedFiles.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// Files the bot shared with `share_file` (spec 10, 15.3): one card each,
// with the file's tile or picture, its name, kind and size, and buttons to
// preview it, open it and save a copy anywhere.

import { Download, ExternalLink } from "lucide-react";
import { useContext } from "react";
import { useT } from "../../i18n";
import { fileSize } from "../../lib/format";
import type { BotFile, ChatItem } from "../../lib/protocol.gen";
import { useHost } from "../../store/context";
import { Button } from "../../ui/Button";
import { useArrival } from "../../ui/motion";
import { attempt } from "../../ui/toast";
import { fileKind, kindIcon } from "../files/kinds";
import { ShowFile } from "../files/showFile";
import { useFileImage } from "./images";
import { sharedFiles } from "./shared";

/** "PDF" from report.pdf; nothing for a name without one. */
function extension(name: string): string {
const dot = name.lastIndexOf(".");
return dot > 0 ? name.slice(dot + 1).toUpperCase() : "";
}

function Picture({ botId, file }: { botId: string; file: BotFile }) {
const source = useFileImage(botId, file);
if (source.kind !== "ready") {
return null;
}
return (
<img
src={source.url}
alt={file.name}
className="block max-h-56 w-full bg-sunken object-contain"
/>
);
}

function FileCard({ botId, file }: { botId: string; file: BotFile }) {
const t = useT().files;
const host = useHost();
const showFile = useContext(ShowFile);
const kind = fileKind(file.mediaType, file.name);
const Icon = kindIcon(kind);
const what = [extension(file.name), fileSize(file.size)].filter(Boolean).join(" · ");
const about = (
<>
<span aria-hidden className="file-tile" data-kind={kind}>
<Icon size={18} />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium text-sm" title={file.path}>
{file.name}
</span>
<span className="block truncate text-muted text-xs">{what}</span>
</span>
</>
);
return (
<div className="@container flex w-96 max-w-full flex-col overflow-hidden rounded-xl border border-line bg-panel shadow-sm">
<Picture botId={botId} file={file} />
<div className="flex items-center gap-1 p-1.5">
{/* Where there is a files panel, the file itself opens its preview. */}
{showFile ? (
<button
type="button"
aria-label={`${t.previewOne}: ${file.name}`}
title={t.previewOne}
onClick={() => showFile(file.path, file)}
className="flex min-w-0 flex-1 items-center gap-2.5 rounded-lg p-1 text-left transition-colors hover:bg-sunken"
>
{about}
</button>
) : (
<div className="flex min-w-0 flex-1 items-center gap-2.5 p-1">{about}</div>
)}
<Button
variant="ghost"
size="sm"
icon={ExternalLink}
label={t.open}
onClick={() => attempt(t.failed.open, () => host.openFile(file.path))}
/>
{/* A narrow card keeps the name and drops the words on the button. */}
<Button
size="sm"
icon={Download}
aria-label={t.save}
title={t.saveOne(file.name)}
onClick={() => attempt(t.failed.save, () => host.saveFileAs(file.path))}
>
<span className="hidden @[22rem]:inline">{t.save}</span>
</Button>
</div>
</div>
);
}

export function SharedFiles({ item, botId, bot }: { item: ChatItem; botId: string; bot: string }) {
const t = useT().files;
const arrival = useArrival(item.createdAt);
if (item.body.kind !== "tool") {
return null;
}
const files = sharedFiles(item.body);
if (files.length === 0) {
return null;
}
return (
<ul aria-label={t.shared(bot)} className={`flex flex-wrap gap-2 ${arrival}`}>
{files.map((file) => (
<li key={file.path} className="max-w-full">
<FileCard botId={botId} file={file} />
</li>
))}
</ul>
);
}
51 changes: 44 additions & 7 deletions app/src/features/chat/images.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
// Images the owner attached, as data URLs for thumbnails. What the app sent
// in this session is known already; the rest is read back from the bot's
// folder with `attachments.read` (spec 9.5). The CSP allows `data:` images
// only, so no blob URLs.
// folder with `attachments.read` (spec 9.5). Images a bot shares are read
// with `files.read` (spec 8.4). The CSP allows `data:` images only, so no
// blob URLs.

import { useEffect, useState } from "react";
import type { Attachment } from "../../lib/protocol.gen";
import type { Attachment, BotFile } from "../../lib/protocol.gen";
import { useApi } from "../../store/context";

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

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

return source;
}

/** The thumbnail of a file a bot shared, read again when the file changes. */
export function useFileImage(botId: string, file: BotFile): ImageSource {
const api = useApi();
const shown = isImage(file.mediaType) && file.size <= THUMBNAIL_MAX_BYTES;
const key = `file:${file.path}:${file.modifiedAt}`;
const [source, setSource] = useState<ImageSource>(() => {
const known = cache.get(key);
if (known) {
return { kind: "ready", url: known };
}
return shown ? { kind: "loading" } : { kind: "none" };
});

useEffect(() => {
if (!shown || cache.has(key)) {
return;
}
let alive = true;
api.call("files.read", { botId, path: file.path }).then(
(read) => {
const url = dataUrl(read.mediaType, read.data);
rememberImage(key, url);
if (alive) {
setSource({ kind: "ready", url });
}
},
() => alive && setSource({ kind: "missing" }),
);
return () => {
alive = false;
};
}, [api, botId, file.path, key, shown]);

return source;
}
13 changes: 11 additions & 2 deletions app/src/features/chat/rows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// line starts every day.

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

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

/** Splits a run into replies, approvals and turns alone, and tool calls together. */
/** Tool calls that run together as lines; shared files are cards of their own. */
function isLine(item: ChatItem | undefined): boolean {
return item?.body.kind === "tool" && !isSharedFiles(item);
}

/**
* Splits a run into replies, approvals, shared files and turns alone, and
* tool calls together.
*/
export function runParts(items: ChatItem[]): ChatItem[][] {
const parts: ChatItem[][] = [];
for (const item of items) {
const last = parts.at(-1);
if (item.body.kind === "tool" && last?.[0]?.body.kind === "tool") {
if (last && isLine(item) && isLine(last[0])) {
last.push(item);
} else {
parts.push([item]);
Expand Down
Loading
Loading