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
58 changes: 57 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,62 @@
# AGENT NOTES

## Styling Guidelines
## Communication Requirement
**CRITICAL: ALWAYS USE QUESTION TOOL**

**MUST use `question` or `ask_user` tool (whichever is available in the current session) for:**

- ANY reply to the user
- ANY response to the user
- ANY summary for the user

**Session ending: ONLY end the session when the user EXPLICITLY requests it through the "question" or "ask_user" tool.**

**NO EXCEPTIONS. This rule is MANDATORY and NON-NEGOTIABLE.**

## Build Commands
- `npm run typecheck` - Typecheck all packages (UI + electron-app)
- `npm run build` - Build the electron-app
- `npm run build:ui` - Build the UI package
- `npm run build:tauri` - Build the Tauri app
- `npm run dev` - Start electron-app dev server
- `npm run dev:tauri` - Start Tauri dev server

## Testing
- Use Node.js built-in test runner (`node:test`)
- Test files are located in `__tests__` directories (e.g., `packages/server/src/filesystem/__tests__/`)
- Run individual test: `node --test packages/server/src/filesystem/__tests__/search-cache.test.ts`

## Code Style Guidelines

### Imports
- Type imports first: `import type { X } from "..."` then regular imports
- External packages before internal modules
- Group SolidJS imports explicitly: `import { Component, For, Show } from "solid-js"`

### Naming Conventions
- Components: PascalCase (`App`, `InstanceShell`)
- Functions/variables: camelCase (`handleSelectFolder`, `launchError`)
- Module-level constants: UPPER_SNAKE_CASE (`FALLBACK_API_BASE`)
- Types/Interfaces: PascalCase (`WorkspaceCreateRequest`, `FileSystemEntry`)
- Files: kebab-case (`api-client.ts`, `workspaces.ts`)

### Formatting
- 2-space indentation
- Use TypeScript strict mode (already enabled in tsconfig.json)
- Use `type` keyword for type-only imports
- Interfaces for object shapes, types for unions/primitives

### SolidJS Specifics
- Use reactive primitives: `createSignal`, `createMemo`, `createEffect`
- Component function type: `Component = () => JSX.Element`
- Signals follow convention: `name()` is accessor, `setName()` is setter

### Error Handling
- Use `unknown` for error types in catch blocks
- Log errors with context: `log.error("message", error)`
- Server routes: return appropriate HTTP status codes (400, 404, 500)

### Styling Guidelines
- Reuse the existing token & utility layers before introducing new CSS variables or custom properties. Extend `src/styles/tokens.css` / `src/styles/utilities.css` if a shared pattern is needed.
- Keep aggregate entry files (e.g., `src/styles/controls.css`, `messaging.css`, `panels.css`) lean—they should only `@import` feature-specific subfiles located inside `src/styles/{components|messaging|panels}`.
- When adding new component styles, place them beside their peers in the scoped subdirectory (e.g., `src/styles/messaging/new-part.css`) and import them from the corresponding aggregator file.
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode-config/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@
"version": "0.5.0",
"private": true,
"dependencies": {
"@opencode-ai/plugin": "1.1.12"
"@opencode-ai/plugin": "1.1.23"
}
}
128 changes: 101 additions & 27 deletions packages/ui/src/components/askquestion-wizard.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { createMemo, For, onMount, onCleanup, Show, type Component } from "solid-js"
import { createMemo, For, onMount, onCleanup, Show, type Component, createSignal } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { createEffect } from "solid-js"
import { Minus } from "lucide-solid"
import type { WizardQuestion, QuestionAnswer, QuestionOption } from "../types/question"
import { renderMarkdown } from "../lib/markdown"

// Custom option marker
const CUSTOM_OPTION_LABEL = "Type something..."
Expand All @@ -12,6 +15,7 @@ export interface AskQuestionWizardProps {
questions: WizardQuestion[]
onSubmit: (answers: QuestionAnswer[]) => void
onCancel: () => void
onMinimize?: () => void
}

