diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index b09880d2b0b8..7b45c9ea5a0e 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -417,7 +417,9 @@ describe("prompt submit worktree selection", () => { model: { providerID: "provider", modelID: "model", variant: "high" }, }, }) - expect((promptInputs[0] as { id?: string }).id).toStartWith("msg_") + // ID minting is delegated to the data layer, which mints a client ID when + // none is supplied (covered by the data-layer tests in packages/tui). + expect((promptInputs[0] as { id?: string }).id).toBeUndefined() }) test("restores the prompt when sending fails", async () => { diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index e5a1541ea020..e96556c66f42 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -11,7 +11,7 @@ import { usePermission } from "@/context/permission" import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt" import { useWorkspaceLocation } from "@/context/location" import { useServerSDK, type ServerSDK } from "@/context/server-sdk" -import { Identifier } from "@/utils/id" +import { SessionMessage } from "@opencode-ai/schema/session-message" import { getDirectory } from "@opencode-ai/util/path" import { buildPromptRequest } from "./build-prompt-request" import { setCursorPosition } from "./editor-dom" @@ -40,7 +40,6 @@ type FollowupSendInput = { data: Data session: Accessor<{ agent?: string; model?: { id: string; providerID: string; variant?: string } } | undefined> draft: FollowupDraft - messageID?: string optimisticBusy?: boolean } @@ -69,10 +68,9 @@ export async function sendFollowupDraft(input: FollowupSendInput) { ) { setBusy() try { - const messageID = Identifier.ascending("message") await input.api.command({ sessionID: input.draft.sessionID, - id: messageID, + id: SessionMessage.ID.create(), command: cmd, arguments: tail.join(" "), agent: input.draft.agent, @@ -95,7 +93,6 @@ export async function sendFollowupDraft(input: FollowupSendInput) { } } - const messageID = input.messageID ?? Identifier.ascending("message") const encodedImages = await Promise.all( images.map(async (attachment) => ({ ...attachment, @@ -132,11 +129,10 @@ export async function sendFollowupDraft(input: FollowupSendInput) { }) } - // The data layer admits optimistically: the prompt renders immediately - // and rolls back if the server rejects it. + // The data layer admits optimistically under a client-minted ID: the + // prompt renders immediately and rolls back if the server rejects it. await input.data.session.prompt({ sessionID: input.draft.sessionID, - id: messageID, text: request.text, files: request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })), agents: request.agents, @@ -448,12 +444,11 @@ export function createPromptSubmit(input: PromptSubmitInput) { ?.find((command) => command.name === commandName) if (customCommand) { clearInput() - const messageID = Identifier.ascending("message") submissionData.session.setStatus(session.id, "running") void submissionServerSDK.api.session .command({ sessionID: session.id, - id: messageID, + id: SessionMessage.ID.create(), command: commandName, arguments: args.join(" "), agent, @@ -478,7 +473,6 @@ export function createPromptSubmit(input: PromptSubmitInput) { } const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim()) - const messageID = Identifier.ascending("message") for (const item of commentItems) submission.target().context.remove(item.key) clearInput() @@ -488,7 +482,6 @@ export function createPromptSubmit(input: PromptSubmitInput) { data: submissionData, session: () => session, draft, - messageID, optimisticBusy: sessionDirectory === projectDirectory, }).catch((err) => { if (sessionDirectory === projectDirectory) { diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 1f7402427bf6..425e96372d0d 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -90,7 +90,7 @@ import { TerminalPanelV2 } from "@/pages/session/terminal-panel-v2" import { useComposerCommands } from "@/pages/session/use-composer-commands" import { useSessionCommands } from "@/pages/session/use-session-commands" import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll" -import { Identifier } from "@/utils/id" +import { SessionMessage } from "@opencode-ai/schema/session-message" import { Persist, persisted } from "@/utils/persist" import { formatServerError, isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors" import { requireServerKey, sessionHref } from "@/utils/session-route" @@ -1564,7 +1564,7 @@ export default function Page() { const queueFollowup = (draft: FollowupDraft) => { setFollowup("items", draft.sessionID, (items) => [ ...(items ?? []), - { id: Identifier.ascending("message"), ...draft }, + { id: SessionMessage.ID.create(), ...draft }, ]) setFollowup("failed", draft.sessionID, undefined) setFollowup("paused", draft.sessionID, undefined) diff --git a/packages/app/src/utils/id.ts b/packages/app/src/utils/id.ts deleted file mode 100644 index dba7a8d95135..000000000000 --- a/packages/app/src/utils/id.ts +++ /dev/null @@ -1,93 +0,0 @@ -const prefixes = { - session: "ses", - message: "msg", - permission: "per", - user: "usr", - part: "prt", - pty: "pty", -} as const - -const LENGTH = 26 -let lastTimestamp = 0 -let counter = 0 - -type Prefix = keyof typeof prefixes -export namespace Identifier { - export function ascending(prefix: Prefix, given?: string) { - return generateID(prefix, false, given) - } - - export function descending(prefix: Prefix, given?: string) { - return generateID(prefix, true, given) - } -} - -function generateID(prefix: Prefix, descending: boolean, given?: string): string { - if (!given) { - return create(prefix, descending) - } - - if (!given.startsWith(prefixes[prefix])) { - throw new Error(`ID ${given} does not start with ${prefixes[prefix]}`) - } - - return given -} - -function create(prefix: Prefix, descending: boolean, timestamp?: number): string { - const currentTimestamp = timestamp ?? Date.now() - - if (currentTimestamp !== lastTimestamp) { - lastTimestamp = currentTimestamp - counter = 0 - } - - counter += 1 - - let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter) - - if (descending) { - now = ~now - } - - const timeBytes = new Uint8Array(6) - for (let i = 0; i < 6; i += 1) { - timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff)) - } - - return prefixes[prefix] + "_" + bytesToHex(timeBytes) + randomBase62(LENGTH - 12) -} - -function bytesToHex(bytes: Uint8Array): string { - let hex = "" - for (let i = 0; i < bytes.length; i += 1) { - hex += bytes[i].toString(16).padStart(2, "0") - } - return hex -} - -function randomBase62(length: number): string { - const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" - const bytes = getRandomBytes(length) - let result = "" - for (let i = 0; i < length; i += 1) { - result += chars[bytes[i] % 62] - } - return result -} - -function getRandomBytes(length: number): Uint8Array { - const bytes = new Uint8Array(length) - const cryptoObj = typeof globalThis !== "undefined" ? globalThis.crypto : undefined - - if (cryptoObj && typeof cryptoObj.getRandomValues === "function") { - cryptoObj.getRandomValues(bytes) - return bytes - } - - for (let i = 0; i < length; i += 1) { - bytes[i] = Math.floor(Math.random() * 256) - } - - return bytes -}