@@ -109,19 +231,19 @@ const WikiViewer: React.FC
= ({
) : (
- {pages.map((page) => (
- -
+ {pages.map((p) => (
+
-
))}
@@ -133,56 +255,72 @@ const WikiViewer: React.FC = ({
{loadingContent ? (
) : selectedPath ? (
-
- (
-
- {children}
-
- ),
- h2: ({ children }) => (
-
- {children}
-
- ),
- h3: ({ children }) => (
-
- {children}
-
- ),
- p: ({ children }) => {children}
,
- ul: ({ children }) => (
-
- ),
- ol: ({ children }) => (
-
- {children}
-
- ),
- a: ({ children, href }) => (
-
+
+ {renderStamp()}
+ {canEdit && !isEditing && (
+
+ )}
+
+
+ {editError && (
+
+ {editError}
+
+ )}
+
+ {isEditing ? (
+
+ ) : (
+
+
+ {content}
+
+
+ )}
+
) : (
{t('settings.sources.wiki.selectPage')}
diff --git a/frontend/src/components/wikiViewerUtils.test.ts b/frontend/src/components/wikiViewerUtils.test.ts
new file mode 100644
index 00000000..73461e80
--- /dev/null
+++ b/frontend/src/components/wikiViewerUtils.test.ts
@@ -0,0 +1,142 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import {
+ formatRelativeTime,
+ provenanceKey,
+ saveWikiPage,
+} from './wikiViewerUtils';
+
+const jsonResponse = (status: number, body: unknown): Response =>
+ ({
+ ok: status >= 200 && status < 300,
+ status,
+ json: () => Promise.resolve(body),
+ }) as unknown as Response;
+
+describe('formatRelativeTime', () => {
+ const now = Date.parse('2026-06-22T12:00:00Z');
+
+ it('returns null for empty or invalid values', () => {
+ expect(formatRelativeTime(null, now)).toBeNull();
+ expect(formatRelativeTime(undefined, now)).toBeNull();
+ expect(formatRelativeTime('not-a-date', now)).toBeNull();
+ });
+
+ it('formats a recent edit in minutes', () => {
+ const fiveMinAgo = new Date(now - 5 * 60000).toISOString();
+ expect(formatRelativeTime(fiveMinAgo, now)).toBe('5 minutes ago');
+ });
+
+ it('formats an older edit in days', () => {
+ const threeDaysAgo = new Date(now - 3 * 86400000).toISOString();
+ expect(formatRelativeTime(threeDaysAgo, now)).toBe('3 days ago');
+ });
+});
+
+describe('provenanceKey', () => {
+ it('maps the current user to "you"', () => {
+ expect(provenanceKey('human', 'user-1', 'user-1')).toBe('you');
+ });
+
+ it('maps agent and human writes', () => {
+ expect(provenanceKey('agent', 'someone', 'user-1')).toBe('agent');
+ expect(provenanceKey('human', 'someone', 'user-1')).toBe('human');
+ });
+
+ it('falls back to "unknown" for missing provenance', () => {
+ expect(provenanceKey(null, null, null)).toBe('unknown');
+ expect(provenanceKey('weird', 'someone', 'user-1')).toBe('unknown');
+ });
+});
+
+describe('saveWikiPage', () => {
+ it('sends expected_version and returns the saved page', async () => {
+ const updateWikiPage = vi
+ .fn()
+ .mockResolvedValue(
+ jsonResponse(200, { page: { path: '/a.md', version: 3 } }),
+ );
+ const getWikiPage = vi.fn();
+ const service = { updateWikiPage, getWikiPage };
+
+ const outcome = await saveWikiPage(
+ service,
+ 'src-1',
+ '/a.md',
+ 'new body',
+ 2,
+ 'tok',
+ );
+
+ expect(updateWikiPage).toHaveBeenCalledWith(
+ 'src-1',
+ { path: '/a.md', content: 'new body', expected_version: 2 },
+ 'tok',
+ );
+ expect(getWikiPage).not.toHaveBeenCalled();
+ expect(outcome).toEqual({
+ status: 'saved',
+ page: { path: '/a.md', version: 3 },
+ });
+ });
+
+ it('reloads the latest page on a 409 conflict', async () => {
+ const updateWikiPage = vi.fn().mockResolvedValue(jsonResponse(409, {}));
+ const getWikiPage = vi.fn().mockResolvedValue(
+ jsonResponse(200, {
+ page: { path: '/a.md', version: 5, content: 'their edit' },
+ }),
+ );
+ const service = { updateWikiPage, getWikiPage };
+
+ const outcome = await saveWikiPage(
+ service,
+ 'src-1',
+ '/a.md',
+ 'my edit',
+ 2,
+ 'tok',
+ );
+
+ expect(getWikiPage).toHaveBeenCalledWith('src-1', '/a.md', 'tok');
+ expect(outcome).toEqual({
+ status: 'conflict',
+ page: { path: '/a.md', version: 5, content: 'their edit' },
+ });
+ });
+
+ it('reports forbidden on 403 without reloading', async () => {
+ const updateWikiPage = vi.fn().mockResolvedValue(jsonResponse(403, {}));
+ const getWikiPage = vi.fn();
+ const service = { updateWikiPage, getWikiPage };
+
+ const outcome = await saveWikiPage(
+ service,
+ 'src-1',
+ '/a.md',
+ 'x',
+ 1,
+ 'tok',
+ );
+
+ expect(getWikiPage).not.toHaveBeenCalled();
+ expect(outcome).toEqual({ status: 'forbidden' });
+ });
+
+ it('reports a generic error on other failures', async () => {
+ const updateWikiPage = vi.fn().mockResolvedValue(jsonResponse(400, {}));
+ const getWikiPage = vi.fn();
+ const service = { updateWikiPage, getWikiPage };
+
+ const outcome = await saveWikiPage(
+ service,
+ 'src-1',
+ '/a.md',
+ 'x',
+ 1,
+ 'tok',
+ );
+
+ expect(outcome).toEqual({ status: 'error' });
+ });
+});
diff --git a/frontend/src/components/wikiViewerUtils.ts b/frontend/src/components/wikiViewerUtils.ts
new file mode 100644
index 00000000..080dc9c5
--- /dev/null
+++ b/frontend/src/components/wikiViewerUtils.ts
@@ -0,0 +1,92 @@
+export interface WikiPageNode {
+ path: string;
+ title?: string | null;
+ token_count?: number;
+ embed_status?: string;
+ version?: number;
+ updated_by?: string | null;
+ updated_via?: string | null;
+ updated_at?: string | null;
+ content?: string;
+}
+
+const RELATIVE_UNITS: [Intl.RelativeTimeFormatUnit, number][] = [
+ ['year', 31536000000],
+ ['month', 2592000000],
+ ['day', 86400000],
+ ['hour', 3600000],
+ ['minute', 60000],
+];
+
+export function formatRelativeTime(
+ value: string | null | undefined,
+ now: number = Date.now(),
+): string | null {
+ if (!value) return null;
+ const then = new Date(value).getTime();
+ if (Number.isNaN(then)) return null;
+ const diffMs = now - then;
+ const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
+ for (const [unit, ms] of RELATIVE_UNITS) {
+ if (Math.abs(diffMs) >= ms) {
+ return formatter.format(-Math.round(diffMs / ms), unit);
+ }
+ }
+ return formatter.format(0, 'second');
+}
+
+export function provenanceKey(
+ via?: string | null,
+ by?: string | null,
+ currentUserSub?: string | null,
+): 'you' | 'agent' | 'human' | 'unknown' {
+ if (by && currentUserSub && by === currentUserSub) return 'you';
+ if (via === 'agent') return 'agent';
+ if (via === 'human') return 'human';
+ return 'unknown';
+}
+
+export type SaveOutcome =
+ | { status: 'saved'; page: WikiPageNode | null }
+ | { status: 'conflict'; page: WikiPageNode | null }
+ | { status: 'forbidden' }
+ | { status: 'error' };
+
+interface WikiSaveService {
+ updateWikiPage: (
+ sourceId: string,
+ data: { path: string; content: string; expected_version?: number },
+ token: string | null,
+ ) => Promise;
+ getWikiPage: (
+ sourceId: string,
+ path: string,
+ token: string | null,
+ ) => Promise;
+}
+
+export async function saveWikiPage(
+ service: WikiSaveService,
+ sourceId: string,
+ path: string,
+ draft: string,
+ expectedVersion: number | undefined,
+ token: string | null,
+): Promise {
+ const response = await service.updateWikiPage(
+ sourceId,
+ { path, content: draft, expected_version: expectedVersion },
+ token,
+ );
+ if (response.ok) {
+ const data = await response.json();
+ return { status: 'saved', page: data?.page ?? null };
+ }
+ if (response.status === 409) {
+ const refreshed = await service.getWikiPage(sourceId, path, token);
+ const data = await refreshed.json();
+ return { status: 'conflict', page: data?.page ?? null };
+ }
+ if (response.status === 403) return { status: 'forbidden' };
+ return { status: 'error' };
+}
diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json
index abb40dc3..50ddcd43 100644
--- a/frontend/src/locale/en.json
+++ b/frontend/src/locale/en.json
@@ -106,7 +106,23 @@
"wiki": {
"view": "View wiki",
"empty": "This wiki has no pages yet.",
- "selectPage": "Select a page to view its contents."
+ "selectPage": "Select a page to view its contents.",
+ "edit": "Edit",
+ "save": "Save",
+ "cancel": "Cancel",
+ "saving": "Saving…",
+ "saveFailed": "Could not save the page. Please try again.",
+ "forbidden": "You do not have permission to edit this page.",
+ "conflict": "This page changed since you opened it and has been reloaded. Your edits are below — reapply and save again.",
+ "editPlaceholder": "Write the page in Markdown…",
+ "stamp": {
+ "editedBy": "Edited by {{who}}",
+ "agent": "agent",
+ "human": "human",
+ "you": "you",
+ "unknown": "unknown",
+ "version": "v{{version}}"
+ }
},
"editConfig": "Source settings",
"shareWithTeam": "Share with team",
diff --git a/frontend/src/settings/Sources.tsx b/frontend/src/settings/Sources.tsx
index 2e2572fe..2a9df22e 100644
--- a/frontend/src/settings/Sources.tsx
+++ b/frontend/src/settings/Sources.tsx
@@ -436,6 +436,10 @@ export default function Sources({
setDocumentToView(undefined)}
/>
) : documentToView.isNested ? (
diff --git a/tests/agents/tools/test_wiki.py b/tests/agents/tools/test_wiki.py
index 8667e529..664a175c 100644
--- a/tests/agents/tools/test_wiki.py
+++ b/tests/agents/tools/test_wiki.py
@@ -39,6 +39,7 @@ class _FakeWikiRepo:
content,
title=None,
updated_by=None,
+ updated_via=None,
expected_version=None,
):
key = (source_id, path)
@@ -55,6 +56,7 @@ class _FakeWikiRepo:
"title": title,
"content_hash": content_hash,
"updated_by": updated_by,
+ "updated_via": updated_via,
"version": existing["version"] + 1,
}
)
@@ -66,6 +68,7 @@ class _FakeWikiRepo:
"title": title,
"content_hash": content_hash,
"updated_by": updated_by,
+ "updated_via": updated_via,
"version": existing["version"] + 1,
}
)
@@ -78,6 +81,7 @@ class _FakeWikiRepo:
"title": title,
"content_hash": content_hash,
"updated_by": updated_by,
+ "updated_via": updated_via,
"version": 1,
"token_count": len(content),
}
@@ -210,6 +214,20 @@ class TestCreateView:
reembed_mock.assert_called_once()
rebuild_mock.assert_called_once()
+ def test_create_stamps_updated_via_agent(self, wiki_tool):
+ wiki_tool.execute_action("wiki_create", path="/guide.md", content="Hello")
+ stored = _FakeWikiRepo().get_by_path("src-1", "/guide.md")
+ assert stored["updated_via"] == "agent"
+
+ def test_str_replace_stamps_updated_via_agent(self, wiki_tool):
+ wiki_tool.execute_action("wiki_create", path="/g.md", content="alpha beta")
+ wiki_tool.execute_action(
+ "wiki_str_replace", path="/g.md", old_str="alpha", new_str="omega"
+ )
+ stored = _FakeWikiRepo().get_by_path("src-1", "/g.md")
+ assert stored["updated_via"] == "agent"
+ assert stored["content"] == "omega beta"
+
def test_create_at_directory_rejected(self, wiki_tool):
assert "directory" in wiki_tool.execute_action(
"wiki_create", path="/docs/", content="x"
diff --git a/tests/api/user/sources/test_wiki_routes.py b/tests/api/user/sources/test_wiki_routes.py
index 89ce19d6..3c08ea90 100644
--- a/tests/api/user/sources/test_wiki_routes.py
+++ b/tests/api/user/sources/test_wiki_routes.py
@@ -162,7 +162,9 @@ class TestWikiPages:
"wiki-list", user_id=user, type="wiki", config={"kind": "wiki"}
)
sid = str(src["id"])
- WikiPagesRepository(pg_conn).upsert(sid, "/index.md", "root", updated_by=user)
+ WikiPagesRepository(pg_conn).upsert(
+ sid, "/index.md", "root", updated_by=user, updated_via="agent"
+ )
WikiPagesRepository(pg_conn).upsert(sid, "/docs/a.md", "a", updated_by=user)
with _patch_db(pg_conn), app.test_request_context(
@@ -174,6 +176,8 @@ class TestWikiPages:
assert response.status_code == 200
paths = {p["path"] for p in response.json["pages"]}
assert paths == {"/index.md", "/docs/a.md"}
+ via = {p["path"]: p["updated_via"] for p in response.json["pages"]}
+ assert via["/index.md"] == "agent"
def test_non_owner_without_grant_404(self, app, pg_conn):
from application.api.user.sources.routes import WikiPages
@@ -270,6 +274,33 @@ class TestWikiPage:
response = WikiPage().get(sid)
assert response.status_code == 404
+ def test_returns_provenance_and_version(self, app, pg_conn):
+ from application.api.user.sources.routes import WikiPage
+ from application.storage.db.repositories.sources import SourcesRepository
+ from application.storage.db.repositories.wiki_pages import WikiPagesRepository
+
+ user = "u-wiki-page-provenance"
+ src = SourcesRepository(pg_conn).create(
+ "wiki", user_id=user, type="wiki", config={"kind": "wiki"}
+ )
+ sid = str(src["id"])
+ WikiPagesRepository(pg_conn).upsert(
+ sid, "/index.md", "x", updated_by=user, updated_via="agent"
+ )
+
+ with _patch_db(pg_conn), app.test_request_context(
+ f"/api/sources/{sid}/wiki/page?path=/index.md"
+ ):
+ from flask import request
+ request.decoded_token = {"sub": user}
+ response = WikiPage().get(sid)
+ assert response.status_code == 200
+ page = response.json["page"]
+ assert page["updated_via"] == "agent"
+ assert page["updated_by"] == user
+ assert page["version"] == 1
+ assert page["updated_at"] is not None
+
def test_non_owner_without_grant_404(self, app, pg_conn):
from application.api.user.sources.routes import WikiPage
from application.storage.db.repositories.sources import SourcesRepository
@@ -455,8 +486,10 @@ class TestWikiPageEdit:
assert response.status_code == 200
assert response.json["page"]["path"] == "/notes.md"
assert response.json["page"]["version"] is not None
+ assert response.json["page"]["updated_via"] == "human"
page = WikiPagesRepository(pg_conn).get_by_path(sid, "/notes.md")
assert page["content"] == "body"
+ assert page["updated_via"] == "human"
mock_reembed.assert_called_once()
assert mock_reembed.call_args.args[1] == "/notes.md"
assert mock_reembed.call_args.kwargs["user"] == user
diff --git a/tests/storage/db/repositories/test_wiki_pages.py b/tests/storage/db/repositories/test_wiki_pages.py
index c611d7fc..721a135e 100644
--- a/tests/storage/db/repositories/test_wiki_pages.py
+++ b/tests/storage/db/repositories/test_wiki_pages.py
@@ -53,6 +53,26 @@ class TestUpsert:
page = repo.upsert(sid, "/a.md", "x", updated_by="agent-1")
assert page["updated_by"] == "agent-1"
+ def test_records_updated_via(self, pg_conn):
+ repo = _repo(pg_conn)
+ sid = _make_source(pg_conn)
+ page = repo.upsert(sid, "/a.md", "x", updated_via="human")
+ assert page["updated_via"] == "human"
+
+ def test_updated_via_overwritten_on_change(self, pg_conn):
+ repo = _repo(pg_conn)
+ sid = _make_source(pg_conn)
+ repo.upsert(sid, "/a.md", "v1", updated_via="agent")
+ page = repo.upsert(sid, "/a.md", "v2", updated_via="human")
+ assert page["updated_via"] == "human"
+
+ def test_updated_via_unchanged_on_identical_content(self, pg_conn):
+ repo = _repo(pg_conn)
+ sid = _make_source(pg_conn)
+ repo.upsert(sid, "/a.md", "same", updated_via="agent")
+ again = repo.upsert(sid, "/a.md", "same", updated_via="human")
+ assert again["updated_via"] == "agent"
+
class TestContentHashShortCircuit:
def test_identical_content_does_not_bump_version(self, pg_conn):
diff --git a/tests/storage/db/test_migration_0024.py b/tests/storage/db/test_migration_0024.py
new file mode 100644
index 00000000..9c215ccd
--- /dev/null
+++ b/tests/storage/db/test_migration_0024.py
@@ -0,0 +1,77 @@
+"""Migration round-trip test for 0024_wiki_pages_updated_via."""
+
+from __future__ import annotations
+
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+from sqlalchemy import text
+
+
+pytestmark = pytest.mark.integration
+
+
+def _alembic_ini() -> Path:
+ return Path(__file__).resolve().parents[3] / "application" / "alembic.ini"
+
+
+def _run_alembic(url: str, *args: str) -> None:
+ subprocess.check_call(
+ [sys.executable, "-m", "alembic", "-c", str(_alembic_ini()), *args],
+ timeout=60,
+ env={**os.environ, "POSTGRES_URI": url},
+ )
+
+
+def _alembic_heads(url: str) -> list[str]:
+ out = subprocess.check_output(
+ [sys.executable, "-m", "alembic", "-c", str(_alembic_ini()), "heads"],
+ timeout=60,
+ env={**os.environ, "POSTGRES_URI": url},
+ text=True,
+ )
+ return [line for line in out.splitlines() if line.strip()]
+
+
+def _alembic_version(conn) -> str:
+ return conn.execute(text("SELECT version_num FROM alembic_version")).scalar()
+
+
+def _column_exists(conn, table: str, column: str) -> bool:
+ row = conn.execute(
+ text(
+ "SELECT 1 FROM information_schema.columns "
+ "WHERE table_name = :t AND column_name = :c AND table_schema = 'public'"
+ ),
+ {"t": table, "c": column},
+ ).fetchone()
+ return row is not None
+
+
+_0024 = "0024_wiki_pages_updated_via"
+_0023 = "0023_wiki_pages"
+
+
+class TestMigration0024RoundTrip:
+ def test_single_head(self, pg_engine):
+ url = pg_engine.url.render_as_string(hide_password=False)
+ assert len(_alembic_heads(url)) == 1
+
+ def test_head_has_updated_via_column(self, pg_engine):
+ with pg_engine.connect() as conn:
+ assert _alembic_version(conn) >= _0024
+ assert _column_exists(conn, "wiki_pages", "updated_via")
+
+ def test_downgrade_drops_then_upgrade_restores(self, pg_engine):
+ url = pg_engine.url.render_as_string(hide_password=False)
+ _run_alembic(url, "downgrade", _0023)
+ with pg_engine.connect() as conn:
+ assert _alembic_version(conn) == _0023
+ assert not _column_exists(conn, "wiki_pages", "updated_via")
+ _run_alembic(url, "upgrade", "head")
+ with pg_engine.connect() as conn:
+ assert _alembic_version(conn) >= _0024
+ assert _column_exists(conn, "wiki_pages", "updated_via")
From 80533f9383743cb068e702f92e0c54a37414ffac Mon Sep 17 00:00:00 2001
From: Alex
Date: Mon, 22 Jun 2026 21:05:35 +0100
Subject: [PATCH 07/13] feat(wiki): convert-to-wiki UX, living-wiki naming,
surface agent edits
Unit 7 of F-Wiki (D20/D23/D24). Source row action 'Convert to wiki' (owner/editor only) -> confirm naming the cost (lossy/one-way/skipped) -> poll task status -> after-summary; blank source enables inline. Agent wiki writes (create/str_replace/insert/delete/rename) render as a prominent top-level card in the conversation (outside the collapsed tool-calls accordion) with the page path; reads stay normal. 'Living wiki'/agent-editable naming + explainer; 'New wiki' blank-source fast path retained.
---
frontend/src/api/endpoints.ts | 2 +
frontend/src/api/services/userService.ts | 4 +
frontend/src/components/WikiViewer.tsx | 19 ++
.../src/conversation/ConversationBubble.tsx | 64 ++++-
.../WikiWriteToolCallCard.test.tsx | 102 ++++++++
.../src/conversation/wikiToolCall.test.ts | 69 +++++
frontend/src/conversation/wikiToolCall.ts | 26 ++
frontend/src/locale/en.json | 40 ++-
frontend/src/settings/ConvertToWikiModal.tsx | 245 ++++++++++++++++++
frontend/src/settings/Sources.tsx | 46 +++-
.../src/settings/wikiConvertUtils.test.ts | 116 +++++++++
frontend/src/settings/wikiConvertUtils.ts | 89 +++++++
frontend/src/upload/types/ingestor.test.ts | 4 +-
frontend/src/upload/types/ingestor.ts | 4 +-
14 files changed, 820 insertions(+), 10 deletions(-)
create mode 100644 frontend/src/conversation/WikiWriteToolCallCard.test.tsx
create mode 100644 frontend/src/conversation/wikiToolCall.test.ts
create mode 100644 frontend/src/conversation/wikiToolCall.ts
create mode 100644 frontend/src/settings/ConvertToWikiModal.tsx
create mode 100644 frontend/src/settings/wikiConvertUtils.test.ts
create mode 100644 frontend/src/settings/wikiConvertUtils.ts
diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts
index 61db92cc..141584ff 100644
--- a/frontend/src/api/endpoints.ts
+++ b/frontend/src/api/endpoints.ts
@@ -57,6 +57,8 @@ const endpoints = {
REINGEST_SOURCE: '/api/sources/reingest',
SOURCE_CONFIG: (id: string) => `/api/sources/${id}/config`,
CREATE_WIKI: '/api/sources/wiki',
+ CONVERT_TO_WIKI: (id: string) => `/api/sources/${id}/wiki/convert`,
+ TASK_STATUS: (taskId: string) => `/api/task_status?task_id=${taskId}`,
WIKI_PAGES: (id: string) => `/api/sources/${id}/wiki/pages`,
WIKI_PAGE: (id: string, path: string) =>
`/api/sources/${id}/wiki/page?path=${encodeURIComponent(path)}`,
diff --git a/frontend/src/api/services/userService.ts b/frontend/src/api/services/userService.ts
index 98ef4d74..f2ec6dd6 100644
--- a/frontend/src/api/services/userService.ts
+++ b/frontend/src/api/services/userService.ts
@@ -107,6 +107,10 @@ const userService = {
token: string | null,
): Promise =>
apiClient.post(endpoints.USER.CREATE_WIKI, data, token),
+ convertToWiki: (sourceId: string, token: string | null): Promise =>
+ apiClient.post(endpoints.USER.CONVERT_TO_WIKI(sourceId), {}, token),
+ getTaskStatus: (taskId: string, token: string | null): Promise =>
+ apiClient.get(endpoints.USER.TASK_STATUS(taskId), token),
getWikiPages: (sourceId: string, token: string | null): Promise =>
throttledApiClient.get(endpoints.USER.WIKI_PAGES(sourceId), token),
getWikiPage: (
diff --git a/frontend/src/components/WikiViewer.tsx b/frontend/src/components/WikiViewer.tsx
index 64f1e63e..e5e7ca6d 100644
--- a/frontend/src/components/WikiViewer.tsx
+++ b/frontend/src/components/WikiViewer.tsx
@@ -1,3 +1,4 @@
+import { BookOpen } from 'lucide-react';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
@@ -221,6 +222,24 @@ const WikiViewer: React.FC = ({
+
{loadingPages ? (
diff --git a/frontend/src/conversation/ConversationBubble.tsx b/frontend/src/conversation/ConversationBubble.tsx
index 90ffb3f2..32d69f89 100644
--- a/frontend/src/conversation/ConversationBubble.tsx
+++ b/frontend/src/conversation/ConversationBubble.tsx
@@ -49,6 +49,11 @@ import classes from './ConversationBubble.module.css';
import { FEEDBACK, MESSAGE_TYPE, ResearchState } from './conversationModels';
import ResearchProgress from './ResearchProgress';
import { ToolCallsType } from './types';
+import {
+ isWikiWriteCall,
+ wikiWriteActionKey,
+ wikiWritePath,
+} from './wikiToolCall';
const DisableSourceFE = import.meta.env.VITE_DISABLE_SOURCE_FE || false;
@@ -1064,7 +1069,47 @@ function ToolCallApprovalBar({
);
}
-function ToolCalls({
+export function WikiWriteToolCallCard({
+ toolCall,
+}: {
+ toolCall: ToolCallsType;
+}) {
+ const { t } = useTranslation();
+ const path = wikiWritePath(toolCall);
+ const actionKey = wikiWriteActionKey(toolCall.action_name);
+ const isError = toolCall.status === 'error';
+
+ return (
+
+
+ ✏️
+
+
+ {t(`conversation.wikiWrite.${actionKey}`, {
+ defaultValue: t('conversation.wikiWrite.edited'),
+ })}
+
+ {path && (
+
+ {path}
+
+ )}
+ {isError && (
+
+ {t('conversation.wikiWrite.failed')}
+
+ )}
+
+ );
+}
+
+export function ToolCalls({
toolCalls,
onToolAction,
agentId,
@@ -1082,8 +1127,11 @@ function ToolCalls({
const awaitingCalls = toolCalls.filter(
(tc) => tc.status === 'awaiting_approval',
);
+ const wikiWriteCalls = toolCalls.filter(
+ (tc) => tc.status !== 'awaiting_approval' && isWikiWriteCall(tc),
+ );
const resolvedCalls = toolCalls.filter(
- (tc) => tc.status !== 'awaiting_approval',
+ (tc) => tc.status !== 'awaiting_approval' && !isWikiWriteCall(tc),
);
return (
@@ -1101,6 +1149,18 @@ function ToolCalls({
)}
+ {/* Wiki write cards — always visible (D24: visibility is the control) */}
+ {wikiWriteCalls.length > 0 && (
+
+ {wikiWriteCalls.map((toolCall, index) => (
+
+ ))}
+
+ )}
+
{/* Regular tool calls accordion */}
{resolvedCalls.length > 0 && (
<>
diff --git a/frontend/src/conversation/WikiWriteToolCallCard.test.tsx b/frontend/src/conversation/WikiWriteToolCallCard.test.tsx
new file mode 100644
index 00000000..576f550b
--- /dev/null
+++ b/frontend/src/conversation/WikiWriteToolCallCard.test.tsx
@@ -0,0 +1,102 @@
+import i18n from 'i18next';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { I18nextProvider, initReactI18next } from 'react-i18next';
+import { beforeAll, describe, expect, it } from 'vitest';
+
+import en from '../locale/en.json';
+import { ToolCalls, WikiWriteToolCallCard } from './ConversationBubble';
+import { ToolCallsType } from './types';
+
+const testI18n = i18n.createInstance();
+
+beforeAll(async () => {
+ await testI18n.use(initReactI18next).init({
+ lng: 'en',
+ fallbackLng: 'en',
+ resources: { en: { translation: en } },
+ });
+});
+
+const render = (toolCall: ToolCallsType): string =>
+ renderToStaticMarkup(
+
+
+ ,
+ );
+
+describe('WikiWriteToolCallCard', () => {
+ it('renders the edited-wiki treatment with the page path', () => {
+ const html = render({
+ tool_name: 'wiki',
+ action_name: 'wiki_str_replace',
+ call_id: 'c1',
+ arguments: { path: '/policy.md', old_str: 'a', new_str: 'b' },
+ status: 'completed',
+ });
+ expect(html).toContain('✏️');
+ expect(html).toContain('Edited wiki page');
+ expect(html).toContain('/policy.md');
+ });
+
+ it('uses the new path for a rename', () => {
+ const html = render({
+ tool_name: 'wiki',
+ action_name: 'wiki_rename',
+ call_id: 'c2',
+ arguments: { old_path: '/a.md', new_path: '/b.md' },
+ status: 'completed',
+ });
+ expect(html).toContain('Renamed wiki page');
+ expect(html).toContain('/b.md');
+ });
+
+ it('still renders the action without a path when none is in the payload', () => {
+ const html = render({
+ tool_name: 'wiki',
+ action_name: 'wiki_create',
+ call_id: 'c3',
+ arguments: {},
+ status: 'completed',
+ });
+ expect(html).toContain('Created wiki page');
+ expect(html).not.toContain('
{
+ const renderToolCalls = (toolCalls: ToolCallsType[]): string =>
+ renderToStaticMarkup(
+
+
+ ,
+ );
+
+ const wikiWrite: ToolCallsType = {
+ tool_name: 'wiki',
+ action_name: 'wiki_str_replace',
+ call_id: 'w1',
+ arguments: { path: '/policy.md', old_str: 'a', new_str: 'b' },
+ status: 'completed',
+ };
+
+ const otherCall: ToolCallsType = {
+ tool_name: 'memory',
+ action_name: 'memory_search',
+ call_id: 'm1',
+ arguments: { query: 'secret-arg' },
+ status: 'completed',
+ };
+
+ it('renders wiki write cards outside the collapsed accordion (default closed)', () => {
+ const html = renderToolCalls([wikiWrite, otherCall]);
+ expect(html).toContain('Edited wiki page');
+ expect(html).toContain('/policy.md');
+ expect(html).not.toContain('secret-arg');
+ });
+
+ it('does not double-render wiki write cards inside the accordion', () => {
+ const html = renderToolCalls([wikiWrite]);
+ const occurrences = html.split('Edited wiki page').length - 1;
+ expect(occurrences).toBe(1);
+ });
+});
diff --git a/frontend/src/conversation/wikiToolCall.test.ts b/frontend/src/conversation/wikiToolCall.test.ts
new file mode 100644
index 00000000..31773368
--- /dev/null
+++ b/frontend/src/conversation/wikiToolCall.test.ts
@@ -0,0 +1,69 @@
+import { describe, expect, it } from 'vitest';
+
+import { ToolCallsType } from './types';
+import {
+ isWikiWriteCall,
+ wikiWriteActionKey,
+ wikiWritePath,
+} from './wikiToolCall';
+
+const call = (overrides: Partial): ToolCallsType => ({
+ tool_name: 'wiki',
+ action_name: 'wiki_str_replace',
+ call_id: 'c1',
+ arguments: {},
+ ...overrides,
+});
+
+describe('isWikiWriteCall', () => {
+ it('flags wiki write actions', () => {
+ expect(isWikiWriteCall(call({ action_name: 'wiki_create' }))).toBe(true);
+ expect(isWikiWriteCall(call({ action_name: 'wiki_str_replace' }))).toBe(
+ true,
+ );
+ expect(isWikiWriteCall(call({ action_name: 'wiki_insert' }))).toBe(true);
+ expect(isWikiWriteCall(call({ action_name: 'wiki_delete' }))).toBe(true);
+ expect(isWikiWriteCall(call({ action_name: 'wiki_rename' }))).toBe(true);
+ });
+
+ it('does not flag wiki read actions', () => {
+ expect(isWikiWriteCall(call({ action_name: 'wiki_view' }))).toBe(false);
+ });
+
+ it('does not flag non-wiki tools', () => {
+ expect(
+ isWikiWriteCall(
+ call({ tool_name: 'memory', action_name: 'wiki_create' }),
+ ),
+ ).toBe(false);
+ });
+});
+
+describe('wikiWritePath', () => {
+ it('reads the path argument for str_replace', () => {
+ const tc = call({
+ action_name: 'wiki_str_replace',
+ arguments: { path: '/policy.md', old_str: 'a', new_str: 'b' },
+ });
+ expect(wikiWritePath(tc)).toBe('/policy.md');
+ });
+
+ it('prefers new_path for a rename', () => {
+ const tc = call({
+ action_name: 'wiki_rename',
+ arguments: { old_path: '/a.md', new_path: '/b.md' },
+ });
+ expect(wikiWritePath(tc)).toBe('/b.md');
+ });
+
+ it('returns null when no path is present in the payload', () => {
+ expect(wikiWritePath(call({ arguments: {} }))).toBeNull();
+ });
+});
+
+describe('wikiWriteActionKey', () => {
+ it('strips the wiki_ prefix', () => {
+ expect(wikiWriteActionKey('wiki_str_replace')).toBe('str_replace');
+ expect(wikiWriteActionKey('wiki_create')).toBe('create');
+ });
+});
diff --git a/frontend/src/conversation/wikiToolCall.ts b/frontend/src/conversation/wikiToolCall.ts
new file mode 100644
index 00000000..a6404da7
--- /dev/null
+++ b/frontend/src/conversation/wikiToolCall.ts
@@ -0,0 +1,26 @@
+import { ToolCallsType } from './types';
+
+const WIKI_WRITE_ACTIONS = new Set([
+ 'wiki_create',
+ 'wiki_str_replace',
+ 'wiki_insert',
+ 'wiki_delete',
+ 'wiki_rename',
+]);
+
+export function isWikiWriteCall(toolCall: ToolCallsType): boolean {
+ return (
+ toolCall.tool_name === 'wiki' &&
+ WIKI_WRITE_ACTIONS.has(toolCall.action_name)
+ );
+}
+
+export function wikiWritePath(toolCall: ToolCallsType): string | null {
+ const args = toolCall.arguments || {};
+ const path = args.new_path ?? args.path;
+ return typeof path === 'string' && path.length > 0 ? path : null;
+}
+
+export function wikiWriteActionKey(actionName: string): string {
+ return actionName.replace(/^wiki_/, '');
+}
diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json
index 50ddcd43..bae7bbd8 100644
--- a/frontend/src/locale/en.json
+++ b/frontend/src/locale/en.json
@@ -115,6 +115,9 @@
"forbidden": "You do not have permission to edit this page.",
"conflict": "This page changed since you opened it and has been reloaded. Your edits are below — reapply and save again.",
"editPlaceholder": "Write the page in Markdown…",
+ "livingTitle": "Living wiki.",
+ "livingExplainer": "An agent-editable source: pages are read and rewritten as agents work, and it is exposed to agents through the agentic search tool by default.",
+ "livingExplainerEditable": "Any agent using this source can edit it.",
"stamp": {
"editedBy": "Edited by {{who}}",
"agent": "agent",
@@ -122,6 +125,28 @@
"you": "you",
"unknown": "unknown",
"version": "v{{version}}"
+ },
+ "convert": {
+ "action": "Convert to wiki",
+ "title": "Convert to a living wiki",
+ "intro": "Convert \"{{name}}\" into a living wiki of editable pages that agents can read and rewrite.",
+ "introGeneric": "Convert this source into a living wiki of editable pages that agents can read and rewrite.",
+ "costReparse": "Files are re-parsed into editable pages. Formatting is simplified, especially for PDFs.",
+ "costSkipped": "Non-text files (images, binaries) are skipped.",
+ "costIrreversible": "This is not reversible — the wiki pages become the source of truth.",
+ "confirm": "Convert to wiki",
+ "inProgress": "Converting files into wiki pages…",
+ "summaryPages_one": "{{count}} page created",
+ "summaryPages_other": "{{count}} pages created",
+ "summarySkipped_one": "{{count}} file skipped",
+ "summarySkipped_other": "{{count}} files skipped",
+ "skippedHeading": "Skipped files",
+ "done": "Done",
+ "errors": {
+ "generic": "Conversion failed. Please try again.",
+ "forbidden": "You don't have permission to convert this source.",
+ "conflict": "This source is still ingesting. Wait for it to finish before converting."
+ }
}
},
"editConfig": "Source settings",
@@ -731,8 +756,8 @@
"heading": "Upload from Confluence"
},
"wiki": {
- "label": "Create Wiki",
- "heading": "Create an LLM-editable wiki"
+ "label": "New wiki",
+ "heading": "Create a living wiki"
}
},
"connectors": {
@@ -979,7 +1004,16 @@
"remove": "Remove attachment"
},
"retry": "Retry",
- "reasoning": "Reasoning"
+ "reasoning": "Reasoning",
+ "wikiWrite": {
+ "edited": "Edited wiki",
+ "create": "Created wiki page",
+ "str_replace": "Edited wiki page",
+ "insert": "Edited wiki page",
+ "delete": "Deleted wiki page",
+ "rename": "Renamed wiki page",
+ "failed": "failed"
+ }
},
"agents": {
"title": "Agents",
diff --git a/frontend/src/settings/ConvertToWikiModal.tsx b/frontend/src/settings/ConvertToWikiModal.tsx
new file mode 100644
index 00000000..d024b8b2
--- /dev/null
+++ b/frontend/src/settings/ConvertToWikiModal.tsx
@@ -0,0 +1,245 @@
+import { useEffect, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useSelector } from 'react-redux';
+
+import userService from '../api/services/userService';
+import Spinner from '../components/Spinner';
+import { Button } from '../components/ui/button';
+import { Modal } from '../components/ui/modal';
+import { ActiveState, Doc } from '../models/misc';
+import { selectToken } from '../preferences/preferenceSlice';
+
+import {
+ ConvertSummary,
+ pollTaskOnce,
+ startWikiConversion,
+} from './wikiConvertUtils';
+
+const POLL_INTERVAL_MS = 2000;
+
+type Phase = 'confirm' | 'converting' | 'summary' | 'error';
+
+interface ConvertToWikiModalProps {
+ modalState: ActiveState;
+ setModalState: (state: ActiveState) => void;
+ document: Doc | null;
+ onConverted: () => void;
+}
+
+export default function ConvertToWikiModal({
+ modalState,
+ setModalState,
+ document,
+ onConverted,
+}: ConvertToWikiModalProps) {
+ const { t } = useTranslation();
+ const token = useSelector(selectToken);
+
+ const [phase, setPhase] = useState('confirm');
+ const [error, setError] = useState(null);
+ const [summary, setSummary] = useState(null);
+ const pollTimer = useRef(null);
+ const isActiveRef = useRef(true);
+
+ const clearPoll = () => {
+ if (pollTimer.current !== null) {
+ window.clearTimeout(pollTimer.current);
+ pollTimer.current = null;
+ }
+ };
+
+ const stopPolling = () => {
+ isActiveRef.current = false;
+ clearPoll();
+ };
+
+ useEffect(() => {
+ if (modalState === 'ACTIVE') {
+ isActiveRef.current = true;
+ setPhase('confirm');
+ setError(null);
+ setSummary(null);
+ }
+ return stopPolling;
+ }, [modalState, document]);
+
+ const closeModal = () => {
+ stopPolling();
+ setModalState('INACTIVE');
+ };
+
+ const fail = (message?: string) => {
+ setError(message ?? t('settings.sources.wiki.convert.errors.generic'));
+ setPhase('error');
+ };
+
+ const poll = (taskId: string) => {
+ pollTaskOnce(userService, taskId, token)
+ .then((result) => {
+ if (!isActiveRef.current) return;
+ if (result.status === 'pending') {
+ pollTimer.current = window.setTimeout(
+ () => poll(taskId),
+ POLL_INTERVAL_MS,
+ );
+ return;
+ }
+ if (result.status === 'done') {
+ setSummary(result.summary);
+ setPhase('summary');
+ onConverted();
+ return;
+ }
+ fail(result.message);
+ })
+ .catch(() => {
+ if (!isActiveRef.current) return;
+ fail();
+ });
+ };
+
+ const handleConvert = async () => {
+ if (!document?.id) return;
+ setPhase('converting');
+ setError(null);
+ const start = await startWikiConversion(userService, document.id, token);
+ if (start.status === 'enabled') {
+ setSummary({ pagesCreated: 0, skipped: [] });
+ setPhase('summary');
+ onConverted();
+ return;
+ }
+ if (start.status === 'task') {
+ poll(start.taskId);
+ return;
+ }
+ if (start.status === 'forbidden') {
+ fail(t('settings.sources.wiki.convert.errors.forbidden'));
+ return;
+ }
+ if (start.status === 'conflict') {
+ fail(start.message ?? t('settings.sources.wiki.convert.errors.conflict'));
+ return;
+ }
+ fail(start.message);
+ };
+
+ return (
+ !o && closeModal()}
+ hideTitle
+ title={t('settings.sources.wiki.convert.title')}
+ size="md"
+ mobileVariant="sheet"
+ className="max-w-[480px]"
+ isPerformingTask={phase === 'converting'}
+ >
+
+
+ {t('settings.sources.wiki.convert.title')}
+
+
+ {phase === 'confirm' && (
+ <>
+
+ {document?.name
+ ? t('settings.sources.wiki.convert.intro', {
+ name: document.name,
+ })
+ : t('settings.sources.wiki.convert.introGeneric')}
+
+
+ - {t('settings.sources.wiki.convert.costReparse')}
+ - {t('settings.sources.wiki.convert.costSkipped')}
+ - {t('settings.sources.wiki.convert.costIrreversible')}
+
+
+
+
+
+ >
+ )}
+
+ {phase === 'converting' && (
+
+
+
+ {t('settings.sources.wiki.convert.inProgress')}
+
+
+ )}
+
+ {phase === 'summary' && summary && (
+ <>
+
+ {[
+ t('settings.sources.wiki.convert.summaryPages', {
+ count: summary.pagesCreated,
+ }),
+ t('settings.sources.wiki.convert.summarySkipped', {
+ count: summary.skipped.length,
+ }),
+ ].join(' · ')}
+
+ {summary.skipped.length > 0 && (
+
+
+ {t('settings.sources.wiki.convert.skippedHeading')}
+
+
+ {summary.skipped.map((s) => (
+ -
+ {s.file}
+ {s.reason ? ` — ${s.reason}` : ''}
+
+ ))}
+
+
+ )}
+
+
+
+ >
+ )}
+
+ {phase === 'error' && (
+ <>
+
+ {error}
+
+
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/frontend/src/settings/Sources.tsx b/frontend/src/settings/Sources.tsx
index 2a9df22e..7d8a8e23 100644
--- a/frontend/src/settings/Sources.tsx
+++ b/frontend/src/settings/Sources.tsx
@@ -1,4 +1,9 @@
-import { Search as SearchIcon, SlidersHorizontal, Users } from 'lucide-react';
+import {
+ BookOpen,
+ Search as SearchIcon,
+ SlidersHorizontal,
+ Users,
+} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -46,6 +51,7 @@ import FileTree from '../components/FileTree';
import ConnectorTree from '../components/ConnectorTree';
import Chunks from '../components/Chunks';
import WikiViewer from '../components/WikiViewer';
+import ConvertToWikiModal from './ConvertToWikiModal';
import SourceConfigModal from './SourceConfigModal';
type SourceMenuOption = {
@@ -111,6 +117,9 @@ export default function Sources({
);
const [configModalState, setConfigModalState] =
useState('INACTIVE');
+ const [documentToConvert, setDocumentToConvert] = useState(null);
+ const [convertModalState, setConvertModalState] =
+ useState('INACTIVE');
const [syncMenuState, setSyncMenuState] = useState<{
isOpen: boolean;
docId: string | null;
@@ -329,6 +338,9 @@ export default function Sources({
document: Doc,
): SourceMenuOption[] => {
const isWiki = document.type === 'wiki';
+ // 'team' viewers cannot write; convert is owner/editor only.
+ const canEdit =
+ document.ownership !== 'team' || document.team_access === 'editor';
const actions: SourceMenuOption[] = [
{
icon: EyeView,
@@ -398,6 +410,26 @@ export default function Sources({
});
}
+ if (
+ document.id &&
+ !isWiki &&
+ canEdit &&
+ document.ingestStatus !== 'processing' &&
+ document.ingestStatus !== 'failed'
+ ) {
+ actions.push({
+ icon: BookOpen,
+ label: t('settings.sources.wiki.convert.action'),
+ onClick: () => {
+ setDocumentToConvert(document);
+ setConvertModalState('ACTIVE');
+ },
+ iconWidth: 16,
+ iconHeight: 16,
+ variant: 'default',
+ });
+ }
+
// Sharing is an owner-only action: hide it for sources shared into the
// user's workspace by a team.
if (document.ownership !== 'team' && document.id) {
@@ -764,6 +796,18 @@ export default function Sources({
document={documentToConfigure}
onReingest={handleReingest}
/>
+
+ {
+ setConvertModalState(state);
+ if (state === 'INACTIVE') {
+ setDocumentToConvert(null);
+ }
+ }}
+ document={documentToConvert}
+ onConverted={() => refreshDocs(undefined, currentPage, rowsPerPage)}
+ />
);
}
diff --git a/frontend/src/settings/wikiConvertUtils.test.ts b/frontend/src/settings/wikiConvertUtils.test.ts
new file mode 100644
index 00000000..41bb482a
--- /dev/null
+++ b/frontend/src/settings/wikiConvertUtils.test.ts
@@ -0,0 +1,116 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import {
+ interpretTaskStatus,
+ pollTaskOnce,
+ startWikiConversion,
+} from './wikiConvertUtils';
+
+const jsonResponse = (status: number, body: unknown): Response =>
+ ({
+ ok: status >= 200 && status < 300,
+ status,
+ json: () => Promise.resolve(body),
+ }) as unknown as Response;
+
+describe('startWikiConversion', () => {
+ it('returns enabled for a blank source ({enabled:true}, no task)', async () => {
+ const convertToWiki = vi
+ .fn()
+ .mockResolvedValue(jsonResponse(200, { success: true, enabled: true }));
+
+ const result = await startWikiConversion({ convertToWiki }, 'src-1', 'tok');
+
+ expect(convertToWiki).toHaveBeenCalledWith('src-1', 'tok');
+ expect(result).toEqual({ status: 'enabled' });
+ });
+
+ it('returns the task id for a fileful source', async () => {
+ const convertToWiki = vi
+ .fn()
+ .mockResolvedValue(jsonResponse(200, { success: true, task_id: 't-9' }));
+
+ const result = await startWikiConversion({ convertToWiki }, 'src-1', 'tok');
+
+ expect(result).toEqual({ status: 'task', taskId: 't-9' });
+ });
+
+ it('maps 403 to forbidden', async () => {
+ const convertToWiki = vi.fn().mockResolvedValue(jsonResponse(403, {}));
+ const result = await startWikiConversion({ convertToWiki }, 'src-1', 'tok');
+ expect(result).toEqual({ status: 'forbidden' });
+ });
+
+ it('maps 409 to conflict with the server message', async () => {
+ const convertToWiki = vi
+ .fn()
+ .mockResolvedValue(jsonResponse(409, { message: 'still ingesting' }));
+ const result = await startWikiConversion({ convertToWiki }, 'src-1', 'tok');
+ expect(result).toEqual({ status: 'conflict', message: 'still ingesting' });
+ });
+
+ it('maps a network throw to a generic error', async () => {
+ const convertToWiki = vi.fn().mockRejectedValue(new Error('offline'));
+ const result = await startWikiConversion({ convertToWiki }, 'src-1', 'tok');
+ expect(result).toEqual({ status: 'error' });
+ });
+});
+
+describe('interpretTaskStatus', () => {
+ it('parses a SUCCESS summary (pages + skipped list)', () => {
+ const result = interpretTaskStatus('SUCCESS', {
+ status: 'converted',
+ pages_created: 12,
+ skipped: [{ file: 'a.png', reason: 'unsupported' }],
+ });
+ expect(result).toEqual({
+ status: 'done',
+ summary: {
+ pagesCreated: 12,
+ skipped: [{ file: 'a.png', reason: 'unsupported' }],
+ },
+ });
+ });
+
+ it('defaults missing summary fields', () => {
+ const result = interpretTaskStatus('SUCCESS', {});
+ expect(result).toEqual({
+ status: 'done',
+ summary: { pagesCreated: 0, skipped: [] },
+ });
+ });
+
+ it('maps FAILURE to failed', () => {
+ expect(interpretTaskStatus('FAILURE', 'boom')).toEqual({
+ status: 'failed',
+ message: 'boom',
+ });
+ });
+
+ it('treats other statuses as pending', () => {
+ expect(interpretTaskStatus('PENDING', null)).toEqual({ status: 'pending' });
+ expect(interpretTaskStatus('STARTED', null)).toEqual({ status: 'pending' });
+ });
+});
+
+describe('pollTaskOnce', () => {
+ it('returns the parsed summary on success', async () => {
+ const getTaskStatus = vi.fn().mockResolvedValue(
+ jsonResponse(200, {
+ status: 'SUCCESS',
+ result: { pages_created: 3, skipped: [] },
+ }),
+ );
+ const result = await pollTaskOnce({ getTaskStatus }, 't-1', 'tok');
+ expect(result).toEqual({
+ status: 'done',
+ summary: { pagesCreated: 3, skipped: [] },
+ });
+ });
+
+ it('returns pending on a non-ok response', async () => {
+ const getTaskStatus = vi.fn().mockResolvedValue(jsonResponse(503, {}));
+ const result = await pollTaskOnce({ getTaskStatus }, 't-1', 'tok');
+ expect(result).toEqual({ status: 'pending' });
+ });
+});
diff --git a/frontend/src/settings/wikiConvertUtils.ts b/frontend/src/settings/wikiConvertUtils.ts
new file mode 100644
index 00000000..8f258407
--- /dev/null
+++ b/frontend/src/settings/wikiConvertUtils.ts
@@ -0,0 +1,89 @@
+export interface SkippedFile {
+ file: string;
+ reason: string;
+}
+
+export interface ConvertSummary {
+ pagesCreated: number;
+ skipped: SkippedFile[];
+}
+
+export type ConvertStart =
+ | { status: 'enabled' }
+ | { status: 'task'; taskId: string }
+ | { status: 'forbidden' }
+ | { status: 'conflict'; message?: string }
+ | { status: 'error'; message?: string };
+
+export type ConvertPoll =
+ | { status: 'pending' }
+ | { status: 'done'; summary: ConvertSummary }
+ | { status: 'failed'; message?: string };
+
+interface ConvertService {
+ convertToWiki: (sourceId: string, token: string | null) => Promise