Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/filetree-incremental-paint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@inkeep/open-knowledge": patch
---

Paint the sidebar file tree incrementally as the directory listing streams in, instead of withholding the whole level until the walk finishes. The `GET /api/documents` NDJSON walk is now applied to the tree per network chunk (additively, so folders not yet streamed are never pruned mid-stream), and the loading skeleton clears on the first batch. The authoritative prune + optimistic-merge reconcile still runs once as a single splice when the stream completes, so the final tree is unchanged. On a knowledge base with a large top-level directory the first rows appear sooner rather than after the entire level is enumerated.
5 changes: 5 additions & 0 deletions .changeset/terminal-launch-no-shell-history.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@inkeep/open-knowledge": patch
---

Desktop: launching an agent CLI from the docked terminal ("Open in Claude/Codex/Cursor/OpenCode") no longer pollutes your shell history. The launch command used to be typed into an interactive shell, so every launch — prompt and all — was recorded in `~/.zsh_history`, cluttering `↑`/`Ctrl-R` and writing document content in plaintext outside `.ok/`. The launch is now baked into the tab's shell spawn (`$SHELL -l -i -c '<cmd>; exec $SHELL -l -i'`): the command runs without going through the line editor (so it is never recorded), PATH is unchanged (the same login-interactive shell is used), and the tab drops into a fresh interactive shell when the agent exits — so your own later commands still record normally. Applies to all four CLIs.
75 changes: 75 additions & 0 deletions packages/app/src/components/FileTree.showall-lazy.dom.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,81 @@ describe('FileTree showAll lazy root seed', () => {
});
});

test('paints the first NDJSON chunk before the stream completes (incremental seed)', async () => {
let releaseRest: () => void = () => {};
const gate = new Promise<void>((resolve) => {
releaseRest = resolve;
});
const encoder = new TextEncoder();
showAllResponseFactory = () =>
new Response(
new ReadableStream<Uint8Array>({
async start(controller) {
controller.enqueue(encoder.encode(`${JSON.stringify(folderEntry('team', true))}\n`));
await gate;
controller.enqueue(
encoder.encode(
`${JSON.stringify(docEntry('README'))}\n${JSON.stringify({
type: 'complete',
truncated: false,
count: 2,
})}\n`,
),
);
controller.close();
},
}),
{ status: 200, headers: { 'content-type': 'application/x-ndjson' } },
);
render(<FileTree />);

await waitFor(() => expect([...model.items.keys()]).toEqual(['team/']));

expect(screen.queryByRole('status')).toBeNull();

releaseRest();

await waitFor(() => expect([...model.items.keys()].sort()).toEqual(['README.md', 'team/']));
});

test('a first chunk of only hidden entries does not clear the skeleton (no empty-state flash)', async () => {
let releaseVisible: () => void = () => {};
const gate = new Promise<void>((resolve) => {
releaseVisible = resolve;
});
const encoder = new TextEncoder();
showAllResponseFactory = () =>
new Response(
new ReadableStream<Uint8Array>({
async start(controller) {
controller.enqueue(encoder.encode(`${JSON.stringify(folderEntry('.github', true))}\n`));
await gate;
controller.enqueue(
encoder.encode(
`${JSON.stringify(folderEntry('docs', true))}\n${JSON.stringify({
type: 'complete',
truncated: false,
count: 2,
})}\n`,
),
);
controller.close();
},
}),
{ status: 200, headers: { 'content-type': 'application/x-ndjson' } },
);
render(<FileTree />);

await waitFor(() => expect(fetchUrls).toContain(SHOW_ALL_DEPTH1_URL));
await new Promise((resolve) => setTimeout(resolve, 30));
expect(screen.queryByText(/No files yet|Create your first/i)).toBeNull();
expect(screen.queryByRole('status')).not.toBeNull();
expect(model.items.size).toBe(0);

releaseVisible();
await waitFor(() => expect([...model.items.keys()]).toEqual(['docs/']));
});

test('a server-emitted NDJSON error line surfaces its problem title, not the connectivity copy', async () => {
const lines = [
JSON.stringify(folderEntry('team', true)),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,27 @@ function abortableBodyResponse(signal: AbortSignal | null | undefined): Response
});
}

function ndjsonFirstEntryThenAbort(
signal: AbortSignal | null | undefined,
firstLine: string,
): Response {
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode(firstLine));
const failWithAbort = () =>
controller.error(new DOMException('The operation was aborted.', 'AbortError'));
if (!signal) return;
if (signal.aborted) failWithAbort();
else signal.addEventListener('abort', failWithAbort, { once: true });
},
});
return new Response(stream, {
status: 200,
headers: { 'content-type': 'application/x-ndjson' },
});
}

