Compare commits

...

10 Commits

Author SHA1 Message Date
Jack 4abddbbfdd feat(go): promote DeepSeek V4 Flash usage (#40988) 2026-08-07 13:24:03 +08:00
opencode-agent[bot] 5aa5cb3523 fix(app): use chronological message boundaries (#41006)
Co-authored-by: Dax <mail@thdxr.com>
2026-08-07 05:23:28 +00:00
opencode-agent[bot] 9113255114 fix(app): order stored messages by creation time (#41001)
Co-authored-by: Dax <mail@thdxr.com>
2026-08-07 05:06:28 +00:00
opencode-agent[bot] 23cc677108 fix(tui): order messages by creation time (#40994)
Co-authored-by: Dax <mail@thdxr.com>
2026-08-07 04:41:15 +00:00
opencode-agent[bot] 28bcc0e4f4 fix(app): sort sessions by persisted time (#41000)
Co-authored-by: Dax <mail@thdxr.com>
2026-08-07 04:23:04 +00:00
Dmitry Nefedov 8bf5062b89 feat(tui): add cursor style configuration (#32295)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-08-06 23:20:51 -05:00
opencode-agent[bot] 20750c332e fix(web): order shared messages by creation time (#40995)
Co-authored-by: Dax <mail@thdxr.com>
2026-08-07 04:11:28 +00:00
opencode-agent[bot] db581e47a3 fix(opencode): order legacy message loop by time (#40990)
Co-authored-by: Dax <mail@thdxr.com>
2026-08-07 04:08:23 +00:00
opencode-agent[bot] a54a693af2 fix(opencode): use chronological message boundaries (#40991)
Co-authored-by: Dax <mail@thdxr.com>
2026-08-07 04:08:02 +00:00
opencode-agent[bot] d468201952 fix(opencode): use file times for truncation cleanup (#40987)
Co-authored-by: Dax <mail@thdxr.com>
2026-08-07 03:58:31 +00:00
64 changed files with 587 additions and 191 deletions
@@ -123,7 +123,8 @@ export function SessionContextTab() {
() => {
const revert = info()?.revert?.messageID
if (!revert) return userMessages()
return userMessages().filter((m) => m.id < revert)
const boundary = userMessages().findIndex((message) => message.id === revert)
return boundary < 0 ? userMessages() : userMessages().slice(0, boundary)
},
emptyUserMessages,
{ equals: same },
@@ -15,12 +15,12 @@ const rootSession = (input: { id: string; parentID?: string; archived?: number }
},
}) as Session
const userMessage = (id: string, sessionID: string) =>
const userMessage = (id: string, sessionID: string, created = 1) =>
({
id,
sessionID,
role: "user",
time: { created: 1 },
time: { created },
agent: "assistant",
model: { providerID: "openai", modelID: "gpt" },
}) as Message
@@ -370,13 +370,13 @@ describe("applyDirectoryEvent", () => {
const sessionID = "ses_1"
const [store, setStore] = createStore(
baseState({
message: { [sessionID]: [userMessage("msg_1", sessionID), userMessage("msg_3", sessionID)] },
part: { msg_2: [textPart("prt_1", sessionID, "msg_2")] },
message: { [sessionID]: [userMessage("msg_z", sessionID, 1), userMessage("msg_b", sessionID, 3)] },
part: { msg_a: [textPart("prt_1", sessionID, "msg_a")] },
}),
)
applyDirectoryEvent({
event: { type: "message.updated", properties: { info: userMessage("msg_2", sessionID) } },
event: { type: "message.updated", properties: { info: userMessage("msg_a", sessionID, 2) } },
store,
setStore,
push() {},
@@ -384,14 +384,14 @@ describe("applyDirectoryEvent", () => {
loadLsp() {},
})
expect(store.message[sessionID]?.map((x) => x.id)).toEqual(["msg_1", "msg_2", "msg_3"])
expect(store.message[sessionID]?.map((x) => x.id)).toEqual(["msg_z", "msg_a", "msg_b"])
applyDirectoryEvent({
event: {
type: "message.updated",
properties: {
info: {
...userMessage("msg_2", sessionID),
...userMessage("msg_a", sessionID, 2),
role: "assistant",
} as Message,
},
@@ -403,10 +403,10 @@ describe("applyDirectoryEvent", () => {
loadLsp() {},
})
expect(store.message[sessionID]?.find((x) => x.id === "msg_2")?.role).toBe("assistant")
expect(store.message[sessionID]?.find((x) => x.id === "msg_a")?.role).toBe("assistant")
applyDirectoryEvent({
event: { type: "message.removed", properties: { sessionID, messageID: "msg_2" } },
event: { type: "message.removed", properties: { sessionID, messageID: "msg_a" } },
store,
setStore,
push() {},
@@ -414,8 +414,8 @@ describe("applyDirectoryEvent", () => {
loadLsp() {},
})
expect(store.message[sessionID]?.map((x) => x.id)).toEqual(["msg_1", "msg_3"])
expect(store.part.msg_2).toBeUndefined()
expect(store.message[sessionID]?.map((x) => x.id)).toEqual(["msg_z", "msg_b"])
expect(store.part.msg_a).toBeUndefined()
})
test("upserts and prunes message parts", () => {
@@ -15,6 +15,7 @@ import type { State, VcsCache } from "./types"
import { trimSessions } from "./session-trim"
import { dropSessionCaches } from "./session-cache"
import { diffs as list, message as clean } from "@/utils/diffs"
import { messageKey } from "@/utils/session-message"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
const SESSION_CONTENT_EVENTS = new Set([
@@ -275,7 +276,7 @@ export function applyDirectoryEvent(input: {
input.setStore("message", info.sessionID, [info])
break
}
const result = Binary.search(messages, info.id, (m) => m.id)
const result = Binary.search(messages, messageKey(info), messageKey)
if (result.found) {
input.setStore("message", info.sessionID, result.index, reconcile(info))
break
@@ -295,8 +296,8 @@ export function applyDirectoryEvent(input: {
produce((draft) => {
const messages = draft.message[props.sessionID]
if (messages) {
const result = Binary.search(messages, props.messageID, (m) => m.id)
if (result.found) messages.splice(result.index, 1)
const index = messages.findIndex((message) => message.id === props.messageID)
if (index >= 0) messages.splice(index, 1)
}
const parts = draft.part[props.messageID]
if (parts) {
@@ -322,7 +323,7 @@ export function applyDirectoryEvent(input: {
input.setStore("part", part.messageID, [part])
break
}
const result = Binary.search(parts, part.id, (p) => p.id)
const result = Binary.search(parts, part.id, (item) => item.id)
if (result.found) {
input.setStore("part", part.messageID, result.index, reconcile(part))
break
@@ -345,13 +346,13 @@ export function applyDirectoryEvent(input: {
)
const parts = input.store.part[props.messageID]
if (!parts) break
const result = Binary.search(parts, props.partID, (p) => p.id)
const result = Binary.search(parts, props.partID, (part) => part.id)
if (result.found) {
input.setStore(
produce((draft) => {
const list = draft.part[props.messageID]
if (!list) return
const next = Binary.search(list, props.partID, (p) => p.id)
const next = Binary.search(list, props.partID, (part) => part.id)
if (!next.found) return
list.splice(next.index, 1)
if (list.length === 0) delete draft.part[props.messageID]
@@ -364,7 +365,7 @@ export function applyDirectoryEvent(input: {
const props = event.properties as { messageID: string; partID: string; field: string; delta: string }
const parts = input.store.part[props.messageID]
if (!parts) break
const result = Binary.search(parts, props.partID, (p) => p.id)
const result = Binary.search(parts, props.partID, (part) => part.id)
if (!result.found) break
const field = props.field as keyof (typeof parts)[number]
const current = parts[result.index]?.[field]
@@ -264,6 +264,7 @@ describe("server session", () => {
expect(requests).toEqual([{ sessionID: "root", limit: 20, order: "desc" }])
expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
expect(store.data.message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
})
test("extends a current page to include the user for split assistant turns", async () => {
@@ -1497,7 +1498,7 @@ describe("server session", () => {
await store.sync("child", { force: true })
expect(store.data.message.child).toEqual([boundary, older])
expect(store.data.message.child).toEqual([older, boundary])
})
test("preserves a part update for a message being loaded from history", async () => {
+28 -29
View File
@@ -18,7 +18,7 @@ import { message as cleanMessage } from "@/utils/diffs"
import { sessionNotFoundError } from "@/utils/server-errors"
import { rootSession } from "@/utils/session-route"
import { normalizeSessionInfo } from "@/utils/session"
import { normalizeSessionMessages } from "@/utils/session-message"
import { compareMessages, messageKey, normalizeSessionMessages } from "@/utils/session-message"
import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
import { createV2SessionReducer, type V2SessionReduction } from "./server-session-v2-reducer"
import type { ServerApi } from "@/utils/server"
@@ -26,7 +26,6 @@ import type { ServerApi } from "@/utils/server"
type MessageApi = ServerApi["message"]
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
const cmpMessage = (a: Message, b: Message) => a.time.created - b.time.created || cmp(a.id, b.id)
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
const initialMessagePageSize = 20
const historyMessagePageSize = 200
@@ -64,7 +63,7 @@ type MessagePage = {
function legacyMessageSource(items: { info: Message; parts: Part[] }[]): SessionMessageInfo[] {
return items
.slice()
.sort((a, b) => cmp(a.info.id, b.info.id))
.sort((a, b) => compareMessages(a.info, b.info))
.map((item) => {
if (item.info.role === "user") {
return {
@@ -111,17 +110,16 @@ function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
const part = new Map(page.part.map((item) => [item.id, item.part]))
const observed: { messageID: string; parts: Part[] }[] = []
for (const item of items) {
const result = Binary.search(session, item.message.id, (message) => message.id)
if (!result.found) session.splice(result.index, 0, item.message)
const result = Binary.search(session, messageKey(item.message), messageKey)
const found = result.found
if (!found) session.splice(result.index, 0, item.message)
const current = part.get(item.message.id)
const confirmed = result.found
? item.parts.filter((part) => Binary.search(current ?? [], part.id, (value) => value.id).found)
: []
if (result.found) observed.push({ messageID: item.message.id, parts: confirmed })
const confirmed = found ? item.parts.filter((part) => current?.some((value) => value.id === part.id)) : []
if (found) observed.push({ messageID: item.message.id, parts: confirmed })
part.set(
item.message.id,
merge(
result.found ? (current ?? []) : merge(item.confirmedParts ?? [], current ?? []),
found ? (current ?? []) : merge(item.confirmedParts ?? [], current ?? []),
item.parts.filter((part) => !confirmed.includes(part)),
),
)
@@ -158,6 +156,7 @@ function reconcileFetched<T extends { id: string }>(
retained?: ReadonlySet<string>
removed?: ReadonlySet<string>
preserveUnfetched?: boolean | ((item: T) => boolean)
compare?: (a: T, b: T) => number
} = {},
) {
const result = new Map(fetched.map((item) => [item.id, item]))
@@ -180,7 +179,8 @@ function reconcileFetched<T extends { id: string }>(
if (!item) result.delete(id)
}
for (const id of options.removed ?? emptyIDs) result.delete(id)
return [...result.values()].sort((a, b) => cmp(a.id, b.id))
const items = [...result.values()]
return options.compare ? items.sort(options.compare) : items
}
type ServerSessionOptions = { retry?: typeof retry; protocol?: Promise<"v1" | "v2"> }
@@ -413,8 +413,7 @@ export function createServerSession(
if (!load) return
// A part event keeps an existing parent when the fetched page omits it without overriding fetched metadata.
const messages = data.message[sessionID]
if (messages && Binary.search(messages, messageID, (message) => message.id).found)
load.retainedMessages.add(messageID)
if (messages?.some((message) => message.id === messageID)) load.retainedMessages.add(messageID)
const parts = load.touchedParts.get(messageID)
if (parts) {
parts.add(partID)
@@ -437,16 +436,14 @@ export function createServerSession(
load.touchedParts.set(messageID, new Set(parts))
load.carriedDeltaParts.set(messageID, new Set(parts))
const messages = data.message[sessionID]
if (messages && Binary.search(messages, messageID, (message) => message.id).found)
load.retainedMessages.add(messageID)
if (messages?.some((message) => message.id === messageID)) load.retainedMessages.add(messageID)
}
for (const [messageID, parts] of load.removedParts) {
const touched = load.touchedParts.get(messageID) ?? new Set<string>()
parts.forEach((partID) => touched.add(partID))
load.touchedParts.set(messageID, touched)
const messages = data.message[sessionID]
if (messages && Binary.search(messages, messageID, (message) => message.id).found)
load.retainedMessages.add(messageID)
if (messages?.some((message) => message.id === messageID)) load.retainedMessages.add(messageID)
}
for (const [messageID, parts] of load.optimisticParts) {
load.removedMessages.delete(messageID)
@@ -555,7 +552,7 @@ export function createServerSession(
const source = pages.flatMap((page) => page.data).toReversed()
const normalized = normalizeSessionMessages(sessionID, source)
return {
session: normalized.messages.sort((a, b) => cmp(a.id, b.id)),
session: normalized.messages.sort(compareMessages),
part: [...normalized.parts.entries()]
.map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) }))
.sort((a, b) => cmp(a.id, b.id)),
@@ -572,7 +569,7 @@ export function createServerSession(
})
const items = (response.data ?? []).filter((item) => !!item?.info?.id)
return {
session: items.map((item) => cleanMessage(item.info)).sort((a, b) => cmp(a.id, b.id)),
session: items.map((item) => cleanMessage(item.info)).sort(compareMessages),
part: items.map((item) => ({
id: item.info.id,
part: item.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)),
@@ -696,7 +693,7 @@ export function createServerSession(
const normalized = normalizeSessionMessages(sessionID, source)
return {
...page,
session: normalized.messages.sort((a, b) => cmp(a.id, b.id)),
session: normalized.messages.sort(compareMessages),
part: [...normalized.parts.entries()]
.map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) }))
.sort((a, b) => cmp(a.id, b.id)),
@@ -713,6 +710,7 @@ export function createServerSession(
retained: load?.retainedMessages,
removed: load?.removedMessages,
preserveUnfetched,
compare: compareMessages,
})
batch(() => {
if (source) setData("session_message", sessionID, reconcile(source))
@@ -754,7 +752,7 @@ export function createServerSession(
try {
const page = await fetchMessages(sessionID, limit, before, () => resetMessageLoad(sessionID, load))
const first = page.session.reduce<Message | undefined>(
(oldest, message) => (!oldest || cmpMessage(message, oldest) < 0 ? message : oldest),
(oldest, message) => (!oldest || compareMessages(message, oldest) < 0 ? message : oldest),
undefined,
)
if (generations.get(sessionID) !== active) return
@@ -804,14 +802,15 @@ export function createServerSession(
session: merge(
page.session,
parents.map((parent) => parent.message),
),
).sort(compareMessages),
part: merge(
page.part,
parents.map((parent) => ({ id: parent.message.id, part: parent.parts })),
),
}
const preserveUnfetched =
mode === "prepend" || (!result.complete && (!first || ((message: Message) => cmpMessage(message, first) < 0)))
mode === "prepend" ||
(!result.complete && (!first || ((message: Message) => compareMessages(message, first) < 0)))
applyMessagePage(
sessionID,
result,
@@ -928,7 +927,7 @@ export function createServerSession(
.message({ sessionID, messageID })
.then((message) => {
const current = data.session_message[sessionID] ?? []
const messages = [...current.filter((item) => item.id !== message.id), message].sort((a, b) => cmp(a.id, b.id))
const messages = [...current.filter((item) => item.id !== message.id), message].sort(compareMessages)
projectV2({ sessionID, messages, touched: [message.id] })
})
.catch(() => {})
@@ -1051,7 +1050,7 @@ export function createServerSession(
setData("message", info.sessionID, [info])
return
}
const result = Binary.search(messages, info.id, (message) => message.id)
const result = Binary.search(messages, messageKey(info), messageKey)
if (result.found) setData("message", info.sessionID, result.index, reconcile(info))
if (!result.found)
setData("message", info.sessionID, (value = []) => {
@@ -1084,8 +1083,8 @@ export function createServerSession(
produce((draft) => {
const messages = draft.message[props.sessionID]
if (messages) {
const result = Binary.search(messages, props.messageID, (message) => message.id)
if (result.found) messages.splice(result.index, 1)
const index = messages.findIndex((message) => message.id === props.messageID)
if (index >= 0) messages.splice(index, 1)
}
deleteMessageParts(draft, props.messageID)
}),
@@ -1097,7 +1096,7 @@ export function createServerSession(
if (SKIP_PARTS.has(part.type)) return
const messages = data.message[part.sessionID]
const load = messageLoads.get(part.sessionID)
const missing = !messages || !Binary.search(messages, part.messageID, (message) => message.id).found
const missing = !messages?.some((message) => message.id === part.messageID)
// Outside a page load, accepting a part without its ordered parent event would create an unbounded orphan.
if (
missing &&
@@ -1341,7 +1340,7 @@ export function createServerSession(
if (items) items.set(input.message.id, { ...input, parts, confirmedParts: [] })
if (!items)
optimistic.set(input.sessionID, new Map([[input.message.id, { ...input, parts, confirmedParts: [] }]]))
setData("message", input.sessionID, (messages = []) => merge(messages, [input.message]))
setData("message", input.sessionID, (messages = []) => merge(messages, [input.message]).sort(compareMessages))
setData(
"part_text_accum_delta",
produce((draft) => {
@@ -4,11 +4,11 @@ import { applyOptimisticAdd, applyOptimisticRemove, mergeOptimisticPage } from "
type Text = Extract<Part, { type: "text" }>
const userMessage = (id: string, sessionID: string): Message => ({
const userMessage = (id: string, sessionID: string, created = 1): Message => ({
id,
sessionID,
role: "user",
time: { created: 1 },
time: { created },
agent: "assistant",
model: { providerID: "openai", modelID: "gpt" },
})
@@ -22,21 +22,21 @@ const textPart = (id: string, sessionID: string, messageID: string): Text => ({
})
describe("sync optimistic reducers", () => {
test("applyOptimisticAdd inserts message in sorted order and stores parts", () => {
test("applyOptimisticAdd inserts by creation time", () => {
const sessionID = "ses_1"
const draft = {
message: { [sessionID]: [userMessage("msg_2", sessionID)] },
message: { [sessionID]: [userMessage("msg_z", sessionID, 1)] },
part: {} as Record<string, Part[] | undefined>,
}
applyOptimisticAdd(draft, {
sessionID,
message: userMessage("msg_1", sessionID),
parts: [textPart("prt_2", sessionID, "msg_1"), textPart("prt_1", sessionID, "msg_1")],
message: userMessage("msg_a", sessionID, 2),
parts: [textPart("prt_2", sessionID, "msg_a"), textPart("prt_1", sessionID, "msg_a")],
})
expect(draft.message[sessionID]?.map((x) => x.id)).toEqual(["msg_1", "msg_2"])
expect(draft.part.msg_1?.map((x) => x.id)).toEqual(["prt_1", "prt_2"])
expect(draft.message[sessionID]?.map((x) => x.id)).toEqual(["msg_z", "msg_a"])
expect(draft.part.msg_a?.map((x) => x.id)).toEqual(["prt_1", "prt_2"])
})
test("applyOptimisticRemove removes message and part entries", () => {
@@ -60,19 +60,33 @@ describe("sync optimistic reducers", () => {
const sessionID = "ses_1"
const page = mergeOptimisticPage(
{
session: [userMessage("msg_1", sessionID)],
part: [{ id: "msg_1", part: [textPart("prt_1", sessionID, "msg_1")] }],
session: [userMessage("msg_z", sessionID, 1)],
part: [{ id: "msg_z", part: [textPart("prt_1", sessionID, "msg_z")] }],
complete: true,
},
[{ message: userMessage("msg_2", sessionID), parts: [textPart("prt_2", sessionID, "msg_2")] }],
[{ message: userMessage("msg_a", sessionID, 2), parts: [textPart("prt_2", sessionID, "msg_a")] }],
)
expect(page.session.map((x) => x.id)).toEqual(["msg_1", "msg_2"])
expect(page.part.find((x) => x.id === "msg_2")?.part.map((x) => x.id)).toEqual(["prt_2"])
expect(page.session.map((x) => x.id)).toEqual(["msg_z", "msg_a"])
expect(page.part.find((x) => x.id === "msg_a")?.part.map((x) => x.id)).toEqual(["prt_2"])
expect(page.confirmed).toEqual([])
expect(page.complete).toBe(true)
})
test("mergeOptimisticPage uses IDs only to break equal-time ties", () => {
const sessionID = "ses_1"
const page = mergeOptimisticPage(
{
session: [userMessage("msg_z", sessionID, 1)],
part: [],
complete: true,
},
[{ message: userMessage("msg_a", sessionID, 1), parts: [] }],
)
expect(page.session.map((message) => message.id)).toEqual(["msg_a", "msg_z"])
})
test("mergeOptimisticPage keeps missing optimistic parts until the server has them", () => {
const sessionID = "ses_1"
const page = mergeOptimisticPage(
+5 -4
View File
@@ -3,6 +3,7 @@ import { createMemo } from "solid-js"
import { useServerSync } from "./server-sync"
import { useSDK } from "./sdk"
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { messageKey } from "@/utils/session-message"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
@@ -67,7 +68,7 @@ export function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[])
const confirmed: string[] = []
for (const item of items) {
const result = Binary.search(session, item.message.id, (message) => message.id)
const result = Binary.search(session, messageKey(item.message), messageKey)
const found = result.found
if (!found) session.splice(result.index, 0, item.message)
@@ -92,7 +93,7 @@ export function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[])
export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) {
const messages = draft.message[input.sessionID]
if (messages) {
const result = Binary.search(messages, input.message.id, (m) => m.id)
const result = Binary.search(messages, messageKey(input.message), messageKey)
messages.splice(result.index, 0, input.message)
} else {
draft.message[input.sessionID] = [input.message]
@@ -103,8 +104,8 @@ export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddI
export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticRemoveInput) {
const messages = draft.message[input.sessionID]
if (messages) {
const result = Binary.search(messages, input.messageID, (m) => m.id)
if (result.found) messages.splice(result.index, 1)
const index = messages.findIndex((message) => message.id === input.messageID)
if (index >= 0) messages.splice(index, 1)
}
delete draft.part[input.messageID]
}
@@ -15,7 +15,7 @@ import type { LocalProject } from "@/context/layout"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server"
import { sessionHasOpenTab, useTabs } from "@/context/tabs"
import { displayName, errorMessage, projectForSession } from "@/pages/layout/helpers"
import { compareSessionTime, displayName, errorMessage, projectForSession } from "@/pages/layout/helpers"
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
import { pathKey } from "@/utils/path-key"
import { showToast } from "@/utils/toast"
@@ -254,7 +254,7 @@ function buildHomeSessionRecords(input: {
const directories = new Set(input.projectDirectories().map(pathKey))
const sessions = input.sessions().filter((session) => directories.has(pathKey(session.directory)))
return [...new Map(sessions.map((session) => [session.id, session] as const)).values()]
.sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
.sort(compareSessionTime)
.flatMap((session) => {
const directory = pathKey(session.directory)
const project =
@@ -10,6 +10,7 @@ import { type Session } from "@opencode-ai/sdk/v2/client"
import {
childSessionOnPath,
closeHomeProject,
compareSessionTime,
displayName,
effectiveWorkspaceOrder,
errorMessage,
@@ -18,6 +19,7 @@ import {
homeProjectDirectories,
homeSessionServerStatus,
latestRootSession,
sortedRootSessions,
toggleHomeProjectSelection,
} from "./helpers"
import { pathKey } from "@/utils/path-key"
@@ -153,6 +155,30 @@ describe("layout workspace helpers", () => {
expect(result?.id).toBe("workspace")
})
test("sorts recent sessions by persisted update time instead of id", () => {
const result = sortedRootSessions(
{
path: { directory: "/workspace" },
session: [
session({ id: "ses_z", directory: "/workspace", time: { created: 1, updated: 2, archived: undefined } }),
session({ id: "ses_a", directory: "/workspace", time: { created: 1, updated: 3, archived: undefined } }),
],
},
3,
)
expect(result.map((item) => item.id)).toEqual(["ses_a", "ses_z"])
})
test("uses id only to break equal session timestamps", () => {
const sessions = [
session({ id: "ses_z", directory: "/workspace", time: { created: 1, updated: 2, archived: undefined } }),
session({ id: "ses_a", directory: "/workspace", time: { created: 1, updated: 2, archived: undefined } }),
]
expect(sessions.sort(compareSessionTime).map((item) => item.id)).toEqual(["ses_a", "ses_z"])
})
test("detects project permissions with a filter", () => {
const result = hasProjectPermissions(
{
+7 -15
View File
@@ -9,18 +9,10 @@ type SessionStore = {
path: { directory: string }
}
function sortSessions(now: number) {
const oneMinuteAgo = now - 60 * 1000
return (a: Session, b: Session) => {
const aUpdated = a.time.updated ?? a.time.created
const bUpdated = b.time.updated ?? b.time.created
const aRecent = aUpdated > oneMinuteAgo
const bRecent = bUpdated > oneMinuteAgo
if (aRecent && bRecent) return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
if (aRecent && !bRecent) return -1
if (!aRecent && bRecent) return 1
return bUpdated - aUpdated
}
export function compareSessionTime(a: Session, b: Session) {
const updated = (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created)
if (updated !== 0) return updated
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
}
const isRootVisibleSession = (session: Session, directory: string) =>
@@ -29,10 +21,10 @@ const isRootVisibleSession = (session: Session, directory: string) =>
export const roots = (store: SessionStore) =>
(store.session ?? []).filter((session) => isRootVisibleSession(session, store.path.directory))
export const sortedRootSessions = (store: SessionStore, now: number) => roots(store).sort(sortSessions(now))
export const sortedRootSessions = (store: SessionStore, _now: number) => roots(store).sort(compareSessionTime)
export const latestRootSession = (stores: SessionStore[], now: number) =>
stores.flatMap(roots).sort(sortSessions(now))[0]
export const latestRootSession = (stores: SessionStore[], _now: number) =>
stores.flatMap(roots).sort(compareSessionTime)[0]
export function hasProjectPermissions<T>(
request: Record<string, T[] | undefined> | undefined,
+6 -2
View File
@@ -1851,7 +1851,9 @@ export default function Page() {
const session = sdk().api.session
const target = sync()
const next = userMessages().find((item) => item.id > id)
const index = userMessages().findIndex((item) => item.id === id)
if (index < 0) return
const next = userMessages()[index + 1]
const last = target.session.get(sessionID)?.revert
await runPromptRollbackMutation({
@@ -1891,8 +1893,10 @@ export default function Page() {
const rolled = createMemo(() => {
const id = revertMessageID()
if (!id) return []
const index = userMessages().findIndex((item) => item.id === id)
if (index < 0) return []
return userMessages()
.filter((item) => item.id >= id)
.slice(index)
.map((item) => ({ id: item.id, text: line(item.id) }))
})
@@ -287,7 +287,9 @@ export function MessageTimeline(props: {
const visible = new Set(props.userMessages.map((message) => message.id))
const boundary = sessionMessages().find((message) => message.role === "user" && !visible.has(message.id))?.id
const messages = sync().data.session_message[id] ?? []
return boundary ? messages.filter((message) => message.id < boundary) : messages
if (!boundary) return messages
const index = messages.findIndex((message) => message.id === boundary)
return index < 0 ? messages : messages.slice(0, index)
})
const info = createMemo(() => {
const id = sessionID()
@@ -7,11 +7,11 @@ const assistant = (id: string) => ({ id, role: "assistant" }) as AssistantMessag
describe("timeline model", () => {
test("selects users and applies the revert boundary", () => {
const messages: Message[] = [user("msg_1"), assistant("msg_2"), user("msg_3"), user("msg_5")]
const messages: Message[] = [user("msg_z"), assistant("msg_a"), user("msg_b"), user("msg_c")]
const users = selectUserMessages(messages)
expect(users.map((message) => message.id)).toEqual(["msg_1", "msg_3", "msg_5"])
expect(selectVisibleUserMessages(users, "msg_5").map((message) => message.id)).toEqual(["msg_1", "msg_3"])
expect(users.map((message) => message.id)).toEqual(["msg_z", "msg_b", "msg_c"])
expect(selectVisibleUserMessages(users, "msg_b").map((message) => message.id)).toEqual(["msg_z"])
expect(selectVisibleUserMessages(users)).toBe(users)
})
@@ -104,7 +104,8 @@ export function isTimelineReady(messages: Message[] | undefined, loading: boolea
export function selectVisibleUserMessages(messages: UserMessage[], revertMessageID?: string) {
if (!revertMessageID) return messages
return messages.filter((message) => message.id < revertMessageID)
const boundary = messages.findIndex((message) => message.id === revertMessageID)
return boundary < 0 ? messages : messages.slice(0, boundary)
}
export async function loadOlderTimeline(input: {
@@ -137,11 +137,11 @@ describe("current session timeline rows", () => {
test("renders an optimistic user turn and thinking before the protocol message arrives", () => {
const source = [
{ id: "msg_1", type: "user", text: "existing", time: { created: 1 } },
{ id: "msg_z", type: "user", text: "existing", time: { created: 1 } },
] satisfies SessionMessageInfo[]
const normalized = normalizeSessionMessages("ses_1", source)
const optimistic = {
id: "msg_2",
id: "msg_a",
sessionID: "ses_1",
role: "user" as const,
time: { created: 2 },
@@ -161,10 +161,10 @@ describe("current session timeline rows", () => {
expect(result.activeMessageID).toBe(optimistic.id)
expect(result.rows.map(TimelineRow.key)).toEqual([
"user-message:msg_1",
"turn-gap:msg_2",
"user-message:msg_2",
"thinking:msg_2",
"user-message:msg_z",
"turn-gap:msg_a",
"user-message:msg_a",
"thinking:msg_a",
])
})
@@ -4,6 +4,7 @@ import { AssistantMessage, Part, SessionStatus, UserMessage } from "@opencode-ai
import { groupParts, renderable, type PartGroup } from "@opencode-ai/session-ui/message-part"
import { TimelineRow, type SummaryDiff } from "./timeline-row"
import { uniqueSummaryDiffs } from "./summary-diffs"
import { compareMessages } from "@/utils/session-message"
export { TimelineRow, type SummaryDiff } from "./timeline-row"
@@ -71,12 +72,12 @@ export namespace Timeline {
turns.push(turn)
turnByUserID.set(user.id, turn)
})
const latestUserMessageID = turns.at(-1)?.user.id
projectedUserMessages.forEach((user) => {
if (turnByUserID.has(user.id)) return
if (latestUserMessageID && user.id < latestUserMessageID) return
const turn = { user, assistants: [] }
turns.push(turn)
const index = turns.findIndex((item) => compareMessages(user, item.user) < 0)
if (index < 0) turns.push(turn)
if (index >= 0) turns.splice(index, 0, turn)
turnByUserID.set(user.id, turn)
})
const activeMessageID = turns.at(-1)?.user.id
@@ -100,7 +100,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const visibleUserMessages = () => {
const revert = info()?.revert?.messageID
if (!revert) return userMessages()
return userMessages().filter((m) => m.id < revert)
const boundary = userMessages().findIndex((message) => message.id === revert)
return boundary < 0 ? userMessages() : userMessages().slice(0, boundary)
}
const showAllFiles = () => {
@@ -337,7 +338,9 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const promptSession = prompt.capture()
const revert = info()?.revert?.messageID
const messages = userMessages()
const message = findLast(messages, (x) => !revert || x.id < revert)
const boundary = revert ? messages.findIndex((message) => message.id === revert) : messages.length
if (boundary < 0) return
const message = messages[boundary - 1]
if (!message) return
const parts = sync().data.part[message.id]
@@ -352,7 +355,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
updatePrompt: (promptSession) => {
if (parts) promptSession.set(extractPromptFromParts(parts, { directory }))
},
updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id < message.id)),
updateViewport: () => setActiveMessage(messages[boundary - 2]),
})
}
@@ -367,14 +370,16 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const revertMessageID = info()?.revert?.messageID
if (!revertMessageID) return
const next = messages.find((x) => x.id > revertMessageID)
const boundary = messages.findIndex((message) => message.id === revertMessageID)
if (boundary < 0) return
const next = messages[boundary + 1]
if (!next) {
await runCommand({
owner,
prompt: promptSession,
request: () => session.revert.clear({ sessionID }),
updatePrompt: (promptSession) => promptSession.reset(),
updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id >= revertMessageID)),
updateViewport: () => setActiveMessage(messages.at(-1)),
})
return
}
@@ -384,7 +389,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
prompt: promptSession,
request: () => session.revert.stage({ sessionID, messageID: next.id }),
updatePrompt: () => undefined,
updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id < next.id)),
updateViewport: () => setActiveMessage(messages[boundary]),
})
}
@@ -12,6 +12,14 @@ const emptyTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write
const emptyModel: { id: string; providerID: string; variant?: string } = { id: "", providerID: "" }
const decodeToolInput = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
export function compareMessages(a: Pick<Message, "id" | "time">, b: Pick<Message, "id" | "time">) {
const left = messageKey(a)
const right = messageKey(b)
return left < right ? -1 : left > right ? 1 : 0
}
export const messageKey = (message: Pick<Message, "id" | "time">) => message.time.created + message.id
function record(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value)
}
+1 -1
View File
@@ -252,7 +252,7 @@ export const dict = {
"zen.privacy.exceptionsLink": "الاستثناءات التالية",
"go.title": "OpenCode Go | نماذج برمجة منخفضة التكلفة للجميع",
"go.banner.text": "يحصل GPT 5.6 Luna على حدود استخدام مضاعفة لفترة محدودة",
"go.banner.text": "يحصل DeepSeek V4 Flash على حدود استخدام مضاعفة لفترة محدودة",
"go.meta.description":
"يبدأ Go بسعر $5 للشهر الأول، ثم $10/شهر، مع حدود استخدام سخية ووصول موثوق إلى نماذج البرمجة الرائدة.",
"go.hero.title": "نماذج برمجة منخفضة التكلفة للجميع",
+1 -1
View File
@@ -256,7 +256,7 @@ export const dict = {
"zen.privacy.exceptionsLink": "seguintes exceções",
"go.title": "OpenCode Go | Modelos de codificação de baixo custo para todos",
"go.banner.text": "GPT 5.6 Luna tem limites de uso 2x maiores por tempo limitado",
"go.banner.text": "DeepSeek V4 Flash tem limites de uso 2x maiores por tempo limitado",
"go.meta.description":
"O Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos de uso e acesso confiável aos principais modelos de codificação.",
"go.hero.title": "Modelos de codificação de baixo custo para todos",
+1 -1
View File
@@ -254,7 +254,7 @@ export const dict = {
"zen.privacy.exceptionsLink": "følgende undtagelser",
"go.title": "OpenCode Go | Kodningsmodeller til lav pris for alle",
"go.banner.text": "GPT 5.6 Luna får fordoblet brugsgrænse i en begrænset periode",
"go.banner.text": "DeepSeek V4 Flash får fordoblet brugsgrænse i en begrænset periode",
"go.meta.description":
"Go starter ved $5 for den første måned, derefter $10/måned, med generøse brugsgrænser og pålidelig adgang til førende kodningsmodeller.",
"go.hero.title": "Kodningsmodeller til lav pris for alle",
+1 -1
View File
@@ -256,7 +256,7 @@ export const dict = {
"zen.privacy.exceptionsLink": "folgenden Ausnahmen",
"go.title": "OpenCode Go | Kostengünstige Coding-Modelle für alle",
"go.banner.text": "GPT 5.6 Luna erhält für begrenzte Zeit 2x Nutzungslimits",
"go.banner.text": "DeepSeek V4 Flash erhält für begrenzte Zeit 2x Nutzungslimits",
"go.meta.description":
"Go beginnt bei $5 für deinen ersten Monat, danach $10/Monat, mit großzügigen Nutzungslimits und zuverlässigem Zugang zu führenden Coding-Modellen.",
"go.hero.title": "Kostengünstige Coding-Modelle für alle",
+1 -1
View File
@@ -253,7 +253,7 @@ export const dict = {
"zen.privacy.exceptionsLink": "following exceptions",
"go.title": "OpenCode Go | Low cost coding models for everyone",
"go.banner.text": "GPT 5.6 Luna gets 2× usage limits for a limited time",
"go.banner.text": "DeepSeek V4 Flash gets 2× usage limits for a limited time",
"go.meta.description":
"Go starts at $5 for your first month, then $10/month, with generous usage limits and reliable access to leading coding models.",
"go.hero.title": "Low cost coding models for everyone",
+1 -1
View File
@@ -257,7 +257,7 @@ export const dict = {
"zen.privacy.exceptionsLink": "siguientes excepciones",
"go.title": "OpenCode Go | Modelos de programación de bajo coste para todos",
"go.banner.text": "GPT 5.6 Luna tiene límites de uso 2x mayores por tiempo limitado",
"go.banner.text": "DeepSeek V4 Flash tiene límites de uso 2x mayores por tiempo limitado",
"go.meta.description":
"Go comienza en $5 el primer mes, luego 10 $/mes, con límites de uso generosos y acceso fiable a modelos de programación líderes.",
"go.hero.title": "Modelos de programación de bajo coste para todos",
+1 -1
View File
@@ -258,7 +258,7 @@ export const dict = {
"zen.privacy.exceptionsLink": "exceptions suivantes",
"go.title": "OpenCode Go | Modèles de code à faible coût pour tous",
"go.banner.text": "GPT 5.6 Luna bénéficie de limites dutilisation 2x supérieures pour une durée limitée",
"go.banner.text": "DeepSeek V4 Flash bénéficie de limites dutilisation 2x supérieures pour une durée limitée",
"go.meta.description":
"Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites d'utilisation généreuses et un accès fiable aux principaux modèles de codage.",
"go.hero.title": "Modèles de code à faible coût pour tous",
+1 -1
View File
@@ -254,7 +254,7 @@ export const dict = {
"zen.privacy.exceptionsLink": "seguenti eccezioni",
"go.title": "OpenCode Go | Modelli di coding a basso costo per tutti",
"go.banner.text": "GPT 5.6 Luna offre limiti di utilizzo 2x superiori per un periodo limitato",
"go.banner.text": "DeepSeek V4 Flash offre limiti di utilizzo 2x superiori per un periodo limitato",
"go.meta.description":
"Go inizia a $5 per il primo mese, poi $10/mese, con limiti di utilizzo generosi e un accesso affidabile ai principali modelli di coding.",
"go.hero.title": "Modelli di coding a basso costo per tutti",
+1 -1
View File
@@ -253,7 +253,7 @@ export const dict = {
"zen.privacy.exceptionsLink": "以下の例外",
"go.title": "OpenCode Go | すべての人のための低価格なコーディングモデル",
"go.banner.text": "GPT 5.6 Lunaの利用上限が期間限定で2倍に",
"go.banner.text": "DeepSeek V4 Flashの利用上限が期間限定で2倍に",
"go.meta.description":
"Goは最初の月$5、その後$10/月で、主要なコーディングモデルへのゆとりある利用上限と安定したアクセスを提供します。",
"go.hero.title": "すべての人のための低価格なコーディングモデル",
+1 -1
View File
@@ -250,7 +250,7 @@ export const dict = {
"zen.privacy.exceptionsLink": "다음 예외",
"go.title": "OpenCode Go | 모두를 위한 저비용 코딩 모델",
"go.banner.text": "GPT 5.6 Luna 사용 한도가 한시적으로 2배 확대됩니다",
"go.banner.text": "DeepSeek V4 Flash 사용 한도가 한시적으로 2배 확대됩니다",
"go.meta.description":
"Go는 첫 달 $5, 이후 $10/월로 시작하며, 넉넉한 사용 한도와 주요 코딩 모델에 대한 안정적인 액세스를 제공합니다.",
"go.hero.title": "모두를 위한 저비용 코딩 모델",
+1 -1
View File
@@ -254,7 +254,7 @@ export const dict = {
"zen.privacy.exceptionsLink": "følgende unntak",
"go.title": "OpenCode Go | Rimelige kodemodeller for alle",
"go.banner.text": "GPT 5.6 Luna får 2x bruksgrense i en begrenset periode",
"go.banner.text": "DeepSeek V4 Flash får 2x bruksgrense i en begrenset periode",
"go.meta.description":
"Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse bruksgrenser og pålitelig tilgang til ledende kodemodeller.",
"go.hero.title": "Rimelige kodemodeller for alle",
+1 -1
View File
@@ -255,7 +255,7 @@ export const dict = {
"zen.privacy.exceptionsLink": "następującymi wyjątkami",
"go.title": "OpenCode Go | Niskokosztowe modele do kodowania dla każdego",
"go.banner.text": "GPT 5.6 Luna oferuje 2x wyższe limity użycia przez ograniczony czas",
"go.banner.text": "DeepSeek V4 Flash oferuje 2x wyższe limity użycia przez ograniczony czas",
"go.meta.description":
"Go kosztuje $5 za pierwszy miesiąc, a następnie $10/miesiąc, oferując hojne limity użycia i niezawodny dostęp do wiodących modeli do kodowania.",
"go.hero.title": "Niskokosztowe modele do kodowania dla każdego",
+1 -1
View File
@@ -258,7 +258,7 @@ export const dict = {
"zen.privacy.exceptionsLink": "следующими исключениями",
"go.title": "OpenCode Go | Недорогие модели для кодинга для всех",
"go.banner.text": "GPT 5.6 Luna получает 2x лимиты использования на ограниченное время",
"go.banner.text": "DeepSeek V4 Flash получает 2x лимиты использования на ограниченное время",
"go.meta.description":
"Go стоит $5 за первый месяц, затем $10/месяц и предлагает щедрые лимиты использования и надежный доступ к ведущим моделям для кодинга.",
"go.hero.title": "Недорогие модели для кодинга для всех",
+1 -1
View File
@@ -253,7 +253,7 @@ export const dict = {
"zen.privacy.exceptionsLink": "ข้อยกเว้นดังนี้",
"go.title": "OpenCode Go | โมเดลเขียนโค้ดราคาประหยัดสำหรับทุกคน",
"go.banner.text": "GPT 5.6 Luna เพิ่มโควตาการใช้งานเป็น 2 เท่าในช่วงเวลาจำกัด",
"go.banner.text": "DeepSeek V4 Flash เพิ่มโควตาการใช้งานเป็น 2 เท่าในช่วงเวลาจำกัด",
"go.meta.description":
"Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดการใช้งานที่เอื้อเฟื้อและการเข้าถึงโมเดลเขียนโค้ดชั้นนำอย่างเชื่อถือได้",
"go.hero.title": "โมเดลเขียนโค้ดราคาประหยัดสำหรับทุกคน",
+1 -1
View File
@@ -256,7 +256,7 @@ export const dict = {
"zen.privacy.exceptionsLink": "aşağıdaki istisnalar",
"go.title": "OpenCode Go | Herkes için düşük maliyetli kodlama modelleri",
"go.banner.text": "GPT 5.6 Luna sınırlı bir süre için 2x kullanım limiti sunuyor",
"go.banner.text": "DeepSeek V4 Flash sınırlı bir süre için 2x kullanım limiti sunuyor",
"go.meta.description":
"Go ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; cömert kullanım limitleri ve önde gelen kodlama modellerine güvenilir erişim sunar.",
"go.hero.title": "Herkes için düşük maliyetli kodlama modelleri",
+1 -1
View File
@@ -255,7 +255,7 @@ export const dict = {
"zen.privacy.exceptionsLink": "такими винятками",
"go.title": "OpenCode Go | Недорогі моделі кодування для всіх",
"go.banner.text": "GPT 5.6 Luna отримує 2x ліміти використання протягом обмеженого часу",
"go.banner.text": "DeepSeek V4 Flash отримує 2x ліміти використання протягом обмеженого часу",
"go.meta.description":
"Go починається від $5 за перший місяць, потім $10/місяць, зі щедрими лімітами використання та надійним доступом до провідних моделей для кодування.",
"go.hero.title": "Недорогі моделі кодування для всіх",
+1 -1
View File
@@ -244,7 +244,7 @@ export const dict = {
"zen.privacy.exceptionsLink": "以下例外情况除外",
"go.title": "OpenCode Go | 人人可用的低成本编程模型",
"go.banner.text": "GPT 5.6 Luna 限时享受 2 倍使用额度",
"go.banner.text": "DeepSeek V4 Flash 限时享受 2 倍使用额度",
"go.meta.description": "Go 首月 $5,之后 $10/月,提供充裕的使用限额,并可可靠访问领先的编程模型。",
"go.hero.title": "人人可用的低成本编程模型",
"go.hero.body":
+1 -1
View File
@@ -244,7 +244,7 @@ export const dict = {
"zen.privacy.exceptionsLink": "以下例外情況",
"go.title": "OpenCode Go | 低成本全民編碼模型",
"go.banner.text": "GPT 5.6 Luna 限時享有 2 倍使用額度",
"go.banner.text": "DeepSeek V4 Flash 限時享有 2 倍使用額度",
"go.meta.description": "Go 首月 $5,之後 $10/月,提供充裕的使用限額,並可穩定存取領先的編碼模型。",
"go.hero.title": "低成本全民編碼模型",
"go.hero.body":
+22 -3
View File
@@ -486,8 +486,8 @@ body {
--bar-go: var(--color-go-2);
@media (max-width: 60rem) {
width: 50%;
max-width: 50%;
width: calc(100% - 32px);
max-width: calc(100% - 32px);
}
[data-slot="plot"] {
@@ -496,6 +496,10 @@ body {
width: 100%;
margin: 0 auto;
margin-left: -56px;
@media (max-width: 60rem) {
margin-left: 0;
}
}
[data-slot="ylabels"] {
@@ -525,7 +529,7 @@ body {
}
@media (max-width: 60rem) {
&:not([data-tick="1"], [data-tick="25"], [data-tick="100"], [data-tick="250"]) {
&:not([data-tick="1"], [data-tick="10"], [data-tick="50"], [data-tick="250"]) {
display: none;
}
}
@@ -572,6 +576,17 @@ body {
opacity: 0;
}
@media (max-width: 60rem) {
[data-item][data-edge] {
right: 0;
left: auto;
transform: translateY(-50%);
max-width: none;
padding-left: 16px;
background: linear-gradient(90deg, transparent, var(--color-background-weak) 12px);
}
}
[data-name] {
color: var(--color-text);
white-space: nowrap;
@@ -589,6 +604,10 @@ body {
font-weight: 400;
line-height: 1;
white-space: nowrap;
@media (max-width: 40rem) {
display: none;
}
}
}
+14 -4
View File
@@ -72,19 +72,27 @@ function LimitsGraph(props: { href: string }) {
{ id: "glm-5.2", name: "GLM-5.2", req: 880, d: "100ms" },
{ id: "minimax-m3", name: "MiniMax M3", req: 3200, d: "210ms" },
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", req: 3450, d: "270ms" },
{ id: "gpt-5.6-luna", name: "GPT 5.6 Luna (2x usage)", req: 4100, baseReq: 2050, d: "290ms" },
{ id: "gpt-5.6-luna", name: "GPT 5.6 Luna", req: 4100, baseReq: 2050, d: "290ms" },
{ id: "qwen3.7-plus", name: "Qwen3.7 Plus", req: 4300, d: "300ms" },
{ id: "hy3", name: "Hy3", req: 4300, d: "320ms" },
{ id: "mimo-v2.5", name: "MiMo-V2.5", req: 30100, d: "340ms" },
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", req: 31650, d: "340ms" },
{
id: "deepseek-v4-flash",
name: "DeepSeek V4 Flash",
req: 63300,
baseReq: 31650,
edge: true,
d: "340ms",
},
]
const w = 720
const w = 1040
const chartW = 720
const left = 40
const right = 60
const top = 18
const bottom = 44
const plot = w - left - right
const plot = chartW - left - right
const ratio = (n: number) => n / baseline
const rmax = Math.max(1, ...graph.map((m) => ratio(m.req)))
@@ -195,10 +203,12 @@ function LimitsGraph(props: { href: string }) {
data-item
data-kind="go"
data-model={m.id}
data-edge={"edge" in m ? "" : undefined}
style={{ "--x": px(x(ratio(m.req))), "--y": py(gy(i())), "--d": m.d } as any}
>
<span data-value>{m.req.toLocaleString()}</span>
<span data-name>{m.name}</span>
{m.baseReq && <span data-bonus>2x usage</span>}
</span>
)}
</For>
+12 -9
View File
@@ -577,29 +577,32 @@ export const filterCompactedEffect = Effect.fnUntraced(function* (sessionID: Ses
// filterCompacted reorders messages for model consumption
// ([compaction-user, summary, ...retained tail..., continue-user]), so array
// position is not chronological. Derive each binding by max id (MessageID
// is monotonic via MessageID.ascending) so a pre-compaction overflowing tail
// assistant doesn't get mistaken for the most recent turn. tasks are
// compaction/subtask parts attached to user messages newer than the latest
// finished assistant — i.e. unprocessed work.
// position is not chronological. IDs are only a deterministic tie-breaker
// because imported messages do not necessarily have monotonic IDs.
export function latest(msgs: WithParts[]) {
let user: User | undefined
let assistant: Assistant | undefined
let finished: Assistant | undefined
for (const msg of msgs) {
const info = msg.info
if (info.role === "user" && (!user || info.id > user.id)) user = info
if (info.role === "assistant" && (!assistant || info.id > assistant.id)) assistant = info
if (info.role === "assistant" && info.finish && (!finished || info.id > finished.id)) finished = info
if (info.role === "user" && isAfter(info, user)) user = info
if (info.role === "assistant" && isAfter(info, assistant)) assistant = info
if (info.role === "assistant" && info.finish && isAfter(info, finished)) finished = info
}
const tasks = msgs.flatMap((m) =>
finished && m.info.id <= finished.id
finished && !isAfter(m.info, finished)
? []
: m.parts.filter((p): p is CompactionPart | SubtaskPart => p.type === "compaction" || p.type === "subtask"),
)
return { user, assistant, finished, tasks }
}
function isAfter(info: Info, other?: Info) {
if (!other) return true
if (info.time.created !== other.time.created) return info.time.created > other.time.created
return info.id > other.id
}
export function fromError(
e: unknown,
ctx: { providerID: ProviderV2.ID; aborted?: boolean },
+1 -1
View File
@@ -1112,7 +1112,7 @@ const layer = Layer.effect(
lastAssistant?.finish &&
!["tool-calls"].includes(lastAssistant.finish) &&
!hasToolCalls &&
lastUser.id < lastAssistant.id
lastAssistant.parentID === lastUser.id
) {
const orphan = lastAssistantMsg?.parts.find(
(part): part is SessionV1.ToolPart => part.type === "tool" && isOrphanedInterruptedTool(part),
+5 -15
View File
@@ -71,7 +71,8 @@ const layer = Layer.effect(
if (session.revert?.snapshot) yield* snap.restore(session.revert.snapshot)
yield* snap.revert(patches)
if (rev.snapshot) rev.diff = yield* snap.diff(rev.snapshot)
const range = all.filter((msg) => msg.info.id >= rev.messageID)
const index = all.findIndex((msg) => msg.info.id === rev.messageID)
const range = index < 0 ? [] : all.slice(index)
const diffs = yield* summary.computeDiff({ messages: range })
yield* storage.write(["session_diff", input.sessionID], diffs).pipe(Effect.ignore)
yield* events.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs })
@@ -102,20 +103,9 @@ const layer = Layer.effect(
const sessionID = session.id
const msgs = yield* sessions.messages({ sessionID }).pipe(Effect.orDie)
const messageID = session.revert.messageID
const remove = [] as SessionV1.WithParts[]
let target: SessionV1.WithParts | undefined
for (const msg of msgs) {
if (msg.info.id < messageID) continue
if (msg.info.id > messageID) {
remove.push(msg)
continue
}
if (session.revert.partID) {
target = msg
continue
}
remove.push(msg)
}
const index = msgs.findIndex((msg) => msg.info.id === messageID)
const target = index < 0 ? undefined : msgs[index]
const remove = index < 0 ? [] : msgs.slice(index + (session.revert.partID ? 1 : 0))
for (const msg of remove) {
yield* sessions.removeMessage({ sessionID, messageID: msg.info.id })
}
+2 -2
View File
@@ -703,9 +703,9 @@ const layer: Layer.Layer<
})
const msgs = yield* messages({ sessionID: input.sessionID })
const idMap = new Map<string, MessageID>()
const target = input.messageID ? msgs.findIndex((msg) => msg.info.id === input.messageID) : msgs.length
for (const msg of msgs) {
if (input.messageID && msg.info.id >= input.messageID) break
for (const msg of msgs.slice(0, target < 0 ? msgs.length : target)) {
const newID = MessageID.ascending()
idMap.set(msg.info.id, newID)
+6 -6
View File
@@ -6,7 +6,6 @@ import type { Agent } from "../agent/agent"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { evaluate } from "@/permission/evaluate"
import { Config } from "@/config/config"
import { Identifier } from "../id/id"
import { ToolID } from "./schema"
import { TRUNCATION_DIR } from "./truncation-dir"
@@ -52,16 +51,17 @@ const layer = Layer.effect(
const fs = yield* FSUtil.Service
const cleanup = Effect.fn("Truncate.cleanup")(function* () {
const cutoff = Identifier.timestamp(
Identifier.create("tool", "ascending", Date.now() - Duration.toMillis(RETENTION)),
)
const cutoff = Date.now() - Duration.toMillis(RETENTION)
const entries = yield* fs.readDirectory(TRUNCATION_DIR).pipe(
Effect.map((all) => all.filter((name) => name.startsWith("tool_"))),
Effect.catch(() => Effect.succeed([])),
)
for (const entry of entries) {
if (Identifier.timestamp(entry) >= cutoff) continue
yield* fs.remove(path.join(TRUNCATION_DIR, entry)).pipe(Effect.catch(() => Effect.void))
const file = path.join(TRUNCATION_DIR, entry)
const info = yield* fs.stat(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
const mtime = info && Option.getOrUndefined(info.mtime)
if (!mtime || mtime.getTime() >= cutoff) continue
yield* fs.remove(file).pipe(Effect.catch(() => Effect.void))
}
})
@@ -1611,6 +1611,44 @@ describe("session.message-v2.latest", () => {
] as SessionV1.Part[],
}
test("selects latest messages by creation time when IDs are nonmonotonic", () => {
const oldUser = { ...userInfo("msg_z_user"), time: { created: 100 } }
const newUser = { ...userInfo("msg_a_user"), time: { created: 200 } }
const oldAssistant = {
...assistantInfo("msg_z_assistant", oldUser.id),
time: { created: 300 },
finish: "stop",
} as SessionV1.Assistant
const newAssistant = {
...assistantInfo("msg_a_assistant", newUser.id),
time: { created: 400 },
finish: "stop",
} as SessionV1.Assistant
const state = MessageV2.latest([
{ info: newAssistant, parts: [] },
{ info: oldUser, parts: [] },
{ info: oldAssistant, parts: [] },
{ info: newUser, parts: [] },
])
expect(state.user?.id).toBe(newUser.id)
expect(state.assistant?.id).toBe(newAssistant.id)
expect(state.finished?.id).toBe(newAssistant.id)
})
test("uses ID as a deterministic tie-breaker for equal creation times", () => {
const lower = { ...userInfo("msg_a_user"), time: { created: 100 } }
const higher = { ...userInfo("msg_z_user"), time: { created: 100 } }
const state = MessageV2.latest([
{ info: higher, parts: [] },
{ info: lower, parts: [] },
])
expect(state.user?.id).toBe(higher.id)
})
// Regression for double auto-compaction. The reorder in filterCompacted
// (#27145) returns [compaction-user, summary, ...tail..., continue-user],
// so picking lastFinished by array position landed on the pre-compaction
@@ -1659,4 +1697,33 @@ describe("session.message-v2.latest", () => {
expect(state.tasks).toHaveLength(1)
expect(state.tasks[0]).toMatchObject({ type: "compaction", auto: true })
})
test("selects compaction and subtask work after the finished boundary by creation time", () => {
const finished = {
...assistantInfo("msg_z_finished", "msg_parent"),
time: { created: 200 },
finish: "stop",
} as SessionV1.Assistant
const oldTask: SessionV1.WithParts = {
info: { ...userInfo("msg_z_old"), time: { created: 100 } },
parts: [{ ...basePart("msg_z_old", "old"), type: "compaction", auto: true }] as SessionV1.Part[],
}
const newTask: SessionV1.WithParts = {
info: { ...userInfo("msg_a_new"), time: { created: 300 } },
parts: [
{
...basePart("msg_a_new", "new"),
type: "subtask",
prompt: "inspect",
description: "inspect ordering",
agent: "general",
},
] as SessionV1.Part[],
}
const state = MessageV2.latest([newTask, { info: finished, parts: [] }, oldTask])
expect(state.tasks).toHaveLength(1)
expect(state.tasks[0]).toMatchObject({ type: "subtask", prompt: "inspect" })
})
})
@@ -460,6 +460,46 @@ noLLMServer.instance(
{ config: cfg },
)
noLLMServer.instance(
"loop exits for a completed parent turn with nonmonotonic message IDs",
() =>
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const chat = yield* sessions.create({ title: "Pinned" })
const userID = MessageID.make("msg_z_user")
const assistantID = MessageID.make("msg_a_assistant")
yield* sessions.updateMessage({
id: userID,
role: "user",
sessionID: chat.id,
agent: "build",
model: ref,
time: { created: 100 },
})
yield* sessions.updateMessage({
id: assistantID,
role: "assistant",
parentID: userID,
sessionID: chat.id,
mode: "build",
agent: "build",
cost: 0,
path: { cwd: "/tmp", root: "/tmp" },
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
modelID: ref.modelID,
providerID: ref.providerID,
time: { created: 200, completed: 201 },
finish: "stop",
})
const result = yield* prompt.loop({ sessionID: chat.id })
expect(result.info.id).toBe(assistantID)
}),
{ config: cfg },
)
it.instance("loop exits without an LLM request for interrupted orphan tool calls", () =>
Effect.gen(function* () {
const { llm } = yield* useServerConfig(providerCfg)
@@ -35,6 +35,18 @@ const user = Effect.fn("test.user")(function* (sessionID: SessionID, agent = "de
})
})
const userAt = Effect.fn("test.userAt")(function* (sessionID: SessionID, id: string, created: number) {
const session = yield* Session.Service
return yield* session.updateMessage({
id: MessageID.make(id),
role: "user" as const,
sessionID,
agent: "default",
model: { providerID: ProviderV2.ID.make("openai"), modelID: ModelV2.ID.make("gpt-4") },
time: { created },
})
})
const assistant = Effect.fn("test.assistant")(function* (sessionID: SessionID, parentID: MessageID, dir: string) {
const session = yield* Session.Service
return yield* session.updateMessage({
@@ -426,6 +438,39 @@ describe("revert + compact workflow", () => {
),
)
it.live(
"reverts chronological suffixes on both sides of mixed message ID ordering",
provideTmpdirInstance(
() =>
Effect.gen(function* () {
const session = yield* Session.Service
const revert = yield* SessionRevert.Service
const ids = ["msg_z9-before", "msg_z1-before-wrap", "msg_a0-after-wrap", "msg_a1-after"]
const run = Effect.fn("test.mixedIDRevert")(function* (target: number) {
const info = yield* session.create({})
for (const [index, id] of ids.entries()) {
const message = yield* userAt(info.id, id, index + 1)
yield* text(info.id, message.id, id)
}
const reverted = yield* revert.revert({
sessionID: info.id,
messageID: MessageID.make(ids[target]!),
})
yield* revert.cleanup(reverted)
const remaining = yield* session.messages({ sessionID: info.id })
yield* session.remove(info.id)
return remaining.map((msg) => msg.info.time.created)
})
expect(yield* run(1)).toEqual([1])
expect(yield* run(2)).toEqual([1, 2])
}),
{ git: true },
),
)
it.live(
"cleanup is a no-op when session has no revert state",
provideTmpdirInstance(
@@ -238,6 +238,38 @@ describe("Session", () => {
}),
)
it.instance("forks the chronological prefix across mixed message ID ordering", () =>
Effect.gen(function* () {
const session = yield* SessionNs.Service
const created = yield* Effect.acquireRelease(session.create({}), (info) =>
session.remove(info.id).pipe(Effect.ignore),
)
const ids = ["msg_z9-before", "msg_z1-before-wrap", "msg_a0-after-wrap", "msg_a1-after"]
for (const [index, id] of ids.entries()) {
yield* session.updateMessage({
id: MessageID.make(id),
sessionID: created.id,
role: "user",
time: { created: index + 1 },
agent: "user",
model: { providerID: "test", modelID: "test" },
} as SessionV1.User)
}
const beforeWrap = yield* Effect.acquireRelease(
session.fork({ sessionID: created.id, messageID: MessageID.make(ids[1]!) }),
(info) => session.remove(info.id).pipe(Effect.ignore),
)
const afterWrap = yield* Effect.acquireRelease(
session.fork({ sessionID: created.id, messageID: MessageID.make(ids[2]!) }),
(info) => session.remove(info.id).pipe(Effect.ignore),
)
expect((yield* session.messages({ sessionID: beforeWrap.id })).map((msg) => msg.info.time.created)).toEqual([1])
expect((yield* session.messages({ sessionID: afterWrap.id })).map((msg) => msg.info.time.created)).toEqual([1, 2])
}),
)
it.instance("omits metadata when not provided", () =>
Effect.gen(function* () {
const session = yield* SessionNs.Service
@@ -242,18 +242,20 @@ describe("Truncate", () => {
describe("cleanup", () => {
const DAY_MS = 24 * 60 * 60 * 1000
it.live("deletes files older than 7 days and preserves recent files", () =>
it.live("uses file mtime when IDs wrap", () =>
Effect.gen(function* () {
const svc = yield* Truncate.Service
const fs = yield* FileSystem.FileSystem
yield* fs.makeDirectory(Truncate.DIR, { recursive: true })
const old = path.join(Truncate.DIR, Identifier.create("tool", "ascending", Date.now() - 10 * DAY_MS))
const recent = path.join(Truncate.DIR, Identifier.create("tool", "ascending", Date.now() - 3 * DAY_MS))
const old = path.join(Truncate.DIR, Identifier.create("tool", "ascending", 2 ** 36 - 1))
const recent = path.join(Truncate.DIR, Identifier.create("tool", "ascending", 2 ** 36 + 1))
yield* writeFileStringScoped(old, "old content")
yield* writeFileStringScoped(recent, "recent content")
yield* fs.utimes(old, new Date(), new Date(Date.now() - 10 * DAY_MS))
yield* fs.utimes(recent, new Date(), new Date(Date.now() - 3 * DAY_MS))
yield* svc.cleanup()
expect(yield* fs.exists(old)).toBe(false)
@@ -251,6 +251,7 @@ export function Prompt(props: PromptProps) {
if (!input || input.isDestroyed) return
if (props.disabled) input.cursorColor = theme.backgroundElement
if (!props.disabled) input.cursorColor = theme.text
if (tuiConfig.cursor) input.cursorStyle = tuiConfig.cursor
})
const lastUserMessage = createMemo(() => {
@@ -1431,11 +1432,13 @@ export function Prompt(props: PromptProps) {
// setTimeout is a workaround and needs to be addressed properly
if (!input || input.isDestroyed) return
input.cursorColor = theme.text
if (tuiConfig.cursor) input.cursorStyle = tuiConfig.cursor
}, 0)
}}
onMouseDown={(r: MouseEvent) => r.target?.focus()}
focusedBackgroundColor={theme.backgroundElement}
cursorColor={props.disabled ? theme.backgroundElement : theme.text}
cursorStyle={tuiConfig.cursor}
syntaxStyle={syntax()}
/>
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
+20 -1
View File
@@ -30,6 +30,14 @@ export const ScrollAcceleration = Schema.Struct({
export const DiffStyle = Schema.Literals(["auto", "stacked"]).annotate({
description: "Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column",
})
export const Cursor = Schema.Struct({
style: Schema.optional(Schema.Literals(["block", "underline", "line", "default"])).annotate({
description: "Cursor shape. Use 'default' to preserve the terminal setting",
}),
blinking: Schema.optional(Schema.Boolean).annotate({
description: "Whether the cursor blinks. Has no effect when style is 'default'",
}),
}).annotate({ description: "Terminal cursor settings" })
export const AttentionSounds = Schema.Record(AttentionSoundName, Schema.optionalKey(Schema.String))
export type AttentionSoundPaths = Schema.Schema.Type<typeof AttentionSounds>
@@ -62,11 +70,12 @@ export const Info = Schema.Struct({
scroll_speed: Schema.optional(ScrollSpeed).annotate({ description: "TUI scroll speed" }),
scroll_acceleration: Schema.optional(ScrollAcceleration),
diff_style: Schema.optional(DiffStyle),
cursor: Schema.optional(Cursor),
mouse: Schema.optional(Schema.Boolean).annotate({ description: "Enable or disable mouse capture (default: true)" }),
})
export type Info = Schema.Schema.Type<typeof Info>
export type Resolved = Omit<Info, "attention" | "keybinds" | "leader_timeout" | "mouse"> & {
export type Resolved = Omit<Info, "attention" | "keybinds" | "leader_timeout" | "mouse" | "cursor"> & {
attention: {
enabled: boolean
notifications: boolean
@@ -78,6 +87,10 @@ export type Resolved = Omit<Info, "attention" | "keybinds" | "leader_timeout" |
keybinds: TuiKeybind.BindingLookupView
leader_timeout: number
mouse: boolean
cursor?: {
style: "block" | "underline" | "line" | "default"
blinking: boolean
}
}
export const ResolveOptions = Schema.Struct({
@@ -113,6 +126,12 @@ export function resolve(input: Info, options: ResolveOptions): Resolved {
}),
leader_timeout: input.leader_timeout ?? LeaderTimeoutDefault,
mouse: input.mouse ?? true,
cursor: input.cursor
? {
style: input.cursor.style ?? "block",
blinking: input.cursor.blinking ?? true,
}
: undefined,
}
}
+14 -7
View File
@@ -51,6 +51,12 @@ function search<T>(items: T[], target: string, key: (item: T) => string) {
return { found: false, index: left }
}
function compareMessage(a: Message, b: Message) {
return a.time.created - b.time.created || a.id.localeCompare(b.id)
}
const messageKey = (message: Message) => message.time.created + message.id
export const {
context: SyncContext,
use: useSync,
@@ -319,7 +325,7 @@ export const {
setStore("message", event.properties.info.sessionID, [event.properties.info])
break
}
const result = search(messages, event.properties.info.id, (m) => m.id)
const result = search(messages, messageKey(event.properties.info), messageKey)
if (result.found) {
setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info))
break
@@ -355,13 +361,13 @@ export const {
case "message.removed": {
touchMessage(event.properties.sessionID, event.properties.messageID)
const messages = store.message[event.properties.sessionID]
const result = search(messages, event.properties.messageID, (m) => m.id)
if (result.found) {
const index = messages.findIndex((message) => message.id === event.properties.messageID)
if (index !== -1) {
setStore(
"message",
event.properties.sessionID,
produce((draft) => {
draft.splice(result.index, 1)
draft.splice(index, 1)
}),
)
}
@@ -374,7 +380,7 @@ export const {
setStore("part", event.properties.part.messageID, [event.properties.part])
break
}
const result = search(parts, event.properties.part.id, (p) => p.id)
const result = search(parts, event.properties.part.id, (part) => part.id)
if (result.found) {
setStore("part", event.properties.part.messageID, result.index, reconcile(event.properties.part))
break
@@ -392,7 +398,7 @@ export const {
case "message.part.delta": {
const parts = store.part[event.properties.messageID]
if (!parts) break
const result = search(parts, event.properties.partID, (p) => p.id)
const result = search(parts, event.properties.partID, (part) => part.id)
if (!result.found) break
touchPart(event.properties.sessionID, event.properties.partID)
setStore(
@@ -411,7 +417,7 @@ export const {
case "message.part.removed": {
touchPart(event.properties.sessionID, event.properties.partID)
const parts = store.part[event.properties.messageID]
const result = search(parts, event.properties.partID, (p) => p.id)
const result = search(parts, event.properties.partID, (part) => part.id)
if (result.found) {
setStore(
"part",
@@ -615,6 +621,7 @@ export const {
(message) => tracker.messages.has(message.id) && !infos.some((item) => item.id === message.id),
),
)
infos.sort(compareMessage)
const removed = infos.slice(0, -100)
const visible = infos.slice(-100)
const visibleIDs = new Set(visible.map((message) => message.id))
+28 -13
View File
@@ -211,6 +211,12 @@ export function Session() {
.toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
})
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
const messagesBeforeRevert = () => {
const messageID = session()?.revert?.messageID
if (!messageID) return messages()
const index = messages().findIndex((message) => message.id === messageID)
return index === -1 ? messages() : messages().slice(0, index)
}
const foregroundTasks = createMemo(() =>
sync.data.capabilities.experimentalBackgroundSubagents
? messages().flatMap((message) =>
@@ -236,9 +242,11 @@ export function Session() {
const disabled = createMemo(() => permissions().length > 0 || questions().length > 0)
const pending = createMemo(() => {
const completed = messages().findLast((x) => x.role === "assistant" && x.time.completed)?.id
return messages().findLast((x) => x.role === "assistant" && !x.time.completed && (!completed || x.id > completed))
?.id
const completed = messages().findLastIndex((message) => message.role === "assistant" && message.time.completed)
const pending = messages().findLastIndex(
(message, index) => index > completed && message.role === "assistant" && !message.time.completed,
)
return pending === -1 ? undefined : pending
})
const lastAssistant = createMemo(() => {
@@ -610,8 +618,7 @@ export function Session() {
run: async () => {
const status = sync.data.session_status?.[route.sessionID]
if (status?.type !== "idle") await sdk.client.session.abort({ sessionID: route.sessionID }).catch(() => {})
const revert = session()?.revert?.messageID
const message = messages().findLast((x) => (!revert || x.id < revert) && x.role === "user")
const message = messagesBeforeRevert().findLast((item) => item.role === "user")
if (!message) return
void sdk.client.session
.revert({
@@ -872,10 +879,7 @@ export function Session() {
value: "messages.copy",
category: "Session",
run: () => {
const revertID = session()?.revert?.messageID
const lastAssistantMessage = messages().findLast(
(msg) => msg.role === "assistant" && (!revertID || msg.id < revertID),
)
const lastAssistantMessage = messagesBeforeRevert().findLast((message) => message.role === "assistant")
if (!lastAssistantMessage) {
toast.show({ message: "No assistant messages found", variant: "error" })
dialog.clear()
@@ -1118,13 +1122,22 @@ export function Session() {
const revertInfo = createMemo(() => session()?.revert)
const revertMessageID = createMemo(() => revertInfo()?.messageID)
const revertMessageIndex = createMemo(() => {
const messageID = revertMessageID()
if (!messageID) return -1
return messages().findIndex((message) => message.id === messageID)
})
const revertDiffFiles = createMemo(() => getRevertDiffFiles(revertInfo()?.diff ?? ""))
const revertRevertedMessages = createMemo(() => {
const messageID = revertMessageID()
if (!messageID) return []
return messages().filter((x) => x.id >= messageID && x.role === "user")
const index = revertMessageIndex()
if (index === -1) return []
return messages()
.slice(index)
.filter((message) => message.role === "user")
})
const revert = createMemo(() => {
@@ -1247,7 +1260,9 @@ export function Session() {
)
})()}
</Match>
<Match when={revert()?.messageID && message.id >= revert()!.messageID}>
<Match
when={revert()?.messageID && revertMessageIndex() !== -1 && index() >= revertMessageIndex()}
>
<></>
</Match>
<Match when={message.role === "user"}>
@@ -1352,7 +1367,7 @@ function UserMessage(props: {
parts: Part[]
onMouseUp: () => void
index: number
pending?: string
pending?: number
}) {
const ctx = use()
const local = useLocal()
@@ -1370,7 +1385,7 @@ function UserMessage(props: {
const files = createMemo(() => props.parts.flatMap((x) => (x.type === "file" ? [x] : [])))
const { theme } = useTheme()
const [hover, setHover] = createSignal(false)
const queued = createMemo(() => props.pending && props.message.id > props.pending)
const queued = createMemo(() => props.pending !== undefined && props.index > props.pending)
const color = createMemo(() => local.agent.color(props.message.agent))
const queuedFg = createMemo(() => selectedForeground(theme, color()))
const metadataVisible = createMemo(() => queued() || ctx.showTimestamps())
@@ -507,6 +507,7 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: (
textColor={theme.text}
focusedTextColor={theme.text}
cursorColor={theme.primary}
cursorStyle={tuiConfig.cursor}
/>
<box flexDirection="row" gap={2} flexShrink={0}>
<text fg={theme.text}>
@@ -441,6 +441,7 @@ export function QuestionPrompt(props: { request: QuestionRequest; directory?: st
textColor={theme.text}
focusedTextColor={theme.text}
cursorColor={theme.primary}
cursorStyle={tuiConfig.cursor}
/>
</box>
</Show>
@@ -3,6 +3,7 @@ import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
import { createStore } from "solid-js/store"
import { onMount, Show } from "solid-js"
import { useTuiConfig } from "../config"
import { useBindings } from "../keymap"
export type DialogExportOptionsProps = {
@@ -24,6 +25,7 @@ export type DialogExportOptionsProps = {
export function DialogExportOptions(props: DialogExportOptionsProps) {
const dialog = useDialog()
const { theme } = useTheme()
const tuiConfig = useTuiConfig()
let textarea: TextareaRenderable
const [store, setStore] = createStore({
thinking: props.defaultThinking,
@@ -116,6 +118,7 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
textColor={theme.text}
focusedTextColor={theme.text}
cursorColor={theme.text}
cursorStyle={tuiConfig.cursor}
/>
</box>
<box flexDirection="column">
+1
View File
@@ -96,6 +96,7 @@ export function DialogPrompt(props: DialogPromptProps) {
textColor={props.busy ? theme.textMuted : theme.text}
focusedTextColor={props.busy ? theme.textMuted : theme.text}
cursorColor={props.busy ? theme.backgroundElement : theme.text}
cursorStyle={tuiConfig.cursor}
/>
<Show when={props.busy}>
<Spinner color={theme.textMuted}>{props.busyText ?? "Working..."}</Spinner>
+1
View File
@@ -579,6 +579,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
}}
focusedBackgroundColor={theme.backgroundPanel}
cursorColor={theme.primary}
cursorStyle={tuiConfig.cursor}
focusedTextColor={theme.textMuted}
ref={(r) => {
input = r
+3 -1
View File
@@ -35,7 +35,9 @@ export function formatTranscript(
transcript += `**Updated:** ${new Date(session.time.updated).toLocaleString()}\n\n`
transcript += `---\n\n`
for (const msg of messages) {
for (const msg of messages.toSorted(
(a, b) => a.info.time.created - b.info.time.created || a.info.id.localeCompare(b.info.id),
)) {
transcript += formatMessage(msg.info, msg.parts, options, providers)
transcript += `---\n\n`
}
@@ -33,6 +33,29 @@ function global(payload: GlobalEvent["payload"]): GlobalEvent {
return { directory: "/tmp/other", project: "proj_test", payload }
}
test("live messages use creation time with an ID tie-break", async () => {
await using tmp = await tmpdir()
await Bun.write(`${tmp.path}/kv.json`, "{}")
const { app, emit, sync } = await mount(undefined, tmp.path)
const messages = [
{ ...assistant, id: "msg_a", time: { created: 30, completed: 31 } },
{ ...assistant, id: "msg_z", time: { created: 10, completed: 11 } },
{ ...assistant, id: "msg_m", time: { created: 20, completed: 21 } },
{ ...assistant, id: "msg_b", time: { created: 20, completed: 21 } },
]
try {
for (const info of messages) {
emit(global({ id: `evt_${info.id}`, type: "message.updated", properties: { sessionID, info } }))
}
await wait(() => sync.data.message[sessionID]?.length === messages.length)
expect(sync.data.message[sessionID].map((message) => message.id)).toEqual(["msg_z", "msg_b", "msg_m", "msg_a"])
} finally {
app.renderer.destroy()
}
})
test("stale session hydration does not overwrite live message parts", async () => {
await using tmp = await tmpdir()
await Bun.write(`${tmp.path}/kv.json`, "{}")
+17 -2
View File
@@ -31,13 +31,20 @@ test("validates config constraints", () => {
prompt: { max_height: 10, max_width: "auto" },
scroll_speed: 0.001,
diff_style: "stacked",
cursor: { blinking: false },
plugin: ["example-plugin"],
}),
).toMatchObject({ leader_timeout: 250, attention: { volume: 1 }, diff_style: "stacked" })
).toMatchObject({
leader_timeout: 250,
attention: { volume: 1 },
diff_style: "stacked",
cursor: { blinking: false },
})
expect(() => decodeInfo({ leader_timeout: 0 })).toThrow()
expect(() => decodeInfo({ attention: { volume: 1.1 } })).toThrow()
expect(() => decodeInfo({ prompt: { max_width: 0 } })).toThrow()
expect(() => decodeInfo({ scroll_speed: 0 })).toThrow()
expect(() => decodeInfo({ cursor: { style: "beam" } })).toThrow()
expect(decodeInfo({ attention: { sounds: { unknown: "sound.wav" } } })).toEqual({ attention: { sounds: {} } })
})
@@ -56,6 +63,7 @@ test("resolves host-neutral defaults", () => {
expect(config.mouse).toBe(true)
expect(config.keybinds.has("terminal.suspend")).toBe(true)
expect(config.keybinds.has("session.list")).toBe(true)
expect(config.cursor).toBeUndefined()
})
test("resolves overrides without mutating input", () => {
@@ -72,10 +80,17 @@ test("resolves overrides without mutating input", () => {
sounds: { question: "/sounds/question.wav" },
},
keybinds: { session_list: "ctrl+l" },
cursor: { blinking: false },
}
const config = resolve(input, { terminalSuspend: true })
expect(config).toMatchObject({ theme: "custom", mouse: false, leader_timeout: 750, attention: input.attention })
expect(config).toMatchObject({
theme: "custom",
mouse: false,
leader_timeout: 750,
attention: input.attention,
cursor: { style: "block", blinking: false },
})
expect(config.keybinds.get("session.list")).toHaveLength(1)
expect(input.keybinds).toEqual({ session_list: "ctrl+l" })
})
+28
View File
@@ -349,6 +349,34 @@ describe("transcript", () => {
expect(result).toContain("---")
})
test("orders messages by creation time and preserves part order", () => {
const message = (id: string, created: number, parts: string[]) => ({
info: {
id,
sessionID: "ses_abc123",
role: "user" as const,
agent: "build",
model: { providerID: "anthropic", modelID: "claude" },
time: { created },
},
parts: parts.map((text, index) => ({
id: `part_${parts.length - index}`,
sessionID: "ses_abc123",
messageID: id,
type: "text" as const,
text,
})),
})
const result = formatTranscript(
{ id: "ses_abc123", title: "Order", time: { created: 1, updated: 2 } },
[message("msg_a", 30, ["third"]), message("msg_z", 10, ["first", "second"])],
{ thinking: false, toolDetails: false, assistantMetadata: false },
)
expect(result.indexOf("first")).toBeLessThan(result.indexOf("second"))
expect(result.indexOf("second")).toBeLessThan(result.indexOf("third"))
})
test("falls back to raw model id when provider data is missing", () => {
const session = {
id: "ses_abc123",
+3 -1
View File
@@ -75,7 +75,9 @@ export default function Share(props: {
},
messages: {},
})
const messages = createMemo(() => Object.values(store.messages).toSorted((a, b) => a.id?.localeCompare(b.id)))
const messages = createMemo(() =>
Object.values(store.messages).toSorted((a, b) => a.time.created - b.time.created || a.id.localeCompare(b.id)),
)
const [connectionStatus, setConnectionStatus] = createSignal<[Status, string?]>(["disconnected"])
onMount(() => {
+6
View File
@@ -273,6 +273,10 @@ Use a dedicated `tui.json` (or `tui.jsonc`) file for TUI-specific settings.
"enabled": true
},
"diff_style": "auto",
"cursor": {
"style": "block",
"blinking": true
},
"mouse": true,
"attention": {
"enabled": true,
@@ -285,6 +289,8 @@ Use a dedicated `tui.json` (or `tui.jsonc`) file for TUI-specific settings.
Use `OPENCODE_TUI_CONFIG` to point to a custom TUI config file.
When `cursor.style` is `"default"`, the terminal default cursor is restored, so `cursor.blinking` has no effect.
Set `attention.enabled` to turn on TUI desktop notifications and sounds. See [TUI attention](/docs/tui#attention).
Legacy `theme`, `keybinds`, and `tui` keys in `opencode.json` are deprecated and automatically migrated when possible.
+5
View File
@@ -369,6 +369,10 @@ You can customize TUI behavior through `tui.json` (or `tui.jsonc`).
"enabled": false
},
"diff_style": "auto",
"cursor": {
"style": "block",
"blinking": true
},
"mouse": true,
"attention": {
"enabled": true,
@@ -395,6 +399,7 @@ This is separate from `opencode.json`, which configures server/runtime behavior.
- `scroll_acceleration.enabled` - Enable macOS-style scroll acceleration for smooth, natural scrolling. When enabled, scroll speed increases with rapid scrolling gestures and stays precise for slower movements. **This setting takes precedence over `scroll_speed` and overrides it when enabled.**
- `scroll_speed` - Controls how fast the TUI scrolls when using scroll commands (minimum: `0.001`, supports decimal values). Defaults to `3`. **Note: This is ignored if `scroll_acceleration.enabled` is set to `true`.**
- `diff_style` - Controls diff rendering. `"auto"` adapts to terminal width, `"stacked"` always shows a single-column layout.
- `cursor` - Controls the terminal cursor in TUI input fields. `style` defaults to `"block"`, can be `"underline"`, `"line"`, or `"default"`; `blinking` defaults to `true`. When `style` is `"default"`, the terminal default cursor is restored, so `blinking` has no effect.
- `mouse` - Enable or disable mouse capture in the TUI (default: `true`). When disabled, the terminal's native mouse selection/scrolling behavior is preserved.
- `attention` - Configures TUI desktop notifications and sounds. Disabled by default.