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
150 changes: 149 additions & 1 deletion packages/app/e2e/regression/timeline-stream.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { SessionMessageAssistant, SessionMessageInfo, ShellInfo } from "@op
import { timelinePresets } from "@opencode/session-ui/timeline/detail"
import { createTwoFilesPatch } from "diff"
import { readSessionDraftText } from "../utils/drafts"
import { holdRoute } from "../utils/app"
import {
assistantID,
assistantMessage,
Expand Down Expand Up @@ -2005,6 +2006,7 @@ test("quotes plain timeline text into the composer, with or without a comment",
const user = '[data-slot="user-message-text"]'
const answer = `[data-timeline-part-id="${renderedPartID("prt_quote_answer")}"] [data-slot="text-part-body"]`
await expect(page.locator(answer).getByText("bundle", { exact: true })).toBeVisible()
await expect(page.locator('[data-component="composer-editor"]')).toBeFocused()

const select = (from: string, to: string) =>
page.evaluate(
Expand Down Expand Up @@ -2050,7 +2052,7 @@ test("quotes plain timeline text into the composer, with or without a comment",
await comment.fill("Why three?")
await expect(popover.getByRole("button")).toHaveCount(1)
await expect(popover.getByRole("button", { name: "Add comment", exact: true })).toBeVisible()
await popover.getByRole("button", { name: "Add comment", exact: true }).click()
await comment.press("Alt+Enter")
await expect(comment).toHaveCount(0)
await expect(chips.nth(1)).toContainText("Why three?")
await chips.nth(1).click()
Expand Down Expand Up @@ -2110,6 +2112,151 @@ test("quotes plain timeline text into the composer, with or without a comment",
await expect(chips).toHaveCount(0)
})

test("quote popup sends independently without consuming the draft or other quotes", async ({ page }) => {
const sent: string[] = []

await setupTimeline(page, {
messages: [userMessage(), assistantMessage([textPart("prt_direct_quote", "Keep this quoted passage.")])],
onPrompt: (input) => void sent.push(String(input.body.delivery)),
})

const answer = page.locator(
`[data-timeline-part-id="${renderedPartID("prt_direct_quote")}"] [data-slot="text-part-body"]`,
)

const editor = page.locator('[data-component="composer-editor"]')

const quote = page
.getByRole("toolbar", { name: "Quote selection" })
.getByRole("button", { name: "Quote", exact: true })

await expect(answer).toHaveText("Keep this quoted passage.")
await editor.fill("Keep my separate draft")
await selectQuoteText(answer)
await quote.click({ modifiers: ["Alt"] })
await expect(editor).toBeFocused()
await selectQuoteText(answer)
await quote.click()
const popup = page.locator('[data-component="composer-quote-comment"]')

const comment = popup.locator("textarea")

const action = popup.getByRole("button")

const modifier = await page.evaluate(() => (/(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control"))

await expect(comment).toBeFocused()
await comment.fill("Explain this please")
await action.focus()
await page.keyboard.down(modifier)
await expect(action).toHaveAccessibleName("Send quote")
await page.keyboard.press("Enter")
await page.keyboard.up(modifier)
await expect(popup).toHaveCount(0)
await expect.poll(() => sent).toEqual(["steer"])
await expect(editor).toHaveText("Keep my separate draft")
const remaining = page.locator('[data-component="composer-attachments"] [data-component="attachment-card"]')

await expect(remaining).toHaveCount(1)
await expect(remaining).toContainText("Keep this quoted passage.")
await expect(remaining).not.toContainText("Explain this please")
})

test("a pending quote send hides mentions and keeps the comment read-only", async ({ page }) => {
const sent: string[] = []

await setupTimeline(page, {
settings: { followUpBehavior: "steer" },
sessionStatus: { [sessionID]: { type: "busy" } },
messages: [userMessage(), assistantMessage([textPart("prt_quote_mentions", "Keep this quoted passage.")])],
findFiles: () => ["src/api.ts"],
onPrompt: (input) => void sent.push(String(input.body.delivery)),
})

const answer = page.locator(
`[data-timeline-part-id="${renderedPartID("prt_quote_mentions")}"] [data-slot="text-part-body"]`,
)

await expect(answer).toHaveText("Keep this quoted passage.")
await expect(page.locator('[data-component="composer-editor"]')).toBeFocused()
await selectQuoteText(answer)
await page
.getByRole("toolbar", { name: "Quote selection" })
.getByRole("button", { name: "Quote", exact: true })
.click()
const popup = page.locator('[data-component="composer-quote-comment"]')

const comment = popup.locator("textarea")

const modifier = await page.evaluate(() => (/(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control"))

await comment.fill("Explain @src/a")
await expect(popup.locator('[data-slot="line-comment-v2-mention-item"]')).toHaveCount(1)
const admission = await holdRoute(page, new RegExp(`/api/session/${sessionID}/prompt(\\?|$)`), { method: "POST" })

await popup.locator('[data-slot="line-comment-v2-submit"]').click({ modifiers: [modifier] })
await admission.arrived
await expect(popup.getByRole("button")).toBeDisabled()
await expect(comment).toHaveAttribute("readonly", "")
await expect(popup.locator('[data-component="loader-v2"]')).toBeVisible()
await expect(popup.locator('[data-slot="line-comment-v2-mention-item"]')).toHaveCount(0)
await comment.press("Tab")
await expect(comment).toHaveValue("Explain @src/a")
expect(admission.requests).toHaveLength(1)
admission.release()
await expect(popup).toHaveCount(0)
await expect.poll(() => sent).toEqual(["queue"])
})

function selectQuoteText(target: Locator) {
return target.evaluate((element) => {
const range = document.createRange()

range.selectNodeContents(element)
window.getSelection()?.removeAllRanges()
window.getSelection()?.addRange(range)
})
}

test("a failed direct quote send keeps the popup and comment for retry", async ({ page }) => {
let reject = true

await setupTimeline(page, {
messages: [userMessage(), assistantMessage([textPart("prt_direct_quote_failure", "Keep this quoted passage.")])],
onPrompt: () => {
if (reject) throw new Error("Quote send failed")
},
})

const answer = page.locator(
`[data-timeline-part-id="${renderedPartID("prt_direct_quote_failure")}"] [data-slot="text-part-body"]`,
)

await expect(answer).toHaveText("Keep this quoted passage.")
await selectQuoteText(answer)
await page
.getByRole("toolbar", { name: "Quote selection" })
.getByRole("button", { name: "Quote", exact: true })
.click()
const popup = page.locator('[data-component="composer-quote-comment"]')

const comment = popup.locator("textarea")

const modifier = await page.evaluate(() => (/(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control"))

await comment.fill("Keep my latest comment")
await comment.press(`${modifier}+Enter`)
await expect(popup.getByRole("alert")).toHaveText("Couldn’t send. Your comment is still here. Try again.")
await expect(comment).toHaveValue("Keep my latest comment")
await expect(comment).toBeEditable()
reject = false
await popup.getByRole("button").click({ modifiers: [modifier] })
await expect(popup).toHaveCount(0)
await expect(page.locator('[data-component="composer-attachments"] [data-component="attachment-card"]')).toHaveCount(
0,
)
})

test("highlights the exact duplicate passage whenever its quote comment is open", async ({ page }) => {
let reject = true
await setupTimeline(page, {
Expand Down Expand Up @@ -2230,6 +2377,7 @@ test("highlights the exact duplicate passage whenever its quote comment is open"
await comment.press("Enter")
await expect(comment).toHaveCount(0)
await expect.poll(highlights).toEqual([[]])
await expect(editor).toBeFocused()

await select(second, 23)
await toolbar.getByRole("button", { name: "Quote", exact: true }).click({ modifiers: ["Alt"] })
Expand Down
9 changes: 9 additions & 0 deletions packages/app/src/composer/editor/editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
onQuoteFocus={props.controller.focusQuotePrompt}
onEditQuote={props.controller.editQuote}
onQuoteComment={(quote, comment) => props.controller.commentQuote(quote.key, comment)}
quoteSubmit={view.submit.quote}
searchPaths={props.controller.searchPaths}
/>
</Show>
Expand Down Expand Up @@ -686,6 +687,7 @@ function ComposerAttachments(props: {
onQuoteFocus?: () => void
onEditQuote?: (key?: string) => void
onQuoteComment?: (quote: Extract<ComposerComment, { type: "quote" }>, comment: string) => void
quoteSubmit?: ComposerEditorModel["view"]["submit"]["quote"]
searchPaths?: (query: string) => Promise<string[]>
}) {
const i18n = useI18n()
Expand Down Expand Up @@ -882,6 +884,13 @@ function ComposerAttachments(props: {
text={current.text}
comment={current.comment ?? ""}
mention={(query) => props.searchPaths?.(query) ?? Promise.resolve([])}
send={
props.quoteSubmit && {
available: props.quoteSubmit.available(),
delivery: props.quoteSubmit.delivery(),
onSubmit: (comment) => props.quoteSubmit!.onSubmit(current.key, comment),
}
}
onSubmit={(comment) => {
props.onQuoteComment?.(current, comment)
props.onEditQuote?.()
Expand Down
7 changes: 6 additions & 1 deletion packages/app/src/composer/editor/interaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {
type ComposerInteractionEvent,
} from "../suggestions/machine"
import { clonePrompt, isAttachment, promptLength } from "../prompt-parts"
import type { ComposerQueue } from "../adapter"
import type { ComposerDelivery, ComposerQueue } from "../adapter"
import { isCommentItem } from "../state"
import type { ComposerQuoteAnchor } from "@opencode/plugin/gui"

Expand All @@ -49,6 +49,11 @@ export type ComposerEditorView = {
held?: Accessor<boolean>
working?: Accessor<boolean>
queue?: ComposerQueue
quote?: {
available: Accessor<boolean>
delivery: Accessor<ComposerDelivery | undefined>
onSubmit: (key: string, comment: string) => Promise<boolean>
}
onSubmit: (options?: { alternate?: boolean }) => void
onStop: () => void
}
Expand Down
56 changes: 54 additions & 2 deletions packages/app/src/composer/editor/quote-comment.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
import { createMemo, onCleanup } from "solid-js"
import { createMemo, onCleanup, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { Portal } from "solid-js/web"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { LineCommentEditor } from "@opencode/ui/line-comment"
import { useLanguage } from "@/runtime/i18n/language"
import type { ComposerQuoteAnchor } from "@opencode/plugin/gui"
import type { ComposerDelivery } from "../adapter"

const IS_MAC = /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)

const sendLabels = {
queue: "session.quote.queue",
steer: "session.quote.steer",
send: "session.quote.send",
} as const

const GAP = 8

Expand All @@ -19,6 +28,11 @@ export function QuoteCommentEditor(props: {
mention: (query: string) => Promise<string[]>
onSubmit: (comment: string) => void
onClose: (focus?: boolean) => void
send?: {
available: boolean
delivery: ComposerDelivery | undefined
onSubmit: (comment: string) => Promise<boolean>
}
}) {
const language = useLanguage()

Expand All @@ -30,6 +44,8 @@ export function QuoteCommentEditor(props: {
revision: 0,
height: 0,
openingHeight: 0,
sending: false,
failed: false,
})

const width = () => Math.min(WIDTH, state.viewport - GAP * 2)
Expand All @@ -47,6 +63,8 @@ export function QuoteCommentEditor(props: {

let frame: number | undefined

let mounted = true

const schedule = () => {
if (frame !== undefined) return
frame = requestAnimationFrame(() => {
Expand All @@ -61,6 +79,8 @@ export function QuoteCommentEditor(props: {
}

onCleanup(() => {
mounted = false

if (frame !== undefined) cancelAnimationFrame(frame)
})

Expand All @@ -72,7 +92,7 @@ export function QuoteCommentEditor(props: {

if (target instanceof Node && (element?.contains(target) || props.anchor.contains(target))) return

if (state.draft.trim() === props.comment.trim()) props.onClose()
if (!state.sending && state.draft.trim() === props.comment.trim()) props.onClose()
})

const left = () => Math.min(Math.max(source()?.left ?? state.rect.left, GAP), state.viewport - width() - GAP)
Expand All @@ -88,6 +108,28 @@ export function QuoteCommentEditor(props: {
const availableHeight = () => Math.max(0, bottom() - GAP)
const top = () => Math.max(GAP, bottom() - state.height)

const alternateSubmit = createMemo(() => {
const send = props.send

if (!send) return

return {
label: language.t(sendLabels[send.delivery ?? "send"]),
keys: [IS_MAC ? "⌘" : language.t("common.key.ctrl"), "⏎"],
disabled: !send.available,
onSubmit: async (comment: string) => {
setState({ sending: true, failed: false })

const sent = await send.onSubmit(comment)

if (!mounted) return
setState({ sending: false, failed: !sent })

if (sent) props.onClose()
},
}
})

return (
<Portal>
<div
Expand Down Expand Up @@ -125,6 +167,8 @@ export function QuoteCommentEditor(props: {
onInput={(value) => setState("draft", value)}
onCancel={() => props.onClose(true)}
onSubmit={props.onSubmit}
submitting={state.sending}
alternateSubmit={alternateSubmit()}
placeholder={language.t("session.quote.optionalCommentPlaceholder")}
submitLabel={language.t(
props.comment.trim()
Expand All @@ -137,6 +181,14 @@ export function QuoteCommentEditor(props: {
mention={{ items: props.mention }}
selection={<span dir="auto">{props.text}</span>}
/>
<Show when={state.failed}>
<p
role="alert"
class="rounded-b-[8px] bg-v2-background-bg-layer-01 px-3 pb-3 text-[12px] leading-[var(--line-height-compact)] text-v2-text-text-base"
>
{language.t("session.quote.sendFailed")}
</p>
</Show>
</div>
</div>
</Portal>
Expand Down
9 changes: 9 additions & 0 deletions packages/app/src/composer/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,15 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
held: submission.held,
working: adapter.working,
queue: options?.queue,
quote:
adapter.kind === "active-session"
? {
available: () =>
available() && !adapter.controls().model.loading && !submission.held() && !options?.queue?.undoing(),
delivery: () => (adapter.working() ? (options?.queue?.alternate() ?? "steer") : undefined),
onSubmit: submission.submitQuote,
}
: undefined,
onSubmit: (submitOptions) => {
if (!available()) return
const queue = options?.queue
Expand Down
Loading
Loading