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
23 changes: 23 additions & 0 deletions packages/app/e2e/regression/session-request-docks.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,29 @@ test("shows a pending question dock", async ({ page }) => {
await expect(question.getByRole("radio", { name: /Extended/ })).toBeVisible()
await expect(page.locator('[data-component="session-composer"]')).toHaveCount(0)

const rejectRequests: string[] = []
page.on("request", (request) => {
if (request.method() !== "POST") return
if (new URL(request.url()).pathname === "/question/question-request/reject") rejectRequests.push(request.url())
})

await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click()
await expect(question).toBeVisible()
await expect(question.getByText("Which implementation should be used?")).toBeVisible()
await expect(question.getByText("Select one answer")).toBeHidden()
await expect(question.getByRole("radio", { name: /Minimal/ })).toBeHidden()
await expect(question.getByRole("radio", { name: /Extended/ })).toBeHidden()
await expect(question.getByRole("button", { name: "Dismiss" })).toBeVisible()
await expect(question.getByRole("button", { name: "Submit" })).toBeVisible()
await expect(page.locator('[data-component="question-minimized-dock"]')).toHaveCount(0)
expect(rejectRequests).toEqual([])

await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click()
await expect(question).toBeVisible()
await expect(question.getByText("Which implementation should be used?")).toBeVisible()
await expect(question.getByRole("radio", { name: /Minimal/ })).toBeVisible()
expect(rejectRequests).toEqual([])

await question.getByRole("radio", { name: /Minimal/ }).click()
const reply = page.waitForRequest(
(request) => request.method() === "POST" && new URL(request.url()).pathname === "/question/question-request/reply",
Expand Down
4 changes: 4 additions & 0 deletions packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -657,6 +657,10 @@ export const dict = {
"session.todo.expand": "Expand",
"session.todo.progress": "{{done}} of {{total}} todos completed",
"session.question.progress": "{{current}} of {{total}} questions",
"session.question.minimize": "Minimize question",
"session.question.restore": "Restore question",
"session.question.pending.one": "{{count}} pending question",
"session.question.pending.other": "{{count}} pending questions",

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like .multi made more sense, but the convention elsewhere was .other so I kept it the same.

"session.followupDock.summary.one": "{{count}} queued message",
"session.followupDock.summary.other": "{{count}} queued messages",
"session.followupDock.sendNow": "Send now",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { For, Show, createMemo, onCleanup, onMount, type Component } from "solid-js"
import { For, Show, createEffect, createMemo, onCleanup, onMount, type Component } from "solid-js"
import { createStore } from "solid-js/store"
import { useMutation } from "@tanstack/solid-query"
import { Button } from "@opencode-ai/ui/button"
import { DockPrompt } from "@opencode-ai/session-ui/dock-prompt"
import { Icon } from "@opencode-ai/ui/icon"
import { useSpring } from "@opencode-ai/ui/motion-spring"
import { showToast } from "@/utils/toast"
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
import { useLanguage } from "@/context/language"
Expand Down Expand Up @@ -77,9 +78,12 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
customOn: cached?.customOn ?? ([] as boolean[]),
editing: false,
focus: 0,
minimized: false,
optionsHeight: 180,
})

let root: HTMLDivElement | undefined
let optionsRef: HTMLDivElement | undefined
let customRef: HTMLButtonElement | undefined
let optsRef: HTMLButtonElement[] = []
let replied = false
Expand All @@ -96,11 +100,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const n = Math.min(store.tab + 1, total())
return language.t("session.question.progress", { current: n, total: total() })
})

const customLabel = () => language.t("ui.messagePart.option.typeOwnAnswer")
const customPlaceholder = () => language.t("ui.question.custom.placeholder")

const last = createMemo(() => store.tab >= total() - 1)
const collapse = useSpring(() => (store.minimized ? 1 : 0), { visualDuration: 0.3, bounce: 0 })
const hidden = createMemo(() => Math.max(0, Math.min(1, collapse())))
const optionsOff = createMemo(() => hidden() > 0.98)

const customUpdate = (value: string, selected: boolean = on()) => {
const prev = input().trim()
Expand Down Expand Up @@ -192,6 +198,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
focus(pickFocus())
})

createEffect(() => {
const el = optionsRef
if (!el) return
const update = () => setStore("optionsHeight", (height) => Math.max(height, el.scrollHeight))
update()
createResizeObserver(el, update)
})

