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
19 changes: 18 additions & 1 deletion ui-web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ file, the compass for the start page.
from where the displayed list ends.
- **A file** opens as its own tab, drawn by the viewer its path picks —
Markdown rendered as prose, code highlighted behind a line gutter, an HTML
document in a frame of its own, an image at its own size, a PDF through the
document in a frame of its own, an image fitted to the panel, a PDF through the
browser's viewer, anything else as bare text — with the text viewers one
pick away in the header's chooser where they still make sense (an HTML
document or an SVG is also its source; a raster image or a PDF is only
Expand Down Expand Up @@ -398,6 +398,10 @@ unchanged.
| Grouped work details | `015a9b202c`, `1bcb633c3f`, `a0a9aef5ec` | Compact, Standard, Detailed and Verbose; independent completion/next-input collapse timing; whole-turn history paging |
| Model search | `6773e10645`, `989b93d86f`, `c87959bc44` | Fuzzy matching within provider groups, sticky headings, keyboard selection, pending indication and viewport placement |
| Font preferences | `849e9ebd8d` | Independent conversation, code and terminal-output fonts and sizes, persisted per browser |
| Structured composer drafts | `e400349e3a`, `98d93db18f` | Session-scoped text and attachment metadata across navigation/reload; argument-taking commands become removable atomic chips |
| Session list folding and IDs | `7df3c41ec9`, `38c700f08e` | Five-row pages, a growing visible prefix for active sessions, search across folded rows and IDs, copyable session IDs |
| Document previews | `0cad3a0137`, `f65c15a5cf` | YAML frontmatter property cards, source switching, per-tab zoom and fit-width image previews |
| Image inspection | `7fe9c06fa6` | Expand inline and sent images, fit/actual-size controls, keyboard dismissal and lazy image-link hover previews |

**Settings → General** owns reading preferences. Detailed mode shows individual
work rows during the current turn and folds completed groups; Verbose retains
Expand All @@ -407,6 +411,19 @@ on collapsed groups. Assistant messages and notices retain their chronological
positions. Long conversations initially render the latest 20 turns; **Show
earlier messages** adds 20 without moving the first visible turn.

Drafts are private to the browser tab and stored in `sessionStorage`. Accepted
uploads remain attached while the same agent runtime is available. When a saved
conversation needs a replacement runtime, its text is restored and stale upload
markers are removed with a notice to attach the files again. Large image previews
may be omitted from persisted metadata when browser storage is full. Command
chips retain the existing slash-command protocol when submitted.

File previews keep independent zoom levels (25–400%) while their tabs are open.
Images initially fit the panel; HTML and PDF retain their native viewer controls.
Leading YAML frontmatter renders as properties without expanding aliases; malformed
metadata remains visible as source. Session ID popovers support copying, outside
click dismissal and Escape, including near the bottom of a scrolled sidebar.

The upstream account, plugin marketplace, native voice input, remote operators,
translation, hosted search, scheduling service and interactive terminal depend
on upstream host services absent from ClawCodex's gateway. They are not copied
Expand Down
36 changes: 36 additions & 0 deletions ui-web/e2e/conversation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,39 @@ test('real gateway: model search selects a provider/model and keeps the menu in
await expect(search).toHaveCount(0)
expect(app.workspace).toContain('project')
})

