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
79 changes: 78 additions & 1 deletion app/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,59 @@ async fn open_path(path: String) -> Result<(), String> {
.await
}

/// Extensions `open_file` will open with the program Windows picks. Anything
/// else could be a program or a script that runs when opened.
const OPENABLE: [&str; 24] = [
"pdf", "png", "jpg", "jpeg", "gif", "webp", "svg", "txt", "md", "csv", "json", "log", "html",
"htm", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "mp3", "wav", "mp4", "rtf",
];

fn is_openable(path: &Path) -> bool {
path.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| OPENABLE.contains(&extension.to_ascii_lowercase().as_str()))
}

/// Opens a file a bot made with the program Windows uses for it. Only
/// documents, images, sound and video: never something that runs.
#[tauri::command]
async fn open_file(path: String) -> Result<(), String> {
blocking(move || {
let file = Path::new(&path);
if !file.is_absolute() || !file.is_file() {
return Err(format!("{path} is not a file"));
}
if !is_openable(file) {
return Err("this kind of file is not opened from Botloft".into());
}
std::process::Command::new("explorer.exe")
.arg(file)
.spawn()
.map(drop)
.map_err(|err| format!("cannot open the file: {err}"))
})
.await
}

/// Shows a file in its folder, selected. Nothing is run.
#[tauri::command]
async fn reveal_file(path: String) -> Result<(), String> {
use std::os::windows::process::CommandExt as _;
blocking(move || {
let file = Path::new(&path);
if !file.is_absolute() || !file.exists() || path.contains('"') {
return Err(format!("{path} is not a file"));
}
// Explorer reads `/select,` and the path as one argument.
std::process::Command::new("explorer.exe")
.raw_arg(format!("/select,\"{path}\""))
.spawn()
.map(drop)
.map_err(|err| format!("cannot open Explorer: {err}"))
})
.await
}

