Skip to content
Open
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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,7 @@ reconnects preserve pending avatar verification work):
- `clearSearchHitEventCache()` — search result event cache
- `clearMarkdownNodeCache()` — markdown parse-node cache
- `resetLinkPreviewTitleCache()` — link preview title cache (Buzz entity titles come from relay events)
- `resetFileViewerStore()` — attachment file-viewer tabs and panel state

**If you add a new module-level cache, Map, or class instance that holds
community-scoped data, you must add its reset to `resetCommunityState()`.**
Expand Down
2 changes: 2 additions & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ export default defineConfig({
"**/doctor-cta-screenshots.spec.ts",
"**/pubkey-display-screenshots.spec.ts",
"**/file-attachment.spec.ts",
"**/file-viewer.spec.ts",
"**/file-viewer-screenshots.spec.ts",
"**/image-attachment-gallery.spec.ts",
"**/composer-image-draw.spec.ts",
"**/video-attachment.spec.ts",
Expand Down
28 changes: 26 additions & 2 deletions desktop/src/features/channels/ui/ChannelPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ import { getThreadPanelLayout } from "@/features/channels/lib/threadPanelLayout"
import { useThreadViewMode } from "@/features/channels/lib/threadViewModePreference";
import { useThreadViewModeSwitch } from "@/features/channels/ui/useThreadViewModeSwitch";
import { useFocusDrawerPresence } from "@/features/channels/ui/useFocusDrawerPresence";
import {
registerFileViewerHost,
selectActiveFileViewerTab,
} from "@/features/fileViewer/fileViewerStore";
import { useFileViewerState } from "@/features/fileViewer/useFileViewerState";
import { FileViewerPanel } from "@/features/fileViewer/ui/FileViewerPanel";
import { useChannelWorkingAgentPubkeys } from "@/features/agents/agentWorkingSignal";
import { useCardMintJobs } from "@/features/agents/cardMintStore";
import { BotActivityComposerAction } from "@/features/channels/ui/BotActivityBar";
Expand Down Expand Up @@ -456,6 +462,11 @@ export const ChannelPane = React.memo(function ChannelPane({
onExternalTargetResolved: onThreadScrollTargetResolved,
onModeChange: markExitComplete,
});
const fileViewerSnapshot = useFileViewerState();
const activeFileViewerTab = selectActiveFileViewerTab(fileViewerSnapshot);
// Register a viewer host so FileCards in this pane open the panel instead of
// falling back to download.
React.useEffect(() => registerFileViewerHost(), []);
const selectedAgent = React.useMemo(
() =>
agentSessionSelection.resolveSelectedAgentSession({
Expand All @@ -468,7 +479,8 @@ export const ChannelPane = React.memo(function ChannelPane({
);
const hasSplitAuxiliaryPane =
useSplitAuxiliaryPane &&
(channelManagementOpen ||
(activeFileViewerTab !== null ||
channelManagementOpen ||
Boolean(threadHeadMessage) ||
shouldShowThreadSkeleton ||
Boolean(activeChannel && selectedAgent) ||
Expand Down Expand Up @@ -753,7 +765,19 @@ export const ChannelPane = React.memo(function ChannelPane({
* frozen snapshot because the panel is fully prop-driven.
*/}
<AnimatePresence onExitComplete={markExitComplete}>
{channelManagementOpen && activeChannel ? (
{activeFileViewerTab ? (
wrapAux(
<FileViewerPanel
isSinglePanelView={
useSplitAuxiliaryPane ? false : isSinglePanelView
}
layout={useSplitAuxiliaryPane ? "split" : "standalone"}
transparentChrome={useSplitAuxiliaryPane}
widthPx={threadPanelWidthPx}
/>,
"file-viewer-panel",
)
) : channelManagementOpen && activeChannel ? (
<ChannelManagementAuxiliaryPanel
activeChannel={activeChannel}
canResetThreadPanelWidth={canResetThreadPanelWidth}
Expand Down
10 changes: 9 additions & 1 deletion desktop/src/features/channels/ui/ChannelScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import { useMainInsetRef } from "@/shared/layout/MainInsetContext";
import { channelContentTopPaddingMeasurement } from "@/shared/layout/chromeLayout";
import { useMeasuredCssVariable } from "@/shared/layout/useMeasuredCssVariable";
import { useElementWidth } from "@/shared/hooks/use-mobile";
import { useFileViewerAuxiliaryPanel } from "@/features/fileViewer/useFileViewerAuxiliaryPanel";
import { useThreadPanelWidth } from "@/shared/hooks/useThreadPanelWidth";
import { AUXILIARY_PANEL_SINGLE_COLUMN_BREAKPOINT_PX } from "@/shared/layout/AuxiliaryPanel";
import { normalizePubkey } from "@/shared/lib/pubkey";
Expand Down Expand Up @@ -666,11 +667,18 @@ export function ChannelScreen({
threadReplyTargetId,
threadReplyTargetMessage,
});
const fileViewerPanelOpen = useFileViewerAuxiliaryPanel({
agentSession: openAgentSessionPubkey,
channelManagement: channelManagementOpen,
profile: profilePanelPubkey,
thread: effectiveOpenThreadHeadId,
});
const hasAuxiliaryPanel = Boolean(
effectiveOpenThreadHeadId ||
openAgentSessionPubkey ||
profilePanelPubkey ||
channelManagementOpen,
channelManagementOpen ||
fileViewerPanelOpen,
);
const displayedThreadHeadMessage = threadPanelData.threadHead;
const displayedThreadAllMessages = threadPanelData.messages;
Expand Down
2 changes: 2 additions & 0 deletions desktop/src/features/communities/useCommunityInit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
} from "@/features/agents/activeAgentTurnsStore";
import { resetAgentWorkingSignal } from "@/features/agents/agentWorkingSignal";
import { resetAgentObserverStore } from "@/features/agents/observerRelayStore";
import { resetFileViewerStore } from "@/features/fileViewer/fileViewerStore";
import { resetAvatarPresentations } from "@/features/profile/avatarPresentationStore";
import { resetAvatarProfileSync } from "@/features/profile/avatarProfileSync";
import { resetSidebarRelayConnectionCardState } from "@/features/sidebar/ui/useSidebarRelayConnectionCard";
Expand Down Expand Up @@ -73,6 +74,7 @@ function resetCommunityState({
resetBackgroundMediaUploads();
clearSearchHitEventCache();
clearMarkdownNodeCache();
resetFileViewerStore();
}

type CommunityInitResult =
Expand Down
16 changes: 16 additions & 0 deletions desktop/src/features/fileViewer/downloadAttachment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { toast } from "sonner";

import { invokeTauri } from "@/shared/api/tauri";

/**
* Save an attachment through the native download command.
*
* Not an `<a download>` link: that navigates the webview to the blob URL,
* which escapes to the OS browser and lands on a corporate CDN interstitial.
* The Rust command fetches inside the app's tunnel and opens a save dialog.
*/
export function downloadAttachment(url: string, filename: string): void {
invokeTauri("download_file", { filename, url }).catch((error: unknown) => {
toast.error(error instanceof Error ? error.message : "Download failed");
});
}
130 changes: 130 additions & 0 deletions desktop/src/features/fileViewer/fileViewClassification.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import assert from "node:assert/strict";
import { test } from "node:test";

import { classifyFileView } from "./fileViewClassification.ts";

test("markdown extensions render as markdown regardless of MIME", () => {
assert.deepEqual(classifyFileView("README.md", "application/octet-stream"), {
kind: "markdown",
});
assert.deepEqual(classifyFileView("GUIDE.markdown"), { kind: "markdown" });
assert.deepEqual(classifyFileView("Page.MDX"), { kind: "markdown" });
});
test("code extensions map to Shiki language ids", () => {
assert.deepEqual(classifyFileView("apply-config.sh"), {
kind: "code",
language: "shellscript",
});
assert.deepEqual(classifyFileView("main.rs"), {
kind: "code",
language: "rust",
});
assert.deepEqual(classifyFileView("data.yml"), {
kind: "code",
language: "yaml",
});
assert.deepEqual(classifyFileView("Config.TOML"), {
kind: "code",
language: "toml",
});
});

// The Shiki registry is what makes the long tail work without a hand table;
// these are languages my original map never listed.
test("languages beyond the hand-written set resolve from the Shiki registry", () => {
for (const [filename, language] of [
["main.zig", "zig"],
["cluster.tf", "terraform"],
["query.sparql", "sparql"],
// `dockerfile` is an alias; the grammar id is `docker`.
["Dockerfile.dockerfile", "docker"],
["notebook.jl", "julia"],
["module.erl", "erlang"],
["view.hbs", "handlebars"],
["schema.prisma", "prisma"],
]) {
assert.deepEqual(
classifyFileView(filename),
{ kind: "code", language },
filename,
);
}
});

// Extensions Shiki does not alias; regressions here silently lose highlighting.
test("override table covers extensions Shiki does not alias", () => {
assert.deepEqual(classifyFileView("stack.h"), {
kind: "code",
language: "c",
});
assert.deepEqual(classifyFileView("stack.hpp"), {
kind: "code",
language: "cpp",
});
assert.deepEqual(classifyFileView("fix.patch"), {
kind: "code",
language: "diff",
});
assert.deepEqual(classifyFileView("server.mjs"), {
kind: "code",
language: "javascript",
});
});

// Real filenames an agent delivered in a channel — these must be viewable
// even though the relay stored them as `application/octet-stream`.
test("agent-delivered attachments classify from the filename, not the MIME", () => {
assert.deepEqual(
classifyFileView("power_law_btc_analysis.py", "application/octet-stream"),
{ kind: "code", language: "python" },
);
assert.deepEqual(
classifyFileView("market_breadth_history.json", "application/octet-stream"),
{ kind: "code", language: "json" },
);
assert.deepEqual(
classifyFileView(
"architecture-hermes-agent-organization.md",
"application/octet-stream",
),
{ kind: "markdown" },
);
});

test("plain-text extensions render as text", () => {
assert.deepEqual(classifyFileView("build.log"), { kind: "text" });
assert.deepEqual(classifyFileView("data.csv"), { kind: "text" });
});

test("binary/container types are not viewable", () => {
assert.deepEqual(classifyFileView("Q3-budget.pdf", "application/pdf"), {
kind: "none",
});
assert.deepEqual(classifyFileView("archive.zip", "application/zip"), {
kind: "none",
});
assert.deepEqual(classifyFileView("photo.png", "image/png"), {
kind: "none",
});
});

test("unknown extension falls back to the imeta MIME", () => {
assert.deepEqual(classifyFileView("notes", "text/markdown"), {
kind: "markdown",
});
assert.deepEqual(classifyFileView("payload", "application/json"), {
kind: "code",
language: "json",
});
assert.deepEqual(classifyFileView("report", "text/plain; charset=utf-8"), {
kind: "text",
});
assert.deepEqual(classifyFileView("blob", "application/octet-stream"), {
kind: "none",
});
assert.deepEqual(classifyFileView("blob"), { kind: "none" });
});

test("a trailing dot yields no extension and defers to MIME", () => {
assert.deepEqual(classifyFileView("weird.", "text/plain"), { kind: "text" });
});
98 changes: 98 additions & 0 deletions desktop/src/features/fileViewer/fileViewClassification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { bundledLanguagesInfo } from "shiki";

/**
* Decide how a message attachment should render in the file viewer panel.
*
* Keyed on the filename extension, not the imeta MIME: text and source files
* have no magic bytes, so uploads routinely arrive as
* `application/octet-stream` (see `validate_file_content` in buzz-media). A
* MIME fallback covers extension-less names.
*
* Languages resolve through Shiki's own grammar ids and aliases instead of a
* hand-maintained table; only extensions Shiki does not alias are listed here.
*/

export type FileViewKind =
| { kind: "markdown" }
| { kind: "code"; language: string }
| { kind: "text" }
| { kind: "none" };

const MARKDOWN_EXTENSIONS: Record<string, true> = {
markdown: true,
md: true,
mdx: true,
};

/**
* Extensions Shiki neither uses as a grammar id nor lists as an alias, mapped
* to the grammar that should highlight them.
*/
const EXTENSION_LANGUAGE_OVERRIDES: Record<string, string> = {
cjs: "javascript",
h: "c",
hpp: "cpp",
mjs: "javascript",
patch: "diff",
};

/** Extensions rendered as plain text — no grammar, no highlighting. */
const TEXT_EXTENSIONS: Record<string, true> = {
cfg: true,
conf: true,
csv: true,
env: true,
gitignore: true,
lock: true,
log: true,
text: true,
tsv: true,
txt: true,
};

/** Extension → Shiki grammar id, built from grammar ids and aliases on first use. */
let languageByExtension: Map<string, string> | null = null;

function resolveShikiLanguage(ext: string): string | undefined {
if (!languageByExtension) {
languageByExtension = new Map();
for (const language of bundledLanguagesInfo) {
languageByExtension.set(language.id, language.id);
for (const alias of language.aliases ?? []) {
languageByExtension.set(alias, language.id);
}
}
}
return languageByExtension.get(ext);
}

export function classifyFileView(
filename: string,
mime?: string,
): FileViewKind {
const dot = filename.lastIndexOf(".");
const ext =
dot === -1 || dot === filename.length - 1
? null
: filename.slice(dot + 1).toLowerCase();

if (ext) {
if (MARKDOWN_EXTENSIONS[ext]) return { kind: "markdown" };
// Plain-text extensions win over Shiki: several (`log`, `csv`) exist as
// grammars whose highlighting adds noise rather than meaning.
if (TEXT_EXTENSIONS[ext]) return { kind: "text" };
const language =
EXTENSION_LANGUAGE_OVERRIDES[ext] ?? resolveShikiLanguage(ext);
if (language) return { kind: "code", language };
}

// Extension unknown or absent: fall back to the imeta MIME. Generic
// container types (octet-stream, pdf, zip…) stay non-viewable.
const normalizedMime = mime?.split(";")[0].trim().toLowerCase();
if (normalizedMime === "text/markdown") return { kind: "markdown" };
if (normalizedMime === "application/json")
return { kind: "code", language: "json" };
if (normalizedMime?.startsWith("text/")) return { kind: "text" };

return { kind: "none" };
}
Loading