NEVER use chrome-devtools_* tools directly in the main conversation.
Chrome DevTools dumps massive snapshots that will exhaust your context window. Always spawn a subagent:
Task(
subagent_type="explore",
description="Debug via Chrome DevTools",
prompt="Use chrome-devtools_* to investigate <issue>. Report findings."
)
This keeps the expensive DOM/network data in disposable subagent context.
opencode-next - Next.js 16 rebuild of the OpenCode web application.
This is the initial scaffold for rebuilding OpenCode's web UI from SolidJS to Next.js 16+ with React Server Components. Currently a simple Bun project that will evolve into a turborepo monorepo (opencode-vibe) with extracted packages.
- β Basic Bun project scaffold
- β TypeScript configuration
- β Architecture Decision Record (ADR 001)
- β³ Next.js app implementation (in progress)
- β³ Turborepo migration (planned)
- β³ Package extraction (planned)
See docs/adr/001-nextjs-rebuild.md for full architecture rationale and migration plan.
| Layer | Technology | Why |
|---|---|---|
| Runtime | Bun | Fast all-in-one runtime, 10x faster installs |
| Testing | Vitest | Fast, isolated tests with proper ESM support |
| Framework | Next.js 16 canary | React Server Components, App Router, Turbopack |
| Bundler | Turbopack | Next-gen bundler, faster than Webpack |
| Monorepo | Turborepo (planned) | Monorepo orchestration, incremental builds |
| Language | TypeScript 5+ | Type safety, LSP support |
| Type Check | typescript-go | Bleeding edge, 10x faster than tsc |
| Linting | oxlint | Fast Rust-based linter |
| Formatting | Biome | Fast formatter, Prettier replacement |
| Chat UI | ai-elements | Battle-tested React components for chat UIs |
| Styling | Tailwind CSS | Utility-first CSS (preserved from SolidJS app) |
Current OpenCode web app (SolidJS) has:
- Provider Hell - 13+ nested context providers
- Mobile UX Issues - 5 confirmed bugs from framework mismatch
- Maintenance Burden - 403-line GlobalSyncProvider god object
Next.js 16 enables:
- Flat hierarchy - RSC eliminates provider nesting
- Better mobile patterns - React hooks map to scroll behavior
- Code reduction - ai-elements eliminates chat UI boilerplate (30-40% reduction)
- Easier hiring - React is 10x more common than SolidJS
opencode-next/
βββ docs/
β βββ adr/
β βββ 001-nextjs-rebuild.md # Architecture rationale
βββ node_modules/
βββ .hive/
β βββ issues.jsonl # Work tracking
βββ .cursor/
β βββ rules/ # Cursor IDE rules
βββ package.json # Bun dependencies
βββ tsconfig.json # TypeScript config
βββ bun.lock # Lockfile
βββ index.ts # Entry point
βββ README.md # Basic setup
βββ CLAUDE.md # AI agent conventions
βββ AGENTS.md # This file
After extraction, directory structure will become:
opencode-vibe/
βββ apps/
β βββ web/ # Next.js 16 app
β βββ app/ # App Router pages
β β βββ layout.tsx
β β βββ page.tsx
β β βββ session/[id]/page.tsx
β βββ src/
β β βββ core/ # β @opencode/core (future package)
β β βββ react/ # β @opencode/react (future package)
β β βββ ui/ # β @opencode/ui (future package)
β βββ package.json
βββ packages/
β βββ core/ # SDK + service layer (extracted)
β βββ react/ # React bindings (extracted)
β βββ ui/ # Shared components (extracted)
βββ turbo.json
βββ package.json
Extraction Strategy:
- Phase 1 - Build in
apps/web/src/folders - Phase 2 - Extract to
packages/when patterns stabilize - No premature extraction - Wait for third use before creating package
# Install dependencies (uses Bun, not npm/pnpm)
bun install# Run dev server (when Next.js app exists)
bun dev
# Build for production
bun build
# Type check (ALWAYS use turbo for full monorepo check)
bun run typecheckCRITICAL: Always run typecheck via turbo to check the full monorepo:
# β
CORRECT - Full monorepo typecheck
bun run typecheck # Runs: turbo type-check
# β WRONG - Only checks single package
cd apps/web && bun run type-checkWhy? Changes in one package can break types in another. Turbo runs type-check across all workspaces with proper dependency ordering.
Before committing: Run bun run typecheck from repo root. Fix all errors.
# Lint (oxlint)
bun lint
# Format (biome)
bun format
# Fix formatting
bun format:fix# Run tests (uses bun:test)
bun test
# Watch mode
bun test --watchRED β GREEN β REFACTOR
Every feature. Every bug fix. No exceptions.
- RED - Write failing test first
- GREEN - Minimum code to pass
- REFACTOR - Clean up while green
Bug fixes: Write test that reproduces bug FIRST, then fix. Prevents regression forever.
NO DOM TESTING. If the DOM is in the mix, we already lost. Don't write tests that render React components with happy-dom/jsdom and assert on DOM output. It's brittle, slow, and tests implementation details not behavior.
renderHookandrenderfrom@testing-libraryare code smells- Component tests that check "does this div have this class" are worthless
- Test pure functions and hooks logic directly
- Test state management (Zustand stores) in isolation
- Test API/SDK integration with mocks
- Use E2E tests (Playwright) for actual UI verification if needed
USE VITEST, NOT BUN TEST. Bun test has poor isolation - Zustand stores and singletons leak state between tests causing flaky failures. Tests pass individually but fail together. Vitest with pool: "forks" has proper isolation.
See @knowledge/tdd-patterns.md for full doctrine.
FIND IT β FIX IT β DON'T BLAME OTHERS
If you encounter broken code, fix it. No excuses.
- Pre-existing type errors? Fix them.
- Failing tests unrelated to your task? Fix them or file a cell.
- Broken imports? Fix them.
- Dead code? Delete it.
What NOT to do:
- β "That's a pre-existing issue" (it's YOUR issue now)
- β "Another agent broke this" (doesn't matter, fix it)
- β "Out of scope" (broken code is always in scope)
- β Leave
// TODOcomments for others (do it yourself)
The codebase should be BETTER after every session, not just different.
If you can't fix it immediately, file a hive cell with priority 1. Don't leave landmines for the next agent.
CRITICAL: Never edit package.json manually.
# β
CORRECT - Use bun CLI
bun add <package> # Production dependency
bun add -d <package> # Dev dependency
bun remove <package> # Uninstall
# β WRONG - Manual edits
# Editing package.json directly breaks lockfile integrityWhy? Bun manages lockfile hashes. Manual edits cause version drift and phantom dependency issues.
Use Bun instead of Node.js, npm, pnpm, or vite.
# β
Use Bun equivalents
bun <file> # Instead of node <file>
bun test # Instead of jest/vitest
bun build <file.html> # Instead of webpack/vite
bun install # Instead of npm/pnpm install
bunx <package> # Instead of npx
# β Don't use these
node index.ts # Use: bun index.ts
npm install # Use: bun install
npx tsc # Use: bunx tscSee CLAUDE.md for full Bun API reference.
No app-level auth needed. Tailscale provides network-level authentication.
This means:
- No OAuth flows in the web app
- No JWT tokens in cookies
- No user login/logout UI
- Trust the network layer
When extracting to turborepo, these will become separate packages:
Framework-agnostic service layer.
// SDK client factory
export function createOpencodeClient(config: {
baseUrl: string;
directory?: string;
}): OpencodeClient
// Namespaces (15 total)
client.session.* // CRUD, messages, prompt
client.provider.* // List, OAuth
client.project.* // List, current, update
client.file.* // List, read, status
client.tool.* // List tools, schemas
// ... 10 morePurpose: Can be used by web, desktop (Tauri), CLI, VSCode extension.
React bindings for OpenCode.
// Hooks
useSession(sessionID: string)
useMessages(sessionID: string)
useSSE(baseUrl: string)
useProvider()
// Context
<OpenCodeProvider baseUrl="..." directory="...">
{children}
</OpenCodeProvider>Purpose: React-specific integration, usable by any React app.
Shared UI components.
// Components (TBD - wait for patterns to emerge)
<ChatUI />
<CodeViewer />
<DiffViewer />
<SessionList />Purpose: Reusable components across UIs (web, desktop).
WAIT FOR THIRD USE before extracting.
| Pattern Usage | Action |
|---|---|
| First use | Implement in apps/web/src/ |
| Second use | Note duplication, consider extraction |
| Third use | Extract to packages/ |
Why? Premature abstraction is worse than duplication. Let patterns emerge organically.
Preserved from backend. Elegant, portable, no changes needed.
// Backend: packages/opencode/src/util/context.ts
export namespace Context {
export function create<T>(name: string) {
const storage = new AsyncLocalStorage<T>();
return {
use() {
return storage.getStore()!;
},
provide<R>(value: T, fn: () => R) {
return storage.run(value, fn);
},
};
}
}
// Usage: Per-directory instance scoping
Instance.provide({ directory: "/path" }, async () => {
// All code here has access to directory context
const dir = Instance.directory;
});Preserved approach, integrated via Server Actions.
// Current (SolidJS)
const events = await client.global.event();
for await (const event of events.stream) {
emitter.emit(event.directory, event.payload);
}
// Future (React)
export function useSSE(baseUrl: string) {
const [connected, setConnected] = useState(false);
useEffect(() => {
const client = createOpencodeClient({ baseUrl });
async function connect() {
const events = await client.global.event();
setConnected(true);
for await (const event of events.stream) {
listeners.current
.get(event.directory)
?.forEach((fn) => fn(event.payload));
}
}
connect().catch(() => setConnected(false));
}, [baseUrl]);
// ... subscribe logic
}Preserved workflow. No changes to SDK generation.
OpenAPI Spec (openapi.json)
β @hey-api/openapi-ts
Generated Types (types.gen.ts)
β
Generated Client (client.gen.ts)
β
SDK Wrapper (sdk.gen.ts) β Namespaced classes
β
Public API (client.ts) β createOpencodeClient()
β
Consumer (apps/web/)
Source of truth: packages/sdk/openapi.json (OpenAPI 3.1.1)
Event-driven state management with Zustand + Immer + React optimizations.
SSE events
β
store.handleSSEEvent()
β
store.handleEvent()
β
Zustand set() with Immer
β
useOpencodeStore selectors
β
useDeferredValue (intentional lag during rapid updates)
β
useMemo (derived state)
β
React.memo (component-level optimization)
β
Component render
| File | Purpose |
|---|---|
apps/web/src/react/use-sse.tsx |
SSE connection, event dispatch to store |
apps/web/src/react/store.ts |
Zustand store with Immer for immutable updates |
apps/web/src/react/use-messages-with-parts.ts |
Hook consuming store with useDeferredValue |
apps/web/src/components/ai-elements/task.tsx |
Component using React.memo for render optimization |
// Binary search for updates (O(log n))
// Assumes ULID IDs are sortable
interface OpencodeStore {
sessions: Session[]; // Sorted by ID
messages: Message[]; // Sorted by ID
parts: Part[]; // Sorted by ID
handleSSEEvent(event); // Entry point from SSE
handleEvent(payload); // Dispatches to specific handlers
// ... event handlers for each entity type
}Updates use binary search on sorted arrays for efficiency, but create new array references on every mutation due to Immer.
Problem: Every store update creates new array/object references, even if content is identical.
Impact: React.memo with shallow comparison always triggers re-renders because references change.
Example:
// Even if metadata.summary hasn't changed, this creates new references
set((state) => {
const partIndex = state.parts.findIndex((p) => p.id === id);
state.parts[partIndex].state.metadata.summary = newSummary; // New part object
});Why It Happens: Immer's copy-on-write semantics ensure immutability by creating new objects for any mutation path.
Problem: "Currently doing" status updates appear slow/laggy during rapid message streaming.
Reality: This is expected behavior, not a bug. useDeferredValue is designed to lag behind the actual value during rapid updates to prevent blocking the UI thread.
Example:
const messages = useOpencodeStore((state) => state.messages);
const deferredMessages = useDeferredValue(messages); // Lags during rapid updatesWhen It's Noticeable: Most visible during AI streaming when parts update every 100-500ms. The deferred value lags by 1-2 frames.
Problem: The "currently doing" summary is deeply nested: part.state.metadata.summary.
Impact: Shallow comparison can't detect changes without comparing the entire part object graph. This makes memoization less effective.
Example:
// Can't just compare part.id, need to check:
part.state?.metadata?.summary !== prevPart.state?.metadata?.summary;Problem: Store uses binary search for O(log n) updates, but Immer creates a new array reference on every insert/update.
Impact: Any component selecting state.parts gets a new reference on every SSE event, triggering re-renders.
Why We Use It: Binary search maintains sorted order for ULIDs and enables efficient lookups. The tradeoff is necessary for performance at scale.
Replace shallow comparison with deep comparison of specific fields:
export const Task = React.memo(TaskComponent, (prev, next) => {
// Compare actual content, not references
return (
prev.part.id === next.part.id &&
prev.part.state?.metadata?.summary === next.part.state?.metadata?.summary
);
});Use Zustand's shallow comparison for derived state:
import { shallow } from "zustand/shallow";
const messages = useOpencodeStore(
(state) => state.messages.filter((m) => m.sessionId === id),
shallow, // Compare array contents, not reference
);Buffer rapid SSE events and dispatch batched updates:
let updateQueue: Event[] = [];
let debounceTimer: NodeJS.Timeout;
function handleSSEEvent(event: Event) {
updateQueue.push(event);
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
store.handleBatchedEvents(updateQueue);
updateQueue = [];
}, 16); // One frame delay (60fps)
}Extract specific fields at the selector level to minimize re-renders:
// Bad: Returns new object on every render
const part = useOpencodeStore((state) => state.parts.find((p) => p.id === id));
// Good: Returns primitive that can be compared
const summary = useOpencodeStore(
(state) => state.parts.find((p) => p.id === id)?.state?.metadata?.summary,
);- SSE event latency: < 50ms from server to store update
- Store update latency: < 5ms (binary search + Immer)
- useDeferredValue lag: 1-2 frames during rapid updates (expected)
- Render frequency: Throttled by React's concurrent rendering
Bottleneck: React.memo with shallow comparison on objects with new references from Immer. Fix by implementing content-aware comparison.
- No timeout on requests - AI operations can run for minutes.
req.timeout = falsein client factory. - Directory scoping -
x-opencode-directoryheader routes requests to specific project instance. - Dual SDK instances - One for SSE (no timeout), one for requests (10min timeout).
- No database - All data in filesystem (
~/.local/state/opencode/). No migrations, no transactions. - Event bus is global -
GlobalBus.emit()broadcasts to ALL clients. No per-client filtering. - Instance caching -
Instance.provide()caches per directory. Dispose required to clear cache. - SSE heartbeat required - 30s heartbeat prevents WKWebView 60s timeout on mobile Safari.
- Binary search everywhere - Updates use binary search on sorted arrays. Assumes IDs are sortable (they are - ULIDs).
- Session limit - UI loads 5 sessions by default + any updated in last 4 hours. Older sessions lazy-loaded.
useOpencodeStore() returns a new reference on every render. This causes infinite loops when used in useEffect/useCallback dependencies.
// β BAD - Causes infinite network requests
const store = useOpencodeStore();
useEffect(() => {
store.initDirectory(directory);
}, [directory, store]); // store changes every render β infinite loop
// β
GOOD - Use getState() for actions inside effects
useEffect(() => {
useOpencodeStore.getState().initDirectory(directory);
}, [directory]);
// β
GOOD - Helper function pattern
const getStoreActions = () => useOpencodeStore.getState();
useEffect(() => {
getStoreActions().initDirectory(directory);
}, [directory]);The Rule:
- Use
getState()for actions inside effects/callbacks (stable reference) - Use the hook return value only for selectors (subscribing to state changes)
Files that follow this pattern:
apps/web/src/react/provider.tsx- UsesgetStoreActions()helperapps/web/src/react/use-multi-server-sse.ts- UsesgetState()in callbackapps/web/src/app/projects-list.tsx- UsesgetState()in async functionsapps/web/src/app/session/[id]/session-layout.tsx- UsesgetState()in useEffect
- ADR 001: Next.js Rebuild - Full architecture rationale
- Bun API Docs - Local Bun reference
- Next.js Docs - Next.js 16 App Router
- ai-elements - Chat UI components
packages/opencode- Backend (Hono server, AsyncLocalStorage DI)packages/sdk- OpenAPI-generated SDK with 15 namespacespackages/app- Current SolidJS app (being replaced)
| File | Purpose |
|---|---|
docs/adr/001-nextjs-rebuild.md |
Architecture rationale, migration plan |
CLAUDE.md |
AI agent conventions, Bun usage |
.hive/issues.jsonl |
Work tracking (git-backed) |
package.json |
Bun dependencies |
tsconfig.json |
TypeScript configuration |
Phase 1: Scaffold & Basic Session View (Current)
- Create Next.js 16 project scaffolding (this repo)
- Document architecture (ADR 001)
- Set up Tailwind, TypeScript, ESLint
- Implement layout hierarchy (no provider nesting)
- Create session list page (RSC)
- Create session detail page with ai-elements ChatUI
Phase 2: Real-Time Sync via SSE (Week 2)
- Implement
useSSEhook with reconnection - Create Server Actions for SDK calls
- Implement message streaming
- Handle part updates (tool calls, results)
Phase 3: Full Feature Parity (Week 3)
- Implement all session features
- Add code/diff viewers
- Implement search/filtering
- Add provider management UI
Phase 4: Mobile-First Polish (Week 4)
- Fix auto-scroll on session load
- Add scroll-to-bottom FAB
- Responsive design for mobile
- Test on real devices
See ADR 001 for detailed timeline and success criteria.
- Architecture questions: See
docs/adr/001-nextjs-rebuild.md - Bun usage: See
CLAUDE.md - Work tracking: Check
.hive/issues.jsonlor runbd list - SDK reference:
packages/sdk/openapi.json(OpenAPI 3.1.1)