interface QuestionState {
Expand All @@ -35,6 +39,8 @@ export const AskQuestionWizard: Component<AskQuestionWizardProps> = (props) => {

let containerRef: HTMLDivElement | undefined
let inputRef: HTMLInputElement | undefined
let optionsContainerRef: HTMLDivElement | undefined
let optionRefs: HTMLButtonElement[] = []

// Current question based on active tab
const currentQuestion = createMemo(() => {
Expand All @@ -43,6 +49,25 @@ export const AskQuestionWizard: Component<AskQuestionWizardProps> = (props) => {
})
const currentState = createMemo(() => store.questionStates[store.activeTab])

// Rendered markdown for the question text
const [questionHtml, setQuestionHtml] = createSignal("")

// Render question text as markdown
createEffect(async () => {
const question = currentQuestion()
if (question && question.question) {
try {
const html = await renderMarkdown(question.question)
setQuestionHtml(html)
} catch (error) {
console.error("[AskQuestionWizard] Failed to render question markdown:", error)
setQuestionHtml(question.question) // Fallback to plain text
}
} else {
setQuestionHtml("")
}
})

// Options including "Type something..." at the end
const optionsWithCustom = createMemo((): WizardOption[] => {
const current = currentQuestion()
Expand Down Expand Up @@ -133,27 +158,42 @@ export const AskQuestionWizard: Component<AskQuestionWizardProps> = (props) => {
function navigateOption(direction: "up" | "down") {
const current = currentState().selectedOption
const max = optionsWithCustom().length - 1
const newOptionIdx = direction === "up"
? (current > 0 ? current - 1 : max)
: (current < max ? current + 1 : 0)

setStore(
produce((s) => {
if (direction === "up") {
s.questionStates[s.activeTab].selectedOption = current > 0 ? current - 1 : max
} else {
s.questionStates[s.activeTab].selectedOption = current < max ? current + 1 : 0
}
s.questionStates[s.activeTab].selectedOption = newOptionIdx
}),
)

// Scroll the newly selected option into view
setTimeout(() => {
const selectedElement = optionsContainerRef?.querySelector('[data-option-selected="true"]')
if (selectedElement) {
selectedElement.scrollIntoView({
behavior: 'smooth',
block: 'nearest'
})
}
}, 0)
}

function navigateQuestion(direction: "left" | "right") {
if (direction === "right") {
if (store.activeTab < props.questions.length - 1) {
setStore("activeTab", store.activeTab + 1)
// Reset option refs when switching questions
optionRefs = []
} else if (allAnswered()) {
handleSubmit()
}
} else {
if (store.activeTab > 0) {
setStore("activeTab", store.activeTab - 1)
// Reset option refs when switching questions
optionRefs = []
}
}
}
Expand Down Expand Up @@ -328,6 +368,25 @@ export const AskQuestionWizard: Component<AskQuestionWizardProps> = (props) => {
// Focus container to capture keyboard events
containerRef?.focus()
document.addEventListener("keydown", handleKeyDown, true)
// Reset option refs when switching questions
optionRefs = []
})

// Scroll selected option into view when active tab changes
createEffect(() => {
const activeTab = store.activeTab
const selectedOption = store.questionStates[activeTab].selectedOption

// Scroll to selected option with a slight delay to ensure DOM is updated
setTimeout(() => {
const selectedElement = optionsContainerRef?.querySelector('[data-option-selected="true"]')
if (selectedElement) {
selectedElement.scrollIntoView({
behavior: 'smooth',
block: 'nearest'
})
}
}, 0)
})

onCleanup(() => {
Expand All @@ -344,25 +403,38 @@ export const AskQuestionWizard: Component<AskQuestionWizardProps> = (props) => {
>
<div class="askquestion-wizard-header">
<div class="askquestion-wizard-title">Answer the questions</div>
<button
type="button"
class="askquestion-wizard-close"
onClick={() => {
console.log('[AskQuestionWizard] Close button clicked, calling onCancel')
console.log('[AskQuestionWizard] props.onCancel type:', typeof props.onCancel)
console.log('[AskQuestionWizard] props.onCancel:', props.onCancel)
try {
props.onCancel()
console.log('[AskQuestionWizard] onCancel called successfully')
} catch (err) {
console.error('[AskQuestionWizard] onCancel threw error:', err)
}
}}
aria-label="Cancel"
title="Cancel (Esc)"
>
</button>
<div class="askquestion-wizard-header-buttons">
<Show when={props.onMinimize}>
<button
type="button"
class="askquestion-wizard-minimize"
onClick={() => props.onMinimize?.()}
aria-label="Minimize"
title="Minimize (hide temporarily)"
>
<Minus size={16} />
</button>
</Show>
<button
type="button"
class="askquestion-wizard-close"
onClick={() => {
console.log('[AskQuestionWizard] Close button clicked, calling onCancel')
console.log('[AskQuestionWizard] props.onCancel type:', typeof props.onCancel)
console.log('[AskQuestionWizard] props.onCancel:', props.onCancel)
try {
props.onCancel()
console.log('[AskQuestionWizard] onCancel called successfully')
} catch (err) {
console.error('[AskQuestionWizard] onCancel threw error:', err)
}
}}
aria-label="Cancel"
title="Cancel (Esc)"
>
</button>
</div>
</div>

{/* Tab bar */}
Expand Down Expand Up @@ -412,14 +484,14 @@ export const AskQuestionWizard: Component<AskQuestionWizardProps> = (props) => {

{/* Current question */}
<div class="askquestion-wizard-question">
<h3 class="askquestion-wizard-question-text">{currentQuestion().question}</h3>
<div class="askquestion-wizard-question-text markdown-body" innerHTML={questionHtml()} />
<Show when={currentQuestion().multiple}>
<p class="askquestion-wizard-question-hint">(select multiple, press Enter to confirm)</p>
</Show>
</div>

{/* Options */}
<div class="askquestion-wizard-options">
<div ref={optionsContainerRef} class="askquestion-wizard-options">
<For each={optionsWithCustom()}>
{(option, index) => {
const optionLabel = option.label // Use label as value
Expand All @@ -438,11 +510,13 @@ export const AskQuestionWizard: Component<AskQuestionWizardProps> = (props) => {
return (
<button
type="button"
ref={(el) => { optionRefs[index()] = el }}
class="askquestion-wizard-option"
classList={{
"askquestion-wizard-option-selected": isSelected(),
"askquestion-wizard-option-chosen": isChosen(),
}}
data-option-selected={isSelected()}
onClick={() => {
// Update selectedOption for visual feedback
setStore(
Expand Down
39 changes: 35 additions & 4 deletions packages/ui/src/components/instance/instance-shell2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import CommandPalette from "../command-palette"
import FolderTreeBrowser from "../folder-tree-browser"
import PermissionNotificationBanner from "../permission-notification-banner"
import PermissionApprovalModal from "../permission-approval-modal"
import QuestionNotificationBanner from "../question-notification-banner"
import { AskQuestionWizard } from "../askquestion-wizard"
import Kbd from "../kbd"
import { TodoListView } from "../tool-call/renderers/todo"
Expand Down Expand Up @@ -157,6 +158,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const [folderTreeBrowserOpen, setFolderTreeBrowserOpen] = createSignal(false)
const [permissionModalOpen, setPermissionModalOpen] = createSignal(false)
const [questionWizardOpen, setQuestionWizardOpen] = createSignal(false)
const [questionWizardMinimized, setQuestionWizardMinimized] = createSignal(false)

const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instance.id))

Expand Down Expand Up @@ -215,13 +217,16 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
}
})

// Auto-open question wizard when a pending question appears
// Auto-open question wizard when a pending question appears (unless minimized)
createEffect(() => {
const pending = getPendingQuestion(props.instance.id)
if (pending && !questionWizardOpen()) {
if (pending && !questionWizardMinimized()) {
// Auto-open only if user hasn't minimized
setQuestionWizardOpen(true)
} else if (!pending && questionWizardOpen()) {
} else if (!pending) {
// Reset states when no pending questions
setQuestionWizardOpen(false)
setQuestionWizardMinimized(false)
}
})

Expand Down Expand Up @@ -276,6 +281,12 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
}
}

const handleQuestionMinimize = () => {
setQuestionWizardMinimized(true)
setQuestionWizardOpen(false)
// Question remains in queue, notification banner will show
}

const measureDrawerHost = () => {
if (typeof window === "undefined") return
const host = drawerHost()
Expand Down Expand Up @@ -1357,6 +1368,16 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
onClick={() => setPermissionModalOpen(true)}
/>

<Show when={questionWizardMinimized() && getPendingQuestion(props.instance.id)}>
<QuestionNotificationBanner
instanceId={props.instance.id}
onClick={() => {
setQuestionWizardMinimized(false)
setQuestionWizardOpen(true)
}}
/>
</Show>

<button
type="button"
class="connection-status-button px-2 py-0.5 text-xs whitespace-nowrap flex-shrink-1 min-w-0"
Expand Down Expand Up @@ -1431,11 +1452,20 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
</button>
</Show>

<div style={{ flex: "0 0 auto", display: "flex", "align-items": "center" }}>
<div style={{ flex: "0 0 auto", display: "flex", "align-items": "center", gap: "8px" }}>
<PermissionNotificationBanner
instanceId={props.instance.id}
onClick={() => setPermissionModalOpen(true)}
/>
<Show when={questionWizardMinimized() && getPendingQuestion(props.instance.id)}>
<QuestionNotificationBanner
instanceId={props.instance.id}
onClick={() => {
setQuestionWizardMinimized(false)
setQuestionWizardOpen(true)
}}
/>
</Show>
</div>
<button
type="button"
Expand Down Expand Up @@ -1608,6 +1638,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
questions={mappedQuestions()}
onSubmit={handleQuestionSubmit}
onCancel={handleQuestionCancel}
onMinimize={handleQuestionMinimize}
/>
</div>
)
Expand Down
Loading