Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
7c64f30
convoy(implementer): Implementer Report — GitHub Repositories as Know…
Jul 20, 2026
64c9f26
convoy(patterns): Pattern Audit — GitHub Repositories as Knowledge Ba…
Jul 20, 2026
be0a825
convoy(security): Security Audit — GitHub Repositories as Knowledge B…
Jul 20, 2026
8e7e676
convoy(design): Design Polish — GitHub Repositories as Knowledge Base…
Jul 20, 2026
2af528b
convoy(tests): Test Report — GitHub Repositories as Knowledge Base So…
Jul 20, 2026
8ba03de
convoy(adversarial): Adversarial Review — GitHub Repositories as Know…
Jul 20, 2026
b170db5
feat(web): add dependencies for PDF export
albertoperdomo2 Jul 22, 2026
3467397
feat(web): add server-side markdown-to-HTML renderer for PDF export
albertoperdomo2 Jul 22, 2026
c598d62
feat(web): add PDF export API route and client utility
albertoperdomo2 Jul 22, 2026
b7fbfad
feat(web): wire Export as PDF into file tree context menu
albertoperdomo2 Jul 22, 2026
30317f8
chore(web): add Chromium deps to Containerfile and update coverage badge
albertoperdomo2 Jul 22, 2026
686c9c0
refactor(web): address code review on PDF export
albertoperdomo2 Jul 22, 2026
15aa20c
fix(web): resolve unified processor type error in CI build
albertoperdomo2 Jul 22, 2026
d085f1b
feat(web): sync active KB file path to URL for deep linking
albertoperdomo2 Jul 22, 2026
84cb9d9
fix(web): bump MCP request body limit from 64 KB to 1 MB
albertoperdomo2 Jul 22, 2026
31c7cae
fix(web): load initial file content on mount for deep link support
albertoperdomo2 Jul 22, 2026
b5b94fd
fix(web): auto-expand file tree to reveal active file on deep link
albertoperdomo2 Jul 22, 2026
9f4c242
fix(web): expand file tree ancestors when nodes load asynchronously
albertoperdomo2 Jul 22, 2026
6871a21
fix(web): address security review findings for PDF export (H-001 to H…
albertoperdomo2 Jul 22, 2026
fd029aa
fix(web): bump PDF export markdown size limit to 4MB
albertoperdomo2 Jul 22, 2026
5094122
fix(web): address review feedback on GitHub KB connector
albertoperdomo2 Jul 22, 2026
fbe0f4f
chore(web): update coverage badges
albertoperdomo2 Jul 23, 2026
2469afe
feat(web): reduce PDF font size to 9.5pt and add Arche logo to header
albertoperdomo2 Jul 23, 2026
c55348a
fix: restore root pnpm-lock.yaml to minimal state
albertoperdomo2 Jul 23, 2026
f0d7e56
fix(web): wrap Uint8Array in Buffer.from for Response body compatibility
albertoperdomo2 Jul 23, 2026
7304258
fix(web): use SVG logo instead of PNG favicon for PDF header
albertoperdomo2 Jul 23, 2026
94c123b
fix(web): fix PDF header logo overlapping with body text
albertoperdomo2 Jul 23, 2026
03a2a2b
Merge remote-tracking branch 'origin/feat/kb-url-deep-linking' into t…
albertoperdomo2 Jul 23, 2026
3863257
Merge remote-tracking branch 'origin/convoy/github-kb-sources' into t…
albertoperdomo2 Jul 23, 2026
e84cf7f
chore(web): raise MCP endpoint body limit to 10 MiB
albertoperdomo2 Jul 23, 2026
eecd8b2
feat(web): add settings dialog for editing GitHub connector pinned repos
albertoperdomo2 Jul 23, 2026
def6b6d
fix(web): remove nonexistent compare_commits from GitHub MCP tool list
albertoperdomo2 Jul 23, 2026
6ee7642
Merge remote-tracking branch 'origin/convoy/github-kb-sources' into t…
albertoperdomo2 Jul 23, 2026
292dc97
fix(web): update test for compare_commits removal, enable reportOnFai…
albertoperdomo2 Jul 23, 2026
c3a3b72
fix(web): make file tree expansion pure to work under React strict mode
albertoperdomo2 Jul 23, 2026
6d4c2c9
Revert "fix(web): make file tree expansion pure to work under React s…
albertoperdomo2 Jul 24, 2026
e8ec544
Merge branch 'main' into tmp/latest-features
albertoperdomo2 Jul 24, 2026
9f914f4
Merge branch 'main' into tmp/latest-features
albertoperdomo2 Jul 25, 2026
e6f3b2f
feat(web): persist open files to localStorage and fix tree expansion
albertoperdomo2 Jul 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/web/src/app/api/mcp/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ describe('POST /api/mcp', () => {
})

it('rejects oversized requests before authentication', async () => {
const response = await POST(createRequest({ headers: { 'content-length': String(64 * 1024 + 1) } }))
const response = await POST(createRequest({ headers: { 'content-length': String(10 * 1024 * 1024 + 1) } }))

expect(response.status).toBe(413)
await expect(response.json()).resolves.toEqual({ error: 'payload_too_large' })
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/app/api/mcp/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { auditService, mcpSettingsService, rateLimitService } from '@/lib/servic
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'

const MCP_MAX_BODY_BYTES = 64 * 1024
const MCP_MAX_BODY_BYTES = 10 * 1024 * 1024
const MCP_PREAUTH_RATE_LIMIT_MAX = 300
const MCP_PREAUTH_RATE_LIMIT_WINDOW_MS = 60 * 1000
const MCP_TOKEN_RATE_LIMIT_MAX = 100
Expand Down
106 changes: 106 additions & 0 deletions apps/web/src/components/workspace/__tests__/file-tree-panel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,112 @@ describe("FileTreePanel", () => {
expect(onDownloadFile).toHaveBeenCalledWith("alpha.md");
});

it("auto-expands top-level directories when nodes arrive", async () => {
render(
<FileTreePanel
nodes={nodes}
activePath={null}
onSelect={() => {}}
/>
);

expect(await screen.findByRole("button", { name: /notes.md/i })).toBeTruthy();
});

it("expands ancestor directories of the active file path", async () => {
const deepNodes: WorkspaceFileNode[] = [
{
id: "docs",
name: "docs",
path: "docs",
type: "directory",
children: [
{
id: "docs/research",
name: "research",
path: "docs/research",
type: "directory",
children: [
{
id: "docs/research/analysis.md",
name: "analysis.md",
path: "docs/research/analysis.md",
type: "file",
},
],
},
],
},
];

render(
<FileTreePanel
nodes={deepNodes}
activePath="docs/research/analysis.md"
onSelect={() => {}}
/>
);

expect(await screen.findByRole("button", { name: /analysis.md/i })).toBeTruthy();
});

it("preserves user-collapsed directories on subsequent updates", async () => {
const onSelect = vi.fn();

const { rerender } = render(
<FileTreePanel
nodes={nodes}
activePath={null}
onSelect={onSelect}
/>
);

const docsButton = await screen.findByRole("button", { name: /^docs$/i });
expect(screen.getByRole("button", { name: /notes.md/i })).toBeTruthy();

fireEvent.click(docsButton);

expect(screen.queryByRole("button", { name: /notes.md/i })).toBeNull();

rerender(
<FileTreePanel
nodes={nodes}
activePath="alpha.md"
onSelect={onSelect}
/>
);

expect(screen.queryByRole("button", { name: /notes.md/i })).toBeNull();
});

it("expands a collapsed dir when navigating to a file inside it", async () => {
const onSelect = vi.fn();

const { rerender } = render(
<FileTreePanel
nodes={nodes}
activePath={null}
onSelect={onSelect}
/>
);

const docsButton = await screen.findByRole("button", { name: /^docs$/i });
expect(screen.getByRole("button", { name: /notes.md/i })).toBeTruthy();

fireEvent.click(docsButton);
expect(screen.queryByRole("button", { name: /notes.md/i })).toBeNull();

rerender(
<FileTreePanel
nodes={nodes}
activePath="docs/notes.md"
onSelect={onSelect}
/>
);

expect(await screen.findByRole("button", { name: /notes.md/i })).toBeTruthy();
});

it("supports downloading files from search results too", async () => {
const onDownloadFile = vi.fn();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1088,7 +1088,7 @@ describe("WorkspaceShell", () => {

const params = new URLSearchParams(window.location.search);
expect(params.get("mode")).toBe("flows");
expect(params.get("path")).toBe("docs/plan.md");
expect(params.get("path")).toBeNull();

rerender(<WorkspaceShell slug="alice" initialWorkspaceMode="flows" />);
rerender(<WorkspaceShell slug="alice" initialWorkspaceMode="knowledge" />);
Expand Down
59 changes: 49 additions & 10 deletions apps/web/src/components/workspace/file-tree.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useMemo, useState, type MouseEvent } from "react";
import { useEffect, useRef, useState, type MouseEvent } from "react";
import { CaretRight, File, Folder, FolderOpen } from "@phosphor-icons/react";

import { cn } from "@/lib/utils";
Expand All @@ -18,19 +18,58 @@ type FileTreeProps = {

type TreeState = Record<string, boolean>;

function getAncestorPaths(filePath: string): string[] {
const segments = filePath.split("/");
const ancestors: string[] = [];
for (let i = 1; i < segments.length; i++) {
ancestors.push(segments.slice(0, i).join("/"));
}
return ancestors;
}

export function FileTree({ nodes, activePath, onSelect, onFileContextMenu }: FileTreeProps) {
const initialExpanded = useMemo<TreeState>(() => {
const state: TreeState = {};
nodes.forEach((node) => {
if (node.type === "directory") state[node.path] = true;
});
return state;
}, [nodes]);
const [expanded, setExpanded] = useState<TreeState>({});
const userCollapsedRef = useRef(new Set<string>());
const prevActivePathRef = useRef(activePath);

const [expanded, setExpanded] = useState<TreeState>(initialExpanded);
useEffect(() => {
const activePathChanged = activePath !== prevActivePathRef.current;
prevActivePathRef.current = activePath;

setExpanded((prev) => {
const next = { ...prev };
let changed = false;
for (const node of nodes) {
if (node.type === "directory" && !(node.path in prev)) {
next[node.path] = true;
changed = true;
}
}
if (activePath) {
for (const ancestor of getAncestorPaths(activePath)) {
if (activePathChanged) {
userCollapsedRef.current.delete(ancestor);
}
if (!next[ancestor] && !userCollapsedRef.current.has(ancestor)) {
next[ancestor] = true;
changed = true;
}
}
}
return changed ? next : prev;
});
}, [nodes, activePath]);

const toggle = (path: string) => {
setExpanded((prev) => ({ ...prev, [path]: !prev[path] }));
setExpanded((prev) => {
const wasOpen = prev[path];
if (wasOpen) {
userCollapsedRef.current.add(path);
} else {
userCollapsedRef.current.delete(path);
}
return { ...prev, [path]: !wasOpen };
});
};

const renderNode = (node: WorkspaceFileNode, depth: number) => {
Expand Down
103 changes: 97 additions & 6 deletions apps/web/src/components/workspace/workspace-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -781,19 +781,110 @@ export function WorkspaceShell({
return normalized;
}, [initialFilePath]);

const [openFilePaths, setOpenFilePaths] = useState<string[]>(
safeInitialFilePath ? [safeInitialFilePath] : []
);
const [activeFilePath, setActiveFilePath] = useState<string | null>(
safeInitialFilePath
);
const openFilesStorageKey = `arche.workspace.${slug}.openFiles`;

const [openFilePaths, setOpenFilePaths] = useState<string[]>(() => {
if (typeof window === "undefined") return safeInitialFilePath ? [safeInitialFilePath] : [];
try {
const stored = window.localStorage.getItem(openFilesStorageKey);
if (!stored) return safeInitialFilePath ? [safeInitialFilePath] : [];
const parsed = JSON.parse(stored) as { paths?: string[]; active?: string | null };
const paths = Array.isArray(parsed.paths) ? parsed.paths.filter((p): p is string => typeof p === "string") : [];
if (safeInitialFilePath && !paths.includes(safeInitialFilePath)) {
paths.push(safeInitialFilePath);
}
return paths.length > 0 ? paths : safeInitialFilePath ? [safeInitialFilePath] : [];
} catch {
return safeInitialFilePath ? [safeInitialFilePath] : [];
}
});

const [activeFilePath, setActiveFilePath] = useState<string | null>(() => {
if (safeInitialFilePath) return safeInitialFilePath;
if (typeof window === "undefined") return null;
try {
const stored = window.localStorage.getItem(openFilesStorageKey);
if (!stored) return null;
const parsed = JSON.parse(stored) as { paths?: string[]; active?: string | null };
return typeof parsed.active === "string" ? parsed.active : null;
} catch {
return null;
}
});
const [fileCache, setFileCache] = useState<FileContentCache>({});
const fileCacheRef = useRef(fileCache);

useEffect(() => {
fileCacheRef.current = fileCache;
}, [fileCache]);

useEffect(() => {
if (typeof window === "undefined") return;
const params = new URLSearchParams(window.location.search);
if (isKnowledgeMode && activeFilePath) {
params.set("path", activeFilePath);
} else {
params.delete("path");
}
const query = params.toString();
window.history.replaceState(
window.history.state,
"",
query ? `/w/${slug}?${query}` : `/w/${slug}`
);
}, [activeFilePath, isKnowledgeMode, slug]);

useEffect(() => {
if (typeof window === "undefined") return;
try {
if (openFilePaths.length === 0) {
window.localStorage.removeItem(openFilesStorageKey);
} else {
window.localStorage.setItem(
openFilesStorageKey,
JSON.stringify({ paths: openFilePaths, active: activeFilePath })
);
}
} catch {
// ignore storage errors
}
}, [openFilePaths, activeFilePath, openFilesStorageKey]);

const initialOpenFilePathsRef = useRef(openFilePaths);
useEffect(() => {
const pathsToLoad = initialOpenFilePathsRef.current.filter(
(p) => !fileCacheRef.current[p]
);
if (pathsToLoad.length === 0) return;
void Promise.all(
pathsToLoad.map((filePath) =>
readWorkspaceFile(filePath).then((result) => {
if (!result) return null;
return { filePath, result };
})
)
).then((results) => {
const loaded = results.filter(
(r): r is NonNullable<typeof r> => r !== null
);
if (loaded.length === 0) return;
setFileCache((prev) => {
const next = { ...prev };
for (const { filePath, result } of loaded) {
next[filePath] = {
content: result.content,
type: result.type,
title: filePath.split("/").pop() ?? filePath,
updatedAt: "Just now",
size: `${(result.content.length / 1024).toFixed(1)} KB`,
hash: result.hash,
};
}
return next;
});
});
}, [readWorkspaceFile]);

const refreshOpenFilesCache = useCallback(async () => {
if (openFilePaths.length === 0) return;

Expand Down
Loading