Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
ee128b0
feat: add React/Electron UI and enable CORS in API
basnijholt Nov 25, 2025
2f6819d
docs: add UI development plan and architecture overview
basnijholt Nov 25, 2025
42bd3f7
fix: refactor list_conversations to use list comprehension
basnijholt Nov 25, 2025
6418b3b
feat: add settings modal for model and config management
basnijholt Nov 25, 2025
cec3d1a
docs(ui): revise plan to use native assistant-ui primitives
basnijholt Nov 25, 2025
fb75a0f
chore: add assistant-ui as reference submodule
basnijholt Nov 25, 2025
491ff20
docs(ui): expand plan with implementation guide and API contract
basnijholt Nov 25, 2025
8854a8b
docs(ui): add detailed context for next agent and verification checklist
basnijholt Nov 25, 2025
9317200
feat(ui): implement native assistant-ui runtime adapter
basnijholt Nov 25, 2025
89a5b3c
chore: remove node_modules from git tracking
basnijholt Nov 25, 2025
4d3f7ef
feat(ui): add settings modal for model and RAG configuration
basnijholt Nov 25, 2025
15e8c89
feat(ui): fetch available models from API instead of hardcoding
basnijholt Nov 25, 2025
cb863b3
fix(ui): auto-select first model on startup to fix chat
basnijholt Nov 25, 2025
a191508
fix(ui): handle reasoning_content in SSE stream for thinking models
basnijholt Nov 25, 2025
2595339
fix(ui): use useExternalStoreRuntime to fix chat messaging
basnijholt Nov 25, 2025
5cb4738
docs(ui): update plan with Phase 3 completion and E2E tests
basnijholt Nov 25, 2025
2c007ac
feat(ui): add conversation persistence across page refresh
basnijholt Nov 25, 2025
103b315
feat(ui): add dark mode toggle and keyboard shortcuts
basnijholt Nov 25, 2025
996b428
docs(ui): update plan with Phase 3.6 - QoL features completion
basnijholt Nov 25, 2025
9b2143b
fix(ui): add animated typing indicator while assistant is responding
basnijholt Nov 25, 2025
ec30276
fix(ui): improve typing indicator and add collapsible reasoning display
basnijholt Nov 25, 2025
4415eba
refactor(ui): use official assistant-ui reasoning component pattern
basnijholt Nov 25, 2025
5c7f594
feat(ui): support reasoning_content from OpenAI-compatible APIs
basnijholt Nov 25, 2025
9414455
feat(ui): add model picker dropdown in composer area
basnijholt Nov 25, 2025
ad7a5e3
feat(ui): move model picker to sidebar with fuzzy search and keyboard…
basnijholt Nov 25, 2025
13ce324
feat: persist message metadata in YAML front matter for UI refresh
basnijholt Nov 25, 2025
ffc0743
chore(ui): add ESLint, Prettier, and pre-commit hooks for TypeScript/…
basnijholt Nov 25, 2025
4d3d53a
refactor(ui): simplify codebase by extracting shared types and hooks
basnijholt Nov 25, 2025
f732dab
refactor: use ResponseMetadata model for metadata persistence
basnijholt Nov 25, 2025
7a2a23d
refactor: remove backward compatibility for flat response_metadata
basnijholt Nov 25, 2025
4c4f8d5
refactor: align TypeScript types with Python models (snake_case)
basnijholt Nov 25, 2025
c8c5ce4
docs: update UI-PLAN with clearer runtime decision rationale
basnijholt Nov 25, 2025
5e7a709
Merge remote-tracking branch 'origin/main' into ui
basnijholt Nov 26, 2025
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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ ENV/
*.swp
*.swo

# Node.js
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# Worktrees directory
worktrees/

