mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-07 01:29:44 -04:00
Compare commits
10 Commits
replay-time-order
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 4abddbbfdd | |||
| 5aa5cb3523 | |||
| 9113255114 | |||
| 23cc677108 | |||
| 28bcc0e4f4 | |||
| 8bf5062b89 | |||
| 20750c332e | |||
| db581e47a3 | |||
| a54a693af2 | |||
| d468201952 |
@@ -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 () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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": "نماذج برمجة منخفضة التكلفة للجميع",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 d’utilisation 2x supérieures pour une durée limitée",
|
||||
"go.banner.text": "DeepSeek V4 Flash bénéficie de limites d’utilisation 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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "すべての人のための低価格なコーディングモデル",
|
||||
|
||||
@@ -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": "모두를 위한 저비용 코딩 모델",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Недорогие модели для кодинга для всех",
|
||||
|
||||
@@ -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": "โมเดลเขียนโค้ดราคาประหยัดสำหรับทุกคน",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Недорогі моделі кодування для всіх",
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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,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">
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`, "{}")
|
||||
|
||||
@@ -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" })
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user