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
50 changes: 50 additions & 0 deletions .github/workflows/demo.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
name: Demo

# The live demo on GitHub Pages: a static build of the UI whose API answers
# and "AI" run in the visitor's browser (demo/). No server, no keys.
on:
push:
branches: [main]
workflow_dispatch:

permissions:
contents: read

concurrency:
group: pages
cancel-in-progress: true

jobs:
build:
name: Build and test the demo
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm run build:demo
- run: npx playwright install --with-deps chromium
- run: npx playwright test -c playwright.demo.config.ts
env:
DEMO_SKIP_BUILD: '1'
- uses: actions/configure-pages@v6
- uses: actions/upload-pages-artifact@v5
with:
path: node_modules/.cache/opencode-web-demo/public

deploy:
name: Deploy to GitHub Pages
needs: build
runs-on: ubuntu-latest
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v5
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@

Built with **Nuxt 4 + Nuxt UI**, keeping the opencode look & feel.

**[▶ Try the live demo](https://juanmandev.github.io/opencode-web/)**: the real UI running entirely in your browser with a tiny scripted AI. Ask it to "fix the bug", "run the tests" or "delegate a review to a subagent". No server, no keys, nothing leaves your browser.

<img src="docs/screenshots/chat.png" alt="Chat view" width="800" />

<sub>Screenshots are generated by CI against a mock opencode server and refreshed on every release.</sub>
Expand All @@ -31,6 +33,7 @@ One origin means no CORS, no double auth, and SSE streaming included. **It works
| --- | --- |
| 🗂️ **Project picker** | Start page with known projects and recents. Open any folder by path or browse the server filesystem. Everything is scoped per project via opencode's `?directory=` API. |
| 💬 **Live chat** | SSE streaming with markdown, collapsible thinking, tool calls with input/output, per-step tokens & cost, timestamps, abort, a prompt queue, and permission and question prompts that survive reloads. Message actions: copy, read aloud, edit & resend, fork. |
| ⌨️ **Autocomplete like opencode** | `@` mentions files, folders and subagents (attached the way opencode does), `/` runs commands, `!` switches to shell mode. On phones the options row hides while the keyboard is open. |
| 🧠 **Model + think level** | Model picker across all providers with pricing & context details, think-level selector for reasoning models, agent picker. Persisted per project. |
| 🔌 **MCP manager** | Per-project and global MCP status, off / ask / auto modes per server and per tool, presets, and add remote or local servers from a catalog, a URL, a command line, pasted JSON or a form. **Per-conversation MCP selection** in the prompt box. Tool lists are discovered from the servers themselves over **Streamable HTTP, legacy SSE or stdio**. Every server has a **details view**: why it fails and how to fix it, its tools with full descriptions and parameters, an **editor** that never exposes stored secrets, and a step-by-step **connection test**. [More →](docs/mcp.md) |
| 🤖 **Agent control** | One click registers this app's MCP server with opencode, so **the chat can manage opencode itself**: test, add and reconnect MCP servers, reload the config (after running replies finish), read and prompt other chats, see task lists, and manage schedules. State-changing tools ask first. [More →](docs/mcp.md#agent-control) |
Expand Down Expand Up @@ -65,6 +68,32 @@ One origin means no CORS, no double auth, and SSE streaming included. **It works
<td>Fullscreen app viewer</td>
<td>Apps on a phone</td>
</tr>
<tr>
<td><img src="docs/screenshots/mcp-diagnose.png" alt="A failing MCP server explains why" /></td>
<td><img src="docs/screenshots/mcp-tools.png" alt="Tools of an MCP server with parameters" /></td>
<td><img src="docs/screenshots/mobile-mcp.png" alt="MCP settings on a phone" width="180" /></td>
</tr>
<tr align="center">
<td>Diagnose &amp; fix a server</td>
<td>Tools with parameters</td>
<td>Settings on a phone</td>
</tr>
<tr>
<td><img src="docs/screenshots/subagents.png" alt="Subagent card in the chat" /></td>
<td><img src="docs/screenshots/mcp-subagents.png" alt="Orchestrator with a subagent per MCP server" /></td>
<td><img src="docs/screenshots/schedules.png" alt="Scheduled prompts" /></td>
</tr>
<tr align="center">
<td>Subagents in the chat</td>
<td>Subagent per MCP</td>
<td>Scheduled prompts</td>
</tr>
<tr>
<td colspan="3" align="center"><img src="docs/screenshots/demo.png" alt="The live demo: a scripted in-browser AI fixing a bug" width="640" /></td>
</tr>
<tr align="center">
<td colspan="3"><a href="https://juanmandev.github.io/opencode-web/">Live demo</a>: everything runs in your browser</td>
</tr>
</table>

## Quick start (Docker Compose)
Expand Down Expand Up @@ -208,6 +237,8 @@ NUXT_OPENCODE_URL=http://127.0.0.1:4517 npm run dev
npm run typecheck # vue-tsc
npm run test:unit # vitest: MCP client (HTTP + SSE), redaction, directory encoding
npm run build && npm run test:e2e # playwright e2e against the mock, incl. MCP UI apps and mobile
npm run build:demo && node demo/serve.mjs # the static in-browser demo at http://127.0.0.1:3340/opencode-web/
npm run test:demo # its Playwright suite
SCREENSHOTS=1 npm run test:e2e # also regenerate docs/screenshots
# real opencode (any version) + a mock model; COMPAT_PROTOCOL=v2 forces the v2 protocol
OPENCODE_VERSION=1.18.34 npx playwright test -c playwright.compat.config.ts
Expand Down
22 changes: 22 additions & 0 deletions app/assets/css/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,28 @@ body {
right: 0;
}

/* action rows: one line on wide screens; on phones every button takes the
full width. `.oc-actions` (modal footers, primary action last in the
markup) stacks with the primary on top; `.oc-actions-col` keeps order */
.oc-actions,
.oc-actions-col {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
width: 100%;
}
.oc-actions { justify-content: flex-end; }
@media (max-width: 639px) {
.oc-actions { flex-direction: column-reverse; align-items: stretch; }
.oc-actions-col { flex-direction: column; align-items: stretch; }
.oc-actions > *,
.oc-actions-col > * { width: 100%; justify-content: center; }
.oc-actions > .oc-spacer,
.oc-actions-col > .oc-spacer { display: none; }
.oc-actions-col > .oc-note { text-align: center; }
}

/* nav tiles in the project sidebar */
.oc-tile {
transition: background-color 0.15s ease, color 0.15s ease, transform 0.12s ease;
Expand Down
4 changes: 2 additions & 2 deletions app/components/AgentControlCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ const TOOLS = [
>
<UInput v-model="url" placeholder="http://web:3000/mcp" class="w-full font-mono" />
</UFormField>
<div class="flex flex-wrap items-center gap-1.5">
<div class="oc-actions-col sm:gap-1.5">
<UButton
size="xs"
color="primary"
Expand Down Expand Up @@ -123,7 +123,7 @@ const TOOLS = [
label="Tools"
@click="emit('details', state.name)"
/>
<span class="text-[10px] text-dimmed">Saved in the global config; new chats get the tools.</span>
<span class="oc-note text-[10px] text-dimmed">Saved in the global config; new chats get the tools.</span>
</div>
</div>
</CollapseTransition>
Expand Down
4 changes: 2 additions & 2 deletions app/components/McpNestCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -210,10 +210,10 @@ function fmt(ts?: number) {
/>
</UFormField>

<div class="flex flex-wrap items-center gap-2">
<div class="oc-actions-col">
<UButton size="xs" color="primary" icon="i-lucide-save" :label="draft.enabled ? 'Save & apply' : 'Save'" :loading="saving" :disabled="!dirty" @click="save()" />
<UButton size="xs" variant="soft" color="neutral" icon="i-lucide-scroll-text" :label="showPrompt ? 'Hide orchestrator prompt' : 'Orchestrator prompt'" @click="showPrompt = !showPrompt" />
<span class="text-[10px] text-dimmed">
<span class="oc-note text-[10px] text-dimmed">
<template v-if="plan.settings.lastSync?.error"><span class="text-error">Last sync failed: {{ plan.settings.lastSync.error }}</span></template>
<template v-else-if="plan.settings.lastSync">Written {{ fmt(plan.settings.lastSync.at) }} · stays in sync when MCP servers change</template>
<template v-else>Uses the servers of the global config; saved there too</template>
Expand Down
16 changes: 8 additions & 8 deletions app/components/McpServerModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -571,10 +571,10 @@ const title = computed(() => creating.value ? 'Add MCP server' : props.name || '
<UFormField v-if="creating" label="Name" size="sm" class="flex-1 min-w-40">
<UInput v-model="form.name" placeholder="home-assistant" class="w-full font-mono" />
</UFormField>
<UFormField label="Type" size="sm">
<div class="flex gap-1">
<UButton size="xs" :variant="form.type === 'remote' ? 'solid' : 'soft'" :color="form.type === 'remote' ? 'primary' : 'neutral'" icon="i-lucide-globe" label="Remote URL" :disabled="jsonMode" @click="form.type = 'remote'" />
<UButton size="xs" :variant="form.type === 'local' ? 'solid' : 'soft'" :color="form.type === 'local' ? 'primary' : 'neutral'" icon="i-lucide-terminal" label="Local command" :disabled="jsonMode" @click="form.type = 'local'" />
<UFormField label="Type" size="sm" class="w-full sm:w-auto">
<div class="grid grid-cols-2 gap-1">
<UButton size="xs" :variant="form.type === 'remote' ? 'solid' : 'soft'" :color="form.type === 'remote' ? 'primary' : 'neutral'" icon="i-lucide-globe" label="Remote URL" class="justify-center" :disabled="jsonMode" @click="form.type = 'remote'" />
<UButton size="xs" :variant="form.type === 'local' ? 'solid' : 'soft'" :color="form.type === 'local' ? 'primary' : 'neutral'" icon="i-lucide-terminal" label="Local command" class="justify-center" :disabled="jsonMode" @click="form.type = 'local'" />
</div>
</UFormField>
<span class="flex-1" />
Expand Down Expand Up @@ -668,10 +668,10 @@ const title = computed(() => creating.value ? 'Add MCP server' : props.name || '
</template>

<USeparator />
<div class="flex flex-wrap items-center gap-2">
<div class="grid grid-cols-[auto_1fr_1fr] items-center gap-1.5">
<span class="text-xs text-dimmed">Save to</span>
<UButton size="xs" :variant="form.scope === 'project' ? 'solid' : 'soft'" :color="form.scope === 'project' ? 'primary' : 'neutral'" icon="i-lucide-folder-git-2" :label="dirName(directory)" @click="form.scope = 'project'" />
<UButton size="xs" :variant="form.scope === 'global' ? 'solid' : 'soft'" :color="form.scope === 'global' ? 'primary' : 'neutral'" icon="i-lucide-globe" label="Global" @click="form.scope = 'global'" />
<UButton size="xs" class="justify-center" :variant="form.scope === 'project' ? 'solid' : 'soft'" :color="form.scope === 'project' ? 'primary' : 'neutral'" icon="i-lucide-folder-git-2" :label="dirName(directory)" @click="form.scope = 'project'" />
<UButton size="xs" class="justify-center" :variant="form.scope === 'global' ? 'solid' : 'soft'" :color="form.scope === 'global' ? 'primary' : 'neutral'" icon="i-lucide-globe" label="Global" @click="form.scope = 'global'" />
<span v-if="!creating && info?.definedIn && info.definedIn !== form.scope" class="text-[11px] text-warning">
defined in the {{ info.definedIn }} config: saving here adds an override
</span>
Expand Down Expand Up @@ -740,7 +740,7 @@ const title = computed(() => creating.value ? 'Add MCP server' : props.name || '
</template>

<template #footer>
<div class="flex flex-wrap items-center justify-end gap-2 w-full">
<div class="oc-actions">
<UButton variant="ghost" color="neutral" label="Close" @click="open = false" />
<UButton
v-if="tab === 'config'"
Expand Down
44 changes: 37 additions & 7 deletions app/components/chat/MessageItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const speech = useSpeech()

const messageText = computed(() =>
(props.message.parts || [])
.filter((p) => p.type === 'text')
.filter((p) => p.type === 'text' && !(p as { synthetic?: boolean }).synthetic)
.map((p) => (p as { text?: string }).text || '')
.join('\n\n')
.trim()
Expand Down Expand Up @@ -61,6 +61,28 @@ const visibleParts = computed<Part[]>(() =>
})
)

// user messages: the text the user wrote (opencode adds synthetic parts with
// the contents of @-mentioned files and agent hand-offs: those stay hidden)
const userTextParts = computed(() => visibleParts.value.filter(
(p) => p.type === 'text' && !(p as { synthetic?: boolean }).synthetic
))

interface Chip { id: string; kind: 'file' | 'folder' | 'agent' | 'attachment'; label: string; icon: string; title: string }
const userChips = computed<Chip[]>(() => visibleParts.value.flatMap((p): Chip[] => {
const part = p as Record<string, any>
if (part.type === 'agent') {
return [{ id: p.id, kind: 'agent', label: `@${part.name}`, icon: 'i-lucide-bot', title: `Handed to the ${part.name} agent` }]
}
if (part.type !== 'file') return []
const url = String(part.url || '')
if (url.startsWith('file:')) {
const folder = part.mime === 'application/x-directory'
const path = part.source?.path || part.filename || decodeURIComponent(url.replace(/^file:\/\//, ''))
return [{ id: p.id, kind: folder ? 'folder' : 'file', label: `@${path}`, icon: folder ? 'i-lucide-folder' : 'i-lucide-file', title: decodeURIComponent(url.replace(/^file:\/\//, '')) }]
}
return [{ id: p.id, kind: 'attachment', label: part.filename || 'attachment', icon: 'i-lucide-paperclip', title: part.mime || '' }]
}))

const showReasoning = ref(false)

function tokenTotal(tokens?: TokenUsage) {
Expand Down Expand Up @@ -91,13 +113,21 @@ const errorMessage = computed(() => {
@click="emit('fork')"
/>
</UTooltip>
<template v-for="part in visibleParts" :key="part.id">
<div v-if="part.type === 'text'" class="text-sm whitespace-pre-wrap break-words">{{ (part as any).text }}</div>
<div v-else-if="part.type === 'file'" class="flex items-center gap-1.5 text-xs text-muted mt-1">
<UIcon name="i-lucide-paperclip" class="size-3.5" />
{{ (part as any).filename || (part as any).url }}
</div>
<template v-for="part in userTextParts" :key="part.id">
<div class="text-sm whitespace-pre-wrap break-words">{{ (part as any).text }}</div>
</template>
<!-- attachments and @ mentions: one row of chips -->
<div v-if="userChips.length" class="flex flex-wrap gap-1 mt-1.5">
<span
v-for="chip in userChips"
:key="chip.id"
class="inline-flex items-center gap-1 rounded-sm bg-accented/70 px-1.5 py-0.5 text-[11px] font-mono text-muted max-w-full"
:title="chip.title"
>
<UIcon :name="chip.icon" class="size-3 shrink-0" :class="chip.kind === 'agent' ? 'text-primary' : ''" />
<span class="truncate">{{ chip.label }}</span>
</span>
</div>
<!-- sent time + user message actions -->
<div class="flex items-center gap-2 mt-1 -mb-1 min-w-0">
<time
Expand Down
Loading
Loading