Expand Down
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[submodule "assistant-ui"]
path = assistant-ui
url = https://github.com/assistant-ui/assistant-ui.git
24 changes: 23 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ repos:
rev: v6.0.0
hooks:
- id: check-added-large-files
exclude: uv\.lock
exclude: uv\.lock|bun\.lock
- id: trailing-whitespace
- id: end-of-file-fixer
- id: mixed-line-ending
# Python linting and formatting
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: "v0.14.6"
hooks:
Expand All @@ -18,3 +19,24 @@ repos:
hooks:
- id: mypy
additional_dependencies: ["types-PyYAML"]
# TypeScript/JavaScript linting and formatting
- repo: local
hooks:
- id: eslint
name: eslint
entry: bash -c 'cd UI && bun run lint:fix'
language: system
files: ^UI/.*\.(ts|tsx|js|jsx)$
pass_filenames: false
- id: prettier
name: prettier
entry: bash -c 'cd UI && bun run format'
language: system
files: ^UI/.*\.(ts|tsx|js|jsx|json|css|scss|md)$
pass_filenames: false
- id: typecheck
name: typecheck
entry: bash -c 'cd UI && bun run typecheck'
language: system
files: ^UI/.*\.(ts|tsx)$
pass_filenames: false
723 changes: 723 additions & 0 deletions UI-PLAN.md

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions UI/.prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": false,
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"bracketSpacing": true,
"bracketSameLine": false,
"arrowParens": "always",
"endOfLine": "lf"
}
2,142 changes: 2,142 additions & 0 deletions UI/bun.lock

Large diffs are not rendered by default.

324 changes: 324 additions & 0 deletions UI/e2e/chat.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,324 @@
import { test, expect } from "@playwright/test";

// Mock API responses
const mockModels = {
data: [
{ id: "test-model-1", owned_by: "test" },
{ id: "test-model-2", owned_by: "test" },
],
};

const mockConversations = {
conversations: [],
};

// Helper to create SSE stream response
function createSSEResponse(content: string): string {
const chunks = content.split(" ");
let response = "";
let accumulated = "";

// Initial chunk with role
response += `data: {"choices":[{"finish_reason":null,"index":0,"delta":{"role":"assistant","content":null}}]}\n\n`;

// Content chunks
for (const chunk of chunks) {
accumulated += (accumulated ? " " : "") + chunk;
response += `data: {"choices":[{"finish_reason":null,"index":0,"delta":{"content":"${chunk} "}}]}\n\n`;
}

// Final chunk
response += `data: {"choices":[{"finish_reason":"stop","index":0,"delta":{}}]}\n\n`;
response += `data: [DONE]\n\n`;

return response;
}

test.describe("Chat UI", () => {
test.beforeEach(async ({ page }) => {
// Mock all API endpoints
await page.route("**/v1/models", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(mockModels),
});
});

await page.route("**/v1/conversations", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(mockConversations),
});
});

await page.route("**/v1/conversations/*", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ messages: [] }),
});
});
});

test("loads and displays main UI elements", async ({ page }) => {
await page.goto("/");

// Wait for loading to complete
await expect(page.getByRole("button", { name: "New Chat" }).first()).toBeVisible();
await expect(page.getByRole("button", { name: "Settings" })).toBeVisible();
await expect(page.getByPlaceholder("Type a message...")).toBeVisible();
await expect(page.getByRole("button", { name: "Send" })).toBeVisible();
});

test("opens settings modal and shows fetched models", async ({ page }) => {
await page.goto("/");

// Wait for app to load
await expect(page.getByRole("button", { name: "Settings" })).toBeVisible();

// Open settings
await page.getByRole("button", { name: "Settings" }).click();

// Check modal is open
await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible();

// Check models are displayed
await expect(page.getByText("2 models available")).toBeVisible();

// Check model dropdown has options
const select = page.locator("select");
await expect(select).toBeVisible();
});

test("sends a message and receives streamed response", async ({ page }) => {
// Track ALL requests
const allRequests: string[] = [];
const consoleMessages: string[] = [];
const consoleErrors: string[] = [];

page.on("request", (request) => {
allRequests.push(`${request.method()} ${request.url()}`);
});

page.on("console", (msg) => {
consoleMessages.push(`[${msg.type()}] ${msg.text()}`);
if (msg.type() === "error") {
consoleErrors.push(msg.text());
}
});

page.on("pageerror", (error) => {
consoleErrors.push(`PAGE ERROR: ${error.message}`);
});

// Mock chat endpoint with streaming response
await page.route("**/v1/chat/completions", async (route) => {
console.log(">>> Chat completions route hit!");
const sseContent = createSSEResponse("Hello! How can I help you today?");
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: sseContent,
});
});

await page.goto("/");

// Wait for app to load
await expect(page.getByPlaceholder("Type a message...")).toBeVisible();

// Type a message
const input = page.getByPlaceholder("Type a message...");
await input.fill("Hello");

// Send message by pressing Enter (more reliable than clicking)
await input.press("Enter");

// Wait a bit for the message to process
await page.waitForTimeout(3000);

// Take screenshot
await page.screenshot({ path: "test-results/after-send.png" });

