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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@
"clsx": "^2.1.1",
"drizzle-kit": "^0.31.10",
"drizzle-orm": "^0.45.2",
"fflate": "^0.8.3",
"hast-util-to-string": "^3.0.1",
"katex": "^0.17.0",
"linkedom": "0.18.13",
Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 0 additions & 4 deletions src/features/workspaces/ai/ai-thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,10 +131,6 @@ export function createAIThreadClass(getUserAIStore: () => typeof UserAIStore) {
);
}

getSystemPrompt(): string {
return getAIThreadSoulPrompt();
}

// On-demand instruction bundles (progressive disclosure). The model sees
// only each skill's name/description until a task matches, then calls
// activate_skill to load the full guide. Bundled from ./skills via the
Expand Down
23 changes: 22 additions & 1 deletion src/features/workspaces/components/WorkspaceContextBar.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ChevronDown, Clock3, Settings, Share2 } from "lucide-react";
import { ChevronDown, Clock3, Download, Settings, Share2 } from "lucide-react";
import { type ComponentType, type ReactElement, useState } from "react";
import { toast } from "sonner";

import {
Breadcrumb,
Expand Down Expand Up @@ -94,6 +95,14 @@ export default function WorkspaceContextBar({
const workspaceItems = Array.from(itemsById.values());
const searchHotkey = formatAppHotkey(getAppHotkey("workspace.search.open").hotkey);
const openWorkspaceSearch = () => setSearchOpen(true);
const exportWorkspace = () => {
toast.message("Preparing workspace export…");
const link = document.createElement("a");
link.href = `/api/v1/workspaces/${encodeURIComponent(workspace.id)}/export`;
link.target = "_blank";
link.rel = "noopener";
link.click();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: When the export endpoint fails (session expired → 401, membership revoked → 403, or 500), this handler still clicks the anchor and the browser receives an error JSON body under a .zip URL while the toast stays at "Preparing workspace export…" with no resolution. The user gets no signal that the export failed or why. Consider wiring the export through a fetch (or the router/fetch handler) that can surface a failure toast/message on non-2xx responses before triggering the download, or at least clear-update the toast on error.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/features/workspaces/components/WorkspaceContextBar.tsx, line 102:

<comment>When the export endpoint fails (session expired → 401, membership revoked → 403, or 500), this handler still clicks the anchor and the browser receives an error JSON body under a `.zip` URL while the toast stays at "Preparing workspace export…" with no resolution. The user gets no signal that the export failed or why. Consider wiring the export through a `fetch` (or the router/fetch handler) that can surface a failure toast/message on non-2xx responses before triggering the download, or at least clear-update the toast on error.</comment>

<file context>
@@ -94,6 +95,12 @@ export default function WorkspaceContextBar({
+		toast.message("Preparing workspace export…");
+		const link = document.createElement("a");
+		link.href = `/api/v1/workspaces/${encodeURIComponent(workspace.id)}/export`;
+		link.click();
+	};
 
</file context>

};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

useAppHotkey("workspace.search.open", () => {
openWorkspaceSearch();
Expand All @@ -117,6 +126,7 @@ export default function WorkspaceContextBar({
) : (
<WorkspaceRootActionsMenu
capabilities={capabilities}
onExport={exportWorkspace}
onOpenSettings={() => setSettingsOpen(true)}
onOpenShare={() => setShareOpen(true)}
trigger={
Expand Down Expand Up @@ -221,11 +231,13 @@ export default function WorkspaceContextBar({

function WorkspaceRootActionsMenu({
capabilities,
onExport,
onOpenSettings,
onOpenShare,
trigger,
}: {
capabilities: ReturnType<typeof useWorkspaceMutationAccess>["capabilities"];
onExport: () => void;
onOpenSettings: () => void;
onOpenShare: () => void;
trigger: ReactElement;
Expand All @@ -237,6 +249,7 @@ function WorkspaceRootActionsMenu({
{renderWorkspaceMenuActions(
getWorkspaceRootMenuActions({
canOpenSettings: capabilities.canMutateContent,
onExport,
onOpenSettings,
onOpenShare,
}),
Expand All @@ -249,6 +262,7 @@ function WorkspaceRootActionsMenu({

function getWorkspaceRootMenuActions(input: {
canOpenSettings: boolean;
onExport: () => void;
onOpenSettings: () => void;
onOpenShare: () => void;
}): WorkspaceMenuAction[] {
Expand All @@ -268,6 +282,13 @@ function getWorkspaceRootMenuActions(input: {
trailing: "Soon",
disabled: true,
},
{
kind: "item",
id: "export",
label: "Export",
leading: <Download className="size-4" />,
onSelect: input.onExport,
},
{
kind: "item",
id: "settings",
Expand Down
6 changes: 4 additions & 2 deletions src/features/workspaces/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
export { workspaceItemTypeSchema };
export type { WorkspaceItemType };

export const WORKSPACE_ITEM_NAME_MAX_LENGTH = 160;

export type JsonValue =
| string
| number
Expand Down Expand Up @@ -226,7 +228,7 @@ export const createWorkspaceItemInputSchema = z
workspaceId: z.string().min(1),
parentId: z.string().min(1).nullable().optional(),
type: workspaceItemTypeSchema,
name: z.string().trim().min(1).max(160).optional(),
name: z.string().trim().min(1).max(WORKSPACE_ITEM_NAME_MAX_LENGTH).optional(),
color: workspaceColorSchema.optional(),
initialContent: z.string().optional(),
clientMutationId: z.uuid().optional(),
Expand All @@ -244,7 +246,7 @@ export const createWorkspaceItemInputSchema = z
export const renameWorkspaceItemInputSchema = z.object({
workspaceId: z.string().min(1),
itemId: z.string().min(1),
name: z.string().trim().min(1).max(160),
name: z.string().trim().min(1).max(WORKSPACE_ITEM_NAME_MAX_LENGTH),
clientMutationId: z.uuid().optional(),
});

Expand Down
24 changes: 24 additions & 0 deletions src/features/workspaces/defaults.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";

import {
getAvailableWorkspaceItemName,
normalizeWorkspaceItemName,
} from "#/features/workspaces/defaults";

describe("workspace item names", () => {
it("normalizes names for portable filesystem paths", () => {
expect(normalizeWorkspaceItemName(" Notes: draft?.md. ")).toBe("Notes- draft-.md");
expect(normalizeWorkspaceItemName("..")).toBe("Untitled");
expect(normalizeWorkspaceItemName("CON.txt")).toBe("_CON.txt");
});

it("allocates sibling names case-insensitively", () => {
expect(
getAvailableWorkspaceItemName({
type: "folder",
existingNames: ["Notes"],
requestedName: "notes",
}),
).toBe("notes 2");
});
});
37 changes: 25 additions & 12 deletions src/features/workspaces/defaults.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type {
WorkspaceColor,
WorkspaceIcon,
WorkspaceItemType,
import {
WORKSPACE_ITEM_NAME_MAX_LENGTH,
type WorkspaceColor,
type WorkspaceIcon,
type WorkspaceItemType,
} from "#/features/workspaces/contracts";
import { getWorkspaceItemRegistryEntry } from "#/features/workspaces/workspace-item-registry";

Expand All @@ -27,17 +28,17 @@ export function getAvailableWorkspaceItemName(input: {
? normalizeWorkspaceItemName(input.requestedName, "")
: "";
const baseName = requestedName || getDefaultWorkspaceItemName(input.type);
const existingNames = new Set(input.existingNames);
const existingNames = new Set(Array.from(input.existingNames, getWorkspaceItemNameKey));

if (requestedName && !existingNames.has(baseName)) {
if (requestedName && !existingNames.has(getWorkspaceItemNameKey(baseName))) {
return baseName;
}

if (!requestedName) {
for (let suffix = 1; suffix < 1000; suffix += 1) {
const candidate = `${baseName} ${suffix}`;

if (!existingNames.has(candidate)) {
if (!existingNames.has(getWorkspaceItemNameKey(candidate))) {
return candidate;
}
}
Expand All @@ -48,7 +49,7 @@ export function getAvailableWorkspaceItemName(input: {
for (let suffix = 2; suffix < 1000; suffix += 1) {
const candidate = `${baseName} ${suffix}`;

if (!existingNames.has(candidate)) {
if (!existingNames.has(getWorkspaceItemNameKey(candidate))) {
return candidate;
}
}
Expand All @@ -59,13 +60,25 @@ export function getAvailableWorkspaceItemName(input: {
export function normalizeWorkspaceItemName(name: string | null | undefined, fallback = "Untitled") {
const normalized =
stripControlCharacters(name ?? "")
.replace(/[\\/]+/g, "-")
.normalize("NFC")
.replace(/[<>:"/\\|?*]+/g, "-")
.replace(/\s+/g, " ")
.trim()
.slice(0, 160)
.trim() ?? "";
.slice(0, WORKSPACE_ITEM_NAME_MAX_LENGTH)
.trim()
.replace(/[. ]+$/g, "") ?? "";

if (!normalized) {
return fallback;
}

return /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(normalized)
? `_${normalized}`.slice(0, WORKSPACE_ITEM_NAME_MAX_LENGTH)
: normalized;
}

return normalized || fallback;
export function getWorkspaceItemNameKey(name: string) {
return name.normalize("NFC").toLowerCase();
}

function stripControlCharacters(value: string) {
Expand Down
108 changes: 108 additions & 0 deletions src/features/workspaces/export/workspace-export-archive.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { strFromU8, unzipSync } from "fflate";
import { describe, expect, it, vi } from "vitest";

import type { WorkspaceItemSummary } from "#/features/workspaces/contracts";
import { createWorkspaceExportStream } from "#/features/workspaces/export/workspace-export-archive";

const baseItem = {
workspaceId: "workspace-1",
meta: "",
color: null,
metadataJson: {},
sortOrder: 0,
createdAt: "2026-08-05T00:00:00.000Z",
updatedAt: "2026-08-05T00:00:00.000Z",
deletedAt: null,
} as const;

describe("workspace export archive", () => {
it("preserves folders, converts documents to Markdown, and streams original files", async () => {
const items: WorkspaceItemSummary[] = [
{
...baseItem,
id: "folder",
parentId: null,
type: "folder",
title: "Research",
name: "Research",
},
{
...baseItem,
id: "document",
parentId: "folder",
type: "document",
title: "Notes",
name: "Notes",
},
{
...baseItem,
id: "file",
parentId: "folder",
type: "file",
title: "source.pdf",
name: "source.pdf",
},
{ ...baseItem, id: "empty", parentId: null, type: "folder", title: "Empty", name: "Empty" },
];
const archive = await new Response(
createWorkspaceExportStream(items, {
readDocument: vi.fn().mockReturnValue({
type: "doc",
content: [{ type: "paragraph", content: [{ type: "text", text: "Hello" }] }],
}),
readFile: vi.fn().mockResolvedValue(new Blob(["PDF bytes"]).stream()),
}),
).arrayBuffer();
const files = unzipSync(new Uint8Array(archive));

expect(Object.keys(files).sort()).toEqual([
"Empty/",
"Research/",
"Research/Notes.md",
"Research/source.pdf",
]);
expect(strFromU8(files["Research/Notes.md"]!)).toBe("Hello\n");
expect(strFromU8(files["Research/source.pdf"]!)).toBe("PDF bytes");
});

it("keeps every item when Markdown extensions collide", async () => {
const items: WorkspaceItemSummary[] = [
{
...baseItem,
id: "document-1",
parentId: null,
type: "document",
title: "Notes",
name: "Notes",
},
{
...baseItem,
id: "document-2",
parentId: null,
type: "document",
title: "Notes.md",
name: "Notes.md",
},
{
...baseItem,
id: "file",
parentId: null,
type: "file",
title: "notes.md",
name: "notes.md",
},
];
const archive = await new Response(
createWorkspaceExportStream(items, {
readDocument: vi.fn().mockReturnValue({ type: "doc" }),
readFile: vi.fn().mockResolvedValue(new Blob(["file"]).stream()),
}),
).arrayBuffer();

expect(Object.keys(unzipSync(new Uint8Array(archive))).sort()).toEqual([
"Notes (2).md",
"Notes (3).md",
"notes.md",
]);
});
});
Loading