refactor(web): consolidate workspace onto root route with URL state machine

Move the workspace shell from /workspace to / and introduce a typed URL state codec (parseUrlState/serializeUrlState) for deep-linkable workspace params. Legacy /workspace URLs are still recognized for backward compatibility.
This commit is contained in:
kumarabhirup 2026-03-05 19:09:29 -08:00
parent f9d454f5c7
commit 1c21b039fc
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
10 changed files with 4453 additions and 3413 deletions

View File

@ -216,7 +216,7 @@ function WorkspaceSection({ tree, onRefresh }: { tree: TreeNode[]; onRefresh: ()
const handleSelect = useCallback((node: TreeNode) => {
// Navigate to workspace page for actionable items
if (node.type === "object" || node.type === "document" || node.type === "file" || node.type === "database" || node.type === "report") {
window.location.href = `/workspace?path=${encodeURIComponent(node.path)}`;
window.location.href = `/?path=${encodeURIComponent(node.path)}`;
}
}, []);
@ -240,7 +240,7 @@ function WorkspaceSection({ tree, onRefresh }: { tree: TreeNode[]; onRefresh: ()
{/* Full workspace link */}
<a
href="/workspace"
href="/"
className="flex items-center gap-1.5 mx-2 mt-2 px-2 py-1.5 rounded-md text-xs transition-colors hover:bg-[var(--color-surface-hover)]"
style={{ color: "var(--color-accent)" }}
>
@ -279,7 +279,7 @@ function ReportsSection({ tree }: { tree: TreeNode[] }) {
{reports.map((report) => (
<a
key={report.path}
href={`/workspace?path=${encodeURIComponent(report.path)}`}
href={`/?path=${encodeURIComponent(report.path)}`}
className="flex items-center gap-2 mx-2 px-2 py-1.5 rounded-md text-xs transition-colors hover:bg-[var(--color-surface-hover)]"
style={{ color: "var(--color-text-muted)" }}
>

View File

@ -170,7 +170,7 @@ export function DuckDBMissing() {
<button
type="button"
onClick={() => {
window.location.href = "/workspace?send=" + encodeURIComponent("install duckdb");
window.location.href = "/?send=" + encodeURIComponent("install duckdb");
}}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors cursor-pointer"
style={{

View File

@ -226,7 +226,7 @@ export function MarkdownEditor({
}, [editor, insertUploadedImages]);
// Handle link clicks for workspace navigation.
// Links are real URLs like /workspace?path=... so clicking them navigates
// Links are real URLs like /?path=... so clicking them navigates
// within the same tab. We intercept to avoid a full page reload.
useEffect(() => {
if (!editor || !onNavigate) {return;}
@ -239,7 +239,7 @@ export function MarkdownEditor({
const href = link.getAttribute("href");
if (!href) {return;}
// Intercept /workspace?... links to handle via client-side state
// Intercept workspace links to handle via client-side state
if (isWorkspaceLink(href)) {
event.preventDefault();
event.stopPropagation();

View File

@ -1,123 +1,7 @@
"use client";
import Link from "next/link";
// Three dramatic slash marks — like a panther raking its claws
const CLAW_ASCII = [
" ░░░░",
" ░░░░░░",
" ░░░░ ░░░░░░░",
" ░░░░░░ ░░░░ ░░░▓▓░░░",
" ░░░░ ░░░░░░░ ░░░░░░ ░░▓▓▓▓░░░",
" ░░░░░░ ░░░▓▓░░░ ░░░░ ░░░░░░░ ░░▓▓▓▓▓░░ ",
" ░░░░░░░ ░░▓▓▓▓░░░ ░░░░░░ ░░░▓▓░░░ ░░▓▓▓▓▓░░ ",
" ░░░▓▓░░░ ░░▓▓▓▓▓░░ ░░░░░░░ ░░▓▓▓▓░░░ ░▓▓▓▓▓▓░░ ",
" ░░▓▓▓▓░░░ ░░▓▓▓▓▓░░ ░░░▓▓░░░ ░░▓▓▓▓▓░░ ░▓▓▓▓▓▓░░ ",
" ░░▓▓▓▓▓░░ ░▓▓▓▓▓▓░░ ░░▓▓▓▓░░░ ░░▓▓▓▓▓░░ ▓▓▓▓▓▓░░ ",
" ░░▓▓▓▓▓░░ ░▓▓▓▓▓▓░░ ░░▓▓▓▓▓░░ ░▓▓▓▓▓▓░░ ▓▓▓▓▓▓░░ ",
" ░░▓▓▓▓▓░░ ▓▓▓▓▓▓░░ ░░▓▓▓▓▓░░ ░▓▓▓▓▓░░ ▓▓▓▓▓░░░ ",
" ░▓▓▓▓▓▓░░ ▓▓▓▓▓▓░░ ░░▓▓▓▓▓░░ ▓▓▓▓▓▓░░ ▓▓▓▓▓░░ ",
" ░▓▓▓▓▓▓░░ ▓▓▓▓▓░░░ ░▓▓▓▓▓▓░░ ▓▓▓▓▓▓░░ ▓▓▓▓▓░░ ",
" ░▓▓▓▓▓▓░░ ▓▓▓▓▓░░ ░▓▓▓▓▓▓░░ ▓▓▓▓▓░░░ ▓▓▓▓▓░░ ",
" ░▓▓▓▓▓░░░ ▓▓▓▓▓░░ ░▓▓▓▓▓▓░░ ▓▓▓▓▓░░ ▓▓▓▓▓░░ ",
" ░▓▓▓▓▓░░ ▓▓▓▓░░░ ░▓▓▓▓▓░░░ ▓▓▓▓▓░░ ▓▓▓▓░░░ ",
" ░▓▓▓▓▓░░ ▓▓▓▓░░ ░░▓▓▓▓▓░░ ▓▓▓▓░░░ ▓▓▓▓░░ ",
" ░▓▓▓▓░░░ ▓▓▓▓░░ ░░▓▓▓▓▓░░ ▓▓▓▓░░ ▓▓▓▓░░ ",
" ░▓▓▓▓░░ ▓▓▓░░░ ░▓▓▓▓▓░░░ ▓▓▓▓░░ ▓▓▓░░░ ",
" ░▓▓▓▓░░ ▓▓▓░░ ░░▓▓▓▓░░ ▓▓▓░░░ ▓▓▓░░ ",
" ░▓▓▓░░░ ▓▓░░░ ░▓▓▓▓░░░ ▓▓▓░░ ▓▓▓░░ ",
" ░▓▓▓░░ ▓▓░░ ░░▓▓▓░░ ▓▓░░░ ▓▓░░░ ",
" ░▓▓░░░ ░▓░░ ░░▓▓▓░░ ▓▓░░ ▓▓░░ ",
" ░▓▓░░ ░▓░░ ░▓▓▓░░░ ░▓░░░ ░▓░░ ",
" ░▓░░░ ░░░ ░▓▓░░ ░▓░░ ░▓░░ ",
" ░▓░░ ░░ ░░▓░░░ ░░░░ ░░░░ ",
" ░░░░ ░ ░░▓░░ ░░░ ░░░ ",
" ░░░ ░░░░░ ░░ ░░ ",
" ░░ ░░░ ░ ",
" ░ ░░ ",
];
const DENCHCLAW_ASCII = [
" ██╗██████╗ ██████╗ ███╗ ██╗ ██████╗██╗ █████╗ ██╗ ██╗",
" ██║██╔══██╗██╔═══██╗████╗ ██║██╔════╝██║ ██╔══██╗██║ ██║",
" ██║██████╔╝██║ ██║██╔██╗ ██║██║ ██║ ███████║██║ █╗ ██║",
" ██║██╔══██╗██║ ██║██║╚██╗██║██║ ██║ ██╔══██║██║███╗██║",
" ██║██║ ██║╚██████╔╝██║ ╚████║╚██████╗███████╗██║ ██║╚███╔███╔╝",
" ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝ ",
];
import { WorkspaceShell } from "./workspace/workspace-content";
export default function Home() {
return (
<>
<style>{`
@keyframes iron-shimmer {
0% {
background-position: -200% center;
}
100% {
background-position: 200% center;
}
}
.ascii-banner {
font-family: "SF Mono", "Fira Code", "Fira Mono", "Roboto Mono",
"Courier New", monospace;
white-space: pre;
line-height: 1.15;
font-size: clamp(0.5rem, 1.8vw, 1.4rem);
background: linear-gradient(
90deg,
#374151 0%,
#4b5563 10%,
#6b7280 20%,
#9ca3af 30%,
#d1d5db 40%,
#f3f4f6 50%,
#d1d5db 60%,
#9ca3af 70%,
#6b7280 80%,
#4b5563 90%,
#374151 100%
);
background-size: 200% 100%;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: iron-shimmer 2.5s ease-in-out infinite;
}
`}</style>
<div className="relative flex flex-col items-center justify-center min-h-screen bg-stone-50 overflow-hidden px-4">
{/* Claw slash marks as full background — hidden on small screens */}
<div
className="absolute inset-0 hidden md:flex items-center justify-center select-none pointer-events-none text-stone-200/40"
style={{
fontFamily: '"SF Mono", "Fira Code", "Fira Mono", "Roboto Mono", "Courier New", monospace',
whiteSpace: "pre",
lineHeight: 1.0,
fontSize: "clamp(0.75rem, 1.6vw, 1.5rem)",
}}
>
{CLAW_ASCII.join("\n")}
</div>
{/* Foreground content */}
<div className="relative z-10 flex flex-col items-center">
<div className="ascii-banner select-none hidden sm:block" aria-label="DENCHCLAW">
{DENCHCLAW_ASCII.join("\n")}
</div>
<h1 className="sm:hidden text-3xl font-bold text-stone-600" style={{ fontFamily: "monospace" }}>
DENCHCLAW
</h1>
<Link
href="/workspace"
className="mt-10 text-lg text-stone-400 hover:text-stone-600 transition-all min-h-[44px] flex items-center"
style={{ fontFamily: "monospace" }}
>
enter the app &rarr;
</Link>
</div>
</div>
</>
);
return <WorkspaceShell />;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -62,11 +62,11 @@ describe("formatWorkspaceFieldValue", () => {
});
it("identifies workspace file links and includes embed metadata", () => {
const result = formatWorkspaceFieldValue("/workspace?path=docs%2Fdeck.pdf", "text");
const result = formatWorkspaceFieldValue("/?path=docs%2Fdeck.pdf", "text");
expect(result.kind).toBe("link");
expect(result.linkType).toBe("file");
expect(result.filePath).toBe("docs/deck.pdf");
expect(result.href).toContain("/workspace?path=");
expect(result.href).toContain("/?path=");
expect(result.mediaType).toBe("pdf");
expect(result.embedUrl).toBe("/api/workspace/raw-file?path=docs%2Fdeck.pdf");
});

View File

@ -2,7 +2,14 @@ import { describe, it, expect } from "vitest";
import {
buildEntryLink,
buildFileLink,
buildChatLink,
buildSubagentLink,
buildBrowseLink,
buildUrl,
parseWorkspaceLink,
parseUrlState,
serializeUrlState,
migrateWorkspaceUrl,
isWorkspaceLink,
isInternalLink,
isEntryLink,
@ -11,8 +18,8 @@ import {
// ─── buildEntryLink ────────────────────────────────────────────────
describe("buildEntryLink", () => {
it("builds a basic entry link", () => {
expect(buildEntryLink("leads", "abc123")).toBe("/workspace?entry=leads:abc123");
it("builds a basic entry link at root route", () => {
expect(buildEntryLink("leads", "abc123")).toBe("/?entry=leads:abc123");
});
it("encodes special characters in object name", () => {
@ -28,13 +35,12 @@ describe("buildEntryLink", () => {
it("handles empty object name", () => {
const result = buildEntryLink("", "id1");
expect(result).toBe("/workspace?entry=:id1");
expect(result).toBe("/?entry=:id1");
});
it("handles unicode characters", () => {
const result = buildEntryLink("対象", "エントリ");
expect(result).toContain("/workspace?entry=");
// Should decode back correctly
expect(result).toContain("/?entry=");
const url = new URL(result, "http://localhost");
expect(url.searchParams.get("entry")).toBe("対象:エントリ");
});
@ -43,8 +49,8 @@ describe("buildEntryLink", () => {
// ─── buildFileLink ────────────────────────────────────────────────
describe("buildFileLink", () => {
it("builds a basic file link", () => {
expect(buildFileLink("knowledge/doc.md")).toBe("/workspace?path=knowledge%2Fdoc.md");
it("builds a basic file link at root route", () => {
expect(buildFileLink("knowledge/doc.md")).toBe("/?path=knowledge%2Fdoc.md");
});
it("builds link for nested path", () => {
@ -66,33 +72,77 @@ describe("buildFileLink", () => {
});
it("handles empty path", () => {
expect(buildFileLink("")).toBe("/workspace?path=");
expect(buildFileLink("")).toBe("/?path=");
});
});
// ─── buildChatLink ────────────────────────────────────────────────
describe("buildChatLink", () => {
it("builds a chat session link", () => {
expect(buildChatLink("sess-123")).toBe("/?chat=sess-123");
});
});
// ─── buildSubagentLink ────────────────────────────────────────────
describe("buildSubagentLink", () => {
it("includes both chat and subagent params", () => {
const result = buildSubagentLink("parent-id", "child-key");
expect(result).toBe("/?chat=parent-id&subagent=child-key");
});
});
// ─── buildBrowseLink ──────────────────────────────────────────────
describe("buildBrowseLink", () => {
it("builds browse link for an absolute directory", () => {
const result = buildBrowseLink("/Users/me/Desktop");
expect(result).toContain("browse=");
const url = new URL(result, "http://localhost");
expect(url.searchParams.get("browse")).toBe("/Users/me/Desktop");
});
it("includes hidden flag when requested", () => {
const result = buildBrowseLink("/tmp", true);
const url = new URL(result, "http://localhost");
expect(url.searchParams.get("hidden")).toBe("1");
});
});
// ─── parseWorkspaceLink ───────────────────────────────────────────
describe("parseWorkspaceLink", () => {
it("parses file link from path param", () => {
const result = parseWorkspaceLink("/workspace?path=knowledge/doc.md");
it("parses file link from root route path param", () => {
const result = parseWorkspaceLink("/?path=knowledge/doc.md");
expect(result).toEqual({ kind: "file", path: "knowledge/doc.md" });
});
it("parses entry link from entry param", () => {
const result = parseWorkspaceLink("/workspace?entry=leads:abc123");
it("parses entry link from root route entry param", () => {
const result = parseWorkspaceLink("/?entry=leads:abc123");
expect(result).toEqual({ kind: "entry", objectName: "leads", entryId: "abc123" });
});
it("parses entry link from full URL", () => {
const result = parseWorkspaceLink("http://localhost:3100/workspace?entry=deals:xyz");
const result = parseWorkspaceLink("http://localhost:3100/?entry=deals:xyz");
expect(result).toEqual({ kind: "entry", objectName: "deals", entryId: "xyz" });
});
it("parses file link from full URL", () => {
const result = parseWorkspaceLink("http://localhost:3100/workspace?path=readme.md");
const result = parseWorkspaceLink("http://localhost:3100/?path=readme.md");
expect(result).toEqual({ kind: "file", path: "readme.md" });
});
it("parses legacy /workspace file link (backward compat)", () => {
const result = parseWorkspaceLink("/workspace?path=knowledge/doc.md");
expect(result).toEqual({ kind: "file", path: "knowledge/doc.md" });
});
it("parses legacy /workspace entry link (backward compat)", () => {
const result = parseWorkspaceLink("/workspace?entry=leads:abc123");
expect(result).toEqual({ kind: "entry", objectName: "leads", entryId: "abc123" });
});
it("parses legacy @entry/ format", () => {
const result = parseWorkspaceLink("@entry/leads/abc123");
expect(result).toEqual({ kind: "entry", objectName: "leads", entryId: "abc123" });
@ -102,25 +152,25 @@ describe("parseWorkspaceLink", () => {
expect(parseWorkspaceLink("not a url ://bad")).toBeNull();
});
it("returns null when no params present", () => {
expect(parseWorkspaceLink("/workspace")).toBeNull();
it("returns null when no params present on root", () => {
expect(parseWorkspaceLink("/")).toBeNull();
});
it("returns null for hash-only link", () => {
expect(parseWorkspaceLink("/workspace#section")).toBeNull();
expect(parseWorkspaceLink("/#section")).toBeNull();
});
it("returns null for entry param without colon", () => {
expect(parseWorkspaceLink("/workspace?entry=nocolon")).toBeNull();
expect(parseWorkspaceLink("/?entry=nocolon")).toBeNull();
});
it("handles deeply nested file path", () => {
const result = parseWorkspaceLink("/workspace?path=a/b/c/d/e/f.txt");
const result = parseWorkspaceLink("/?path=a/b/c/d/e/f.txt");
expect(result).toEqual({ kind: "file", path: "a/b/c/d/e/f.txt" });
});
it("handles encoded characters in path", () => {
const result = parseWorkspaceLink("/workspace?path=my%20docs%2Ffile.md");
const result = parseWorkspaceLink("/?path=my%20docs%2Ffile.md");
expect(result).toEqual({ kind: "file", path: "my docs/file.md" });
});
@ -129,12 +179,12 @@ describe("parseWorkspaceLink", () => {
});
it("entry param takes priority over path param", () => {
const result = parseWorkspaceLink("/workspace?entry=obj:id&path=file.md");
const result = parseWorkspaceLink("/?entry=obj:id&path=file.md");
expect(result).toEqual({ kind: "entry", objectName: "obj", entryId: "id" });
});
it("handles entry with colon in ID", () => {
const result = parseWorkspaceLink("/workspace?entry=obj:id:with:colons");
const result = parseWorkspaceLink("/?entry=obj:id:with:colons");
expect(result).toEqual({ kind: "entry", objectName: "obj", entryId: "id:with:colons" });
});
@ -146,27 +196,35 @@ describe("parseWorkspaceLink", () => {
// ─── isWorkspaceLink ──────────────────────────────────────────────
describe("isWorkspaceLink", () => {
it("returns true for /workspace?path=...", () => {
expect(isWorkspaceLink("/workspace?path=doc.md")).toBe(true);
it("returns true for /?path=...", () => {
expect(isWorkspaceLink("/?path=doc.md")).toBe(true);
});
it("returns true for /workspace#...", () => {
expect(isWorkspaceLink("/workspace#section")).toBe(true);
it("returns true for /#...", () => {
expect(isWorkspaceLink("/#section")).toBe(true);
});
it("returns true for /workspace alone", () => {
expect(isWorkspaceLink("/workspace")).toBe(true);
it("returns true for / alone", () => {
expect(isWorkspaceLink("/")).toBe(true);
});
it("returns true for @entry/ format", () => {
expect(isWorkspaceLink("@entry/leads/abc")).toBe(true);
});
it("returns true for legacy /workspace?path=... (backward compat)", () => {
expect(isWorkspaceLink("/workspace?path=doc.md")).toBe(true);
});
it("returns true for legacy /workspace alone", () => {
expect(isWorkspaceLink("/workspace")).toBe(true);
});
it("returns false for external URL", () => {
expect(isWorkspaceLink("https://example.com")).toBe(false);
});
it("returns false for random path", () => {
it("returns false for non-root path", () => {
expect(isWorkspaceLink("/other-page")).toBe(false);
});
@ -191,7 +249,7 @@ describe("isInternalLink", () => {
});
it("returns true for relative paths", () => {
expect(isInternalLink("/workspace?path=doc.md")).toBe(true);
expect(isInternalLink("/?path=doc.md")).toBe(true);
});
it("returns true for @entry/ links", () => {
@ -206,7 +264,11 @@ describe("isInternalLink", () => {
// ─── isEntryLink ──────────────────────────────────────────────────
describe("isEntryLink", () => {
it("returns true for new format entry link", () => {
it("returns true for new format entry link at root", () => {
expect(isEntryLink("/?entry=leads:abc")).toBe(true);
});
it("returns true for legacy /workspace entry link", () => {
expect(isEntryLink("/workspace?entry=leads:abc")).toBe(true);
});
@ -215,14 +277,266 @@ describe("isEntryLink", () => {
});
it("returns false for file workspace link", () => {
expect(isEntryLink("/workspace?path=doc.md")).toBe(false);
expect(isEntryLink("/?path=doc.md")).toBe(false);
});
it("returns false for external URL", () => {
expect(isEntryLink("https://example.com")).toBe(false);
});
it("returns false for plain /workspace", () => {
expect(isEntryLink("/workspace")).toBe(false);
it("returns false for plain root", () => {
expect(isEntryLink("/")).toBe(false);
});
});
// ─── migrateWorkspaceUrl ──────────────────────────────────────────
describe("migrateWorkspaceUrl", () => {
it("migrates /workspace to /", () => {
expect(migrateWorkspaceUrl("/workspace")).toBe("/");
});
it("migrates /workspace?path=doc.md preserving query params", () => {
expect(migrateWorkspaceUrl("/workspace?path=doc.md")).toBe("/?path=doc.md");
});
it("migrates /workspace?chat=abc&entry=obj:id", () => {
expect(migrateWorkspaceUrl("/workspace?chat=abc&entry=obj:id")).toBe("/?chat=abc&entry=obj:id");
});
it("preserves hash fragments", () => {
expect(migrateWorkspaceUrl("/workspace#section")).toBe("/#section");
});
it("preserves both query and hash", () => {
expect(migrateWorkspaceUrl("/workspace?path=a.md#heading")).toBe("/?path=a.md#heading");
});
it("returns null for non-workspace URLs", () => {
expect(migrateWorkspaceUrl("/other")).toBeNull();
expect(migrateWorkspaceUrl("https://example.com")).toBeNull();
expect(migrateWorkspaceUrl("/")).toBeNull();
});
});
// ─── parseUrlState ────────────────────────────────────────────────
describe("parseUrlState", () => {
it("parses path from search params", () => {
const state = parseUrlState("path=knowledge/doc.md");
expect(state.path).toBe("knowledge/doc.md");
expect(state.chat).toBeNull();
});
it("parses chat session", () => {
const state = parseUrlState("chat=sess-123");
expect(state.chat).toBe("sess-123");
expect(state.path).toBeNull();
});
it("parses subagent with parent chat", () => {
const state = parseUrlState("chat=parent&subagent=child-key");
expect(state.chat).toBe("parent");
expect(state.subagent).toBe("child-key");
});
it("parses entry param", () => {
const state = parseUrlState("entry=leads:abc123");
expect(state.entry).toEqual({ objectName: "leads", entryId: "abc123" });
});
it("handles entry with colon in ID", () => {
const state = parseUrlState("entry=obj:id:with:colons");
expect(state.entry).toEqual({ objectName: "obj", entryId: "id:with:colons" });
});
it("returns null entry for entry param without colon", () => {
const state = parseUrlState("entry=nocolon");
expect(state.entry).toBeNull();
});
it("parses browse mode", () => {
const state = parseUrlState("browse=/Users/me/Desktop&hidden=1");
expect(state.browse).toBe("/Users/me/Desktop");
expect(state.hidden).toBe(true);
});
it("hidden defaults to false", () => {
const state = parseUrlState("browse=/tmp");
expect(state.hidden).toBe(false);
});
it("parses preview target", () => {
const state = parseUrlState("path=file.md&preview=other.md");
expect(state.preview).toBe("other.md");
});
it("parses object view params", () => {
const state = parseUrlState("path=leads&viewType=kanban&view=MyView&search=hello&page=3&pageSize=50&cols=name,email,status");
expect(state.viewType).toBe("kanban");
expect(state.view).toBe("MyView");
expect(state.search).toBe("hello");
expect(state.page).toBe(3);
expect(state.pageSize).toBe(50);
expect(state.cols).toEqual(["name", "email", "status"]);
});
it("rejects invalid view types", () => {
const state = parseUrlState("viewType=invalid");
expect(state.viewType).toBeNull();
});
it("parses base64-encoded filters", () => {
const fg = { id: "root", conjunction: "and" as const, rules: [{ id: "r1", field: "status", operator: "equals" as const, value: "active" }] };
const encoded = btoa(JSON.stringify(fg));
const state = parseUrlState(`filters=${encoded}`);
expect(state.filters).toEqual(fg);
});
it("parses base64-encoded sort rules", () => {
const rules = [{ field: "name", direction: "asc" as const }];
const encoded = btoa(JSON.stringify(rules));
const state = parseUrlState(`sort=${encoded}`);
expect(state.sort).toEqual(rules);
});
it("handles invalid base64 filters gracefully", () => {
const state = parseUrlState("filters=!!!invalid!!!");
expect(state.filters).toBeNull();
});
it("handles invalid base64 sort gracefully", () => {
const state = parseUrlState("sort=!!!invalid!!!");
expect(state.sort).toBeNull();
});
it("returns all nulls/defaults for empty search", () => {
const state = parseUrlState("");
expect(state.path).toBeNull();
expect(state.chat).toBeNull();
expect(state.subagent).toBeNull();
expect(state.fileChat).toBeNull();
expect(state.entry).toBeNull();
expect(state.send).toBeNull();
expect(state.browse).toBeNull();
expect(state.hidden).toBe(false);
expect(state.preview).toBeNull();
expect(state.view).toBeNull();
expect(state.viewType).toBeNull();
expect(state.filters).toBeNull();
expect(state.search).toBeNull();
expect(state.sort).toBeNull();
expect(state.page).toBeNull();
expect(state.pageSize).toBeNull();
expect(state.cols).toBeNull();
});
it("accepts URLSearchParams directly", () => {
const params = new URLSearchParams();
params.set("chat", "s1");
const state = parseUrlState(params);
expect(state.chat).toBe("s1");
});
});
// ─── serializeUrlState ────────────────────────────────────────────
describe("serializeUrlState", () => {
it("omits null/default values", () => {
expect(serializeUrlState({})).toBe("");
});
it("serializes path only", () => {
const qs = serializeUrlState({ path: "doc.md" });
expect(qs).toBe("path=doc.md");
});
it("serializes chat and subagent", () => {
const qs = serializeUrlState({ chat: "parent", subagent: "child" });
expect(qs).toContain("chat=parent");
expect(qs).toContain("subagent=child");
});
it("serializes entry", () => {
const qs = serializeUrlState({ entry: { objectName: "leads", entryId: "x" } });
// URLSearchParams encodes : as %3A in values
expect(qs).toContain("entry=leads");
expect(qs).toContain("x");
// Round-trip must restore the entry
const parsed = parseUrlState(qs);
expect(parsed.entry).toEqual({ objectName: "leads", entryId: "x" });
});
it("serializes browse with hidden", () => {
const qs = serializeUrlState({ browse: "/tmp", hidden: true });
expect(qs).toContain("browse=%2Ftmp");
expect(qs).toContain("hidden=1");
});
it("omits page=1 (default)", () => {
const qs = serializeUrlState({ page: 1 });
expect(qs).toBe("");
});
it("includes page > 1", () => {
const qs = serializeUrlState({ page: 3 });
expect(qs).toBe("page=3");
});
it("serializes cols as comma-separated", () => {
const qs = serializeUrlState({ cols: ["name", "email"] });
expect(qs).toBe("cols=name%2Cemail");
});
it("round-trips through parseUrlState", () => {
const original: Partial<import("./workspace-links").WorkspaceUrlState> = {
path: "leads",
viewType: "kanban",
search: "hello",
page: 2,
pageSize: 50,
cols: ["name", "email"],
};
const qs = serializeUrlState(original);
const parsed = parseUrlState(qs);
expect(parsed.path).toBe("leads");
expect(parsed.viewType).toBe("kanban");
expect(parsed.search).toBe("hello");
expect(parsed.page).toBe(2);
expect(parsed.pageSize).toBe(50);
expect(parsed.cols).toEqual(["name", "email"]);
});
it("round-trips filters through serialize/parse", () => {
const fg = { id: "root", conjunction: "and" as const, rules: [{ id: "r1", field: "status", operator: "equals" as const, value: "active" }] };
const qs = serializeUrlState({ filters: fg });
const parsed = parseUrlState(qs);
expect(parsed.filters).toEqual(fg);
});
it("round-trips sort through serialize/parse", () => {
const sort = [{ field: "name", direction: "desc" as const }];
const qs = serializeUrlState({ sort });
const parsed = parseUrlState(qs);
expect(parsed.sort).toEqual(sort);
});
});
// ─── buildUrl ─────────────────────────────────────────────────────
describe("buildUrl", () => {
it("returns / for empty state", () => {
expect(buildUrl({})).toBe("/");
});
it("builds root URL with query params", () => {
expect(buildUrl({ path: "doc.md" })).toBe("/?path=doc.md");
});
it("builds complex URL with multiple params", () => {
const url = buildUrl({ chat: "s1", subagent: "sa1" });
expect(url).toContain("/?");
expect(url).toContain("chat=s1");
expect(url).toContain("subagent=sa1");
});
});

View File

@ -1,45 +1,251 @@
/**
* Workspace link utilities.
*
* All workspace links use REAL URLs so they work if the browser follows them:
* Files/docs: /workspace?path=knowledge/path/to/doc.md
* Objects: /workspace?path=knowledge/leads
* Entries: /workspace?entry=leads:abc123
* All workspace links use the root route with query params:
* Files/docs: /?path=knowledge/path/to/doc.md
* Objects: /?path=knowledge/leads
* Entries: /?entry=leads:abc123
* Chat: /?chat=session-id
* Subagent: /?chat=parent-id&subagent=child-key
* Browse: /?browse=/abs/path&hidden=1
* Cron: /?path=~cron or /?path=~cron/job-id
* Object view: /?path=leads&viewType=kanban&filters=...&sort=...&search=...&page=1&pageSize=50&cols=a,b,c&view=MyView
* Preview: /?path=file.md&preview=other.md
* Send: /?send=install+duckdb (consumed immediately)
*
* Legacy /workspace?... links are accepted by parseWorkspaceLink and
* migrateWorkspaceUrl for backward compat.
*/
import type { FilterGroup, SortRule, ViewType } from "./object-filters";
// ---------------------------------------------------------------------------
// Parsed link (simple)
// ---------------------------------------------------------------------------
export type WorkspaceLink =
| { kind: "file"; path: string }
| { kind: "entry"; objectName: string; entryId: string };
// --- Builders ---
// ---------------------------------------------------------------------------
// Full URL state
// ---------------------------------------------------------------------------
/** Build a real URL for an entry detail modal. */
export function buildEntryLink(objectName: string, entryId: string): string {
return `/workspace?entry=${encodeURIComponent(objectName)}:${encodeURIComponent(entryId)}`;
}
export type WorkspaceUrlState = {
path: string | null;
chat: string | null;
subagent: string | null;
/** File-scoped chat session (active when a file is open in the main panel). */
fileChat: string | null;
entry: { objectName: string; entryId: string } | null;
send: string | null;
browse: string | null;
hidden: boolean;
preview: string | null;
view: string | null;
viewType: ViewType | null;
filters: FilterGroup | null;
search: string | null;
sort: SortRule[] | null;
page: number | null;
pageSize: number | null;
cols: string[] | null;
};
/** Build a real URL for a file or object in the workspace. */
export function buildFileLink(path: string): string {
return `/workspace?path=${encodeURIComponent(path)}`;
}
const VALID_VIEW_TYPES: ViewType[] = [
"table", "kanban", "calendar", "timeline", "gallery", "list",
];
// --- Parsers ---
// ---------------------------------------------------------------------------
// URL state codec
// ---------------------------------------------------------------------------
/** Parse a workspace URL into a structured link. Returns null if not a workspace link. */
export function parseWorkspaceLink(href: string): WorkspaceLink | null {
// Handle full or relative /workspace?... URLs
let url: URL | null = null;
try {
if (href.startsWith("/workspace")) {
url = new URL(href, "http://localhost");
} else if (href.includes("/workspace?")) {
url = new URL(href);
}
} catch {
// not a valid URL
/** Parse search params (from any origin) into a typed WorkspaceUrlState. */
export function parseUrlState(search: string | URLSearchParams): WorkspaceUrlState {
const params = typeof search === "string"
? new URLSearchParams(search)
: search;
let entry: WorkspaceUrlState["entry"] = null;
const entryRaw = params.get("entry");
if (entryRaw && entryRaw.includes(":")) {
const idx = entryRaw.indexOf(":");
entry = {
objectName: entryRaw.slice(0, idx),
entryId: entryRaw.slice(idx + 1),
};
}
if (url) {
let filters: FilterGroup | null = null;
const filtersRaw = params.get("filters");
if (filtersRaw) {
try {
filters = JSON.parse(atob(filtersRaw)) as FilterGroup;
} catch { /* invalid — ignore */ }
}
let sort: SortRule[] | null = null;
const sortRaw = params.get("sort");
if (sortRaw) {
try {
sort = JSON.parse(atob(sortRaw)) as SortRule[];
} catch { /* invalid — ignore */ }
}
const pageRaw = params.get("page");
const pageSizeRaw = params.get("pageSize");
const colsRaw = params.get("cols");
const viewTypeRaw = params.get("viewType") as ViewType | null;
return {
path: params.get("path"),
chat: params.get("chat"),
subagent: params.get("subagent"),
fileChat: params.get("fileChat"),
entry,
send: params.get("send"),
browse: params.get("browse"),
hidden: params.get("hidden") === "1",
preview: params.get("preview"),
view: params.get("view"),
viewType:
viewTypeRaw && VALID_VIEW_TYPES.includes(viewTypeRaw)
? viewTypeRaw
: null,
filters,
search: params.get("search"),
sort,
page: pageRaw ? parseInt(pageRaw, 10) || null : null,
pageSize: pageSizeRaw ? parseInt(pageSizeRaw, 10) || null : null,
cols: colsRaw ? colsRaw.split(",").filter(Boolean) : null,
};
}
/** Serialize a (partial) WorkspaceUrlState to a query string. Omits null/default values. */
export function serializeUrlState(state: Partial<WorkspaceUrlState>): string {
const params = new URLSearchParams();
if (state.path) params.set("path", state.path);
if (state.chat) params.set("chat", state.chat);
if (state.subagent) params.set("subagent", state.subagent);
if (state.fileChat) params.set("fileChat", state.fileChat);
if (state.entry) {
params.set("entry", `${state.entry.objectName}:${state.entry.entryId}`);
}
if (state.send) params.set("send", state.send);
if (state.browse) params.set("browse", state.browse);
if (state.hidden) params.set("hidden", "1");
if (state.preview) params.set("preview", state.preview);
if (state.view) params.set("view", state.view);
if (state.viewType) params.set("viewType", state.viewType);
if (state.filters && state.filters.rules.length > 0) {
params.set("filters", btoa(JSON.stringify(state.filters)));
}
if (state.search) params.set("search", state.search);
if (state.sort && state.sort.length > 0) {
params.set("sort", btoa(JSON.stringify(state.sort)));
}
if (state.page != null && state.page > 1) params.set("page", String(state.page));
if (state.pageSize != null) params.set("pageSize", String(state.pageSize));
if (state.cols && state.cols.length > 0) params.set("cols", state.cols.join(","));
return params.toString();
}
/** Build a full root-route URL string from partial state. */
export function buildUrl(state: Partial<WorkspaceUrlState>): string {
const qs = serializeUrlState(state);
return qs ? `/?${qs}` : "/";
}
// ---------------------------------------------------------------------------
// Legacy migration
// ---------------------------------------------------------------------------
/**
* Convert a legacy /workspace?... URL to the equivalent root URL.
* Returns null if the href is not a legacy workspace URL.
*/
export function migrateWorkspaceUrl(href: string): string | null {
const isLegacy =
href === "/workspace" ||
href.startsWith("/workspace?") ||
href.startsWith("/workspace#");
if (!isLegacy) return null;
if (href === "/workspace") return "/";
const qIdx = href.indexOf("?");
const hIdx = href.indexOf("#");
let qs = "";
let hash = "";
if (qIdx >= 0) {
const endOfQs = hIdx > qIdx ? hIdx : href.length;
qs = href.slice(qIdx, endOfQs);
}
if (hIdx >= 0) {
hash = href.slice(hIdx);
}
return `/${qs}${hash}`;
}
// ---------------------------------------------------------------------------
// Simple link builders
// ---------------------------------------------------------------------------
/** Build a URL for an entry detail modal. */
export function buildEntryLink(objectName: string, entryId: string): string {
return `/?entry=${encodeURIComponent(objectName)}:${encodeURIComponent(entryId)}`;
}
/** Build a URL for a file or object in the workspace. */
export function buildFileLink(path: string): string {
return `/?path=${encodeURIComponent(path)}`;
}
/** Build a URL for a chat session. */
export function buildChatLink(sessionId: string): string {
return `/?chat=${encodeURIComponent(sessionId)}`;
}
/** Build a URL for a subagent panel. */
export function buildSubagentLink(chatId: string, subagentKey: string): string {
return `/?chat=${encodeURIComponent(chatId)}&subagent=${encodeURIComponent(subagentKey)}`;
}
/** Build a URL for browse mode. */
export function buildBrowseLink(dir: string, showHidden?: boolean): string {
const p = new URLSearchParams();
p.set("browse", dir);
if (showHidden) p.set("hidden", "1");
return `/?${p.toString()}`;
}
// ---------------------------------------------------------------------------
// Simple link parsers / predicates
// ---------------------------------------------------------------------------
function tryParseAppUrl(href: string): URL | null {
try {
if (href.startsWith("/")) {
return new URL(href, "http://localhost");
}
const u = new URL(href);
if (u.pathname === "/" || u.pathname === "/workspace") return u;
} catch { /* invalid */ }
return null;
}
/** Parse a workspace URL into a structured link. Accepts both root and legacy /workspace URLs. */
export function parseWorkspaceLink(href: string): WorkspaceLink | null {
const url = tryParseAppUrl(href);
if (url && (url.pathname === "/" || url.pathname === "/workspace")) {
const entryParam = url.searchParams.get("entry");
if (entryParam && entryParam.includes(":")) {
const colonIdx = entryParam.indexOf(":");
@ -72,14 +278,20 @@ export function parseWorkspaceLink(href: string): WorkspaceLink | null {
return null;
}
/** Check if an href is a workspace link (either /workspace?... or legacy @entry/). */
/** Check if an href is an app-internal workspace link (root, legacy /workspace, or @entry/). */
export function isWorkspaceLink(href: string): boolean {
return (
href.startsWith("/workspace?") ||
href.startsWith("/workspace#") ||
if (href.startsWith("@entry/")) return true;
// Legacy /workspace links
if (
href === "/workspace" ||
href.startsWith("@entry/")
);
href.startsWith("/workspace?") ||
href.startsWith("/workspace#")
) return true;
// New root links with query params
if (href === "/") return true;
if (href.startsWith("/?")) return true;
if (href.startsWith("/#")) return true;
return false;
}
/** Check if an href is a workspace-internal link (not external URL). */
@ -93,7 +305,7 @@ export function isInternalLink(href: string): boolean {
/** Check if an href is an entry link (any format). */
export function isEntryLink(href: string): boolean {
if (href.startsWith("@entry/")) {return true;}
if (href.startsWith("/workspace") && href.includes("entry=")) {return true;}
if (href.startsWith("@entry/")) return true;
if ((href.startsWith("/") || href.startsWith("/workspace")) && href.includes("entry=")) return true;
return false;
}

View File

@ -0,0 +1,483 @@
/**
* Tests for the workspace URL state machine.
*
* These tests verify invariants around deep-linking, state restoration,
* URL serialization round-trips, and precedence rules that keep the app
* navigable via copied/bookmarked URLs.
*/
import { describe, it, expect } from "vitest";
import {
parseUrlState,
serializeUrlState,
buildUrl,
buildEntryLink,
buildFileLink,
buildChatLink,
buildSubagentLink,
buildBrowseLink,
migrateWorkspaceUrl,
parseWorkspaceLink,
isWorkspaceLink,
isEntryLink,
type WorkspaceUrlState,
} from "./workspace-links";
// ---------------------------------------------------------------------------
// Deep-link restoration invariants
// ---------------------------------------------------------------------------
describe("deep-link restoration", () => {
it("restores file path from copied URL (prevents lost context on page reload)", () => {
const url = buildFileLink("knowledge/CRM/contacts.md");
const state = parseUrlState(new URL(url, "http://localhost").search);
expect(state.path).toBe("knowledge/CRM/contacts.md");
});
it("restores chat session from copied URL (prevents lost conversation on refresh)", () => {
const url = buildChatLink("sess-abc-123");
const state = parseUrlState(new URL(url, "http://localhost").search);
expect(state.chat).toBe("sess-abc-123");
});
it("restores subagent panel from copied URL (prevents lost subagent context on refresh)", () => {
const url = buildSubagentLink("parent-sess", "child-key-456");
const state = parseUrlState(new URL(url, "http://localhost").search);
expect(state.chat).toBe("parent-sess");
expect(state.subagent).toBe("child-key-456");
});
it("restores entry modal from copied URL (prevents lost entry detail on refresh)", () => {
const url = buildEntryLink("leads", "entry-789");
const state = parseUrlState(new URL(url, "http://localhost").search);
expect(state.entry).toEqual({ objectName: "leads", entryId: "entry-789" });
});
it("restores browse mode from copied URL (prevents lost directory context on refresh)", () => {
const url = buildBrowseLink("/Users/me/projects/app", true);
const state = parseUrlState(new URL(url, "http://localhost").search);
expect(state.browse).toBe("/Users/me/projects/app");
expect(state.hidden).toBe(true);
});
it("restores cron dashboard from virtual path (prevents lost cron view on refresh)", () => {
const url = buildFileLink("~cron");
const state = parseUrlState(new URL(url, "http://localhost").search);
expect(state.path).toBe("~cron");
});
it("restores cron job detail from virtual path (prevents lost job detail on refresh)", () => {
const url = buildFileLink("~cron/daily-sync");
const state = parseUrlState(new URL(url, "http://localhost").search);
expect(state.path).toBe("~cron/daily-sync");
});
it("restores object view with filter/sort/search/page from URL (prevents lost table state on refresh)", () => {
const filters = { id: "root", conjunction: "and" as const, rules: [{ id: "r1", field: "status", operator: "equals" as const, value: "active" }] };
const sort = [{ field: "name", direction: "asc" as const }];
const qs = serializeUrlState({
path: "knowledge/leads",
viewType: "table",
filters,
sort,
search: "acme",
page: 3,
pageSize: 25,
cols: ["name", "email", "status"],
view: "Active Leads",
});
const state = parseUrlState(qs);
expect(state.path).toBe("knowledge/leads");
expect(state.viewType).toBe("table");
expect(state.filters).toEqual(filters);
expect(state.sort).toEqual(sort);
expect(state.search).toBe("acme");
expect(state.page).toBe(3);
expect(state.pageSize).toBe(25);
expect(state.cols).toEqual(["name", "email", "status"]);
expect(state.view).toBe("Active Leads");
});
it("restores sidebar preview from copied URL (prevents lost preview on refresh)", () => {
const qs = serializeUrlState({ path: "README.md", preview: "other/file.md" });
const state = parseUrlState(qs);
expect(state.path).toBe("README.md");
expect(state.preview).toBe("other/file.md");
});
});
// ---------------------------------------------------------------------------
// Precedence rules when multiple params coexist
// ---------------------------------------------------------------------------
describe("URL parameter precedence", () => {
it("entry takes priority over path in parseWorkspaceLink (prevents ambiguous navigation)", () => {
const link = parseWorkspaceLink("/?entry=leads:abc&path=some/file.md");
expect(link?.kind).toBe("entry");
});
it("path and chat are mutually exclusive in serialization (prevents dual-mode confusion)", () => {
const qs = serializeUrlState({ path: "doc.md", chat: "sess1" });
const params = new URLSearchParams(qs);
expect(params.has("path")).toBe(true);
expect(params.has("chat")).toBe(true);
// Both are present but the page-level effect chooses path over chat
});
it("subagent param is only meaningful with a chat param (prevents orphan subagent state)", () => {
const state = parseUrlState("subagent=child-key");
expect(state.subagent).toBe("child-key");
expect(state.chat).toBeNull();
// Without chat, the subagent param has no parent session to attach to
});
it("browse and path can coexist (browse controls sidebar, path controls main panel)", () => {
const qs = serializeUrlState({ path: "some/file.md", browse: "/absolute/dir" });
const state = parseUrlState(qs);
expect(state.path).toBe("some/file.md");
expect(state.browse).toBe("/absolute/dir");
});
it("hidden flag only applies when browse is set (prevents hidden leak into workspace mode)", () => {
const state = parseUrlState("hidden=1");
expect(state.hidden).toBe(true);
expect(state.browse).toBeNull();
// The page-level handler should ignore hidden without browse
});
});
// ---------------------------------------------------------------------------
// Object view URL state edge cases
// ---------------------------------------------------------------------------
describe("object view URL state", () => {
it("omits default view type to keep URLs clean (prevents URL bloat)", () => {
const qs = serializeUrlState({ path: "leads" });
expect(qs).not.toContain("viewType");
});
it("omits page=1 since it is the default (prevents URL bloat)", () => {
const qs = serializeUrlState({ path: "leads", page: 1 });
expect(qs).not.toContain("page=");
});
it("preserves page > 1 in URL (prevents pagination loss on refresh)", () => {
const qs = serializeUrlState({ path: "leads", page: 5 });
expect(qs).toContain("page=5");
});
it("omits empty filter group (prevents URL noise)", () => {
const qs = serializeUrlState({ filters: { id: "root", conjunction: "and", rules: [] } });
expect(qs).not.toContain("filters");
});
it("preserves non-empty filter group (prevents filter loss on refresh)", () => {
const fg = { id: "root", conjunction: "and" as const, rules: [{ id: "r1", field: "f", operator: "equals" as const, value: "v" }] };
const qs = serializeUrlState({ filters: fg });
expect(qs).toContain("filters=");
const restored = parseUrlState(qs);
expect(restored.filters?.rules).toHaveLength(1);
});
it("omits empty sort array (prevents URL noise)", () => {
const qs = serializeUrlState({ sort: [] });
expect(qs).not.toContain("sort");
});
it("preserves sort rules (prevents sort loss on refresh)", () => {
const sort = [{ field: "created_at", direction: "desc" as const }];
const qs = serializeUrlState({ sort });
const restored = parseUrlState(qs);
expect(restored.sort).toEqual(sort);
});
it("columns survive round-trip with special characters in names (prevents data loss)", () => {
const cols = ["full name", "email & phone", "status"];
const qs = serializeUrlState({ cols });
const restored = parseUrlState(qs);
expect(restored.cols).toEqual(cols);
});
it("rejects unknown view types (prevents invalid state from corrupted URLs)", () => {
const state = parseUrlState("viewType=pivot");
expect(state.viewType).toBeNull();
});
it("accepts all valid view types (ensures all view modes are deep-linkable)", () => {
for (const vt of ["table", "kanban", "calendar", "timeline", "gallery", "list"] as const) {
const qs = serializeUrlState({ viewType: vt });
const state = parseUrlState(qs);
expect(state.viewType).toBe(vt);
}
});
it("handles complex filter with nested groups (prevents structured data loss)", () => {
const fg = {
id: "root",
conjunction: "and" as const,
rules: [
{ id: "r1", field: "status", operator: "equals" as const, value: "active" },
{
id: "g1",
conjunction: "or" as const,
rules: [
{ id: "r2", field: "priority", operator: "equals" as const, value: "high" },
{ id: "r3", field: "priority", operator: "equals" as const, value: "critical" },
],
},
],
};
const qs = serializeUrlState({ filters: fg });
const restored = parseUrlState(qs);
expect(restored.filters).toEqual(fg);
});
});
// ---------------------------------------------------------------------------
// Legacy /workspace migration
// ---------------------------------------------------------------------------
describe("legacy /workspace URL migration", () => {
it("migrates bare /workspace to / (prevents stale bookmarks)", () => {
expect(migrateWorkspaceUrl("/workspace")).toBe("/");
});
it("migrates /workspace?path=doc.md preserving all params (prevents param loss)", () => {
const migrated = migrateWorkspaceUrl("/workspace?path=doc.md&entry=obj:id");
expect(migrated).toBe("/?path=doc.md&entry=obj:id");
});
it("migrates /workspace#hash preserving fragment (prevents anchor loss)", () => {
expect(migrateWorkspaceUrl("/workspace#section")).toBe("/#section");
});
it("migrates /workspace?query#hash preserving both (prevents combined loss)", () => {
expect(migrateWorkspaceUrl("/workspace?path=x#y")).toBe("/?path=x#y");
});
it("returns null for non-workspace URLs (prevents false positive migration)", () => {
expect(migrateWorkspaceUrl("/")).toBeNull();
expect(migrateWorkspaceUrl("/other")).toBeNull();
expect(migrateWorkspaceUrl("https://example.com/workspace")).toBeNull();
});
it("isWorkspaceLink recognizes both old and new formats (prevents broken link detection)", () => {
expect(isWorkspaceLink("/workspace")).toBe(true);
expect(isWorkspaceLink("/workspace?path=x")).toBe(true);
expect(isWorkspaceLink("/")).toBe(true);
expect(isWorkspaceLink("/?path=x")).toBe(true);
});
it("parseWorkspaceLink handles both old and new entry formats (prevents entry link breakage)", () => {
const oldResult = parseWorkspaceLink("/workspace?entry=leads:abc");
const newResult = parseWorkspaceLink("/?entry=leads:abc");
expect(oldResult).toEqual(newResult);
expect(oldResult).toEqual({ kind: "entry", objectName: "leads", entryId: "abc" });
});
it("parseWorkspaceLink handles both old and new file formats (prevents file link breakage)", () => {
const oldResult = parseWorkspaceLink("/workspace?path=doc.md");
const newResult = parseWorkspaceLink("/?path=doc.md");
expect(oldResult).toEqual(newResult);
expect(oldResult).toEqual({ kind: "file", path: "doc.md" });
});
it("isEntryLink recognizes entries in both old and new format (prevents entry detection failure)", () => {
expect(isEntryLink("/workspace?entry=obj:id")).toBe(true);
expect(isEntryLink("/?entry=obj:id")).toBe(true);
expect(isEntryLink("@entry/obj/id")).toBe(true);
});
});
// ---------------------------------------------------------------------------
// URL builder correctness
// ---------------------------------------------------------------------------
describe("URL builders produce correct root-route URLs", () => {
it("buildFileLink produces root URL (prevents /workspace leak)", () => {
expect(buildFileLink("doc.md")).toMatch(/^\/\?path=/);
expect(buildFileLink("doc.md")).not.toContain("/workspace");
});
it("buildEntryLink produces root URL (prevents /workspace leak)", () => {
expect(buildEntryLink("obj", "id")).toMatch(/^\/\?entry=/);
expect(buildEntryLink("obj", "id")).not.toContain("/workspace");
});
it("buildChatLink produces root URL (prevents /workspace leak)", () => {
expect(buildChatLink("sess")).toMatch(/^\/\?chat=/);
});
it("buildSubagentLink produces root URL with both params (prevents /workspace leak)", () => {
const url = buildSubagentLink("parent", "child");
expect(url).toMatch(/^\/\?/);
expect(url).toContain("chat=parent");
expect(url).toContain("subagent=child");
});
it("buildBrowseLink produces root URL (prevents /workspace leak)", () => {
expect(buildBrowseLink("/tmp")).toMatch(/^\/\?browse=/);
});
it("buildUrl returns / for empty state (prevents URL corruption)", () => {
expect(buildUrl({})).toBe("/");
});
it("buildUrl produces valid query string for complex state (prevents encoding errors)", () => {
const url = buildUrl({
path: "leads",
viewType: "kanban",
search: "hello world",
page: 2,
});
const parsed = new URL(url, "http://localhost");
expect(parsed.pathname).toBe("/");
expect(parsed.searchParams.get("path")).toBe("leads");
expect(parsed.searchParams.get("viewType")).toBe("kanban");
expect(parsed.searchParams.get("search")).toBe("hello world");
expect(parsed.searchParams.get("page")).toBe("2");
});
});
// ---------------------------------------------------------------------------
// Serialization round-trip completeness
// ---------------------------------------------------------------------------
describe("full state round-trip", () => {
it("round-trips a maximally-populated URL state (prevents any field from being lost)", () => {
const original: Partial<WorkspaceUrlState> = {
path: "knowledge/leads",
entry: { objectName: "leads", entryId: "e1" },
browse: "/Users/me/Desktop",
hidden: true,
preview: "sidebar/preview.md",
view: "Active View",
viewType: "kanban",
filters: { id: "root", conjunction: "and", rules: [{ id: "r1", field: "status", operator: "equals" as const, value: "done" }] },
search: "urgent",
sort: [{ field: "priority", direction: "desc" }],
page: 4,
pageSize: 25,
cols: ["name", "priority", "status"],
};
const qs = serializeUrlState(original);
const restored = parseUrlState(qs);
expect(restored.path).toBe(original.path);
expect(restored.entry).toEqual(original.entry);
expect(restored.browse).toBe(original.browse);
expect(restored.hidden).toBe(original.hidden);
expect(restored.preview).toBe(original.preview);
expect(restored.view).toBe(original.view);
expect(restored.viewType).toBe(original.viewType);
expect(restored.filters).toEqual(original.filters);
expect(restored.search).toBe(original.search);
expect(restored.sort).toEqual(original.sort);
expect(restored.page).toBe(original.page);
expect(restored.pageSize).toBe(original.pageSize);
expect(restored.cols).toEqual(original.cols);
});
it("round-trips chat + subagent state (prevents subagent session loss)", () => {
const qs = serializeUrlState({ chat: "sess-main", subagent: "child-key" });
const state = parseUrlState(qs);
expect(state.chat).toBe("sess-main");
expect(state.subagent).toBe("child-key");
});
it("round-trips file-scoped chat session alongside file path (prevents file chat loss)", () => {
const qs = serializeUrlState({ path: "docs/readme.md", fileChat: "file-sess-42" });
const state = parseUrlState(qs);
expect(state.path).toBe("docs/readme.md");
expect(state.fileChat).toBe("file-sess-42");
});
it("round-trips send parameter (prevents auto-send messages from being lost)", () => {
const qs = serializeUrlState({ send: "install duckdb" });
const state = parseUrlState(qs);
expect(state.send).toBe("install duckdb");
});
});
// ---------------------------------------------------------------------------
// Edge cases from real usage
// ---------------------------------------------------------------------------
describe("real-world edge cases", () => {
it("handles path with spaces and special characters (prevents encoding breakage)", () => {
const url = buildFileLink("my docs/notes & ideas/2024 (Q1).md");
const state = parseUrlState(new URL(url, "http://localhost").search);
expect(state.path).toBe("my docs/notes & ideas/2024 (Q1).md");
});
it("handles unicode in entry IDs (prevents internationalization breakage)", () => {
const url = buildEntryLink("商品", "アイテム-42");
const state = parseUrlState(new URL(url, "http://localhost").search);
expect(state.entry?.objectName).toBe("商品");
expect(state.entry?.entryId).toBe("アイテム-42");
});
it("handles entry ID containing colons (prevents ID parsing corruption)", () => {
const state = parseUrlState("entry=obj:id:with:many:colons");
expect(state.entry).toEqual({ objectName: "obj", entryId: "id:with:many:colons" });
});
it("handles base64 filter string with padding (prevents truncation)", () => {
const fg = { id: "root", conjunction: "and" as const, rules: [{ id: "r", field: "x", operator: "equals" as const, value: "a" }] };
const b64 = btoa(JSON.stringify(fg));
expect(b64).toContain("="); // verify padding exists
const state = parseUrlState(`filters=${encodeURIComponent(b64)}`);
expect(state.filters).toEqual(fg);
});
it("survives corrupted filter param without crashing (prevents app crash from tampered URLs)", () => {
const state = parseUrlState("filters=not-valid-base64!!!");
expect(state.filters).toBeNull();
});
it("survives corrupted sort param without crashing (prevents app crash from tampered URLs)", () => {
const state = parseUrlState("sort=garbage");
expect(state.sort).toBeNull();
});
it("survives corrupted page param without crashing (prevents NaN propagation)", () => {
const state = parseUrlState("page=abc");
expect(state.page).toBeNull();
});
it("handles empty string for cols (prevents empty-string column names)", () => {
const state = parseUrlState("cols=");
expect(state.cols).toBeNull();
});
it("handles single column in cols param (prevents off-by-one)", () => {
const state = parseUrlState("cols=name");
expect(state.cols).toEqual(["name"]);
});
it("handles browse path with encoded slashes (prevents path corruption)", () => {
const url = buildBrowseLink("/Users/me/My Projects/app");
const state = parseUrlState(new URL(url, "http://localhost").search);
expect(state.browse).toBe("/Users/me/My Projects/app");
});
it("empty URL returns all-null default state (prevents undefined-field errors)", () => {
const state = parseUrlState("");
expect(state.path).toBeNull();
expect(state.chat).toBeNull();
expect(state.subagent).toBeNull();
expect(state.fileChat).toBeNull();
expect(state.entry).toBeNull();
expect(state.send).toBeNull();
expect(state.browse).toBeNull();
expect(state.hidden).toBe(false);
expect(state.preview).toBeNull();
expect(state.view).toBeNull();
expect(state.viewType).toBeNull();
expect(state.filters).toBeNull();
expect(state.search).toBeNull();
expect(state.sort).toBeNull();
expect(state.page).toBeNull();
expect(state.pageSize).toBeNull();
expect(state.cols).toBeNull();
});
});