Skip to content
Open
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
21 changes: 21 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,24 @@
- To regenerate the javascript SDK, run ./packages/sdk/js/script/build.ts
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.
- the default branch in this repo is `dev`

## Testing the Web UI

To test changes in the web UI (`packages/app` or `packages/ui`), you need to run **two servers**:

1. **API Server** (in `packages/opencode`):

```bash
cd packages/opencode
bun dev -- serve --port 5555
```

2. **Vite Dev Server** (in `packages/app`):
```bash
cd packages/app
bun dev
```

Then open http://localhost:3000 (Vite dev server with HMR), NOT port 5555 (API only).

The Vite dev server provides hot module reloading so code changes are reflected immediately.
75 changes: 75 additions & 0 deletions packages/app/src/context/global-sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
type LspStatus,
type VcsInfo,
type PermissionRequest,
type QuestionRequest,
createOpencodeClient,
} from "@opencode-ai/sdk/v2/client"
import { createStore, produce, reconcile } from "solid-js/store"
Expand Down Expand Up @@ -48,6 +49,9 @@ type State = {
permission: {
[sessionID: string]: PermissionRequest[]
}
question: {
[sessionID: string]: QuestionRequest[]
}
mcp: {
[name: string]: McpStatus
}
Expand Down Expand Up @@ -96,6 +100,7 @@ function createGlobalSync() {
session_diff: {},
todo: {},
permission: {},
question: {},
mcp: {},
lsp: [],
vcs: undefined,
Expand Down Expand Up @@ -205,6 +210,38 @@ function createGlobalSync() {
}
})
}),
sdk.question.list().then((x) => {
const grouped: Record<string, QuestionRequest[]> = {}
for (const q of x.data ?? []) {
if (!q?.id || !q.sessionID) continue
const existing = grouped[q.sessionID]
if (existing) {
existing.push(q)
continue
}
grouped[q.sessionID] = [q]
}

batch(() => {
for (const sessionID of Object.keys(store.question)) {
if (grouped[sessionID]) continue
setStore("question", sessionID, [])
}
for (const [sessionID, questions] of Object.entries(grouped)) {
setStore(
"question",
sessionID,
reconcile(
questions
.filter((q) => !!q?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id)),
{ key: "id" },
),
)
}
})
}),
]).then(() => {
setStore("status", "complete")
})
Expand Down Expand Up @@ -393,6 +430,44 @@ function createGlobalSync() {
)
break
}
case "question.asked": {
const sessionID = event.properties.sessionID
const questions = store.question[sessionID]
if (!questions) {
setStore("question", sessionID, [event.properties])
break
}

const result = Binary.search(questions, event.properties.id, (q) => q.id)
if (result.found) {
setStore("question", sessionID, result.index, reconcile(event.properties))
break
}

setStore(
"question",
sessionID,
produce((draft) => {
draft.splice(result.index, 0, event.properties)
}),
)
break
}
case "question.replied":
case "question.rejected": {
const questions = store.question[event.properties.sessionID]
if (!questions) break
const result = Binary.search(questions, event.properties.requestID, (q) => q.id)
if (!result.found) break
setStore(
"question",
event.properties.sessionID,
produce((draft) => {
draft.splice(result.index, 1)
}),
)
break
}
case "lsp.updated": {
const sdk = createOpencodeClient({
baseUrl: globalSDK.url,
Expand Down
12 changes: 12 additions & 0 deletions packages/app/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,15 @@
cursor: default;
}
}

/* Style prompt input when question is attached above it */
.question-attached {
border-radius: 0 0 var(--radius-md) var(--radius-md) !important;
box-shadow:
-1px 0 0 0 var(--border-base),
1px 0 0 0 var(--border-base),
0 1px 0 0 var(--border-base),
0 1px 2px -1px rgba(19, 16, 16, 0.04),
0 1px 2px 0 rgba(19, 16, 16, 0.06),
0 1px 3px 0 rgba(19, 16, 16, 0.08) !important;
}
12 changes: 10 additions & 2 deletions packages/app/src/pages/directory-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { LocalProvider } from "@/context/local"
import { base64Decode } from "@opencode-ai/util/encode"
import { DataProvider } from "@opencode-ai/ui/context"
import { iife } from "@opencode-ai/util/iife"
import type { QuestionAnswer } from "@opencode-ai/sdk/v2/client"

export default function Layout(props: ParentProps) {
const params = useParams()
Expand All @@ -21,12 +22,17 @@ export default function Layout(props: ParentProps) {
{iife(() => {
const sync = useSync()
const sdk = useSDK()
const respond = (input: {
const respondToPermission = (input: {
sessionID: string
permissionID: string
response: "once" | "always" | "reject"
}) => sdk.client.permission.respond(input)

const respondToQuestion = (input: { requestID: string; answers: QuestionAnswer[] }) =>
sdk.client.question.reply({ requestID: input.requestID, answers: input.answers })

const rejectQuestion = (requestID: string) => sdk.client.question.reject({ requestID })

const navigateToSession = (sessionID: string) => {
navigate(`/${params.dir}/session/${sessionID}`)
}
Expand All @@ -35,7 +41,9 @@ export default function Layout(props: ParentProps) {
<DataProvider
data={sync.data}
directory={directory()}
onPermissionRespond={respond}
onPermissionRespond={respondToPermission}
onQuestionRespond={respondToQuestion}
onQuestionReject={rejectQuestion}
onNavigateToSession={navigateToSession}
>
<LocalProvider>{props.children}</LocalProvider>
Expand Down
43 changes: 40 additions & 3 deletions packages/app/src/pages/session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Tabs } from "@opencode-ai/ui/tabs"
import { useCodeComponent } from "@opencode-ai/ui/context/code"
import { SessionTurn } from "@opencode-ai/ui/session-turn"
import { SessionTurn, QuestionPrompt } from "@opencode-ai/ui/session-turn"
import { createAutoScroll } from "@opencode-ai/ui/hooks"
import { SessionReview } from "@opencode-ai/ui/session-review"
import { SessionMessageRail } from "@opencode-ai/ui/session-message-rail"
Expand Down Expand Up @@ -268,6 +268,23 @@ export default function Page() {
const hasReview = createMemo(() => reviewCount() > 0)
const revertMessageID = createMemo(() => info()?.revert?.messageID)
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))

// Get child sessions for question/permission aggregation (like TUI)
const children = createMemo(() => {
const parentID = info()?.parentID ?? info()?.id
if (!parentID) return []
return sync.data.session
.filter((s) => s.parentID === parentID || s.id === parentID)
.toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
})

// Get questions from all child sessions (only if not a child session itself)
const questions = createMemo(() => {
if (info()?.parentID) return []
const result = children().flatMap((x) => sync.data.question?.[x.id] ?? [])
return result
})
const nextQuestion = createMemo(() => (questions().length > 0 ? questions()[0] : undefined))
const messagesReady = createMemo(() => {
const id = params.id
if (!id) return true
Expand Down Expand Up @@ -1212,17 +1229,36 @@ export default function Page() {
</Switch>
</div>

{/* Prompt input */}
{/* Prompt input and question prompt */}
<div
ref={(el) => (promptDock = el)}
class="absolute inset-x-0 bottom-0 pt-12 pb-4 md:pb-8 flex flex-col justify-center items-center z-50 px-4 md:px-0 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none"
>
<div
classList={{
"w-full md:px-6 pointer-events-auto": true,
"w-full md:px-6 pointer-events-auto flex flex-col": true,
"md:max-w-200": !showTabs(),
}}
>
{/* Question prompt - attached directly above the prompt input */}
<Show when={params.id ? nextQuestion() : undefined}>
{(question) => (
<QuestionPrompt
question={question()}
onRespond={(answers) => {
sdk.client.question.reply({
requestID: question().id,
answers,
})
}}
onReject={() => {
sdk.client.question.reject({
requestID: question().id,
})
}}
/>
)}
</Show>
<Show
when={prompt.ready()}
fallback={
Expand All @@ -1237,6 +1273,7 @@ export default function Page() {
}}
newSessionWorktree={newSessionWorktree()}
onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
class={nextQuestion() ? "question-attached" : ""}
/>
</Show>
</div>
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/tool/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ export namespace ToolRegistry {

return [
InvalidTool,
...(Flag.OPENCODE_CLIENT === "cli" ? [QuestionTool] : []),
QuestionTool,
BashTool,
ReadTool,
GlobTool,
Expand Down
Loading