/** * Workspace link utilities. * * 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 * Cron views: /?path=~cron&cronView=calendar&cronCalMode=week&cronDate=2026-03-05 * 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 { CalendarMode, FilterGroup, SortRule, ViewType } from "./object-filters"; // --------------------------------------------------------------------------- // Parsed link (simple) // --------------------------------------------------------------------------- export type WorkspaceLink = | { kind: "file"; path: string } | { kind: "entry"; objectName: string; entryId: string }; // --------------------------------------------------------------------------- // Full URL state // --------------------------------------------------------------------------- export type CronDashboardView = "overview" | "calendar" | "timeline" | "insights"; export type CronRunStatusFilter = "all" | "ok" | "error" | "running"; 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; /** Cron dashboard active tab (only relevant when path is ~cron). */ cronView: CronDashboardView | null; /** Calendar mode when cronView=calendar. */ cronCalMode: CalendarMode | null; /** Date anchor for cron calendar/timeline view (ISO date string). */ cronDate: string | null; /** Run status filter for cron job detail history. */ cronRunFilter: CronRunStatusFilter | null; /** Selected run timestamp in cron job detail. */ cronRun: number | null; }; const VALID_VIEW_TYPES: ViewType[] = [ "table", "kanban", "calendar", "timeline", "gallery", "list", ]; const VALID_CRON_VIEWS: CronDashboardView[] = ["overview", "calendar", "timeline", "insights"]; const VALID_CRON_CAL_MODES: CalendarMode[] = ["day", "week", "month", "year"]; const VALID_CRON_RUN_FILTERS: CronRunStatusFilter[] = ["all", "ok", "error", "running"]; // --------------------------------------------------------------------------- // URL state codec // --------------------------------------------------------------------------- /** 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), }; } 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; const cronViewRaw = params.get("cronView") as CronDashboardView | null; const cronCalModeRaw = params.get("cronCalMode") as CalendarMode | null; const cronRunFilterRaw = params.get("cronRunFilter") as CronRunStatusFilter | null; const cronRunRaw = params.get("cronRun"); 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, cronView: cronViewRaw && VALID_CRON_VIEWS.includes(cronViewRaw) ? cronViewRaw : null, cronCalMode: cronCalModeRaw && VALID_CRON_CAL_MODES.includes(cronCalModeRaw) ? cronCalModeRaw : null, cronDate: params.get("cronDate"), cronRunFilter: cronRunFilterRaw && VALID_CRON_RUN_FILTERS.includes(cronRunFilterRaw) ? cronRunFilterRaw : null, cronRun: cronRunRaw ? parseInt(cronRunRaw, 10) || null : null, }; } /** Serialize a (partial) WorkspaceUrlState to a query string. Omits null/default values. */ export function serializeUrlState(state: Partial): 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(",")); if (state.cronView && state.cronView !== "overview") params.set("cronView", state.cronView); if (state.cronCalMode) params.set("cronCalMode", state.cronCalMode); if (state.cronDate) params.set("cronDate", state.cronDate); if (state.cronRunFilter && state.cronRunFilter !== "all") params.set("cronRunFilter", state.cronRunFilter); if (state.cronRun != null) params.set("cronRun", String(state.cronRun)); return params.toString(); } /** Build a full root-route URL string from partial state. */ export function buildUrl(state: Partial): 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(":"); return { kind: "entry", objectName: entryParam.slice(0, colonIdx), entryId: entryParam.slice(colonIdx + 1), }; } const pathParam = url.searchParams.get("path"); if (pathParam) { return { kind: "file", path: pathParam }; } } // Legacy: handle old @entry/ format for backward compat if (href.startsWith("@entry/")) { const rest = href.slice("@entry/".length); const slashIdx = rest.indexOf("/"); if (slashIdx > 0) { return { kind: "entry", objectName: rest.slice(0, slashIdx), entryId: rest.slice(slashIdx + 1), }; } } return null; } /** Check if an href is an app-internal workspace link (root, legacy /workspace, or @entry/). */ export function isWorkspaceLink(href: string): boolean { if (href.startsWith("@entry/")) return true; // Legacy /workspace links if ( href === "/workspace" || 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). */ export function isInternalLink(href: string): boolean { return ( !href.startsWith("http://") && !href.startsWith("https://") && !href.startsWith("mailto:") ); } /** 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("/") || href.startsWith("/workspace")) && href.includes("entry=")) return true; return false; }