test('real gateway: drafts and accepted files survive navigation and reload; command chips stay atomic', async ({ page, app }) => {
const composer = page.getByRole('textbox', { name: 'Message ClawCodex' })
await composer.fill('Start a session for draft persistence')
await composer.press('Enter')
await expect(page.getByText('Hello from the mock inference server! The full boot chain is working.', { exact: true })).toBeVisible({ timeout: 90_000 })
await expect(page.getByRole('button', { name: 'Stop', exact: true })).toHaveCount(0)
const title = page.getByRole('textbox', { name: 'Session title' })
await expect(title).toHaveValue(/Hello from the mock inference server/)
await title.fill('Draft one')
await title.press('Enter')
await expect(page.getByRole('button', { name: /^Draft one/ })).toBeVisible()
await composer.fill('/plan fix the project')
await expect(page.getByRole('button', { name: 'Remove /plan command' })).toBeVisible()
await expect(composer).toHaveValue('fix the project')
await page.locator('input[type=file]').last().setInputFiles({ name: 'notes.txt', mimeType: 'text/plain', buffer: Buffer.from('A persisted attachment.') })
await expect(composer).toHaveValue(/\[File #\d+\]/)
const draft = await composer.inputValue()
await page.reload()
await expect(composer).toHaveValue(draft)
await expect(page.getByRole('button', { name: 'Remove notes.txt' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Remove /plan command' })).toBeVisible()

await page.getByRole('button', { name: 'New session', exact: true }).first().click()
await page.getByRole('button', { name: 'Create', exact: true }).click()
await expect(composer).toHaveValue('')
await composer.fill('A separate unsent draft')
await page.getByRole('button', { name: /^Draft one/ }).click()
await expect(composer).toHaveValue(draft)
await page.getByRole('button', { name: 'Remove /plan command' }).click()
await expect(composer).toHaveValue(draft)
await composer.press('Enter')
await expect(page.locator('[data-file-card]').getByText('notes.txt')).toBeVisible()
await expect.poll(() => app.received.join('')).toContain('A persisted attachment.')
expect(app.workspace).toContain('project')
})
8 changes: 5 additions & 3 deletions ui-web/e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ import { existsSync, appendFileSync } from 'node:fs'
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { startMockServer } from './model-server'
import { startMockServer, PREVIEW_SVG } from './model-server'

const repo = path.resolve(import.meta.dirname, '../..')
const python = process.env.CLAWCODEX_TEST_PYTHON ?? (existsSync(path.join(repo, '.venv/bin/python')) ? path.join(repo, '.venv/bin/python') : 'python3')

/** Real Python gateway and agent loop; only model inference is replaced. */
export const test = base.extend<{ app: { url: string; workspace: string } }>({
export const test = base.extend<{ app: { url: string; workspace: string; received: string[] } }>({
app: async ({ page }, use, testInfo) => {
const temp = await mkdtemp(path.join(tmpdir(), 'clawcodex-web-e2e-'))
const config = path.join(temp, 'config')
Expand All @@ -26,6 +26,7 @@ export const test = base.extend<{ app: { url: string; workspace: string } }>({
}))
await writeFile(path.join(workspace, 'README.md'), '---\ntitle: Preview document\ntags: [web, port]\n---\n# Preview body\n\nA document served by the ClawCodex gateway.\n')
await writeFile(path.join(workspace, 'example.txt'), 'A file from the real workspace.\n')
await writeFile(path.join(workspace, 'preview.svg'), PREVIEW_SVG)
const env: NodeJS.ProcessEnv = {
PATH: process.env.PATH, SystemRoot: process.env.SystemRoot, TMPDIR: process.env.TMPDIR,
PYTHONPATH: repo, PYTHONUNBUFFERED: '1', CLAWCODEX_DISABLE_LEGACY_MIGRATION: '1',
Expand All @@ -52,7 +53,8 @@ export const test = base.extend<{ app: { url: string; workspace: string } }>({
const url = `http://127.0.0.1:${port}`
await page.goto(url)
await expect(page.getByRole('textbox', { name: 'Message ClawCodex' })).toBeVisible()
await use({ url, workspace })
await expect(page.getByText('Connected', { exact: true })).toBeVisible()
await use({ url, workspace, received: mock.received })
expect(errors).toEqual([])
} finally {
await testInfo.attach('backend.log', { body: logs, contentType: 'text/plain' })
Expand Down
15 changes: 13 additions & 2 deletions ui-web/e2e/model-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,18 @@ import http from 'node:http'

export const FINAL_REPLY = 'All done! Here is the complete summary of what I found.'
export const SIMPLE_REPLY = 'Hello from the mock inference server! The full boot chain is working.'
export const PREVIEW_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="2000" height="1000"><rect width="2000" height="1000" fill="#e0ece9"/><path d="M0 1000L2000 0" stroke="#137c6e" stroke-width="40"/><text x="100" y="220" font-size="100" fill="#143b34">ClawCodex image preview</text></svg>'

/** Deterministic OpenAI-compatible inference, with real Read tool execution by the agent. */
export async function startMockServer() {
let toolIssued = false
const received: string[] = []
const server = http.createServer(async (request, response) => {
if (request.url === '/preview.svg') {
response.setHeader('Content-Type', 'image/svg+xml')
response.end(PREVIEW_SVG)
return
}
if (request.url === '/v1/models') {
response.setHeader('Content-Type', 'application/json')
response.end(JSON.stringify({ data: [{ id: 'mock-model' }] }))
Expand All @@ -15,11 +22,15 @@ export async function startMockServer() {
let raw = ''
for await (const chunk of request) raw += String(chunk)
const body = JSON.parse(raw || '{}') as { stream?: boolean; tools?: { function: { name: string } }[]; messages?: { role: string; content: unknown }[] }
received.push(JSON.stringify(body.messages ?? []))
const scripted = JSON.stringify(body.messages ?? []).includes('E2E_INTERIM_TRIGGER')
const readTool = body.tools?.find(entry => ['Read', 'read_file'].includes(entry.function.name))
const tool = scripted && !toolIssued && readTool !== undefined
if (tool) toolIssued = true
const text = tool ? 'I will read the project documentation.' : scripted ? FINAL_REPLY : SIMPLE_REPLY
const imageUrl = `http://${request.headers.host}/preview.svg`
const text = tool ? 'I will read the project documentation.' : scripted ? FINAL_REPLY :
JSON.stringify(body.messages ?? []).includes('E2E_IMAGE_TRIGGER') ?
`![Workspace image](${imageUrl})\n\n[Inspect linked image](${imageUrl})` : SIMPLE_REPLY
const calls = tool ? [{ id: 'read-project', type: 'function', function: { name: readTool?.function.name, arguments: JSON.stringify({ file_path: 'README.md' }) } }] : undefined
const finish = tool ? 'tool_calls' : 'stop'
const usage = { prompt_tokens: 100, completion_tokens: 20, total_tokens: 120 }
Expand All @@ -41,7 +52,7 @@ export async function startMockServer() {
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
if (!address || typeof address === 'string') throw new Error('No model server port')
return { url: `http://127.0.0.1:${address.port}`, close: () => new Promise<void>((resolve, reject) => {
return { url: `http://127.0.0.1:${address.port}`, received, close: () => new Promise<void>((resolve, reject) => {
server.closeAllConnections()
server.close(error => error ? reject(error) : resolve())
}) }
Expand Down
56 changes: 56 additions & 0 deletions ui-web/e2e/previews.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { test, expect } from './fixtures'

test('real gateway: document metadata and per-tab zoom, source switching, and image fit', async ({ page, app }, testInfo) => {
const composer = page.getByRole('textbox', { name: 'Message ClawCodex' })
await composer.fill('Open the workspace')
await composer.press('Enter')
await expect(page.getByText('Hello from the mock inference server! The full boot chain is working.', { exact: true })).toBeVisible({ timeout: 90_000 })
await page.getByRole('button', { name: 'Open workspace in Files' }).click()
await page.getByRole('button', { name: 'README.md', exact: true }).click()
await expect(page.getByRole('heading', { name: 'Preview body' })).toBeVisible()
await expect(page.getByLabel('Document properties').getByText('Preview document')).toBeVisible()
await page.screenshot({ path: testInfo.outputPath('document-preview.png') })
await page.getByRole('button', { name: 'Zoom in', exact: true }).click()
await expect(page.getByLabel('Preview zoom level')).toHaveValue('1.25')
await page.getByRole('tab', { name: 'Files', exact: true }).click()
await page.getByRole('button', { name: 'example.txt', exact: true }).click()
await expect(page.getByText('A file from the real workspace.', { exact: true })).toBeVisible()
await expect(page.getByLabel('Preview zoom level')).toHaveValue('1')
await page.getByRole('tab', { name: 'README.md', exact: true }).click()
await expect(page.getByLabel('Preview zoom level')).toHaveValue('1.25')
await page.getByRole('button', { name: 'Viewer', exact: true }).click()
await page.getByRole('menuitem', { name: 'Plain text', exact: true }).click()
await expect(page.locator('[data-preview-line="2"]')).toContainText('title: Preview document')
await page.getByRole('tab', { name: 'Files', exact: true }).click()
await page.getByRole('button', { name: 'preview.svg', exact: true }).click()
const image = page.getByRole('img', { name: 'Preview of preview.svg' })
await expect(image).toBeVisible()
await expect(page.getByLabel('Preview zoom level')).toHaveValue('fit')
expect((await image.boundingBox())!.width).toBeLessThan(1000)
await page.getByLabel('Preview zoom level').selectOption('1')
await expect.poll(async () => (await image.boundingBox())!.width).toBe(2000)
await page.getByLabel('Preview zoom level').selectOption('fit')
await page.screenshot({ path: testInfo.outputPath('image-file-preview.png') })
expect(app.workspace).toContain('project')
})

test('real gateway: Markdown images expand, fit, close with Escape, and preview on hover', async ({ page, app }, testInfo) => {
await page.getByRole('textbox', { name: 'Message ClawCodex' }).fill('E2E_IMAGE_TRIGGER show the image')
await page.getByRole('textbox', { name: 'Message ClawCodex' }).press('Enter')
const thumbnail = page.getByRole('button', { name: 'Expand image: Workspace image' })
await expect(thumbnail).toBeVisible({ timeout: 90_000 })
await thumbnail.click()
const dialog = page.getByRole('dialog', { name: 'Image preview: Workspace image' })
await expect(dialog).toBeVisible()
await page.getByRole('button', { name: 'Actual size', exact: true }).click()
await expect.poll(async () => (await dialog.getByRole('img').boundingBox())!.width).toBe(2000)
await page.getByRole('button', { name: 'Fit image', exact: true }).click()
expect((await dialog.getByRole('img').boundingBox())!.width).toBeLessThan(1440)
await page.screenshot({ path: testInfo.outputPath('expanded-image.png') })
await page.keyboard.press('Escape')
await expect(dialog).toHaveCount(0)
await expect(thumbnail).toBeFocused()
await page.getByRole('link', { name: 'Inspect linked image' }).hover()
await expect(page.getByRole('img', { name: 'Linked image preview' })).toBeVisible()
expect(app.received.join('')).toContain('E2E_IMAGE_TRIGGER')
})
42 changes: 42 additions & 0 deletions ui-web/e2e/sessions.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { test, expect } from './fixtures'

test('real gateway: session lists fold, search hidden IDs, copy IDs, and keep the selected row visible', async ({ page, context, app }, testInfo) => {
const composer = page.getByRole('textbox', { name: 'Message ClawCodex' })
for (let index = 0; index < 6; index++) {
if (index > 0) {
await page.getByRole('button', { name: 'New session', exact: true }).first().click()
await page.getByRole('button', { name: 'Create', exact: true }).click()
await expect(page.getByRole('dialog')).toHaveCount(0)
}
await composer.fill(`Start saved conversation ${index}`)
await composer.press('Enter')
await expect(page.getByText('Hello from the mock inference server! The full boot chain is working.', { exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: 'Stop', exact: true })).toHaveCount(0)
const title = page.getByRole('textbox', { name: 'Session title' })
await expect(title).toHaveValue(/Hello from the mock inference server/)
await title.fill(`Saved conversation ${index}`)
await title.press('Enter')
await expect(page.getByRole('button', { name: new RegExp(`^Saved conversation ${index}`) })).toBeVisible()
}
await page.reload()
await expect(page.getByRole('textbox', { name: 'Session title' })).toHaveValue('Saved conversation 5')
await expect(page.getByRole('button', { name: /^Saved conversation 0/ })).toHaveCount(0)
await page.getByRole('button', { name: 'Show more (1)', exact: true }).click()
await page.getByRole('button', { name: 'Session actions: Saved conversation 0', exact: true }).click()
const details = page.getByRole('dialog', { name: 'Session details' })
const id = await details.locator('code').innerText()
await context.grantPermissions(['clipboard-read', 'clipboard-write'], { origin: app.url })
await details.getByRole('button', { name: 'Copy session ID' }).click()
await expect(details.getByRole('button', { name: 'Copied' })).toBeVisible()
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(id)
await page.keyboard.press('Escape')
await expect(details).toHaveCount(0)
await page.getByRole('button', { name: 'Show less', exact: true }).click()
await page.getByRole('textbox', { name: 'Filter sessions' }).fill(id)
await page.getByRole('button', { name: /^Saved conversation 0/ }).click()
await page.getByRole('button', { name: 'Clear the filter' }).click()
await expect(page.getByRole('textbox', { name: 'Session title' })).toHaveValue('Saved conversation 0')
await expect(page.getByRole('button', { name: /^Saved conversation 0/ })).toBeVisible()
await page.emulateMedia({ colorScheme: 'dark' })
await page.screenshot({ path: testInfo.outputPath('sessions-dark.png') })
})
18 changes: 17 additions & 1 deletion ui-web/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion ui-web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@
"nanostores": "1.4.0",
"react": "19.2.7",
"react-dom": "19.2.7",
"shiki": "4.3.1"
"shiki": "4.3.1",
"yaml": "2.9.0"
},
"devDependencies": {
"@playwright/test": "1.58.2",
Expand Down
Loading
Loading