Skip to content

Commit 409ccf8

Browse files
committed
feat(app): jump to an earlier prompt by searching session messages
1 parent 5418e01 commit 409ccf8

10 files changed

Lines changed: 531 additions & 6 deletions

File tree

‎packages/app/e2e/regression/timeline-history.spec.ts‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
assistantMessage,
88
historyMessages,
99
messageUpdated,
10+
partDelta,
1011
partUpdated,
1112
session,
1213
sessionID,
@@ -23,6 +24,65 @@ import { openSession } from "../utils/workspace"
2324
test.use({ serviceWorkers: "block" })
2425

2526
test.describe("timeline history", () => {
27+
test("jump to message searches offscreen history without moving the reader until selection", async ({ page }) => {
28+
const target = userMessage([textPart("prt_dispatch_note", "Use the loading dock for delivery")], {
29+
id: "msg_00000_dispatch",
30+
created: 1680000000000,
31+
})
32+
33+
const messages = [
34+
target,
35+
...historyMessages(45),
36+
userMessage(),
37+
assistantMessage([textPart("prt_active", "Checking the route")], { completed: false }),
38+
]
39+
40+
const timeline = await setupTimeline(page, {
41+
sessionMessages: messages,
42+
pageMessages: (_, limit, before) => {
43+
const end = before ? messages.findIndex((message) => message.id === before) : messages.length
44+
const start = Math.max(0, end - Math.min(16, limit))
45+
46+
return { items: messages.slice(start, end), cursor: start ? messages[start]!.id : undefined }
47+
},
48+
})
49+
50+
const scroller = timelineScroller(page)
51+
52+
const old = scroller.getByText(target.text, { exact: true })
53+
54+
await expect(scroller.getByText("Checking the route", { exact: true })).toBeInViewport()
55+
await expect(old).toHaveCount(0)
56+
const top = await scroller.evaluate((element) => element.scrollTop)
57+
58+
const mod = await page.evaluate(() => (/Mac/.test(navigator.platform) ? "Meta" : "Control"))
59+
60+
const search = page.getByRole("combobox", { name: "Jump to message" })
61+
62+
await page.keyboard.press(`${mod}+Alt+g`)
63+
await expect(search).toBeFocused()
64+
await search.fill("loading dock")
65+
await expect(page.getByRole("option", { name: /Use the loading dock/ })).toBeVisible()
66+
await search.press("Escape")
67+
await expect(search).toBeHidden()
68+
expect(await scroller.evaluate((element) => element.scrollTop)).toBe(top)
69+
await expect(old).toHaveCount(0)
70+
71+
await page.keyboard.press(`${mod}+Shift+p`)
72+
await page.getByRole("combobox").fill("sent prompts")
73+
await page.getByRole("option", { name: /Jump to message/ }).click()
74+
await expect(search).toBeFocused()
75+
await search.fill("loading dock")
76+
await expect(page.getByRole("option", { name: /Use the loading dock/ })).toBeVisible()
77+
await search.press("Enter")
78+
await expect(old).toBeInViewport()
79+
await timeline.send(partDelta("prt_active", "\n\nThe route is ready."))
80+
await expect(old).toBeInViewport()
81+
await expect(page).toHaveURL(new RegExp(`#message-${target.id}$`))
82+
await page.getByRole("button", { name: "Jump to latest" }).click()
83+
await expect(scroller.getByText("The route is ready.", { exact: true })).toBeInViewport()
84+
})
85+
2686
test("restoring a paged transcript waits for another upward scroll before loading history", async ({ page }) => {
2787
const id = "ses_history_restore"
2888
const other = "ses_history_other"

‎packages/app/src/runtime/i18n/en.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,14 @@ export const dict = {
406406
"mcp.timedOut": "{{name}} took too long to respond",
407407

408408
"dialog.fork.empty": "No messages to fork from",
409+
"dialog.jump.title": "Jump to message",
410+
"dialog.jump.description": "Choose a message to jump to",
411+
"command.session.jumpToMessage.description": "Search sent prompts in this session",
412+
"dialog.jump.loading": "Loading older prompts…",
413+
"dialog.jump.opening": "Opening message…",
414+
"dialog.jump.failed": "Couldn’t load the full history. Reopen to try again.",
415+
"dialog.jump.empty": "No sent prompts yet",
416+
"dialog.jump.noMatches": "No matching prompts",
409417

410418
"dialog.directory.action.selectFile": "Select file",
411419
"dialog.directory.action.selectFolder": "Select folder",
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import type { SessionMessageUser } from "@opencode/client/promise"
2+
import { useDialog } from "@opencode/ui/context/dialog"
3+
import { createEffect, createMemo, createResource, createSignal, onCleanup } from "solid-js"
4+
import { readPromptPresentation } from "@/composer/comment-note"
5+
import { useLanguage } from "@/runtime/i18n/language"
6+
import { useServerSDK } from "@/runtime/server/client"
7+
import { useData } from "@/runtime/server/current"
8+
import type { SessionCommandContext } from "./use-session-commands"
9+
import { parseVoiceDelegation } from "../timeline/voice-delegation"
10+
import { JumpToMessage, type JumpPrompt } from "./jump-to-message"
11+
12+
function prompt(message: SessionMessageUser, attachment: string): JumpPrompt {
13+
const presentation = readPromptPresentation(message.metadata)
14+
15+
const text = presentation?.displayText ?? message.text
16+
17+
const voice = parseVoiceDelegation(text)
18+
19+
const labels = [
20+
voice?.framing ?? voice?.instruction ?? text,
21+
...(presentation?.attachments ?? []).map((file) => file.name),
22+
...(message.files ?? []).filter((file) => !file.mention).map((file) => file.name || attachment),
23+
...(message.agents ?? []).filter((agent) => !agent.mention).map((agent) => `@${agent.name}`),
24+
...(message.skills ?? []).filter((skill) => !skill.mention).map((skill) => skill.name),
25+
...(presentation?.comments ?? []).map((comment) => {
26+
if (comment.comment) return comment.comment
27+
28+
if (comment.type === "quote") return comment.text
29+
30+
if (comment.type === "note") return comment.subject
31+
32+
return comment.path
33+
}),
34+
]
35+
36+
return { id: message.id, text: labels.filter(Boolean).join("\n"), created: message.time.created }
37+
}
38+
39+
export function DialogJumpToMessage(props: {
40+
session: SessionCommandContext["session"]
41+
onJump: (id: string) => void
42+
}) {
43+
const dialog = useDialog()
44+
const language = useLanguage()
45+
const data = useData()
46+
const sdk = useServerSDK()
47+
const key = props.session.identity.sessionKey()
48+
const id = props.session.identity.params.id
49+
50+
if (!id) return void dialog.close()
51+
const controller = new AbortController()
52+
onCleanup(() => controller.abort())
53+
createEffect(() => {
54+
if (key !== props.session.identity.sessionKey()) dialog.close()
55+
56+
if (dialog.closing) controller.abort()
57+
})
58+
59+
// Read older prompts into the picker only. Prepending pages to the timeline while browsing would move the reader.
60+
const [history] = createResource(async () => {
61+
const rows: JumpPrompt[] = []
62+
63+
// Join the timeline's initial sync before interpreting a missing cursor as the end of history.
64+
await data.session.message.sync(id)
65+
controller.signal.throwIfAborted()
66+
67+
if (!data.session.message.more(id)) return rows
68+
let cursor: string | undefined
69+
70+
do {
71+
const page = await sdk.api.message.list(
72+
{ sessionID: id, type: "user", limit: 200, cursor },
73+
{ signal: AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)]) },
74+
)
75+
76+
controller.signal.throwIfAborted()
77+
78+
for (const message of page.data) {
79+
if (message.type === "user") rows.push(prompt(message, language.t("common.attachment")))
80+
}
81+
82+
cursor = page.cursor.next ?? undefined
83+
} while (cursor)
84+
85+
return rows
86+
})
87+
88+
const prompts = createMemo(() => {
89+
const all = new Map((history.state === "ready" ? history() : []).map((row) => [row.id, row]))
90+
91+
const reverted = data.session.get(id)?.revert?.messageID
92+
93+
for (const message of props.session.history.visibleUserMessages()) {
94+
all.set(message.id, prompt(message, language.t("common.attachment")))
95+
}
96+
97+
const pending = new Set(data.session.input.list(id))
98+
99+
return [...all.values()].filter(
100+
(row) => row.text.trim() && !pending.has(row.id) && (!reverted || row.id < reverted),
101+
)
102+
})
103+
104+
const [selection, setSelection] = createSignal<string>()
105+
106+
// The store publishes backfill in one batch. Only then ask the virtualizer to land on the selected bubble.
107+
const [loaded] = createResource(selection, async (messageID) => {
108+
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(60_000)])
109+
110+
await data.session.message.loadMore(id, { all: true, signal })
111+
signal.throwIfAborted()
112+
113+
if (
114+
!props.session.history.visibleUserMessages().some((message) => message.id === messageID) ||
115+
data.session.input.has(id, messageID)
116+
)
117+
throw new Error("Message unavailable")
118+
119+
return messageID
120+
})
121+
122+
const jump = (messageID: string) => {
123+
if (dialog.closing || key !== props.session.identity.sessionKey()) return
124+
dialog.close()
125+
props.onJump(messageID)
126+
}
127+
128+
createEffect(() => {
129+
const messageID = loaded.state === "ready" && loaded()
130+
131+
if (messageID) jump(messageID)
132+
})
133+
134+
const status = () => {
135+
if (loaded.loading) return language.t("dialog.jump.opening")
136+
137+
if (loaded.error || history.error) return language.t("dialog.jump.failed")
138+
139+
if (history.loading) return language.t("dialog.jump.loading")
140+
}
141+
142+
return (
143+
<JumpToMessage
144+
prompts={prompts()}
145+
opening={loaded.loading}
146+
status={status()}
147+
onClose={() => dialog.close()}
148+
onJump={(row) => {
149+
if (loaded.loading || dialog.closing || key !== props.session.identity.sessionKey()) return
150+
151+
if (data.session.message.get(id, row.id)) return jump(row.id)
152+
setSelection(row.id)
153+
}}
154+
/>
155+
)
156+
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { Button } from "@opencode/ui/button"
2+
import { useDialog } from "@opencode/ui/context/dialog"
3+
import { Keybind } from "@opencode/ui/keybind"
4+
import { For, onMount, Show } from "solid-js"
5+
import { useLanguage } from "@/runtime/i18n/language"
6+
import { formatKeybindParts, matchKeybind, parseKeybind } from "@/shell/commands/command"
7+
import { JumpToMessage } from "./jump-to-message"
8+
9+
const shortcut = "mod+alt+g"
10+
11+
const turns = [
12+
[
13+
"Our delivery CSV has 48 stops. Why does the import preview show 49?",
14+
"The extra stop appears after order 731. Could you paste that record straight from the CSV? A spreadsheet hides some of the formatting.",
15+
],
16+
[
17+
'Here’s the header and the record as they appear in the file:\n\norder_id,driver,note\n731,Sam,"Use the loading dock\nRing twice"\n\nThe preview puts “Ring twice” in the order ID column.',
18+
"That newline is inside the quoted note. The preview splits the file into lines before it parses the fields, so it treats “Ring twice” as the start of another stop.",
19+
],
20+
[
21+
"Did we save a bogus stop too?",
22+
"I checked the import receipt: 48 stops. The upload worker parses the whole file and keeps both lines in the note for 731.",
23+
],
24+
[
25+
"Can the preview use that parser?",
26+
"Yes. I switched the preview to the worker’s CSV reader. Your file now shows 48 stops before upload.",
27+
],
28+
[
29+
"Another note says “Gate 2, ask for ليلى”. Does that comma break it?",
30+
"I tried it as a quoted field. The whole note stayed in one cell, and Sam was still in the driver column.",
31+
],
32+
[
33+
"Try it with Windows line endings too.",
34+
"With CRLF between records and inside the quoted note, I still get one stop for 731. Both lines of its note are there.",
35+
],
36+
[
37+
"Which line do we report if the next order is missing its ID?",
38+
"Line 4 in your sample: the header is line 1 and order 731 spans lines 2 and 3. The error uses the next record’s starting line.",
39+
],
40+
[
41+
"Keep that case in the tests.",
42+
"Added a fixture with the two-line note followed by a missing ID. The test checks the note on 731 and the error on line 4. It passes with both LF and CRLF.",
43+
],
44+
].map(([text, reply], index) => ({
45+
id: `prompt-${index}`,
46+
created: new Date(2026, 9, 28, 10, index * 4).getTime(),
47+
text,
48+
reply,
49+
}))
50+
51+
function SessionStory(props: { query?: string; empty?: boolean }) {
52+
const language = useLanguage()
53+
54+
const dialog = useDialog()
55+
56+
const prompts = () => (props.empty ? [] : turns)
57+
58+
const bubbles = new Map<string, HTMLElement>()
59+
60+
let session!: HTMLElement
61+
62+
let transcript!: HTMLDivElement
63+
64+
const open = (query = "") =>
65+
dialog.show(() => (
66+
<JumpToMessage
67+
prompts={prompts()}
68+
initialQuery={query}
69+
onClose={() => dialog.close()}
70+
onJump={(prompt) => {
71+
dialog.close()
72+
const bubble = bubbles.get(prompt.id)
73+
bubble?.focus({ preventScroll: true })
74+
bubble?.scrollIntoView({ block: "center", inline: "nearest" })
75+
}}
76+
/>
77+
))
78+
79+
onMount(() => {
80+
transcript.scrollTop = transcript.scrollHeight
81+
session.focus({ preventScroll: true })
82+
83+
void open(props.query)
84+
})
85+
86+
return (
87+
<section
88+
ref={session}
89+
tabIndex={-1}
90+
class="mx-auto flex h-dvh max-h-full w-full max-w-3xl flex-col text-13-regular outline-none"
91+
aria-label="Fixture session"
92+
onKeyDown={(event) => {
93+
if (!matchKeybind(parseKeybind(shortcut), event)) return
94+
event.preventDefault()
95+
event.stopPropagation()
96+
void open()
97+
}}
98+
>
99+
<header class="flex flex-wrap items-center gap-2 border-b border-v2-border-border-muted p-4">
100+
<h2 class="me-auto font-medium">Extra stop in CSV import preview</h2>
101+
<Button size="small" variant="neutral" onClick={() => open()}>
102+
Jump to message <Keybind keys={formatKeybindParts(shortcut, language.t)} variant="neutral" />
103+
</Button>
104+
</header>
105+
<div ref={transcript} data-scrollable class="min-h-0 flex-1 overflow-y-auto px-5 py-6" aria-label="Conversation">
106+
<For each={prompts()}>
107+
{(turn) => (
108+
<div class="mb-8 flex flex-col items-start gap-4">
109+
<p
110+
ref={(el) => bubbles.set(turn.id, el)}
111+
tabIndex={-1}
112+
class="ms-8 self-end whitespace-pre-wrap rounded-xl bg-v2-background-bg-layer-02 px-4 py-3 focus:outline-v2-border-border-base"
113+
dir="auto"
114+
>
115+
{turn.text}
116+
</p>
117+
<p dir="auto">{turn.reply}</p>
118+
</div>
119+
)}
120+
</For>
121+
<Show when={!prompts().length}>
122+
<p class="text-v2-text-text-muted">Your conversation will appear here.</p>
123+
</Show>
124+
</div>
125+
<footer class="border-t border-v2-border-border-muted px-4 py-3 text-v2-text-text-muted">
126+
<span class="font-medium">Draft</span>
127+
<p>Can you put that two-line example in the import guide?</p>
128+
</footer>
129+
</section>
130+
)
131+
}
132+
133+
export default {
134+
title: "Session/Jump to message",
135+
id: "session-jump-to-message",
136+
parameters: { layout: "fullscreen" },
137+
}
138+
139+
export const Browse = { render: () => <SessionStory /> }
140+
141+
export const MultilineMatch = { render: () => <SessionStory query="loading dock" /> }
142+
143+
export const Empty = { render: () => <SessionStory empty /> }
144+
145+
export const NoMatches = { render: () => <SessionStory query="missing bookmark" /> }

0 commit comments

Comments
 (0)