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: 6 additions & 3 deletions app/src/features/files/FilesPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { Callout } from "../../ui/Callout";
import { SidePanel } from "../../ui/SidePanel";
import { BotAvatar } from "../bots/BotAvatar";
import { FilePreview } from "./FilePreview";
import { fileIcon } from "./kinds";
import { fileKind, kindIcon } from "./kinds";
import type { BotFiles } from "./useBotFiles";

export function FilesPanel({
Expand Down Expand Up @@ -124,7 +124,8 @@ function FileRow({
onOpen(path: string): void;
}) {
const t = useT().files;
const Icon = fileIcon(file.mediaType, file.name);
const kind = fileKind(file.mediaType, file.name);
const Icon = kindIcon(kind);
return (
<li>
<button
Expand All @@ -133,7 +134,9 @@ function FileRow({
title={file.path}
className="flex w-full min-w-0 items-center gap-3 rounded-lg px-2.5 py-2 text-left transition-colors hover:bg-sunken"
>
<Icon aria-hidden size={18} className="shrink-0 text-muted" />
<span aria-hidden className="file-tile" data-kind={kind}>
<Icon size={18} />
</span>
<span className="min-w-0 flex-1">
<span className="flex items-center gap-2">
<span className="truncate font-medium text-sm">{file.name}</span>
Expand Down
39 changes: 39 additions & 0 deletions app/src/features/files/kinds.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { describe, expect, test } from "vitest";
import { fileKind } from "./kinds";

const OCTET = "application/octet-stream";

describe("fileKind", () => {
test("office files by their media type", () => {
expect(
fileKind(
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"report.docx",
),
).toBe("word");
expect(
fileKind("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "q3.xlsx"),
).toBe("sheet");
expect(fileKind("text/csv", "plan.csv")).toBe("sheet");
expect(fileKind("application/pdf", "contract.pdf")).toBe("pdf");
});

test("old and open formats by extension, though they come as octet-stream", () => {
expect(fileKind(OCTET, "Old Letter.DOC")).toBe("word");
expect(fileKind(OCTET, "budget.ods")).toBe("sheet");
expect(fileKind(OCTET, "pitch.ppt")).toBe("slides");
expect(fileKind(OCTET, "backup.7z")).toBe("archive");
expect(fileKind(OCTET, "call.m4a")).toBe("audio");
});

test("media, code, text and the rest", () => {
expect(fileKind("image/png", "chart.png")).toBe("image");
expect(fileKind("audio/mpeg", "memo.mp3")).toBe("audio");
expect(fileKind("video/mp4", "demo.mp4")).toBe("video");
expect(fileKind("text/plain", "main.rs")).toBe("code");
expect(fileKind("application/json", "data.json")).toBe("code");
expect(fileKind("text/markdown", "notes.md")).toBe("text");
expect(fileKind(OCTET, "blob.bin")).toBe("other");
expect(fileKind(OCTET, ".docx")).toBe("other");
});
});
109 changes: 89 additions & 20 deletions app/src/features/files/kinds.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// What a file is, from its media type: the icon in the list and how the
// preview shows it.
// What a file is, from its name and media type: the icon and color in the
// list and how the preview shows it.

import {
File,
Expand All @@ -9,8 +9,10 @@ import {
FileImage,
FileSpreadsheet,
FileText,
FileType,
FileVideo,
type LucideIcon,
Presentation,
} from "lucide-react";

export type PreviewKind = "image" | "pdf" | "markdown" | "text" | "none";
Expand All @@ -31,28 +33,95 @@ export function previewKind(mediaType: string): PreviewKind {
return mediaType.startsWith("text/") || TEXT.includes(mediaType) ? "text" : "none";
}

export function fileIcon(mediaType: string, name: string): LucideIcon {
if (mediaType.startsWith("image/")) {
return FileImage;
}
if (mediaType.startsWith("audio/")) {
return FileAudio;
/** The family a file belongs to; each has its own icon and color (files.css). */
export type FileKind =
| "pdf"
| "word"
| "sheet"
| "slides"
| "image"
| "audio"
| "video"
| "archive"
| "code"
| "text"
| "other";

// By extension first: the folder scan sends old Office, OpenDocument and most
// media files as application/octet-stream.
const BY_EXTENSION: Record<string, FileKind> = {
pdf: "pdf",
doc: "word",
docx: "word",
odt: "word",
rtf: "word",
xls: "sheet",
xlsx: "sheet",
ods: "sheet",
csv: "sheet",
ppt: "slides",
pptx: "slides",
odp: "slides",
zip: "archive",
rar: "archive",
"7z": "archive",
mp3: "audio",
wav: "audio",
m4a: "audio",
ogg: "audio",
mp4: "video",
mov: "video",
webm: "video",
};

const CODE = /\.(ts|tsx|js|jsx|py|rs|css|html?|json|xml|ya?ml|toml|sql|sh|ps1|bat)$/i;

export function fileKind(mediaType: string, name: string): FileKind {
const dot = name.lastIndexOf(".");
const known = dot > 0 ? BY_EXTENSION[name.slice(dot + 1).toLowerCase()] : undefined;
if (known) {
return known;
}
if (mediaType.startsWith("video/")) {
return FileVideo;
if (mediaType === "application/pdf") {
return "pdf";
}
if (mediaType === "application/zip") {
return FileArchive;
if (mediaType.includes("wordprocessing")) {
return "word";
}
if (mediaType === "text/csv" || mediaType.includes("spreadsheet")) {
return FileSpreadsheet;
return "sheet";
}
if (
mediaType === "text/html" ||
TEXT.includes(mediaType) ||
/\.(ts|tsx|js|py|rs|css)$/i.test(name)
) {
return FileCode;
if (mediaType.includes("presentation")) {
return "slides";
}
return mediaType === "application/octet-stream" ? File : FileText;
for (const kind of ["image", "audio", "video"] as const) {
if (mediaType.startsWith(`${kind}/`)) {
return kind;
}
}
if (mediaType === "application/zip") {
return "archive";
}
if (mediaType === "text/html" || TEXT.includes(mediaType) || CODE.test(name)) {
return "code";
}
return mediaType === "application/octet-stream" ? "other" : "text";
}

const ICONS: Record<FileKind, LucideIcon> = {
pdf: FileText,
word: FileType,
sheet: FileSpreadsheet,
slides: Presentation,
image: FileImage,
audio: FileAudio,
video: FileVideo,
archive: FileArchive,
code: FileCode,
text: FileText,
other: File,
};

export function kindIcon(kind: FileKind): LucideIcon {
return ICONS[kind];
}
70 changes: 70 additions & 0 deletions app/src/files.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/* The tile in front of each file in a bot's files panel: one color per kind
of file, so a Word document, a sheet and a PDF tell apart at a glance. The
kind also has its own icon, and the name says the extension. */

.file-tile {
--kind: var(--muted);
display: grid;
flex-shrink: 0;
place-items: center;
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
color: var(--kind);
background: color-mix(in srgb, var(--kind) 14%, transparent);
}

.file-tile[data-kind="pdf"] {
--kind: #d13a2f;
}
.file-tile[data-kind="word"] {
--kind: #2b62c9;
}
.file-tile[data-kind="sheet"] {
--kind: #1e7d45;
}
.file-tile[data-kind="slides"] {
--kind: #c8561b;
}
.file-tile[data-kind="image"] {
--kind: #8546c9;
}
.file-tile[data-kind="audio"],
.file-tile[data-kind="video"] {
--kind: #c2367a;
}
.file-tile[data-kind="archive"] {
--kind: #93641a;
}
.file-tile[data-kind="code"] {
--kind: #127a80;
}

[data-theme="dark"] .file-tile {
background: color-mix(in srgb, var(--kind) 20%, transparent);
}
[data-theme="dark"] .file-tile[data-kind="pdf"] {
--kind: #ff7066;
}
[data-theme="dark"] .file-tile[data-kind="word"] {
--kind: #74a6ff;
}
[data-theme="dark"] .file-tile[data-kind="sheet"] {
--kind: #55c985;
}
[data-theme="dark"] .file-tile[data-kind="slides"] {
--kind: #ff9a5c;
}
[data-theme="dark"] .file-tile[data-kind="image"] {
--kind: #bf8cff;
}
[data-theme="dark"] .file-tile[data-kind="audio"],
[data-theme="dark"] .file-tile[data-kind="video"] {
--kind: #ff7ab8;
}
[data-theme="dark"] .file-tile[data-kind="archive"] {
--kind: #e0ad5a;
}
[data-theme="dark"] .file-tile[data-kind="code"] {
--kind: #4fc8cf;
}
1 change: 1 addition & 0 deletions app/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
@import "./panels.css";
@import "./sidebar.css";
@import "./effort.css";
@import "./files.css";
@import "./motion-less.css";
@import "./paused.css";
@import "./offscreen.css";
Expand Down
2 changes: 1 addition & 1 deletion docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -777,7 +777,7 @@ Layout, como um app de mensagens:
- **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.
- **Pedido de permissão** (10.1): um cartão no chat, "<bot> quer <ação>" ("Scout quer rodar um comando"), com Permitir, Negar e um campo para dizer ao bot por que não. Num comando, o texto principal é a explicação do bot, em letra comum; embaixo, menor, "<bot> escreveu isto. O que roda de verdade é o comando abaixo.", e "Ver o comando", uma seta que abre o comando inteiro, em letra de código e com as quebras de linha. Sem explicação, o cartão diz "<bot> não disse para que serve este comando. Na dúvida, negue e pergunte." e já vem com o comando aberto. Um comando grande demais para guardar aparece até onde coube, com o aviso de que está cortado. Nas outras ferramentas, o resumo (o arquivo, a URL) e a entrada inteira em "Detalhes". Respondido, o cartão vira uma linha ("Permitido: rodar um comando") com a explicação ou, sem ela, o comando. A linha da ferramenta acima do cartão segue a mesma ideia: "Rodar um comando" e a explicação do bot (sem ela, o comando), e um clique abre o comando e a saída; a lista de conversas mostra o mesmo texto.
- **Detalhes do bot** (pasta, instruções, sessão) ficam num painel, fora do caminho da conversa. Detalhes e arquivos são painéis laterais que o dono redimensiona arrastando a borda esquerda (ou com as setas do teclado, Shift anda mais; duplo clique volta ao padrão): 256 a 900 px, sempre deixando ao chat ao menos 22rem, e a largura de cada um fica no `localStorage` (`botloft.panel.details`, `botloft.panel.files`, `botloft.panel.browser`, `botloft.panel.screens`). O chat encolhe com eles e nunca passa por baixo nem por cima do painel.
- **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 com o painel aberto e a janela à vista, e de novo quando ele para: cada leitura percorre as pastas do bot, então ninguém paga por ela sem olhar a lista. Com o painel fechado, o contador abaixo se atualiza quando o turno acaba. 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, arquivos, o navegador (21.8) e as telas (22.5) dividem o mesmo lugar: abrir um fecha o outro. O painel abre deslizando da borda direita (460 ms, sem pressa no começo) e fecha voltando para ela (360 ms) antes de sair, com o conteúdo na largura final o tempo todo e o chat acompanhando; quem troca de painel começa da largura do anterior. Fechando, o painel já não recebe clique nem leitor de tela. Com movimento reduzido, abre e fecha de uma vez. O painel segue o que o bot começa a fazer, para o dono ver acontecer: o navegador abre quando o bot sobe o navegador ou pede uma mão (21.8), as telas quando ele começa a escrever uma (22.5), no lugar do painel que estiver aberto e uma vez por começo, a menos que o dono tenha desligado isso nas Configurações. Fechado pelo dono, fica fechado, com o ponto no botão. O navegador nas mãos do dono (21.10) nunca é trocado. O painel volta com o bot: o app guarda, por bot e até fechar (no store, não no `localStorage`), o painel que ficou ao lado do chat, aberto pelo dono ou pelo bot, e trocar de bot ou ir à página da crew e voltar o mostra de novo, já aberto, sem deslizar. Um painel que o dono fechou volta fechado. Só o painel é guardado: o arquivo ou a tela em foco dentro dele e o navegador nas mãos do dono, que é devolvido ao trocar de bot (21.10), não. Voltar conta como abrir o bot: um navegador que o bot abriu nesse meio-tempo fica atrás do botão, e o que abre sozinho ao abrir um bot (um pedido de mão ainda sem resposta, uma tela que ele ainda escreve) e o que ele começa dali em diante entram no lugar do painel que voltou.
- **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 e cor pelo tipo, nome, pasta, tamanho, "há 5 min"). Cada tipo tem seu ícone num quadrado da sua cor, para Word, planilha e PDF se distinguirem de longe: PDF vermelho, Word azul, planilha verde, apresentação laranja, imagem roxa, áudio e vídeo rosa, compactado âmbar, código azul-petróleo, texto e o resto cinza. O tipo vem primeiro da extensão (os `.doc`, `.xls`, `.ppt` e OpenDocument chegam como `application/octet-stream`) e depois do media type. 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 com o painel aberto e a janela à vista, e de novo quando ele para: cada leitura percorre as pastas do bot, então ninguém paga por ela sem olhar a lista. Com o painel fechado, o contador abaixo se atualiza quando o turno acaba. 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, arquivos, o navegador (21.8) e as telas (22.5) dividem o mesmo lugar: abrir um fecha o outro. O painel abre deslizando da borda direita (460 ms, sem pressa no começo) e fecha voltando para ela (360 ms) antes de sair, com o conteúdo na largura final o tempo todo e o chat acompanhando; quem troca de painel começa da largura do anterior. Fechando, o painel já não recebe clique nem leitor de tela. Com movimento reduzido, abre e fecha de uma vez. O painel segue o que o bot começa a fazer, para o dono ver acontecer: o navegador abre quando o bot sobe o navegador ou pede uma mão (21.8), as telas quando ele começa a escrever uma (22.5), no lugar do painel que estiver aberto e uma vez por começo, a menos que o dono tenha desligado isso nas Configurações. Fechado pelo dono, fica fechado, com o ponto no botão. O navegador nas mãos do dono (21.10) nunca é trocado. O painel volta com o bot: o app guarda, por bot e até fechar (no store, não no `localStorage`), o painel que ficou ao lado do chat, aberto pelo dono ou pelo bot, e trocar de bot ou ir à página da crew e voltar o mostra de novo, já aberto, sem deslizar. Um painel que o dono fechou volta fechado. Só o painel é guardado: o arquivo ou a tela em foco dentro dele e o navegador nas mãos do dono, que é devolvido ao trocar de bot (21.10), não. Voltar conta como abrir o bot: um navegador que o bot abriu nesse meio-tempo fica atrás do botão, e o que abre sozinho ao abrir um bot (um pedido de mão ainda sem resposta, uma tela que ele ainda escreve) e o que ele começa dali em diante entram no lugar do painel que voltou.

### 15.2 Comandos Tauri

Expand Down
Loading