// Debug: log all info
console.log(
"All requests to 8100:",
allRequests.filter((r) => r.includes("8100"))
);
console.log("Console errors:", consoleErrors);
console.log("Console messages:", consoleMessages.slice(-10)); // Last 10 messages

// Wait for response to appear - use a more flexible matcher
await expect(page.getByText(/How can I help/)).toBeVisible({ timeout: 10000 });
});

test("handles thinking model with reasoning_content", async ({ page }) => {
// Mock chat endpoint with reasoning_content (thinking model)
await page.route("**/v1/chat/completions", async (route) => {
const response =
[
`data: {"choices":[{"finish_reason":null,"index":0,"delta":{"role":"assistant","content":null}}]}`,
`data: {"choices":[{"finish_reason":null,"index":0,"delta":{"reasoning_content":"Let me think..."}}]}`,
`data: {"choices":[{"finish_reason":null,"index":0,"delta":{"reasoning_content":" The user said hello."}}]}`,
`data: {"choices":[{"finish_reason":"stop","index":0,"delta":{}}]}`,
`data: [DONE]`,
].join("\n\n") + "\n\n";

await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: response,
});
});

await page.goto("/");
await expect(page.getByPlaceholder("Type a message...")).toBeVisible();

await page.getByPlaceholder("Type a message...").fill("Hello");
await page.getByText("Send").click();

// Should display reasoning content
await expect(page.getByText("Let me think... The user said hello.")).toBeVisible({
timeout: 10000,
});
});

test("shows error when no model selected", async ({ page }) => {
// Mock models endpoint to return empty list
await page.route("**/v1/models", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: [] }),
});
});

await page.goto("/");

// Should show "No model selected" warning
await expect(page.getByText("No model selected")).toBeVisible();
await expect(page.getByText("Open Settings")).toBeVisible();
});

test("loads existing conversations from backend", async ({ page }) => {
// Mock conversations endpoint to return existing conversations
const existingConversations = {
conversations: ["project-alpha", "work-notes", "personal-chat"],
};

await page.route("**/v1/conversations", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(existingConversations),
});
});

// Mock individual conversation endpoint to return messages
await page.route("**/v1/conversations/project-alpha", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
messages: [
{ role: "user", content: "Previous message from user" },
{ role: "assistant", content: "Previous response from assistant" },
],
}),
});
});

await page.goto("/");

// Wait for app to load
await expect(page.getByRole("button", { name: "New Chat" }).first()).toBeVisible();

// The thread list should show existing conversations
await expect(page.getByText("project-alpha")).toBeVisible();
await expect(page.getByText("work-notes")).toBeVisible();
await expect(page.getByText("personal-chat")).toBeVisible();
});

test("auto-selects first conversation and loads its messages", async ({ page }) => {
// Mock conversations endpoint
const existingConversations = {
conversations: ["my-conversation"],
};

await page.route("**/v1/conversations", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(existingConversations),
});
});

// Mock conversation history with messages
await page.route("**/v1/conversations/my-conversation", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
messages: [
{ role: "user", content: "Hello from previous session" },
{ role: "assistant", content: "Hi! I remember our conversation." },
],
}),
});
});

await page.goto("/");

// Wait for app to load
await expect(page.getByRole("button", { name: "New Chat" }).first()).toBeVisible();

// The conversation should be auto-selected and messages loaded
await expect(page.getByText("Hello from previous session")).toBeVisible({ timeout: 5000 });
await expect(page.getByText("Hi! I remember our conversation.")).toBeVisible({ timeout: 5000 });
});

test("persists selected thread in localStorage", async ({ page, context: _context }) => {
// Mock conversations endpoint
await page.route("**/v1/conversations", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ conversations: ["chat-1", "chat-2"] }),
});
});

// Mock conversation endpoints
await page.route("**/v1/conversations/chat-1", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ messages: [{ role: "user", content: "Chat 1 message" }] }),
});
});

await page.route("**/v1/conversations/chat-2", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ messages: [{ role: "user", content: "Chat 2 message" }] }),
});
});

await page.goto("/");
await expect(page.getByRole("button", { name: "New Chat" }).first()).toBeVisible();

// Click on chat-2 to switch
await page.getByText("chat-2").click();

// Wait for chat-2 messages to load
await expect(page.getByText("Chat 2 message")).toBeVisible({ timeout: 5000 });

// Verify localStorage was set (we can check via evaluate)
const savedThread = await page.evaluate(() =>
localStorage.getItem("agent-cli-selected-thread")
);
expect(savedThread).toBe("chat-2");
});
});
Loading
Loading