0}>
diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx
index 1789cbfdc470..469169e1f0a6 100644
--- a/packages/ui/src/components/session-turn.tsx
+++ b/packages/ui/src/components/session-turn.tsx
@@ -92,6 +92,7 @@ function AssistantMessageItem(props: {
responsePartId: string | undefined
hideResponsePart: boolean
hideReasoning: boolean
+ anchorId?: string
}) {
const data = useData()
const emptyParts: PartType[] = []
@@ -121,7 +122,7 @@ function AssistantMessageItem(props: {
return parts.filter((part) => part?.id !== responsePartId)
})
- return
+ return
}
export function SessionTurn(
@@ -605,18 +606,19 @@ export function SessionTurn(
{/* Response */}
- 0}>
-
-
- {(assistantMessage) => (
-
- )}
-
+
0}>
+
+
+ {(assistantMessage) => (
+
+ )}
+
{error()?.data?.message as string}
From 283f5ab293a114a38d92f7af1ea8d5d868085cf8 Mon Sep 17 00:00:00 2001
From: Ariane Emory
Date: Mon, 26 Jan 2026 03:35:52 -0500
Subject: [PATCH 04/13] Add n and p keybinds for user message navigation in
timeline
- Added useKeyboard hook to intercept n and p keys in DialogTimeline
- Implemented navigation that skips assistant messages and only navigates user messages
- Pressing n moves to next user message in timeline
- Pressing p moves to previous user message in timeline
- Navigation respects message boundaries and works correctly with filters
- Tracks selected message ID to maintain state between navigation methods
---
.../tui/routes/session/dialog-timeline.tsx | 51 +++++++++++++++++--
1 file changed, 48 insertions(+), 3 deletions(-)
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx
index 4b608f2f83a4..f08082d71d2c 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx
@@ -1,4 +1,4 @@
-import { createMemo, onMount } from "solid-js"
+import { createMemo, onMount, createSignal } from "solid-js"
import { useSync } from "@tui/context/sync"
import { DialogSelect, type DialogSelectOption, type DialogSelectRef } from "@tui/ui/dialog-select"
import type { Part, Message, AssistantMessage, ToolPart, FilePart } from "@opencode-ai/sdk/v2"
@@ -15,6 +15,7 @@ import path from "path"
import { produce } from "solid-js/store"
import { Binary } from "@opencode-ai/util/binary"
import { Global } from "@/global"
+import { useKeyboard } from "@opentui/solid"
// Module-level variable to store the selected message when opening details
let timelineSelection: string | undefined
@@ -118,6 +119,7 @@ export function DialogTimeline(props: {
timelineSelection = undefined
let selectRef: DialogSelectRef | undefined
+ const [selectedMessageID, setSelectedMessageID] = createSignal(undefined)
onMount(() => {
dialog.setSize("large")
@@ -130,6 +132,46 @@ export function DialogTimeline(props: {
}
})
+ useKeyboard((evt) => {
+ // Only handle 'n' and 'p' without any modifiers
+ if (evt.ctrl || evt.meta || evt.shift) return
+
+ const opts = options()
+ if (opts.length === 0) return
+
+ const currentIndex = opts.findIndex(opt => opt.value === selectedMessageID())
+
+ if (evt.name === "n") {
+ evt.preventDefault()
+ evt.stopPropagation()
+ // Find next user message
+ for (let i = currentIndex + 1; i < opts.length; i++) {
+ const msgID = opts[i].value
+ const msg = sync.message[props.sessionID]?.find(m => m.id === msgID)
+ if (msg && msg.role === "user") {
+ setSelectedMessageID(msgID)
+ props.onMove(msgID)
+ break
+ }
+ }
+ }
+
+ if (evt.name === "p") {
+ evt.preventDefault()
+ evt.stopPropagation()
+ // Find previous user message
+ for (let i = currentIndex - 1; i >= 0; i--) {
+ const msgID = opts[i].value
+ const msg = sync.message[props.sessionID]?.find(m => m.id === msgID)
+ if (msg && msg.role === "user") {
+ setSelectedMessageID(msgID)
+ props.onMove(msgID)
+ break
+ }
+ }
+ }
+ })
+
const options = createMemo((): DialogSelectOption[] => {
const messages = sync.message[props.sessionID] ?? []
const result = [] as DialogSelectOption[]
@@ -271,7 +313,10 @@ export function DialogTimeline(props: {
ref={(r) => {
selectRef = r
}}
- onMove={(option) => props.onMove(option.value)}
+ onMove={(option) => {
+ setSelectedMessageID(option.value)
+ props.onMove(option.value)
+ }}
title="Timeline"
options={options()}
keybind={[
@@ -289,7 +334,7 @@ export function DialogTimeline(props: {
const messageID = option.value
const message = sync.message[props.sessionID]?.find((m) => m.id === messageID)
const parts = sync.part[messageID] ?? []
-
+
if (message && message.role === "assistant") {
// Store the current selection before opening details
timelineSelection = messageID
From 8523d9cf791e770986f3264ddcc401c023436cd9 Mon Sep 17 00:00:00 2001
From: Ariane Emory
Date: Mon, 26 Jan 2026 04:20:25 -0500
Subject: [PATCH 05/13] Add n and p keybinds for user message navigation in
timeline
Fixed navigation to properly move selection highlight in timeline dialog
by using selectRef.moveToValue() instead of calling onMove directly.
Keys now appear in help text and skip assistant messages.
---
.../tui/routes/session/dialog-timeline.tsx | 81 ++++++++-----------
1 file changed, 33 insertions(+), 48 deletions(-)
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx
index f08082d71d2c..98cbb7280ecb 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx
@@ -1,4 +1,4 @@
-import { createMemo, onMount, createSignal } from "solid-js"
+import { createMemo, onMount } from "solid-js"
import { useSync } from "@tui/context/sync"
import { DialogSelect, type DialogSelectOption, type DialogSelectRef } from "@tui/ui/dialog-select"
import type { Part, Message, AssistantMessage, ToolPart, FilePart } from "@opencode-ai/sdk/v2"
@@ -15,7 +15,6 @@ import path from "path"
import { produce } from "solid-js/store"
import { Binary } from "@opencode-ai/util/binary"
import { Global } from "@/global"
-import { useKeyboard } from "@opentui/solid"
// Module-level variable to store the selected message when opening details
let timelineSelection: string | undefined
@@ -119,7 +118,6 @@ export function DialogTimeline(props: {
timelineSelection = undefined
let selectRef: DialogSelectRef | undefined
- const [selectedMessageID, setSelectedMessageID] = createSignal(undefined)
onMount(() => {
dialog.setSize("large")
@@ -132,46 +130,6 @@ export function DialogTimeline(props: {
}
})
- useKeyboard((evt) => {
- // Only handle 'n' and 'p' without any modifiers
- if (evt.ctrl || evt.meta || evt.shift) return
-
- const opts = options()
- if (opts.length === 0) return
-
- const currentIndex = opts.findIndex(opt => opt.value === selectedMessageID())
-
- if (evt.name === "n") {
- evt.preventDefault()
- evt.stopPropagation()
- // Find next user message
- for (let i = currentIndex + 1; i < opts.length; i++) {
- const msgID = opts[i].value
- const msg = sync.message[props.sessionID]?.find(m => m.id === msgID)
- if (msg && msg.role === "user") {
- setSelectedMessageID(msgID)
- props.onMove(msgID)
- break
- }
- }
- }
-
- if (evt.name === "p") {
- evt.preventDefault()
- evt.stopPropagation()
- // Find previous user message
- for (let i = currentIndex - 1; i >= 0; i--) {
- const msgID = opts[i].value
- const msg = sync.message[props.sessionID]?.find(m => m.id === msgID)
- if (msg && msg.role === "user") {
- setSelectedMessageID(msgID)
- props.onMove(msgID)
- break
- }
- }
- }
- })
-
const options = createMemo((): DialogSelectOption[] => {
const messages = sync.message[props.sessionID] ?? []
const result = [] as DialogSelectOption[]
@@ -313,13 +271,40 @@ export function DialogTimeline(props: {
ref={(r) => {
selectRef = r
}}
- onMove={(option) => {
- setSelectedMessageID(option.value)
- props.onMove(option.value)
- }}
+ onMove={(option) => props.onMove(option.value)}
title="Timeline"
options={options()}
keybind={[
+ {
+ keybind: { name: "n", ctrl: false, meta: false, shift: false, leader: false },
+ title: "Next user",
+ onTrigger: (option) => {
+ const currentIdx = options().findIndex(opt => opt.value === option.value)
+ for (let i = currentIdx + 1; i < options().length; i++) {
+ const msgID = options()[i].value
+ const msg = sync.message[props.sessionID]?.find(m => m.id === msgID)
+ if (msg && msg.role === "user") {
+ selectRef?.moveToValue(msgID)
+ break
+ }
+ }
+ },
+ },
+ {
+ keybind: { name: "p", ctrl: false, meta: false, shift: false, leader: false },
+ title: "Previous user",
+ onTrigger: (option) => {
+ const currentIdx = options().findIndex(opt => opt.value === option.value)
+ for (let i = currentIdx - 1; i >= 0; i--) {
+ const msgID = options()[i].value
+ const msg = sync.message[props.sessionID]?.find(m => m.id === msgID)
+ if (msg && msg.role === "user") {
+ selectRef?.moveToValue(msgID)
+ break
+ }
+ }
+ },
+ },
{
keybind: { name: "delete", ctrl: false, meta: false, shift: false, leader: false },
title: "Delete",
@@ -334,7 +319,7 @@ export function DialogTimeline(props: {
const messageID = option.value
const message = sync.message[props.sessionID]?.find((m) => m.id === messageID)
const parts = sync.part[messageID] ?? []
-
+
if (message && message.role === "assistant") {
// Store the current selection before opening details
timelineSelection = messageID
From 760aee0b818b8dda44b4132ab793e97be4c7fb05 Mon Sep 17 00:00:00 2001
From: Ariane Emory
Date: Mon, 26 Jan 2026 04:34:10 -0500
Subject: [PATCH 06/13] tidy: whitespace
---
.../opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx
index 98cbb7280ecb..07d4d80a17bf 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx
@@ -319,7 +319,7 @@ export function DialogTimeline(props: {
const messageID = option.value
const message = sync.message[props.sessionID]?.find((m) => m.id === messageID)
const parts = sync.part[messageID] ?? []
-
+
if (message && message.role === "assistant") {
// Store the current selection before opening details
timelineSelection = messageID
From 56b60f988bb7d90b8ea65f6fd955c108a39d2bb2 Mon Sep 17 00:00:00 2001
From: Ariane Emory
Date: Mon, 26 Jan 2026 13:04:06 -0500
Subject: [PATCH 07/13] Fix missing createSignal import
---
packages/app/src/pages/session.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index 3a81a6e16991..12af2f7d0a43 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -1,4 +1,4 @@
-import { For, onCleanup, onMount, Show, Match, Switch, createMemo, createEffect, on } from "solid-js"
+import { For, onCleanup, onMount, Show, Match, Switch, createMemo, createEffect, createSignal, on } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Dynamic } from "solid-js/web"
From 28e9fa708b53fba1fa0ff31d8a80bc03b3b5f1de Mon Sep 17 00:00:00 2001
From: Ariane Emory
Date: Wed, 28 Jan 2026 23:25:48 -0500
Subject: [PATCH 08/13] Fix TypeScript errors introduced by merge
Fixed applyHash function logic to properly handle undefined message cases
and prevent TypeScript errors about potentially undefined values.
---
packages/app/src/pages/session.tsx | 11 +++++++++--
1 file changed, 9 insertions(+), 2 deletions(-)
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index 666f28a54b9d..bc88544ab83b 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -1581,12 +1581,19 @@ export default function Page() {
return
}
- if (msg.role === "assistant") {
+ // Check if message exists in all messages (including hidden ones)
+ const allMsg = messages().find((m) => m.id === match[1])
+ if (!allMsg) {
+ if (visibleUserMessages().find((m) => m.id === match[1])) return
+ return
+ }
+
+ if (allMsg.role === "assistant") {
setPendingAssistantMessage(match[1])
return
}
- scrollToMessage(msg as UserMessage, behavior)
+ scrollToMessage(allMsg as UserMessage, behavior)
return
}
From 41a1b5ae21db80c302abef0a227d2279781fdd12 Mon Sep 17 00:00:00 2001
From: Ariane Emory
Date: Fri, 30 Jan 2026 21:06:57 -0500
Subject: [PATCH 09/13] Change n/p keybinds to use Alt modifier
Changed 'n' and 'p' shortcuts to use Alt+N and Alt+P instead of plain keys.
This allows users to type 'n' and 'p' in the filter box while still having
convenient navigation shortcuts.
---
.../src/cli/cmd/tui/routes/session/dialog-timeline.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx
index 07d4d80a17bf..f3cfcf60b580 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx
@@ -276,7 +276,7 @@ export function DialogTimeline(props: {
options={options()}
keybind={[
{
- keybind: { name: "n", ctrl: false, meta: false, shift: false, leader: false },
+ keybind: { name: "n", ctrl: false, meta: true, shift: false, leader: false },
title: "Next user",
onTrigger: (option) => {
const currentIdx = options().findIndex(opt => opt.value === option.value)
@@ -291,7 +291,7 @@ export function DialogTimeline(props: {
},
},
{
- keybind: { name: "p", ctrl: false, meta: false, shift: false, leader: false },
+ keybind: { name: "p", ctrl: false, meta: true, shift: false, leader: false },
title: "Previous user",
onTrigger: (option) => {
const currentIdx = options().findIndex(opt => opt.value === option.value)
From 77c6cb445ecc2d62006c6a82d2c0bd98c539c0d1 Mon Sep 17 00:00:00 2001
From: Ariane Emory
Date: Sat, 31 Jan 2026 02:09:16 -0500
Subject: [PATCH 10/13] Filter out '[no content]' messages from timeline
display and selection
Messages without text, tool, or file parts are now omitted from the
session_timeline to avoid cluttering the interface.
---
.../src/cli/cmd/tui/routes/session/dialog-timeline.tsx | 3 +++
1 file changed, 3 insertions(+)
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx
index f3cfcf60b580..b2c19656f8ed 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx
@@ -165,6 +165,9 @@ export function DialogTimeline(props: {
const summary = getMessageSummary(parts)
+ // Skip messages with no content
+ if (summary === "[no content]") continue
+
// Debug: Extract token breakdown for assistant messages
let tokenDebug = ""
if (message.role === "assistant") {
From fae177bbf406afb3652c14575d4702cf2c9cf702 Mon Sep 17 00:00:00 2001
From: Ariane Emory
Date: Sat, 14 Feb 2026 22:40:40 -0500
Subject: [PATCH 11/13] Fix tests: await async ProviderTransform.message calls
---
packages/opencode/test/provider/transform.test.ts | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts
index 30ec0349e24e..0735c8cfe6b1 100644
--- a/packages/opencode/test/provider/transform.test.ts
+++ b/packages/opencode/test/provider/transform.test.ts
@@ -1458,7 +1458,7 @@ describe("ProviderTransform.message - cache control on gateway", () => {
...overrides,
}) as any
- test("gateway does not set cache control for anthropic models", () => {
+ test("gateway does not set cache control for anthropic models", async () => {
const model = createModel()
const msgs = [
{
@@ -1471,13 +1471,13 @@ describe("ProviderTransform.message - cache control on gateway", () => {
},
] as any[]
- const result = ProviderTransform.message(msgs, model, {}) as any[]
+ const result = (await ProviderTransform.message(msgs, model, {})) as any[]
expect(result[0].content[0].providerOptions).toBeUndefined()
expect(result[0].providerOptions).toBeUndefined()
})
- test("non-gateway anthropic keeps existing cache control behavior", () => {
+ test("non-gateway anthropic keeps existing cache control behavior", async () => {
const model = createModel({
providerID: "anthropic",
api: {
@@ -1497,7 +1497,7 @@ describe("ProviderTransform.message - cache control on gateway", () => {
},
] as any[]
- const result = ProviderTransform.message(msgs, model, {}) as any[]
+ const result = (await ProviderTransform.message(msgs, model, {})) as any[]
expect(result[0].providerOptions).toEqual({
anthropic: {
From 738469e429f6e1fa1d76f54e9535c55586fce6d3 Mon Sep 17 00:00:00 2001
From: Ariane Emory
Date: Sat, 14 Feb 2026 22:45:37 -0500
Subject: [PATCH 12/13] Fix cacheInvalidated handling: read/write session JSON
file directly
---
packages/opencode/src/provider/transform.ts | 27 ++++++++++++++++-----
1 file changed, 21 insertions(+), 6 deletions(-)
diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts
index cc82f22272e5..de98bc564087 100644
--- a/packages/opencode/src/provider/transform.ts
+++ b/packages/opencode/src/provider/transform.ts
@@ -2,6 +2,7 @@ import type { ModelMessage } from "ai"
import { mergeDeep, unique } from "remeda"
import type { JSONSchema7 } from "@ai-sdk/provider"
import type { JSONSchema } from "zod/v4/core"
+import path from "path"
import type { Provider } from "./provider"
import type { ModelsDev } from "./models"
import { iife } from "@/util/iife"
@@ -203,14 +204,28 @@ export namespace ProviderTransform {
async function applyCaching(msgs: ModelMessage[], model: Provider.Model, sessionID?: string): Promise {
// Skip caching if session cache was invalidated (e.g., message deletion)
if (sessionID) {
+ const { Global } = await import("@/global")
const { Session } = await import("../session")
const session = await Session.get(sessionID).catch(() => null)
- if (session?.cacheInvalidated) {
- // Clear flag and return without cache control markers
- await Session.update(sessionID, (draft) => {
- delete draft.cacheInvalidated
- }).catch(() => {})
- return msgs
+ if (session) {
+ const sessionPath = path.join(
+ Global.Path.data,
+ "storage",
+ "session",
+ `project_${session.projectID}`,
+ `${sessionID}.json`
+ )
+ try {
+ const sessionData = await Bun.file(sessionPath).json()
+ if (sessionData.cacheInvalidated) {
+ // Clear flag and return without cache control markers
+ delete sessionData.cacheInvalidated
+ await Bun.write(sessionPath, JSON.stringify(sessionData, null, 2))
+ return msgs
+ }
+ } catch {
+ // File doesn't exist or can't be read, continue with caching
+ }
}
}
From d845fd8ca0760095a63169897df093a0f4f276f4 Mon Sep 17 00:00:00 2001
From: Ariane Emory
Date: Sun, 15 Feb 2026 10:23:12 -0500
Subject: [PATCH 13/13] fix: await async ProviderTransform.message in tests
---
packages/opencode/test/provider/transform.test.ts | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts
index 30ec0349e24e..0735c8cfe6b1 100644
--- a/packages/opencode/test/provider/transform.test.ts
+++ b/packages/opencode/test/provider/transform.test.ts
@@ -1458,7 +1458,7 @@ describe("ProviderTransform.message - cache control on gateway", () => {
...overrides,
}) as any
- test("gateway does not set cache control for anthropic models", () => {
+ test("gateway does not set cache control for anthropic models", async () => {
const model = createModel()
const msgs = [
{
@@ -1471,13 +1471,13 @@ describe("ProviderTransform.message - cache control on gateway", () => {
},
] as any[]
- const result = ProviderTransform.message(msgs, model, {}) as any[]
+ const result = (await ProviderTransform.message(msgs, model, {})) as any[]
expect(result[0].content[0].providerOptions).toBeUndefined()
expect(result[0].providerOptions).toBeUndefined()
})
- test("non-gateway anthropic keeps existing cache control behavior", () => {
+ test("non-gateway anthropic keeps existing cache control behavior", async () => {
const model = createModel({
providerID: "anthropic",
api: {
@@ -1497,7 +1497,7 @@ describe("ProviderTransform.message - cache control on gateway", () => {
},
] as any[]
- const result = ProviderTransform.message(msgs, model, {}) as any[]
+ const result = (await ProviderTransform.message(msgs, model, {})) as any[]
expect(result[0].providerOptions).toEqual({
anthropic: {