onCleanup(() => {
if (focusFrame !== undefined) cancelAnimationFrame(focusFrame)
if (replied) return
Expand Down Expand Up @@ -405,7 +419,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const tab = store.tab + 1
setStore("tab", tab)
setStore("editing", false)
focus(pickFocus(tab))
if (!store.minimized) focus(pickFocus(tab))
}

const back = () => {
Expand All @@ -414,24 +428,38 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const tab = store.tab - 1
setStore("tab", tab)
setStore("editing", false)
focus(pickFocus(tab))
if (!store.minimized) focus(pickFocus(tab))
}

const jump = (tab: number) => {
if (sending()) return
setStore("tab", tab)
setStore("editing", false)
focus(pickFocus(tab))
if (!store.minimized) focus(pickFocus(tab))
}

const minimize = () => {
if (sending()) return
setStore("editing", false)
setStore("minimized", true)
}

const restore = () => {
if (sending()) return
setStore("minimized", false)
focus(pickFocus())
}

return (
<div data-component="session-question-dock">
<DockPrompt
kind="question"
ref={(el) => (root = el)}
onKeyDown={nav}
header={
<>
<div data-slot="question-header-title">{summary()}</div>
<div data-slot="question-header-actions">
<Show when={total() > 1}>
<div data-slot="question-progress">
<For each={questions()}>
Expand All @@ -449,6 +477,20 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
</For>
</div>
</Show>
<button
type="button"
data-component="icon-button"
data-icon="chevron-down"
data-size="normal"
data-variant="ghost"
disabled={sending()}
style={{ transform: `rotate(${hidden() * 180}deg)` }}
onClick={store.minimized ? restore : minimize}
aria-label={language.t(store.minimized ? "session.question.restore" : "session.question.minimize")}
>
<Icon name="chevron-down" size="small" />
</button>
</div>
</>
}
footer={
Expand All @@ -475,13 +517,33 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
</>
}
>
<div data-slot="question-text" class="overflow-auto">
<div
data-slot="question-text"
style={{
display: store.minimized ? "-webkit-box" : undefined,
"-webkit-line-clamp": store.minimized ? "3" : undefined,
"-webkit-box-orient": store.minimized ? "vertical" : undefined,
overflow: store.minimized ? "hidden" : undefined,
}}
>
{question()?.question}
</div>
<Show when={!store.minimized}>
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
</Show>
<div data-slot="question-options">
</Show>
<div
ref={(el) => (optionsRef = el)}
data-slot="question-options"
aria-hidden={store.minimized || optionsOff() ? "true" : undefined}
classList={{ "pointer-events-none": hidden() > 0.1 }}
style={{
"max-height": `${Math.max(0, store.optionsHeight * (1 - hidden()))}px`,
opacity: `${Math.max(0, Math.min(1, 1 - hidden()))}`,
visibility: optionsOff() ? "hidden" : "visible",
}}
>
<For each={options()}>
{(opt, i) => (
<Option
Expand Down Expand Up @@ -572,5 +634,6 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
</Show>
</div>
</DockPrompt>
</div>
)
}
49 changes: 47 additions & 2 deletions packages/session-ui/src/components/message-part.css
Original file line number Diff line number Diff line change
Expand Up @@ -860,6 +860,13 @@
min-width: 0;
}

[data-slot="question-header-actions"] {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}

[data-slot="question-progress"] {
display: flex;
align-items: center;
Expand Down Expand Up @@ -918,6 +925,7 @@
line-height: var(--line-height-large);
color: var(--v2-text-text-base);
padding: 0 10px;
flex-shrink: 0;
-webkit-user-select: text;
user-select: text;
}
Expand All @@ -929,15 +937,16 @@
line-height: var(--line-height-large);
color: var(--v2-text-text-muted);
padding: 0 10px;
flex-shrink: 0;
}

[data-slot="question-options"] {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 12px;
padding: 1px 1px 8px;
flex-shrink: 0;
padding: 1px 1px 16px;
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
scrollbar-width: none;
Expand Down Expand Up @@ -1126,6 +1135,42 @@
}
}

[data-component="question-minimized-dock"] {
margin-bottom: 8px;

[data-slot="question-minimized-trigger"] {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 10px 12px;
border: 0;
background: transparent;
color: var(--v2-text-text-base);
text-align: left;
cursor: pointer;
touch-action: manipulation;

&:disabled {
cursor: not-allowed;
opacity: 0.6;
}

[data-component="icon"] {
transform: rotate(180deg);
}
}

[data-slot="question-minimized-summary"] {
flex: 1;
font-family: var(--font-family-sans);
font-size: 14px;
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
color: var(--v2-text-text-base);
}
}

[data-component="question-answers"] {
display: flex;
flex-direction: column;
Expand Down
Loading