Files
Dotta d734bd43d1 [codex] Roll up May 17 branch changes (#6210)
## Thinking Path

> - Paperclip is the control plane for autonomous AI companies, so agent
work needs visible ownership, recovery, and operator controls.
> - This local branch had accumulated several related control-plane
reliability and operator-experience fixes across recovery actions,
watchdog folding, model-profile defaults, mentions, markdown editing,
plugin launchers, and small UI polish.
> - The branch needed to be converted into a PR against the current
`origin/master` without losing dirty work or including lockfile/workflow
churn.
> - The safest standalone shape is a single rollup PR because the
recovery/server/UI files overlap heavily across the local commits and
splitting would create avoidable conflicts.
> - This pull request replays the local branch onto latest
`origin/master`, preserves the uncommitted work as logical commits, and
adds a Zod 4 validator compatibility fix found during verification.
> - The benefit is that the May 17 local branch can be reviewed and
merged as one coherent, conflict-free branch under the 100-file Greptile
limit.

## What Changed

- Rebased the local May 17 branch work onto current `origin/master` in a
dedicated worktree.
- Preserved and committed previously dirty changes for recovery retry
handling, plugin/sidebar launcher polish, and `.herenow` ignores.
- Added recovery-action behavior for returning source issues to `todo`
when retrying source-scoped recovery.
- Included the existing local recovery/liveness/watchdog fold, Codex
cheap-profile, markdown/mention, duplicate-agent, and UI polish commits
from the branch.
- Normalized shared validator `z.record(...)` schemas to explicit
string-key records for Zod 4 compatibility.
- Confirmed the PR has no `pnpm-lock.yaml` or `.github/workflows/*`
changes and stays below the 100-file Greptile limit.

## Verification

- `pnpm install --frozen-lockfile --ignore-scripts`
- `npm run install` in
`node_modules/.pnpm/sqlite3@5.1.7/node_modules/sqlite3` to build the
local native sqlite3 binding after installing with scripts disabled
- `pnpm exec vitest run packages/shared/src/validators/issue.test.ts
packages/shared/src/project-mentions.test.ts
packages/adapter-utils/src/server-utils.test.ts
server/src/__tests__/heartbeat-model-profile.test.ts
server/src/__tests__/issue-recovery-actions.test.ts
server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts
server/src/__tests__/heartbeat-active-run-output-watchdog.test.ts
server/src/__tests__/plugin-local-folders.test.ts
ui/src/components/IssueRecoveryActionCard.test.tsx
ui/src/components/Sidebar.test.tsx
ui/src/components/SidebarAccountMenu.test.tsx
ui/src/components/IssueProperties.test.tsx
ui/src/components/MarkdownEditor.test.tsx
ui/src/components/MarkdownBody.test.tsx
ui/src/lib/duplicate-agent-payload.test.ts
ui/src/pages/Routines.test.tsx`
- First pass: 13 files passed with 201 passing tests; 3 server files
failed before sqlite3 native binding was built.
- After rebuilding sqlite3:
`server/src/__tests__/heartbeat-model-profile.test.ts`,
`server/src/__tests__/issue-recovery-actions.test.ts`, and
`server/src/__tests__/heartbeat-active-run-output-watchdog.test.ts`
passed/loaded; embedded Postgres tests were skipped by the local host
guard.
- `pnpm --filter @paperclipai/shared typecheck`
- `pnpm --filter @paperclipai/adapter-utils typecheck`
- `pnpm --filter @paperclipai/server typecheck`
- `pnpm --filter @paperclipai/ui typecheck`

## Risks

- Medium risk: this is a broad rollup PR across recovery semantics,
server tests, shared validators, and UI surfaces.
- Some embedded Postgres tests skipped locally due the host guard, so CI
should provide the stronger database-backed signal.
- UI changes were covered by component tests, but no browser screenshot
was captured in this PR creation pass.
- This branch may overlap with existing recovery/liveness PR work; merge
this PR independently or restack/close overlapping branches rather than
merging duplicate implementations together.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5-based coding agent, tool-enabled local repository
and GitHub workflow, medium reasoning effort.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-17 17:15:06 -05:00

7126 lines
275 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
AssigneePicker,
FileTree,
IssuesList as PluginIssuesList,
ManagedRoutinesList as PluginManagedRoutinesList,
MarkdownBlock,
MarkdownEditor,
ProjectPicker,
usePluginAction,
usePluginData,
usePluginStream,
usePluginToast,
useHostLocation,
useHostNavigation,
type FileTreeNode,
type ManagedRoutinesListItem,
type PluginPageProps,
type PluginRouteSidebarProps,
type PluginSettingsPageProps,
type PluginSidebarProps,
} from "@paperclipai/plugin-sdk/ui";
import { useCallback, useEffect, useMemo, useRef, useState, type AnchorHTMLAttributes, type CSSProperties, type ReactElement, type ReactNode } from "react";
import { readIngestOperationIssueId, uploadIssueAttachmentFile } from "./issue-attachments.js";
// ---------------------------------------------------------------------------
// Shared design tokens — copied from the UX wireframe shared.css so the plugin
// looks identical inside the host whether or not host theme tokens are
// available at runtime.
// ---------------------------------------------------------------------------
const tokens = {
border: "var(--border, oklch(0.269 0 0))",
card: "var(--card, oklch(0.205 0 0))",
bg: "var(--background, oklch(0.145 0 0))",
fg: "var(--foreground, oklch(0.985 0 0))",
muted: "var(--muted-foreground, oklch(0.708 0 0))",
accent: "var(--accent, oklch(0.269 0 0))",
primary: "var(--primary, oklch(0.985 0 0))",
primaryFg: "var(--primary-foreground, oklch(0.205 0 0))",
destructive: "var(--destructive, oklch(0.637 0.237 25.331))",
pluginBg: "oklch(0.3 0.06 70)",
pluginFg: "oklch(0.92 0.08 80)",
pluginBorder: "oklch(0.55 0.15 70)",
hiddenOpBg: "oklch(0.27 0.04 280)",
hiddenOpFg: "oklch(0.85 0.08 280)",
hiddenOpBorder: "oklch(0.45 0.1 280)",
callout: { bg: "oklch(0.2 0.04 250)", fg: "oklch(0.85 0.08 250)", border: "oklch(0.4 0.1 250)" },
statusDone: "oklch(0.65 0.16 145)",
statusRunning: "oklch(0.7 0.13 200)",
statusBlocked: "oklch(0.6 0.21 25)",
statusInProgress: "oklch(0.58 0.18 280)",
statusTodo: "oklch(0.6 0.17 250)",
statusPaused: "oklch(0.72 0.15 70)",
};
type Tone = "todo" | "in_progress" | "in_review" | "done" | "blocked" | "running" | "paused" | "failed" | "queued" | "default";
const toneStyles: Record<Tone, CSSProperties> = {
default: { background: "var(--secondary, oklch(0.269 0 0))", color: tokens.fg, border: `1px solid ${tokens.border}` },
todo: { background: "oklch(0.27 0.06 250)", color: "oklch(0.85 0.1 250)" },
in_progress: { background: "oklch(0.27 0.06 280)", color: "oklch(0.85 0.1 280)" },
in_review: { background: "oklch(0.27 0.07 305)", color: "oklch(0.85 0.1 305)" },
done: { background: "oklch(0.27 0.06 145)", color: "oklch(0.85 0.1 145)" },
blocked: { background: "oklch(0.27 0.08 25)", color: "oklch(0.82 0.13 25)" },
running: { background: "oklch(0.27 0.06 200)", color: "oklch(0.83 0.11 200)" },
paused: { background: "oklch(0.27 0.07 70)", color: "oklch(0.85 0.1 70)" },
failed: { background: "oklch(0.27 0.08 25)", color: "oklch(0.82 0.13 25)" },
queued: { background: "oklch(0.27 0.06 250)", color: "oklch(0.85 0.1 250)" },
};
const fontStack = `ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif`;
const mobileMediaQuery = "(max-width: 767px)";
const PLUGIN_ID = "paperclipai.plugin-llm-wiki";
const WIKI_SIDEBAR_NAV_STATE_KEY = "paperclipWikiSidebarTreePath";
const ROUTE_SIDEBAR_EXPANDED_STORAGE_PREFIX = `${PLUGIN_ID}:route-sidebar-expanded:v2`;
const WIKI_TOC_STICKY_TOP = 88;
const WIKI_SPACE_PREFETCH_LIMIT = 8;
const DEFAULT_ROUTE_SIDEBAR_EXPANDED_PATHS = [
"wiki",
"wiki/sources",
"wiki/projects",
"wiki/entities",
"wiki/concepts",
"wiki/synthesis",
] as const;
// ---------------------------------------------------------------------------
// Shared types coming back from the worker.
// ---------------------------------------------------------------------------
type FolderStatus = {
configured: boolean;
path: string | null;
realPath: string | null;
access: "read" | "readWrite";
readable: boolean;
writable: boolean;
requiredDirectories: string[];
requiredFiles: string[];
missingDirectories: string[];
missingFiles: string[];
healthy: boolean;
problems: { code: string; message: string; path?: string }[];
checkedAt: string;
};
type ManagedAgent = {
status: string;
source?: "managed" | "selected";
agentId?: string | null;
resourceKey?: string | null;
details?: { name?: string; status?: string; adapterType?: string | null; icon?: string | null; urlKey?: string | null } | null;
defaultDrift?: { entryFile: string; changedFiles: string[] } | null;
};
type ManagedProject = {
status: string;
source?: "managed" | "selected";
projectId?: string | null;
resourceKey?: string | null;
details?: { name?: string; status?: string; color?: string | null } | null;
};
type ManagedRoutine = {
status: string;
routineId?: string | null;
resourceKey?: string | null;
missingRefs?: Array<{ pluginKey?: string; resourceKind: string; resourceKey: string }>;
defaultDrift?: {
changedFields: string[];
defaultTitle?: string | null;
defaultDescription?: string | null;
} | null;
routine?: {
id?: string;
title?: string;
status?: string;
assigneeAgentId?: string | null;
projectId?: string | null;
lastTriggeredAt?: string | null;
lastEnqueuedAt?: string | null;
managedByPlugin?: {
pluginDisplayName?: string;
resourceKey?: string;
} | null;
} | null;
details?: {
title?: string;
status?: string;
cronExpression?: string | null;
enabled?: boolean;
nextRunAt?: string | null;
lastRunAt?: string | null;
assigneeAgentId?: string | null;
} | null;
};
type ManagedRoutineDefaultDrift = NonNullable<ManagedRoutine["defaultDrift"]>;
type ManagedRoutinesListItemWithDrift = ManagedRoutinesListItem & {
defaultDrift?: ManagedRoutineDefaultDrift | null;
};
type ManagedSkill = {
status: string;
skillId?: string | null;
resourceKey?: string | null;
defaultDrift?: { changedFiles: string[] } | null;
skill?: {
id?: string;
name?: string;
key?: string;
description?: string | null;
} | null;
details?: {
name?: string;
key?: string;
description?: string | null;
} | null;
};
type OverviewData = {
status: "ok";
checkedAt: string;
wikiId: string;
folder: FolderStatus;
managedAgent: ManagedAgent;
managedProject: ManagedProject;
managedSkills: ManagedSkill[];
operationCount: number;
eventIngestion: EventIngestionSettings;
capabilities: string[];
prompts: { query: string; lint: string };
};
type EventIngestionSettings = {
enabled: boolean;
sources: {
issues: boolean;
comments: boolean;
documents: boolean;
};
wikiId: string;
maxCharacters: number;
};
type WikiEventIngestionSource = "issues" | "comments" | "documents";
type PaperclipIngestionSourceScope =
| { kind: "active_projects"; limit: number; statuses?: Array<"in_progress" | "todo" | "done"> }
| { kind: "selected_projects"; projectIds: string[] }
| { kind: "root_issues"; issueIds: string[] }
| { kind: "company_all"; requiresBoardConfirmation: true };
type PaperclipIngestionProfile = {
version: 1;
enabled: boolean;
sourceScopes: PaperclipIngestionSourceScope[];
sourceKinds: {
issues: boolean;
comments: boolean;
documents: boolean;
attachments: "off" | "metadata_only";
workProducts: "off" | "metadata_only";
};
cursor: {
maxWindowCharacters: number;
maxCharactersPerSource: number;
minSourceAgeMinutes: number;
maxWindowsPerRun: number;
staleAfterHours: number;
};
backfill: {
defaultStartAt?: string | null;
defaultEndAt?: string | null;
requireManualQueue: boolean;
};
};
type PaperclipIngestionProfileData = {
wikiId: string;
space: Pick<WikiSpace, "id" | "slug" | "displayName" | "accessScope" | "status">;
profile: PaperclipIngestionProfile;
effectiveState: "enabled" | "disabled" | "policy_blocked" | "pending_approval" | "enabled_no_scopes";
policyBlocks: string[];
historicalPageCount: number;
overlapCount: number;
};
type SettingsData = {
folder: FolderStatus;
managedAgent: ManagedAgent;
managedProject: ManagedProject;
managedRoutine?: ManagedRoutine;
managedRoutines?: ManagedRoutine[];
managedSkills?: ManagedSkill[];
distillationPolicy?: {
autoApplyAllowed: boolean;
autoApplyRestriction: string | null;
deploymentMode: "local_trusted" | "authenticated" | null;
deploymentExposure: "private" | "public" | null;
};
eventIngestion: EventIngestionSettings;
agentOptions: Array<{ id: string; name: string; status?: string | null; adapterType?: string | null; icon?: string | null; urlKey?: string | null }>;
projectOptions: Array<{ id: string; name: string; status?: string | null; color?: string | null }>;
capabilities: string[];
};
type WikiSpace = {
id: string;
companyId: string;
wikiId: string;
slug: string;
displayName: string;
spaceType: string;
folderMode: string;
rootFolderKey: string;
pathPrefix: string | null;
configuredRootPath: string | null;
accessScope: string;
ownerUserId: string | null;
ownerAgentId: string | null;
teamKey: string | null;
settings: Record<string, unknown>;
status: string;
createdAt: string | null;
updatedAt: string | null;
};
type WikiSpacesData = {
spaces: WikiSpace[];
};
type WikiSpaceWithFolderStatus = WikiSpace & {
relativeRoot: string;
folder: FolderStatus;
};
const DEFAULT_SPACE_SLUG = "default";
type WikiPageRow = {
path: string;
title: string | null;
pageType: string | null;
backlinkCount: number;
sourceCount: number;
contentHash: string | null;
updatedAt: string;
};
type WikiSourceRow = {
rawPath: string;
title: string | null;
sourceType: string;
url: string | null;
status: string;
createdAt: string;
};
type PagesData = {
pages: WikiPageRow[];
sources: WikiSourceRow[];
};
type PageContentData = {
wikiId: string;
path: string;
contents: string;
title: string | null;
pageType: string | null;
backlinks: string[];
sourceRefs: Array<Record<string, unknown> | string>;
updatedAt: string | null;
hash: string;
};
type WikiOperationRow = {
id: string;
operationType: string;
status: string;
hiddenIssueId: string | null;
hiddenIssueIdentifier: string | null;
hiddenIssueTitle: string | null;
hiddenIssueStatus: string | null;
projectId: string | null;
runIds: unknown[];
costCents: number;
warnings: unknown[];
affectedPages: unknown[];
metadata?: Record<string, unknown>;
createdAt: string;
updatedAt: string;
};
type OperationsData = {
operations: WikiOperationRow[];
};
type TemplateData = {
path: string;
contents: string;
hash: string | null;
exists: boolean;
};
type WikiFrontmatterValue = string | string[];
type WikiFrontmatterProperty = {
key: string;
value: WikiFrontmatterValue;
};
type ParsedWikiMarkdown = {
body: string;
frontmatter: WikiFrontmatterProperty[];
};
type WikiTocHeading = {
id: string;
text: string;
level: number;
};
// ---------------------------------------------------------------------------
// Small presentational primitives.
// ---------------------------------------------------------------------------
function Badge({ children, tone = "default", style }: { children: ReactNode; tone?: Tone; style?: CSSProperties }) {
return (
<span style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
padding: "2px 8px",
borderRadius: 999,
fontSize: 11,
fontWeight: 500,
whiteSpace: "nowrap",
...toneStyles[tone],
...style,
}}>{children}</span>
);
}
function HiddenOpBadge() {
return (
<span style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
padding: "2px 8px",
borderRadius: 999,
fontSize: 11,
fontWeight: 500,
background: tokens.hiddenOpBg,
color: tokens.hiddenOpFg,
border: `1px solid ${tokens.hiddenOpBorder}`,
}}>📖 wiki task</span>
);
}
function StatusIcon({ status }: { status: string }) {
const map: Record<string, { color: string; filled?: boolean; pulse?: boolean }> = {
done: { color: tokens.statusDone, filled: true },
in_progress: { color: tokens.statusInProgress },
running: { color: tokens.statusRunning, pulse: true },
queued: { color: tokens.statusTodo },
todo: { color: tokens.statusTodo },
blocked: { color: tokens.statusBlocked },
failed: { color: tokens.statusBlocked },
paused: { color: tokens.statusPaused },
};
const tone = map[status] ?? { color: tokens.muted };
return (
<span style={{
width: 12,
height: 12,
flexShrink: 0,
borderRadius: "50%",
border: `2px solid ${tone.color}`,
background: tone.filled ? tone.color : "transparent",
animation: tone.pulse ? "pcWikiPulse 1.6s infinite" : undefined,
}} aria-hidden />
);
}
function Card({ children, style }: { children: ReactNode; style?: CSSProperties }) {
return (
<section style={{
background: tokens.card,
border: `1px solid ${tokens.border}`,
borderRadius: 8,
overflow: "hidden",
minWidth: 0,
...style,
}}>{children}</section>
);
}
function CardHeader({ title, right, badges }: { title: ReactNode; right?: ReactNode; badges?: ReactNode }) {
return (
<div style={{
padding: "12px 16px",
borderBottom: `1px solid ${tokens.border}`,
display: "flex",
alignItems: "center",
flexWrap: "wrap",
gap: 12,
minWidth: 0,
}}>
<h3 style={{ margin: 0, fontSize: 14, fontWeight: 600, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis" }}>{title}</h3>
{badges}
{right ? <div style={{ marginLeft: "auto", minWidth: 0, maxWidth: "100%" }}>{right}</div> : null}
</div>
);
}
function CardBody({ children, padding = 16 }: { children: ReactNode; padding?: number | string }) {
return <div style={{ padding }}>{children}</div>;
}
const unfilledSurfaceStyle: CSSProperties = {
background: "transparent",
};
function PropRow({ label, value }: { label: ReactNode; value: ReactNode }) {
return (
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", flexWrap: "wrap", padding: "4px 0", fontSize: 13, gap: 12, minWidth: 0 }}>
<span style={{ color: tokens.muted, fontSize: 12, flexShrink: 0 }}>{label}</span>
<span style={{ flex: "1 1 160px", minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "normal", overflowWrap: "anywhere", textAlign: "right" }}>{value}</span>
</div>
);
}
function Tiny({ children, style }: { children: ReactNode; style?: CSSProperties }) {
return <div style={{ fontSize: 11, color: tokens.muted, ...style }}>{children}</div>;
}
function Mono({ children, style }: { children: ReactNode; style?: CSSProperties }) {
return <span style={{ fontFamily: "ui-monospace, SFMono-Regular, monospace", fontSize: 12, overflowWrap: "anywhere", wordBreak: "break-word", ...style }}>{children}</span>;
}
type ButtonVariant = "primary" | "default" | "ghost" | "destructive";
type ButtonSize = "sm" | "md";
function Button({
variant = "default",
size = "md",
disabled,
loading,
onClick,
children,
type = "button",
style,
title,
}: {
variant?: ButtonVariant;
size?: ButtonSize;
disabled?: boolean;
loading?: boolean;
onClick?: () => void;
children: ReactNode;
type?: "button" | "submit";
style?: CSSProperties;
title?: string;
}) {
const palette: Record<ButtonVariant, CSSProperties> = {
primary: {
background: tokens.primary,
color: tokens.primaryFg,
border: `1px solid transparent`,
},
default: {
background: tokens.card,
color: tokens.fg,
border: `1px solid ${tokens.border}`,
},
ghost: {
background: "transparent",
color: tokens.fg,
border: `1px solid transparent`,
},
destructive: {
background: "transparent",
color: "oklch(0.7 0.2 25)",
border: `1px solid oklch(0.5 0.18 25)`,
},
};
return (
<button
type={type}
title={title}
disabled={disabled || loading}
onClick={onClick}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: size === "sm" ? "3px 8px" : "6px 12px",
borderRadius: 6,
fontSize: size === "sm" ? 11 : 13,
fontWeight: 500,
cursor: disabled || loading ? "not-allowed" : "pointer",
opacity: disabled || loading ? 0.5 : 1,
fontFamily: fontStack,
minWidth: 0,
whiteSpace: "nowrap",
...palette[variant],
...style,
}}
>{children}</button>
);
}
function TextInput(props: React.InputHTMLAttributes<HTMLInputElement>) {
return (
<input
{...props}
style={{
background: "oklch(0.2 0 0)",
border: `1px solid ${tokens.border}`,
borderRadius: 6,
padding: "6px 10px",
fontSize: 13,
color: tokens.fg,
width: "100%",
boxSizing: "border-box",
minWidth: 0,
fontFamily: fontStack,
...props.style,
}}
/>
);
}
function TextArea(props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) {
return (
<textarea
{...props}
style={{
background: "oklch(0.2 0 0)",
border: `1px solid ${tokens.border}`,
borderRadius: 6,
padding: "6px 10px",
fontSize: 13,
color: tokens.fg,
width: "100%",
boxSizing: "border-box",
minWidth: 0,
minHeight: 96,
fontFamily: fontStack,
resize: "vertical",
...props.style,
}}
/>
);
}
type AutosaveStatus = "idle" | "dirty" | "saving" | "saved" | "error";
function AutosaveStatusLabel({ status, error }: { status: AutosaveStatus; error: string | null }) {
if (status === "saving") return <Tiny>Saving</Tiny>;
if (status === "saved") return <Tiny>Saved</Tiny>;
if (status === "dirty") return <Tiny>Unsaved changes</Tiny>;
if (status === "error") return <Tiny style={{ color: "oklch(0.7 0.2 25)" }}>{error ?? "Autosave failed"}</Tiny>;
return <Tiny>Autosave on</Tiny>;
}
function AutosaveMarkdownEditor({
value,
placeholder,
minHeight,
resetKey,
onSave,
onStatusChange,
}: {
value: string;
placeholder?: string;
minHeight?: number;
resetKey: string;
onSave: (value: string) => Promise<void>;
onStatusChange?: (status: AutosaveStatus) => void;
}) {
const [draft, setDraft] = useState(value);
const [lastSaved, setLastSaved] = useState(value);
const [status, setStatus] = useState<AutosaveStatus>("idle");
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setDraft(value);
setLastSaved(value);
setStatus("idle");
setError(null);
onStatusChange?.("idle");
}, [onStatusChange, resetKey, value]);
useEffect(() => {
if (draft === lastSaved) return;
setStatus("dirty");
setError(null);
onStatusChange?.("dirty");
const timeout = window.setTimeout(async () => {
setStatus("saving");
onStatusChange?.("saving");
try {
await onSave(draft);
setLastSaved(draft);
setStatus("saved");
onStatusChange?.("saved");
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setError(message);
setStatus("error");
onStatusChange?.("error");
}
}, 800);
return () => window.clearTimeout(timeout);
}, [draft, lastSaved, onSave, onStatusChange]);
return (
<div style={{ display: "grid", gap: 6, minWidth: 0 }}>
<MarkdownEditor
value={draft}
onChange={setDraft}
placeholder={placeholder}
bordered
contentClassName="min-h-[260px]"
className="pc-wiki-markdown-editor"
/>
<style>{`
.pc-wiki-markdown-editor .mdxeditor-root-contenteditable {
min-height: ${minHeight ?? 260}px;
}
`}</style>
<AutosaveStatusLabel status={status} error={error} />
</div>
);
}
function Callout({ children, tone = "info" }: { children: ReactNode; tone?: "info" | "warn" | "danger" }) {
const palette = tone === "danger"
? { bg: "oklch(0.22 0.06 25)", fg: "oklch(0.85 0.12 25)", border: "oklch(0.45 0.12 25)" }
: tone === "warn"
? { bg: "oklch(0.22 0.06 70)", fg: "oklch(0.85 0.1 70)", border: "oklch(0.45 0.12 70)" }
: tokens.callout;
return (
<div style={{
background: palette.bg,
color: palette.fg,
border: `1px solid ${palette.border}`,
borderRadius: 8,
padding: "12px 14px",
fontSize: 13,
lineHeight: 1.55,
}}>{children}</div>
);
}
function Divider() {
return <div style={{ height: 1, background: tokens.border, margin: "16px 0" }} />;
}
// ---------------------------------------------------------------------------
// Hooks
// ---------------------------------------------------------------------------
function useMediaQuery(query: string): boolean {
const getSnapshot = () => {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
return window.matchMedia(query).matches;
};
const [matches, setMatches] = useState(getSnapshot);
useEffect(() => {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
const mediaQuery = window.matchMedia(query);
const handleChange = (event: MediaQueryListEvent) => setMatches(event.matches);
setMatches(mediaQuery.matches);
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
}, [query]);
return matches;
}
function useIsMobileLayout(): boolean {
return useMediaQuery(mobileMediaQuery);
}
function useOverview(companyId: string | null) {
const params = useMemo(() => companyId ? { companyId } : undefined, [companyId]);
return usePluginData<OverviewData>("overview", params);
}
function useSettings(companyId: string | null) {
const params = useMemo(() => companyId ? { companyId } : undefined, [companyId]);
return usePluginData<SettingsData>("settings", params);
}
function usePages(companyId: string | null, opts: { includeRaw?: boolean; spaceSlug?: string | null } = {}) {
const params = useMemo(() => {
if (!companyId) return undefined;
const next: Record<string, unknown> = { companyId, includeRaw: opts.includeRaw ?? true };
if (opts.spaceSlug && opts.spaceSlug !== DEFAULT_SPACE_SLUG) next.spaceSlug = opts.spaceSlug;
else if (opts.spaceSlug === DEFAULT_SPACE_SLUG) next.spaceSlug = DEFAULT_SPACE_SLUG;
return next;
}, [companyId, opts.includeRaw, opts.spaceSlug]);
return usePluginData<PagesData>("pages", params);
}
function useSpaces(companyId: string | null) {
const params = useMemo(() => companyId ? { companyId } : undefined, [companyId]);
return usePluginData<WikiSpacesData>("spaces", params);
}
function useSpaceFolderStatus(companyId: string | null, spaceSlug: string | null) {
const params = useMemo(() => {
if (!companyId || !spaceSlug) return undefined;
return { companyId, spaceSlug };
}, [companyId, spaceSlug]);
return usePluginData<WikiSpaceWithFolderStatus>("space", params);
}
function usePaperclipIngestionProfile(companyId: string | null, spaceSlug: string | null) {
const params = useMemo(() => {
if (!companyId || !spaceSlug) return undefined;
return { companyId, spaceSlug };
}, [companyId, spaceSlug]);
return usePluginData<PaperclipIngestionProfileData>("paperclip-ingestion-profile", params);
}
function usePageContent(companyId: string | null, path: string | null, spaceSlug?: string | null) {
const params = useMemo(() => {
if (!companyId || !path) return undefined;
const next: Record<string, unknown> = { companyId, path };
if (spaceSlug) next.spaceSlug = spaceSlug;
return next;
}, [companyId, path, spaceSlug]);
return usePluginData<PageContentData>("page-content", params);
}
function useOperations(companyId: string | null, filter: { operationType?: string | null; status?: string | null; spaceSlug?: string | null } = {}) {
const params = useMemo(() => {
if (!companyId) return undefined;
return {
companyId,
operationType: filter.operationType ?? null,
status: filter.status ?? null,
spaceSlug: filter.spaceSlug ?? null,
};
}, [companyId, filter.operationType, filter.status, filter.spaceSlug]);
return usePluginData<OperationsData>("operations", params);
}
function useTemplate(companyId: string | null, path: string) {
const params = useMemo(() => {
if (!companyId) return undefined;
return { companyId, path };
}, [companyId, path]);
return usePluginData<TemplateData>("template", params);
}
type DistillationCursor = {
id: string;
sourceScope: string;
scopeKey: string;
projectId: string | null;
projectName: string | null;
projectColor: string | null;
rootIssueId: string | null;
rootIssueIdentifier: string | null;
rootIssueTitle: string | null;
lastProcessedAt: string | null;
lastObservedAt: string | null;
pendingEventCount: number;
lastSourceHash: string | null;
lastSuccessfulRunId: string | null;
};
type DistillationRun = {
id: string;
cursorId: string | null;
workItemId: string | null;
projectId: string | null;
projectName: string | null;
rootIssueId: string | null;
rootIssueIdentifier: string | null;
sourceWindowStart: string | null;
sourceWindowEnd: string | null;
sourceHash: string | null;
status: string;
costCents: number;
retryCount: number;
warnings: string[];
metadata: Record<string, unknown>;
operationIssueId: string | null;
operationIssueIdentifier: string | null;
operationIssueTitle: string | null;
affectedPagePaths: string[];
createdAt: string;
updatedAt: string;
};
type DistillationWorkItem = {
id: string;
workItemKind: string;
status: string;
priority: string;
projectId: string | null;
rootIssueId: string | null;
metadata: Record<string, unknown>;
createdAt: string;
updatedAt: string;
};
type DistillationPageBinding = {
id: string;
pagePath: string;
projectId: string | null;
projectName: string | null;
rootIssueId: string | null;
lastAppliedSourceHash: string | null;
lastDistillationRunId: string | null;
lastRunStatus: string | null;
lastRunCompletedAt: string | null;
lastRunSourceWindowEnd: string | null;
lastRunSourceHash: string | null;
metadata: Record<string, unknown>;
updatedAt: string;
};
type DistillationOverviewData = {
cursors: DistillationCursor[];
runs: DistillationRun[];
workItems: DistillationWorkItem[];
pageBindings: DistillationPageBinding[];
reviewWorkItems: DistillationWorkItem[];
counts: {
cursors: number;
runningRuns: number;
failedRuns24h: number;
reviewRequired: number;
};
};
function useDistillationOverview(companyId: string | null) {
const params = useMemo(() => (companyId ? { companyId } : undefined), [companyId]);
return usePluginData<DistillationOverviewData>("distillation-overview", params);
}
type DistillationProvenanceData = {
binding: DistillationPageBinding | null;
runs: DistillationRun[];
snapshot: {
id: string;
distillationRunId: string;
sourceHash: string;
maxCharacters: number;
clipped: boolean;
sourceRefs: Array<Record<string, unknown> | string>;
metadata: Record<string, unknown>;
createdAt: string;
} | null;
cursor: DistillationCursor | null;
};
function useDistillationProvenance(companyId: string | null, pagePath: string | null) {
const params = useMemo(() => {
if (!companyId || !pagePath) return undefined;
return { companyId, pagePath };
}, [companyId, pagePath]);
return usePluginData<DistillationProvenanceData>("distillation-page-provenance", params);
}
function stripYamlInlineComment(value: string): string {
let quote: "'" | "\"" | null = null;
for (let i = 0; i < value.length; i++) {
const char = value[i];
const previous = value[i - 1];
if ((char === "'" || char === "\"") && previous !== "\\") {
quote = quote === char ? null : quote ?? char;
}
if (char === "#" && quote === null && (i === 0 || /\s/.test(previous ?? ""))) {
return value.slice(0, i).trimEnd();
}
}
return value.trim();
}
function unquoteYamlScalar(value: string): string {
const trimmed = stripYamlInlineComment(value).trim();
if (trimmed.length >= 2) {
const first = trimmed[0];
const last = trimmed[trimmed.length - 1];
if ((first === "\"" && last === "\"") || (first === "'" && last === "'")) {
return trimmed.slice(1, -1);
}
}
return trimmed;
}
function parseYamlInlineArray(value: string): string[] | null {
const trimmed = stripYamlInlineComment(value).trim();
if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) return null;
const body = trimmed.slice(1, -1).trim();
if (!body) return [];
const items: string[] = [];
let current = "";
let quote: "'" | "\"" | null = null;
for (let i = 0; i < body.length; i++) {
const char = body[i];
const previous = body[i - 1];
if ((char === "'" || char === "\"") && previous !== "\\") {
quote = quote === char ? null : quote ?? char;
current += char;
continue;
}
if (char === "," && quote === null) {
const item = unquoteYamlScalar(current);
if (item) items.push(item);
current = "";
continue;
}
current += char;
}
const item = unquoteYamlScalar(current);
if (item) items.push(item);
return items;
}
function parseFrontmatterValue(rawValue: string, followingList: string[]): WikiFrontmatterValue {
const inlineArray = parseYamlInlineArray(rawValue);
if (inlineArray) return inlineArray;
if (!rawValue.trim() && followingList.length > 0) return followingList;
return unquoteYamlScalar(rawValue);
}
function parseWikiFrontmatterBlock(block: string): WikiFrontmatterProperty[] {
const lines = block.replace(/\r\n/g, "\n").split("\n");
const properties: WikiFrontmatterProperty[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!line.trim() || line.trimStart().startsWith("#")) continue;
if (/^\s+-\s+/.test(line)) continue;
const match = line.match(/^([A-Za-z0-9_-]+):(?:\s*(.*))?$/);
if (!match) continue;
const key = match[1];
const rawValue = match[2] ?? "";
const followingList: string[] = [];
let cursor = i + 1;
while (cursor < lines.length) {
const listMatch = lines[cursor]?.match(/^\s+-\s+(.+)$/);
if (!listMatch) break;
followingList.push(unquoteYamlScalar(listMatch[1] ?? ""));
cursor += 1;
}
if (!rawValue.trim() && followingList.length > 0) i = cursor - 1;
const value = parseFrontmatterValue(rawValue, followingList);
if (Array.isArray(value) ? value.length > 0 : value.length > 0) {
properties.push({ key, value });
}
}
return properties;
}
function parseWikiMarkdown(contents: string): ParsedWikiMarkdown {
const normalized = contents.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
if (!normalized.startsWith("---\n")) {
return { body: contents, frontmatter: [] };
}
const closingMatch = normalized.slice(4).match(/\n(?:---|\.\.\.)[ \t]*(?:\n|$)/);
if (!closingMatch || closingMatch.index == null) {
return { body: contents, frontmatter: [] };
}
const frontmatterBlock = normalized.slice(4, closingMatch.index + 4);
const bodyStart = 4 + closingMatch.index + closingMatch[0].length;
return {
body: normalized.slice(bodyStart).replace(/^\n+/, ""),
frontmatter: parseWikiFrontmatterBlock(frontmatterBlock),
};
}
function stripMarkdownHeadingSyntax(text: string): string {
return text
.replace(/\\([\\`*_{}\[\]()#+\-.!|>])/g, "$1")
.replace(/!\[([^\]]*)\]\([^)]+\)/g, "$1")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/\[\[([^\]|]+)\|([^\]]+)\]\]/g, "$2")
.replace(/\[\[([^\]]+)\]\]/g, "$1")
.replace(/`([^`]+)`/g, "$1")
.replace(/[*_~]+/g, "")
.replace(/<[^>]+>/g, "")
.trim();
}
function slugifyWikiHeading(text: string): string {
const slug = stripMarkdownHeadingSyntax(text)
.toLowerCase()
.replace(/&[a-z0-9#]+;/g, "")
.replace(/[^a-z0-9\s-]/g, "")
.trim()
.replace(/\s+/g, "-")
.replace(/-+/g, "-");
return slug || "section";
}
function extractWikiTocHeadings(markdownBody: string): WikiTocHeading[] {
const lines = markdownBody.replace(/\r\n/g, "\n").split("\n");
const headings: WikiTocHeading[] = [];
const usedIds = new Map<string, number>();
let fenced = false;
for (const line of lines) {
if (/^\s*(```|~~~)/.test(line)) {
fenced = !fenced;
continue;
}
if (fenced) continue;
const match = line.match(/^\s{0,3}(#{2,4})\s+(.+?)\s*#*\s*$/);
if (!match) continue;
const text = stripMarkdownHeadingSyntax(match[2] ?? "");
if (!text) continue;
const baseId = slugifyWikiHeading(text);
const count = usedIds.get(baseId) ?? 0;
usedIds.set(baseId, count + 1);
headings.push({
id: count === 0 ? baseId : `${baseId}-${count + 1}`,
text,
level: match[1]?.length ?? 2,
});
}
return headings;
}
// ---------------------------------------------------------------------------
// Sidebar entry and settings page.
// ---------------------------------------------------------------------------
// Stroke-2, 16×16 lucide-react icons inlined here because plugin bundles
// cannot import `lucide-react` directly (host-only dep). Path data tracks the
// upstream lucide source 1:1 — keep them in sync if upstream changes.
type LucideIconProps = { size?: number };
function makeLucideIcon(paths: ReactNode) {
return function LucideIcon({ size = 16 }: LucideIconProps) {
return (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{ width: size, height: size, display: "block" }}
>
{paths}
</svg>
);
};
}
const BookOpenIcon = makeLucideIcon(
<>
<path d="M12 7v14" />
<path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z" />
</>,
);
const DownloadCloudIcon = makeLucideIcon(
<>
<path d="M12 13v8l-4-4" />
<path d="m12 21 4-4" />
<path d="M4.393 15.269A7 7 0 1 1 15.71 8.071" />
</>,
);
const MessageSquareTextIcon = makeLucideIcon(
<>
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
<path d="M13 8H7" />
<path d="M17 12H7" />
</>,
);
const ListChecksIcon = makeLucideIcon(
<>
<path d="m3 17 2 2 4-4" />
<path d="m3 7 2 2 4-4" />
<path d="M13 6h8" />
<path d="M13 12h8" />
<path d="M13 18h8" />
</>,
);
const HistoryIcon = makeLucideIcon(
<>
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
<path d="M12 7v5l4 2" />
</>,
);
const SlidersHorizontalIcon = makeLucideIcon(
<>
<line x1="21" x2="14" y1="4" y2="4" />
<line x1="10" x2="3" y1="4" y2="4" />
<line x1="21" x2="12" y1="12" y2="12" />
<line x1="8" x2="3" y1="12" y2="12" />
<line x1="21" x2="16" y1="20" y2="20" />
<line x1="12" x2="3" y1="20" y2="20" />
<line x1="14" x2="14" y1="2" y2="6" />
<line x1="8" x2="8" y1="10" y2="14" />
<line x1="16" x2="16" y1="18" y2="22" />
</>,
);
const FolderOpenIcon = makeLucideIcon(
<>
<path d="M6 14h.01" />
<path d="M3 6h5l2 2h11v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
</>,
);
const ActivityIcon = makeLucideIcon(
<path d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.5.5 0 0 1-.96 0L9.24 3.18a.5.5 0 0 0-.96 0l-2.35 8.36A2 2 0 0 1 4 13H2" />,
);
const InfoIcon = makeLucideIcon(
<>
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4" />
<path d="M12 8h.01" />
</>,
);
const SparklesIcon = makeLucideIcon(
<>
<path d="m12 3-1.9 5.8a2 2 0 0 1-1.3 1.3L3 12l5.8 1.9a2 2 0 0 1 1.3 1.3L12 21l1.9-5.8a2 2 0 0 1 1.3-1.3L21 12l-5.8-1.9a2 2 0 0 1-1.3-1.3z" />
<path d="M5 3v4" />
<path d="M19 17v4" />
<path d="M3 5h4" />
<path d="M17 19h4" />
</>,
);
const RefreshIcon = makeLucideIcon(
<>
<path d="M3 12a9 9 0 0 1 15-6.7L21 8" />
<path d="M21 3v5h-5" />
<path d="M21 12a9 9 0 0 1-15 6.7L3 16" />
<path d="M3 21v-5h5" />
</>,
);
const ExternalLinkIcon = makeLucideIcon(
<>
<path d="M15 3h6v6" />
<path d="M10 14 21 3" />
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
</>,
);
const ClockIcon = makeLucideIcon(
<>
<circle cx="12" cy="12" r="10" />
<path d="M12 6v6l4 2" />
</>,
);
const AlertTriangleIcon = makeLucideIcon(
<>
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3z" />
<path d="M12 9v4" />
<path d="M12 17h.01" />
</>,
);
const XIcon = makeLucideIcon(
<>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</>,
);
const ChevronLeftIcon = makeLucideIcon(<path d="m15 18-6-6 6-6" />);
const ChevronRightIcon = makeLucideIcon(<path d="m9 6 6 6-6 6" />);
const ChevronDownIcon = makeLucideIcon(<path d="m6 9 6 6 6-6" />);
const PlusIcon = makeLucideIcon(
<>
<path d="M12 5v14" />
<path d="M5 12h14" />
</>,
);
const PlusCircleIcon = makeLucideIcon(
<>
<circle cx="12" cy="12" r="9" />
<path d="M12 8v8" />
<path d="M8 12h8" />
</>,
);
const FolderIcon = makeLucideIcon(<path d="M3 7h6l2 2h10v10H3z" />);
const MoreHorizontalIcon = makeLucideIcon(
<>
<circle cx="6" cy="12" r="1" />
<circle cx="12" cy="12" r="1" />
<circle cx="18" cy="12" r="1" />
</>,
);
const ArchiveIcon = makeLucideIcon(
<>
<path d="M3 6h18v4H3z" />
<path d="M5 10v10h14V10" />
<path d="M10 14h4" />
</>,
);
const PencilIcon = makeLucideIcon(
<>
<path d="M12 20h9" />
<path d="M16.5 3.5a2.121 2.121 0 1 1 3 3L7 19l-4 1 1-4z" />
</>,
);
export function SidebarLink({ context }: PluginSidebarProps) {
const hostNavigation = useHostNavigation();
return (
<a
{...hostNavigation.linkProps("/wiki")}
className="flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium text-foreground/80 transition-colors hover:bg-accent/50 hover:text-foreground"
style={{ textDecoration: "none" }}
>
<span aria-hidden="true" className="shrink-0">
<BookOpenIcon />
</span>
<span className="flex-1 truncate">Wiki</span>
</a>
);
}
export function SettingsPage({ context }: PluginSettingsPageProps) {
const isMobile = useIsMobileLayout();
return (
<main style={{ padding: isMobile ? 16 : 24, maxWidth: isMobile ? "none" : 1040, minWidth: 0, fontFamily: fontStack, color: tokens.fg }}>
<SettingsBody context={context} />
</main>
);
}
// ---------------------------------------------------------------------------
// Main wiki page: section shell for Pages / Ask / Ingest / Lint / History /
// Settings. Wiki navigation uses path segments so pages can be deep-linked as
// `/:companyPrefix/wiki/page/path/to/file`. Legacy query-param links are still
// accepted as a compatibility fallback.
// ---------------------------------------------------------------------------
type SectionKey = "browse" | "ingest" | "query" | "lint" | "history" | "settings";
const SECTIONS: ReadonlyArray<{
key: SectionKey;
label: string;
Icon: (props: LucideIconProps) => ReactElement;
description: string;
}> = [
{ key: "browse", label: "Wiki", Icon: BookOpenIcon, description: "Open wiki pages and raw sources from the sidebar." },
{ key: "query", label: "Ask", Icon: MessageSquareTextIcon, description: "Ask the Wiki Maintainer agent a cited question against the local wiki." },
{ key: "ingest", label: "Add Content", Icon: PlusCircleIcon, description: "Capture a new source into the active space and queue an ingest operation." },
{ key: "lint", label: "Lint", Icon: ListChecksIcon, description: "Run structural checks for orphan pages, missing backlinks, and stale provenance." },
{ key: "history", label: "History", Icon: HistoryIcon, description: "Inspect recent LLM Wiki operation issues." },
{ key: "settings", label: "Settings", Icon: SlidersHorizontalIcon, description: "Folder, agent, project, and routine configuration scoped to this company." },
];
const TOP_TOOL_KEYS: ReadonlySet<SectionKey> = new Set<SectionKey>(["query", "ingest"]);
const BOTTOM_TOOL_KEYS: ReadonlySet<SectionKey> = new Set<SectionKey>(["history", "settings"]);
const TOP_TOOL_SECTIONS = SECTIONS.filter((section) => TOP_TOOL_KEYS.has(section.key));
const BOTTOM_TOOL_SECTIONS = SECTIONS.filter((section) => BOTTOM_TOOL_KEYS.has(section.key));
const SECTION_KEYS: ReadonlySet<SectionKey> = new Set(SECTIONS.map((s) => s.key));
const LEGACY_SECTION_ALIASES: Readonly<Partial<Record<string, SectionKey>>> = {
operations: "history",
};
function isSectionKey(value: string | null | undefined): value is SectionKey {
return typeof value === "string" && SECTION_KEYS.has(value as SectionKey);
}
function normalizeSectionKey(value: string | null | undefined): SectionKey | null {
if (typeof value !== "string") return null;
return LEGACY_SECTION_ALIASES[value] ?? (isSectionKey(value) ? value : null);
}
function readSectionFromSearch(search: string): SectionKey {
const params = new URLSearchParams(search);
const raw = params.get("section");
return normalizeSectionKey(raw) ?? "browse";
}
function decodeRouteSegment(segment: string): string {
try {
return decodeURIComponent(segment);
} catch {
return segment;
}
}
function readWikiRouteSegments(pathname: string): string[] {
const segments = pathname.split("/").filter(Boolean);
const wikiIndex = segments.findIndex((segment) => decodeRouteSegment(segment).toLowerCase() === "wiki");
if (wikiIndex === -1) return [];
return segments.slice(wikiIndex + 1).map(decodeRouteSegment);
}
// Strip an optional `spaces/<slug>` prefix from the wiki route segments.
// Returns the active space slug (defaults to "default") and the remaining segments
// after that prefix so existing section/page parsing keeps working unchanged.
function readWikiSpaceContext(pathname: string): { spaceSlug: string; rest: string[] } {
const segments = readWikiRouteSegments(pathname);
if (segments[0] === "spaces" && typeof segments[1] === "string" && segments[1].length > 0) {
return { spaceSlug: segments[1], rest: segments.slice(2) };
}
return { spaceSlug: DEFAULT_SPACE_SLUG, rest: segments };
}
function readActiveSpaceSlugFromLocation(pathname: string): string {
return readWikiSpaceContext(pathname).spaceSlug;
}
function readSectionFromLocation(pathname: string, search: string): SectionKey {
const [firstSegment] = readWikiSpaceContext(pathname).rest;
if (firstSegment === "page") return "browse";
return normalizeSectionKey(firstSegment) ?? readSectionFromSearch(search);
}
function readSettingsSectionFromLocation(pathname: string): SettingsSectionKey {
const [firstSegment, secondSegment] = readWikiSpaceContext(pathname).rest;
if (firstSegment !== "settings") return "root";
if (secondSegment === "maintainer" || secondSegment === "project") return "root";
return SETTINGS_SECTIONS.some((section) => section.key === secondSegment)
? secondSegment as SettingsSectionKey
: "root";
}
function readSettingsSpaceSlugFromLocation(pathname: string): string | null {
const segs = readWikiSpaceContext(pathname).rest;
if (segs[0] !== "settings" || segs[1] !== "spaces") return null;
const slug = segs[2];
return typeof slug === "string" && slug.length > 0 ? slug : null;
}
function buildSpacePrefix(spaceSlug: string): string {
return spaceSlug && spaceSlug !== DEFAULT_SPACE_SLUG
? `/wiki/spaces/${encodeURIComponent(spaceSlug)}`
: `/wiki`;
}
function buildSectionHref(section: SectionKey, spaceSlug: string = DEFAULT_SPACE_SLUG): string {
const prefix = buildSpacePrefix(spaceSlug);
return section === "browse" ? prefix : `${prefix}/${section}`;
}
function buildSettingsSectionHref(settingsSection: SettingsSectionKey | "spaces", spaceSlug: string = DEFAULT_SPACE_SLUG, slug?: string): string {
const prefix = buildSpacePrefix(spaceSlug);
if (settingsSection === "spaces" && slug) {
return `${prefix}/settings/spaces/${encodeURIComponent(slug)}`;
}
if (settingsSection === "root") return `${prefix}/settings`;
return `${prefix}/settings/${settingsSection}`;
}
function readSelectedTreePathFromSearch(search: string): string | null {
const params = new URLSearchParams(search);
const raw = params.get("page")?.trim();
return raw || null;
}
function readSelectedTreePathFromLocation(pathname: string, search: string): string | null {
const [firstSegment, ...rest] = readWikiSpaceContext(pathname).rest;
if (firstSegment === "page") {
return treePathFromRouteSegments(rest);
}
return readSelectedTreePathFromSearch(search);
}
function buildPageHref(treePath: string, spaceSlug: string = DEFAULT_SPACE_SLUG): string {
const prefix = buildSpacePrefix(spaceSlug);
const encodedPath = routeSegmentsFromTreePath(treePath).map((segment) => encodeURIComponent(segment)).join("/");
return encodedPath ? `${prefix}/page/${encodedPath}` : prefix;
}
function wikiSidebarNavigationState(treePath: string): Record<typeof WIKI_SIDEBAR_NAV_STATE_KEY, string> {
return { [WIKI_SIDEBAR_NAV_STATE_KEY]: treePath };
}
function readSidebarSelectedPathFromNavigationState(state: unknown): string | null {
if (!state || typeof state !== "object") return null;
const value = (state as Record<string, unknown>)[WIKI_SIDEBAR_NAV_STATE_KEY];
return typeof value === "string" && value.trim() ? value : null;
}
function routeSidebarExpandedStorageKey(companyId: string | null | undefined): string {
return `${ROUTE_SIDEBAR_EXPANDED_STORAGE_PREFIX}:${companyId ?? "global"}`;
}
function readRouteSidebarExpandedPaths(storageKey: string): Set<string> {
if (typeof window === "undefined") return new Set(DEFAULT_ROUTE_SIDEBAR_EXPANDED_PATHS);
try {
const raw = window.localStorage.getItem(storageKey);
if (raw === null) return new Set(DEFAULT_ROUTE_SIDEBAR_EXPANDED_PATHS);
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return new Set(DEFAULT_ROUTE_SIDEBAR_EXPANDED_PATHS);
return new Set(parsed.filter((value): value is string => typeof value === "string" && value.trim().length > 0));
} catch {
return new Set(DEFAULT_ROUTE_SIDEBAR_EXPANDED_PATHS);
}
}
function writeRouteSidebarExpandedPaths(storageKey: string, paths: ReadonlySet<string>): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(storageKey, JSON.stringify([...paths].sort()));
} catch {
// Ignore storage failures; the tree still works for the current render.
}
}
const ROOT_WIKI_LINK_PAGES = new Set(["WIKI.md", "AGENTS.md", "IDEA.md", "index.md", "log.md"]);
function splitWikiLinkTarget(target: string): { path: string; fragment: string | null } | null {
const trimmed = target.trim();
if (!trimmed || /^[a-z][a-z\d+.-]*:/i.test(trimmed) || trimmed.startsWith("//")) return null;
const hashIndex = trimmed.indexOf("#");
const rawPath = (hashIndex >= 0 ? trimmed.slice(0, hashIndex) : trimmed)
.trim()
.replace(/^\/+/, "");
if (
!rawPath ||
rawPath.includes("\\") ||
rawPath.split("/").some((segment) => !segment || segment === "." || segment === "..")
) {
return null;
}
const fragment = hashIndex >= 0 ? trimmed.slice(hashIndex + 1).trim() || null : null;
return { path: rawPath, fragment };
}
function withMarkdownExtension(path: string): string {
return path.toLowerCase().endsWith(".md") ? path : `${path}.md`;
}
function normalizeWikiLinkPagePath(target: string): { path: string; fragment: string | null } | null {
const parsed = splitWikiLinkTarget(target);
if (!parsed) return null;
let path = withMarkdownExtension(parsed.path);
if (!path.startsWith("wiki/") && !path.startsWith("raw/") && !ROOT_WIKI_LINK_PAGES.has(path)) {
path = `wiki/${path}`;
}
return { path, fragment: parsed.fragment };
}
function buildWikiLinkHref(target: string, resolveHref: (to: string) => string): string | null {
const normalized = normalizeWikiLinkPagePath(target);
if (!normalized) return null;
const href = resolveHref(buildPageHref(normalized.path));
return normalized.fragment ? `${href}#${encodeURIComponent(normalized.fragment)}` : href;
}
function routeSegmentsFromTreePath(treePath: string): string[] {
return treePath.split("/").filter(Boolean);
}
function treePathFromRouteSegments(segments: string[]): string | null {
if (segments.length === 0) return null;
const [firstSegment, ...rest] = segments;
if (firstSegment === "templates") {
const templatePath = rest.join("/").trim();
return templatePath || null;
}
const routePath = segments.join("/").trim();
return routePath || null;
}
function firstSelectableTreePath(data: PagesData | null | undefined): string | null {
const firstPage = data?.pages.find((p) => p.path !== "wiki/index.md" && p.path !== "index.md") ?? data?.pages[0] ?? null;
if (firstPage) return firstPage.path;
const firstSource = data?.sources[0] ?? null;
if (firstSource) return firstSource.rawPath;
return TEMPLATE_PATHS[0] ?? null;
}
function contentPathFromTreePath(treePath: string | null): string | null {
return treePath;
}
function isEditableWikiPagePath(path: string): boolean {
return path === "WIKI.md"
|| path === "AGENTS.md"
|| path === "IDEA.md"
|| path === "index.md"
|| path === "log.md"
|| path.startsWith("wiki/");
}
export function WikiPage({ context }: PluginPageProps) {
const { pathname, search } = useHostLocation();
const isMobile = useIsMobileLayout();
const section = useMemo(() => readSectionFromLocation(pathname, search), [pathname, search]);
const settingsSection = useMemo(() => readSettingsSectionFromLocation(pathname), [pathname]);
const activeSpaceSlug = useMemo(() => readActiveSpaceSlugFromLocation(pathname), [pathname]);
const settingsSpaceSlug = useMemo(() => readSettingsSpaceSlugFromLocation(pathname), [pathname]);
const overview = useOverview(context.companyId);
const [isDragActive, setIsDragActive] = useState(false);
const [stagedFiles, setStagedFiles] = useState<StagedIngestFile[]>([]);
const [isIngestModalOpen, setIsIngestModalOpen] = useState(false);
const resetDragState = useCallback(() => {
setIsDragActive(false);
}, []);
const stageFiles = useCallback((files: File[]) => {
if (files.length === 0) return;
const now = Date.now();
setStagedFiles((current) => [
...current,
...files.map((file, index) => ({
id: `${now}-${index}-${file.name}-${file.size}-${file.lastModified}`,
file,
})),
]);
setIsIngestModalOpen(true);
}, []);
const handleDragEnter = useCallback((event: React.DragEvent<HTMLElement>) => {
if (!isFileDrag(event)) return;
event.preventDefault();
event.stopPropagation();
setIsDragActive(true);
}, []);
const handleDragOver = useCallback((event: React.DragEvent<HTMLElement>) => {
if (!isFileDrag(event)) return;
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = "copy";
setIsDragActive(true);
}, []);
const handleDragLeave = useCallback((event: React.DragEvent<HTMLElement>) => {
if (!isFileDrag(event)) return;
event.preventDefault();
event.stopPropagation();
const relatedTarget = event.relatedTarget;
if (relatedTarget instanceof Node && event.currentTarget.contains(relatedTarget)) return;
resetDragState();
}, [resetDragState]);
const handleDrop = useCallback((event: React.DragEvent<HTMLElement>) => {
if (!isFileDrag(event)) return;
event.preventDefault();
event.stopPropagation();
resetDragState();
stageFiles(Array.from(event.dataTransfer.files ?? []));
}, [resetDragState, stageFiles]);
useEffect(() => {
if (!isDragActive) return;
const handleWindowDragLeave = (event: DragEvent) => {
const leftViewport =
event.clientX <= 0 ||
event.clientY <= 0 ||
event.clientX >= window.innerWidth ||
event.clientY >= window.innerHeight;
if (leftViewport) resetDragState();
};
const handleVisibilityChange = () => {
if (document.visibilityState !== "visible") resetDragState();
};
window.addEventListener("dragend", resetDragState);
window.addEventListener("drop", resetDragState);
window.addEventListener("blur", resetDragState);
window.addEventListener("dragleave", handleWindowDragLeave);
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
window.removeEventListener("dragend", resetDragState);
window.removeEventListener("drop", resetDragState);
window.removeEventListener("blur", resetDragState);
window.removeEventListener("dragleave", handleWindowDragLeave);
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [isDragActive, resetDragState]);
if (!context.companyId) {
return <main style={{ ...shellStyle, height: isMobile ? "auto" : "100%", minHeight: isMobile ? "auto" : 600 }}>Choose a company to open the LLM Wiki.</main>;
}
return (
<main
style={{ ...shellStyle, position: "relative", height: isMobile ? "auto" : "100%", minHeight: isMobile ? "auto" : 600 }}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<style>{`@keyframes pcWikiPulse { 0%,100% { opacity:1 } 50% { opacity:0.45 } }`}</style>
<section style={{ flex: 1, minHeight: isMobile ? "auto" : 0, overflow: isMobile ? "visible" : "hidden", display: "flex" }}>
{overview.error ? (
<div style={{ padding: 24, flex: 1 }}>
<Callout tone="danger">LLM Wiki bridge error: {overview.error.message}</Callout>
</div>
) : !overview.data ? (
<div style={{ padding: 24, flex: 1, color: tokens.muted, fontSize: 13 }}>Loading wiki</div>
) : !overview.data.folder.healthy ? (
<UnconfiguredFolder context={context} folder={overview.data.folder} refresh={overview.refresh} />
) : section === "browse" ? (
<BrowseTab context={context} />
) : section === "ingest" ? (
<IngestTab context={context} refreshOverview={overview.refresh} />
) : section === "query" ? (
<QueryTab context={context} overview={overview.data} />
) : section === "lint" ? (
<SettingsTab context={context} initialSection="lint" />
) : section === "history" ? (
<HistoryTab context={context} overview={overview.data} />
) : (
<SettingsTab context={context} initialSection={settingsSection} />
)}
</section>
{isDragActive ? <WikiPageDropOverlay onClose={resetDragState} /> : null}
{isIngestModalOpen ? (
<IngestFilesModal
companyId={context.companyId}
files={stagedFiles}
initialSpaceSlug={activeSpaceSlug}
onAddFiles={stageFiles}
onRemoveFile={(id) => setStagedFiles((current) => current.filter((item) => item.id !== id))}
onClose={() => {
setIsIngestModalOpen(false);
setStagedFiles([]);
}}
onIngested={() => {
overview.refresh();
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("pc-wiki-ingest-queued"));
}
}}
/>
) : null}
</main>
);
}
type StagedIngestFile = {
id: string;
file: File;
};
function isFileDrag(event: React.DragEvent<HTMLElement>): boolean {
return Array.from(event.dataTransfer?.types ?? []).includes("Files");
}
function WikiPageDropOverlay({ onClose }: { onClose: () => void }) {
return (
<div
data-testid="llm-wiki-page-drop-overlay"
style={{
position: "fixed",
inset: 0,
zIndex: 1000,
pointerEvents: "auto",
display: "grid",
placeItems: "center",
padding: 24,
background: "color-mix(in oklab, var(--background, oklch(0.145 0 0)) 72%, transparent)",
backdropFilter: "blur(3px)",
}}
>
<button
type="button"
aria-label="Close ingest drop overlay"
title="Close"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onClose();
}}
style={{
position: "absolute",
top: 16,
right: 16,
width: 34,
height: 34,
borderRadius: 8,
border: `1px solid ${tokens.border}`,
background: "color-mix(in oklab, var(--card, oklch(0.205 0 0)) 90%, transparent)",
color: tokens.fg,
display: "inline-grid",
placeItems: "center",
cursor: "pointer",
boxShadow: "0 10px 30px rgba(0,0,0,0.28)",
}}
>
<XIcon size={16} />
</button>
<div style={{
width: "min(520px, 100%)",
borderRadius: 8,
border: `1.5px dashed ${tokens.pluginBorder}`,
background: "color-mix(in oklab, var(--card, oklch(0.205 0 0)) 92%, transparent)",
color: tokens.fg,
padding: "28px 24px",
textAlign: "center",
boxShadow: "0 20px 60px rgba(0,0,0,0.35)",
}}>
<div style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 44, height: 44, borderRadius: 8, background: tokens.pluginBg, color: tokens.pluginFg, marginBottom: 12 }}>
<DownloadCloudIcon size={24} />
</div>
<div style={{ fontSize: 18, fontWeight: 650, marginBottom: 6 }}>Drop to ingest into LLM Wiki</div>
<Tiny>Files will be staged for review before the wiki maintainer queues ingest operations.</Tiny>
</div>
</div>
);
}
function IngestFilesModal({
companyId,
files,
onAddFiles,
onRemoveFile,
onClose,
onIngested,
initialSpaceSlug,
}: {
companyId: string;
files: StagedIngestFile[];
onAddFiles: (files: File[]) => void;
onRemoveFile: (id: string) => void;
onClose: () => void;
onIngested: () => void;
initialSpaceSlug: string;
}) {
const ingest = usePluginAction("ingest-source");
const toast = usePluginToast();
const spacesQuery = useSpaces(companyId);
const spaces = useMemo(() => {
const list = spacesQuery.data?.spaces ?? [];
return [...list].sort(compareSpaces);
}, [spacesQuery.data]);
const inputRef = useRef<HTMLInputElement | null>(null);
const [busy, setBusy] = useState(false);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [targetSpaceSlug, setTargetSpaceSlug] = useState(initialSpaceSlug || DEFAULT_SPACE_SLUG);
const [pickerOpen, setPickerOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const targetSpace = useMemo(() => spaces.find((s) => s.slug === targetSpaceSlug) ?? null, [spaces, targetSpaceSlug]);
const requestClose = useCallback(() => {
if (!busy) onClose();
}, [busy, onClose]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape" || busy) return;
event.preventDefault();
onClose();
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [busy, onClose]);
async function confirm() {
if (busy || files.length === 0) return;
setBusy(true);
setErrorMsg(null);
try {
for (const item of files) {
const contents = await item.file.text();
const result = await ingest({
companyId,
spaceSlug: targetSpaceSlug,
sourceType: "file",
title: item.file.name,
url: null,
contents,
metadata: {
fileName: item.file.name,
fileSize: item.file.size,
fileType: item.file.type || null,
lastModified: item.file.lastModified,
},
});
await uploadIssueAttachmentFile({
companyId,
issueId: readIngestOperationIssueId(result),
file: item.file,
});
}
const count = files.length;
const spaceLabel = targetSpace?.displayName ?? targetSpaceSlug;
toast({ tone: "success", title: `Files queued for ingest into ${spaceLabel}`, body: `${count} ${count === 1 ? "file" : "files"} captured into raw sources and attached to ingest tasks.` });
onIngested();
onClose();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setErrorMsg(message);
toast({ tone: "error", title: "File ingest failed", body: message });
} finally {
setBusy(false);
}
}
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="llm-wiki-ingest-modal-title"
data-testid="llm-wiki-ingest-modal"
onMouseDown={(event) => {
if (event.currentTarget === event.target) requestClose();
}}
style={{
position: "fixed",
inset: 0,
zIndex: 1010,
display: "grid",
placeItems: "center",
padding: 18,
background: "rgba(0,0,0,0.52)",
}}
>
<div style={{
width: "min(680px, 100%)",
maxHeight: "min(720px, calc(100vh - 36px))",
overflow: "auto",
background: tokens.card,
color: tokens.fg,
border: `1px solid ${tokens.border}`,
borderRadius: 8,
boxShadow: "0 24px 80px rgba(0,0,0,0.45)",
}}>
<div style={{ padding: "16px 18px", borderBottom: `1px solid ${tokens.border}`, display: "flex", gap: 12, alignItems: "flex-start" }}>
<div style={{ width: 34, height: 34, borderRadius: 8, background: tokens.pluginBg, color: tokens.pluginFg, display: "grid", placeItems: "center", flexShrink: 0 }}>
<DownloadCloudIcon size={18} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<h2 id="llm-wiki-ingest-modal-title" style={{ margin: 0, fontSize: 16, fontWeight: 650 }}>Ingest files into {targetSpace?.displayName ?? targetSpaceSlug}</h2>
<Tiny style={{ marginTop: 4 }}>
Review the staged files, switch the destination space if needed, then queue them as LLM
Wiki ingest operations. This is manual file ingest - Paperclip-derived distillation always
routes to the default space regardless of the destination picked here.
</Tiny>
</div>
<Button size="sm" variant="ghost" onClick={requestClose} disabled={busy} title="Close ingest modal">Close</Button>
</div>
<div style={{ padding: 18, display: "grid", gap: 14 }}>
<SpacePicker
spaces={spaces}
activeSpaceSlug={targetSpaceSlug}
loading={spacesQuery.loading}
error={spacesQuery.error?.message ?? null}
isOpen={pickerOpen}
onToggle={() => setPickerOpen((v) => !v)}
onClose={() => setPickerOpen(false)}
onSelect={(slug) => {
setPickerOpen(false);
setTargetSpaceSlug(slug);
}}
onCreate={() => {
setPickerOpen(false);
setCreateOpen(true);
}}
/>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", alignItems: "center" }}>
<Badge tone="running">{files.length} staged</Badge>
<Button size="sm" onClick={() => inputRef.current?.click()} disabled={busy}>Add files</Button>
<input
ref={inputRef}
type="file"
multiple
style={{ display: "none" }}
onChange={(event) => {
onAddFiles(Array.from(event.currentTarget.files ?? []));
event.currentTarget.value = "";
}}
/>
</div>
<div style={{ border: `1px solid ${tokens.border}`, borderRadius: 8, overflow: "hidden" }}>
{files.length === 0 ? (
<div style={{ padding: 16 }}><Tiny>No files staged.</Tiny></div>
) : files.map((item) => (
<div key={item.id} style={{ display: "flex", gap: 10, alignItems: "center", padding: "10px 12px", borderBottom: `1px solid ${tokens.border}`, minWidth: 0 }}>
<div style={{ flex: "1 1 auto", minWidth: 0 }}>
<strong style={{ display: "block", fontSize: 13, overflowWrap: "anywhere" }}>{item.file.name}</strong>
<Tiny>{formatFileSize(item.file.size)}{item.file.type ? ` · ${item.file.type}` : ""}</Tiny>
</div>
<Button size="sm" variant="ghost" onClick={() => onRemoveFile(item.id)} disabled={busy}>Remove</Button>
</div>
))}
</div>
{errorMsg ? <Callout tone="danger">{errorMsg}</Callout> : null}
<Callout>
Confirming captures each file into <Mono>{targetSpaceSlug}/raw/</Mono>, attaches the original file to the ingest task, and initiates a task for the Wiki Maintainer to process.
</Callout>
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end", flexWrap: "wrap" }}>
<Button variant="ghost" onClick={requestClose} disabled={busy}>Cancel</Button>
<Button variant="primary" onClick={confirm} disabled={files.length === 0} loading={busy}>Capture & ingest into {targetSpace?.displayName ?? targetSpaceSlug}</Button>
</div>
</div>
</div>
{createOpen ? (
<CreateSpaceModal
companyId={companyId}
existingSlugs={new Set(spaces.map((s) => s.slug))}
onClose={() => setCreateOpen(false)}
onCreated={(space) => {
setCreateOpen(false);
spacesQuery.refresh();
setTargetSpaceSlug(space.slug);
}}
/>
) : null}
</div>
);
}
function formatFileSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return "Unknown size";
if (bytes < 1024) return `${bytes} B`;
const kib = bytes / 1024;
if (kib < 1024) return `${kib.toFixed(kib >= 10 ? 0 : 1)} KB`;
const mib = kib / 1024;
return `${mib.toFixed(mib >= 10 ? 0 : 1)} MB`;
}
// ---------------------------------------------------------------------------
// Wiki route sidebar — replaces the company sidebar while the user is on a
// `/wiki` route. Mirrors the shell of the host's CompanySettingsSidebar so
// users see a familiar takeover.
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Create-space modal — opened from the sidebar `+` icon and also reachable
// from the edit-space settings sub-nav. Disables the "Cloud" type and the
// "Existing absolute path" folder source per the PAP-3640 security review.
// ---------------------------------------------------------------------------
function slugify(input: string): string {
return input
.toLowerCase()
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 40);
}
const SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/;
function CreateSpaceModal({
companyId,
existingSlugs,
onClose,
onCreated,
}: {
companyId: string;
existingSlugs: ReadonlySet<string>;
onClose: () => void;
onCreated: (space: WikiSpace) => void;
}) {
const create = usePluginAction("create-space");
const toast = usePluginToast();
const [displayName, setDisplayName] = useState("");
const [slug, setSlug] = useState("");
const [slugDirty, setSlugDirty] = useState(false);
const [folderMode, setFolderMode] = useState<"managed_subfolder" | "existing_local_folder">("managed_subfolder");
const [accessScope, setAccessScope] = useState<"shared" | "personal" | "team">("shared");
const [busy, setBusy] = useState(false);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const isMobile = useIsMobileLayout();
useEffect(() => {
const handler = (event: KeyboardEvent) => { if (event.key === "Escape" && !busy) onClose(); };
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [busy, onClose]);
const effectiveSlug = slug.trim() || (slugDirty ? "" : slugify(displayName));
const slugError = (() => {
if (!effectiveSlug) return null;
if (effectiveSlug === DEFAULT_SPACE_SLUG) return "Slug 'default' is reserved.";
if (!SLUG_PATTERN.test(effectiveSlug)) return "Use 2-40 chars: lowercase letters, numbers, hyphens.";
if (existingSlugs.has(effectiveSlug)) return "A space with this slug already exists.";
return null;
})();
const canSubmit = displayName.trim().length > 0 && effectiveSlug.length > 0 && !slugError && !busy;
async function submit() {
if (!canSubmit) return;
setBusy(true);
setErrorMsg(null);
try {
const result = await create({
companyId,
slug: effectiveSlug,
displayName: displayName.trim(),
folderMode,
accessScope,
}) as { status: "created"; space: WikiSpace };
toast({ tone: "success", title: "Space created", body: `${result.space.displayName} is ready for ingest.` });
onCreated(result.space);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setErrorMsg(msg);
toast({ tone: "error", title: "Could not create space", body: msg });
} finally {
setBusy(false);
}
}
const previewName = displayName.trim() || "your-space";
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="create-space-modal-title"
style={{
position: "fixed",
inset: 0,
zIndex: 200,
display: "grid",
placeItems: isMobile ? "end" : "center",
padding: isMobile ? 0 : 18,
background: "rgba(0,0,0,0.52)",
}}
onClick={(event) => {
if (event.target === event.currentTarget && !busy) onClose();
}}
>
<div
style={{
width: isMobile ? "100%" : "min(560px, 100%)",
maxHeight: isMobile ? "92vh" : "min(760px, calc(100vh - 36px))",
overflow: "auto",
background: tokens.card,
color: tokens.fg,
border: `1px solid ${tokens.border}`,
borderRadius: isMobile ? "12px 12px 0 0" : 8,
boxShadow: "0 24px 80px rgba(0,0,0,0.45)",
fontFamily: fontStack,
}}
>
<div style={{ padding: "16px 20px", borderBottom: `1px solid ${tokens.border}`, display: "flex", alignItems: "flex-start", gap: 12 }}>
<div style={{ width: 34, height: 34, borderRadius: 8, background: tokens.pluginBg, color: tokens.pluginFg, display: "grid", placeItems: "center", flexShrink: 0 }}>
<FolderIcon size={18} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<h2 id="create-space-modal-title" style={{ margin: 0, fontSize: 17, fontWeight: 650 }}>Create a shared space</h2>
<Tiny style={{ marginTop: 4 }}>
Spaces partition wiki pages, sources, and manual ingest into separate slug-prefixed folders
under the wiki root. Paperclip distillation and event capture always write into the
default space and skip new spaces created here - per-space Paperclip routing is a later
phase.
</Tiny>
</div>
<Button size="sm" variant="ghost" onClick={onClose} disabled={busy} title="Close">Close</Button>
</div>
<div style={{ padding: 20, display: "grid", gap: 16 }}>
<FormField label="Display name">
<TextInput
value={displayName}
autoFocus
onChange={(event) => {
setDisplayName(event.target.value);
if (!slugDirty) setSlug("");
}}
placeholder="Team research"
maxLength={120}
/>
</FormField>
<FormField
label="Slug"
help={slugError ?? `Stored as the URL segment and the on-disk folder. Defaults to ${slugify(displayName) || "auto-derived from display name"}.`}
tone={slugError ? "danger" : "muted"}
>
<TextInput
value={slug || (slugDirty ? "" : slugify(displayName))}
onChange={(event) => {
setSlugDirty(true);
setSlug(event.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""));
}}
placeholder="team-research"
style={{ fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace" }}
/>
</FormField>
<FormField label="Type">
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
<SegmentedOption selected label="Folder" onClick={() => undefined} />
<SegmentedOption disabled label="Cloud" suffix="Coming soon" onClick={() => undefined} />
</div>
</FormField>
<FormField label="Folder source" help="New managed folders create a slug-scoped subfolder under your wiki root. Existing folders must already live under the same wiki root.">
<div style={{ display: "grid", gap: 8 }}>
<FolderModeRow
checked={folderMode === "managed_subfolder"}
onSelect={() => setFolderMode("managed_subfolder")}
label="New managed folder"
help={`Creates spaces/${previewName.replace(/\s+/g, "-").toLowerCase()}/ under the configured wiki root with the standard skeleton.`}
/>
<FolderModeRow
checked={folderMode === "existing_local_folder"}
onSelect={() => setFolderMode("existing_local_folder")}
label="Existing folder under wiki root"
help="Re-use a sub-folder you've already created inside the wiki root. The folder path is stored in the space settings."
/>
<FolderModeRow
disabled
checked={false}
onSelect={() => undefined}
label="Existing absolute path"
help="Pending host capability — security review (PAP-3640) gates this until host-managed dynamic local-folder bindings ship."
suffix="Disabled"
/>
</div>
</FormField>
<FormField label="Access scope" help="Access scope is metadata only. It does not currently enforce who can read or write the space, and it does not change which Paperclip sources reach the space.">
<div style={{ display: "grid", gridTemplateColumns: isMobile ? "1fr" : "1fr 1fr 1fr", gap: 8 }}>
<ScopeTile
selected={accessScope === "shared"}
onSelect={() => setAccessScope("shared")}
label="Shared"
help="Visible to everyone in this company."
/>
<ScopeTile
selected={accessScope === "personal"}
onSelect={() => setAccessScope("personal")}
label="Personal"
help="Future scope — stored only."
tag="Future"
/>
<ScopeTile
selected={accessScope === "team"}
onSelect={() => setAccessScope("team")}
label="Team"
help="Future scope — stored only."
tag="Future"
/>
</div>
</FormField>
{errorMsg ? <Callout tone="danger">{errorMsg}</Callout> : null}
</div>
<div style={{
padding: "12px 20px",
borderTop: `1px solid ${tokens.border}`,
display: "flex",
gap: 8,
justifyContent: isMobile ? "stretch" : "flex-end",
flexWrap: "wrap",
position: isMobile ? "sticky" : "static",
bottom: 0,
background: tokens.card,
}}>
<Button variant="ghost" onClick={onClose} disabled={busy} style={{ flex: isMobile ? 1 : undefined }}>Cancel</Button>
<Button variant="primary" onClick={submit} disabled={!canSubmit} loading={busy} style={{ flex: isMobile ? 1 : undefined }}>Create space</Button>
</div>
</div>
</div>
);
}
function FormField({ label, help, tone = "muted", children }: { label: ReactNode; help?: ReactNode; tone?: "muted" | "danger"; children: ReactNode }) {
return (
<div style={{ display: "grid", gap: 6 }}>
<label style={{ fontSize: 12, fontWeight: 600, color: tokens.fg }}>{label}</label>
{children}
{help ? (
<span style={{ fontSize: 11, color: tone === "danger" ? "oklch(0.78 0.18 25)" : tokens.muted, lineHeight: 1.4 }}>{help}</span>
) : null}
</div>
);
}
function SegmentedOption({ label, selected, disabled, suffix, onClick }: { label: string; selected?: boolean; disabled?: boolean; suffix?: string; onClick: () => void }) {
return (
<button
type="button"
onClick={disabled ? undefined : onClick}
disabled={disabled}
aria-pressed={selected}
aria-disabled={disabled}
style={{
padding: "8px 14px",
borderRadius: 6,
border: `1px solid ${selected ? tokens.border : tokens.border}`,
background: selected ? tokens.accent : "transparent",
color: disabled ? tokens.muted : tokens.fg,
fontSize: 13,
fontWeight: 600,
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.6 : 1,
display: "inline-flex",
alignItems: "center",
gap: 6,
fontFamily: fontStack,
}}
>
<span>{label}</span>
{suffix ? (
<span style={{
fontSize: 10,
fontWeight: 500,
padding: "1px 6px",
borderRadius: 3,
border: `1px dashed ${tokens.border}`,
color: tokens.muted,
}}>{suffix}</span>
) : null}
</button>
);
}
function FolderModeRow({ checked, onSelect, label, help, disabled, suffix }: { checked: boolean; onSelect: () => void; label: string; help: string; disabled?: boolean; suffix?: string }) {
return (
<button
type="button"
onClick={disabled ? undefined : onSelect}
disabled={disabled}
style={{
display: "flex",
alignItems: "flex-start",
gap: 10,
padding: "10px 12px",
borderRadius: 8,
border: `1px solid ${checked ? tokens.fg : tokens.border}`,
background: "transparent",
color: tokens.fg,
textAlign: "left",
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.55 : 1,
fontFamily: fontStack,
}}
>
<span
aria-hidden="true"
style={{
width: 14,
height: 14,
borderRadius: 7,
border: `2px solid ${checked ? tokens.fg : tokens.muted}`,
flexShrink: 0,
marginTop: 2,
background: checked ? tokens.fg : "transparent",
}}
/>
<div style={{ display: "grid", gap: 2, flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ fontSize: 13, fontWeight: 600 }}>{label}</span>
{suffix ? <Badge tone="default" style={{ fontSize: 10 }}>{suffix}</Badge> : null}
</div>
<span style={{ fontSize: 11, color: tokens.muted, lineHeight: 1.4 }}>{help}</span>
</div>
</button>
);
}
function ScopeTile({ selected, onSelect, label, help, tag }: { selected: boolean; onSelect: () => void; label: string; help: string; tag?: string }) {
return (
<button
type="button"
onClick={onSelect}
style={{
padding: "10px 12px",
borderRadius: 8,
border: `1px solid ${selected ? tokens.fg : tokens.border}`,
background: selected ? tokens.accent : "transparent",
color: tokens.fg,
textAlign: "left",
display: "grid",
gap: 4,
cursor: "pointer",
fontFamily: fontStack,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ fontSize: 13, fontWeight: 600 }}>{label}</span>
{tag ? <Badge tone="default" style={{ fontSize: 10 }}>{tag}</Badge> : null}
</div>
<span style={{ fontSize: 11, color: tokens.muted, lineHeight: 1.4 }}>{help}</span>
</button>
);
}
function compareSpaces(a: WikiSpace, b: WikiSpace): number {
if (a.slug === DEFAULT_SPACE_SLUG && b.slug !== DEFAULT_SPACE_SLUG) return -1;
if (b.slug === DEFAULT_SPACE_SLUG && a.slug !== DEFAULT_SPACE_SLUG) return 1;
return a.displayName.localeCompare(b.displayName, undefined, { sensitivity: "base" });
}
function activeWikiSpaces(spaces: WikiSpace[]): WikiSpace[] {
return spaces.filter((space) => space.status !== "archived");
}
function spaceTreeKey(spaceSlug: string, path: string): string {
return `${spaceSlug}::${path}`;
}
function SpacePageContentWarmup({ companyId, path, spaceSlug }: { companyId: string | null; path: string; spaceSlug: string }) {
usePageContent(companyId, path, spaceSlug);
return null;
}
function SpacePagesWarmup({ companyId, spaceSlug }: { companyId: string | null; spaceSlug: string }) {
const pages = usePages(companyId, { includeRaw: true, spaceSlug });
const selectedTreePath = firstSelectableTreePath(pages.data);
const selected = contentPathFromTreePath(selectedTreePath);
return selected ? <SpacePageContentWarmup companyId={companyId} path={selected} spaceSlug={spaceSlug} /> : null;
}
export function WikiRouteSidebar({ context }: PluginRouteSidebarProps) {
const hostNavigation = useHostNavigation();
const { pathname, search, state } = useHostLocation();
const activeSection = useMemo(() => readSectionFromLocation(pathname, search), [pathname, search]);
const activeSpaceSlug = useMemo(() => readActiveSpaceSlugFromLocation(pathname), [pathname]);
const companyName = context.companyPrefix ?? "Company";
const spacesQuery = useSpaces(context.companyId);
const spaces = useMemo(() => {
const list = spacesQuery.data?.spaces ?? [];
if (list.length === 0) return list;
return activeWikiSpaces(list).sort(compareSpaces);
}, [spacesQuery.data]);
const pages = usePages(context.companyId, { includeRaw: true, spaceSlug: activeSpaceSlug });
const activeSpaceNodes = useMemo(
() => buildBrowseTree(pages.data?.pages ?? [], pages.data?.sources ?? []),
[pages.data],
);
const warmupSpaces = useMemo(() => {
const active = spaces.find((space) => space.slug === activeSpaceSlug);
const rest = spaces.filter((space) => space.slug !== activeSpaceSlug);
return (active ? [active, ...rest] : rest).slice(0, WIKI_SPACE_PREFETCH_LIMIT);
}, [spaces, activeSpaceSlug]);
const storageKey = useMemo(() => routeSidebarExpandedStorageKey(context.companyId), [context.companyId]);
const [expandedRaw, setExpandedRaw] = useState<Set<string>>(() => readRouteSidebarExpandedPaths(storageKey));
const [selectedTreePath, setSelectedTreePath] = useState<string | null>(null);
const [spaceCollapse, setSpaceCollapse] = useState<Set<string>>(new Set());
const [createOpen, setCreateOpen] = useState(false);
const [openMenuFor, setOpenMenuFor] = useState<string | null>(null);
useEffect(() => {
if (activeSection !== "browse") return;
const sidebarSelectedPath = readSidebarSelectedPathFromNavigationState(state);
if (sidebarSelectedPath === null) return;
setSelectedTreePath(sidebarSelectedPath);
}, [activeSection, state]);
useEffect(() => {
setExpandedRaw(readRouteSidebarExpandedPaths(storageKey));
}, [storageKey]);
useEffect(() => {
writeRouteSidebarExpandedPaths(storageKey, expandedRaw);
}, [expandedRaw, storageKey]);
useEffect(() => {
const ancestors = expandedAncestors(selectedTreePath);
if (ancestors.length === 0) return;
const slug = activeSpaceSlug;
setExpandedRaw((current) => {
const next = new Set(current);
let changed = false;
for (const ancestor of ancestors) {
const key = spaceTreeKey(slug, ancestor);
if (next.has(key)) continue;
next.add(key);
changed = true;
}
return changed ? next : current;
});
}, [selectedTreePath, activeSpaceSlug]);
// Project the per-space-prefixed expanded paths down to the active space's
// bare paths so FileTree (which is space-agnostic) can read them directly.
// Legacy entries written before this change have no `slug::` prefix and are
// treated as belonging to the default space.
const expandedForActiveSpace = useMemo(() => {
const next = new Set<string>();
const prefix = `${activeSpaceSlug}::`;
for (const entry of expandedRaw) {
if (entry.startsWith(prefix)) {
next.add(entry.slice(prefix.length));
} else if (!entry.includes("::") && activeSpaceSlug === DEFAULT_SPACE_SLUG) {
next.add(entry);
}
}
return next;
}, [expandedRaw, activeSpaceSlug]);
const handleToggleDir = (dirPath: string) => {
const key = spaceTreeKey(activeSpaceSlug, dirPath);
setExpandedRaw((current) => {
const next = new Set(current);
// For the default space, legacy un-prefixed entries also need to be
// removed so that the projection (which falls through legacy keys to
// default) does not leave a "ghost" expansion after a collapse click.
if (next.has(key)) {
next.delete(key);
if (activeSpaceSlug === DEFAULT_SPACE_SLUG) next.delete(dirPath);
} else if (activeSpaceSlug === DEFAULT_SPACE_SLUG && next.has(dirPath)) {
next.delete(dirPath);
} else {
next.add(key);
}
return next;
});
};
const toggleSpaceCollapse = (slug: string) => {
setSpaceCollapse((current) => {
const next = new Set(current);
if (next.has(slug)) next.delete(slug);
else next.add(slug);
return next;
});
};
const renderToolLink = ({ key, label, Icon }: (typeof SECTIONS)[number]) => {
const isLegacyLintSettingsActive = key === "settings" && activeSection === "lint";
const isActive = key === activeSection || isLegacyLintSettingsActive;
return (
<a
key={key}
{...hostNavigation.linkProps(buildSectionHref(key, activeSpaceSlug))}
aria-current={isActive ? "page" : undefined}
className={[
"flex items-center gap-2.5 rounded-md px-2 py-1.5 text-[13px] font-medium transition-colors",
isActive && !isLegacyLintSettingsActive
? "bg-accent text-foreground"
: isActive
? "text-foreground"
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground",
].join(" ")}
style={{ textDecoration: "none" }}
>
<span aria-hidden="true" className="shrink-0">
<Icon />
</span>
<span className="flex-1 truncate">{label}</span>
</a>
);
};
return (
<aside className="w-full h-full min-h-0 border-r border-border bg-background flex flex-col">
{warmupSpaces.map((space) => (
<SpacePagesWarmup key={space.slug} companyId={context.companyId} spaceSlug={space.slug} />
))}
<div className="flex flex-col gap-1 px-3 py-3 shrink-0">
<a
{...hostNavigation.linkProps("/dashboard")}
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
style={{ textDecoration: "none" }}
>
<span aria-hidden="true" className="shrink-0">
<ChevronLeftIcon size={14} />
</span>
<span className="truncate">{companyName}</span>
</a>
</div>
<div className="flex-1 min-h-0 overflow-y-auto border-t border-border px-3 py-3">
<nav aria-label="Wiki primary" className="mb-3">
<div className="flex flex-col gap-0.5">
{TOP_TOOL_SECTIONS.map(renderToolLink)}
</div>
</nav>
<div className="mb-1 flex items-center gap-1 px-2 text-[11px] font-semibold uppercase tracking-normal text-muted-foreground" style={{ height: 24 }}>
<span
className="flex-1 truncate"
title="Destination spaces. Browsing and manual ingest happen in the active space; Paperclip distillation always writes into the default space in Phase 1."
>
Shared Spaces
</span>
<button
type="button"
aria-label="Create space"
title="Create space"
onClick={() => setCreateOpen(true)}
style={{
width: 22,
height: 22,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
border: "none",
background: "transparent",
color: tokens.muted,
borderRadius: 4,
cursor: "pointer",
}}
>
<PlusIcon size={14} />
</button>
</div>
{spacesQuery.error ? (
<div style={{ padding: "6px 8px", fontSize: 11, color: tokens.statusBlocked }}>
Failed to load spaces: {spacesQuery.error.message}
</div>
) : null}
{spaces.length === 0 && spacesQuery.loading ? (
<Tiny style={{ padding: "6px 8px" }}>Loading spaces</Tiny>
) : null}
<div style={{ display: "flex", flexDirection: "column" }}>
{spaces.map((space) => {
const isActiveSpace = space.slug === activeSpaceSlug;
const collapsed = spaceCollapse.has(space.slug);
const showTree = isActiveSpace && !collapsed;
return (
<div key={space.slug} style={{ position: "relative" }}>
<SpaceRow
space={space}
active={isActiveSpace}
expanded={showTree}
hostNavigation={hostNavigation}
onToggleCollapse={() => {
if (!isActiveSpace) {
setSpaceCollapse((current) => {
if (!current.has(space.slug)) return current;
const next = new Set(current);
next.delete(space.slug);
return next;
});
hostNavigation.navigate(buildSectionHref("browse", space.slug));
} else {
toggleSpaceCollapse(space.slug);
}
}}
onMenuToggle={() => setOpenMenuFor((curr) => curr === space.slug ? null : space.slug)}
menuOpen={openMenuFor === space.slug}
onMenuClose={() => setOpenMenuFor(null)}
onArchived={(slug) => {
spacesQuery.refresh();
setOpenMenuFor(null);
setSpaceCollapse((current) => {
if (!current.has(slug)) return current;
const next = new Set(current);
next.delete(slug);
return next;
});
if (activeSpaceSlug === slug) {
hostNavigation.navigate(buildSectionHref("browse", DEFAULT_SPACE_SLUG));
}
}}
activeSpaceSlug={activeSpaceSlug}
companyId={context.companyId}
/>
{showTree ? (
<div style={{ paddingLeft: 18, marginTop: 2, marginBottom: 6 }}>
<FileTree
nodes={activeSpaceNodes}
selectedFile={selectedTreePath}
expandedPaths={expandedForActiveSpace}
onSelectFile={(path) => {
setSelectedTreePath(path);
hostNavigation.navigate(buildPageHref(path, space.slug), { state: wikiSidebarNavigationState(path) });
}}
onToggleDir={handleToggleDir}
wrapLabels={false}
loading={pages.loading}
error={pages.error ? { message: pages.error.message } : null}
empty={{ title: "No pages yet", description: "Add content to populate this space." }}
ariaLabel={`Wiki pages in ${space.displayName}`}
/>
</div>
) : null}
</div>
);
})}
</div>
</div>
<nav
aria-label="Wiki secondary"
className="shrink-0 border-t border-border px-3 py-3"
>
<div className="flex flex-col gap-0.5">
{BOTTOM_TOOL_SECTIONS.map(renderToolLink)}
</div>
</nav>
{createOpen && context.companyId ? (
<CreateSpaceModal
companyId={context.companyId}
existingSlugs={new Set(spaces.map((s) => s.slug))}
onClose={() => setCreateOpen(false)}
onCreated={(space) => {
setCreateOpen(false);
spacesQuery.refresh();
hostNavigation.navigate(buildSectionHref("browse", space.slug));
}}
/>
) : null}
</aside>
);
}
function SpaceRow({
space,
active,
expanded,
hostNavigation,
onToggleCollapse,
onMenuToggle,
menuOpen,
onMenuClose,
onArchived,
activeSpaceSlug,
companyId,
}: {
space: WikiSpace;
active: boolean;
expanded: boolean;
hostNavigation: ReturnType<typeof useHostNavigation>;
onToggleCollapse: () => void;
onMenuToggle: () => void;
menuOpen: boolean;
onMenuClose: () => void;
onArchived: (slug: string) => void;
activeSpaceSlug: string;
companyId: string | null;
}) {
const [hover, setHover] = useState(false);
const isDefault = space.slug === DEFAULT_SPACE_SLUG;
return (
<div
role="button"
tabIndex={0}
aria-expanded={expanded}
aria-label={`${expanded ? "Collapse" : active ? "Expand" : "Open"} ${space.displayName} space`}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "6px 8px",
borderRadius: 6,
cursor: "pointer",
background: active ? tokens.accent : "transparent",
position: "relative",
}}
onClick={onToggleCollapse}
onKeyDown={(event) => {
if (event.target !== event.currentTarget) return;
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
onToggleCollapse();
}}
>
<span aria-hidden="true" style={{ color: tokens.muted, display: "flex", flexShrink: 0, transform: expanded ? "rotate(0)" : "rotate(0)" }}>
{expanded ? <ChevronDownIcon size={14} /> : <ChevronRightIcon size={14} />}
</span>
<span aria-hidden="true" style={{ color: tokens.muted, display: "flex", flexShrink: 0 }}>
<FolderIcon size={16} />
</span>
<span style={{ flex: 1, fontSize: 13, fontWeight: 600, color: tokens.fg, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{space.displayName}
</span>
{space.accessScope === "personal" ? (
<Badge tone="default" style={{ height: 18, padding: "0 6px", fontSize: 10 }}>personal</Badge>
) : space.accessScope === "team" ? (
<Badge tone="default" style={{ height: 18, padding: "0 6px", fontSize: 10 }}>team</Badge>
) : null}
<button
type="button"
aria-label={`${space.displayName} space menu`}
title="Space menu"
onClick={(event) => {
event.stopPropagation();
onMenuToggle();
}}
style={{
opacity: hover || menuOpen ? 1 : 0,
transition: "opacity 80ms ease",
width: 22,
height: 22,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
border: "none",
background: "transparent",
color: tokens.fg,
borderRadius: 4,
cursor: "pointer",
}}
>
<MoreHorizontalIcon size={14} />
</button>
{menuOpen ? (
<SpaceRowMenu
space={space}
isDefault={isDefault}
hostNavigation={hostNavigation}
activeSpaceSlug={activeSpaceSlug}
companyId={companyId}
onClose={onMenuClose}
onArchived={onArchived}
/>
) : null}
</div>
);
}
function SpaceRowMenu({
space,
isDefault,
hostNavigation,
activeSpaceSlug,
companyId,
onClose,
onArchived,
}: {
space: WikiSpace;
isDefault: boolean;
hostNavigation: ReturnType<typeof useHostNavigation>;
activeSpaceSlug: string;
companyId: string | null;
onClose: () => void;
onArchived: (slug: string) => void;
}) {
const ref = useRef<HTMLDivElement>(null);
const archive = usePluginAction("archive-space");
const bootstrap = usePluginAction("bootstrap-space");
const toast = usePluginToast();
const [busy, setBusy] = useState(false);
useEffect(() => {
const handler = (event: MouseEvent) => {
if (!ref.current) return;
if (event.target instanceof Node && ref.current.contains(event.target)) return;
onClose();
};
const keyHandler = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
document.addEventListener("mousedown", handler);
document.addEventListener("keydown", keyHandler);
return () => {
document.removeEventListener("mousedown", handler);
document.removeEventListener("keydown", keyHandler);
};
}, [onClose]);
const handleArchive = async () => {
if (!companyId || isDefault || busy) return;
if (typeof window !== "undefined" && !window.confirm(`Archive ${space.displayName}? Pages remain on disk; you can restore later through the plugin API or by un-archiving from the database.`)) {
return;
}
setBusy(true);
try {
await archive({ companyId, spaceSlug: space.slug });
toast({ tone: "success", title: "Space archived", body: `${space.displayName} hidden from sidebar.` });
onArchived(space.slug);
} catch (err) {
toast({ tone: "error", title: "Archive failed", body: err instanceof Error ? err.message : String(err) });
} finally {
setBusy(false);
}
};
const handleRefresh = async () => {
if (!companyId || busy) return;
setBusy(true);
try {
await bootstrap({ companyId, spaceSlug: space.slug });
toast({ tone: "success", title: "Space refreshed", body: `${space.displayName} index re-bootstrapped.` });
onClose();
} catch (err) {
toast({ tone: "error", title: "Refresh failed", body: err instanceof Error ? err.message : String(err) });
} finally {
setBusy(false);
}
};
return (
<div
ref={ref}
role="menu"
onClick={(event) => event.stopPropagation()}
style={{
position: "absolute",
top: "calc(100% + 4px)",
right: 0,
zIndex: 30,
minWidth: 220,
background: tokens.card,
border: `1px solid ${tokens.border}`,
borderRadius: 8,
boxShadow: "0 12px 36px rgba(0,0,0,0.45)",
padding: 4,
}}
>
<SpaceMenuItem
label="Edit space"
Icon={PencilIcon}
onClick={() => {
onClose();
hostNavigation.navigate(buildSettingsSectionHref("spaces", activeSpaceSlug, space.slug));
}}
/>
<SpaceMenuItem
label="Refresh index"
Icon={RefreshIcon}
onClick={handleRefresh}
disabled={busy}
/>
<SpaceMenuItem
label="Open ingest"
Icon={PlusCircleIcon}
onClick={() => {
onClose();
hostNavigation.navigate(buildSectionHref("ingest", space.slug));
}}
/>
<SpaceMenuDivider />
<SpaceMenuItem
label={isDefault ? "Archive space (default cannot be archived)" : "Archive space…"}
Icon={ArchiveIcon}
onClick={handleArchive}
disabled={isDefault || busy}
destructive
/>
</div>
);
}
function SpaceMenuItem({
label,
Icon,
onClick,
disabled,
destructive,
}: {
label: string;
Icon: (props: LucideIconProps) => ReactElement;
onClick: () => void;
disabled?: boolean;
destructive?: boolean;
}) {
return (
<button
type="button"
role="menuitem"
onClick={onClick}
disabled={disabled}
style={{
width: "100%",
display: "flex",
alignItems: "center",
gap: 10,
padding: "7px 10px",
background: "transparent",
border: "none",
cursor: disabled ? "not-allowed" : "pointer",
color: destructive ? "oklch(0.7 0.2 25)" : tokens.fg,
opacity: disabled ? 0.5 : 1,
fontSize: 13,
textAlign: "left",
borderRadius: 4,
fontFamily: fontStack,
}}
onMouseEnter={(event) => {
if (disabled) return;
event.currentTarget.style.background = tokens.accent;
}}
onMouseLeave={(event) => {
event.currentTarget.style.background = "transparent";
}}
>
<span aria-hidden="true" style={{ display: "flex", color: destructive ? "oklch(0.7 0.2 25)" : tokens.muted }}>
<Icon size={14} />
</span>
<span style={{ flex: 1 }}>{label}</span>
</button>
);
}
function SpaceMenuDivider() {
return <div style={{ height: 1, background: tokens.border, margin: "4px 0" }} />;
}
const shellStyle: CSSProperties = {
display: "flex",
flexDirection: "column",
height: "100%",
minHeight: 600,
background: tokens.bg,
color: tokens.fg,
fontFamily: fontStack,
fontSize: 14,
};
// ---------------------------------------------------------------------------
// Pre-flight: prompt for folder before any tab can be useful.
// ---------------------------------------------------------------------------
function UnconfiguredFolder({ context, folder, refresh }: { context: { companyId: string | null }; folder?: FolderStatus; refresh: () => void }) {
const bootstrap = usePluginAction("bootstrap-root");
const toast = usePluginToast();
const isMobile = useIsMobileLayout();
const [path, setPath] = useState(folder?.path ?? "");
const [busy, setBusy] = useState(false);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const configuredButUnhealthy = Boolean(folder?.configured);
useEffect(() => {
setPath(folder?.path ?? "");
}, [folder?.path]);
async function submit() {
if (!context.companyId || !path.trim()) return;
setBusy(true);
setErrorMsg(null);
try {
const result = await bootstrap({ companyId: context.companyId, path: path.trim() });
const written = (result as { writtenFiles?: string[] }).writtenFiles ?? [];
toast({ tone: "success", title: "Wiki root configured", body: written.length ? `Created ${written.length} bootstrap file(s).` : "Existing files preserved." });
refresh();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setErrorMsg(message);
toast({ tone: "error", title: "Could not configure wiki root", body: message });
} finally {
setBusy(false);
}
}
return (
<div style={{ flex: 1, padding: isMobile ? 16 : 28, display: "grid", placeItems: "start", overflow: isMobile ? "visible" : "auto", minWidth: 0 }}>
<Card style={{ maxWidth: 720, width: "100%" }}>
<CardHeader title={configuredButUnhealthy ? "Repair wiki root folder" : "Choose a wiki root folder"} />
<CardBody>
<Tiny style={{ marginBottom: 12 }}>
{configuredButUnhealthy
? "The configured wiki root is not ready. Update the path or repair it to recreate required baseline files."
: "Pick an absolute path on this machine. The plugin creates "}
{!configuredButUnhealthy ? <><Mono>raw/</Mono>, <Mono>wiki/</Mono>, <Mono>AGENTS.md</Mono>, <Mono>IDEA.md</Mono>, <Mono>wiki/index.md</Mono>, and <Mono>wiki/log.md</Mono> if they don't already exist.</> : null}
</Tiny>
{folder?.problems?.length ? (
<Callout tone="danger">
<div style={{ display: "grid", gap: 6 }}>
{folder.problems.map((problem, index) => (
<div key={`${problem.code}:${problem.path ?? index}`}>
{problem.message}{problem.path ? <> <Mono>{problem.path}</Mono></> : null}
</div>
))}
</div>
</Callout>
) : null}
<div style={{ display: "grid", gap: 12 }}>
{folder ? <FolderHealthChecklist folder={folder} /> : null}
<FolderPathPicker
value={path}
onChange={setPath}
onApply={submit}
applyLabel={configuredButUnhealthy ? "Repair & bootstrap" : "Configure & bootstrap"}
busy={busy}
disabled={!path.trim()}
/>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<Button variant="ghost" onClick={() => refresh()}>I already configured it</Button>
</div>
{errorMsg ? <Callout tone="danger">{errorMsg}</Callout> : null}
</div>
</CardBody>
</Card>
</div>
);
}
// ---------------------------------------------------------------------------
// Browse tab: selected page detail. The route sidebar owns the file tree.
// ---------------------------------------------------------------------------
const TEMPLATE_PATHS = ["AGENTS.md", "IDEA.md"] as const;
const BASELINE_DIRECTORIES = ["raw", "wiki", "wiki/sources", "wiki/projects", "wiki/entities", "wiki/concepts", "wiki/synthesis"] as const;
const BASELINE_FILES = [...TEMPLATE_PATHS, "wiki/index.md", "wiki/log.md"] as const;
const BASELINE_TREE_ORDER = new Map<string, number>([
["AGENTS.md", 0],
["IDEA.md", 1],
["raw", 2],
["wiki", 3],
["wiki/index.md", 0],
["wiki/log.md", 1],
["wiki/sources", 2],
["wiki/projects", 3],
["wiki/entities", 4],
["wiki/concepts", 5],
["wiki/synthesis", 6],
]);
function basename(path: string): string {
return path.split("/").pop() ?? path;
}
function dirname(path: string): string {
const idx = path.lastIndexOf("/");
return idx === -1 ? "" : path.slice(0, idx);
}
function ensureDir(roots: FileTreeNode[], dirPath: string): FileTreeNode {
const segments = dirPath.split("/").filter(Boolean);
let parentChildren = roots;
let currentPath = "";
let currentNode: FileTreeNode | null = null;
for (const segment of segments) {
currentPath = currentPath ? `${currentPath}/${segment}` : segment;
let next = parentChildren.find((c) => c.kind === "dir" && c.path === currentPath);
if (!next) {
next = { name: segment, path: currentPath, kind: "dir", children: [] };
parentChildren.push(next);
}
parentChildren = next.children;
currentNode = next;
}
if (!currentNode) {
throw new Error(`ensureDir called with empty path`);
}
return currentNode;
}
function pageDisplayName(path: string, title: string | null): string {
const trimmed = title?.trim();
return trimmed ? trimmed : basename(path);
}
function treeDisplayName(path: string, title: string | null): string {
return (BASELINE_FILES as readonly string[]).includes(path) ? basename(path) : pageDisplayName(path, title);
}
function buildBrowseTree(
pages: WikiPageRow[],
sources: WikiSourceRow[],
): FileTreeNode[] {
const roots: FileTreeNode[] = [];
const seenPaths = new Set<string>();
for (const dirPath of BASELINE_DIRECTORIES) {
ensureDir(roots, dirPath);
}
if (sources.length > 0) {
const rawDir = ensureDir(roots, "raw");
for (const source of sources) {
seenPaths.add(source.rawPath);
const node: FileTreeNode = {
name: pageDisplayName(source.rawPath, source.title),
path: source.rawPath,
kind: "file",
children: [],
};
rawDir.children.push(node);
}
}
for (const page of pages) {
seenPaths.add(page.path);
const parentDir = dirname(page.path);
const file: FileTreeNode = {
name: treeDisplayName(page.path, page.title),
path: page.path,
kind: "file",
children: [],
};
if (parentDir) {
ensureDir(roots, parentDir).children.push(file);
} else {
roots.push(file);
}
}
// Add the baseline files using their real wiki-root paths so the browser
// mirrors the on-disk default layout even before metadata has been indexed.
for (const path of BASELINE_FILES) {
if (seenPaths.has(path)) continue;
const parentDir = dirname(path);
const node = {
name: basename(path),
path,
kind: "file" as const,
children: [],
action: path,
};
seenPaths.add(path);
if (parentDir) {
ensureDir(roots, parentDir).children.push(node);
continue;
}
roots.push({
...node,
name: path,
});
}
function sortNodes(nodes: FileTreeNode[]) {
nodes.sort((a, b) => {
const orderA = BASELINE_TREE_ORDER.get(a.path);
const orderB = BASELINE_TREE_ORDER.get(b.path);
if (orderA != null || orderB != null) return (orderA ?? Number.MAX_SAFE_INTEGER) - (orderB ?? Number.MAX_SAFE_INTEGER);
if (a.kind !== b.kind) return a.kind === "dir" ? -1 : 1;
return a.name.localeCompare(b.name);
});
for (const node of nodes) {
if (node.kind === "dir") sortNodes(node.children);
}
}
sortNodes(roots);
return roots;
}
function expandedAncestors(path: string | null): string[] {
if (!path) return [];
const out: string[] = [];
const segments = path.split("/").filter(Boolean);
let current = "";
for (let i = 0; i < segments.length - 1; i++) {
current = current ? `${current}/${segments[i]}` : segments[i];
out.push(current);
}
return out;
}
function BrowseTab({ context }: { context: { companyId: string | null } }) {
const { pathname, search } = useHostLocation();
const activeSpaceSlug = useMemo(() => readActiveSpaceSlugFromLocation(pathname), [pathname]);
const pages = usePages(context.companyId, { includeRaw: true, spaceSlug: activeSpaceSlug });
const isMobile = useIsMobileLayout();
const selectedTreePath = readSelectedTreePathFromLocation(pathname, search) ?? firstSelectableTreePath(pages.data);
const selected = contentPathFromTreePath(selectedTreePath);
return (
<div style={{ flex: 1, minWidth: 0, overflow: isMobile ? "visible" : "auto" }}>
{pages.loading && !selected ? (
<div style={{ padding: isMobile ? 16 : 28, color: tokens.muted, fontSize: 13 }}>Loading pages…</div>
) : pages.error && !selected ? (
<div style={{ padding: isMobile ? 16 : 28 }}><Callout tone="danger">Failed to load pages: {pages.error.message}</Callout></div>
) : (
<PageDetail context={context} path={selected} spaceSlug={activeSpaceSlug} />
)}
</div>
);
}
function PageDetail({ context, path, spaceSlug }: { context: { companyId: string | null }; path: string | null; spaceSlug?: string }) {
const content = usePageContent(context.companyId, path, spaceSlug ?? null);
const writePage = usePluginAction("write-page");
const toast = usePluginToast();
const hostNavigation = useHostNavigation();
const isMobile = useIsMobileLayout();
const markdownBodyRef = useRef<HTMLDivElement | null>(null);
const [editing, setEditing] = useState(false);
const [savedHash, setSavedHash] = useState<string | null>(null);
const [provenanceOpen, setProvenanceOpen] = useState(false);
const [tocOpen, setTocOpen] = useState(true);
const [activeTocId, setActiveTocId] = useState<string | null>(null);
const parsedMarkdown = useMemo(() => parseWikiMarkdown(content.data?.contents ?? ""), [content.data?.contents]);
const tocHeadings = useMemo(() => extractWikiTocHeadings(parsedMarkdown.body), [parsedMarkdown.body]);
useEffect(() => {
setEditing(false);
setSavedHash(null);
setTocOpen(true);
setActiveTocId(null);
}, [path]);
useEffect(() => {
if (!content.data || editing) return;
const root = markdownBodyRef.current;
if (!root) return;
const renderedHeadings = Array.from(root.querySelectorAll("h2, h3, h4"));
tocHeadings.forEach((heading, index) => {
const element = renderedHeadings[index];
if (element instanceof HTMLElement) {
element.id = heading.id;
}
});
}, [content.data, editing, tocHeadings]);
useEffect(() => {
if (!content.data || editing || tocHeadings.length === 0) return;
const root = markdownBodyRef.current;
if (!root) return;
const scrollParent = findScrollableAncestor(root);
const updateActiveHeading = () => {
const containerTop = scrollParent instanceof HTMLElement ? scrollParent.getBoundingClientRect().top : 0;
const activationY = containerTop + 96;
let activeId = tocHeadings[0]?.id ?? null;
for (const heading of tocHeadings) {
const element = root.ownerDocument.getElementById(heading.id);
if (!(element instanceof HTMLElement)) continue;
if (!root.contains(element)) continue;
if (element.getBoundingClientRect().top <= activationY) {
activeId = heading.id;
} else {
break;
}
}
setActiveTocId(activeId);
};
updateActiveHeading();
scrollParent.addEventListener("scroll", updateActiveHeading, { passive: true });
window.addEventListener("resize", updateActiveHeading);
return () => {
scrollParent.removeEventListener("scroll", updateActiveHeading);
window.removeEventListener("resize", updateActiveHeading);
};
}, [content.data, editing, tocHeadings]);
const editable = path ? isEditableWikiPagePath(path) : false;
const resolveWikiLinkHref = useCallback(
(target: string) => buildWikiLinkHref(target, hostNavigation.resolveHref),
[hostNavigation.resolveHref],
);
const handleTocClick = useCallback((event: React.MouseEvent<HTMLAnchorElement>, id: string) => {
const root = markdownBodyRef.current;
const target = root?.ownerDocument.getElementById(id);
if (!(target instanceof HTMLElement)) return;
if (root && !root.contains(target)) return;
event.preventDefault();
setActiveTocId(id);
target.scrollIntoView({ block: "start", behavior: "smooth" });
if (typeof window !== "undefined") {
window.history.replaceState(window.history.state, "", `${window.location.pathname}${window.location.search}#${id}`);
}
}, []);
const savePageContents = useCallback(async (nextContents: string) => {
if (!context.companyId || !content.data || !editable || !path) return;
const result = await writePage({
companyId: context.companyId,
wikiId: content.data.wikiId,
spaceSlug: spaceSlug ?? null,
path,
contents: nextContents,
expectedHash: savedHash ?? content.data.hash,
summary: `Edited ${path} from the LLM Wiki page`,
}) as { hash?: string };
if (typeof result.hash === "string") setSavedHash(result.hash);
}, [context.companyId, content.data, editable, path, savedHash, writePage, spaceSlug]);
if (!path) return <div style={{ padding: isMobile ? 16 : 28, color: tokens.muted, fontSize: 13 }}>Pick a page from the tree.</div>;
if (content.loading) return <div style={{ padding: isMobile ? 16 : 28, color: tokens.muted, fontSize: 13 }}>Loading {path}…</div>;
if (content.error && path.startsWith("raw/")) {
return (
<div style={{ padding: isMobile ? 16 : 28, display: "grid", gap: 12 }}>
<Callout tone="warn">
The captured source <Mono>{path}</Mono> is indexed but no longer exists in the configured wiki folder. Refresh the wiki or re-ingest the source to restore it.
</Callout>
<Tiny>{content.error.message}</Tiny>
</div>
);
}
if (content.error) return <div style={{ padding: isMobile ? 16 : 28 }}><Callout tone="danger">Failed to read {path}: {content.error.message}</Callout></div>;
if (!content.data) return <div style={{ padding: isMobile ? 16 : 28, color: tokens.muted, fontSize: 13 }}>No content for {path}.</div>;
const { contents, title, sourceRefs, updatedAt, hash } = content.data;
const visibleFrontmatter = parsedMarkdown.frontmatter.filter((property) => property.key.toLowerCase() !== "title");
const displayTitle = (BASELINE_FILES as readonly string[]).includes(path) ? basename(path) : title ?? basename(path);
const folderPath = dirname(path);
const isDistilledProjectPage = path.startsWith("wiki/projects/");
const showToc = !editing && tocHeadings.length > 0;
const displaySourceRefs = sourceRefs
.map((ref, index) => ({
id: sourceRefIdentity(ref, index),
label: formatSourceRef(ref, index),
hasDisplayText: typeof ref === "string" || readSourceRefField(ref, "title") !== null,
}))
.filter((ref) => ref.hasDisplayText);
return (
<article style={{ padding: isMobile ? "16px" : "24px 28px", display: "grid", gap: isMobile ? 12 : 14, minWidth: 0 }}>
<header style={{ display: "grid", gap: 8, minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "flex-start", gap: 12, flexWrap: "wrap", minWidth: 0 }}>
<div style={{ flex: "1 1 260px", minWidth: 0 }}>
{folderPath ? <Tiny style={{ display: "block", marginBottom: 6 }}><Mono>{folderPath}</Mono></Tiny> : null}
<h1 style={{ margin: 0, fontSize: isMobile ? 20 : 22, overflowWrap: "anywhere" }}>{displayTitle}</h1>
</div>
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
{isDistilledProjectPage ? (
<Button size="sm" variant="ghost" onClick={() => setProvenanceOpen(true)} title="Show source provenance and freshness">
<InfoIcon size={12} /> Provenance
</Button>
) : null}
{editable && !editing ? (
<Button size="sm" onClick={() => { setSavedHash(hash); setEditing(true); }}>Edit page</Button>
) : editable ? (
<Button size="sm" variant="ghost" onClick={() => { setEditing(false); content.refresh(); }}>Done</Button>
) : (
<Badge>read-only source</Badge>
)}
</div>
</div>
<Tiny>Updated {updatedAt ? formatTime(updatedAt) : "—"}</Tiny>
</header>
{isDistilledProjectPage && path ? (
<FreshnessChip companyId={context.companyId} pagePath={path} companyPrefix={(context as { companyPrefix?: string | null }).companyPrefix ?? null} />
) : null}
{editing ? (
<>
<AutosaveMarkdownEditor
key={`${path}:${hash}`}
resetKey={`${path}:${hash}`}
value={contents}
placeholder={`Edit ${path}`}
minHeight={isMobile ? 260 : 420}
onSave={async (nextContents) => {
await savePageContents(nextContents);
toast({ tone: "success", title: `${path} saved` });
}}
/>
</>
) : (
<div
data-testid="llm-wiki-page-content-layout"
style={{
display: "grid",
gridTemplateColumns: showToc && !isMobile ? `minmax(0, 1fr) ${tocOpen ? "minmax(180px, 240px)" : "36px"}` : "minmax(0, 1fr)",
gap: showToc && !isMobile ? (tocOpen ? 24 : 10) : 0,
alignItems: "start",
minWidth: 0,
}}
>
<div style={{ display: "grid", gap: isMobile ? 12 : 14, minWidth: 0 }}>
<div ref={markdownBodyRef} style={{ minWidth: 0, fontSize: 13, lineHeight: 1.65 }}>
<FrontmatterProperties properties={visibleFrontmatter} />
<MarkdownBlock
content={parsedMarkdown.body}
enableWikiLinks
resolveWikiLinkHref={resolveWikiLinkHref}
/>
{displaySourceRefs.length > 0 ? (
<section aria-label="Paperclip source refs" style={{ marginTop: 16, display: "grid", gap: 6 }}>
<Tiny style={{ fontWeight: 650 }}>Paperclip source refs</Tiny>
<ul style={{ margin: 0, paddingLeft: 18, color: tokens.muted, fontSize: 12, lineHeight: 1.5 }}>
{displaySourceRefs.map((ref) => (
<li key={ref.id}>{ref.label}</li>
))}
</ul>
</section>
) : null}
</div>
</div>
{showToc ? (
<OnThisPagePane
headings={tocHeadings}
activeHeadingId={activeTocId}
open={tocOpen}
onToggle={() => setTocOpen((current) => !current)}
onHeadingClick={handleTocClick}
mobile={isMobile}
/>
) : null}
</div>
)}
{provenanceOpen && path ? (
<ProvenanceDrawer
companyId={context.companyId}
pagePath={path}
onClose={() => setProvenanceOpen(false)}
/>
) : null}
</article>
);
}
function findScrollableAncestor(element: HTMLElement): HTMLElement | Window {
let current: HTMLElement | null = element.parentElement;
while (current && current !== document.body && current !== document.documentElement) {
const style = window.getComputedStyle(current);
const overflowY = style.overflowY;
if ((overflowY === "auto" || overflowY === "scroll") && current.scrollHeight > current.clientHeight) {
return current;
}
current = current.parentElement;
}
return window;
}
function OnThisPagePane({
headings,
activeHeadingId,
open,
onToggle,
onHeadingClick,
mobile,
}: {
headings: WikiTocHeading[];
activeHeadingId: string | null;
open: boolean;
onToggle: () => void;
onHeadingClick: (event: React.MouseEvent<HTMLAnchorElement>, id: string) => void;
mobile: boolean;
}) {
const contentId = "llm-wiki-on-this-page";
const currentHeadingId = activeHeadingId ?? headings[0]?.id ?? null;
const shellRef = useRef<HTMLElement | null>(null);
const [fixedFrame, setFixedFrame] = useState<{ left: number; top: number; width: number } | null>(null);
useEffect(() => {
if (mobile) {
setFixedFrame(null);
return;
}
const shell = shellRef.current;
if (!shell) return;
let animationFrame = 0;
const updateFrame = () => {
cancelAnimationFrame(animationFrame);
animationFrame = requestAnimationFrame(() => {
const rect = shell.getBoundingClientRect();
const top = Math.max(WIKI_TOC_STICKY_TOP, rect.top);
setFixedFrame((current) => {
if (
current &&
Math.abs(current.left - rect.left) < 0.5 &&
Math.abs(current.top - top) < 0.5 &&
Math.abs(current.width - rect.width) < 0.5
) {
return current;
}
return { left: rect.left, top, width: rect.width };
});
});
};
updateFrame();
const resizeObserver = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(updateFrame);
resizeObserver?.observe(shell);
window.addEventListener("resize", updateFrame);
window.addEventListener("scroll", updateFrame, true);
return () => {
cancelAnimationFrame(animationFrame);
resizeObserver?.disconnect();
window.removeEventListener("resize", updateFrame);
window.removeEventListener("scroll", updateFrame, true);
};
}, [mobile, open]);
const paneStyle: CSSProperties = mobile ? {} : fixedFrame ? {
position: "fixed",
top: fixedFrame.top,
left: fixedFrame.left,
width: fixedFrame.width,
maxHeight: `calc(100vh - ${fixedFrame.top + 16}px)`,
overflowY: "auto",
zIndex: 2,
} : {
position: "sticky",
top: WIKI_TOC_STICKY_TOP,
};
return (
<aside
ref={shellRef}
aria-label="On this page"
style={{
order: mobile ? -1 : 0,
minWidth: 0,
minHeight: mobile ? undefined : open ? 120 : 24,
alignSelf: "start",
}}
>
<div style={{
...paneStyle,
borderLeft: open ? `1px solid ${tokens.border}` : 0,
paddingLeft: open ? 10 : 0,
}}>
<button
type="button"
aria-label={open ? "Collapse on this page" : "Expand on this page"}
aria-expanded={open}
aria-controls={contentId}
onClick={onToggle}
style={{
display: "flex",
alignItems: "center",
justifyContent: open ? "space-between" : "center",
gap: 10,
width: "100%",
border: 0,
background: "transparent",
color: tokens.fg,
padding: open ? "0 0 8px" : "0",
fontFamily: fontStack,
fontSize: 12,
fontWeight: 650,
cursor: "pointer",
textAlign: "left",
}}
>
{open ? <span>On this page</span> : null}
<span aria-hidden="true" style={{ color: tokens.muted, transform: open ? "rotate(90deg)" : "rotate(0deg)", transition: "transform 120ms ease" }}>
<ChevronLeftIcon size={13} />
</span>
</button>
{open ? (
<nav id={contentId} style={{ display: "grid", gap: 2 }}>
{headings.map((heading) => {
const active = heading.id === currentHeadingId;
return (
<a
key={heading.id}
href={`#${heading.id}`}
aria-current={active ? "location" : undefined}
onClick={(event) => onHeadingClick(event, heading.id)}
style={{
display: "block",
padding: `3px 0 3px ${Math.max(0, heading.level - 2) * 12}px`,
color: active ? tokens.fg : tokens.muted,
fontSize: 12,
fontWeight: active ? 700 : 450,
lineHeight: 1.35,
textDecoration: "none",
overflowWrap: "anywhere",
}}
>
{heading.text}
</a>
);
})}
</nav>
) : null}
</div>
</aside>
);
}
function FreshnessChip({ companyId, pagePath, companyPrefix }: { companyId: string | null; pagePath: string; companyPrefix: string | null }) {
const provenance = useDistillationProvenance(companyId, pagePath);
const binding = provenance.data?.binding ?? null;
const cursor = provenance.data?.cursor ?? null;
if (provenance.loading && !provenance.data) {
return <FreshnessChipShell tone="info" icon={<ClockIcon size={14} />}>Checking distillation cursor…</FreshnessChipShell>;
}
if (!binding) return null;
const lastEnd = binding.lastRunSourceWindowEnd ?? binding.lastRunCompletedAt;
const status = binding.lastRunStatus ?? "unknown";
const sourceCount = (() => {
const meta = binding.metadata as Record<string, unknown>;
const refs = Array.isArray(meta.sourceRefs) ? meta.sourceRefs.length : null;
return refs;
})();
const isStale = (() => {
if (status === "failed" || status === "refused_cost_cap") return true;
if (!lastEnd) return true;
const diff = Date.now() - Date.parse(lastEnd);
return Number.isFinite(diff) ? diff > 72 * 60 * 60 * 1000 : true;
})();
const isFailed = status === "failed" || status === "refused_cost_cap";
const isRunning = status === "running";
const tone: "info" | "warn" | "danger" | "running" = isFailed ? "danger" : isStale ? "warn" : isRunning ? "running" : "info";
const projectLink = cursor && cursor.projectId && companyPrefix ? `/${companyPrefix}/projects/${cursor.projectId}` : null;
return (
<FreshnessChipShell
tone={tone}
icon={isFailed ? <AlertTriangleIcon size={14} /> : isRunning ? <ActivityIcon size={14} /> : <ClockIcon size={14} />}
>
<span><strong>Current as of {lastEnd ? formatTimestamp(lastEnd) : "—"}.</strong>
{sourceCount ? ` ${sourceCount} sources in this window. ` : " "}
Cursor at <Mono>{lastEnd ?? "—"}</Mono>.
</span>
{projectLink ? (
<a href={projectLink} style={{ marginLeft: 8, color: "inherit", textDecoration: "underline" }}>
Open Paperclip for live state →
</a>
) : null}
</FreshnessChipShell>
);
}
function FreshnessChipShell({ tone, icon, children }: { tone: "info" | "warn" | "danger" | "running"; icon: ReactNode; children: ReactNode }) {
const palette = tone === "danger"
? { bg: "oklch(0.22 0.06 25)", fg: "oklch(0.85 0.12 25)", border: "oklch(0.45 0.12 25)" }
: tone === "warn"
? { bg: "oklch(0.22 0.06 70)", fg: "oklch(0.85 0.1 70)", border: "oklch(0.45 0.12 70)" }
: tone === "running"
? { bg: "oklch(0.22 0.06 200)", fg: "oklch(0.85 0.11 200)", border: "oklch(0.45 0.12 200)" }
: tokens.callout;
return (
<div style={{
display: "inline-flex",
alignItems: "center",
gap: 8,
padding: "8px 12px",
borderRadius: 999,
background: palette.bg,
color: palette.fg,
border: `1px solid ${palette.border}`,
fontSize: 12.5,
lineHeight: 1.5,
flexWrap: "wrap",
}}>
{icon}
<div style={{ display: "flex", alignItems: "center", gap: 4, flexWrap: "wrap" }}>
{children}
</div>
</div>
);
}
function readSourceRefField(ref: Record<string, unknown>, field: string): string | null {
const value = ref[field];
return typeof value === "string" && value.trim().length > 0 ? value : null;
}
function sourceRefIdentity(ref: Record<string, unknown> | string, fallbackIndex: number): string {
if (typeof ref === "string") return ref;
const issueIdentifier = readSourceRefField(ref, "issueIdentifier");
const issueId = readSourceRefField(ref, "issueId");
const commentId = readSourceRefField(ref, "commentId");
const documentKey = readSourceRefField(ref, "documentKey");
const documentId = readSourceRefField(ref, "documentId");
const kind = readSourceRefField(ref, "kind");
const parts = [kind, issueIdentifier ?? issueId, commentId, documentKey ?? documentId].filter(Boolean);
return parts.length > 0 ? parts.join(":") : `source-ref-${fallbackIndex}`;
}
function formatSourceRef(ref: Record<string, unknown> | string, fallbackIndex: number): string {
if (typeof ref === "string") return ref;
const kind = readSourceRefField(ref, "kind");
const issue = readSourceRefField(ref, "issueIdentifier") ?? readSourceRefField(ref, "issueId");
const title = readSourceRefField(ref, "title");
const commentId = readSourceRefField(ref, "commentId");
const documentKey = readSourceRefField(ref, "documentKey");
const documentId = readSourceRefField(ref, "documentId");
const primary = issue ?? sourceRefIdentity(ref, fallbackIndex);
const suffix = kind === "comment" && commentId
? ` comment ${commentId.slice(0, 8)}`
: kind === "document" && (documentKey || documentId)
? ` document ${documentKey ?? documentId?.slice(0, 8)}`
: kind ? ` ${kind}` : "";
return title ? `${primary}${suffix} - ${title}` : `${primary}${suffix}`;
}
function ProvenanceDrawer({ companyId, pagePath, onClose }: { companyId: string | null; pagePath: string; onClose: () => void }) {
const isMobile = useIsMobileLayout();
const provenance = useDistillationProvenance(companyId, pagePath);
const data = provenance.data;
const binding = data?.binding ?? null;
const cursor = data?.cursor ?? null;
const snapshot = data?.snapshot ?? null;
const runs = data?.runs ?? [];
return (
<div
onClick={onClose}
style={{
position: "fixed",
inset: 0,
background: "oklch(0 0 0 / 0.55)",
zIndex: 50,
display: "flex",
justifyContent: isMobile ? "stretch" : "flex-end",
alignItems: isMobile ? "flex-end" : "stretch",
}}
>
<div
onClick={(event) => event.stopPropagation()}
style={{
background: tokens.bg,
borderLeft: isMobile ? undefined : `1px solid ${tokens.border}`,
borderTop: isMobile ? `1px solid ${tokens.border}` : undefined,
width: isMobile ? "100%" : 420,
maxWidth: "100%",
maxHeight: isMobile ? "85vh" : "100vh",
display: "flex",
flexDirection: "column",
}}
>
<div style={{ padding: "14px 18px", borderBottom: `1px solid ${tokens.border}`, display: "flex", alignItems: "center", gap: 10 }}>
<InfoIcon size={16} />
<strong style={{ fontSize: 14, flex: 1 }}>Provenance</strong>
<Button variant="ghost" size="sm" onClick={onClose}>
<XIcon size={14} />
</Button>
</div>
<div style={{ overflow: "auto", flex: 1, padding: 18, display: "flex", flexDirection: "column", gap: 14 }}>
{provenance.loading && !data ? <Tiny>Loading provenance…</Tiny> : null}
{!binding && !provenance.loading ? <Callout>This page is not currently bound to a distillation cursor. It may be hand-authored or pre-distillation.</Callout> : null}
{binding ? (
<Card>
<CardBody padding={14}>
<PropRow label="Page path" value={<Mono>{binding.pagePath}</Mono>} />
<PropRow label="Source hash" value={<Mono>{binding.lastRunSourceHash?.slice(0, 16) ?? "—"}…</Mono>} />
<PropRow label="Cursor end" value={<Mono>{binding.lastRunSourceWindowEnd ? formatTimestamp(binding.lastRunSourceWindowEnd) : "—"}</Mono>} />
<PropRow label="Last run status" value={<Badge tone={runStatusTone(binding.lastRunStatus ?? "")}>{binding.lastRunStatus ?? "—"}</Badge>} />
<PropRow label="Project" value={binding.projectName ?? "—"} />
<PropRow label="Updated" value={formatTimestamp(binding.updatedAt)} />
</CardBody>
</Card>
) : null}
{cursor ? (
<Card>
<CardHeader title="Cursor" />
<CardBody padding={14}>
<PropRow label="Scope" value={cursor.sourceScope} />
<PropRow label="Pending events" value={String(cursor.pendingEventCount)} />
<PropRow label="Last observed" value={formatTimestamp(cursor.lastObservedAt)} />
<PropRow label="Last processed" value={formatTimestamp(cursor.lastProcessedAt)} />
</CardBody>
</Card>
) : null}
{snapshot ? (
<Card>
<CardHeader title="Sources in this window" />
<CardBody padding={14}>
<Tiny style={{ marginBottom: 8 }}>{snapshot.sourceRefs.length} ref{snapshot.sourceRefs.length === 1 ? "" : "s"}{snapshot.clipped ? " · clipped" : ""}</Tiny>
<ul style={{ margin: 0, paddingLeft: 18, fontSize: 12, color: tokens.muted, lineHeight: 1.5 }}>
{snapshot.sourceRefs.slice(0, 8).map((ref, index) => {
const obj = typeof ref === "object" && ref ? ref as Record<string, unknown> : null;
const id = obj && typeof obj.id === "string" ? obj.id : typeof ref === "string" ? ref : `ref-${index}`;
const kind = obj && typeof obj.kind === "string" ? obj.kind : null;
const title = obj && typeof obj.title === "string" ? obj.title : null;
return (
<li key={`${id}-${index}`} style={{ marginBottom: 4 }}>
<Mono>{id}</Mono>
{kind ? ` · ${kind}` : ""}
{title ? ` · ${title}` : ""}
</li>
);
})}
</ul>
{snapshot.sourceRefs.length > 8 ? <Tiny style={{ marginTop: 6 }}>{`+${snapshot.sourceRefs.length - 8} more`}</Tiny> : null}
</CardBody>
</Card>
) : null}
{runs.length > 0 ? (
<Card>
<CardHeader title="Operations affecting this page" />
<CardBody padding={0}>
<ul style={{ margin: 0, padding: 0, listStyle: "none" }}>
{runs.slice(0, 8).map((run) => (
<li key={run.id} style={{ padding: "10px 14px", borderBottom: `1px solid ${tokens.border}`, display: "flex", justifyContent: "space-between", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
<Mono style={{ fontSize: 12 }}>{run.operationIssueIdentifier ?? `op-${run.id.slice(0, 6)}`}</Mono>
<div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
<Tiny>{formatTimestamp(run.updatedAt)}</Tiny>
<Mono style={{ fontSize: 11 }}>{formatCostCents(run.costCents)}</Mono>
<Badge tone={runStatusTone(run.status)}>{runStatusLabel(run.status)}</Badge>
</div>
</li>
))}
</ul>
</CardBody>
</Card>
) : null}
</div>
</div>
</div>
);
}
function FrontmatterProperties({ properties }: { properties: WikiFrontmatterProperty[] }) {
if (properties.length === 0) return null;
return (
<details
open
style={{
marginBottom: 20,
paddingBottom: 16,
borderBottom: `1px solid ${tokens.border}`,
}}
>
<summary
style={{
cursor: "pointer",
color: tokens.fg,
fontSize: 13,
fontWeight: 650,
listStylePosition: "outside",
marginBottom: 10,
}}
>
Properties
</summary>
<dl style={{ display: "grid", gap: 8, margin: 0, maxWidth: 720 }}>
{properties.map((property) => (
<div
key={property.key}
style={{
display: "grid",
gridTemplateColumns: "minmax(96px, 0.32fr) minmax(0, 1fr)",
gap: 12,
alignItems: "baseline",
minWidth: 0,
}}
>
<dt style={{ color: tokens.muted, fontSize: 12, minWidth: 0, overflowWrap: "anywhere" }}>{property.key}</dt>
<dd style={{ margin: 0, minWidth: 0 }}>
<FrontmatterValue value={property.value} />
</dd>
</div>
))}
</dl>
</details>
);
}
function FrontmatterValue({ value }: { value: WikiFrontmatterValue }) {
if (Array.isArray(value)) {
return (
<span style={{ display: "flex", flexWrap: "wrap", gap: 6, minWidth: 0 }}>
{value.map((item) => (
<span
key={item}
style={{
display: "inline-flex",
alignItems: "center",
minWidth: 0,
maxWidth: "100%",
padding: "1px 7px",
borderRadius: 999,
background: "var(--secondary, oklch(0.269 0 0))",
color: tokens.fg,
fontSize: 12,
lineHeight: 1.5,
overflowWrap: "anywhere",
}}
>
{item}
</span>
))}
</span>
);
}
return (
<span style={{ color: tokens.fg, fontSize: 13, overflowWrap: "anywhere" }}>
{value}
</span>
);
}
function Row({ primary, secondary, right }: { primary: ReactNode; secondary?: ReactNode; right?: ReactNode }) {
return (
<div style={{
display: "flex",
alignItems: "center",
flexWrap: "wrap",
gap: 12,
padding: "8px 12px",
borderBottom: `1px solid ${tokens.border}`,
fontSize: 13,
minWidth: 0,
}}>
<div style={{ flex: "1 1 220px", minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "normal", overflowWrap: "anywhere" }}>{primary}</div>
{secondary ? <div style={{ flex: "0 1 auto", minWidth: 0, color: tokens.muted, fontSize: 12, overflowWrap: "anywhere" }}>{secondary}</div> : null}
{right ? <div style={{ marginLeft: "auto", minWidth: 0, display: "flex", gap: 6, alignItems: "center", justifyContent: "flex-end", flexWrap: "wrap" }}>{right}</div> : null}
</div>
);
}
function formatTime(iso: string | null | undefined): string {
if (!iso) return "—";
try {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return iso;
const diffMs = Date.now() - date.getTime();
const minutes = Math.floor(diffMs / 60000);
if (minutes < 1) return "just now";
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
return date.toLocaleDateString();
} catch {
return iso;
}
}
// ---------------------------------------------------------------------------
// Ingest tab.
// ---------------------------------------------------------------------------
function IngestTab({ context, refreshOverview }: { context: { companyId: string | null }; refreshOverview: () => void }) {
const { pathname } = useHostLocation();
const activeSpaceSlug = useMemo(() => readActiveSpaceSlugFromLocation(pathname), [pathname]);
const spacesQuery = useSpaces(context.companyId);
const spaces = useMemo(() => {
const list = spacesQuery.data?.spaces ?? [];
return activeWikiSpaces(list).sort(compareSpaces);
}, [spacesQuery.data]);
const ingest = usePluginAction("ingest-source");
const toast = usePluginToast();
const hostNavigation = useHostNavigation();
const isMobile = useIsMobileLayout();
const [url, setUrl] = useState("");
const [pasted, setPasted] = useState("");
const [title, setTitle] = useState("");
const [busy, setBusy] = useState(false);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [spaceMenuOpen, setSpaceMenuOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const activeSpace = useMemo(() => {
return spaces.find((space) => space.slug === activeSpaceSlug)
?? spaces.find((space) => space.slug === DEFAULT_SPACE_SLUG)
?? null;
}, [spaces, activeSpaceSlug]);
const canSubmit = !!context.companyId && (pasted.trim().length > 0 || url.trim().length > 0) && !busy;
useEffect(() => {
const refresh = () => refreshOverview();
window.addEventListener("pc-wiki-ingest-queued", refresh);
return () => window.removeEventListener("pc-wiki-ingest-queued", refresh);
}, [refreshOverview]);
async function submit() {
if (!context.companyId) return;
setBusy(true);
setErrorMsg(null);
try {
let contents = pasted.trim();
let sourceType: "url" | "text" = "text";
let resolvedTitle = title.trim();
if (url.trim()) {
sourceType = "url";
contents = pasted.trim() || `Captured URL: ${url.trim()}\n\n_Plugin needs to fetch the URL — placeholder body for the alpha._`;
resolvedTitle = resolvedTitle || url.trim();
} else {
resolvedTitle = resolvedTitle || pasted.split("\n", 1)[0]?.slice(0, 80) || "Pasted source";
}
await ingest({
companyId: context.companyId,
spaceSlug: activeSpaceSlug,
sourceType,
url: url.trim() || null,
title: resolvedTitle,
contents,
});
const spaceLabel = activeSpace?.displayName ?? activeSpaceSlug;
toast({ tone: "success", title: `Source captured into ${spaceLabel}`, body: `Operation issue created. Check History to inspect.` });
setUrl("");
setPasted("");
setTitle("");
refreshOverview();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setErrorMsg(message);
toast({ tone: "error", title: "Ingest failed", body: message });
} finally {
setBusy(false);
}
}
const spaceLabel = activeSpace?.displayName ?? activeSpaceSlug;
return (
<div style={{ flex: 1, minHeight: isMobile ? "auto" : 0, minWidth: 0, overflow: isMobile ? "visible" : "auto" }}>
<div style={{
padding: isMobile ? "16px" : "24px 28px",
maxWidth: 920,
minWidth: 0,
}}>
<div style={{ marginBottom: 4, fontSize: 11, fontWeight: 600, letterSpacing: "0.04em", textTransform: "uppercase", color: tokens.muted }}>Add Content</div>
<h2 style={{ margin: "0 0 6px", fontSize: 18, fontWeight: 650 }}>Capture into <span style={{ color: tokens.fg }}>{spaceLabel}</span></h2>
<Tiny style={{ marginBottom: 18 }}>
Each capture queues an ingest operation scoped to <Mono>{activeSpaceSlug}</Mono>. Files land in that space's <Mono>raw/</Mono> folder and the Wiki Maintainer proposes a patch.
</Tiny>
<div style={{ display: "grid", gap: 14, marginBottom: 18 }}>
<SpacePicker
spaces={spaces}
activeSpaceSlug={activeSpaceSlug}
loading={spacesQuery.loading}
error={spacesQuery.error?.message ?? null}
isOpen={spaceMenuOpen}
onToggle={() => setSpaceMenuOpen((v) => !v)}
onClose={() => setSpaceMenuOpen(false)}
onSelect={(slug) => {
setSpaceMenuOpen(false);
hostNavigation.navigate(buildSectionHref("ingest", slug));
}}
onCreate={() => {
setSpaceMenuOpen(false);
setCreateOpen(true);
}}
/>
<div>
<label style={{ fontSize: 11, color: tokens.muted, display: "block", marginBottom: 6 }}>Drop files anywhere on this page</label>
<div style={{
minHeight: isMobile ? 180 : 230,
border: `1.5px dashed ${tokens.pluginBorder}`,
borderRadius: 8,
padding: isMobile ? 20 : 30,
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
gap: 10,
textAlign: "center",
color: tokens.muted,
background: tokens.pluginBg,
}}>
<div style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 46, height: 46, borderRadius: 8, background: tokens.card, color: tokens.pluginFg, border: `1px solid ${tokens.pluginBorder}` }}>
<DownloadCloudIcon size={24} />
</div>
<div style={{ fontSize: 16, fontWeight: 650, color: tokens.fg }}>Drop source files here</div>
<Tiny>Review staged files before queueing maintainer tasks.</Tiny>
</div>
</div>
<div data-testid="llm-wiki-ingest-manual-separator" aria-hidden="true" style={{ display: "flex", alignItems: "center", gap: 12, color: tokens.muted, fontSize: 11, fontWeight: 650, textTransform: "uppercase", letterSpacing: "0.04em" }}>
<span style={{ height: 1, flex: 1, background: tokens.border }} />
<span>or</span>
<span style={{ height: 1, flex: 1, background: tokens.border }} />
</div>
<div>
<label style={{ fontSize: 11, color: tokens.muted, display: "block", marginBottom: 4 }}>Source title (optional)</label>
<TextInput value={title} onChange={(e) => setTitle(e.target.value)} placeholder="e.g. Karpathy LLM Wiki gist" />
</div>
<div>
<label style={{ fontSize: 11, color: tokens.muted, display: "block", marginBottom: 4 }}>URL</label>
<TextInput value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://example.com/article" />
</div>
<div>
<label style={{ fontSize: 11, color: tokens.muted, display: "block", marginBottom: 4 }}>Paste markdown / text</label>
<TextArea value={pasted} onChange={(e) => setPasted(e.target.value)} placeholder="Paste source content…" rows={8} />
</div>
</div>
<div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
<Button variant="primary" onClick={submit} disabled={!canSubmit} loading={busy}>+ Capture & ingest</Button>
</div>
{errorMsg ? <div style={{ marginTop: 14 }}><Callout tone="danger">{errorMsg}</Callout></div> : null}
</div>
{createOpen && context.companyId ? (
<CreateSpaceModal
companyId={context.companyId}
existingSlugs={new Set(spaces.map((s) => s.slug))}
onClose={() => setCreateOpen(false)}
onCreated={(space) => {
setCreateOpen(false);
spacesQuery.refresh();
hostNavigation.navigate(buildSectionHref("ingest", space.slug));
}}
/>
) : null}
</div>
);
}
function SpacePicker({
spaces,
activeSpaceSlug,
loading,
error,
isOpen,
onToggle,
onClose,
onSelect,
onCreate,
}: {
spaces: WikiSpace[];
activeSpaceSlug: string;
loading: boolean;
error: string | null;
isOpen: boolean;
onToggle: () => void;
onClose: () => void;
onSelect: (slug: string) => void;
onCreate: () => void;
}) {
const ref = useRef<HTMLDivElement>(null);
const active = spaces.find((s) => s.slug === activeSpaceSlug);
useEffect(() => {
if (!isOpen) return;
const handler = (event: MouseEvent) => {
if (!ref.current) return;
if (event.target instanceof Node && ref.current.contains(event.target)) return;
onClose();
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [isOpen, onClose]);
return (
<div ref={ref} style={{ position: "relative" }}>
<label style={{ fontSize: 11, color: tokens.muted, display: "block", marginBottom: 4 }}>Space</label>
<button
type="button"
onClick={onToggle}
aria-haspopup="listbox"
aria-expanded={isOpen}
style={{
width: "100%",
display: "flex",
alignItems: "center",
gap: 8,
background: "oklch(0.2 0 0)",
border: `1px solid ${tokens.border}`,
borderRadius: 6,
padding: "8px 10px",
color: tokens.fg,
fontFamily: fontStack,
fontSize: 13,
cursor: "pointer",
textAlign: "left",
}}
>
<FolderIcon size={14} />
<span style={{ flex: 1, fontWeight: 600 }}>{active?.displayName ?? activeSpaceSlug}</span>
{active ? (
<Badge tone="default" style={{ fontSize: 10, padding: "0 6px" }}>{active.accessScope}</Badge>
) : null}
<ChevronDownIcon size={14} />
</button>
<div style={{ marginTop: 6, fontSize: 11, color: tokens.muted, lineHeight: 1.4 }}>
Defaults to the space you opened. Switching now re-routes the page so deep links carry the destination.
</div>
{isOpen ? (
<div
role="listbox"
style={{
position: "absolute",
top: "calc(100% + 4px)",
left: 0,
right: 0,
zIndex: 25,
background: tokens.card,
border: `1px solid ${tokens.border}`,
borderRadius: 8,
boxShadow: "0 16px 40px rgba(0,0,0,0.45)",
padding: 4,
maxHeight: 320,
overflowY: "auto",
}}
>
{error ? <div style={{ padding: 10, fontSize: 12, color: tokens.statusBlocked }}>{error}</div> : null}
{loading ? <div style={{ padding: 10 }}><Tiny>Loading spaces</Tiny></div> : null}
{spaces.map((space) => (
<button
key={space.slug}
type="button"
role="option"
aria-selected={space.slug === activeSpaceSlug}
onClick={() => onSelect(space.slug)}
style={{
width: "100%",
display: "flex",
alignItems: "center",
gap: 8,
padding: "8px 10px",
background: space.slug === activeSpaceSlug ? tokens.accent : "transparent",
border: "none",
borderRadius: 6,
color: tokens.fg,
fontSize: 13,
fontFamily: fontStack,
cursor: "pointer",
textAlign: "left",
}}
>
<FolderIcon size={14} />
<span style={{ flex: 1, fontWeight: 600 }}>{space.displayName}</span>
<span style={{ fontSize: 10, color: tokens.muted, fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace" }}>{space.slug}</span>
</button>
))}
<SpaceMenuDivider />
<button
type="button"
onClick={onCreate}
style={{
width: "100%",
display: "flex",
alignItems: "center",
gap: 8,
padding: "8px 10px",
background: "transparent",
border: "none",
borderRadius: 6,
color: "oklch(0.78 0.13 250)",
fontSize: 13,
fontFamily: fontStack,
cursor: "pointer",
textAlign: "left",
fontWeight: 600,
}}
>
<PlusIcon size={14} />
<span>New shared space</span>
</button>
</div>
) : null}
</div>
);
}
function OperationCard({ op }: { op: WikiOperationRow }) {
return (
<Card>
<div style={{ padding: "12px 14px" }}>
<div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap", minWidth: 0 }}>
<StatusIcon status={op.status} />
<strong style={{ flex: "1 1 180px", minWidth: 0, fontSize: 13, overflowWrap: "anywhere" }}>{op.hiddenIssueTitle ?? `LLM Wiki ${op.operationType}`}</strong>
<Badge tone={statusTone(op.status)} style={{ marginLeft: "auto" }}>{op.status}</Badge>
</div>
<Tiny style={{ marginTop: 4 }}>
{op.operationType.toUpperCase()} · started {formatTime(op.createdAt)} · op-{op.id.slice(0, 6)}
{op.hiddenIssueIdentifier ? <> · <Mono>{op.hiddenIssueIdentifier}</Mono></> : null}
</Tiny>
{Array.isArray(op.warnings) && op.warnings.length > 0 ? (
<Tiny style={{ marginTop: 4, color: tokens.statusBlocked }}>
{op.warnings.length} warning{op.warnings.length === 1 ? "" : "s"}
</Tiny>
) : null}
</div>
</Card>
);
}
function statusTone(status: string): Tone {
if (status === "done") return "done";
if (status === "running" || status === "in_progress") return "running";
if (status === "blocked" || status === "failed") return "failed";
if (status === "queued" || status === "todo") return "todo";
if (status === "paused") return "paused";
return "default";
}
// ---------------------------------------------------------------------------
// Ask tab.
// ---------------------------------------------------------------------------
type QueryThreadEntry = {
id: string;
prompt: string;
operationId: string | null;
querySessionId: string | null;
hiddenIssueIdentifier: string | null;
channel: string | null;
status: "queued" | "running" | "done" | "error";
createdAt: string;
answer: string;
errorMessage?: string;
};
type QueryStreamEvent = {
type: string;
operationId?: string;
querySessionId?: string;
message?: string | null;
payload?: Record<string, unknown> | null;
eventType?: string;
stream?: string | null;
answer?: string;
};
function QueryTab({ context, overview }: { context: { companyId: string | null }; overview: OverviewData }) {
const startQuery = usePluginAction("start-query");
const fileAsPage = usePluginAction("file-as-page");
const toast = usePluginToast();
const { pathname } = useHostLocation();
const activeSpaceSlug = useMemo(() => readActiveSpaceSlugFromLocation(pathname), [pathname]);
const isMobile = useIsMobileLayout();
const [thread, setThread] = useState<QueryThreadEntry[]>([]);
const [prompt, setPrompt] = useState("");
const [busy, setBusy] = useState(false);
const [filePath, setFilePath] = useState("wiki/concepts/new-page.md");
const [fileBody, setFileBody] = useState("");
const [filing, setFiling] = useState<string | null>(null);
const fileSource = useMemo(() => {
for (let i = thread.length - 1; i >= 0; i -= 1) {
const entry = thread[i];
if (entry.answer.trim()) return entry;
}
return null;
}, [thread]);
const activeEntry = useMemo(() => {
for (let i = thread.length - 1; i >= 0; i -= 1) {
const entry = thread[i];
if (entry.status === "running" || entry.status === "queued") return entry;
}
return null;
}, [thread]);
const stream = usePluginStream<QueryStreamEvent>(activeEntry?.channel ?? "llm-wiki:idle", {
companyId: context.companyId ?? undefined,
});
useEffect(() => {
if (!activeEntry || !stream.lastEvent) return;
const event = stream.lastEvent;
setThread((prev) => prev.map((entry) => {
if (entry.id !== activeEntry.id) return entry;
if (event.type === "agent.event" && event.eventType === "chunk" && event.message && event.stream !== "stderr") {
return { ...entry, answer: entry.answer + event.message, status: "running" };
}
if (event.type === "query.done") {
return { ...entry, status: "done", answer: event.answer ?? entry.answer };
}
if (event.type === "query.error") {
return { ...entry, status: "error", errorMessage: event.message ?? "agent session error" };
}
return entry;
}));
if (event.type === "query.done" && event.answer && !fileBody.trim()) {
setFileBody(event.answer);
}
}, [fileBody, stream.lastEvent, activeEntry?.id]);
async function send() {
if (!context.companyId || !prompt.trim()) return;
setBusy(true);
const entryId = `q-${Date.now()}`;
setThread((prev) => [...prev, {
id: entryId,
prompt: prompt.trim(),
operationId: null,
querySessionId: null,
hiddenIssueIdentifier: null,
channel: null,
status: "queued",
createdAt: new Date().toISOString(),
answer: "",
}]);
try {
const res = await startQuery({ companyId: context.companyId, spaceSlug: activeSpaceSlug, question: prompt.trim() });
const result = res as {
operationId: string;
querySessionId?: string;
channel?: string;
issue?: { identifier?: string | null };
};
setThread((prev) => prev.map((entry) =>
entry.id === entryId ? {
...entry,
operationId: result.operationId,
querySessionId: result.querySessionId ?? result.operationId,
hiddenIssueIdentifier: result.issue?.identifier ?? null,
channel: result.channel ?? `llm-wiki:query:${result.operationId}`,
status: "running",
} : entry,
));
setPrompt("");
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setThread((prev) => prev.map((entry) =>
entry.id === entryId ? { ...entry, status: "error", errorMessage: message } : entry,
));
toast({ tone: "error", title: "Ask failed", body: message });
} finally {
setBusy(false);
}
}
async function fileAnswer(entry?: QueryThreadEntry) {
const source = entry ?? fileSource;
const answer = fileBody.trim() || source?.answer.trim() || "";
if (!context.companyId || !filePath.trim() || !answer) return;
setFiling(source?.id ?? "manual");
try {
await fileAsPage({
companyId: context.companyId,
wikiId: overview.wikiId,
spaceSlug: activeSpaceSlug,
path: filePath.trim(),
question: source?.prompt,
answer,
querySessionId: source?.querySessionId,
});
toast({ tone: "success", title: "Answer filed", body: `Wrote ${filePath.trim()} and recorded a file-as-page task.` });
setFileBody("");
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
toast({ tone: "error", title: "Could not file answer", body: message });
} finally {
setFiling(null);
}
}
return (
<div style={{ display: "flex", flexDirection: isMobile ? "column" : "row", flex: 1, minHeight: isMobile ? "auto" : 0, minWidth: 0 }}>
<div style={{ flex: 1, padding: isMobile ? "16px" : "24px 28px", overflow: isMobile ? "visible" : "auto", minWidth: 0 }}>
{thread.length === 0 ? (
<Callout>
Ask the wiki anything. Each question initiates a task assigned to the Wiki Maintainer. The answer streams below; you can promote useful answers into a wiki page.
</Callout>
) : null}
<div style={{ display: "grid", gap: 22, marginTop: 18 }}>
{thread.map((entry) => (
<div key={entry.id}>
<Tiny style={{ marginBottom: 4 }}>You · {formatTime(entry.createdAt)}</Tiny>
<div style={{ background: tokens.card, border: `1px solid ${tokens.border}`, padding: "10px 12px", borderRadius: 8, fontSize: 13 }}>{entry.prompt}</div>
<Tiny style={{ marginTop: 8 }}>
Wiki Maintainer · {entry.status}
{entry.hiddenIssueIdentifier ? <> · <Mono>{entry.hiddenIssueIdentifier}</Mono></> : null}
</Tiny>
{entry.status === "error" ? (
<div style={{ marginTop: 6 }}><Callout tone="danger">{entry.errorMessage}</Callout></div>
) : (
<pre style={{
margin: "8px 0 0",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
fontFamily: "ui-sans-serif, system-ui, sans-serif",
fontSize: 13,
lineHeight: 1.65,
color: tokens.fg,
}}>{entry.answer || (entry.status === "running" ? "Streaming…" : "")}</pre>
)}
{entry.answer.trim() && entry.status === "done" ? (
<div style={{ marginTop: 10, border: `1px dashed ${tokens.border}`, borderRadius: 8, padding: "10px 12px" }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
<strong style={{ fontSize: 13 }}>📑 File this answer as a wiki page?</strong>
<Tiny style={{ marginLeft: isMobile ? 0 : "auto", width: isMobile ? "100%" : undefined }}>Path: <Mono>{filePath}</Mono></Tiny>
</div>
<div style={{ display: "flex", gap: 6, marginTop: 8, alignItems: "center", flexWrap: "wrap" }}>
<TextInput value={filePath} onChange={(e) => setFilePath(e.target.value)} style={{ maxWidth: isMobile ? "none" : 360 }} />
<Button size="sm" variant="primary" onClick={() => fileAnswer(entry)} disabled={!filePath.trim()} loading={filing === entry.id}>Accept &amp; file</Button>
</div>
</div>
) : null}
</div>
))}
</div>
<div style={{ borderTop: `1px solid ${tokens.border}`, paddingTop: 14, marginTop: 22 }}>
<TextArea value={prompt} onChange={(e) => setPrompt(e.target.value)} placeholder="Ask the wiki…" rows={3} />
<div style={{ display: "flex", gap: 6, marginTop: 8, alignItems: "center", flexWrap: "wrap" }}>
<Button variant="primary" size="sm" onClick={send} disabled={!prompt.trim()} loading={busy}>Send ()</Button>
<Badge>Cite: wiki + raw</Badge>
<Badge>Max steps: 6</Badge>
<Tiny style={{ marginLeft: "auto" }}>Streamed via agent session · maintainer task</Tiny>
</div>
</div>
</div>
<aside style={{
width: isMobile ? "auto" : 320,
borderLeft: isMobile ? "none" : `1px solid ${tokens.border}`,
borderTop: isMobile ? `1px solid ${tokens.border}` : "none",
padding: isMobile ? "16px" : "18px 20px",
overflow: isMobile ? "visible" : "auto",
minWidth: 0,
}}>
<Tiny style={{ marginBottom: 8 }}>SESSION</Tiny>
<PropRow label="Wiki" value={overview.wikiId} />
<PropRow label="Project" value={overview.managedProject.details?.name ?? overview.managedProject.status} />
<PropRow label="Agent" value={overview.managedAgent.details?.name ?? overview.managedAgent.status} />
<PropRow label="Operations" value={overview.operationCount} />
<PropRow label="Stream" value={stream.connected ? "live" : stream.connecting ? "connecting…" : "idle"} />
<Divider />
<Tiny style={{ marginBottom: 8 }}>ASK PROMPT</Tiny>
<pre style={{ margin: 0, whiteSpace: "pre-wrap", fontFamily: "ui-monospace, monospace", fontSize: 12, color: tokens.muted }}>{overview.prompts.query}</pre>
</aside>
</div>
);
}
// ---------------------------------------------------------------------------
// Lint tab.
// ---------------------------------------------------------------------------
function LintTab({ context, overview, refreshOverview }: { context: { companyId: string | null }; overview: OverviewData; refreshOverview: () => void }) {
const isMobile = useIsMobileLayout();
return (
<div style={{ flex: 1, minHeight: isMobile ? "auto" : 0, overflow: isMobile ? "visible" : "auto", padding: isMobile ? "16px" : "24px 28px", display: "grid", gap: isMobile ? 14 : 18, minWidth: 0 }}>
<LintPanelContent context={context} overview={overview} refreshOverview={refreshOverview} />
</div>
);
}
function SettingsLintPanel({ context }: { context: { companyId: string | null } }) {
const overview = useOverview(context.companyId);
if (overview.error) {
return <SettingsPanel title="Lint" badge={<HiddenOpBadge />} description="Run structural checks for orphan pages, missing backlinks, and stale provenance.">
<Callout tone="danger">LLM Wiki bridge error: {overview.error.message}</Callout>
</SettingsPanel>;
}
if (!overview.data) {
return <SettingsPanel title="Lint" badge={<HiddenOpBadge />} description="Run structural checks for orphan pages, missing backlinks, and stale provenance.">
<Tiny>Loading lint controls</Tiny>
</SettingsPanel>;
}
return (
<SettingsPanel
title="Lint"
badge={<HiddenOpBadge />}
description="Run structural checks for orphan pages, missing backlinks, and stale provenance."
>
<LintPanelContent context={context} overview={overview.data} refreshOverview={overview.refresh} showHeading={false} />
</SettingsPanel>
);
}
function LintPanelContent({
context,
overview,
refreshOverview,
showHeading = true,
}: {
context: { companyId: string | null };
overview: OverviewData;
refreshOverview: () => void;
showHeading?: boolean;
}) {
const create = usePluginAction("create-operation");
const { pathname } = useHostLocation();
const activeSpaceSlug = useMemo(() => readActiveSpaceSlugFromLocation(pathname), [pathname]);
const operations = useOperations(context.companyId, { operationType: "lint", spaceSlug: activeSpaceSlug });
const toast = usePluginToast();
const isMobile = useIsMobileLayout();
const [busy, setBusy] = useState(false);
async function runLint() {
if (!context.companyId) return;
setBusy(true);
try {
await create({
companyId: context.companyId,
spaceSlug: activeSpaceSlug,
operationType: "lint",
title: `Run LLM Wiki lint · ${activeSpaceSlug}`,
prompt: overview.prompts.lint,
});
toast({ tone: "success", title: "Lint queued", body: "Lint runs as a Wiki Maintainer task. Findings will appear here once the run completes." });
operations.refresh();
refreshOverview();
} catch (err) {
toast({ tone: "error", title: "Could not run lint", body: err instanceof Error ? err.message : String(err) });
} finally {
setBusy(false);
}
}
const recent = operations.data?.operations ?? [];
const latestDone = recent.find((op) => op.status === "done");
const findings = Array.isArray(latestDone?.warnings) ? (latestDone!.warnings as Record<string, unknown>[]) : [];
const counts = aggregateLintFindings(findings);
return (
<div style={{ display: "grid", gap: isMobile ? 14 : 18, minWidth: 0 }}>
<div style={{ display: "flex", gap: 12, alignItems: "center", flexWrap: "wrap" }}>
{showHeading ? <h2 style={{ margin: 0, fontSize: 16, fontWeight: 600 }}>Lint</h2> : null}
<Badge style={unfilledSurfaceStyle}>{recent.length} run{recent.length === 1 ? "" : "s"}</Badge>
<Button variant="primary" size="sm" onClick={runLint} loading={busy} style={{ marginLeft: isMobile ? 0 : "auto" }}> Run lint now</Button>
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(140px, 1fr))", gap: 12 }}>
<StatCard label="Findings" value={String(counts.total)} hint={latestDone ? `last run ${formatTime(latestDone.createdAt)}` : "no runs yet"} />
<StatCard label="Critical" value={String(counts.critical)} hint="contradictions / conflict" tone={counts.critical > 0 ? "danger" : undefined} />
<StatCard label="Orphans" value={String(counts.orphan)} hint="no inbound backlinks" />
<StatCard label="Stale" value={String(counts.stale)} hint="provenance > 30d" />
<StatCard label="Index drift" value={String(counts.index)} hint="wiki/index.md / wiki/log.md" />
</div>
<Card style={unfilledSurfaceStyle}>
<CardHeader title="Findings" badges={<HiddenOpBadge />} right={<Tiny>Lint runs as a Wiki Maintainer task. Critical findings can optionally open visible follow-up issues toggle in Settings Lint policy.</Tiny>} />
<CardBody padding={0}>
{findings.length === 0 ? (
<div style={{ padding: 16, color: tokens.muted, fontSize: 13 }}>
{latestDone ? "Latest lint run reported no findings." : "No completed lint runs yet. Use ▶ Run lint now to start one."}
</div>
) : findings.map((f, idx) => {
const severityTone: Tone = f.severity === "critical" ? "failed" : f.severity === "orphan" ? "paused" : "default";
return (
<Row
key={idx}
primary={
<span>
<Badge tone={severityTone} style={severityTone === "default" ? unfilledSurfaceStyle : undefined}>
{String(f.severity ?? "info")}
</Badge>
<span style={{ marginLeft: 8 }}>{String(f.message ?? f.title ?? "(no description)")}</span>
</span>
}
secondary={f.path ? <Mono>{String(f.path)}</Mono> : null}
/>
);
})}
</CardBody>
</Card>
<Card style={unfilledSurfaceStyle}>
<CardHeader title="Recent lint runs" />
<CardBody padding={0}>
{recent.length === 0 ? <div style={{ padding: 12, color: tokens.muted, fontSize: 13 }}>No lint runs yet.</div> : recent.map((op) => (
<Row
key={op.id}
primary={<><Mono>op-{op.id.slice(0, 6)}</Mono> {op.hiddenIssueTitle ?? "Wiki lint"}</>}
secondary={formatTime(op.createdAt)}
right={<Badge tone={statusTone(op.status)}>{op.status}</Badge>}
/>
))}
</CardBody>
</Card>
</div>
);
}
function StatCard({ label, value, hint, tone }: { label: string; value: string; hint?: string; tone?: "danger" | "warn" }) {
const palette = tone === "danger" ? { color: "oklch(0.7 0.2 25)" } : tone === "warn" ? { color: "oklch(0.85 0.1 70)" } : { color: tokens.fg };
return (
<Card style={{ ...unfilledSurfaceStyle, padding: 14 }}>
<Tiny>{label.toUpperCase()}</Tiny>
<div style={{ fontSize: 22, fontWeight: 700, marginTop: 4, ...palette }}>{value}</div>
{hint ? <Tiny style={{ marginTop: 2 }}>{hint}</Tiny> : null}
</Card>
);
}
function aggregateLintFindings(findings: Record<string, unknown>[]): { total: number; critical: number; orphan: number; stale: number; index: number } {
const counts = { total: findings.length, critical: 0, orphan: 0, stale: 0, index: 0 };
for (const f of findings) {
const sev = String(f.severity ?? "");
if (sev === "critical") counts.critical += 1;
else if (sev === "orphan") counts.orphan += 1;
else if (sev === "stale") counts.stale += 1;
else if (sev === "index") counts.index += 1;
}
return counts;
}
// ---------------------------------------------------------------------------
// History tab: native Paperclip issue table for recent LLM Wiki operation
// issues. Each plugin run is represented by an issue, so the standard issue
// history view is the right surface here.
// ---------------------------------------------------------------------------
function formatCostCents(cents: number): string {
if (!Number.isFinite(cents) || cents <= 0) return "$0.00";
return `$${(cents / 100).toFixed(2)}`;
}
function formatTimestamp(value: string | null | undefined): string {
if (!value) return "—";
const ms = Date.parse(value);
if (!Number.isFinite(ms)) return "—";
return new Date(ms).toLocaleString(undefined, { month: "short", day: "2-digit", hour: "2-digit", minute: "2-digit" });
}
function runStatusTone(status: string): Tone {
if (status === "running") return "running";
if (status === "succeeded" || status === "completed" || status === "done") return "done";
if (status === "failed" || status === "refused_cost_cap") return "failed";
if (status === "review_required") return "in_review";
if (status === "paused") return "paused";
if (status === "source_ready" || status === "queued") return "queued";
return "default";
}
function runStatusLabel(status: string): string {
switch (status) {
case "review_required":
return "review required";
case "refused_cost_cap":
return "cost capped";
case "source_ready":
return "source ready";
default:
return status.replace(/_/g, " ");
}
}
function HistoryTab({ context, overview }: { context: { companyId: string | null; companyPrefix?: string | null }; overview: OverviewData }) {
const isMobile = useIsMobileLayout();
const projectId = overview.managedProject.projectId;
const originKindPrefix = `plugin:${PLUGIN_ID}:operation`;
if (!context.companyId) {
return <div style={{ padding: isMobile ? 16 : 24, flex: 1 }}><Callout>Choose a company to view LLM Wiki history.</Callout></div>;
}
if (!projectId) {
return (
<div style={{ padding: isMobile ? 16 : 24, flex: 1 }}>
<Callout tone="warn">The LLM Wiki operations project is not resolved yet. Reconcile the managed project in Settings, then history will show its issues here.</Callout>
</div>
);
}
return (
<div style={{ flex: 1, minHeight: isMobile ? "auto" : 0, overflow: isMobile ? "visible" : "auto", padding: isMobile ? 12 : 0, minWidth: 0 }}>
<PluginIssuesList
companyId={context.companyId}
projectId={projectId}
filters={{ originKindPrefix }}
viewStateKey="paperclip:llm-wiki-history-issues-view"
searchWithinLoadedIssues
/>
</div>
);
}
// ---------------------------------------------------------------------------
// Settings (tab) and the standalone host SettingsPage share this body.
// ---------------------------------------------------------------------------
function SettingsTab({ context, initialSection = "root" }: { context: { companyId: string | null; companyPrefix?: string | null }; initialSection?: SettingsSectionKey }) {
const isMobile = useIsMobileLayout();
return (
<div style={{ flex: 1, minHeight: isMobile ? "auto" : 0, overflow: isMobile ? "visible" : "auto", padding: isMobile ? "16px" : "24px 28px", minWidth: 0 }}>
<SettingsBody context={context} initialSection={initialSection} />
</div>
);
}
const ROUTINE_FALLBACKS: Record<string, { title: string; cron: string }> = {
"cursor-window-processing": { title: "Process LLM Wiki updates", cron: "0 */6 * * *" },
"nightly-wiki-lint": { title: "Run LLM Wiki lint", cron: "0 3 * * *" },
"index-refresh": { title: "Refresh LLM Wiki index", cron: "0 * * * *" },
};
const MANAGED_SKILL_LABELS: Record<string, string> = {
"wiki-maintainer": "LLM Wiki Maintainer",
"wiki-ingest": "Wiki Ingest",
"wiki-query": "Wiki Query",
"wiki-lint": "Wiki Lint",
"paperclip-distill": "Paperclip Distill",
"index-refresh": "Index Refresh",
};
function routineFallbackFor(routine: ManagedRoutine) {
const key = routine.resourceKey?.split(":").pop() ?? "";
return ROUTINE_FALLBACKS[key] ?? { title: routine.resourceKey ?? "Managed routine", cron: "—" };
}
function managedRoutineStatus(routine: ManagedRoutine) {
return routine.routine?.status ?? routine.details?.status ?? (routine.routineId ? "paused" : "missing");
}
type RoutineHealthItem = {
label: string;
ok: boolean;
detail: string;
};
function routineResourceKey(routine: ManagedRoutine) {
return routine.resourceKey?.split(":").pop() ?? "";
}
function managedAgentIsReady(resource: ManagedAgent) {
return resource.source === "managed" && Boolean(resource.agentId);
}
function managedProjectIsReady(resource: ManagedProject) {
return resource.source === "managed" && Boolean(resource.projectId);
}
function managedSkillIsReady(resource: ManagedSkill) {
return resource.status !== "missing" && Boolean(resource.skillId);
}
function managedResourceKey(resourceKey?: string | null) {
return resourceKey?.split(":").pop() ?? "";
}
function skillLabel(resource: ManagedSkill) {
const declaredLabel = MANAGED_SKILL_LABELS[managedResourceKey(resource.resourceKey)];
return declaredLabel ?? resource.details?.name ?? resource.skill?.name ?? "Managed skill";
}
function buildAgentHealthItems(managedAgent: ManagedAgent): RoutineHealthItem[] {
const agentName = managedAgent.details?.name ?? "Wiki Maintainer";
return [{
label: agentName,
ok: managedAgentIsReady(managedAgent) && !managedAgent.defaultDrift?.changedFiles.length,
detail: managedAgent.source === "managed"
? managedAgent.defaultDrift?.changedFiles.length
? `The Wiki Maintainer instructions differ from the plugin default: ${managedAgent.defaultDrift.changedFiles.join(", ")}.`
: "The plugin-managed Wiki Maintainer exists with current default instructions."
: "The settings page is using a selected maintainer instead of the plugin-managed Wiki Maintainer.",
}];
}
function buildProjectHealthItems(managedProject: ManagedProject): RoutineHealthItem[] {
const projectName = managedProject.details?.name ?? "LLM Wiki";
return [{
label: projectName,
ok: managedProjectIsReady(managedProject),
detail: managedProject.source === "managed"
? "The plugin-managed LLM Wiki project exists."
: "The settings page is using a selected project instead of the plugin-managed LLM Wiki project.",
}];
}
function buildSkillHealthItems(skills: ManagedSkill[]): RoutineHealthItem[] {
if (skills.length === 0) {
return [{
label: "Managed skill",
ok: false,
detail: "No plugin-managed skills are installed in the company skill library.",
}];
}
return skills.map((skill) => ({
label: skillLabel(skill),
ok: managedSkillIsReady(skill) && !skill.defaultDrift?.changedFiles.length,
detail: managedSkillIsReady(skill)
? skill.defaultDrift?.changedFiles.length
? `${skillLabel(skill)} differs from the plugin default: ${skill.defaultDrift.changedFiles.join(", ")}.`
: `${skillLabel(skill)} is installed in the company skill library.`
: `${skillLabel(skill)} is not installed in the company skill library.`,
}));
}
function buildRoutineHealthItems(
routines: ManagedRoutine[],
managedAgent: ManagedAgent,
managedProject: ManagedProject,
): RoutineHealthItem[] {
const routineByKey = new Map(routines.map((routine) => [routineResourceKey(routine), routine]));
const expectedAgentId = managedAgent.source === "managed" ? managedAgent.agentId ?? null : null;
const expectedProjectId = managedProject.source === "managed" ? managedProject.projectId ?? null : null;
const items: RoutineHealthItem[] = [];
for (const [key, fallback] of Object.entries(ROUTINE_FALLBACKS)) {
const routine = routineByKey.get(key);
const routineAgentId = routine?.routine?.assigneeAgentId ?? null;
const routineProjectId = routine?.routine?.projectId ?? null;
const missingRefs = routine?.missingRefs ?? [];
const missing = !routine?.routineId || !routine.routine;
const wrongAgent = Boolean(expectedAgentId && routineAgentId && routineAgentId !== expectedAgentId);
const wrongProject = Boolean(expectedProjectId && routineProjectId && routineProjectId !== expectedProjectId);
const missingAgent = Boolean(expectedAgentId && !routineAgentId);
const missingProject = Boolean(expectedProjectId && !routineProjectId);
const blockedByManagedResources = !expectedAgentId || !expectedProjectId;
const ok = Boolean(routine && !missing && missingRefs.length === 0 && !wrongAgent && !wrongProject && !missingAgent && !missingProject && !blockedByManagedResources);
let detail = `${fallback.title} is installed with the Wiki Maintainer and LLM Wiki project.`;
if (missing) {
detail = `${fallback.title} is not installed.`;
} else if (missingRefs.length > 0) {
detail = `${fallback.title} cannot resolve ${missingRefs.map((ref) => `${ref.resourceKind}:${ref.resourceKey}`).join(", ")}.`;
} else if (blockedByManagedResources) {
detail = `${fallback.title} cannot be validated until the managed agent and project are restored.`;
} else if (wrongAgent || missingAgent) {
detail = `${fallback.title} is not assigned to the Wiki Maintainer.`;
} else if (wrongProject || missingProject) {
detail = `${fallback.title} is not attached to the LLM Wiki project.`;
}
items.push({ label: fallback.title, ok, detail });
}
return items;
}
function RoutineHealthChecklist({ items }: { items: RoutineHealthItem[] }) {
return (
<ManagedResourceHealthChecklist
items={items}
ariaLabel="Wiki routines health checklist"
heading="Routine health"
/>
);
}
function ManagedResourceHealthChecklist({
items,
ariaLabel,
heading,
}: {
items: RoutineHealthItem[];
ariaLabel: string;
heading: string;
}) {
return (
<div style={{ display: "grid", gap: 8 }} aria-label={ariaLabel}>
<div style={{ fontSize: 12, fontWeight: 650, color: tokens.muted }}>{heading}</div>
<div role="list" style={{ position: "relative", display: "grid", gap: 0, padding: "2px 0" }}>
{items.length > 1 ? (
<span
aria-hidden
style={{
position: "absolute",
left: 8,
top: 12,
bottom: 12,
width: 1,
background: "oklch(0.38 0.09 145)",
}}
/>
) : null}
{items.map((item) => (
<div
key={item.label}
role="listitem"
title={item.detail}
style={{
display: "grid",
gridTemplateColumns: "18px minmax(0, 1fr)",
alignItems: "center",
gap: 10,
padding: "7px 0",
minWidth: 0,
}}
>
<span style={{ display: "inline-flex", justifyContent: "center", position: "relative", zIndex: 1, background: tokens.bg }}>
<StatusIcon status={item.ok ? "done" : "blocked"} />
</span>
<span style={{ fontSize: 13, fontWeight: 600, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", color: item.ok ? tokens.fg : "oklch(0.85 0.1 70)" }}>
{item.label}
</span>
</div>
))}
</div>
</div>
);
}
function SkillHealthChecklist({ items }: { items: RoutineHealthItem[] }) {
return (
<ManagedResourceHealthChecklist
items={items}
ariaLabel="Wiki skills health checklist"
heading="Skill health"
/>
);
}
type SettingsSectionKey = "root" | "spaces" | "distillation" | "routines" | "lint" | "events";
const SETTINGS_SECTIONS: ReadonlyArray<{
key: SettingsSectionKey;
label: string;
description: string;
}> = [
{ key: "root", label: "Setup", description: "" },
{ key: "spaces", label: "Spaces", description: "Destination spaces - folders, slugs, and folder health. Per-space Paperclip indexing is not configurable yet." },
{ key: "distillation", label: "Distillation", description: "Paperclip -> default space. Cursors, caps, and routines for the company-wide distillation pipeline." },
{ key: "routines", label: "Managed Routines", description: "Scheduled wiki maintenance." },
{ key: "lint", label: "Lint", description: "Run checks and review wiki health findings." },
{ key: "events", label: "Ingestion Settings", description: "Paperclip event capture into the default space (issues, comments, documents)." },
];
function SettingsSectionButton({
section,
active,
onSelect,
}: {
section: (typeof SETTINGS_SECTIONS)[number];
active: boolean;
onSelect: () => void;
}) {
return (
<button
type="button"
aria-current={active ? "page" : undefined}
onClick={onSelect}
style={{
width: "100%",
border: `1px solid ${active ? tokens.border : "transparent"}`,
borderRadius: 6,
background: "transparent",
color: active ? tokens.fg : tokens.muted,
cursor: "pointer",
display: "grid",
gap: 2,
padding: "8px 10px",
textAlign: "left",
fontFamily: fontStack,
}}
>
<span style={{ fontSize: 13, fontWeight: 600, lineHeight: 1.3 }}>{section.label}</span>
{section.description ? (
<span style={{ fontSize: 11, lineHeight: 1.35, overflowWrap: "anywhere" }}>{section.description}</span>
) : null}
</button>
);
}
function SettingsPanel({
title,
badge,
description,
children,
}: {
title: ReactNode;
badge?: ReactNode;
description?: ReactNode;
children: ReactNode;
}) {
return (
<section style={{ display: "grid", gap: 14, minWidth: 0 }}>
<header style={{ display: "grid", gap: 6, minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap", minWidth: 0 }}>
<h2 style={{ margin: 0, fontSize: 17, fontWeight: 650, overflowWrap: "anywhere" }}>{title}</h2>
{badge}
</div>
{description ? <Tiny>{description}</Tiny> : null}
</header>
<div style={{ minWidth: 0 }}>{children}</div>
</section>
);
}
function SetupSection({ title, children, separated = false }: { title: ReactNode; children: ReactNode; separated?: boolean }) {
return (
<section style={{ display: "grid", gap: 12, minWidth: 0, paddingTop: separated ? 22 : 0, borderTop: separated ? `1px solid ${tokens.border}` : "none" }}>
<h2 style={{ margin: 0, fontSize: 16, fontWeight: 650 }}>{title}</h2>
<div style={{ minWidth: 0 }}>{children}</div>
</section>
);
}
type PathPlatform = "mac" | "windows" | "linux";
const PATH_PLATFORM_LABELS: Record<PathPlatform, string> = {
mac: "macOS",
windows: "Windows",
linux: "Linux",
};
const PATH_PLATFORM_STEPS: Record<PathPlatform, string[]> = {
mac: [
"Open Finder and navigate to the folder.",
"Control-click the folder.",
"Hold Option, choose Copy as Pathname, then paste it here.",
],
windows: [
"Open File Explorer and navigate to the folder.",
"Click the address bar to reveal the full path.",
"Copy the path and paste it here.",
],
linux: [
"Open a terminal in the directory.",
"Run pwd to print the full path.",
"Copy the output and paste it here.",
],
};
function detectPathPlatform(): PathPlatform {
if (typeof navigator === "undefined") return "mac";
const agent = navigator.userAgent.toLowerCase();
if (agent.includes("win")) return "windows";
if (agent.includes("linux")) return "linux";
return "mac";
}
function PathInstructionsDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const [platform, setPlatform] = useState<PathPlatform>(detectPathPlatform);
useEffect(() => {
if (!open) return;
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") onClose();
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose, open]);
if (!open) return null;
return (
<div
role="presentation"
onClick={onClose}
style={{
position: "fixed",
inset: 0,
zIndex: 50,
background: "rgba(0, 0, 0, 0.48)",
display: "grid",
placeItems: "center",
padding: 18,
}}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="wiki-path-help-title"
onClick={(event) => event.stopPropagation()}
style={{
width: "min(460px, 100%)",
border: `1px solid ${tokens.border}`,
borderRadius: 8,
background: tokens.card,
color: tokens.fg,
boxShadow: "0 24px 70px rgba(0, 0, 0, 0.45)",
padding: 18,
display: "grid",
gap: 14,
}}
>
<div style={{ display: "flex", justifyContent: "space-between", gap: 12, alignItems: "start" }}>
<div style={{ display: "grid", gap: 4 }}>
<h3 id="wiki-path-help-title" style={{ margin: 0, fontSize: 15, fontWeight: 650 }}>Get a full folder path</h3>
<Tiny>Paste an absolute path such as <Mono>/Users/you/company-wiki</Mono>.</Tiny>
</div>
<Button size="sm" variant="ghost" onClick={onClose} title="Close path help">Close</Button>
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 4, border: `1px solid ${tokens.border}`, borderRadius: 7, padding: 3 }}>
{(Object.keys(PATH_PLATFORM_LABELS) as PathPlatform[]).map((key) => (
<button
key={key}
type="button"
onClick={() => setPlatform(key)}
style={{
border: 0,
borderRadius: 5,
background: key === platform ? tokens.accent : "transparent",
color: key === platform ? tokens.fg : tokens.muted,
padding: "6px 8px",
cursor: "pointer",
fontSize: 12,
fontFamily: fontStack,
}}
>
{PATH_PLATFORM_LABELS[key]}
</button>
))}
</div>
<ol style={{ margin: 0, paddingLeft: 20, display: "grid", gap: 8, fontSize: 13, lineHeight: 1.45 }}>
{PATH_PLATFORM_STEPS[platform].map((step) => <li key={step}>{step}</li>)}
</ol>
</div>
</div>
);
}
function FolderPathPicker({
value,
onChange,
onApply,
applyLabel,
busy,
disabled,
onRefresh,
}: {
value: string;
onChange: (value: string) => void;
onApply: () => void;
applyLabel: string;
busy?: boolean;
disabled?: boolean;
onRefresh?: () => void;
}) {
const [helpOpen, setHelpOpen] = useState(false);
return (
<div style={{
border: `1px solid ${tokens.border}`,
borderRadius: 8,
background: "oklch(0.18 0 0)",
overflow: "hidden",
minWidth: 0,
}}>
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 12px", borderBottom: `1px solid ${tokens.border}` }}>
<span aria-hidden style={{
width: 28,
height: 28,
borderRadius: 7,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
background: tokens.accent,
color: tokens.pluginFg,
flexShrink: 0,
}}>
<FolderOpenIcon />
</span>
<div style={{ display: "grid", gap: 2, minWidth: 0 }}>
<span style={{ fontSize: 13, fontWeight: 650 }}>Local wiki folder</span>
<Tiny>Absolute path on this machine</Tiny>
</div>
</div>
<div style={{ display: "flex", gap: 8, padding: 12, alignItems: "center", flexWrap: "wrap" }}>
<TextInput
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder="/absolute/path/to/wiki-root"
style={{ flex: "1 1 320px", fontFamily: "ui-monospace, SFMono-Regular, monospace" }}
/>
<Button size="sm" onClick={() => setHelpOpen(true)}><FolderOpenIcon size={13} /> Choose</Button>
<Button variant="primary" size="sm" onClick={onApply} loading={busy} disabled={disabled || !value.trim()}>{applyLabel}</Button>
{onRefresh ? <Button size="sm" variant="ghost" onClick={onRefresh}>Run health check</Button> : null}
</div>
<PathInstructionsDialog open={helpOpen} onClose={() => setHelpOpen(false)} />
</div>
);
}
type FolderHealthItem = {
label: string;
ok: boolean;
};
function folderHealthItems(folder: FolderStatus): FolderHealthItem[] {
return [
{ label: "Path configured", ok: folder.configured },
{ label: "Readable", ok: folder.readable },
{ label: folder.access === "readWrite" ? "Writable" : "Read-only access", ok: folder.access === "read" || folder.writable },
{ label: "Baseline files", ok: folder.missingFiles.length === 0 },
{ label: "Wiki folders", ok: folder.missingDirectories.length === 0 },
];
}
function FolderHealthChecklist({ folder }: { folder: FolderStatus }) {
const items = folderHealthItems(folder);
return (
<div style={{ display: "grid", gap: 8 }} aria-label="Wiki root health checklist">
<div style={{ fontSize: 12, fontWeight: 650, color: tokens.muted }}>Health check</div>
<div role="list" style={{ position: "relative", display: "grid", gap: 0, padding: "2px 0" }}>
{items.length > 1 ? (
<span
aria-hidden
style={{
position: "absolute",
left: 8,
top: 12,
bottom: 12,
width: 1,
background: "oklch(0.38 0.09 145)",
}}
/>
) : null}
{items.map((item) => (
<div
key={item.label}
role="listitem"
style={{
display: "grid",
gridTemplateColumns: "18px minmax(0, 1fr)",
alignItems: "center",
gap: 10,
padding: "7px 0",
minWidth: 0,
}}
>
<span style={{ display: "inline-flex", justifyContent: "center", position: "relative", zIndex: 1, background: tokens.bg }}>
<StatusIcon status={item.ok ? "done" : "blocked"} />
</span>
<span style={{ fontSize: 13, fontWeight: 600, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", color: item.ok ? tokens.fg : "oklch(0.85 0.1 70)" }}>
{item.label}
</span>
</div>
))}
</div>
</div>
);
}
type MaintainerAgentOption = SettingsData["agentOptions"][number];
type ProjectOption = SettingsData["projectOptions"][number];
function agentStatusLabel(status?: string | null) {
if (!status) return "unknown";
return status.replace(/_/g, " ");
}
function projectStatusLabel(status?: string | null) {
if (!status) return "unknown";
return status.replace(/_/g, " ");
}
function adapterTypeLabel(adapterType?: string | null) {
if (!adapterType) return "unknown adapter";
return adapterType.replace(/_/g, " ");
}
function agentAvatarGlyph(icon?: string | null, name?: string | null) {
if (icon === "book-open") return "📖";
return (name?.trim().charAt(0) || "A").toUpperCase();
}
function AgentAvatar({ agent, size = 30 }: { agent: Pick<MaintainerAgentOption, "name" | "icon">; size?: number }) {
return (
<span
aria-hidden
style={{
width: size,
height: size,
borderRadius: "50%",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
border: `1px solid ${tokens.border}`,
background: tokens.accent,
color: tokens.fg,
fontSize: size > 28 ? 14 : 12,
fontWeight: 700,
}}
>
{agentAvatarGlyph(agent.icon, agent.name)}
</span>
);
}
function AgentOptionLabel({ agent, muted = false }: { agent: MaintainerAgentOption; muted?: boolean }) {
return (
<span style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
<AgentAvatar agent={agent} />
<span style={{ display: "grid", gap: 1, minWidth: 0 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: muted ? tokens.muted : tokens.fg, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{agent.name}
</span>
<span style={{ fontSize: 11, color: tokens.muted, textTransform: "capitalize" }}>
{agentStatusLabel(agent.status)} · {adapterTypeLabel(agent.adapterType)}
</span>
</span>
</span>
);
}
function MaintainerAgentLink({
agent,
hrefProps,
}: {
agent: MaintainerAgentOption;
hrefProps?: Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "style">;
}) {
return (
<a
{...hrefProps}
style={{
display: "inline-flex",
alignItems: "center",
gap: 8,
maxWidth: "100%",
color: tokens.fg,
textDecoration: "none",
}}
>
<AgentAvatar agent={agent} />
<span style={{ display: "grid", gap: 1, minWidth: 0, textAlign: "left" }}>
<span style={{ fontSize: 13, fontWeight: 650, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
Resolve agent wiki maintainer
</span>
<span style={{ fontSize: 11, color: tokens.muted, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{agent.name} · {agentStatusLabel(agent.status)} · {adapterTypeLabel(agent.adapterType)}
</span>
</span>
</a>
);
}
// ---------------------------------------------------------------------------
// Distillation settings panel (configured + unconfigured states).
// Source filters / cursor windows / model lanes / auto-apply / backfill.
// Most controls render the current state; persistence is wired through
// existing managed-routine and plugin-action endpoints.
// ---------------------------------------------------------------------------
function DistillationSettingsPanel({ context, settings }: { context: { companyId: string | null }; settings: SettingsData }) {
const overview = useDistillationOverview(context.companyId);
const distillNow = usePluginAction("distill-paperclip-now");
const enableActiveProjects = usePluginAction("enable-paperclip-distillation-active-projects");
const queueBackfill = usePluginAction("backfill-paperclip-distillation");
const toast = usePluginToast();
const isMobile = useIsMobileLayout();
const [busy, setBusy] = useState<string | null>(null);
const data = overview.data;
const cursors = data?.cursors ?? [];
const counts = data?.counts ?? { cursors: 0, runningRuns: 0, failedRuns24h: 0, reviewRequired: 0 };
const isConfigured = cursors.length > 0;
const autoApplyRestriction = settings.distillationPolicy?.autoApplyRestriction ?? null;
const [useCheapPath, setUseCheapPath] = useState(true);
const projectsCovered = useMemo(() => {
const set = new Set<string>();
for (const cursor of cursors) {
if (cursor.projectId) set.add(cursor.projectId);
}
return set.size;
}, [cursors]);
async function runDistillNow() {
if (!context.companyId) return;
if (cursors.length === 0) {
toast({ tone: "warn", title: "Distill now needs at least one cursor" });
return;
}
setBusy("distill-now");
try {
await distillNow({
companyId: context.companyId,
useCheapModelProfile: useCheapPath,
idempotencyKey: `manual:company:${Date.now()}`,
});
toast({
tone: "success",
title: "Distill now queued",
body: "Wiki Maintainer will scan changed projects in the company and write into the default wiki space.",
});
overview.refresh();
} catch (err) {
toast({ tone: "error", title: "Distill now failed", body: err instanceof Error ? err.message : String(err) });
} finally {
setBusy(null);
}
}
async function enableForActiveProjects() {
if (!context.companyId) return;
setBusy("enable-active-projects");
try {
const result = await enableActiveProjects({ companyId: context.companyId, limit: 3 }) as {
selectedProjects?: Array<{ name?: string | null }>;
};
const count = result.selectedProjects?.length ?? 0;
toast({
tone: count > 0 ? "success" : "warn",
title: count > 0 ? "Distillation enabled" : "No active projects found",
body: count > 0
? `${count} active project${count === 1 ? "" : "s"} added to the distillation cursor set.`
: "Create or resume a project, then enable distillation again.",
});
overview.refresh();
} catch (err) {
toast({ tone: "error", title: "Enable distillation failed", body: err instanceof Error ? err.message : String(err) });
} finally {
setBusy(null);
}
}
async function runBackfill() {
if (!context.companyId || cursors.length === 0) return;
const target = cursors.find((cursor) => cursor.projectId) ?? cursors[0];
if (!target.projectId && !target.rootIssueId) {
toast({ tone: "warn", title: "Backfill needs a project or root issue scope" });
return;
}
setBusy("backfill");
try {
await queueBackfill({
companyId: context.companyId,
projectId: target.projectId ?? undefined,
rootIssueId: target.rootIssueId ?? undefined,
useCheapModelProfile: useCheapPath,
});
toast({ tone: "success", title: "Backfill queued", body: target.projectName ?? target.rootIssueIdentifier ?? "Selected scope" });
overview.refresh();
} catch (err) {
toast({ tone: "error", title: "Backfill failed", body: err instanceof Error ? err.message : String(err) });
} finally {
setBusy(null);
}
}
if (overview.loading && !data) {
return <Tiny>Loading distillation overview</Tiny>;
}
if (!isConfigured) {
return (
<div style={{ display: "grid", gap: 16, maxWidth: 720 }}>
<Card>
<CardBody padding={isMobile ? 18 : 26}>
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-start", gap: 12 }}>
<SparklesIcon size={36} />
<div>
<h3 style={{ margin: 0, fontSize: 17, fontWeight: 650 }}>Distillation is off</h3>
<Tiny style={{ marginTop: 6, fontSize: 13, color: tokens.fg, lineHeight: 1.55, maxWidth: 540 }}>
When enabled, the Wiki Maintainer reads Paperclip issues, comments, and documents for this
company and keeps <Mono>wiki/projects/&lt;slug&gt;/standup.md</Mono> plus <Mono>wiki/projects/&lt;slug&gt;/index.md</Mono> pages in the
<strong> default wiki space</strong>. Pages stay marked stale until a cursor window succeeds -
they never imply live state.
</Tiny>
<Tiny style={{ marginTop: 6, fontSize: 13, color: tokens.fg, lineHeight: 1.55, maxWidth: 540 }}>
Other spaces do not receive Paperclip-derived pages yet. They stay on manual and raw-file
ingest until per-space Paperclip ingestion profiles ship.
</Tiny>
</div>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<Button variant="primary" size="md" onClick={enableForActiveProjects} loading={busy === "enable-active-projects"}>
<SparklesIcon size={14} /> Enable for active projects
</Button>
<Button variant="default" size="md" onClick={() => overview.refresh()}>Configure manually</Button>
</div>
<Tiny>Suggested defaults: 3 active projects in the default space · all-section auto-apply where allowed · routines paused for 24h.</Tiny>
</div>
</CardBody>
</Card>
<div style={{ display: "grid", gap: 12, gridTemplateColumns: isMobile ? "1fr" : "1fr 1fr" }}>
<Card>
<CardHeader title="What gets created" />
<CardBody padding={14}>
<ul style={{ margin: 0, paddingLeft: 18, fontSize: 12.5, color: tokens.muted, lineHeight: 1.5 }}>
<li>Project overviews at <Mono>wiki/projects/&lt;slug&gt;/index.md</Mono></li>
<li>Executive standups at <Mono>wiki/projects/&lt;slug&gt;/standup.md</Mono></li>
<li>Decisions and history under <Mono>wiki/projects/&lt;slug&gt;/</Mono></li>
<li>Source bundles cached under <Mono>raw/distill/</Mono></li>
</ul>
</CardBody>
</Card>
<Card>
<CardHeader title="What it never does" />
<CardBody padding={14}>
<ul style={{ margin: 0, paddingLeft: 18, fontSize: 12.5, color: tokens.muted, lineHeight: 1.5 }}>
<li>Read across companies strict per-company isolation.</li>
<li>Re-distill its own plugin operation issues.</li>
<li>Auto-apply patches when source hashes drift.</li>
</ul>
</CardBody>
</Card>
</div>
</div>
);
}
return (
<div style={{ display: "grid", gap: 16, minWidth: 0 }}>
{autoApplyRestriction ? (
<Callout tone="warn">
{autoApplyRestriction} The plugin ignores auto-apply requests from config and manual distill actions on this instance.
</Callout>
) : null}
<Callout>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, flexWrap: "wrap" }}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<InfoIcon size={16} />
<strong style={{ fontSize: 13 }}>Active for {projectsCovered} project{projectsCovered === 1 ? "" : "s"} · {counts.cursors} cursor{counts.cursors === 1 ? "" : "s"} catching up · default space</strong>
</div>
<div style={{ display: "flex", gap: 8 }}>
<Button variant="ghost" size="sm" onClick={() => overview.refresh()}><RefreshIcon size={12} /> Refresh</Button>
<Button variant="primary" size="sm" onClick={runDistillNow} loading={busy === "distill-now"}><SparklesIcon size={12} /> Distill now</Button>
</div>
</div>
<Tiny style={{ marginTop: 6 }}>
Distillation runs on the assigned Wiki Maintainer agent and writes only into the default
space. Use the cheap path option when the agent exposes a cheap model profile.
</Tiny>
</Callout>
<Card>
<CardHeader title="Source filters" />
<CardBody padding={14}>
<div style={{ display: "grid", gridTemplateColumns: isMobile ? "1fr" : "1fr 1fr", gap: 16 }}>
<fieldset style={{ border: 0, padding: 0, margin: 0, display: "grid", gap: 6 }}>
<legend style={{ fontSize: 12, color: tokens.muted, marginBottom: 4 }}>Issue scope</legend>
<CheckboxRow label="Active projects" defaultChecked help="Cursors are created for projects with recent activity." />
<CheckboxRow label="Root issues marked distillable" defaultChecked />
<CheckboxRow label="All company issues" help="May create large source windows." />
<Tiny>
These filters narrow the Paperclip source scope. The destination is always the default
wiki space in Phase 1.
</Tiny>
<Tiny>Plugin-operation issues are always excluded to prevent feedback loops.</Tiny>
</fieldset>
<fieldset style={{ border: 0, padding: 0, margin: 0, display: "grid", gap: 6 }}>
<legend style={{ fontSize: 12, color: tokens.muted, marginBottom: 4 }}>Source kinds</legend>
<CheckboxRow label="Issue title + description" defaultChecked locked />
<CheckboxRow label="Comments (ranked, clipped)" defaultChecked />
<CheckboxRow label="Documents (plan, spec, report)" defaultChecked />
<CheckboxRow label="Work products / attachments" suffix="coming soon" />
<Tiny>Heartbeats and hidden documents are never included.</Tiny>
</fieldset>
</div>
</CardBody>
</Card>
<Card>
<CardHeader title="Cursor windows" />
<CardBody padding={14}>
<div style={{ display: "grid", gridTemplateColumns: isMobile ? "1fr" : "1fr 1fr", gap: 16 }}>
<SettingField label="Max source characters per project" hint="Source bundles above this size are clipped and the page warns 'source clipped'.">
<TextInput defaultValue="48000" />
</SettingField>
<SettingField label="Min source age before processing" hint="Debounces a hot project so a flurry of comments collapses into one cursor window.">
<SelectInput defaultValue="15" options={[["5", "5 min"], ["15", "15 min"], ["30", "30 min"], ["60", "1 hour"]]} />
</SettingField>
<SettingField label="Max cursor windows per routine run" hint="Routine runs that hit this cap split the remainder into the next routine fire.">
<TextInput defaultValue="6" />
</SettingField>
<SettingField label="Stale window threshold" hint="After this, project pages render a 'Stale' badge until a successful run advances the cursor.">
<SelectInput defaultValue="72" options={[["24", "24 h"], ["48", "48 h"], ["72", "72 h"], ["168", "7 days"]]} />
</SettingField>
</div>
</CardBody>
</Card>
<Card>
<CardHeader title="Agent execution" />
<CardBody padding={14}>
<div style={{ display: "grid", gridTemplateColumns: isMobile ? "1fr" : "1fr 1fr", gap: 16 }}>
<SettingField label="Assigned maintainer" hint="Model selection comes from the agent adapter and its runtime config. The plugin does not choose Claude/Codex/Gemini models here.">
<div style={{ minHeight: 34, display: "flex", alignItems: "center", border: `1px solid ${tokens.border}`, borderRadius: 6, padding: "6px 10px", fontSize: 13 }}>
{settings.managedAgent.details
? `${settings.managedAgent.details.name} · ${adapterTypeLabel(settings.managedAgent.details.adapterType)}`
: "No maintainer agent resolved"}
</div>
</SettingField>
<SettingField label="Cheap path" hint="When enabled, manual distill and backfill operation issues request assigneeAdapterOverrides.modelProfile = cheap.">
<CheckboxRow
label="Request the assigned agent's cheap model profile for distillation tasks"
checked={useCheapPath}
onChange={setUseCheapPath}
/>
</SettingField>
</div>
</CardBody>
</Card>
<Card>
<CardHeader title="Auto-apply policy" />
<CardBody padding={14}>
<div style={{ display: "grid", gap: 8 }}>
<RadioRow name="autoapply" value="never" label="Never — every patch goes to review-required." />
<RadioRow name="autoapply" value="status" label="Executive-status sections only — standup, current direction, and risks." />
<RadioRow name="autoapply" value="all" label="All sections — apply when source hash matches and confidence ≥ 0.8 (default)." defaultChecked />
<Tiny>Stale-hash collisions always fall through to review, regardless of policy.</Tiny>
</div>
</CardBody>
</Card>
<Card>
<CardHeader
title="Backfill"
right={<Button variant="default" size="sm" onClick={runBackfill} loading={busy === "backfill"}>Queue backfill</Button>}
/>
<CardBody padding={14}>
<Tiny style={{ marginBottom: 6 }}>Backfills replay a bounded source window for a single scope so newly-enabled projects can catch up to fresh state.</Tiny>
<div style={{ display: "grid", gap: 8, fontSize: 13 }}>
<PropRow label="Default scope" value={cursors.find((c) => c.projectName)?.projectName ?? cursors[0]?.scopeKey ?? "—"} />
<PropRow label="Cursors active" value={String(counts.cursors)} />
<PropRow label="Runs in flight" value={String(counts.runningRuns)} />
<PropRow label="Failed (24h)" value={String(counts.failedRuns24h)} />
<PropRow label="Review queue" value={String(counts.reviewRequired)} />
</div>
</CardBody>
</Card>
<Tiny>
Active cursors:&nbsp;
{cursors.slice(0, 6).map((cursor, idx) => (
<span key={cursor.id}>
{idx > 0 ? " · " : ""}
{cursor.projectName ?? cursor.rootIssueIdentifier ?? cursor.scopeKey}
</span>
))}
{cursors.length > 6 ? <span>{` +${cursors.length - 6} more`}</span> : null}
</Tiny>
</div>
);
}
function SettingField({ label, hint, children }: { label: ReactNode; hint?: ReactNode; children: ReactNode }) {
return (
<div style={{ display: "grid", gap: 6, minWidth: 0 }}>
<label style={{ fontSize: 12, color: tokens.muted }}>{label}</label>
{children}
{hint ? <Tiny>{hint}</Tiny> : null}
</div>
);
}
function CheckboxRow({
label,
help,
defaultChecked,
checked,
onChange,
locked,
suffix,
}: {
label: string;
help?: string;
defaultChecked?: boolean;
checked?: boolean;
onChange?: (checked: boolean) => void;
locked?: boolean;
suffix?: string;
}) {
return (
<label style={{ display: "flex", alignItems: "flex-start", gap: 8, fontSize: 13 }}>
<input
type="checkbox"
defaultChecked={checked === undefined ? defaultChecked : undefined}
checked={checked}
disabled={locked}
onChange={(event) => onChange?.(event.currentTarget.checked)}
/>
<span>
{label}
{suffix ? <span style={{ marginLeft: 6, fontSize: 11, color: tokens.muted }}>({suffix})</span> : null}
{help ? <Tiny style={{ display: "block", marginTop: 2 }}>{help}</Tiny> : null}
</span>
</label>
);
}
function RadioRow({ name, value, label, defaultChecked }: { name: string; value: string; label: string; defaultChecked?: boolean }) {
return (
<label style={{ display: "flex", alignItems: "flex-start", gap: 8, fontSize: 13 }}>
<input type="radio" name={name} value={value} defaultChecked={defaultChecked} />
<span>{label}</span>
</label>
);
}
function SelectInput({ defaultValue, options }: { defaultValue: string; options: ReadonlyArray<readonly [string, string]> }) {
return (
<select
defaultValue={defaultValue}
style={{
background: "oklch(0.2 0 0)",
color: tokens.fg,
border: `1px solid ${tokens.border}`,
borderRadius: 6,
padding: "6px 10px",
fontSize: 13,
}}
>
{options.map(([value, label]) => <option key={value} value={value}>{label}</option>)}
</select>
);
}
function SettingsBody({ context, initialSection = "root" }: { context: { companyId: string | null; companyPrefix?: string | null }; initialSection?: SettingsSectionKey }) {
const settings = useSettings(context.companyId);
const hostNavigation = useHostNavigation();
const { pathname } = useHostLocation();
const isMobile = useIsMobileLayout();
const bootstrap = usePluginAction("bootstrap-root");
const updateEventIngestion = usePluginAction("update-event-ingestion-settings");
const resetAgent = usePluginAction("reset-managed-agent");
const resetProject = usePluginAction("reset-managed-project");
const resetRoutine = usePluginAction("reset-managed-routine");
const reconcileAgent = usePluginAction("reconcile-managed-agent");
const reconcileProject = usePluginAction("reconcile-managed-project");
const selectAgent = usePluginAction("select-managed-agent");
const selectProject = usePluginAction("select-managed-project");
const resetSkills = usePluginAction("reset-managed-skills");
const reconcileRoutines = usePluginAction("reconcile-managed-routines");
const updateRoutineStatus = usePluginAction("update-managed-routine-status");
const runManagedRoutine = usePluginAction("run-managed-routine");
const toast = usePluginToast();
const [folderPath, setFolderPath] = useState("");
const [folderBusy, setFolderBusy] = useState(false);
const [agentBusy, setAgentBusy] = useState(false);
const [projectBusy, setProjectBusy] = useState(false);
const [selectedAgentId, setSelectedAgentId] = useState("");
const [selectedProjectId, setSelectedProjectId] = useState("");
const [routineBusyKey, setRoutineBusyKey] = useState<string | null>(null);
const [routineRepairBusy, setRoutineRepairBusy] = useState(false);
const [skillBusy, setSkillBusy] = useState(false);
const [allRepairBusy, setAllRepairBusy] = useState(false);
const [eventPolicy, setEventPolicy] = useState<EventIngestionSettings | null>(null);
const [eventPolicyBusy, setEventPolicyBusy] = useState(false);
const [activeSettingsSection, setActiveSettingsSection] = useState<SettingsSectionKey>(initialSection);
useEffect(() => {
if (settings.data?.folder.path && !folderPath) setFolderPath(settings.data.folder.path);
}, [settings.data?.folder.path, folderPath]);
useEffect(() => { if (settings.data?.managedAgent.agentId) setSelectedAgentId(settings.data.managedAgent.agentId); }, [settings.data?.managedAgent.agentId]);
useEffect(() => { if (settings.data?.managedProject.projectId) setSelectedProjectId(settings.data.managedProject.projectId); }, [settings.data?.managedProject.projectId]);
useEffect(() => {
if (settings.data?.eventIngestion && eventPolicy === null) setEventPolicy(settings.data.eventIngestion);
}, [settings.data?.eventIngestion, eventPolicy]);
useEffect(() => {
setActiveSettingsSection(initialSection);
}, [initialSection]);
if (!context.companyId) return <Callout>Choose a company to view LLM Wiki settings.</Callout>;
if (settings.loading) return <Tiny>Loading settings</Tiny>;
if (settings.error) return <Callout tone="danger">{settings.error.message}</Callout>;
if (!settings.data) return <Tiny>No settings available.</Tiny>;
const data = settings.data;
const maintainerFallbackAgent: MaintainerAgentOption | null = data.managedAgent.agentId
? {
id: data.managedAgent.agentId,
name: data.managedAgent.details?.name ?? "Wiki Maintainer",
status: data.managedAgent.details?.status ?? data.managedAgent.status,
adapterType: data.managedAgent.details?.adapterType ?? null,
icon: data.managedAgent.details?.icon ?? "book-open",
urlKey: data.managedAgent.details?.urlKey ?? null,
}
: null;
const maintainerAgentOptions = maintainerFallbackAgent && !data.agentOptions.some((agent) => agent.id === maintainerFallbackAgent.id)
? [maintainerFallbackAgent, ...data.agentOptions]
: data.agentOptions;
const effectiveSelectedAgentId = selectedAgentId || data.managedAgent.agentId || "";
const currentMaintainerAgent = maintainerAgentOptions.find((agent) => agent.id === effectiveSelectedAgentId) ?? maintainerFallbackAgent;
const savedCustomMaintainer = data.managedAgent.source === "selected";
const selectingDifferentMaintainer = Boolean(
data.managedAgent.source === "managed" &&
data.managedAgent.agentId &&
effectiveSelectedAgentId &&
effectiveSelectedAgentId !== data.managedAgent.agentId,
);
const showMaintainerWarning = savedCustomMaintainer || selectingDifferentMaintainer;
const maintainerPendingApproval = currentMaintainerAgent?.status === "pending_approval" || data.managedAgent.details?.status === "pending_approval";
const agentLink = currentMaintainerAgent?.id
? `/agents/${currentMaintainerAgent.id}`
: null;
const projectLink = data.managedProject.projectId
? `/projects/${data.managedProject.projectId}`
: null;
const managedRoutines = data.managedRoutines ?? (data.managedRoutine ? [data.managedRoutine] : []);
const agentHealthItems = buildAgentHealthItems(data.managedAgent);
const agentHealthWarnings = agentHealthItems.filter((item) => !item.ok);
const routineHealthItems = buildRoutineHealthItems(managedRoutines, data.managedAgent, data.managedProject);
const routineHealthWarnings = routineHealthItems.filter((item) => !item.ok);
const projectHealthItems = buildProjectHealthItems(data.managedProject);
const projectHealthWarnings = projectHealthItems.filter((item) => !item.ok);
const managedSkills = data.managedSkills ?? [];
const skillHealthItems = buildSkillHealthItems(managedSkills);
const skillHealthWarnings = skillHealthItems.filter((item) => !item.ok);
const configurationErrors = [
...(!data.folder.healthy ? ["Wiki root folder"] : []),
...(agentHealthWarnings.length > 0 ? ["Managed agents"] : []),
...(skillHealthWarnings.length > 0 ? ["Managed skills"] : []),
...(projectHealthWarnings.length > 0 ? ["Managed projects"] : []),
...(routineHealthWarnings.length > 0 ? ["Managed routines"] : []),
];
const hasConfigurationErrors = configurationErrors.length > 0;
const projectFallbackOption: ProjectOption | null = data.managedProject.projectId
? {
id: data.managedProject.projectId,
name: data.managedProject.details?.name ?? "Current project",
status: data.managedProject.details?.status ?? data.managedProject.status,
color: data.managedProject.details?.color ?? null,
}
: null;
const projectOptions = projectFallbackOption && !data.projectOptions.some((project) => project.id === projectFallbackOption.id)
? [projectFallbackOption, ...data.projectOptions]
: data.projectOptions;
const effectiveSelectedProjectId = selectedProjectId || data.managedProject.projectId || "";
const currentProjectOption = projectOptions.find((project) => project.id === effectiveSelectedProjectId) ?? projectFallbackOption;
const currentEventPolicy = eventPolicy ?? data.eventIngestion;
const managedRoutineItems: ManagedRoutinesListItemWithDrift[] = managedRoutines.map((routine) => {
const fallback = routineFallbackFor(routine);
const key = routine.resourceKey ?? routine.routineId ?? fallback.title;
const status = managedRoutineStatus(routine);
const assigneeAgentId = routine.routine?.assigneeAgentId ?? routine.details?.assigneeAgentId ?? null;
return {
key,
title: routine.routine?.title ?? routine.details?.title ?? fallback.title,
status: status === "missing" || status === "missing_refs" ? "paused" : status,
routineId: routine.routineId ?? routine.routine?.id ?? null,
href: routine.routineId ? `/routines/${routine.routineId}` : null,
resourceKey: routine.resourceKey ?? null,
projectId: routine.routine?.projectId ?? null,
assigneeAgentId,
cronExpression: routine.details?.cronExpression ?? fallback.cron,
lastRunAt: routine.routine?.lastTriggeredAt ?? routine.details?.lastRunAt ?? null,
managedByPluginDisplayName: routine.routine?.managedByPlugin?.pluginDisplayName ?? "LLM Wiki",
missingRefs: routine.missingRefs?.map((ref) => ({
resourceKind: ref.resourceKind,
resourceKey: ref.resourceKey,
})),
defaultDrift: routine.defaultDrift
? {
changedFields: routine.defaultDrift.changedFields,
defaultTitle: routine.defaultDrift.defaultTitle ?? null,
defaultDescription: routine.defaultDrift.defaultDescription ?? null,
}
: null,
};
});
const routineDefaultDriftItems = managedRoutineItems.filter((routine) => routine.defaultDrift?.changedFields.length);
const agentDefaultDrift = data.managedAgent.defaultDrift;
const activeSpaceSlug = readActiveSpaceSlugFromLocation(pathname);
function routineBusyKeyFor(prefix: string) {
const marker = `${prefix}:`;
return routineBusyKey?.startsWith(marker) ? routineBusyKey.slice(marker.length) : null;
}
async function changeFolder() {
if (!context.companyId || !folderPath.trim()) return;
setFolderBusy(true);
try {
await bootstrap({ companyId: context.companyId, path: folderPath.trim() });
toast({ tone: "success", title: "Folder updated" });
settings.refresh();
} catch (err) {
toast({ tone: "error", title: "Folder update failed", body: err instanceof Error ? err.message : String(err) });
} finally {
setFolderBusy(false);
}
}
async function chooseAgent() {
const agentId = selectedAgentId || data.managedAgent.agentId;
if (!context.companyId || !agentId) return;
setAgentBusy(true);
try {
await selectAgent({ companyId: context.companyId, agentId });
toast({ tone: "success", title: "Maintainer agent selected" });
settings.refresh();
} catch (err) {
toast({ tone: "error", title: "Agent selection failed", body: err instanceof Error ? err.message : String(err) });
} finally {
setAgentBusy(false);
}
}
async function chooseProject() {
const projectId = effectiveSelectedProjectId;
if (!context.companyId || !projectId) return;
setProjectBusy(true);
try {
await selectProject({ companyId: context.companyId, projectId });
toast({ tone: "success", title: "Project selected" });
settings.refresh();
} catch (err) {
toast({ tone: "error", title: "Project selection failed", body: err instanceof Error ? err.message : String(err) });
} finally {
setProjectBusy(false);
}
}
async function saveEventPolicy() {
if (!context.companyId || !eventPolicy) return;
setEventPolicyBusy(true);
try {
const next = await updateEventIngestion({ companyId: context.companyId, ...eventPolicy }) as EventIngestionSettings;
setEventPolicy(next);
toast({ tone: "success", title: "Event ingestion controls saved" });
settings.refresh();
} catch (err) {
toast({ tone: "error", title: "Could not save event controls", body: err instanceof Error ? err.message : String(err) });
} finally {
setEventPolicyBusy(false);
}
}
async function repairManagedRoutines() {
if (!context.companyId) return;
setRoutineRepairBusy(true);
try {
await reconcileRoutines({ companyId: context.companyId });
toast({ tone: "success", title: "Routines fixed" });
settings.refresh();
} catch (err) {
toast({ tone: "error", title: "Routine repair failed", body: err instanceof Error ? err.message : String(err) });
} finally {
setRoutineRepairBusy(false);
}
}
async function resyncManagedSkills() {
if (!context.companyId) return;
setSkillBusy(true);
try {
await resetSkills({ companyId: context.companyId });
toast({ tone: "success", title: "Skills synced" });
settings.refresh();
} catch (err) {
toast({ tone: "error", title: "Skill sync failed", body: err instanceof Error ? err.message : String(err) });
} finally {
setSkillBusy(false);
}
}
async function fixAllConfigurationErrors() {
if (!context.companyId || !hasConfigurationErrors) return;
const confirmed = typeof window === "undefined" || window.confirm(
"Fix all detected LLM Wiki configuration errors? This may recreate missing wiki baseline files and restore plugin-managed agents, projects, routines, and skills to their current defaults.",
);
if (!confirmed) return;
setAllRepairBusy(true);
try {
if (!data.folder.healthy) {
const path = folderPath.trim() || data.folder.path?.trim() || "";
if (!path && !data.folder.configured) {
throw new Error("Choose a wiki root folder path before fixing all configuration errors.");
}
await bootstrap(path ? { companyId: context.companyId, path } : { companyId: context.companyId });
}
if (skillHealthWarnings.length > 0) {
await resetSkills({ companyId: context.companyId });
}
const shouldResetAgent =
data.managedAgent.source !== "managed" ||
!managedAgentIsReady(data.managedAgent) ||
Boolean(data.managedAgent.defaultDrift?.changedFiles.length);
const shouldResetProject =
data.managedProject.source !== "managed" ||
!managedProjectIsReady(data.managedProject);
if (shouldResetAgent) {
await resetAgent({ companyId: context.companyId });
} else if (routineHealthWarnings.length > 0) {
await reconcileAgent({ companyId: context.companyId });
}
if (shouldResetProject) {
await resetProject({ companyId: context.companyId });
} else if (routineHealthWarnings.length > 0) {
await reconcileProject({ companyId: context.companyId });
}
if (routineHealthWarnings.length > 0) {
await reconcileRoutines({ companyId: context.companyId });
}
toast({ tone: "success", title: "Configuration errors fixed" });
settings.refresh();
} catch (err) {
toast({ tone: "error", title: "Fix all failed", body: err instanceof Error ? err.message : String(err) });
} finally {
setAllRepairBusy(false);
}
}
async function toggleManagedRoutine(routine: ManagedRoutinesListItem, enabled: boolean) {
if (!context.companyId || !routine.resourceKey) return;
if (!enabled && !routine.assigneeAgentId) {
toast({ tone: "warn", title: "Default agent required", body: "Set a default maintainer before enabling this routine." });
return;
}
setRoutineBusyKey(`status:${routine.key}`);
try {
await updateRoutineStatus({ companyId: context.companyId, routineKey: routine.resourceKey, status: enabled ? "paused" : "active" });
toast({ tone: "success", title: enabled ? "Routine paused" : "Routine enabled" });
settings.refresh();
} catch (err) {
toast({ tone: "error", title: "Routine update failed", body: err instanceof Error ? err.message : String(err) });
} finally {
setRoutineBusyKey(null);
}
}
async function runManagedRoutineNow(routine: ManagedRoutinesListItem) {
if (!context.companyId || !routine.resourceKey) return;
const assigneeAgentId = routine.assigneeAgentId ?? data.managedAgent.agentId ?? null;
const projectId = routine.projectId ?? data.managedProject.projectId ?? null;
if (!assigneeAgentId) {
toast({ tone: "warn", title: "Default agent required", body: "Set a default maintainer before running this routine." });
return;
}
setRoutineBusyKey(`run:${routine.key}`);
try {
await runManagedRoutine({
companyId: context.companyId,
routineKey: routine.resourceKey,
assigneeAgentId,
projectId,
});
toast({ tone: "success", title: "Routine run started" });
settings.refresh();
} catch (err) {
toast({ tone: "error", title: "Routine run failed", body: err instanceof Error ? err.message : String(err) });
} finally {
setRoutineBusyKey(null);
}
}
async function resetManagedRoutineToDefaults(routine: ManagedRoutinesListItem) {
if (!context.companyId || !routine.resourceKey) return;
const changedFields = (routine as ManagedRoutinesListItemWithDrift).defaultDrift?.changedFields ?? [];
const fieldList = changedFields.length > 0 ? changedFields.join(", ") : "managed defaults";
const confirmed = typeof window === "undefined" || window.confirm(
`Update "${routine.title}" to the current LLM Wiki plugin defaults? This replaces ${fieldList}. Cancel to keep the current custom routine text.`,
);
if (!confirmed) return;
const assigneeAgentId = routine.assigneeAgentId ?? data.managedAgent.agentId ?? null;
const projectId = routine.projectId ?? data.managedProject.projectId ?? null;
setRoutineBusyKey(`reset:${routine.key}`);
try {
await resetRoutine({
companyId: context.companyId,
routineKey: routine.resourceKey,
assigneeAgentId,
projectId,
});
toast({ tone: "success", title: "Routine defaults updated" });
settings.refresh();
} catch (err) {
toast({ tone: "error", title: "Routine reset failed", body: err instanceof Error ? err.message : String(err) });
} finally {
setRoutineBusyKey(null);
}
}
async function resetManagedAgentToDefaults() {
if (!context.companyId) return;
const changedFiles = agentDefaultDrift?.changedFiles ?? [];
const fileList = changedFiles.length > 0 ? changedFiles.join(", ") : "managed instructions and defaults";
const confirmed = typeof window === "undefined" || window.confirm(
`Update the Wiki Maintainer to the current LLM Wiki plugin defaults? This replaces ${fileList}. Cancel to keep the current custom instructions.`,
);
if (!confirmed) return;
setAgentBusy(true);
try {
await resetAgent({ companyId: context.companyId });
toast({ tone: "success", title: "Agent reset to plugin defaults" });
settings.refresh();
} catch (err) {
toast({ tone: "error", title: "Reset failed", body: err instanceof Error ? err.message : String(err) });
} finally {
setAgentBusy(false);
}
}
const activeSettingsConfig =
SETTINGS_SECTIONS.find((section) => section.key === activeSettingsSection) ?? SETTINGS_SECTIONS[0];
return (
<div style={{
display: "flex",
flexDirection: isMobile ? "column" : "row",
gap: isMobile ? 16 : 24,
maxWidth: isMobile ? "none" : 1040,
minWidth: 0,
}}>
<aside style={{
width: isMobile ? "auto" : 230,
flexShrink: 0,
borderRight: isMobile ? "none" : `1px solid ${tokens.border}`,
borderBottom: isMobile ? `1px solid ${tokens.border}` : "none",
paddingRight: isMobile ? 0 : 16,
paddingBottom: isMobile ? 12 : 0,
}}>
<nav aria-label="LLM Wiki settings sections" style={{
display: "flex",
flexDirection: isMobile ? "row" : "column",
gap: 4,
overflowX: isMobile ? "auto" : "visible",
paddingBottom: isMobile ? 2 : 0,
}}>
{SETTINGS_SECTIONS.map((section) => (
<div key={section.key} style={{ minWidth: isMobile ? 190 : 0 }}>
<SettingsSectionButton
section={section}
active={activeSettingsSection === section.key}
onSelect={() => {
setActiveSettingsSection(section.key);
hostNavigation.navigate(buildSettingsSectionHref(section.key, activeSpaceSlug));
}}
/>
</div>
))}
</nav>
</aside>
<div style={{ flex: 1, minWidth: 0 }}>
{hasConfigurationErrors ? (
<div style={{ marginBottom: 18 }}>
<Callout tone="warn">
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, flexWrap: "wrap" }}>
<div style={{ display: "grid", gap: 4, minWidth: 0 }}>
<strong>configuration errors detected, fix them all?</strong>
<Tiny>
{configurationErrors.join(", ")} {configurationErrors.length === 1 ? "needs" : "need"} attention.
</Tiny>
</div>
<Button size="sm" variant="primary" onClick={fixAllConfigurationErrors} loading={allRepairBusy}>
Fix them all
</Button>
</div>
</Callout>
</div>
) : null}
{activeSettingsSection === "root" ? (
<section style={{ display: "grid", gap: 22, minWidth: 0 }}>
<h1 style={{ margin: 0, fontSize: isMobile ? 20 : 22, fontWeight: 700 }}>Setup</h1>
<SetupSection title="Base Folder">
<div style={{ display: "grid", gap: 12 }}>
<FolderPathPicker
value={folderPath}
onChange={setFolderPath}
onApply={changeFolder}
applyLabel="Apply path"
busy={folderBusy}
disabled={!folderPath.trim()}
onRefresh={() => settings.refresh()}
/>
<FolderHealthChecklist folder={data.folder} />
{data.folder.problems.length > 0 ? (
<Callout tone="warn">{data.folder.problems.length} folder issue(s): {data.folder.problems.map((p) => p.message).join("; ")}</Callout>
) : null}
</div>
</SetupSection>
<SetupSection title="Managed Agents" separated>
<div style={{ display: "grid", gap: 14, maxWidth: isMobile ? "none" : 620, minWidth: 0 }}>
<ManagedResourceHealthChecklist
items={agentHealthItems}
ariaLabel="Wiki agents health checklist"
heading="Agent health"
/>
{agentHealthWarnings.length > 0 ? (
<Callout tone="warn">
<div style={{ display: "grid", gap: 8 }}>
<div>{agentHealthWarnings.length} agent issue(s) need attention.</div>
<ul style={{ margin: 0, paddingLeft: 18 }}>
{agentHealthWarnings.map((item) => <li key={item.label}>{item.detail}</li>)}
</ul>
</div>
</Callout>
) : null}
<div style={{ display: "grid", gap: 8 }}>
<label style={{ fontSize: 12, color: tokens.muted }}>Maintainer</label>
<fieldset
disabled={agentBusy}
style={{ border: 0, margin: 0, minWidth: 0, padding: 0 }}
>
<AssigneePicker
companyId={context.companyId}
value={effectiveSelectedAgentId ? `agent:${effectiveSelectedAgentId}` : ""}
includeUsers={false}
placeholder="Select maintainer"
noneLabel="No maintainer"
searchPlaceholder="Search agents..."
emptyMessage="No agents found."
onChange={(_value, selection) => {
setSelectedAgentId(selection.assigneeAgentId ?? "");
}}
/>
</fieldset>
<Tiny>
Adapter: {adapterTypeLabel(currentMaintainerAgent?.adapterType ?? data.managedAgent.details?.adapterType ?? null)}
</Tiny>
{maintainerPendingApproval ? (
<Callout tone="warn">
The Wiki Maintainer is pending approval. Approve the agent before relying on wiki ingest, query, lint, or scheduled maintenance tasks.
</Callout>
) : null}
{showMaintainerWarning ? (
<Callout tone="warn">
This is not the Paperclip-provided Wiki Maintainer. Plugin operations and routines may miss the recommended wiki role, tools, and default instructions.
</Callout>
) : null}
{agentDefaultDrift?.changedFiles.length ? (
<Callout tone="warn">
Wiki Maintainer instruction defaults changed: {agentDefaultDrift.changedFiles.join(", ")}. Reset only if you want to replace current custom instructions with the plugin template.
</Callout>
) : null}
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<Button
size="sm"
variant="primary"
onClick={chooseAgent}
loading={agentBusy}
disabled={!effectiveSelectedAgentId || effectiveSelectedAgentId === data.managedAgent.agentId}
>
Save maintainer
</Button>
{agentLink ? <Button size="sm" onClick={() => hostNavigation.navigate(agentLink)}>Open agent </Button> : null}
<Button size="sm" variant="ghost" onClick={async () => {
if (!context.companyId) return;
setAgentBusy(true);
try {
await reconcileAgent({ companyId: context.companyId });
toast({ tone: "success", title: "Agent reconciled" });
settings.refresh();
} catch (err) {
toast({ tone: "error", title: "Reconcile failed", body: err instanceof Error ? err.message : String(err) });
} finally {
setAgentBusy(false);
}
}} loading={agentBusy}>Repair</Button>
<Button size="sm" variant="ghost" onClick={resetManagedAgentToDefaults} loading={agentBusy}>Reset to defaults</Button>
</div>
</div>
</div>
</SetupSection>
<SetupSection title="Managed Skills" separated>
<div style={{ display: "grid", gap: 12, maxWidth: isMobile ? "none" : 620, minWidth: 0 }}>
<SkillHealthChecklist items={skillHealthItems} />
{skillHealthWarnings.length > 0 ? (
<Callout tone="warn">
<div style={{ display: "grid", gap: 8 }}>
<div>{skillHealthWarnings.length} skill issue(s) need attention.</div>
<ul style={{ margin: 0, paddingLeft: 18 }}>
{skillHealthWarnings.map((item) => <li key={item.label}>{item.detail}</li>)}
</ul>
<div>
<Button size="sm" variant="primary" onClick={resyncManagedSkills} loading={skillBusy}>Re-sync skills</Button>
</div>
</div>
</Callout>
) : (
<Callout>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, flexWrap: "wrap" }}>
<span>LLM Wiki skills are installed in the company skill library.</span>
<Button size="sm" variant="ghost" onClick={resyncManagedSkills} loading={skillBusy}>Re-sync skills</Button>
</div>
</Callout>
)}
</div>
</SetupSection>
<SetupSection title="Managed Projects" separated>
<div style={{ display: "grid", gap: 10, maxWidth: isMobile ? "none" : 620, minWidth: 0 }}>
<ManagedResourceHealthChecklist
items={projectHealthItems}
ariaLabel="Wiki projects health checklist"
heading="Project health"
/>
{projectHealthWarnings.length > 0 ? (
<Callout tone="warn">
<div style={{ display: "grid", gap: 8 }}>
<div>{projectHealthWarnings.length} project issue(s) need attention.</div>
<ul style={{ margin: 0, paddingLeft: 18 }}>
{projectHealthWarnings.map((item) => <li key={item.label}>{item.detail}</li>)}
</ul>
</div>
</Callout>
) : null}
<div style={{ display: "grid", gap: 8 }}>
<label style={{ fontSize: 12, color: tokens.muted }}>Use existing project</label>
<fieldset
disabled={projectBusy}
style={{ border: 0, margin: 0, minWidth: 0, padding: 0 }}
>
<ProjectPicker
companyId={context.companyId}
value={effectiveSelectedProjectId}
includeArchived
placeholder="Project"
noneLabel="No project"
searchPlaceholder="Search projects..."
emptyMessage="No projects found."
onChange={setSelectedProjectId}
/>
</fieldset>
<Tiny>
Status: {projectStatusLabel(currentProjectOption?.status ?? data.managedProject.details?.status ?? data.managedProject.status)}
</Tiny>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<Button size="sm" variant="primary" onClick={chooseProject} loading={projectBusy} disabled={!effectiveSelectedProjectId}>Save project</Button>
{projectLink ? <Button size="sm" onClick={() => hostNavigation.navigate(projectLink)}>Open project </Button> : null}
<Button size="sm" variant="ghost" onClick={async () => {
if (!context.companyId) return;
setProjectBusy(true);
try {
await reconcileProject({ companyId: context.companyId });
toast({ tone: "success", title: "Project reconciled" });
settings.refresh();
} catch (err) {
toast({ tone: "error", title: "Reconcile failed", body: err instanceof Error ? err.message : String(err) });
} finally {
setProjectBusy(false);
}
}} loading={projectBusy}>Repair / reconcile</Button>
<Button size="sm" variant="ghost" onClick={async () => {
if (!context.companyId) return;
setProjectBusy(true);
try {
await resetProject({ companyId: context.companyId });
toast({ tone: "success", title: "Project reset to plugin defaults" });
settings.refresh();
} catch (err) {
toast({ tone: "error", title: "Reset failed", body: err instanceof Error ? err.message : String(err) });
} finally {
setProjectBusy(false);
}
}} loading={projectBusy}> Reset to plugin defaults</Button>
</div>
</div>
</div>
</SetupSection>
<SetupSection title="Managed Routines" separated>
<div style={{ display: "grid", gap: 12, maxWidth: isMobile ? "none" : 620, minWidth: 0 }}>
<RoutineHealthChecklist items={routineHealthItems} />
{routineHealthWarnings.length > 0 ? (
<Callout tone="warn">
<div style={{ display: "grid", gap: 8 }}>
<div>{routineHealthWarnings.length} routine issue(s) need attention.</div>
<ul style={{ margin: 0, paddingLeft: 18 }}>
{routineHealthWarnings.map((item) => <li key={item.label}>{item.detail}</li>)}
</ul>
<div>
<Button size="sm" variant="primary" onClick={repairManagedRoutines} loading={routineRepairBusy}>Fix routines</Button>
</div>
</div>
</Callout>
) : routineDefaultDriftItems.length > 0 ? (
<Callout tone="warn">
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, flexWrap: "wrap" }}>
<span>{routineDefaultDriftItems.length} routine default update{routineDefaultDriftItems.length === 1 ? "" : "s"} available.</span>
<Button size="sm" variant="primary" onClick={() => {
setActiveSettingsSection("routines");
hostNavigation.navigate(buildSettingsSectionHref("routines", activeSpaceSlug));
}}>Review defaults</Button>
</div>
</Callout>
) : (
<Tiny>Managed routines are installed with the Wiki Maintainer and LLM Wiki project.</Tiny>
)}
</div>
</SetupSection>
</section>
) : activeSettingsSection === "distillation" ? (
<SettingsPanel
title="Distillation"
badge={<Badge tone="default">Default space only</Badge>}
description="Read Paperclip issues, comments, and documents for this company and write project pages into the default wiki space. Assets/attachments and work products stay metadata-only in Phase 5 and are excluded from source-text extraction. Other spaces cannot be selected as a destination yet - that lands with per-space Paperclip ingestion profiles."
>
<DistillationSettingsPanel context={context} settings={data} />
</SettingsPanel>
) : activeSettingsSection === "routines" ? (
<SettingsPanel title="Managed Routines" description={activeSettingsConfig.description}>
<div style={{ display: "grid", gap: 12 }}>
{routineDefaultDriftItems.length > 0 ? (
<Callout tone="warn">
<div style={{ display: "grid", gap: 6 }}>
<strong>Routine defaults changed.</strong>
<span>
Review rows marked with changed defaults. Reset a row to update it to the current LLM Wiki instructions, or leave it unchanged to keep custom routine text.
</span>
</div>
</Callout>
) : null}
{routineHealthWarnings.length > 0 ? (
<Callout tone="warn">
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 12, flexWrap: "wrap" }}>
<div style={{ display: "grid", gap: 6, minWidth: 0 }}>
<strong>Routine setup needs repair.</strong>
<ul style={{ margin: 0, paddingLeft: 18 }}>
{routineHealthWarnings.map((item) => <li key={item.label}>{item.detail}</li>)}
</ul>
</div>
<Button size="sm" variant="primary" onClick={repairManagedRoutines} loading={routineRepairBusy}>Fix routines</Button>
</div>
</Callout>
) : (
<Callout>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, flexWrap: "wrap" }}>
<span>Managed routines are installed with the Wiki Maintainer and LLM Wiki project.</span>
<Button size="sm" disabled>Routines valid</Button>
</div>
</Callout>
)}
<PluginManagedRoutinesList
routines={managedRoutineItems}
agents={maintainerAgentOptions}
projects={projectOptions}
pluginDisplayName="LLM Wiki"
runningRoutineKey={routineBusyKeyFor("run")}
statusMutationRoutineKey={routineBusyKeyFor("status")}
resettingRoutineKey={routineBusyKeyFor("reset")}
onRunNow={runManagedRoutineNow}
onToggleEnabled={toggleManagedRoutine}
onReset={resetManagedRoutineToDefaults}
/>
</div>
</SettingsPanel>
) : activeSettingsSection === "lint" ? (
<SettingsLintPanel context={context} />
) : activeSettingsSection === "spaces" ? (
<SpacesSettingsPanel context={context} description={activeSettingsConfig.description} />
) : activeSettingsSection === "events" ? (
<SettingsPanel
title="Paperclip event ingestion"
badge={<Badge tone={currentEventPolicy.enabled ? "running" : "default"}>{currentEventPolicy.enabled ? "enabled" : "off by default"}</Badge>}
description={activeSettingsConfig.description}
>
<Tiny style={{ marginBottom: 10 }}>
Company-scoped Paperclip events can advance default-space cursors. Enable only the first-party text sources this wiki should observe for default-space distillation.
</Tiny>
<div style={{ display: "grid", gap: 10 }}>
<label style={{ display: "flex", gap: 8, alignItems: "center", fontSize: 13 }}>
<input
type="checkbox"
checked={currentEventPolicy.enabled}
onChange={(event) => setEventPolicy({ ...currentEventPolicy, enabled: event.currentTarget.checked })}
/>
Enable event ingestion for this company
</label>
<div style={{ display: "grid", gap: 8, paddingLeft: isMobile ? 0 : 22 }}>
{([
["issues", "Issues", "Capture issue title and description when issue events fire."],
["comments", "Comments", "Capture comment body when comment-created events fire."],
["documents", "Documents", "Capture document body when document-created or document-updated events fire."],
] as const).map(([key, label, help]) => (
<label key={key} style={{ display: "grid", gridTemplateColumns: "auto 1fr", columnGap: 8, rowGap: 2, alignItems: "start", fontSize: 13 }}>
<input
type="checkbox"
checked={currentEventPolicy.sources[key]}
onChange={(event) => setEventPolicy({
...currentEventPolicy,
sources: { ...currentEventPolicy.sources, [key]: event.currentTarget.checked },
})}
/>
<span>
{label}
<Tiny style={{ display: "block" }}>{help}</Tiny>
</span>
</label>
))}
</div>
<div style={{ display: "grid", gap: 6, maxWidth: isMobile ? "none" : 220 }}>
<label style={{ fontSize: 12, color: tokens.muted }}>Max characters per captured event</label>
<TextInput
value={String(currentEventPolicy.maxCharacters)}
onChange={(event) => {
const parsed = Number(event.currentTarget.value);
setEventPolicy({ ...currentEventPolicy, maxCharacters: Number.isFinite(parsed) ? parsed : currentEventPolicy.maxCharacters });
}}
/>
</div>
<Callout tone="warn">
Event ingestion records selected Paperclip issue, comment, and document activity for the default wiki space. Assets/attachments and work products are excluded here: Phase 5 allows metadata-only references later, not blob reads or linked-content fetches. It never reads across companies or creates non-default space cursors.
</Callout>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<Button size="sm" variant="primary" onClick={saveEventPolicy} loading={eventPolicyBusy}>Save controls</Button>
<Button size="sm" variant="ghost" onClick={() => setEventPolicy(data.eventIngestion)}>Revert</Button>
</div>
</div>
</SettingsPanel>
) : null}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Spaces settings — list of spaces with sub-nav and a per-space edit panel.
// Reachable via /wiki/settings/spaces or /wiki/settings/spaces/<slug>.
// ---------------------------------------------------------------------------
function SpacesSettingsPanel({ context, description }: { context: { companyId: string | null; companyPrefix?: string | null }; description: string }) {
const { pathname } = useHostLocation();
const hostNavigation = useHostNavigation();
const activeSpaceSlug = useMemo(() => readActiveSpaceSlugFromLocation(pathname), [pathname]);
const editingSlug = useMemo(() => readSettingsSpaceSlugFromLocation(pathname), [pathname]);
const isMobile = useIsMobileLayout();
const spacesQuery = useSpaces(context.companyId);
const spaces = useMemo(() => {
const list = spacesQuery.data?.spaces ?? [];
return activeWikiSpaces(list).sort(compareSpaces);
}, [spacesQuery.data]);
const [createOpen, setCreateOpen] = useState(false);
const focusedSpace = useMemo(() => {
if (editingSlug) return spaces.find((s) => s.slug === editingSlug) ?? null;
return spaces.find((s) => s.slug === activeSpaceSlug) ?? spaces.find((s) => s.slug === DEFAULT_SPACE_SLUG) ?? spaces[0] ?? null;
}, [editingSlug, spaces, activeSpaceSlug]);
return (
<SettingsPanel title="Shared spaces" description={description}>
<div style={{ display: "grid", gridTemplateColumns: isMobile ? "1fr" : "220px 1fr", gap: 18, alignItems: "flex-start" }}>
<aside style={{ display: "grid", gap: 4, minWidth: 0 }}>
{spaces.map((space) => {
const active = focusedSpace?.slug === space.slug;
return (
<button
key={space.slug}
type="button"
onClick={() => hostNavigation.navigate(buildSettingsSectionHref("spaces", activeSpaceSlug, space.slug))}
style={{
textAlign: "left",
background: active ? tokens.accent : "transparent",
border: `1px solid ${active ? tokens.border : "transparent"}`,
borderRadius: 6,
padding: "8px 10px",
color: tokens.fg,
cursor: "pointer",
fontFamily: fontStack,
display: "grid",
gap: 2,
}}
>
<span style={{ fontSize: 13, fontWeight: 600 }}>{space.displayName}</span>
<span style={{ fontSize: 11, color: tokens.muted, fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace" }}>{space.slug}</span>
</button>
);
})}
<button
type="button"
onClick={() => setCreateOpen(true)}
style={{
textAlign: "left",
background: "transparent",
border: `1px dashed ${tokens.border}`,
borderRadius: 6,
padding: "8px 10px",
color: "oklch(0.78 0.13 250)",
cursor: "pointer",
fontFamily: fontStack,
display: "flex",
alignItems: "center",
gap: 8,
fontSize: 13,
fontWeight: 600,
marginTop: 6,
}}
>
<PlusIcon size={14} />
Add space
</button>
</aside>
<div style={{ minWidth: 0, display: "grid", gap: 14 }}>
{spacesQuery.loading && spaces.length === 0 ? <Tiny>Loading spaces</Tiny> : null}
{spacesQuery.error ? <Callout tone="danger">Failed to load spaces: {spacesQuery.error.message}</Callout> : null}
{focusedSpace ? (
<SpaceEditCard
space={focusedSpace}
companyId={context.companyId}
isOnlySpace={spaces.length === 1}
refresh={spacesQuery.refresh}
onArchived={() => {
hostNavigation.navigate(buildSettingsSectionHref("spaces", activeSpaceSlug));
}}
/>
) : (
<Callout>Pick a space from the list, or create one with the Add space button.</Callout>
)}
</div>
</div>
{createOpen && context.companyId ? (
<CreateSpaceModal
companyId={context.companyId}
existingSlugs={new Set(spaces.map((s) => s.slug))}
onClose={() => setCreateOpen(false)}
onCreated={(space) => {
setCreateOpen(false);
spacesQuery.refresh();
hostNavigation.navigate(buildSettingsSectionHref("spaces", activeSpaceSlug, space.slug));
}}
/>
) : null}
</SettingsPanel>
);
}
function SpaceEditCard({
space,
companyId,
isOnlySpace,
refresh,
onArchived,
}: {
space: WikiSpace;
companyId: string | null;
isOnlySpace: boolean;
refresh: () => void;
onArchived: () => void;
}) {
const updateSpace = usePluginAction("update-space");
const archiveSpace = usePluginAction("archive-space");
const bootstrapSpace = usePluginAction("bootstrap-space");
const folderStatusQuery = useSpaceFolderStatus(companyId, space.slug);
const toast = usePluginToast();
const isDefault = space.slug === DEFAULT_SPACE_SLUG;
const [displayName, setDisplayName] = useState(space.displayName);
const [busy, setBusy] = useState(false);
const [folderBusy, setFolderBusy] = useState(false);
const [archiveBusy, setArchiveBusy] = useState(false);
useEffect(() => {
setDisplayName(space.displayName);
}, [space.slug, space.displayName]);
const folder = folderStatusQuery.data?.folder ?? null;
const relativeRoot = folderStatusQuery.data?.relativeRoot ?? "";
const settingsRecord = (space.settings ?? {}) as Record<string, unknown>;
const folderModeLabel = space.folderMode === "managed_subfolder"
? "New managed folder (under wiki root)"
: space.folderMode === "existing_local_folder"
? "Existing folder under wiki root"
: space.folderMode;
async function saveName() {
if (!companyId || displayName.trim().length === 0 || displayName.trim() === space.displayName) return;
setBusy(true);
try {
await updateSpace({ companyId, spaceSlug: space.slug, displayName: displayName.trim() });
toast({ tone: "success", title: "Display name updated" });
refresh();
} catch (err) {
toast({ tone: "error", title: "Update failed", body: err instanceof Error ? err.message : String(err) });
} finally {
setBusy(false);
}
}
async function recreateBaseline() {
if (!companyId || folderBusy) return;
setFolderBusy(true);
try {
await bootstrapSpace({ companyId, spaceSlug: space.slug });
toast({ tone: "success", title: "Baseline restored", body: `Re-created the standard skeleton for ${space.displayName}.` });
folderStatusQuery.refresh();
} catch (err) {
toast({ tone: "error", title: "Bootstrap failed", body: err instanceof Error ? err.message : String(err) });
} finally {
setFolderBusy(false);
}
}
async function archive() {
if (!companyId || isDefault || isOnlySpace || archiveBusy) return;
if (typeof window !== "undefined" && !window.confirm(`Archive ${space.displayName}? Pages stay on disk; you can restore later through the plugin API or by un-archiving from the database.`)) {
return;
}
setArchiveBusy(true);
try {
await archiveSpace({ companyId, spaceSlug: space.slug });
toast({ tone: "success", title: "Space archived", body: `${space.displayName} hidden from the sidebar.` });
refresh();
onArchived();
} catch (err) {
toast({ tone: "error", title: "Archive failed", body: err instanceof Error ? err.message : String(err) });
} finally {
setArchiveBusy(false);
}
}
return (
<div style={{ display: "grid", gap: 12 }}>
<Card>
<CardHeader
title={
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
<FolderIcon size={16} />
<span>{space.displayName}</span>
<Badge tone="default" style={{ fontSize: 10 }}>{space.slug}</Badge>
<Badge tone={space.status === "active" ? "running" : "default"} style={{ fontSize: 10 }}>{space.status}</Badge>
</span>
}
right={
<span style={{ fontSize: 11, color: tokens.muted }}>
{space.spaceType} · {space.accessScope}
</span>
}
/>
<CardBody>
<Tiny>
Stored under <Mono>{relativeRoot || (isDefault ? "(wiki root)" : `spaces/${space.slug}/`)}</Mono> within the configured wiki root.
</Tiny>
</CardBody>
</Card>
<Card>
<CardHeader title="Identity" />
<CardBody>
<div style={{ display: "grid", gap: 12 }}>
<FormField label="Display name">
<TextInput value={displayName} onChange={(event) => setDisplayName(event.target.value)} maxLength={120} />
</FormField>
<FormField label="Slug" help="Slug is locked once a space has indexed pages. Contact platform team to migrate.">
<TextInput value={space.slug} disabled style={{ fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", opacity: 0.7 }} />
</FormField>
<FormField label="Type" help="(Cloud connectors coming soon)">
<TextInput value={space.spaceType === "managed" ? "Folder" : space.spaceType} disabled style={{ opacity: 0.7 }} />
</FormField>
<div>
<Button variant="primary" size="sm" onClick={saveName} disabled={busy || displayName.trim() === space.displayName} loading={busy}>Save name</Button>
</div>
</div>
</CardBody>
</Card>
<Card>
<CardHeader title="Folder source & health" />
<CardBody>
<div style={{ display: "grid", gap: 12 }}>
<FormField label="Mode">
<TextInput value={folderModeLabel} disabled style={{ opacity: 0.7 }} />
</FormField>
<FormField label="Path">
<TextInput value={folder?.path ?? folder?.realPath ?? relativeRoot ?? "(unconfigured)"} disabled style={{ fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", opacity: 0.7 }} />
</FormField>
{folderStatusQuery.loading ? <Tiny>Loading folder status</Tiny> : null}
{folderStatusQuery.error ? <Callout tone="danger">{folderStatusQuery.error.message}</Callout> : null}
{folder ? <SpaceFolderHealthChecklist folder={folder} /> : null}
<div>
<Button size="sm" onClick={recreateBaseline} loading={folderBusy}>Recreate baseline</Button>
</div>
</div>
</CardBody>
</Card>
<PaperclipIngestionSpaceCard companyId={companyId} space={space} refresh={refresh} />
<Card style={{ opacity: 0.56 }}>
<CardHeader title="Access" />
<CardBody>
<div aria-disabled="true" style={{ display: "grid", gap: 10 }}>
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
<Badge tone="default" style={{ fontSize: 10 }}>{space.accessScope}</Badge>
<Badge tone="default" style={{ fontSize: 10 }}>Coming soon</Badge>
</div>
<Tiny>
Access scope is stored as metadata only. <Mono>shared</Mono>, <Mono>team</Mono>, and{" "}
<Mono>personal</Mono> are saved on the space record but do not currently enforce
read/write permissions, and they do not change which Paperclip sources reach this space.
</Tiny>
<FormField label="Owner user id">
<TextInput value={(settingsRecord.ownerUserHint as string | undefined) ?? space.ownerUserId ?? ""} disabled style={{ fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace" }} />
</FormField>
<FormField label="Owner team key">
<TextInput value={space.teamKey ?? ""} disabled style={{ fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace" }} />
</FormField>
</div>
</CardBody>
</Card>
<Card style={{ borderColor: "oklch(0.5 0.18 25)" }}>
<CardHeader
title={<span style={{ color: "oklch(0.78 0.18 25)" }}>Danger zone</span>}
/>
<CardBody>
<div style={{ display: "grid", gap: 8 }}>
<Tiny>
{isDefault
? "The default space cannot be archived because new operations and tools fall back to it."
: isOnlySpace
? "This is the only space in the company. Create another before archiving this one."
: "Archiving hides the space from the sidebar and pauses scheduled lint/index. Pages remain on disk."}
</Tiny>
<div>
<Button
variant="destructive"
size="sm"
onClick={archive}
disabled={isDefault || isOnlySpace || archiveBusy}
loading={archiveBusy}
title={isDefault ? "Default space cannot be archived" : isOnlySpace ? "At least one space must remain" : undefined}
>
Archive this space
</Button>
</div>
</div>
</CardBody>
</Card>
</div>
);
}
function paperclipIngestionStateBadge(data: PaperclipIngestionProfileData | null): { tone: Tone; label: string } {
if (!data) return { tone: "default", label: "Loading" };
if (data.effectiveState === "policy_blocked") return { tone: "blocked", label: "Locked" };
if (data.effectiveState === "pending_approval") return { tone: "queued", label: "Pending approval" };
if (data.effectiveState === "enabled_no_scopes") return { tone: "failed", label: "Misconfigured" };
if (data.effectiveState === "enabled") return { tone: "running", label: `On · ${data.profile.sourceScopes.length} source${data.profile.sourceScopes.length === 1 ? "" : "s"}` };
return { tone: "default", label: data.historicalPageCount > 0 ? `Off · ${data.historicalPageCount} historical pages` : "Off" };
}
function PaperclipIngestionSpaceCard({ companyId, space, refresh }: { companyId: string | null; space: WikiSpace; refresh: () => void }) {
const profileQuery = usePaperclipIngestionProfile(companyId, space.slug);
const updateProfile = usePluginAction("update-paperclip-ingestion-profile");
const toast = usePluginToast();
const data = profileQuery.data ?? null;
const [draft, setDraft] = useState<PaperclipIngestionProfile | null>(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
setDraft(data?.profile ?? null);
}, [data?.space.slug, data?.profile]);
const badge = paperclipIngestionStateBadge(data);
const locked = data?.effectiveState === "policy_blocked";
const sourceScope = draft?.sourceScopes[0];
const activeProjectLimit = sourceScope?.kind === "active_projects" ? sourceScope.limit : 3;
const canSave = Boolean(companyId && draft && !busy && !locked);
const emptyScopes = Boolean(draft?.enabled && draft.sourceScopes.length === 0);
function patchDraft(patch: Partial<PaperclipIngestionProfile>) {
setDraft((current) => current ? { ...current, ...patch } : current);
}
function setSourceKind(key: WikiEventIngestionSource, value: boolean) {
setDraft((current) => current
? { ...current, sourceKinds: { ...current.sourceKinds, [key]: value } }
: current);
}
function setActiveProjectsLimit(value: number) {
setDraft((current) => current
? {
...current,
sourceScopes: [{ kind: "active_projects", limit: Math.max(1, Math.floor(value || 1)) }],
}
: current);
}
async function save() {
if (!companyId || !draft || locked) return;
setBusy(true);
try {
await updateProfile({ companyId, spaceSlug: space.slug, profile: draft });
toast({ tone: "success", title: "Paperclip ingestion profile saved", body: `${space.displayName} will use the selected Paperclip sources.` });
profileQuery.refresh();
refresh();
} catch (err) {
toast({ tone: "error", title: "Profile save failed", body: err instanceof Error ? err.message : String(err) });
} finally {
setBusy(false);
}
}
return (
<Card>
<CardHeader
title={<span>Paperclip {space.displayName}</span>}
right={<Badge tone={badge.tone} style={{ fontSize: 10 }}>{badge.label}</Badge>}
/>
<CardBody>
<div style={{ display: "grid", gap: 12 }}>
{profileQuery.loading && !data ? <Tiny>Loading Paperclip ingestion profile</Tiny> : null}
{profileQuery.error ? <Callout tone="danger">{profileQuery.error.message}</Callout> : null}
{locked ? (
<Callout tone="warn">
Locked host permissions pending. Paperclip ingestion stays disabled on team and personal spaces until LLM Wiki enforces read/write permissions for non-shared spaces.
</Callout>
) : null}
{data && data.historicalPageCount > 0 && data.effectiveState === "disabled" ? (
<Callout>
Off · {data.historicalPageCount} historical Paperclip page{data.historicalPageCount === 1 ? "" : "s"} still in this space. Disabling stops new observations but does not delete prior wiki pages.
</Callout>
) : null}
{data && data.overlapCount > 0 ? (
<Callout>
{data.overlapCount} source overlap{data.overlapCount === 1 ? "" : "s"} with another enabled space. Duplicate destinations are allowed, but they are explicit.
</Callout>
) : null}
{emptyScopes ? <Callout tone="warn">Pick at least one source scope before saving.</Callout> : null}
{draft ? (
<>
<label style={{ display: "flex", gap: 8, alignItems: "flex-start", fontSize: 13 }}>
<input
type="checkbox"
checked={draft.enabled}
disabled={locked || busy}
onChange={(event) => patchDraft({
enabled: event.currentTarget.checked,
sourceScopes: event.currentTarget.checked && draft.sourceScopes.length === 0
? [{ kind: "active_projects", limit: activeProjectLimit }]
: draft.sourceScopes,
})}
/>
<span>
Enable Paperclip ingestion for this destination space
<Tiny style={{ display: "block" }}>Future Paperclip issue, comment, and document events can advance cursors in {space.displayName}. Existing pages are preserved when this is turned off.</Tiny>
</span>
</label>
<div style={{ display: "grid", gap: 8 }}>
<strong style={{ fontSize: 13 }}>Source scope</strong>
<label style={{ display: "grid", gap: 6, maxWidth: 260 }}>
<Tiny>Recently active projects (auto)</Tiny>
<TextInput
value={String(activeProjectLimit)}
disabled={locked || busy}
onChange={(event) => setActiveProjectsLimit(Number(event.currentTarget.value))}
/>
</label>
<Tiny>Specific projects, issue trees, and company-wide ingestion use the same profile API; this first editor keeps the default auto-scope path visible and capped.</Tiny>
</div>
<div style={{ display: "grid", gap: 8 }}>
<strong style={{ fontSize: 13 }}>Source kinds</strong>
{([
["issues", "Issues"],
["comments", "Comments"],
["documents", "Documents"],
] as const).map(([key, label]) => (
<label key={key} style={{ display: "flex", gap: 8, alignItems: "center", fontSize: 13 }}>
<input
type="checkbox"
checked={draft.sourceKinds[key]}
disabled={locked || busy}
onChange={(event) => setSourceKind(key, event.currentTarget.checked)}
/>
{label}
</label>
))}
<Tiny>Attachments locked, metadata only; no file contents. Future extraction needs separate review.</Tiny>
<Tiny>Work products locked, metadata only; no artifact contents.</Tiny>
</div>
<div style={{ display: "grid", gap: 8 }}>
<strong style={{ fontSize: 13 }}>Caps</strong>
<Tiny>
Defaults: {draft.cursor.maxWindowCharacters.toLocaleString()} chars/window · {draft.cursor.maxCharactersPerSource.toLocaleString()} chars/source.
</Tiny>
</div>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<Button size="sm" variant="primary" onClick={save} loading={busy} disabled={!canSave || emptyScopes}>Save Paperclip profile</Button>
<Button size="sm" variant="ghost" onClick={() => setDraft(data?.profile ?? null)} disabled={busy}>Revert</Button>
</div>
</>
) : null}
</div>
</CardBody>
</Card>
);
}
function SpaceFolderHealthChecklist({ folder }: { folder: FolderStatus }) {
const items = [
{ key: "readable", label: "Folder readable", ok: folder.readable },
{ key: "writable", label: "Folder writable", ok: folder.writable },
...folder.requiredDirectories.map((dir) => ({
key: `dir-${dir}`,
label: `${dir}/ present`,
ok: !folder.missingDirectories.includes(dir),
})),
...folder.requiredFiles.map((file) => ({
key: `file-${file}`,
label: `${file} present`,
ok: !folder.missingFiles.includes(file),
})),
];
return (
<div role="list" aria-label="Space folder health checklist" style={{ position: "relative", display: "grid", gap: 0, padding: "2px 0" }}>
{items.length > 1 ? (
<span
aria-hidden
style={{
position: "absolute",
left: 8,
top: 12,
bottom: 12,
width: 1,
background: "oklch(0.38 0.09 145)",
}}
/>
) : null}
{items.map((item) => (
<div
key={item.key}
role="listitem"
style={{
display: "grid",
gridTemplateColumns: "18px minmax(0, 1fr)",
alignItems: "center",
gap: 10,
padding: "7px 0",
minWidth: 0,
}}
>
<span style={{ display: "inline-flex", justifyContent: "center", position: "relative", zIndex: 1, background: tokens.bg }}>
<StatusIcon status={item.ok ? "done" : "blocked"} />
</span>
<span style={{ fontSize: 13, fontWeight: 600, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", color: item.ok ? tokens.fg : "oklch(0.85 0.1 70)" }}>
{item.label}
</span>
</div>
))}
</div>
);
}