/// Whether `url` is a plain web link, safe to hand to the default browser.
fn is_web_link(url: &str) -> bool {
let rest = url
Expand Down Expand Up @@ -108,6 +161,8 @@ pub fn run() {
claude_sign_in,
read_owner_token,
open_path,
open_file,
reveal_file,
open_url
])
.run(tauri::generate_context!())
Expand All @@ -116,7 +171,29 @@ pub fn run() {

#[cfg(test)]
mod tests {
use super::is_web_link;
use std::path::Path;

use super::{is_openable, is_web_link};

#[test]
fn only_documents_and_media_open() {
for file in [r"C:\w\Report.PDF", r"C:\w\a b\notes.md", r"C:\w\chart.png"] {
assert!(is_openable(Path::new(file)), "{file}");
}
for file in [
r"C:\w\setup.exe",
r"C:\w\run.bat",
r"C:\w\run.cmd",
r"C:\w\run.ps1",
r"C:\w\link.lnk",
r"C:\w\page.hta",
r"C:\w\macro.docm",
r"C:\w\noextension",
r"C:\w\report.pdf.exe",
] {
assert!(!is_openable(Path::new(file)), "{file}");
}
}

#[test]
fn only_plain_web_links_open() {
Expand Down
2 changes: 1 addition & 1 deletion app/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
}
],
"security": {
"csp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost ws://127.0.0.1:*; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'"
"csp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost ws://127.0.0.1:*; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; frame-src blob:; font-src 'self'"
}
},
"plugins": {
Expand Down
13 changes: 13 additions & 0 deletions app/src/dev/seedChats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,19 @@ export function seedChats(fake: FakeBotloft, crew: Crew): void {
const call = chat.tool(crew.scout, name, { summary, input: JSON.stringify({ path: summary }) });
chat.finish(call, name === "Write" ? "Wrote 42 lines" : "ok");
}
at(93);
const made = fake.files;
made.add(crew.scout, "week-39.md", {
folder: "summaries",
text: SUMMARY,
writtenByBot: true,
at: fake.now,
});
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 });
at(92);
chat.reply(crew.scout, SUMMARY);
const handoff = chat.tool(crew.scout, "mcp__botloft__send_message", {
Expand Down
31 changes: 31 additions & 0 deletions app/src/features/bots/BotHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
Archive,
Crown,
Ellipsis,
Files,
FolderOpen,
PanelRight,
Pause,
Expand Down Expand Up @@ -32,11 +33,18 @@ export function BotHeader({
crew,
detailsOpen,
onToggleDetails,
filesOpen,
freshFiles,
onToggleFiles,
}: {
bot: Bot;
crew: Crew;
detailsOpen: boolean;
onToggleDetails(): void;
filesOpen: boolean;
/** Files that showed up since the owner last looked. */
freshFiles: number;
onToggleFiles(): void;
}) {
const t = useT();
const words = t.bots.header;
Expand Down Expand Up @@ -99,6 +107,29 @@ export function BotHeader({
<Button icon={RotateCw} disabled={stopped} onClick={() => restart(false)}>
{words.restart}
</Button>
<span className="relative">
<Button
variant={filesOpen ? "secondary" : "ghost"}
icon={Files}
label={
freshFiles > 0
? `${t.files.show}: ${t.files.fresh(freshFiles)}`
: filesOpen
? t.files.hide
: t.files.show
}
aria-pressed={filesOpen}
onClick={onToggleFiles}
/>
{freshFiles > 0 && (
<span
aria-hidden
className="pointer-events-none absolute -top-1 -right-1 grid min-w-4 place-items-center rounded-full bg-work px-1 font-semibold text-[10px] text-canvas leading-4"
>
{freshFiles}
</span>
)}
</span>
<Button
variant={detailsOpen ? "secondary" : "ghost"}
icon={PanelRight}
Expand Down
36 changes: 31 additions & 5 deletions app/src/features/bots/BotView.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { X } from "lucide-react";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { useT } from "../../i18n";
import type { Bot, Crew } from "../../lib/protocol.gen";
Expand All @@ -9,21 +9,43 @@ import { Button } from "../../ui/Button";
import { Callout } from "../../ui/Callout";
import { type Tab, Tabs, tabId } from "../../ui/Tabs";
import { ChatView } from "../chat/ChatView";
import { FilesPanel } from "../files/FilesPanel";
import { useBotFiles } from "../files/useBotFiles";
import { SignInButton } from "../onboarding/SignIn";
import { BotRoutines } from "../routines/RoutineList";
import { BotHeader } from "./BotHeader";
import { stateView } from "./BotStateBadge";

type Pane = "chat" | "routines";
/** What the panel beside the chat shows. */
type Side = "details" | "files" | null;

/**
* A bot's conversation and its routines, with its details in a side panel
* (spec 15.1, 20.9).
*/
export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) {
const t = useT();
const [details, setDetails] = useState(false);
const [side, setSide] = useState<Side>(null);
const [pane, setPane] = useState<Pane>("chat");
const files = useBotFiles(bot);
// What the owner has seen: files newer than this are new to them.
const [seenAt, setSeenAt] = useState(() => Date.now());
const [since, setSince] = useState(seenAt);
// biome-ignore lint/correctness/useExhaustiveDependencies: a new bot has its own files to see
useEffect(() => {
const now = Date.now();
setSeenAt(now);
setSince(now);
}, [bot.id]);
const filesOpen = side === "files";
const toggleFiles = () => {
// What was there when the panel opened or closed counts as seen.
setSince(seenAt);
setSeenAt(Date.now());
setSide(filesOpen ? null : "files");
};
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);
const tabs: Tab<Pane>[] = [
Expand All @@ -39,8 +61,11 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) {
<BotHeader
bot={bot}
crew={crew}
detailsOpen={details}
onToggleDetails={() => setDetails(!details)}
detailsOpen={side === "details"}
onToggleDetails={() => setSide(side === "details" ? null : "details")}
filesOpen={filesOpen}
freshFiles={fresh}
onToggleFiles={toggleFiles}
/>
<Notices bot={bot} crew={crew} view={view} />
<Tabs<Pane> label={bot.name} tabs={tabs} value={pane} onChange={setPane} />
Expand All @@ -52,7 +77,8 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) {
<BotRoutines bot={bot} />
)}
</div>
{details && <Details bot={bot} onClose={() => setDetails(false)} />}
{side === "details" && <Details bot={bot} onClose={() => setSide(null)} />}
{filesOpen && <FilesPanel bot={bot} data={files} since={since} onClose={toggleFiles} />}
</div>
</section>
);
Expand Down
1 change: 1 addition & 0 deletions app/src/features/chat/rows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const tool = (name: string) =>
input: "{}",
status: "done",
output: null,
file: null,
});
const turn = () => item({ kind: "turn", durationMs: 1000, costUsd: null, error: null });
const inbound = (body: string, at?: number) =>
Expand Down
Loading
Loading