function deferredDocumentsResponse(): Promise<Response> {
return new Promise((resolve) => {
resolveTrailingDocuments = (body: unknown) => resolve(jsonResponse(body));
Expand Down Expand Up @@ -316,4 +337,26 @@ describe('FileTree superseded documents refresh', () => {
);
expect(fetchFailedWarns).toEqual([]);
});

test('NDJSON: a superseded mid-stream refresh drops its batch; the fresh listing wins', async () => {
documentsFetchPlan.push(
(signal) => ndjsonFirstEntryThenAbort(signal, `${JSON.stringify(docEntry('stale'))}\n`),
() => jsonResponse({ documents: [docEntry('fresh')], truncated: false }),
);
render(<FileTree />);

await waitFor(() => expect(documentsCallCount).toBe(1));
await waitFor(() => expect([...model.items.keys()]).toEqual(['stale.md']));

window.dispatchEvent(new Event('focus'));
await waitFor(() => expect(documentsCallCount).toBe(2));

await waitFor(() => expect([...model.items.keys()]).toEqual(['fresh.md']));
expect(model.items.has('stale.md')).toBe(false);

const supersededWarns = consoleWarnSpy.mock.calls.filter(
(args) => typeof args[0] === 'string' && args[0].includes('[FileTree] fetch failed:'),
);
expect(supersededWarns).toEqual([]);
});
});
20 changes: 17 additions & 3 deletions packages/app/src/components/FileTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ import {
import { OK_SIDEBAR_DRAG_MIME, serializeSidebarDragPayload } from '@/lib/sidebar-drag';
import { cn } from '@/lib/utils';
import { joinWorkspacePath } from '@/lib/workspace-paths';
import { spliceLazyFolderChildren } from './file-tree-merge';
import { mergeRootEntriesAdditive, spliceLazyFolderChildren } from './file-tree-merge';
import { OpenInAgentContextSubmenu } from './handoff/OpenInAgentContextSubmenu';
import {
buildFolderHandoffInput,
Expand Down Expand Up @@ -1881,9 +1881,23 @@ export function FileTree({
headers: SHOW_ALL_NDJSON_ACCEPT,
});
if (isNdjsonResponse(res)) {
const { entries, truncated } = await consumeShowAllStream(res);
if (!active) return;
const bypassClientDotDrop = showHiddenFilesRef.current;
let paintedFirstBatch = false;
const { entries, truncated } = await consumeShowAllStream(res, {
onBatch: (batch) => {
if (!active || controller.signal.aborted) return;
const batchEntries = filterVisibleEntries(toFileEntries(batch), bypassClientDotDrop);
if (batchEntries.length === 0) return;
setDocuments((prev) => mergeRootEntriesAdditive(prev, batchEntries));
if (!paintedFirstBatch) {
paintedFirstBatch = true;
setError(null);
noteConnectivityRecovered();
setLoading(false);
}
},
});
if (!active) return;
const serverEntries = filterVisibleEntries(toFileEntries(entries), bypassClientDotDrop);
setDocuments((prev) =>
spliceLazyFolderChildren(prev, '', serverEntries, recentLocalAddsRef.current),
Expand Down
13 changes: 11 additions & 2 deletions packages/app/src/components/SkillsSidebarSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -162,13 +162,22 @@ function SkillFolderItem({
}) {
const { t } = useLingui();
const [open, setOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const active = activeDocName === skillLiveDocName(skill.scope, skill.name);

return (
<Collapsible open={open} onOpenChange={setOpen} className="group/skill">
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton size="sm" isActive={active} className="h-6 text-[11px]">
<SidebarMenuButton
size="sm"
isActive={active}
className="h-6 text-[11px]"
onContextMenu={(e) => {
e.preventDefault();
setMenuOpen(true);
}}
>
<ChevronRight className="size-3 shrink-0 text-muted-foreground transition-transform group-data-[state=open]/skill:rotate-90" />
<Hexagon className="size-3 shrink-0 text-muted-foreground" />
{/* Display the prefix-stripped name (`open-knowledge-pack-X` → `X`) so
Expand Down Expand Up @@ -196,7 +205,7 @@ function SkillFolderItem({
{/* The same context menu a file row gets (Reveal / Open with AI / Terminal
/ Copy Path / Duplicate / Rename / Delete), with Install/Uninstall in
place of Hide — reuses SkillContextMenuItems. */}
<DropdownMenu>
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
<DropdownMenuTrigger asChild>
<SidebarMenuAction showOnHover aria-label={t`Actions for ${skill.name}`}>
<MoreHorizontal />
Expand Down
Loading