mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-05 06:56:16 +00:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f06708d0e | ||
|
|
e86ec98711 | ||
|
|
c542a43503 | ||
|
|
2db9c2e1b3 | ||
|
|
3a861ff8b0 | ||
|
|
22803892bc | ||
|
|
69ba898a7b | ||
|
|
375ff827aa | ||
|
|
ed747091e9 | ||
|
|
44193bd71f | ||
|
|
238d4cf5ca | ||
|
|
3e77d5c170 | ||
|
|
eaf80d99e5 | ||
|
|
0a46301e36 |
No files matched your search
@@ -42,6 +42,7 @@ const RESPECTS_INLINE_HINTS = new Set([
|
||||
"alibaba-messages",
|
||||
"anthropic-messages",
|
||||
"anthropic-compatible-messages",
|
||||
"bedrock-mantle-messages",
|
||||
"cloudflare-ai-gateway-messages",
|
||||
"google-vertex-messages",
|
||||
"meta-messages",
|
||||
|
||||
@@ -584,7 +584,7 @@ const serverToolResultType = (name: string): AnthropicServerToolResultType | und
|
||||
return undefined
|
||||
}
|
||||
|
||||
const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult")(function* (
|
||||
const lowerServerToolResult = Effect.fnUntraced(function* (
|
||||
part: ToolResultPart,
|
||||
providerMetadataKey: string,
|
||||
) {
|
||||
@@ -657,7 +657,7 @@ const citationsFromMetadata = (metadata: MediaPart["metadata"]): AnthropicDocume
|
||||
|
||||
const isHttpUrl = (value: string) => /^https?:\/\//i.test(value.trim())
|
||||
|
||||
const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (
|
||||
const lowerMedia = Effect.fnUntraced(function* (
|
||||
part: MediaPart,
|
||||
breakpoints?: Cache.Breakpoints,
|
||||
) {
|
||||
@@ -847,7 +847,7 @@ const splitsLocalToolResults = (messages: LLMRequest["messages"], index: number)
|
||||
return pending.size > 0
|
||||
}
|
||||
|
||||
const lowerNativeSystemUpdate = Effect.fn("AnthropicMessages.lowerNativeSystemUpdate")(function* (
|
||||
const lowerNativeSystemUpdate = Effect.fnUntraced(function* (
|
||||
message: LLMRequest["messages"][number],
|
||||
breakpoints: Cache.Breakpoints,
|
||||
) {
|
||||
@@ -862,7 +862,7 @@ const lowerNativeSystemUpdate = Effect.fn("AnthropicMessages.lowerNativeSystemUp
|
||||
}
|
||||
})
|
||||
|
||||
const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||
const lowerMessages = Effect.fnUntraced(function* (
|
||||
request: LLMRequest,
|
||||
breakpoints: Cache.Breakpoints,
|
||||
) {
|
||||
@@ -989,6 +989,9 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||
return messages
|
||||
})
|
||||
|
||||
// TODO: Move per-model capability heuristics (`supportsEffortUpdates`, `supportsNativeSystemUpdates`,
|
||||
// `supportsThinkingBlockBinding`) into explicit model/provider `compatibility` metadata so the protocol
|
||||
// only reads `request.model.compatibility`.
|
||||
// Per-turn effort started with Claude Opus 5 and every Claude 5.1 model; later versions of any family inherit it.
|
||||
const supportsEffortUpdates = (model: LLMRequest["model"]) => {
|
||||
const override = model.compatibility?.supportsEffortUpdates
|
||||
@@ -1300,7 +1303,7 @@ const onContentBlockStart = (
|
||||
return [{ ...state, lifecycle: Lifecycle.stepStart(state.lifecycle, events) }, [...events, result]]
|
||||
}
|
||||
|
||||
const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(function* (
|
||||
const onContentBlockDelta = Effect.fnUntraced(function* (
|
||||
state: ParserState,
|
||||
event: AnthropicEvent & { readonly delta: AnthropicStreamDelta },
|
||||
) {
|
||||
@@ -1368,7 +1371,7 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
|
||||
return [state, NO_EVENTS] satisfies StepResult
|
||||
})
|
||||
|
||||
const onContentBlockStop = Effect.fn("AnthropicMessages.onContentBlockStop")(function* (
|
||||
const onContentBlockStop = Effect.fnUntraced(function* (
|
||||
state: ParserState,
|
||||
event: AnthropicEvent,
|
||||
) {
|
||||
@@ -1439,7 +1442,7 @@ const onMessageDelta = (
|
||||
]
|
||||
}
|
||||
|
||||
const onMessageStop = Effect.fn("AnthropicMessages.onMessageStop")(function* (state: ParserState) {
|
||||
const onMessageStop = Effect.fnUntraced(function* (state: ParserState) {
|
||||
if (Object.keys(state.compactions).length)
|
||||
return yield* ProviderShared.eventError(ADAPTER, "Response ended with an incomplete compaction block")
|
||||
const result = yield* ToolStream.finishAll(ADAPTER, state.tools)
|
||||
|
||||
@@ -285,7 +285,7 @@ const lowerToolCall = (part: ToolCallPart, normalizeID: (id: string) => string):
|
||||
},
|
||||
})
|
||||
|
||||
const lowerToolResultContent = Effect.fn("BedrockConverse.lowerToolResultContent")(function* (
|
||||
const lowerToolResultContent = Effect.fnUntraced(function* (
|
||||
part: ToolResultPart,
|
||||
documentNames: Set<string>,
|
||||
) {
|
||||
@@ -305,7 +305,7 @@ const lowerToolResultContent = Effect.fn("BedrockConverse.lowerToolResultContent
|
||||
return content
|
||||
})
|
||||
|
||||
const lowerToolResult = Effect.fn("BedrockConverse.lowerToolResult")(function* (
|
||||
const lowerToolResult = Effect.fnUntraced(function* (
|
||||
part: ToolResultPart,
|
||||
documentNames: Set<string>,
|
||||
normalizeID: (id: string) => string,
|
||||
@@ -322,7 +322,7 @@ const lowerToolResult = Effect.fn("BedrockConverse.lowerToolResult")(function* (
|
||||
// Keep Claude and Nova tool-result images inline; put other models' images beside the result.
|
||||
const keepToolImagesInline = (id: string) => id.includes("anthropic.claude-") || id.includes("amazon.nova-")
|
||||
|
||||
const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
|
||||
const lowerMessages = Effect.fnUntraced(function* (
|
||||
request: LLMRequest,
|
||||
breakpoints: BedrockCache.Breakpoints,
|
||||
) {
|
||||
|
||||
@@ -75,7 +75,7 @@ const usesSse = (request: MediaProtocol.Addressed<Request>) => request.mode ===
|
||||
|
||||
const CONTAINERS: Readonly<Record<string, "raw" | "wav" | "mp3">> = { pcm: "raw", wav: "wav", mp3: "mp3" }
|
||||
|
||||
const outputFormat = Effect.fn("CartesiaSpeech.outputFormat")(function* (request: MediaProtocol.Addressed<Request>) {
|
||||
const outputFormat = Effect.fnUntraced(function* (request: MediaProtocol.Addressed<Request>) {
|
||||
const sse = usesSse(request)
|
||||
const format = request.format ?? (sse ? "pcm" : "mp3")
|
||||
const container = CONTAINERS[format]
|
||||
@@ -121,7 +121,7 @@ const fromRequest = Effect.fn("CartesiaSpeech.fromRequest")(function* (request:
|
||||
// 6. Stream parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const onEvent = Effect.fn("CartesiaSpeech.onEvent")(function* (state: State, frame: string) {
|
||||
const onEvent = Effect.fnUntraced(function* (state: State, frame: string) {
|
||||
const event = yield* decodeEvent(frame)
|
||||
if (event.type === "chunk" && event.data !== undefined) return SpeechStream.delta(state, event.data)
|
||||
if (event.type === "timestamps" && event.word_timestamps !== undefined) {
|
||||
@@ -140,7 +140,7 @@ const onEvent = Effect.fn("CartesiaSpeech.onEvent")(function* (state: State, fra
|
||||
return [state, []] as const
|
||||
})
|
||||
|
||||
const finish = Effect.fn("CartesiaSpeech.finish")(function* (
|
||||
const finish = Effect.fnUntraced(function* (
|
||||
state: State,
|
||||
context: MediaProtocol.ResponseContext<Request>,
|
||||
) {
|
||||
|
||||
@@ -252,7 +252,7 @@ const mapUsage = (usage: typeof NativeUsage.Type) =>
|
||||
})
|
||||
|
||||
// Lifecycle deltas open blocks on demand and ends are no-ops for closed blocks, so content-start needs no handling.
|
||||
const step = Effect.fn("CohereChat.step")(function* (state: State, event: Event) {
|
||||
const step = Effect.fnUntraced(function* (state: State, event: Event) {
|
||||
const events: LLMEvent[] = []
|
||||
switch (event.type) {
|
||||
case "message-start":
|
||||
|
||||
@@ -95,7 +95,7 @@ const OUTPUT_FORMATS: Readonly<Record<string, string>> = {
|
||||
}
|
||||
|
||||
/** WAV is served only by the non-streaming endpoints. */
|
||||
const outputFormat = Effect.fn("ElevenLabsSpeech.outputFormat")(function* (request: MediaProtocol.Addressed<Request>) {
|
||||
const outputFormat = Effect.fnUntraced(function* (request: MediaProtocol.Addressed<Request>) {
|
||||
const format = request.providerOptions?.outputFormat ?? OUTPUT_FORMATS[request.format ?? "mp3"]
|
||||
if (format === undefined)
|
||||
return yield* route.unsupported(
|
||||
@@ -138,7 +138,7 @@ const path = (request: MediaProtocol.Addressed<Request>) =>
|
||||
// 6. Stream parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const onRecord = Effect.fn("ElevenLabsSpeech.onRecord")(function* (state: State, frame: string) {
|
||||
const onRecord = Effect.fnUntraced(function* (state: State, frame: string) {
|
||||
const record = yield* decodeRecord(frame)
|
||||
const [next, events] = SpeechStream.delta(state, record.audio_base64)
|
||||
const alignment = record.alignment
|
||||
@@ -169,7 +169,7 @@ const describeOutput = (format: string) => {
|
||||
return encoding === undefined ? SpeechStream.container(codec, sampleRate) : SpeechStream.pcm(encoding, sampleRate)
|
||||
}
|
||||
|
||||
const finish = Effect.fn("ElevenLabsSpeech.finish")(function* (
|
||||
const finish = Effect.fnUntraced(function* (
|
||||
state: State,
|
||||
context: MediaProtocol.ResponseContext<Request>,
|
||||
) {
|
||||
|
||||
@@ -283,7 +283,7 @@ const lowerToolConfig = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
|
||||
tool: (name) => ({ functionCallingConfig: { mode: "ANY" as const, allowedFunctionNames: [name] } }),
|
||||
})
|
||||
|
||||
const lowerContentPart = Effect.fn("Gemini.lowerContentPart")(function* (part: TextPart | MediaPart) {
|
||||
const lowerContentPart = Effect.fnUntraced(function* (part: TextPart | MediaPart) {
|
||||
if (part.type === "text") return { text: part.text }
|
||||
return yield* GeminiGenerateContent.mediaPart("Gemini", part.media)
|
||||
})
|
||||
@@ -302,7 +302,7 @@ const lowerToolCall = (part: ToolCallPart, omitIds: boolean, metadataKey: string
|
||||
thoughtSignature: thoughtSignature(part.providerMetadata, metadataKey),
|
||||
})
|
||||
|
||||
const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMRequest) {
|
||||
const lowerMessages = Effect.fnUntraced(function* (request: LLMRequest) {
|
||||
const contents: GeminiContent[] = []
|
||||
const metadataKey = request.model.route.providerMetadataKey ?? String(request.model.provider)
|
||||
const omitCallIds = omitsFunctionCallIds(request.model.id)
|
||||
|
||||
@@ -182,7 +182,7 @@ type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
|
||||
// =============================================================================
|
||||
// Request Body Construction
|
||||
// =============================================================================
|
||||
const mediaContent = Effect.fn("GoogleInteractions.mediaContent")(function* (asset: Media.Asset) {
|
||||
const mediaContent = Effect.fnUntraced(function* (asset: Media.Asset) {
|
||||
if (
|
||||
asset.kind !== "image" &&
|
||||
asset.kind !== "audio" &&
|
||||
@@ -208,7 +208,7 @@ const signature = (metadata: ProviderMetadata | undefined, key: string) => {
|
||||
: undefined
|
||||
}
|
||||
|
||||
const lowerMessages = Effect.fn("GoogleInteractions.lowerMessages")(function* (request: LLMRequest) {
|
||||
const lowerMessages = Effect.fnUntraced(function* (request: LLMRequest) {
|
||||
const steps: InputStep[] = []
|
||||
const key = request.model.route.providerMetadataKey ?? String(request.model.provider)
|
||||
for (const message of request.messages) {
|
||||
@@ -275,7 +275,7 @@ const lowerMessages = Effect.fn("GoogleInteractions.lowerMessages")(function* (r
|
||||
return steps
|
||||
})
|
||||
|
||||
const lowerToolResult = Effect.fn("GoogleInteractions.lowerToolResult")(function* (part: ToolResultPart) {
|
||||
const lowerToolResult = Effect.fnUntraced(function* (part: ToolResultPart) {
|
||||
if (part.result.type === "json" && ProviderShared.isRecord(part.result.value)) return part.result.value
|
||||
if (part.result.type !== "content") return ProviderShared.toolResultText(part)
|
||||
|
||||
@@ -364,7 +364,7 @@ const mapUsage = (usage: RawUsage | undefined, key: string) => {
|
||||
})
|
||||
}
|
||||
|
||||
const onStart = Effect.fn("GoogleInteractions.onStart")(function* (
|
||||
const onStart = Effect.fnUntraced(function* (
|
||||
state: ParserState,
|
||||
index: number,
|
||||
step: OutputStep,
|
||||
@@ -402,7 +402,7 @@ const onStart = Effect.fn("GoogleInteractions.onStart")(function* (
|
||||
return [{ ...state, lifecycle, tools, steps: { ...state.steps, [index]: step } }, events] satisfies StepResult
|
||||
})
|
||||
|
||||
const onDelta = Effect.fn("GoogleInteractions.onDelta")(function* (
|
||||
const onDelta = Effect.fnUntraced(function* (
|
||||
state: ParserState,
|
||||
index: number,
|
||||
delta: typeof Delta.Type,
|
||||
@@ -447,7 +447,7 @@ const onDelta = Effect.fn("GoogleInteractions.onDelta")(function* (
|
||||
return yield* ProviderShared.eventError(ADAPTER, `Unsupported Interactions delta: ${delta.type}`, encodeJson(delta))
|
||||
})
|
||||
|
||||
const onStop = Effect.fn("GoogleInteractions.onStop")(function* (state: ParserState, index: number) {
|
||||
const onStop = Effect.fnUntraced(function* (state: ParserState, index: number) {
|
||||
const step = state.steps[index]
|
||||
if (!step) return yield* ProviderShared.eventError(ADAPTER, "Interactions step.stop without step.start")
|
||||
const events: LLMEvent[] = []
|
||||
@@ -465,7 +465,7 @@ const onStop = Effect.fn("GoogleInteractions.onStop")(function* (state: ParserSt
|
||||
return [{ ...state, tools: result.tools }, result.events ?? []] satisfies StepResult
|
||||
})
|
||||
|
||||
const step = Effect.fn("GoogleInteractions.step")(function* (state: ParserState, event: Event) {
|
||||
const step = Effect.fnUntraced(function* (state: ParserState, event: Event) {
|
||||
switch (event.event_type) {
|
||||
case "step.start":
|
||||
return yield* onStart(state, event.index, event.step)
|
||||
|
||||
@@ -94,7 +94,7 @@ const fromRequest = Effect.fn("GoogleSpeech.fromRequest")(function* (request: Me
|
||||
// 6. Stream parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const step = Effect.fn("GoogleSpeech.step")(function* (state: State, frame: string) {
|
||||
const step = Effect.fnUntraced(function* (state: State, frame: string) {
|
||||
const chunk = yield* decodeChunk(frame)
|
||||
const blocked = GeminiGenerateContent.blocked(route.name, chunk, frame)
|
||||
if (blocked !== undefined) return yield* blocked
|
||||
|
||||
@@ -138,7 +138,7 @@ const turn = (part: Schema.Schema.Type<typeof AudioTranscription>) => {
|
||||
}
|
||||
}
|
||||
|
||||
const step = Effect.fn("GoogleTranscription.step")(function* (state: State, frame: string) {
|
||||
const step = Effect.fnUntraced(function* (state: State, frame: string) {
|
||||
const chunk = yield* decodeChunk(frame)
|
||||
const blocked = GeminiGenerateContent.blocked(route.name, chunk, frame)
|
||||
if (blocked !== undefined) return yield* blocked
|
||||
|
||||
@@ -95,7 +95,7 @@ const HOSTED_TOOLS = {
|
||||
image_generation_call: {
|
||||
name: "image_generation",
|
||||
input: () => ({}),
|
||||
result: Effect.fn("MetaResponses.imageResult")(function* (raw: ResponsesHostedTools.Item) {
|
||||
result: Effect.fnUntraced(function* (raw: ResponsesHostedTools.Item) {
|
||||
const item = yield* Schema.decodeUnknownEffect(ImageItem)(raw).pipe(
|
||||
Effect.mapError((cause) =>
|
||||
ProviderShared.eventError(
|
||||
@@ -136,7 +136,7 @@ const HOSTED_TOOLS = {
|
||||
},
|
||||
} satisfies ResponsesHostedTools.Definitions
|
||||
|
||||
const onEvent = Effect.fn("MetaResponses.onEvent")(function* (
|
||||
const onEvent = Effect.fnUntraced(function* (
|
||||
state: OpenResponses.ParserState,
|
||||
input: OpenResponses.Event,
|
||||
) {
|
||||
@@ -173,7 +173,7 @@ const onEvent = Effect.fn("MetaResponses.onEvent")(function* (
|
||||
] satisfies OpenResponses.StepResult
|
||||
})
|
||||
|
||||
const step = Effect.fn("MetaResponses.step")(function* (state: ParserState, input: OpenResponses.Event) {
|
||||
const step = Effect.fnUntraced(function* (state: ParserState, input: OpenResponses.Event) {
|
||||
const completedItems = new Set(state.completedItems)
|
||||
const event = OpenResponses.normalize(state, input)
|
||||
if (event.type === "response.output_item.done" && event.item && completedItems.has(event.item.id))
|
||||
|
||||
@@ -226,7 +226,7 @@ const MistralEvent = Schema.StructWithRest(
|
||||
type MistralEvent = Schema.Schema.Type<typeof MistralEvent>
|
||||
const MistralStreamEvent = Schema.Union([Schema.Literal(DONE), Protocol.jsonEvent(MistralEvent)])
|
||||
|
||||
const lowerMedia = Effect.fn("MistralChat.lowerMedia")(function* (part: MediaPart) {
|
||||
const lowerMedia = Effect.fnUntraced(function* (part: MediaPart) {
|
||||
const mime = part.media.mediaType.toLowerCase()
|
||||
const url =
|
||||
ProviderShared.mediaUrl(part.media) ??
|
||||
@@ -236,7 +236,7 @@ const lowerMedia = Effect.fn("MistralChat.lowerMedia")(function* (part: MediaPar
|
||||
return yield* ProviderShared.invalidRequest(`Mistral Chat does not support media type ${part.media.mediaType}`)
|
||||
})
|
||||
|
||||
const lowerUser = Effect.fn("MistralChat.lowerUser")(function* (message: LLMRequest["messages"][number]) {
|
||||
const lowerUser = Effect.fnUntraced(function* (message: LLMRequest["messages"][number]) {
|
||||
const content: MistralUserContent[] = []
|
||||
for (const part of message.content) {
|
||||
if (part.type === "text") {
|
||||
@@ -260,7 +260,7 @@ const lowerToolCall = (part: ToolCallPart, normalizeID: (id: string) => string):
|
||||
function: { name: part.name, arguments: ProviderShared.encodeJson(part.input) },
|
||||
})
|
||||
|
||||
const lowerAssistant = Effect.fn("MistralChat.lowerAssistant")(function* (
|
||||
const lowerAssistant = Effect.fnUntraced(function* (
|
||||
message: LLMRequest["messages"][number],
|
||||
normalizeID: (id: string) => string,
|
||||
prefix: boolean,
|
||||
@@ -298,7 +298,7 @@ const lowerAssistant = Effect.fn("MistralChat.lowerAssistant")(function* (
|
||||
}
|
||||
})
|
||||
|
||||
const lowerToolResults = Effect.fn("MistralChat.lowerToolResults")(function* (
|
||||
const lowerToolResults = Effect.fnUntraced(function* (
|
||||
message: LLMRequest["messages"][number],
|
||||
normalizeID: (id: string) => string,
|
||||
) {
|
||||
@@ -335,7 +335,7 @@ const lowerToolResults = Effect.fn("MistralChat.lowerToolResults")(function* (
|
||||
return output
|
||||
})
|
||||
|
||||
const lowerMessages = Effect.fn("MistralChat.lowerMessages")(function* (request: LLMRequest) {
|
||||
const lowerMessages = Effect.fnUntraced(function* (request: LLMRequest) {
|
||||
const normalizeID = MistralToolID.normalizer(request)
|
||||
const messages: MistralMessage[] =
|
||||
request.system.length === 0
|
||||
@@ -596,7 +596,7 @@ const toolText = (tool: MistralToolDelta) => {
|
||||
return value === null || value === undefined ? "" : ProviderShared.encodeJson(value)
|
||||
}
|
||||
|
||||
const appendTools = Effect.fn("MistralChat.appendTools")(function* (
|
||||
const appendTools = Effect.fnUntraced(function* (
|
||||
initial: ParserState,
|
||||
events: LLMEvent[],
|
||||
deltas: ReadonlyArray<MistralToolDelta>,
|
||||
@@ -662,7 +662,7 @@ const hasLateContent = (event: MistralEvent) => {
|
||||
)
|
||||
}
|
||||
|
||||
const step = Effect.fn("MistralChat.step")(function* (state: ParserState, event: MistralEvent) {
|
||||
const step = Effect.fnUntraced(function* (state: ParserState, event: MistralEvent) {
|
||||
if (event.error) {
|
||||
const body = ProviderShared.encodeJson(event)
|
||||
return yield* new AIError({
|
||||
@@ -726,7 +726,7 @@ const step = Effect.fn("MistralChat.step")(function* (state: ParserState, event:
|
||||
] as const
|
||||
})
|
||||
|
||||
const finishEvents = Effect.fn("MistralChat.finishEvents")(function* (state: ParserState) {
|
||||
const finishEvents = Effect.fnUntraced(function* (state: ParserState) {
|
||||
if (!state.finishReason)
|
||||
return yield* new AIError({
|
||||
reason: new InvalidProviderOutputError({
|
||||
|
||||
@@ -442,7 +442,7 @@ interface ReasoningStreamItem {
|
||||
// =============================================================================
|
||||
// Request Lowering
|
||||
// =============================================================================
|
||||
export const lowerTool = Effect.fn("OpenResponses.lowerTool")(function* (protocolName: string, tool: ToolDefinition) {
|
||||
export const lowerTool = Effect.fnUntraced(function* (protocolName: string, tool: ToolDefinition) {
|
||||
if (tool.native !== undefined)
|
||||
return yield* ProviderShared.invalidRequest(`${protocolName} does not support provider-native tool ${tool.name}`)
|
||||
return {
|
||||
@@ -507,7 +507,10 @@ const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenR
|
||||
}
|
||||
}
|
||||
|
||||
const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
|
||||
const decodeImageDetail = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenResponsesInputImage.fields.detail))
|
||||
const decodeMessageMetadata = ProviderShared.validateWith(Schema.decodeUnknownEffect(MessageMetadata))
|
||||
|
||||
const lowerMedia = Effect.fnUntraced(function* (
|
||||
part: MediaPart,
|
||||
request: LLMRequest,
|
||||
adapter: ProviderAdapter,
|
||||
@@ -516,9 +519,8 @@ const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
|
||||
const media = part.media.inline()
|
||||
const providerMedia = adapter.lowerMedia?.({ part, media, request })
|
||||
if (providerMedia) return providerMedia
|
||||
const detail = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenResponsesInputImage.fields.detail))(
|
||||
part.providerMetadata?.[metadataKey(request.model)]?.detail,
|
||||
)
|
||||
const rawDetail = part.providerMetadata?.[metadataKey(request.model)]?.detail
|
||||
const detail = rawDetail === undefined ? undefined : yield* decodeImageDetail(rawDetail)
|
||||
const mime = part.media.mediaType.toLowerCase()
|
||||
const url = ProviderShared.mediaUrl(part.media)
|
||||
const location = url ?? (yield* ProviderShared.requireInlineMedia(adapter.name, part.media)).dataUrl
|
||||
@@ -591,7 +593,7 @@ const lowerToolResultOutput = Effect.fnUntraced(function* (
|
||||
|
||||
const DEFAULT_EFFORT = "medium"
|
||||
|
||||
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
|
||||
const lowerMessages = Effect.fnUntraced(function* (
|
||||
request: LLMRequest,
|
||||
adapter: ProviderAdapter,
|
||||
) {
|
||||
@@ -599,9 +601,8 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
|
||||
const providerMetadataKey = metadataKey(request.model)
|
||||
|
||||
for (const message of request.messages) {
|
||||
const metadata = yield* ProviderShared.validateWith(
|
||||
Schema.decodeUnknownEffect(Schema.UndefinedOr(MessageMetadata)),
|
||||
)(message.providerMetadata?.[providerMetadataKey])
|
||||
const rawMetadata = message.providerMetadata?.[providerMetadataKey]
|
||||
const metadata = rawMetadata === undefined ? undefined : yield* decodeMessageMetadata(rawMetadata)
|
||||
if (message.role === "system") {
|
||||
const update = effortUpdate(message)
|
||||
if (update) {
|
||||
@@ -755,7 +756,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
|
||||
return input
|
||||
})
|
||||
|
||||
export const lowerConversation = Effect.fn("OpenResponses.lowerConversation")(function* (
|
||||
export const lowerConversation = Effect.fnUntraced(function* (
|
||||
request: LLMRequest,
|
||||
adapter: ProviderAdapter,
|
||||
) {
|
||||
@@ -1142,7 +1143,7 @@ const onReasoningSummaryPartDone = (state: ParserState, event: Event): StepResul
|
||||
]
|
||||
}
|
||||
|
||||
const onFunctionCallArgumentsDelta = Effect.fn("OpenResponses.onFunctionCallArgumentsDelta")(function* (
|
||||
const onFunctionCallArgumentsDelta = Effect.fnUntraced(function* (
|
||||
state: ParserState,
|
||||
event: Event,
|
||||
) {
|
||||
@@ -1173,7 +1174,7 @@ const onFunctionCallArgumentsDelta = Effect.fn("OpenResponses.onFunctionCallArgu
|
||||
return [{ ...state, lifecycle, tools: result.tools }, events] satisfies StepResult
|
||||
})
|
||||
|
||||
const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
const onOutputItemDone = Effect.fnUntraced(function* (
|
||||
state: ParserState,
|
||||
item: NormalizedEvent["item"],
|
||||
) {
|
||||
@@ -1309,7 +1310,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
return [state, NO_EVENTS] satisfies StepResult
|
||||
})
|
||||
|
||||
const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (state: ParserState, event: Event) {
|
||||
const onResponseFinish = Effect.fnUntraced(function* (state: ParserState, event: Event) {
|
||||
let current = state
|
||||
const events: LLMEvent[] = []
|
||||
if (event.type === "response.completed") {
|
||||
|
||||
@@ -360,7 +360,7 @@ const lowerToolCall = (
|
||||
extra_content: decodeExtraContent(part.providerMetadata?.[options.providerMetadataKey]?.extraContent),
|
||||
})
|
||||
|
||||
const lowerMedia = Effect.fn("OpenAIChat.lowerMedia")(function* (part: MediaPart) {
|
||||
const lowerMedia = Effect.fnUntraced(function* (part: MediaPart) {
|
||||
// Chat Completions accepts PDFs, and no other documents, as inline `file` parts; file URLs are not supported.
|
||||
if (part.media.mediaType.toLowerCase() === "application/pdf")
|
||||
return {
|
||||
@@ -406,7 +406,7 @@ const lowerReasoningDetail = (detail: ReasoningDetail) => {
|
||||
|
||||
const isKimiDetail = (detail: { readonly type: string }) => detail.type === "summary" || detail.type === "encrypted"
|
||||
|
||||
const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (
|
||||
const lowerUserMessage = Effect.fnUntraced(function* (
|
||||
message: OpenAIChatRequestMessage,
|
||||
options: LoweringOptions,
|
||||
) {
|
||||
@@ -430,7 +430,7 @@ const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (
|
||||
return { role: "user" as const, content }
|
||||
})
|
||||
|
||||
const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(function* (
|
||||
const lowerAssistantMessage = Effect.fnUntraced(function* (
|
||||
message: OpenAIChatRequestMessage,
|
||||
configuredField: string | undefined,
|
||||
requireReasoning: boolean,
|
||||
@@ -495,7 +495,7 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
|
||||
return { ...result, [field]: reasoningText }
|
||||
})
|
||||
|
||||
const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (
|
||||
const lowerToolMessages = Effect.fnUntraced(function* (
|
||||
message: OpenAIChatRequestMessage,
|
||||
options: LoweringOptions,
|
||||
) {
|
||||
@@ -532,7 +532,7 @@ const toolMessage = (toolCallID: string, text: string, cacheControl: OpenAIChatC
|
||||
content: cacheControl === undefined ? text : [{ type: "text" as const, text, cache_control: cacheControl }],
|
||||
})
|
||||
|
||||
const lowerMessage = Effect.fn("OpenAIChat.lowerMessage")(function* (
|
||||
const lowerMessage = Effect.fnUntraced(function* (
|
||||
message: OpenAIChatRequestMessage,
|
||||
reasoningField: string | undefined,
|
||||
requireReasoning: boolean,
|
||||
@@ -544,7 +544,7 @@ const lowerMessage = Effect.fn("OpenAIChat.lowerMessage")(function* (
|
||||
return (yield* lowerToolMessages(message, options)).messages
|
||||
})
|
||||
|
||||
const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request: LLMRequest, options: LoweringOptions) {
|
||||
const lowerMessages = Effect.fnUntraced(function* (request: LLMRequest, options: LoweringOptions) {
|
||||
const system: OpenAIChatMessage[] =
|
||||
request.system.length === 0
|
||||
? []
|
||||
@@ -855,7 +855,7 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
|
||||
// Streaming parsers are small state machines: every event returns a new state
|
||||
// plus the common `LLMEvent`s produced by that event. Tool calls are accumulated
|
||||
// because OpenAI streams JSON arguments across multiple deltas.
|
||||
const mapFinishReason = Effect.fn("OpenAIChat.mapFinishReason")(function* (event: OpenAIChatEvent, reason: string) {
|
||||
const mapFinishReason = Effect.fnUntraced(function* (event: OpenAIChatEvent, reason: string) {
|
||||
switch (reason) {
|
||||
case "error":
|
||||
return yield* new AIError({
|
||||
@@ -1214,7 +1214,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
] as const
|
||||
})
|
||||
|
||||
const finishEvents = Effect.fn("OpenAIChat.finishEvents")(function* (state: ParserState) {
|
||||
const finishEvents = Effect.fnUntraced(function* (state: ParserState) {
|
||||
if (state.finishReason === undefined && state.requireFinishReason)
|
||||
return yield* new AIError({
|
||||
reason: new InvalidProviderOutputError({
|
||||
|
||||
@@ -208,7 +208,7 @@ const eventImage = (frame: string, label: string, data: string, format: string,
|
||||
info: info(format, size),
|
||||
})
|
||||
|
||||
const onEvent = Effect.fn("OpenAIImages.onEvent")(function* (state: State, frame: string) {
|
||||
const onEvent = Effect.fnUntraced(function* (state: State, frame: string) {
|
||||
const event = yield* decodeEvent(frame)
|
||||
const format = event.output_format
|
||||
if ("partial_image_index" in event) {
|
||||
@@ -229,7 +229,7 @@ const onEvent = Effect.fn("OpenAIImages.onEvent")(function* (state: State, frame
|
||||
] as const
|
||||
})
|
||||
|
||||
const onDocument = Effect.fn("OpenAIImages.onDocument")(function* (frame: Exclude<Frame, string>) {
|
||||
const onDocument = Effect.fnUntraced(function* (frame: Exclude<Frame, string>) {
|
||||
const invalid = (message: string, cause?: unknown) => route.frameError(message, frame.document, cause)
|
||||
const decoded = yield* decodeDocument(frame.document).pipe(
|
||||
Effect.mapError((cause) => invalid(`${route.name} returned an invalid response`, cause)),
|
||||
|
||||
@@ -157,7 +157,7 @@ const nativeImageTool = (tool: ToolDefinition) => {
|
||||
return Schema.is(OpenAIResponsesImageGenerationTool)(native) ? native : undefined
|
||||
}
|
||||
|
||||
const lowerTool = Effect.fn("OpenAIResponses.lowerTool")(function* (tool: ToolDefinition) {
|
||||
const lowerTool = Effect.fnUntraced(function* (tool: ToolDefinition) {
|
||||
const native = nativeImageToolInput(tool)
|
||||
if (native !== undefined) {
|
||||
if (Schema.is(OpenAIResponsesImageGenerationTool)(native)) return native
|
||||
@@ -168,7 +168,7 @@ const lowerTool = Effect.fn("OpenAIResponses.lowerTool")(function* (tool: ToolDe
|
||||
|
||||
// Native namespaces hold only function tools, so deeper levels flatten into
|
||||
// the leaf names the same way non-native protocols flatten the whole tree.
|
||||
const lowerToolEntry = Effect.fn("OpenAIResponses.lowerToolEntry")(function* (tool: ToolEntry) {
|
||||
const lowerToolEntry = Effect.fnUntraced(function* (tool: ToolEntry) {
|
||||
if (tool.type === "tool") return yield* lowerTool(tool)
|
||||
// OpenAI requires a namespace description; fall back to a generic one so a
|
||||
// missing description never blocks the request.
|
||||
@@ -237,7 +237,7 @@ const checkpointBody = {
|
||||
}),
|
||||
}
|
||||
|
||||
const hostedToolResult = Effect.fn("OpenAIResponses.hostedToolResult")(function* (item: ResponsesHostedTools.Item) {
|
||||
const hostedToolResult = Effect.fnUntraced(function* (item: ResponsesHostedTools.Item) {
|
||||
const isError = item.error !== undefined && item.error !== null
|
||||
if (item.type === "image_generation_call" && item.result) {
|
||||
yield* Effect.fromResult(Encoding.decodeBase64(item.result)).pipe(
|
||||
|
||||
@@ -94,7 +94,7 @@ const fromRequest = Effect.fn("OpenAISpeech.fromRequest")(function* (request: Me
|
||||
|
||||
const isSse = (body: MediaProtocol.Body) => body.type === "json" && body.value.stream_format === "sse"
|
||||
|
||||
const onEvent = Effect.fn("OpenAISpeech.onEvent")(function* (state: State, frame: string) {
|
||||
const onEvent = Effect.fnUntraced(function* (state: State, frame: string) {
|
||||
const event = yield* decodeEvent(frame)
|
||||
if (event.type === "speech.audio.delta") return SpeechStream.delta(state, event.audio)
|
||||
const usage = event.usage
|
||||
|
||||
@@ -210,7 +210,7 @@ const segment = (value: Schema.Schema.Type<typeof Segment>): TranscriptionSegmen
|
||||
speaker: value.speaker,
|
||||
})
|
||||
|
||||
const onEvent = Effect.fn("OpenAITranscription.onEvent")(function* (state: State, frame: string) {
|
||||
const onEvent = Effect.fnUntraced(function* (state: State, frame: string) {
|
||||
if (!EVENT_TYPES.has((yield* decodeEventType(frame)).type)) return [state, []] as const
|
||||
const event = yield* decodeEvent(frame)
|
||||
if (event.type === "error")
|
||||
|
||||
@@ -154,7 +154,7 @@ export const wrapSystemUpdate = (parts: ReadonlyArray<{ readonly text: string }>
|
||||
* raw retrieved, tool, or web content into privileged updates: keep untrusted
|
||||
* data in ordinary user/tool messages instead.
|
||||
*/
|
||||
export const systemUpdateText = Effect.fn("ProviderShared.systemUpdateText")(function* (
|
||||
export const systemUpdateText = Effect.fnUntraced(function* (
|
||||
route: string,
|
||||
message: LLMRequest["messages"][number],
|
||||
) {
|
||||
@@ -167,7 +167,7 @@ export const systemUpdateText = Effect.fn("ProviderShared.systemUpdateText")(fun
|
||||
})
|
||||
|
||||
/** Lower an unsupported privileged update into visible, in-order user text. */
|
||||
export const wrappedSystemUpdate = Effect.fn("ProviderShared.wrappedSystemUpdate")(function* (
|
||||
export const wrappedSystemUpdate = Effect.fnUntraced(function* (
|
||||
route: string,
|
||||
message: LLMRequest["messages"][number],
|
||||
) {
|
||||
|
||||
@@ -82,7 +82,7 @@ const endpoint = (model: string) => (model.startsWith("sd3") ? "sd3" : model)
|
||||
|
||||
const RESERVED_FORM_FIELDS = new Set(["image", "prompt", "mode", "model"])
|
||||
|
||||
const form = Effect.fn("StabilityImages.form")(function* (
|
||||
const form = Effect.fnUntraced(function* (
|
||||
identity: MediaProtocol.Identity,
|
||||
fields: Record<string, unknown>,
|
||||
native: Record<string, unknown> | undefined,
|
||||
|
||||
@@ -76,7 +76,7 @@ function documentName(filename: string | undefined, names: Set<string>) {
|
||||
return name
|
||||
}
|
||||
|
||||
const mediaBase64 = Effect.fn("BedrockMedia.mediaBase64")(function* (part: MediaPart) {
|
||||
const mediaBase64 = Effect.fnUntraced(function* (part: MediaPart) {
|
||||
const media = yield* ProviderShared.requireInlineMedia("Bedrock Converse", part.media)
|
||||
const bytes = yield* Effect.fromResult(Encoding.decodeBase64(media.base64)).pipe(
|
||||
Effect.mapError((cause) =>
|
||||
@@ -91,7 +91,7 @@ const mediaBase64 = Effect.fn("BedrockMedia.mediaBase64")(function* (part: Media
|
||||
// document block. Image MIME types not in `IMAGE_FORMATS` (e.g. `image/svg+xml`)
|
||||
// get an image-specific error so the caller knows it's a format-support issue,
|
||||
// not a kind-detection issue.
|
||||
export const lower = Effect.fn("BedrockMedia.lower")(function* (part: MediaPart, documentNames: Set<string>) {
|
||||
export const lower = Effect.fnUntraced(function* (part: MediaPart, documentNames: Set<string>) {
|
||||
const mime = part.media.mediaType.toLowerCase()
|
||||
const imageFormat = IMAGE_FORMATS[mime as keyof typeof IMAGE_FORMATS]
|
||||
if (imageFormat) {
|
||||
|
||||
@@ -11,7 +11,7 @@ interface State {
|
||||
readonly responseID?: string
|
||||
}
|
||||
|
||||
const onOutputItem = Effect.fn("ResponsesCheckpoint.onOutputItem")(function* (
|
||||
const onOutputItem = Effect.fnUntraced(function* (
|
||||
state: State,
|
||||
input: OpenResponses.Event,
|
||||
) {
|
||||
@@ -63,7 +63,7 @@ export const make = <Body>(body: RouteBody<Body>): TriggerCompactOperation =>
|
||||
checkpoints: {},
|
||||
}),
|
||||
terminal: OpenResponses.terminal,
|
||||
step: Effect.fn("ResponsesCheckpoint.step")(function* (state: State, event: OpenResponses.Event) {
|
||||
step: Effect.fnUntraced(function* (state: State, event: OpenResponses.Event) {
|
||||
if (event.response?.id && state.responseID && event.response.id !== state.responseID)
|
||||
return yield* ProviderShared.eventError(source.id, "Compaction response ID changed during execution")
|
||||
if (event.type === "response.created") return [{ ...state, responseID: event.response?.id }, []] as const
|
||||
|
||||
@@ -33,7 +33,7 @@ export const onDone: (
|
||||
state: OpenResponses.ParserState,
|
||||
item: Item,
|
||||
tools: Definitions,
|
||||
) => Effect.Effect<OpenResponses.StepResult, AIError> = Effect.fn("ResponsesHostedTools.onDone")(
|
||||
) => Effect.Effect<OpenResponses.StepResult, AIError> = Effect.fnUntraced(
|
||||
function* (state, item, tools) {
|
||||
const tool = tools[item.type]
|
||||
if (!tool) return [state, []] satisfies OpenResponses.StepResult
|
||||
|
||||
@@ -60,14 +60,21 @@ const inputStart = (tool: PendingTool) =>
|
||||
providerMetadata: tool.providerMetadata,
|
||||
})
|
||||
|
||||
const inputDelta = (tool: PendingTool, text: string) =>
|
||||
LLMEvent.toolInputDelta({
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
namespace: tool.namespace,
|
||||
text,
|
||||
input: Option.getOrElse(parsePartialInput(tool.input), () => ({})),
|
||||
})
|
||||
const inputDelta = (tool: PendingTool, text: string): LLMEvent => {
|
||||
const raw = tool.input
|
||||
let parsed: unknown
|
||||
return {
|
||||
...LLMEvent.toolInputDelta({
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
namespace: tool.namespace,
|
||||
text,
|
||||
}),
|
||||
get input() {
|
||||
return (parsed ??= Option.getOrElse(parsePartialInput(raw), () => ({})))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
|
||||
const raw = inputOverride ?? tool.input
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { AnthropicMessages } from "../protocols/anthropic-messages.js"
|
||||
import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { OpenResponses } from "../protocols/open-responses.js"
|
||||
import { BedrockAuth, type Credentials } from "../protocols/utils/bedrock-auth.js"
|
||||
import { claudeVersion } from "../protocols/utils/claude-model.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
export const id = ProviderID.make("amazon-bedrock")
|
||||
|
||||
export type Config = RouteDefaultsInput & {
|
||||
export type OpenAIOptionsInput = OpenAIProviderOptionsInput
|
||||
export type MessagesOptionsInput = AnthropicMessages.ProviderOptionsInput
|
||||
|
||||
export type Config = Omit<RouteDefaultsInput, "providerOptions"> & {
|
||||
/** Bedrock API key. Falls back to `AWS_BEARER_TOKEN_BEDROCK`; bearer auth takes precedence over SigV4. */
|
||||
readonly apiKey?: string
|
||||
/** `sigv4` ignores `apiKey` fallbacks from the environment; `bearer` requires a token. */
|
||||
@@ -20,11 +25,11 @@ export type Config = RouteDefaultsInput & {
|
||||
/** Shared config profile for the default credential chain. */
|
||||
readonly profile?: string
|
||||
readonly region?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput | AnthropicMessages.ProviderOptionsInput
|
||||
}
|
||||
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
OpenAIProviderOptionsInput & {
|
||||
export type Settings<Options = OpenAIProviderOptionsInput> = ProviderPackage.Settings &
|
||||
Options & {
|
||||
readonly apiKey?: string
|
||||
readonly auth?: "bearer" | "sigv4"
|
||||
readonly baseURL?: string
|
||||
@@ -34,6 +39,8 @@ export type Settings = ProviderPackage.Settings &
|
||||
readonly topP?: number
|
||||
}
|
||||
|
||||
export type MessagesSettings = Settings<AnthropicMessages.ProviderOptionsInput>
|
||||
|
||||
const responsesRoute = Route.make({
|
||||
id: "bedrock-mantle-responses",
|
||||
provider: id,
|
||||
@@ -50,12 +57,35 @@ const chatRoute = OpenAIChat.route.with({
|
||||
providerMetadataKey: "mantle",
|
||||
})
|
||||
|
||||
export const routes = [responsesRoute, chatRoute]
|
||||
const messagesRoute = Route.make({
|
||||
id: "bedrock-mantle-messages",
|
||||
provider: id,
|
||||
providerMetadataKey: "mantle",
|
||||
protocol: {
|
||||
...AnthropicMessages.protocol,
|
||||
// Mantle rejects mid-conversation `output_config` on Opus 5.0; support starts at 5.1+.
|
||||
supportsEffortUpdates: (request) => {
|
||||
const override = request.model.compatibility?.supportsEffortUpdates
|
||||
if (override !== undefined) return override
|
||||
const version = claudeVersion(request.model.id)
|
||||
return version !== undefined && (version.major > 5 || (version.major === 5 && version.minor >= 1))
|
||||
},
|
||||
},
|
||||
endpoint: Endpoint.path(AnthropicMessages.PATH),
|
||||
transport: AnthropicMessages.transport<AnthropicMessages.AnthropicMessagesBody>(),
|
||||
headers: () => ({ "anthropic-version": "2023-06-01" }),
|
||||
})
|
||||
|
||||
const configuredRoute = <Body, Prepared>(route: Route<Body, Prepared>, input: Config) => {
|
||||
export const routes = [responsesRoute, chatRoute, messagesRoute]
|
||||
|
||||
const configuredRoute = <Body, Prepared>(
|
||||
route: Route<Body, Prepared>,
|
||||
input: Config,
|
||||
defaultBaseURL = (region: string) => `https://bedrock-mantle.${region}.api.aws/v1`,
|
||||
) => {
|
||||
const region = BedrockAuth.resolveRegion(input)
|
||||
return route.with({
|
||||
endpoint: { baseURL: input.baseURL ?? `https://bedrock-mantle.${region}.api.aws/v1` },
|
||||
endpoint: { baseURL: input.baseURL ?? defaultBaseURL(region) },
|
||||
auth: BedrockAuth.resolveAuth(input, region, {
|
||||
service: "bedrock-mantle",
|
||||
name: "Bedrock Mantle",
|
||||
@@ -87,6 +117,11 @@ export const configure = (input: Config = {}) => {
|
||||
})
|
||||
const configuredResponsesRoute = configuredRoute(responsesRoute, input)
|
||||
const configuredChatRoute = configuredRoute(chatRoute, input)
|
||||
const configuredMessagesRoute = configuredRoute(
|
||||
messagesRoute,
|
||||
input,
|
||||
(region) => `https://bedrock-mantle.${region}.api.aws/anthropic/v1`,
|
||||
)
|
||||
const modelDefaults = defaults(input)
|
||||
const responses = (modelID: string | ModelID) =>
|
||||
configuredResponsesRoute
|
||||
@@ -96,11 +131,14 @@ export const configure = (input: Config = {}) => {
|
||||
configuredChatRoute
|
||||
.with(withOpenAIOptions(modelID, modelDefaults))
|
||||
.model<OpenAIProviderOptionsInput>({ id: modelID })
|
||||
const messages = (modelID: string | ModelID) =>
|
||||
configuredMessagesRoute.with(modelDefaults).model<AnthropicMessages.ProviderOptionsInput>({ id: modelID })
|
||||
|
||||
return {
|
||||
id,
|
||||
model: responses,
|
||||
chat,
|
||||
messages,
|
||||
responses,
|
||||
configure,
|
||||
}
|
||||
@@ -119,7 +157,7 @@ const fromSettings = ({
|
||||
region,
|
||||
topP,
|
||||
...providerOptions
|
||||
}: Settings) =>
|
||||
}: Settings<Config["providerOptions"]>) =>
|
||||
configure({
|
||||
apiKey,
|
||||
auth,
|
||||
@@ -137,6 +175,10 @@ export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptio
|
||||
modelID,
|
||||
settings,
|
||||
) => fromSettings(settings).chat(modelID)
|
||||
export const messagesModel: ProviderPackage.Definition<
|
||||
MessagesSettings,
|
||||
AnthropicMessages.ProviderOptionsInput
|
||||
>["model"] = (modelID, settings) => fromSettings(settings).messages(modelID)
|
||||
export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export { messagesModel as model } from "../../amazon-bedrock-mantle.js"
|
||||
export type { MessagesSettings as Settings } from "../../amazon-bedrock-mantle.js"
|
||||
@@ -50,7 +50,7 @@ export const gpt5DefaultOptions = (modelID: string): ProviderOptions | undefined
|
||||
export const openAIDefaultOptions = (modelID: string): ProviderOptions | undefined =>
|
||||
mergeProviderOptions(openAIProviderOptions({ store: false }), gpt5DefaultOptions(modelID))
|
||||
|
||||
export const withOpenAIOptions = <Options extends { readonly providerOptions?: OpenAIProviderOptionsInput }>(
|
||||
export const withOpenAIOptions = <Options extends { readonly providerOptions?: ProviderOptions }>(
|
||||
modelID: string,
|
||||
options: Options,
|
||||
): Omit<Options, "providerOptions"> & { readonly providerOptions?: ProviderOptions } => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect, Stream } from "effect"
|
||||
import { makeParser, type Event } from "effect/unstable/encoding/Sse"
|
||||
import { makeParser } from "effect/unstable/encoding/Sse"
|
||||
import { AIError, InvalidProviderOutputError } from "../schema/index.js"
|
||||
|
||||
/**
|
||||
@@ -42,43 +42,39 @@ export const sseFraming = (
|
||||
Stream.decodeText(),
|
||||
Stream.mapAccumEffect(
|
||||
() => {
|
||||
const output: Event[] = []
|
||||
const output: string[] = []
|
||||
return {
|
||||
output,
|
||||
parser: makeParser((event) => {
|
||||
if (event._tag === "Event") output.push(event)
|
||||
if (
|
||||
event._tag === "Event" &&
|
||||
(events === undefined || events.has(event.event)) &&
|
||||
event.data.length > 0 &&
|
||||
// Some OpenAI-compatible proxies serialize an empty flush as a bare
|
||||
// `data: null`, between events or after `[DONE]`. No protocol has a
|
||||
// null event, so it carries nothing and must not abort the stream.
|
||||
event.data !== "null" &&
|
||||
// Vertex AI partner models (e.g. `xai/grok-4.6`) send their SSE
|
||||
// keepalive comment as `data: : keepalive` while reasoning.
|
||||
event.data !== ": keepalive" &&
|
||||
(event.data !== "[DONE]" || includeDone || (events !== undefined && event.event !== "message"))
|
||||
)
|
||||
output.push(event.data)
|
||||
}),
|
||||
}
|
||||
},
|
||||
(state, chunk) =>
|
||||
Effect.gen(function* () {
|
||||
const error = state.parser.feed(chunk)
|
||||
if (error)
|
||||
return yield* new AIError({
|
||||
reason: new InvalidProviderOutputError({
|
||||
route: "sse",
|
||||
message: error.message,
|
||||
body: chunk,
|
||||
cause: error,
|
||||
}),
|
||||
})
|
||||
return [state, state.output.splice(0)] as const
|
||||
}),
|
||||
(state, chunk) => {
|
||||
const error = state.parser.feed(chunk)
|
||||
if (!error) return Effect.succeed([state, state.output.splice(0)] as const)
|
||||
const reason = new InvalidProviderOutputError({
|
||||
route: "sse",
|
||||
message: error.message,
|
||||
body: chunk,
|
||||
cause: error,
|
||||
})
|
||||
return Effect.fail(new AIError({ reason }))
|
||||
},
|
||||
),
|
||||
Stream.filter(
|
||||
(event) =>
|
||||
(events === undefined || events.has(event.event)) &&
|
||||
event.data.length > 0 &&
|
||||
// Some OpenAI-compatible proxies serialize an empty flush as a bare
|
||||
// `data: null`, between events or after `[DONE]`. No protocol has a
|
||||
// null event, so it carries nothing and must not abort the stream.
|
||||
event.data !== "null" &&
|
||||
// Vertex AI partner models (e.g. `xai/grok-4.6`) send their SSE
|
||||
// keepalive comment as `data: : keepalive` while reasoning.
|
||||
event.data !== ": keepalive" &&
|
||||
(event.data !== "[DONE]" || includeDone || (events !== undefined && event.event !== "message")),
|
||||
),
|
||||
Stream.map((event) => event.data),
|
||||
)
|
||||
|
||||
/** Server-Sent Events framing. Used by every JSON-streaming HTTP provider. */
|
||||
|
||||
@@ -6,7 +6,7 @@ import { compileRequest } from "../src/route/client.js"
|
||||
import { AnthropicMessages } from "../src/protocols/anthropic-messages.js"
|
||||
import { OpenAIResponses } from "../src/protocols/openai-responses.js"
|
||||
import { Gemini } from "../src/protocols/gemini.js"
|
||||
import { GoogleVertexMessages, OpenAI } from "../src/providers.js"
|
||||
import { AmazonBedrockMantle, GoogleVertexMessages, OpenAI } from "../src/providers.js"
|
||||
import { applyEffortUpdates } from "../src/effort-updates.js"
|
||||
import { it, testEffect } from "./lib/effect.js"
|
||||
import { dynamicResponse } from "./lib/http.js"
|
||||
@@ -241,6 +241,31 @@ describe("Anthropic Messages effort updates", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("strips markers for Opus 5.0 on Bedrock Mantle Messages while lowering Opus 5.5", () =>
|
||||
Effect.gen(function* () {
|
||||
const mantle = AmazonBedrockMantle.configure({ apiKey: "test", region: "us-east-1" })
|
||||
const opus50 = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: mantle.messages("anthropic.claude-opus-5"),
|
||||
messages: conversation,
|
||||
providerOptions: { effort: "low" },
|
||||
}),
|
||||
)
|
||||
const opus55 = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: mantle.messages("anthropic.claude-opus-5-5"),
|
||||
messages: conversation,
|
||||
providerOptions: { effort: "low" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(systemMessages(opus50.body)).toHaveLength(0)
|
||||
expect(opus50.body.output_config).toEqual({ effort: "low" })
|
||||
expect(systemMessages(opus55.body)).toEqual([{ role: "system", content: [], output_config: { effort: "low" } }])
|
||||
expect(opus55.body.output_config).toEqual({ effort: "high" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("strips markers on the Vertex Anthropic route, whose protocol wrapper does not forward support", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"model": "anthropic.claude-opus-5-5",
|
||||
"tags": [
|
||||
"prefix:bedrock-mantle-messages",
|
||||
"provider:amazon-bedrock",
|
||||
"protocol:anthropic-messages",
|
||||
"reasoning",
|
||||
"effort-update"
|
||||
],
|
||||
"name": "bedrock-mantle-messages/applies-mid-conversation-effort-updates-and-thinking-block-binding-on-opus-5-5",
|
||||
"recordedAt": "2026-10-04T03:56:23.139Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://bedrock-mantle.us-east-1.api.aws/anthropic/v1/messages",
|
||||
"headers": {
|
||||
"anthropic-beta": "interleaved-thinking-2025-05-14,thinking-binding-controls-2026-08-01",
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"anthropic.claude-opus-5-5\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Compute 37 * 43 step by step, then reply with only the integer.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"stream\":true,\"max_tokens\":2048,\"thinking\":{\"type\":\"adaptive\",\"display\":\"summarized\",\"block_binding\":{\"prefix_mismatch_behavior\":\"drop_block\"}},\"output_config\":{\"effort\":\"high\"}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-opus-5-5\",\"id\":\"msg_bdrk_y7h2x5bz6w6ggmekuxekbi6rseptglsljkwgj36phisnsnu3syea\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"container\":null,\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":31,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":2,\"service_tier\":\"standard\"},\"input_transformations\":[]}}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\",\"signature\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"They\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" want\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" only\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" the final\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" integer, so\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" I just\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" need\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" to compute 37\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"×43 \"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"using the dif\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"ference of squares: 40\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"²−3²\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" = 1600−9 =\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" 1591.\\n\\n\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"signature_delta\",\"signature\":\"CAQSogUKEggSEAEYAjgBQgh0aGlua2luZxIM48GlQ7IvNDW3jk2BGgw0Aq5cIs+m8gjYEIQiMFOyG984KYDLRzchzn2ApX6Rs3r7+CtXL0KeCRsXr1XKY3Y0UNK73Qrjyk94TGFPTSq9BCCZALbMiaeiIDqgB3H8jfrU26rP6kORoTJuF84ByBhXGI9AGo30c3RU2ZueXE/oLvRkK12g8r3TPql2fam1gcC/SeZ627fqGykV6G8WrPH7kthjbR45vnRuaqgFZmyA4C/XCxEH0zSSlVAhXf3Um9sjO/RD8lMJttQCAJSurbB5wmKbUQmZeOjIn8augHV/KmsGcFaHBjGLNfnwfEUwLEbRjNh5eIsmcs9dFfIeYHYNRgtLnBkERACoKPQLp2xU090/wCwrFHIGgLYbklR5wUhGqrAERhOAybqMxlNN3dwd7dV5Tbd16xEghDWb6wUtoxnNQdsUsV/0F/b8Ov9TSu93db/QJAkR74OBMtPuEu914SxtC4NBtqX5YZQAo//O4piaQGzFRVE7TahKzMS5bD111w/qz/cLpqEI/bZMqsXKdzBuMMOAox9TvgTumemdiiPu8tn2ONQeAnuNLjArrr7Jz1mbE9GqPl6aJW9HYWPb0BxCQ/hRXFz5d0v+okmmn5KCV2nD55wk2ySWz9OxIMQUsJ6uEUVOZPk7O0ZJ2ppoLAx6GYq2f55nNcUCsbKB4gB4+QlNp3Tf5IemDdZaY/tGW71dgpYI7iWYYEhqNtlC8pXpoEIbO31IvAHS9BGeQRYQQh1h0muEz4S1+YICPTdXnUmjc0uJ9MGBIKOuBsUhg+E4dzZzOfwS4KteNmJgjhj53WVdjJgRslemZle0tZphrTLLgvZpLZDVIYmOkIpbo2tf5Cflx/rf/NPg7RgB\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\"1591\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":1}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"container\":null},\"usage\":{\"input_tokens\":31,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":86,\"output_tokens_details\":{\"thinking_tokens\":82}}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://bedrock-mantle.us-east-1.api.aws/anthropic/v1/messages",
|
||||
"headers": {
|
||||
"anthropic-beta": "interleaved-thinking-2025-05-14,mid-conversation-output-config-2026-07-01,thinking-binding-controls-2026-08-01",
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"anthropic.claude-opus-5-5\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Compute 37 * 43 step by step, then reply with only the integer.\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"thinking\",\"thinking\":\"They want only the final integer, so I just need to compute 37×43 using the difference of squares: 40²−3² = 1600−9 = 1591.\\n\\n\",\"signature\":\"CAQSogUKEggSEAEYAjgBQgh0aGlua2luZxIM48GlQ7IvNDW3jk2BGgw0Aq5cIs+m8gjYEIQiMFOyG984KYDLRzchzn2ApX6Rs3r7+CtXL0KeCRsXr1XKY3Y0UNK73Qrjyk94TGFPTSq9BCCZALbMiaeiIDqgB3H8jfrU26rP6kORoTJuF84ByBhXGI9AGo30c3RU2ZueXE/oLvRkK12g8r3TPql2fam1gcC/SeZ627fqGykV6G8WrPH7kthjbR45vnRuaqgFZmyA4C/XCxEH0zSSlVAhXf3Um9sjO/RD8lMJttQCAJSurbB5wmKbUQmZeOjIn8augHV/KmsGcFaHBjGLNfnwfEUwLEbRjNh5eIsmcs9dFfIeYHYNRgtLnBkERACoKPQLp2xU090/wCwrFHIGgLYbklR5wUhGqrAERhOAybqMxlNN3dwd7dV5Tbd16xEghDWb6wUtoxnNQdsUsV/0F/b8Ov9TSu93db/QJAkR74OBMtPuEu914SxtC4NBtqX5YZQAo//O4piaQGzFRVE7TahKzMS5bD111w/qz/cLpqEI/bZMqsXKdzBuMMOAox9TvgTumemdiiPu8tn2ONQeAnuNLjArrr7Jz1mbE9GqPl6aJW9HYWPb0BxCQ/hRXFz5d0v+okmmn5KCV2nD55wk2ySWz9OxIMQUsJ6uEUVOZPk7O0ZJ2ppoLAx6GYq2f55nNcUCsbKB4gB4+QlNp3Tf5IemDdZaY/tGW71dgpYI7iWYYEhqNtlC8pXpoEIbO31IvAHS9BGeQRYQQh1h0muEz4S1+YICPTdXnUmjc0uJ9MGBIKOuBsUhg+E4dzZzOfwS4KteNmJgjhj53WVdjJgRslemZle0tZphrTLLgvZpLZDVIYmOkIpbo2tf5Cflx/rf/NPg7RgB\"},{\"type\":\"text\",\"text\":\"1591\"}]},{\"role\":\"system\",\"content\":[],\"output_config\":{\"effort\":\"low\"}},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Add 9 to that result. Reply with only the integer.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"stream\":true,\"max_tokens\":2048,\"thinking\":{\"type\":\"adaptive\",\"display\":\"summarized\",\"block_binding\":{\"prefix_mismatch_behavior\":\"drop_block\"}},\"output_config\":{\"effort\":\"high\"}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-opus-5-5\",\"id\":\"msg_bdrk_yzpmbqh3aag3i7kymdm7ctqputcpeuln3cy44msgbdnctkcweena\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"container\":null,\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":139,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":4,\"service_tier\":\"standard\"},\"input_transformations\":[]}}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"1600\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"container\":null},\"usage\":{\"input_tokens\":139,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":4,\"output_tokens_details\":{\"thinking_tokens\":0}}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"model": "anthropic.claude-haiku-4-5",
|
||||
"tags": [
|
||||
"prefix:bedrock-mantle-messages",
|
||||
"provider:amazon-bedrock",
|
||||
"protocol:anthropic-messages",
|
||||
"reasoning",
|
||||
"system-update"
|
||||
],
|
||||
"name": "bedrock-mantle-messages/lowers-system-updates-to-wrapped-user-text-on-haiku-4-5-with-budget-thinking",
|
||||
"recordedAt": "2026-10-04T03:56:18.874Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://bedrock-mantle.us-east-1.api.aws/anthropic/v1/messages",
|
||||
"headers": {
|
||||
"anthropic-beta": "interleaved-thinking-2025-05-14",
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"anthropic.claude-haiku-4-5\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is 19 multiplied by 23?\"},{\"type\":\"text\",\"text\":\"<system-update>\\nReply with only the integer.\\n</system-update>\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"stream\":true,\"max_tokens\":2048,\"thinking\":{\"type\":\"enabled\",\"budget_tokens\":1024}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_bdrk_prlwlyj7mdyfbm6ejq74ibwylnwd6deibs6j5xjq6lsc4dzuyzqa\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"container\":null,\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":65,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":1,\"service_tier\":\"standard\"}}}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\",\"signature\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"19\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" × 23 = ?\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"\\n\\nLet me calculate\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" this:\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"\\n19 × 23 =\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" 19 × (20 +\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" 3)\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"\\n= 19 × 20\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" + 19 × 3\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"\\n= 380 + 57\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"\\n= 437\\n\\nOr\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" alternatively\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\":\\n19 × 23 \"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"= (\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"20 - 1) × \"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"23\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"\\n= 20 × 23 \"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"- 23\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"\\n= 460 - 23\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"\\n= 437\\n\\nSo\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" the\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" answer is 437.\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"signature_delta\",\"signature\":\"ErEDCoQBCBIQARgCKkBdVqTX3D5rRVz4O3SlBHJaAUb4Jv4CxOT9igcrHKXsDtU6Wwh9dw7QJbtvpCA0UJh0Wi0PCzu1Tlp8W8jmIU9wMhljbGF1ZGUtaGFpa3UtNC01LTIwMjUxMDAxOABCCHRoaW5raW5nWgwyMzI3NzE4NTY3ODGoAeKch9YGEgyP1xZ80Q63y4Uko9EaDHZo9a+qBGcqeBRLcSIw5/J5NpbvXm6aIqCN58pfjKvW9/35kh6B+t2jS/7dssFzks6J4m+5jtG7BRf9x65GKtkBOhxxxMXrJDYW8RJkXU9iKampxOVFPJuSrvSw43H1MF4V/dt2RFIXeKMNmIJ4umr4nTNVtSlaIEUt+uYmT/F0QL4wXB1Pd40e4VMcBqkJ3CmHkspPJgkyLursJIpDVJQ4TMCHDBzvEObimdAgoAzHM8ahMVOzb9nW9C7ZGE7OFinrq/uR+AzXR0N4zyBIenjcDF76n+OFm3ap3S7ngBQl6+2enaRiseO/UZ4UzgrYYkxk/V4oobGytu64Dn+ngeceb7P+uszDG8BsDs3zXWQdi2w56OvD6HGFVhgB\"}}\n\nevent: content_block_stop\ndata: {Line truncated
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"model": "anthropic.claude-opus-4-8",
|
||||
"tags": [
|
||||
"prefix:bedrock-mantle-messages",
|
||||
"provider:amazon-bedrock",
|
||||
"protocol:anthropic-messages",
|
||||
"tool",
|
||||
"tool-loop",
|
||||
"reasoning",
|
||||
"system-update"
|
||||
],
|
||||
"name": "bedrock-mantle-messages/replays-signed-thinking-through-a-tool-loop-and-native-system-update",
|
||||
"recordedAt": "2026-10-04T04:05:26.433Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://bedrock-mantle.us-east-1.api.aws/anthropic/v1/messages",
|
||||
"headers": {
|
||||
"anthropic-beta": "interleaved-thinking-2025-05-14",
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"anthropic.claude-opus-4-8\",\"system\":[{\"type\":\"text\",\"text\":\"You are a concise assistant.\",\"cache_control\":{\"type\":\"ephemeral\"}}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"First calculate 37 * 43. Then call get_weather for Paris. After receiving the tool result, state both the product and the weather.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get the current weather in a city\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false},\"cache_control\":{\"type\":\"ephemeral\"}}],\"stream\":true,\"max_tokens\":2048,\"thinking\":{\"type\":\"adaptive\",\"display\":\"summarized\"},\"output_config\":{\"effort\":\"medium\"}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-opus-4-8\",\"id\":\"msg_bdrk_fycfxoffsnlt6mj5wu2urbd2psp276vwn4pddgbaazfim6fxcvhq\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"container\":null,\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":439,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":2,\"service_tier\":\"standard\"}}}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\",\"signature\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"37\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" times\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" 43\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" is 1591\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\", that\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" one\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"'s\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" quick to do\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" my\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"self. Now I'll go\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" ahead and call get\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"_weather.\\n\\n\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"signature_delta\",\"signature\":\"EqoDCngIEhABGAIqQIGpXRhzl8JEPJWxprG5sxaT1WjZ8/kEJgxZTN2qtzM5uC63GiumUjsHzfEIJikrldkFbwPbc0enJCXIoLr0G5UyDWNsYXVkZS1xdWluY2U4AEIIdGhpbmtpbmdaDDIzMjc3MTg1Njc4MagBhaGH1gYSDI7iCpVUnPgOaCirJBoMR20SZNRICFBH7f84IjDDaA7qNPHUlJN+eQa/aYqKn6Z2LobkjfWtscoBJs9sjR8qTy477EOozPy6lTkt1Pwq3wFcgWmJHdh5a0V2iRnSN4CtMEwxVPqC2im7vDCJDpNeAld8VmAO0u594guMJca+jme4lM/MW5oy3WrkmcL6Uc+qRByhUXhSqTWra8w9Xxf5S2nbPcT+FXjx1z1oDR1I8SIMzZrUM1DGOtQp7L/oWPadGwJYZ9EqKf9beRmSoc23kJKF5eA6+ZE9+HbS5gG1XxMDRFvx5rETS+mYMamOsfdowxWGWP7TzqHMhv+VGSCXoy2IXEIPBkXwen0K+dSAtgy/t21chzEmsGlBdBrAn7Ab0rCSqHvbZNZuA2T8CFPtGAE=\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\"37 × 43 \"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\"= 1591.\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":1}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":2,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_bdrk_011We3Z1qCBXge7JLoyNwDat\",\"name\":\"get_weather\",\"input\":{}}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":2,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":2,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"city\\\": \\\"Pa\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":2,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"ris\\\"}\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":2}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"container\":null},\"usage\":{\"input_tokens\":439,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":135,\"output_tokens_details\":{\"thinking_tokens\":73}}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://bedrock-mantle.us-east-1.api.aws/anthropic/v1/messages",
|
||||
"headers": {
|
||||
"anthropic-beta": "interleaved-thinking-2025-05-14",
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"anthropic.claude-opus-4-8\",\"system\":[{\"type\":\"text\",\"text\":\"You are a concise assistant.\",\"cache_control\":{\"type\":\"ephemeral\"}}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"First calculate 37 * 43. Then call get_weather for Paris. After receiving the tool result, state both the product and the weather.\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"thinking\",\"thinking\":\"37 times 43 is 1591, that one's quick to do myself. Now I'll go ahead and call get_weather.\\n\\n\",\"signature\":\"EqoDCngIEhABGAIqQIGpXRhzl8JEPJWxprG5sxaT1WjZ8/kEJgxZTN2qtzM5uC63GiumUjsHzfEIJikrldkFbwPbc0enJCXIoLr0G5UyDWNsYXVkZS1xdWluY2U4AEIIdGhpbmtpbmdaDDIzMjc3MTg1Njc4MagBhaGH1gYSDI7iCpVUnPgOaCirJBoMR20SZNRICFBH7f84IjDDaA7qNPHUlJN+eQa/aYqKn6Z2LobkjfWtscoBJs9sjR8qTy477EOozPy6lTkt1Pwq3wFcgWmJHdh5a0V2iRnSN4CtMEwxVPqC2im7vDCJDpNeAld8VmAO0u594guMJca+jme4lM/MW5oy3WrkmcL6Uc+qRByhUXhSqTWra8w9Xxf5S2nbPcT+FXjx1z1oDR1I8SIMzZrUM1DGOtQp7L/oWPadGwJYZ9EqKf9beRmSoc23kJKF5eA6+ZE9+HbS5gG1XxMDRFvx5rETS+mYMamOsfdowxWGWP7TzqHMhv+VGSCXoy2IXEIPBkXwen0K+dSAtgy/t21chzEmsGlBdBrAn7Ab0rCSqHvbZNZuA2T8CFPtGAE=\"},{\"type\":\"text\",\"text\":\"37 × 43 = 1591.\"},{\"type\":\"tool_use\",\"id\":\"toolu_bdrk_011We3Z1qCBXge7JLoyNwDat\",\"name\":\"get_weather\",\"input\":{\"city\":\"Paris\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"toolu_bdrk_011We3Z1qCBXge7JLoyNwDat\",\"content\":\"{\\\"condition\\\":\\\"sunny\\\",\\\"temperatureC\\\":18}\"}]},{\"role\":\"system\",\"content\":[{\"type\":\"text\",\"text\":\"Reply in French in one short sentence.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get the current weather in a city\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false},\"cache_control\":{\"type\":\"ephemeral\"}}],\"stream\":true,\"max_tokens\":2048,\"thinking\":{\"type\":\"adaptive\",\"display\":\"summarized\"},\"output_config\":{\"effort\":\"medium\"}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-opus-4-8\",\"id\":\"msg_bdrk_uak5dibczxblk4xunblaiowmid7zhky7ozwinjhpjtcbtgzvuwyq\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"container\":null,\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":611,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":3,\"service_tier\":\"standard\"}}}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Le produ\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"it est \"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"1591 \"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"et il\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" f\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ait beau à Paris av\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ec 18°C.\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"container\":null},\"usage\":{\"input_tokens\":611,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":28,\"output_tokens_details\":{\"thinking_tokens\":0}}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"model": "anthropic.claude-opus-5",
|
||||
"tags": [
|
||||
"prefix:bedrock-mantle-messages",
|
||||
"provider:amazon-bedrock",
|
||||
"protocol:anthropic-messages",
|
||||
"reasoning",
|
||||
"effort-update"
|
||||
],
|
||||
"name": "bedrock-mantle-messages/strips-unsupported-mid-conversation-effort-updates-on-opus-5-0",
|
||||
"recordedAt": "2026-10-04T03:56:23.956Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://bedrock-mantle.us-east-1.api.aws/anthropic/v1/messages",
|
||||
"headers": {
|
||||
"anthropic-beta": "interleaved-thinking-2025-05-14",
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"anthropic.claude-opus-5\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is 12 + 30?\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"42\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Add 8 to that result. Reply with only the integer.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"stream\":true,\"max_tokens\":1024,\"thinking\":{\"type\":\"adaptive\",\"display\":\"summarized\"},\"output_config\":{\"effort\":\"low\"}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-opus-5\",\"id\":\"msg_bdrk_5mwa5agz3cvj44w72lno46ljoylvzjhipc4jmraridpzxktjquha\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"container\":null,\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":38,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":1,\"service_tier\":\"standard\"}}}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"50\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"container\":null},\"usage\":{\"input_tokens\":38,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":3,\"output_tokens_details\":{\"thinking_tokens\":0}}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -7,67 +7,6 @@ const configuration = (provider: string, message: string) =>
|
||||
expect.objectContaining({ _tag: "ProviderConfiguration", provider, message })
|
||||
|
||||
describe("provider package entrypoints", () => {
|
||||
test("semantic API aliases expose the same contract", async () => {
|
||||
const modules = await Promise.all([
|
||||
import("@opencode/ai/providers/openai"),
|
||||
import("@opencode/ai/providers/openai/responses"),
|
||||
import("@opencode/ai/providers/openai/chat"),
|
||||
import("@opencode/ai/providers/anthropic"),
|
||||
import("@opencode/ai/providers/anthropic-compatible"),
|
||||
import("@opencode/ai/providers/openai-compatible"),
|
||||
import("@opencode/ai/providers/openai-compatible/responses"),
|
||||
import("@opencode/ai/providers/amazon-bedrock"),
|
||||
import("@opencode/ai/providers/azure"),
|
||||
import("@opencode/ai/providers/azure/responses"),
|
||||
import("@opencode/ai/providers/azure/chat"),
|
||||
import("@opencode/ai/providers/google"),
|
||||
import("@opencode/ai/providers/google-vertex"),
|
||||
import("@opencode/ai/providers/google-vertex/gemini"),
|
||||
import("@opencode/ai/providers/google-vertex/chat"),
|
||||
import("@opencode/ai/providers/google-vertex/responses"),
|
||||
import("@opencode/ai/providers/google-vertex/messages"),
|
||||
import("@opencode/ai/providers/openrouter"),
|
||||
import("@opencode/ai/providers/xai"),
|
||||
import("@opencode/ai/providers/amazon-bedrock/mantle"),
|
||||
import("@opencode/ai/providers/amazon-bedrock/mantle/chat"),
|
||||
import("@opencode/ai/providers/amazon-bedrock/mantle/responses"),
|
||||
import("@opencode/ai/providers/togetherai"),
|
||||
import("@opencode/ai/providers/cerebras"),
|
||||
import("@opencode/ai/providers/deepinfra"),
|
||||
import("@opencode/ai/providers/groq"),
|
||||
import("@opencode/ai/providers/baseten"),
|
||||
import("@opencode/ai/providers/deepseek"),
|
||||
import("@opencode/ai/providers/fireworks"),
|
||||
import("@opencode/ai/providers/cloudflare-ai-gateway"),
|
||||
import("@opencode/ai/providers/cloudflare-workers-ai"),
|
||||
import("@opencode/ai/providers/minimax"),
|
||||
import("@opencode/ai/providers/minimax/messages"),
|
||||
import("@opencode/ai/providers/minimax/chat"),
|
||||
import("@opencode/ai/providers/minimax/responses"),
|
||||
import("@opencode/ai/providers/moonshot"),
|
||||
import("@opencode/ai/providers/moonshot/chat"),
|
||||
import("@opencode/ai/providers/moonshot/messages"),
|
||||
import("@opencode/ai/providers/moonshot/responses"),
|
||||
import("@opencode/ai/providers/zai"),
|
||||
import("@opencode/ai/providers/zai/chat"),
|
||||
import("@opencode/ai/providers/zai-coding-plan"),
|
||||
import("@opencode/ai/providers/zai-coding-plan/chat"),
|
||||
import("@opencode/ai/providers/zai-coding-plan/messages"),
|
||||
import("@opencode/ai/providers/zai-coding-plan/responses"),
|
||||
import("@opencode/ai/providers/alibaba"),
|
||||
import("@opencode/ai/providers/alibaba/chat"),
|
||||
import("@opencode/ai/providers/alibaba/messages"),
|
||||
import("@opencode/ai/providers/alibaba/responses"),
|
||||
])
|
||||
|
||||
for (const module of modules) expect(module.model).toBeFunction()
|
||||
expect(modules[0].model).toBe(modules[1].model)
|
||||
expect(modules[8].model).toBe(modules[9].model)
|
||||
expect(modules[12].model).toBe(modules[13].model)
|
||||
expect(modules[19].model).toBe(modules[21].model)
|
||||
expect(modules[19].model).not.toBe(modules[20].model)
|
||||
})
|
||||
|
||||
test("maps Alibaba API entrypoints onto explicit regional routes", async () => {
|
||||
const modules = await Promise.all([
|
||||
import("@opencode/ai/providers/alibaba"),
|
||||
@@ -75,7 +14,6 @@ describe("provider package entrypoints", () => {
|
||||
import("@opencode/ai/providers/alibaba/messages"),
|
||||
import("@opencode/ai/providers/alibaba/responses"),
|
||||
])
|
||||
expect(modules[0].model).toBe(modules[1].model)
|
||||
const settings = {
|
||||
region: "eu-central-1",
|
||||
workspaceID: "llm-fixture",
|
||||
@@ -103,7 +41,6 @@ describe("provider package entrypoints", () => {
|
||||
import("@opencode/ai/providers/moonshot/messages"),
|
||||
import("@opencode/ai/providers/moonshot/responses"),
|
||||
])
|
||||
expect(modules[0].model).toBe(modules[1].model)
|
||||
const settings = {
|
||||
apiKey: "fixture",
|
||||
baseURL: "https://gateway.example/v1",
|
||||
@@ -147,7 +84,6 @@ describe("provider package entrypoints", () => {
|
||||
import("@opencode/ai/providers/minimax/chat"),
|
||||
import("@opencode/ai/providers/minimax/responses"),
|
||||
])
|
||||
expect(modules[0].model).toBe(modules[1].model)
|
||||
const settings = {
|
||||
apiKey: "fixture",
|
||||
baseURL: "https://gateway.example/v1",
|
||||
@@ -174,8 +110,6 @@ describe("provider package entrypoints", () => {
|
||||
import("@opencode/ai/providers/zai-coding-plan/messages"),
|
||||
import("@opencode/ai/providers/zai-coding-plan/responses"),
|
||||
])
|
||||
expect(modules[0].model).toBe(modules[1].model)
|
||||
expect(modules[2].model).toBe(modules[3].model)
|
||||
const routes = [
|
||||
"zai-chat",
|
||||
"zai-chat",
|
||||
@@ -458,7 +392,6 @@ describe("provider package entrypoints", () => {
|
||||
|
||||
test("selects Vertex entrypoints with the same model contract", async () => {
|
||||
const GoogleVertex = await import("@opencode/ai/providers/google-vertex")
|
||||
const GoogleVertexGemini = await import("@opencode/ai/providers/google-vertex/gemini")
|
||||
const GoogleVertexChat = await import("@opencode/ai/providers/google-vertex/chat")
|
||||
const GoogleVertexResponses = await import("@opencode/ai/providers/google-vertex/responses")
|
||||
const GoogleVertexMessages = await import("@opencode/ai/providers/google-vertex/messages")
|
||||
@@ -483,7 +416,6 @@ describe("provider package entrypoints", () => {
|
||||
project: "vertex-project",
|
||||
})
|
||||
|
||||
expect(GoogleVertexGemini.model).toBe(GoogleVertex.model)
|
||||
expect(gemini.route.id).toBe("google-vertex-gemini")
|
||||
expect(gemini.route.protocol).toBe("gemini")
|
||||
expect(gemini.route.endpoint.baseURL).toBe("https://aiplatform.googleapis.com/v1/publishers/google")
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM, Message } from "../../src/index.js"
|
||||
import { LLM, LLMEvent, LLMRequest, Message, ToolDefinition } from "../../src/index.js"
|
||||
import { AmazonBedrockMantle } from "../../src/providers.js"
|
||||
import { model } from "../../src/providers/amazon-bedrock/mantle.js"
|
||||
import { OpenResponses } from "../../src/protocols/open-responses.js"
|
||||
import { compileRequest, LLMClient } from "../../src/route/client.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
@@ -19,14 +18,14 @@ const credentials = {
|
||||
}
|
||||
|
||||
describe("Amazon Bedrock Mantle provider", () => {
|
||||
it.effect("uses Responses by default and exposes Chat explicitly", () =>
|
||||
it.effect("uses Responses by default and exposes Chat and Messages explicitly", () =>
|
||||
Effect.gen(function* () {
|
||||
const provider = AmazonBedrockMantle.configure({ credentials })
|
||||
expect(provider.model).toBe(provider.responses)
|
||||
expect(AmazonBedrockMantle.model).toBe(AmazonBedrockMantle.responsesModel)
|
||||
expect(model).toBe(AmazonBedrockMantle.responsesModel)
|
||||
expect(provider.model("openai.gpt-oss-120b").route.transport).toBe(OpenResponses.httpTransport)
|
||||
const chat = yield* compileRequest(LLM.request({ model: provider.chat("openai.gpt-oss-120b"), prompt: "Hi" }))
|
||||
const messages = yield* compileRequest(
|
||||
LLM.request({ model: provider.messages("anthropic.claude-opus-4-8"), prompt: "Hi", cache: "none" }),
|
||||
)
|
||||
const responses = yield* compileRequest(
|
||||
LLM.request({ model: provider.model("openai.gpt-oss-120b"), prompt: "Hi" }),
|
||||
)
|
||||
@@ -36,6 +35,11 @@ describe("Amazon Bedrock Mantle provider", () => {
|
||||
protocol: "openai-chat",
|
||||
body: { model: "openai.gpt-oss-120b" },
|
||||
})
|
||||
expect(messages).toMatchObject({
|
||||
route: "bedrock-mantle-messages",
|
||||
protocol: "anthropic-messages",
|
||||
body: { model: "anthropic.claude-opus-4-8", stream: true },
|
||||
})
|
||||
expect(responses).toMatchObject({
|
||||
route: "bedrock-mantle-responses",
|
||||
protocol: "open-responses",
|
||||
@@ -43,43 +47,77 @@ describe("Amazon Bedrock Mantle provider", () => {
|
||||
})
|
||||
expect(provider.model("openai.gpt-oss-120b").route.providerMetadataKey).toBe("mantle")
|
||||
expect(provider.chat("openai.gpt-oss-120b").route.providerMetadataKey).toBe("mantle")
|
||||
expect(provider.messages("anthropic.claude-opus-4-8").route.providerMetadataKey).toBe("mantle")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves configured top-p generation defaults for Chat and Responses", () =>
|
||||
it.effect("preserves configured top-p generation defaults for Chat, Messages, and Responses", () =>
|
||||
Effect.gen(function* () {
|
||||
const settings = { apiKey: "test-key", topP: 0.8 }
|
||||
const chat = yield* compileRequest(
|
||||
LLM.request({ model: AmazonBedrockMantle.chatModel("openai.gpt-oss-safeguard-20b", settings), prompt: "Hi" }),
|
||||
)
|
||||
const messages = yield* compileRequest(
|
||||
LLM.request({ model: AmazonBedrockMantle.messagesModel("anthropic.claude-opus-4-8", settings), prompt: "Hi" }),
|
||||
)
|
||||
const responses = yield* compileRequest(
|
||||
LLM.request({ model: AmazonBedrockMantle.responsesModel("openai.gpt-oss-120b", settings), prompt: "Hi" }),
|
||||
)
|
||||
|
||||
expect(chat.body.top_p).toBe(0.8)
|
||||
expect(messages.body.top_p).toBe(0.8)
|
||||
expect(responses.body.top_p).toBe(0.8)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses the Mantle endpoint and signing service", () =>
|
||||
it.effect("uses the Mantle endpoint and signing service across Responses and Messages", () =>
|
||||
Effect.gen(function* () {
|
||||
const seen: Array<{ readonly url: string; readonly authorization: string | undefined }> = []
|
||||
const model = AmazonBedrockMantle.configure({ credentials, region: "us-west-1" }).responses("openai.gpt-oss-120b")
|
||||
yield* LLMClient.generate(LLM.request({ model, prompt: "Hi" })).pipe(
|
||||
Effect.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpClientRequest.toWeb(input.request)
|
||||
seen.push({ url: request.url, authorization: request.headers.get("authorization") ?? undefined })
|
||||
return input.respond("", { headers: { "content-type": "text/event-stream" } })
|
||||
}),
|
||||
const configured = AmazonBedrockMantle.configure({ credentials, region: "us-west-1" })
|
||||
for (const selected of [
|
||||
configured.responses("openai.gpt-oss-120b"),
|
||||
configured.messages("anthropic.claude-opus-4-8"),
|
||||
]) {
|
||||
yield* LLMClient.generate(LLM.request({ model: selected, prompt: "Hi" })).pipe(
|
||||
Effect.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpClientRequest.toWeb(input.request)
|
||||
seen.push({ url: request.url, authorization: request.headers.get("authorization") ?? undefined })
|
||||
return input.respond("", { headers: { "content-type": "text/event-stream" } })
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
Effect.flip,
|
||||
)
|
||||
}
|
||||
|
||||
expect(seen.map((item) => item.url)).toEqual([
|
||||
"https://bedrock-mantle.us-west-1.api.aws/v1/responses",
|
||||
"https://bedrock-mantle.us-west-1.api.aws/anthropic/v1/messages",
|
||||
])
|
||||
expect(seen.every((item) => item.authorization?.includes("/us-west-1/bedrock-mantle/aws4_request"))).toBe(true)
|
||||
}).pipe(withProcessEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined })),
|
||||
)
|
||||
|
||||
it.effect("applies inline cache breakpoints on Mantle Messages", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = AmazonBedrockMantle.configure({ apiKey: "test-key" }).messages("anthropic.claude-opus-4-8")
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
system: "You are concise.",
|
||||
messages: [Message.user("Hello")],
|
||||
cache: "auto",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(seen[0]?.url).toBe("https://bedrock-mantle.us-west-1.api.aws/v1/responses")
|
||||
expect(seen[0]?.authorization).toContain("/us-west-1/bedrock-mantle/aws4_request")
|
||||
expect(prepared.body.system).toEqual([
|
||||
{ type: "text", text: "You are concise.", cache_control: { type: "ephemeral" } },
|
||||
])
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{ role: "user", content: [{ type: "text", text: "Hello", cache_control: { type: "ephemeral" } }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -201,3 +239,198 @@ describe("Amazon Bedrock Mantle recorded", () => {
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const recordedMessages = recordedTests({
|
||||
prefix: "bedrock-mantle-messages",
|
||||
provider: "amazon-bedrock",
|
||||
protocol: "anthropic-messages",
|
||||
requires: ["AWS_BEARER_TOKEN_BEDROCK"],
|
||||
options: { redact: { allowRequestHeaders: ["anthropic-version", "anthropic-beta"] } },
|
||||
})
|
||||
|
||||
const mantleMessages = (modelID: string) =>
|
||||
AmazonBedrockMantle.configure({
|
||||
apiKey: process.env.AWS_BEARER_TOKEN_BEDROCK ?? "fixture",
|
||||
region: "us-east-1",
|
||||
}).messages(modelID)
|
||||
|
||||
const weatherTool = ToolDefinition.make({
|
||||
name: "get_weather",
|
||||
description: "Get the current weather in a city",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { city: { type: "string", enum: ["Paris"] } },
|
||||
required: ["city"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
})
|
||||
|
||||
describe("Amazon Bedrock Mantle Messages recorded", () => {
|
||||
recordedMessages.effect.with(
|
||||
"replays signed thinking through a tool loop and native system update",
|
||||
{ tags: ["tool", "tool-loop", "reasoning", "system-update"], metadata: { model: "anthropic.claude-opus-4-8" } },
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const model = mantleMessages("anthropic.claude-opus-4-8")
|
||||
const initial = LLM.request({
|
||||
model,
|
||||
system: "You are a concise assistant.",
|
||||
prompt:
|
||||
"First calculate 37 * 43. Then call get_weather for Paris. After receiving the tool result, state both the product and the weather.",
|
||||
tools: [weatherTool],
|
||||
providerOptions: {
|
||||
thinking: { type: "adaptive", display: "summarized" },
|
||||
effort: "medium",
|
||||
},
|
||||
generation: { maxTokens: 2048 },
|
||||
})
|
||||
const first = yield* LLMClient.generate(initial)
|
||||
expect(first.finishReason.normalized).toBe("tool-calls")
|
||||
expect(first.toolCalls).toMatchObject([{ name: "get_weather", input: { city: "Paris" } }])
|
||||
expect(first.reasoning.length).toBeGreaterThan(0)
|
||||
expect(first.events.some(LLMEvent.is.toolInputDelta)).toBe(true)
|
||||
const reasoningPart = first.message.content.find((part) => part.type === "reasoning")
|
||||
const signature = (reasoningPart?.providerMetadata?.mantle as { readonly signature?: unknown } | undefined)
|
||||
?.signature
|
||||
expect(typeof signature).toBe("string")
|
||||
|
||||
const followUp = LLMRequest.update(initial, {
|
||||
messages: [
|
||||
...initial.messages,
|
||||
first.message,
|
||||
...first.toolCalls.map((call) =>
|
||||
Message.tool({ id: call.id, name: call.name, result: { condition: "sunny", temperatureC: 18 } }),
|
||||
),
|
||||
Message.system("Reply in French in one short sentence."),
|
||||
],
|
||||
})
|
||||
const compiled = yield* compileRequest(followUp)
|
||||
expect(compiled.body.messages[1]?.content[0]).toEqual({
|
||||
type: "thinking",
|
||||
thinking: first.reasoning,
|
||||
signature: signature as string,
|
||||
})
|
||||
expect(compiled.body.messages.at(-1)).toEqual({
|
||||
role: "system",
|
||||
content: [
|
||||
{ type: "text", text: "Reply in French in one short sentence.", cache_control: { type: "ephemeral" } },
|
||||
],
|
||||
})
|
||||
|
||||
const second = yield* LLMClient.generate(followUp)
|
||||
expect(second.finishReason.normalized).toBe("stop")
|
||||
expect(second.text).toContain("1591")
|
||||
expect(second.text).toContain("18")
|
||||
}),
|
||||
120_000,
|
||||
)
|
||||
|
||||
recordedMessages.effect.with(
|
||||
"lowers system updates to wrapped user text on Haiku 4.5 with budget thinking",
|
||||
{ tags: ["reasoning", "system-update"], metadata: { model: "anthropic.claude-haiku-4-5" } },
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const request = LLM.request({
|
||||
model: mantleMessages("anthropic.claude-haiku-4-5"),
|
||||
messages: [Message.user("What is 19 multiplied by 23?"), Message.system("Reply with only the integer.")],
|
||||
providerOptions: {
|
||||
thinking: { type: "enabled", budgetTokens: 1024 },
|
||||
},
|
||||
generation: { maxTokens: 2048 },
|
||||
})
|
||||
const compiled = yield* compileRequest(request)
|
||||
expect(compiled.body.thinking).toEqual({ type: "enabled", budget_tokens: 1024 })
|
||||
expect(compiled.body.messages.some((message) => message.role === "system")).toBe(false)
|
||||
|
||||
const response = yield* LLMClient.generate(request)
|
||||
expect(response.finishReason.normalized).toBe("stop")
|
||||
expect(response.reasoning.length).toBeGreaterThan(0)
|
||||
expect(response.text.trim()).toContain("437")
|
||||
}),
|
||||
120_000,
|
||||
)
|
||||
|
||||
recordedMessages.effect.with(
|
||||
"applies mid-conversation effort updates and thinking block binding on Opus 5.5",
|
||||
{ tags: ["reasoning", "effort-update"], metadata: { model: "anthropic.claude-opus-5-5" } },
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const model = mantleMessages("anthropic.claude-opus-5-5")
|
||||
const firstRequest = LLM.request({
|
||||
model,
|
||||
prompt: "Compute 37 * 43 step by step, then reply with only the integer.",
|
||||
providerOptions: {
|
||||
thinking: { type: "adaptive", display: "summarized" },
|
||||
effort: "high",
|
||||
},
|
||||
generation: { maxTokens: 2048 },
|
||||
})
|
||||
const firstCompiled = yield* compileRequest(firstRequest)
|
||||
expect(firstCompiled.body.thinking).toEqual({
|
||||
type: "adaptive",
|
||||
display: "summarized",
|
||||
block_binding: { prefix_mismatch_behavior: "drop_block" },
|
||||
})
|
||||
|
||||
const first = yield* LLMClient.generate(firstRequest)
|
||||
expect(first.finishReason.normalized).toBe("stop")
|
||||
expect(first.reasoning.length).toBeGreaterThan(0)
|
||||
expect(first.text.replaceAll(",", "")).toContain("1591")
|
||||
|
||||
const secondRequest = LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
...firstRequest.messages,
|
||||
first.message,
|
||||
Message.effort({ effort: "low", previous: "high" }),
|
||||
Message.user("Add 9 to that result. Reply with only the integer."),
|
||||
],
|
||||
providerOptions: {
|
||||
thinking: { type: "adaptive", display: "summarized" },
|
||||
effort: "low",
|
||||
},
|
||||
generation: { maxTokens: 2048 },
|
||||
})
|
||||
const secondCompiled = yield* compileRequest(secondRequest)
|
||||
expect(secondCompiled.body.output_config).toEqual({ effort: "high" })
|
||||
expect(secondCompiled.body.messages.filter((message) => message.role === "system")).toEqual([
|
||||
{ role: "system", content: [], output_config: { effort: "low" } },
|
||||
])
|
||||
|
||||
const second = yield* LLMClient.generate(secondRequest)
|
||||
expect(second.finishReason.normalized).toBe("stop")
|
||||
expect(second.text.replaceAll(",", "")).toContain("1600")
|
||||
}),
|
||||
120_000,
|
||||
)
|
||||
|
||||
recordedMessages.effect.with(
|
||||
"strips unsupported mid-conversation effort updates on Opus 5.0",
|
||||
{ tags: ["reasoning", "effort-update"], metadata: { model: "anthropic.claude-opus-5" } },
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const request = LLM.request({
|
||||
model: mantleMessages("anthropic.claude-opus-5"),
|
||||
messages: [
|
||||
Message.user("What is 12 + 30?"),
|
||||
Message.assistant("42"),
|
||||
Message.effort({ effort: "low", previous: "high" }),
|
||||
Message.user("Add 8 to that result. Reply with only the integer."),
|
||||
],
|
||||
providerOptions: {
|
||||
thinking: { type: "adaptive", display: "summarized" },
|
||||
effort: "low",
|
||||
},
|
||||
generation: { maxTokens: 1024 },
|
||||
})
|
||||
const compiled = yield* compileRequest(request)
|
||||
expect(compiled.body.output_config).toEqual({ effort: "low" })
|
||||
expect(compiled.body.messages.some((message) => message.role === "system")).toBe(false)
|
||||
|
||||
const response = yield* LLMClient.generate(request)
|
||||
expect(response.finishReason.normalized).toBe("stop")
|
||||
expect(response.text.trim()).toContain("50")
|
||||
}),
|
||||
120_000,
|
||||
)
|
||||
})
|
||||
@@ -43,6 +43,7 @@ describe("native OpenAI-compatible providers", () => {
|
||||
[Azure.configure({ resourceName: "resource", apiKey: "test" }).responses("model"), "azure"],
|
||||
[AmazonBedrock.configure({ apiKey: "test" }).model("model"), "bedrock"],
|
||||
[AmazonBedrockMantle.configure({ apiKey: "test" }).chat("model"), "mantle"],
|
||||
[AmazonBedrockMantle.configure({ apiKey: "test" }).messages("model"), "mantle"],
|
||||
[AmazonBedrockMantle.configure({ apiKey: "test" }).responses("model"), "mantle"],
|
||||
[Google.configure({ apiKey: "test" }).model("model"), "google"],
|
||||
[GoogleVertex.configure(vertex).model("model"), "vertex"],
|
||||
|
||||
@@ -15,21 +15,25 @@ export function mountTimelineVirtualizer(input: { count: number; rowHeight: numb
|
||||
|
||||
function Fixture() {
|
||||
const [state, setState] = createStore({ pinned: true, ready: false })
|
||||
|
||||
const rows = Array.from(
|
||||
{ length: input.count },
|
||||
(_, index) => new TimelineRow.UserMessage({ userMessageID: `message-${index}` }),
|
||||
)
|
||||
|
||||
const rowByKey = new Map(rows.map((row) => [TimelineRow.key(row), row]))
|
||||
const indexes = new Map(rows.map((row, index) => [row.userMessageID, index]))
|
||||
let viewport!: HTMLDivElement
|
||||
let content!: HTMLDivElement
|
||||
let container!: HTMLDivElement
|
||||
|
||||
const timeline = createTimelineVirtualizer({
|
||||
sessionKey: () => "cold-reveal-fixture",
|
||||
projection: {
|
||||
rows: () => rows,
|
||||
rowByKey: () => rowByKey,
|
||||
activeMessageID: () => undefined,
|
||||
messageByID: () => new Map(),
|
||||
messageRowIndex: () => indexes,
|
||||
messageLastRowIndex: () => indexes,
|
||||
},
|
||||
@@ -62,6 +66,7 @@ export function mountTimelineVirtualizer(input: { count: number; rowHeight: numb
|
||||
host.dataset.observedHeight = String(entries[0].borderBoxSize[0].blockSize)
|
||||
host.dataset.viewportResizes = String(Number(host.dataset.viewportResizes) + 1)
|
||||
})
|
||||
|
||||
const reveal = new MutationObserver(() => {
|
||||
if (content.style.visibility === "hidden" || host.dataset.firstReveal) return
|
||||
// Capture the first reveal, not a later frame after geometry has recovered.
|
||||
@@ -71,11 +76,14 @@ export function mountTimelineVirtualizer(input: { count: number; rowHeight: numb
|
||||
pendingMarkdown: content.querySelectorAll('[data-component="markdown"]:not([data-markdown-ready])').length,
|
||||
viewportHeight: viewport.clientHeight,
|
||||
scrollTop: viewport.scrollTop,
|
||||
clipped: mounted
|
||||
.filter((element) => element.firstElementChild!.getBoundingClientRect().height > element.offsetHeight + 1)
|
||||
.map((element) => element.dataset.timelineKey),
|
||||
clipped: mounted.flatMap((element) =>
|
||||
element.firstElementChild!.getBoundingClientRect().height > element.offsetHeight + 1
|
||||
? [element.dataset.timelineKey]
|
||||
: [],
|
||||
),
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
resize.disconnect()
|
||||
reveal.disconnect()
|
||||
|
||||
@@ -7,6 +7,7 @@ test.use({ permissions: ["clipboard-read", "clipboard-write"] })
|
||||
async function draft(page: Page) {
|
||||
const { editor } = await openDraft(page, { name: "ComposerDraft", provider: NO_PROVIDER })
|
||||
await editor.click()
|
||||
|
||||
return editor
|
||||
}
|
||||
|
||||
@@ -15,9 +16,11 @@ async function expectCaretVisible(input: Locator) {
|
||||
.poll(() =>
|
||||
input.evaluate((element) => {
|
||||
const selection = window.getSelection()
|
||||
|
||||
if (!selection?.isCollapsed || !selection.rangeCount || !element.contains(selection.anchorNode)) return false
|
||||
const caret = selection.getRangeAt(0).getBoundingClientRect()
|
||||
const viewport = (element.closest("[data-scrollable]") ?? element).getBoundingClientRect()
|
||||
|
||||
return caret.height > 0 && caret.top >= viewport.top - 1 && caret.bottom <= viewport.bottom + 1
|
||||
}),
|
||||
)
|
||||
@@ -28,11 +31,14 @@ test("keeps a 25000-line crash report editable in a new session", async ({ page
|
||||
const input = await draft(page)
|
||||
const text = "Thread 0 Crashed:\n" + "0 Example 0x0000000100000000 frame + 32\n".repeat(25000) + "End of report"
|
||||
await page.evaluate((text) => navigator.clipboard.writeText(text), text)
|
||||
|
||||
const events = await input.evaluateHandle((element) => {
|
||||
const events = { count: 0 }
|
||||
element.addEventListener("input", () => events.count++)
|
||||
|
||||
return events
|
||||
})
|
||||
|
||||
await page.keyboard.press("ControlOrMeta+V")
|
||||
await expect.poll(async () => (await input.innerText()) === text).toBe(true)
|
||||
expect(await events.evaluate((events) => events.count)).toBe(1)
|
||||
@@ -46,6 +52,7 @@ test("keeps a 25000-line crash report editable in a new session", async ({ page
|
||||
await expectCaretVisible(input)
|
||||
const thumb = await scroll.locator(".scroll-view__thumb").boundingBox()
|
||||
const bounds = await scroll.boundingBox()
|
||||
|
||||
if (!thumb || !bounds) throw new Error("Missing composer scrollbar bounds")
|
||||
await page.mouse.move(thumb.x + thumb.width / 2, thumb.y + thumb.height / 2)
|
||||
await page.mouse.down()
|
||||
@@ -58,6 +65,26 @@ test("keeps a 25000-line crash report editable in a new session", async ({ page
|
||||
await expectCaretVisible(input)
|
||||
})
|
||||
|
||||
test("a key-up never moves the caret back after later navigation", async ({ page }) => {
|
||||
const input = await draft(page)
|
||||
await input.pressSequentially("first line")
|
||||
await expect(input).toHaveText("first line")
|
||||
|
||||
// Navigation can land before the next frame; the cursor a key-up recorded must not overwrite it there.
|
||||
const offset = await input.evaluate(async (element) => {
|
||||
const text = document.createTreeWalker(element, NodeFilter.SHOW_TEXT).nextNode()!
|
||||
const selection = window.getSelection()!
|
||||
selection.collapse(text, 0)
|
||||
element.dispatchEvent(new KeyboardEvent("keyup", { key: "Home", bubbles: true }))
|
||||
selection.collapse(text, text.textContent!.length)
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))
|
||||
|
||||
return selection.anchorOffset
|
||||
})
|
||||
|
||||
expect(offset).toBe("first line".length)
|
||||
})
|
||||
|
||||
for (const [width, direction] of [
|
||||
[390, "rtl"],
|
||||
[1280, "ltr"],
|
||||
@@ -83,6 +110,7 @@ for (const [width, direction] of [
|
||||
|
||||
test("pastes plain text without markup and keeps native undo", async ({ page }) => {
|
||||
const input = await draft(page)
|
||||
|
||||
for (const text of [
|
||||
"single line <b> &",
|
||||
"first\nsecond",
|
||||
@@ -110,13 +138,16 @@ test("replaces only the selected text and leaves the caret after the paste", asy
|
||||
await page.keyboard.type("before replace after")
|
||||
await expect(input).toHaveText("before replace after")
|
||||
await page.evaluate(() => document.fonts.ready)
|
||||
|
||||
const word = await input.evaluate((element) => {
|
||||
const range = document.createRange()
|
||||
range.setStart(element.firstChild!, 7)
|
||||
range.setEnd(element.firstChild!, 14)
|
||||
const rect = range.getBoundingClientRect()
|
||||
|
||||
return { x: rect.x, y: rect.y + rect.height / 2, width: rect.width }
|
||||
})
|
||||
|
||||
await page.mouse.move(word.x, word.y)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(word.x + word.width, word.y, { steps: 5 })
|
||||
@@ -141,9 +172,11 @@ test("shows the dropzone and attaches a dropped file", async ({ page }) => {
|
||||
})
|
||||
const surface = page.locator('[data-component="new-session"]')
|
||||
const dropzone = page.locator('[data-component="session-dropzone"]')
|
||||
|
||||
const transfer = await page.evaluateHandle(() => {
|
||||
const value = new DataTransfer()
|
||||
value.items.add(new File(["Dropzone fixture"], "dropzone.txt", { type: "text/plain" }))
|
||||
|
||||
return value
|
||||
})
|
||||
|
||||
@@ -194,6 +227,7 @@ test("lists slash commands in their built-in order", async ({ page }) => {
|
||||
// A sent message enables /undo, /compact and /fork.
|
||||
pageMessages: () => ({ items: [{ id: "msg_slash", type: "user", text: "Hello", time: { created: T0 } }] }),
|
||||
})
|
||||
|
||||
await editor.fill("/")
|
||||
await expect(page.locator('[data-component="composer-suggestions"] [data-suggestion-id] bdi')).toHaveText([
|
||||
"/init",
|
||||
@@ -213,6 +247,7 @@ test("lists slash commands in their built-in order", async ({ page }) => {
|
||||
})
|
||||
|
||||
const followUp = "Add follow-up, / for commands, @ for context…"
|
||||
|
||||
for (const row of [
|
||||
{ state: "an idle", copy: "Ask anything, / for commands, @ for context…" },
|
||||
{ state: "a running", copy: followUp, sessionStatus: { ses_placeholder: { type: "running" } } },
|
||||
@@ -238,6 +273,7 @@ for (const row of [
|
||||
sessionStatus: row.sessionStatus,
|
||||
inbox: row.inbox,
|
||||
})
|
||||
|
||||
const scroll = page.locator('[data-component="composer-scroll"]')
|
||||
await expect(scroll).toHaveText(row.copy)
|
||||
await editor.pressSequentially("!")
|
||||
@@ -250,6 +286,7 @@ test("shows thinking on hover or a non-default selection while preserving keyboa
|
||||
name: "ComposerThinking",
|
||||
provider: provider({ id: "thinking-model", name: "Thinking Model", variants: { high: {} } }),
|
||||
})
|
||||
|
||||
const composer = page.locator('[data-component="composer"]')
|
||||
const control = composer.getByRole("button", { name: "Choose model variant" })
|
||||
const option = (name: string) => page.getByRole("menuitemradio", { name, exact: true })
|
||||
|
||||
@@ -833,6 +833,32 @@ test("a chosen mode the session stops offering shows as Git, and comes back when
|
||||
await expect(file).toHaveText("beta.ts")
|
||||
})
|
||||
|
||||
test("names a single change source and keeps the review header rows at 48px", async ({ page }) => {
|
||||
// Turn changes are offered only for Git, so another VCS offers one source.
|
||||
await openSession(page, { name: "ReviewHeader", project: { vcs: "hg" }, vcsDiff: [fileDiff("src/alpha.ts")] })
|
||||
await page.getByRole("button", { name: "Toggle review", exact: true }).click()
|
||||
const panel = page.locator("#review-panel")
|
||||
const toggle = panel.getByRole("button", { name: "Toggle file tree" })
|
||||
const header = panel.locator('[data-slot="session-review-v2-sidebar-header"]')
|
||||
const toolbar = panel.locator('[data-slot="session-review-v2-toolbar"]')
|
||||
|
||||
await expect(header.getByText("Git changes", { exact: true })).toBeVisible()
|
||||
await expect(panel.getByRole("button", { name: "Git changes" })).toHaveCount(0)
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(header).toHaveCSS("height", "48px")
|
||||
await expect(toolbar).toHaveCSS("height", "48px")
|
||||
|
||||
await toggle.click()
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(toolbar.getByText("Git changes", { exact: true })).toBeVisible()
|
||||
await expect(toolbar).toHaveCSS("height", "48px")
|
||||
|
||||
await toggle.click()
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(header).toHaveCSS("height", "48px")
|
||||
await expect(toolbar).toHaveCSS("height", "48px")
|
||||
})
|
||||
|
||||
for (const direction of ["ltr", "rtl"] as const) {
|
||||
test(`review toggle stays at the header edge in ${direction}`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1000, height: 900 })
|
||||
|
||||
@@ -29,12 +29,14 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
await expect(page.locator('[data-slot="titlebar-v2"]').getByRole("button", { name: "Status" })).toHaveCount(0)
|
||||
const titleBounds = await header.getByRole("heading").boundingBox()
|
||||
expect(titleBounds).not.toBeNull()
|
||||
|
||||
for (const editing of [false, true]) {
|
||||
if (editing) {
|
||||
await header.getByRole("heading").click()
|
||||
await expect(header.getByRole("textbox")).toHaveValue(fixture.expected.targetTitle)
|
||||
await expect(header.getByRole("textbox")).toBeFocused()
|
||||
}
|
||||
|
||||
await expect(header.locator('[data-slot="session-title-child"]')).toHaveCSS("padding-left", "4px")
|
||||
await expect(header.locator('[data-slot="session-title-child"]')).toHaveCSS("padding-right", "4px")
|
||||
await expect
|
||||
@@ -44,8 +46,11 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
control.boundingBox(),
|
||||
),
|
||||
)
|
||||
|
||||
if (!icon || !title || !menu || !sidebar || !summary || !titleBounds) return false
|
||||
|
||||
if (Math.abs(title.y - titleBounds.y) > 0.5 || Math.abs(title.height - titleBounds.height) > 0.5) return false
|
||||
|
||||
return direction === "ltr"
|
||||
? Math.abs(title.x - icon.x - icon.width - 2) <= 0.5 &&
|
||||
Math.abs(menu.x - title.x - title.width - 2) <= 0.5 &&
|
||||
@@ -58,6 +63,7 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
})
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
await header.getByRole("textbox").press("Escape")
|
||||
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
|
||||
|
||||
@@ -70,6 +76,7 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
await more.click()
|
||||
const options = page.getByRole("menu")
|
||||
await expect(options.getByRole("menuitem")).toHaveText(["Rename", "Export…", "Delete…"])
|
||||
|
||||
if (direction === "ltr") {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
@@ -77,20 +84,24 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
header.getByRole("button", { name: "More options", exact: true, includeHidden: true }).boundingBox(),
|
||||
options.boundingBox(),
|
||||
])
|
||||
|
||||
return button && menu ? Math.abs(button.x - menu.x) : Infinity
|
||||
})
|
||||
.toBeLessThanOrEqual(1)
|
||||
}
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
options.evaluate((element) => {
|
||||
const menu = element.getBoundingClientRect()
|
||||
const rtl = getComputedStyle(element).direction === "rtl"
|
||||
|
||||
return Math.min(
|
||||
...Array.from(element.querySelectorAll('[data-slot="menu-v2-item-content"]'), (label) => {
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(label)
|
||||
const text = range.getBoundingClientRect()
|
||||
|
||||
return rtl ? text.left - menu.left : menu.right - text.right
|
||||
}),
|
||||
)
|
||||
@@ -103,6 +114,7 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
const menu = element.getBoundingClientRect()
|
||||
const divider = element.querySelector('[data-slot="menu-v2-separator"]')?.getBoundingClientRect()
|
||||
const rows = Array.from(element.querySelectorAll('[role="menuitem"]'), (row) => row.getBoundingClientRect())
|
||||
|
||||
return (
|
||||
!!divider &&
|
||||
Math.abs(divider.left - menu.left) <= 0.5 &&
|
||||
@@ -135,6 +147,7 @@ test.describe("rename", () => {
|
||||
renames.length = 0
|
||||
page.on("request", (request) => {
|
||||
const match = new URL(request.url()).pathname.match(/^\/api\/session\/([^/]+)$/)
|
||||
|
||||
if (request.method() === "PATCH" && match) renames.push({ sessionID: match[1]!, body: request.postDataJSON() })
|
||||
})
|
||||
await mockStressTimeline(page)
|
||||
@@ -149,8 +162,11 @@ test.describe("rename", () => {
|
||||
const input = page.locator('input[data-slot="session-title-child"]')
|
||||
await expect(input).toBeFocused()
|
||||
await input.fill("Renamed session")
|
||||
|
||||
if (commit === "Enter") await input.press("Enter")
|
||||
|
||||
if (commit === "blur") await input.press("Tab")
|
||||
|
||||
if (commit === "click outside") await page.locator('[data-component="composer-editor"]').click()
|
||||
await expect(heading(page, "Renamed session")).toBeVisible()
|
||||
await expect(tabs(page).filter({ hasText: "Renamed session" })).toBeVisible()
|
||||
@@ -252,6 +268,7 @@ test.describe("revert", () => {
|
||||
},
|
||||
{ id: "msg_second", type: "user", text: "Second prompt", time: { created: 4 } },
|
||||
] satisfies SessionMessageInfo[]
|
||||
|
||||
const workspace = {
|
||||
name: "SessionMessageRevert",
|
||||
pageMessages: () => ({ items: messages }),
|
||||
@@ -260,18 +277,54 @@ test.describe("revert", () => {
|
||||
test("reverts directly to the selected user message", async ({ page }) => {
|
||||
const staged: { sessionID: string; messageID: string }[] = []
|
||||
const { session } = await openSession(page, { ...workspace, onRevertStage: (input) => staged.push(input) })
|
||||
const settles: string[] = []
|
||||
page.on("request", (request) => {
|
||||
const path = new URL(request.url()).pathname
|
||||
const step = ["/interrupt", "/wait", "/revert/stage"].find((suffix) => path.endsWith(`/${session.id}${suffix}`))
|
||||
|
||||
if (step) settles.push(step)
|
||||
})
|
||||
const message = page.locator('[data-message-id="msg_second"]')
|
||||
await message.hover()
|
||||
|
||||
const response = page.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === "POST" &&
|
||||
new URL(response.url()).pathname === `/api/session/${session.id}/revert/stage`,
|
||||
)
|
||||
|
||||
await message.getByRole("button", { name: "Revert message" }).click()
|
||||
expect((await response).ok()).toBe(true)
|
||||
|
||||
await expect(page.getByRole("textbox", { name: "Prompt" })).toHaveText("Second prompt")
|
||||
expect(staged).toEqual([{ sessionID: session.id, messageID: "msg_second" }])
|
||||
// Interrupt acknowledges before the run settles, and the server refuses to stage while it is active.
|
||||
expect(settles).toEqual(["/interrupt", "/wait", "/revert/stage"])
|
||||
})
|
||||
|
||||
test("redo restores every reverted message at once, as in the TUI", async ({ page }) => {
|
||||
const staged: { sessionID: string; messageID: string }[] = []
|
||||
|
||||
const { editor } = await openSession(page, {
|
||||
...workspace,
|
||||
sessions: [{ id: "ses_revert_redo", title: "Session message revert", revert: { messageID: "msg_first" } }],
|
||||
onRevertStage: (input) => staged.push(input),
|
||||
})
|
||||
|
||||
const cleared = page.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === "DELETE" &&
|
||||
new URL(response.url()).pathname === "/api/session/ses_revert_redo/revert",
|
||||
)
|
||||
|
||||
await editor.pressSequentially("/redo")
|
||||
await expect(
|
||||
page.locator('[data-component="composer-suggestions"] [data-suggestion-id][data-active]'),
|
||||
).toContainText("/redo")
|
||||
await editor.press("Enter")
|
||||
|
||||
expect((await cleared).ok()).toBe(true)
|
||||
expect(staged).toEqual([])
|
||||
})
|
||||
|
||||
test("hides revert actions in a child session", async ({ page }) => {
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import type { OpenCodeEvent, SessionInboxInfo, SessionMessageInfo } from "@opencode/client/promise"
|
||||
import { PromptInput } from "@opencode/schema/prompt-input"
|
||||
import { SessionInbox } from "@opencode/schema/session-inbox"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import { Schema } from "effect"
|
||||
import { provider } from "../utils/app"
|
||||
import type { MockServerConfig } from "../utils/mock-server"
|
||||
import { openSession } from "../utils/workspace"
|
||||
|
||||
const sessionID = "ses_session_queue_regression"
|
||||
|
||||
// The session.prompt payload, as the server's endpoint validates it.
|
||||
const PromptBody = Schema.Struct({
|
||||
id: SessionMessage.ID.pipe(Schema.optional),
|
||||
...PromptInput.Prompt.fields,
|
||||
metadata: SessionInbox.UserPayload.fields.metadata,
|
||||
delivery: SessionInbox.Delivery.pipe(Schema.optional),
|
||||
resume: Schema.Boolean.pipe(Schema.optional),
|
||||
})
|
||||
|
||||
const decodePromptBody = Schema.decodeUnknownSync(PromptBody)
|
||||
|
||||
type InboxRow = {
|
||||
id: string
|
||||
sessionID: string
|
||||
@@ -12,11 +28,26 @@ type InboxRow = {
|
||||
type: "user"
|
||||
payload: {
|
||||
text: string
|
||||
metadata?: Record<string, unknown>
|
||||
metadata?: typeof PromptBody.Type.metadata
|
||||
files?: Extract<SessionInboxInfo, { type: "user" }>["payload"]["files"]
|
||||
agents?: Extract<SessionInboxInfo, { type: "user" }>["payload"]["agents"]
|
||||
}
|
||||
delivery: "steer" | "queue"
|
||||
delivery: SessionInbox.Delivery
|
||||
}
|
||||
|
||||
function storedFile(file: NonNullable<typeof PromptBody.Type.files>[number]) {
|
||||
const inline = file.uri.startsWith("data:")
|
||||
|
||||
const stored: NonNullable<InboxRow["payload"]["files"]>[number] = {
|
||||
data: inline ? file.uri.slice(file.uri.indexOf(",") + 1) : "",
|
||||
mime: inline ? file.uri.slice("data:".length, file.uri.indexOf(";")) : "text/plain",
|
||||
source: inline ? { type: "inline" } : { type: "uri", uri: file.uri },
|
||||
name: file.name,
|
||||
description: file.description,
|
||||
mention: file.mention,
|
||||
}
|
||||
|
||||
return stored
|
||||
}
|
||||
|
||||
function createQueueMock(seed: string[], messages: SessionMessageInfo[] = []) {
|
||||
@@ -28,16 +59,20 @@ function createQueueMock(seed: string[], messages: SessionMessageInfo[] = []) {
|
||||
payload: { text },
|
||||
delivery: "queue",
|
||||
}))
|
||||
|
||||
const events: OpenCodeEvent[] = []
|
||||
const prompts: Record<string, unknown>[] = []
|
||||
const prompts: (typeof PromptBody.Type)[] = []
|
||||
const compactions: SessionInboxInfo[] = []
|
||||
const changes: { inboxID: string; action: "cancel" | "steer" | "queue" }[] = []
|
||||
const log: string[] = []
|
||||
let sequence = 0
|
||||
|
||||
const emit = <Type extends OpenCodeEvent["type"]>(
|
||||
type: Type,
|
||||
data: Extract<OpenCodeEvent, { type: Type }>["data"],
|
||||
) => {
|
||||
sequence += 1
|
||||
// SAFETY: `type` selects `data` from the same OpenCodeEvent member, so the pair is that member.
|
||||
events.push({
|
||||
id: `evt_queue_${sequence}`,
|
||||
type,
|
||||
@@ -46,28 +81,37 @@ function createQueueMock(seed: string[], messages: SessionMessageInfo[] = []) {
|
||||
data,
|
||||
} as OpenCodeEvent)
|
||||
}
|
||||
|
||||
return {
|
||||
rows,
|
||||
compactions,
|
||||
prompts,
|
||||
changes,
|
||||
log,
|
||||
messages,
|
||||
emit,
|
||||
events: () => events.splice(0),
|
||||
onPrompt: (input: { sessionID: string; body: Record<string, unknown> }) => {
|
||||
prompts.push(input.body)
|
||||
log.push(`prompt:${String(input.body.delivery ?? "steer")}`)
|
||||
onPrompt: (input: Parameters<NonNullable<MockServerConfig["onPrompt"]>>[0]) => {
|
||||
const body = decodePromptBody(input.body)
|
||||
prompts.push(body)
|
||||
log.push(`prompt:${body.delivery ?? "steer"}`)
|
||||
|
||||
const row: InboxRow = {
|
||||
id: typeof input.body.id === "string" ? input.body.id : `inb_mock_${sequence}`,
|
||||
id: body.id ?? `inb_mock_${sequence}`,
|
||||
sessionID: input.sessionID,
|
||||
time: { created: Date.now() },
|
||||
type: "user",
|
||||
payload: {
|
||||
text: typeof input.body.text === "string" ? input.body.text : "",
|
||||
...(input.body.metadata === undefined ? {} : { metadata: input.body.metadata as Record<string, unknown> }),
|
||||
},
|
||||
delivery: input.body.delivery === "queue" ? "queue" : "steer",
|
||||
payload: { text: body.text },
|
||||
delivery: body.delivery ?? "steer",
|
||||
}
|
||||
|
||||
if (body.metadata !== undefined) row.payload.metadata = body.metadata
|
||||
|
||||
// Store attachments the way the server materializes them, so re-admissions round-trip.
|
||||
if (body.files !== undefined) row.payload.files = body.files.map(storedFile)
|
||||
|
||||
if (body.agents !== undefined)
|
||||
row.payload.agents = body.agents.map((agent) => ({ name: agent.name, mention: agent.mention }))
|
||||
rows.push(row)
|
||||
emit("session.inbox.enqueued", {
|
||||
sessionID: input.sessionID,
|
||||
@@ -75,17 +119,32 @@ function createQueueMock(seed: string[], messages: SessionMessageInfo[] = []) {
|
||||
item: { type: "user", payload: row.payload, delivery: row.delivery },
|
||||
})
|
||||
},
|
||||
onCompact: (input: Parameters<NonNullable<MockServerConfig["onCompact"]>>[0]) => {
|
||||
log.push("compact")
|
||||
compactions.push({
|
||||
id: Schema.decodeUnknownSync(SessionMessage.ID)(input.body.id),
|
||||
sessionID: input.sessionID,
|
||||
time: { created: Date.now() },
|
||||
type: "compaction",
|
||||
payload: {},
|
||||
delivery: "steer",
|
||||
})
|
||||
},
|
||||
onInboxChange: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" | "queue" }) => {
|
||||
changes.push({ inboxID: input.inboxID, action: input.action })
|
||||
log.push(`${input.action}:${input.inboxID}`)
|
||||
const index = rows.findIndex((row) => row.id === input.inboxID)
|
||||
const row = rows[index]
|
||||
|
||||
if (!row) return
|
||||
|
||||
if (input.action === "cancel") {
|
||||
rows.splice(index, 1)
|
||||
emit("session.inbox.cancelled", { sessionID: input.sessionID, inboxID: input.inboxID })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
row.delivery = input.action
|
||||
emit("session.inbox.delivery.changed", {
|
||||
sessionID: input.sessionID,
|
||||
@@ -96,21 +155,38 @@ function createQueueMock(seed: string[], messages: SessionMessageInfo[] = []) {
|
||||
}
|
||||
}
|
||||
|
||||
async function openQueue(page: Page, mock: ReturnType<typeof createQueueMock>, followUpBehavior?: "queue" | "steer") {
|
||||
async function openQueue(
|
||||
page: Page,
|
||||
mock: ReturnType<typeof createQueueMock>,
|
||||
followUpBehavior?: "queue" | "steer",
|
||||
revert?: string,
|
||||
) {
|
||||
const model = { id: "queue-model", name: "Queue Model" }
|
||||
await openSession(page, {
|
||||
|
||||
const options: Parameters<typeof openSession>[1] = {
|
||||
name: "SessionQueueRegression",
|
||||
sessions: [{ id: sessionID, title: "Session queue regression", model: { id: model.id, providerID: "opencode" } }],
|
||||
sessions: [
|
||||
{
|
||||
id: sessionID,
|
||||
title: "Session queue regression",
|
||||
model: { id: model.id, providerID: "opencode" },
|
||||
revert: revert ? { messageID: revert } : undefined,
|
||||
},
|
||||
],
|
||||
provider: provider(model),
|
||||
pageMessages: () => ({ items: mock.messages }),
|
||||
sessionStatus: () => ({ [sessionID]: { type: "running" } }),
|
||||
inbox: () => mock.rows.map((row) => ({ ...row, payload: { ...row.payload } })),
|
||||
inbox: () => [...mock.rows.map((row) => ({ ...row, payload: { ...row.payload } })), ...mock.compactions],
|
||||
onPrompt: mock.onPrompt,
|
||||
onCompact: mock.onCompact,
|
||||
onInboxChange: mock.onInboxChange,
|
||||
events: mock.events,
|
||||
...(followUpBehavior ? { seed: { settings: { general: { followUpBehavior } } } } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
if (followUpBehavior) options.seed = { settings: { general: { followUpBehavior } } }
|
||||
await openSession(page, options)
|
||||
const composer = page.locator('[data-component="composer"]')
|
||||
|
||||
return {
|
||||
composer,
|
||||
input: composer.locator('[data-component="composer-editor"]'),
|
||||
@@ -118,6 +194,20 @@ async function openQueue(page: Page, mock: ReturnType<typeof createQueueMock>, f
|
||||
}
|
||||
}
|
||||
|
||||
async function runSlash(page: Page, input: Awaited<ReturnType<typeof openQueue>>["input"], name: string) {
|
||||
await input.pressSequentially(`/${name}`)
|
||||
await expect(page.locator('[data-component="composer-suggestions"] [data-suggestion-id][data-active]')).toContainText(
|
||||
`/${name}`,
|
||||
)
|
||||
await input.press("Enter")
|
||||
}
|
||||
|
||||
function userRow(page: Page, messageID: string) {
|
||||
return page.locator(
|
||||
`[data-timeline-virtual-content] [data-timeline-row="UserMessage"][data-message-id="${messageID}"]`,
|
||||
)
|
||||
}
|
||||
|
||||
test("follow-up preference controls Enter while Mod+Enter uses the alternate delivery", async ({ page }) => {
|
||||
const mock = createQueueMock([])
|
||||
const view = await openQueue(page, mock, "queue")
|
||||
@@ -135,6 +225,9 @@ test("follow-up preference controls Enter while Mod+Enter uses the alternate del
|
||||
|
||||
test("dragging reorders queued prompts", async ({ page }) => {
|
||||
const mock = createQueueMock(["first queued prompt", "second queued prompt", "third queued prompt"])
|
||||
mock.rows[0].payload.files = [
|
||||
{ data: "aGk=", mime: "text/plain", source: { type: "uri", uri: "file:///repo/main.ts" }, name: "main.ts" },
|
||||
]
|
||||
const view = await openQueue(page, mock)
|
||||
await expect(view.rows).toHaveCount(3)
|
||||
|
||||
@@ -143,6 +236,7 @@ test("dragging reorders queued prompts", async ({ page }) => {
|
||||
await first.getByRole("button", { name: "Reorder queued prompt" }).hover()
|
||||
await page.mouse.down()
|
||||
const target = await third.boundingBox()
|
||||
|
||||
if (!target) throw new Error("The target queue row is not visible")
|
||||
await page.mouse.move(target.x + target.width / 2, target.y + target.height / 2, { steps: 10 })
|
||||
await page.mouse.up()
|
||||
@@ -157,6 +251,8 @@ test("dragging reorders queued prompts", async ({ page }) => {
|
||||
"third queued prompt",
|
||||
"first queued prompt",
|
||||
])
|
||||
// A re-admitted file keeps the URI it came from instead of becoming an inline snapshot.
|
||||
expect(mock.prompts[2].files).toMatchObject([{ uri: "file:///repo/main.ts", name: "main.ts" }])
|
||||
expect(mock.changes).toEqual([
|
||||
{ inboxID: "inb_seed_1", action: "cancel" },
|
||||
{ inboxID: "inb_seed_2", action: "cancel" },
|
||||
@@ -164,6 +260,56 @@ test("dragging reorders queued prompts", async ({ page }) => {
|
||||
])
|
||||
})
|
||||
|
||||
for (const change of ["reorder", "edit"] as const) {
|
||||
test(`a ${change} that cannot re-admit a prompt leaves the queue unchanged`, async ({ page }) => {
|
||||
const order = ["first queued prompt", "second queued prompt", "third queued prompt"]
|
||||
const mock = createQueueMock(order)
|
||||
const admit = mock.onPrompt
|
||||
// The last prompt's re-admission fails after the server admitted it, so its response is lost.
|
||||
mock.onPrompt = (input) => {
|
||||
admit(input)
|
||||
|
||||
if (input.body.text === order[2]) throw new Error("Connection lost after admission")
|
||||
}
|
||||
|
||||
const view = await openQueue(page, mock)
|
||||
await expect(view.rows).toHaveCount(3)
|
||||
|
||||
if (change === "reorder") {
|
||||
const first = view.rows.filter({ hasText: order[0] })
|
||||
const second = view.rows.filter({ hasText: order[1] })
|
||||
await first.getByRole("button", { name: "Reorder queued prompt" }).hover()
|
||||
await page.mouse.down()
|
||||
const target = await second.boundingBox()
|
||||
|
||||
if (!target) throw new Error("The target queue row is not visible")
|
||||
await page.mouse.move(target.x + target.width / 2, target.y + target.height / 2, { steps: 10 })
|
||||
await page.mouse.up()
|
||||
}
|
||||
|
||||
if (change === "edit") {
|
||||
await view.rows.getByText(order[1], { exact: true }).click()
|
||||
await expect(view.input).toHaveText(order[1])
|
||||
await expect(view.input).toBeFocused()
|
||||
await view.input.press("End")
|
||||
await view.input.pressSequentially(", edited")
|
||||
await view.input.press("Enter")
|
||||
}
|
||||
|
||||
// The replacements admitted before the failure are withdrawn; the originals stay in place.
|
||||
await expect.poll(() => mock.log.includes(`cancel:${mock.prompts.at(-1)?.id}`)).toBe(true)
|
||||
await expect.poll(() => mock.changes.length).toBe(mock.prompts.length)
|
||||
expect(mock.changes.every((entry) => entry.action === "cancel" && !entry.inboxID.startsWith("inb_seed_"))).toBe(
|
||||
true,
|
||||
)
|
||||
await expect(view.rows.locator('[data-action="session-queue-edit"]')).toHaveText(order)
|
||||
expect(mock.rows.map((row) => row.payload.text)).toEqual(order)
|
||||
|
||||
// A failed edit keeps its draft in the composer so nothing typed is lost.
|
||||
if (change === "edit") await expect(view.input).toHaveText("second queued prompt, edited")
|
||||
})
|
||||
}
|
||||
|
||||
test("editing restores the existing draft and replaces only the original queue position", async ({ page }) => {
|
||||
const mock = createQueueMock(["first queued prompt", "tighten the error copy", "third queued prompt"])
|
||||
const view = await openQueue(page, mock)
|
||||
@@ -180,7 +326,9 @@ test("editing restores the existing draft and replaces only the original queue p
|
||||
await original.click()
|
||||
await expect(view.input).toHaveText("tighten the error copy")
|
||||
await expect(view.input).toBeFocused()
|
||||
await view.input.fill("tighten the error copy and add a retry hint")
|
||||
// fill() on a composer that holds text can append, so extend the loaded text instead.
|
||||
await view.input.press("End")
|
||||
await view.input.pressSequentially(" and add a retry hint")
|
||||
await expect(view.input).toHaveText("tighten the error copy and add a retry hint")
|
||||
await view.input.press("Enter")
|
||||
|
||||
@@ -190,16 +338,123 @@ test("editing restores the existing draft and replaces only the original queue p
|
||||
"third queued prompt",
|
||||
])
|
||||
await expect(view.input).toHaveText("my in-progress draft")
|
||||
// One rewrite from the edited position: the replacement, then the prompts after it.
|
||||
expect(mock.prompts.map((prompt) => prompt.text)).toEqual([
|
||||
"tighten the error copy and add a retry hint",
|
||||
"tighten the error copy and add a retry hint",
|
||||
"third queued prompt",
|
||||
])
|
||||
expect(mock.prompts.every((prompt) => prompt.delivery === "queue" && prompt.resume === false)).toBe(true)
|
||||
expect(mock.changes.map((change) => change.action)).toEqual(["cancel", "cancel", "cancel"])
|
||||
expect(mock.changes).toEqual([
|
||||
{ inboxID: "inb_seed_2", action: "cancel" },
|
||||
{ inboxID: "inb_seed_3", action: "cancel" },
|
||||
])
|
||||
expect(mock.log[0]).toBe("prompt:queue")
|
||||
})
|
||||
|
||||
for (const delivery of ["queue", "steer"] as const) {
|
||||
test(`editing a prompt the server delivered meanwhile keeps the edit draft (${delivery})`, async ({ page }) => {
|
||||
const mock = createQueueMock(["first queued prompt", "second queued prompt"])
|
||||
const view = await openQueue(page, mock, "queue")
|
||||
await view.rows.getByText("second queued prompt", { exact: true }).click()
|
||||
await expect(view.input).toHaveText("second queued prompt")
|
||||
await expect(view.input).toBeFocused()
|
||||
await view.input.press("End")
|
||||
await view.input.pressSequentially(", edited")
|
||||
// The server delivers the original before the edit lands; this client has not heard yet.
|
||||
mock.rows.splice(1, 1)
|
||||
await view.input.press(delivery === "queue" ? "Enter" : "ControlOrMeta+Enter")
|
||||
|
||||
await expect(page.getByText("Request failed")).toBeVisible()
|
||||
await expect(view.input).toHaveText("second queued prompt, edited")
|
||||
expect(mock.prompts).toEqual([])
|
||||
expect(mock.changes).toEqual([])
|
||||
})
|
||||
}
|
||||
|
||||
for (const change of ["reorder", "edit"] as const) {
|
||||
test(`a staged revert refuses a queue ${change}, whose admission would commit it`, async ({ page }) => {
|
||||
const order = ["first queued prompt", "second queued prompt"]
|
||||
|
||||
const mock = createQueueMock(order, [
|
||||
{ id: "msg_queue_delivered", type: "user", text: "First prompt", time: { created: 1 } },
|
||||
])
|
||||
|
||||
const view = await openQueue(page, mock, undefined, "msg_queue_delivered")
|
||||
await expect(view.rows).toHaveCount(2)
|
||||
|
||||
if (change === "reorder") {
|
||||
await view.rows.filter({ hasText: order[0] }).getByRole("button", { name: "Reorder queued prompt" }).hover()
|
||||
await page.mouse.down()
|
||||
const target = await view.rows.filter({ hasText: order[1] }).boundingBox()
|
||||
|
||||
if (!target) throw new Error("The target queue row is not visible")
|
||||
await page.mouse.move(target.x + target.width / 2, target.y + target.height / 2, { steps: 10 })
|
||||
await page.mouse.up()
|
||||
}
|
||||
|
||||
if (change === "edit") await view.rows.getByText(order[1], { exact: true }).click()
|
||||
|
||||
await expect(page.getByText("Redo the revert before you reorder or edit queued prompts")).toBeVisible()
|
||||
await expect(view.rows.locator('[data-action="session-queue-edit"]')).toHaveText(order)
|
||||
await expect(view.input).toHaveText("")
|
||||
expect(mock.prompts).toEqual([])
|
||||
expect(mock.changes).toEqual([])
|
||||
})
|
||||
}
|
||||
|
||||
test("editing drops a file whose mention was deleted and keeps unmentioned context", async ({ page }) => {
|
||||
const mock = createQueueMock(["inspect @main.ts here"])
|
||||
mock.rows[0].payload.files = [
|
||||
{
|
||||
data: "aGk=",
|
||||
mime: "text/plain",
|
||||
source: { type: "uri", uri: "file:///repo/main.ts" },
|
||||
name: "main.ts",
|
||||
mention: { start: 8, end: 16, text: "@main.ts" },
|
||||
},
|
||||
{ data: "bm90ZXM=", mime: "text/plain", source: { type: "uri", uri: "file:///repo/notes.md" }, name: "notes.md" },
|
||||
]
|
||||
const view = await openQueue(page, mock)
|
||||
await view.rows.getByText("inspect @main.ts here", { exact: true }).click()
|
||||
await expect(view.input).toHaveText("inspect @main.ts here")
|
||||
// The edit focuses the composer a frame after loading it; keys sent earlier miss it.
|
||||
await expect(view.input).toBeFocused()
|
||||
await view.input.press("ControlOrMeta+a")
|
||||
await view.input.pressSequentially("inspect here")
|
||||
await view.input.press("Enter")
|
||||
|
||||
await expect.poll(() => mock.prompts.length).toBe(1)
|
||||
expect(mock.prompts[0].files?.map((file) => file.uri)).toEqual(["file:///repo/notes.md"])
|
||||
})
|
||||
|
||||
test("editing a comment-only prompt keeps its notes once", async ({ page }) => {
|
||||
const note = "The user made the following comment regarding line 2 of /repo/app.ts: check the guard"
|
||||
const mock = createQueueMock([note])
|
||||
mock.rows[0].payload.metadata = {
|
||||
displayText: "",
|
||||
comments: [
|
||||
{
|
||||
path: "/repo/app.ts",
|
||||
comment: "check the guard",
|
||||
selection: { startLine: 2, startChar: 0, endLine: 2, endChar: 0 },
|
||||
origin: "review",
|
||||
},
|
||||
],
|
||||
}
|
||||
const view = await openQueue(page, mock)
|
||||
// With no display text, the editor shows the note itself, so the edit owns it as text.
|
||||
await view.rows.getByText(note, { exact: true }).click()
|
||||
await expect(view.input).toHaveText(note)
|
||||
await view.input.press("End")
|
||||
await view.input.pressSequentially(", please")
|
||||
await expect(view.input).toHaveText(`${note}, please`)
|
||||
await view.input.press("Enter")
|
||||
|
||||
await expect.poll(() => mock.prompts.length).toBe(1)
|
||||
expect(mock.prompts[0].text).toBe(`${note}, please`)
|
||||
expect(mock.prompts[0].metadata).toMatchObject({ displayText: `${note}, please`, comments: [] })
|
||||
})
|
||||
|
||||
test("Undo cancels only the selected queued prompt and focuses the restored input", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
const text = "Review the detailed error report and check every step of the retry path ".repeat(4)
|
||||
@@ -241,6 +496,47 @@ test("Undo appends to an existing draft and restores inline attachments", async
|
||||
expect(mock.changes).toEqual([{ inboxID: "inb_seed_1", action: "cancel" }])
|
||||
})
|
||||
|
||||
test("Undo of a comment-only prompt keeps the draft text and restores the comment once", async ({ page }) => {
|
||||
const comment = "check the guard"
|
||||
const mock = createQueueMock([`The user made the following comment regarding line 2 of /repo/app.ts: ${comment}`])
|
||||
mock.rows[0].payload.metadata = {
|
||||
displayText: "",
|
||||
comments: [
|
||||
{
|
||||
path: "/repo/app.ts",
|
||||
comment,
|
||||
selection: { startLine: 2, startChar: 0, endLine: 2, endChar: 0 },
|
||||
origin: "review",
|
||||
},
|
||||
],
|
||||
}
|
||||
mock.rows[0].payload.files = [
|
||||
{
|
||||
data: "aGk=",
|
||||
mime: "text/plain",
|
||||
source: { type: "uri", uri: "file:///repo/app.ts?start=2&end=2" },
|
||||
name: "app.ts",
|
||||
},
|
||||
]
|
||||
const view = await openQueue(page, mock)
|
||||
await view.input.fill("my draft")
|
||||
await view.rows.getByRole("button", { name: "Undo" }).click()
|
||||
|
||||
await expect(view.rows).toHaveCount(0)
|
||||
await expect(view.input).toHaveText("my draft")
|
||||
const cards = view.composer.locator('[data-component="composer-attachments"] [data-component="attachment-card"]')
|
||||
await expect(cards).toHaveCount(1)
|
||||
await expect(cards).toContainText(comment)
|
||||
|
||||
// The resubmission carries the draft text with no stray break and the comment's file exactly once.
|
||||
await view.input.press("Enter")
|
||||
await expect.poll(() => mock.prompts.length).toBe(1)
|
||||
expect(mock.prompts[0].text).toBe(
|
||||
`my draft\nThe user made the following comment regarding line 2 of /repo/app.ts: ${comment}`,
|
||||
)
|
||||
expect(mock.prompts[0].files?.map((file) => file.uri)).toEqual(["file:///repo/app.ts?start=2&end=2"])
|
||||
})
|
||||
|
||||
test("Undo preserves mentioned file and agent references on resubmission", async ({ page }) => {
|
||||
const mock = createQueueMock(["inspect @main.ts with @build"])
|
||||
mock.rows[0].payload.files = [
|
||||
@@ -258,23 +554,256 @@ test("Undo preserves mentioned file and agent references on resubmission", async
|
||||
await expect(view.input).toHaveText("inspect @main.ts with @build")
|
||||
await view.input.press("Enter")
|
||||
await expect.poll(() => mock.prompts.length).toBe(1)
|
||||
// Like the TUI, a restored mention points at the file it named rather than a snapshot of it.
|
||||
expect(mock.prompts[0].files).toMatchObject([
|
||||
{ uri: "data:text/plain;base64,aGk=", mention: { text: "@main.ts", start: 8, end: 16 } },
|
||||
{ uri: "file:///repo/main.ts", mention: { text: "@main.ts", start: 8, end: 16 } },
|
||||
])
|
||||
expect(mock.prompts[0].agents).toMatchObject([{ name: "build", mention: { text: "@build" } }])
|
||||
})
|
||||
|
||||
test("Undo does not discard hidden file context", async ({ page }) => {
|
||||
const mock = createQueueMock(["inspect this file"])
|
||||
mock.rows[0].payload.files = [
|
||||
{ data: "aGk=", mime: "text/plain", source: { type: "uri", uri: "file:///repo/main.ts" }, name: "main.ts" },
|
||||
for (const delivery of ["queue", "steer"] as const) {
|
||||
test(`${delivery === "queue" ? "Undo" : "/undo of a pending steer"} keeps unmentioned file context`, async ({
|
||||
page,
|
||||
}) => {
|
||||
// Another client (for example ACP) can attach a file without mentioning it.
|
||||
const mock = createQueueMock(["inspect this file"])
|
||||
const inboxID = mock.rows[0].id
|
||||
mock.rows[0].delivery = delivery
|
||||
mock.rows[0].payload.files = [
|
||||
{
|
||||
data: "aGk=",
|
||||
mime: "text/plain",
|
||||
source: { type: "uri", uri: "file:///repo/main.ts" },
|
||||
name: "main.ts",
|
||||
description: "the failing version",
|
||||
},
|
||||
]
|
||||
const view = await openQueue(page, mock)
|
||||
|
||||
if (delivery === "queue") await view.rows.getByRole("button", { name: "Undo" }).click()
|
||||
|
||||
if (delivery === "steer") {
|
||||
await expect(userRow(page, inboxID)).toContainText("inspect this file")
|
||||
await runSlash(page, view.input, "undo")
|
||||
}
|
||||
|
||||
// Like the TUI, the file returns attached without a mention, as a chip rather than prompt text.
|
||||
const chip = view.composer.locator('[data-slot="composer-context-file"]')
|
||||
|
||||
await expect(view.input).toHaveText("inspect this file")
|
||||
await expect(chip).toHaveCount(1)
|
||||
await expect(chip).toContainText("main.ts")
|
||||
expect(mock.changes).toEqual([{ inboxID, action: "cancel" }])
|
||||
|
||||
// Like the TUI's blank Enter, a chip alone sends nothing: without text the draft reads as blank.
|
||||
await expect(view.input).toBeFocused()
|
||||
await view.input.press("ControlOrMeta+a")
|
||||
await view.input.press("Backspace")
|
||||
await expect(view.input).toHaveText("")
|
||||
await expect(view.composer.locator('[data-action="composer-submit"]')).toHaveAttribute("aria-label", "Stop")
|
||||
await expect(chip).toHaveCount(1)
|
||||
|
||||
await view.input.pressSequentially("inspect this file")
|
||||
await view.input.press("Enter")
|
||||
await expect.poll(() => mock.prompts.length).toBe(1)
|
||||
expect(mock.prompts[0].files).toMatchObject([
|
||||
{ uri: "file:///repo/main.ts", name: "main.ts", description: "the failing version" },
|
||||
])
|
||||
expect(mock.prompts[0].files?.[0]?.mention).toBeUndefined()
|
||||
await expect(chip).toHaveCount(0)
|
||||
})
|
||||
}
|
||||
|
||||
test("reverting to another prompt replaces the file chips of an earlier restore", async ({ page }) => {
|
||||
const mock = createQueueMock(
|
||||
[],
|
||||
[
|
||||
{ id: "msg_queue_first", type: "user", text: "First prompt", time: { created: 1 } },
|
||||
{
|
||||
id: "msg_queue_second",
|
||||
type: "user",
|
||||
text: "Second prompt",
|
||||
files: [
|
||||
{ data: "aGk=", mime: "text/plain", source: { type: "uri", uri: "file:///repo/main.ts" }, name: "main.ts" },
|
||||
],
|
||||
time: { created: 2 },
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
const view = await openQueue(page, mock)
|
||||
const chip = view.composer.locator('[data-slot="composer-context-file"]')
|
||||
|
||||
for (const [id, text, chips] of [
|
||||
["msg_queue_second", "Second prompt", 1],
|
||||
["msg_queue_first", "First prompt", 0],
|
||||
] as const) {
|
||||
const row = userRow(page, id)
|
||||
|
||||
await row.hover()
|
||||
await row.getByRole("button", { name: "Revert message" }).click()
|
||||
await expect(view.input).toHaveText(text)
|
||||
await expect(chip).toHaveCount(chips)
|
||||
}
|
||||
})
|
||||
|
||||
test("/undo withdraws a pending steer without interrupting the running session", async ({ page }) => {
|
||||
const mock = createQueueMock([])
|
||||
const view = await openQueue(page, mock, "steer")
|
||||
const stops: string[] = []
|
||||
page.on("request", (request) => {
|
||||
const path = new URL(request.url()).pathname
|
||||
|
||||
if (path.endsWith("/interrupt") || path.endsWith("/wait") || path.endsWith("/revert/stage")) stops.push(path)
|
||||
})
|
||||
const text = "U2: Also check the retry path."
|
||||
await view.input.fill(text)
|
||||
await view.input.press("Enter")
|
||||
await expect.poll(() => mock.rows.map((row) => row.delivery)).toEqual(["steer"])
|
||||
await expect(view.input).toHaveText("")
|
||||
|
||||
const inboxID = mock.rows[0].id
|
||||
const pending = userRow(page, inboxID)
|
||||
|
||||
await expect(pending).toContainText(text)
|
||||
await runSlash(page, view.input, "undo")
|
||||
|
||||
await expect(pending).toHaveCount(0)
|
||||
await expect(view.input).toHaveText(text)
|
||||
expect(mock.changes).toEqual([{ inboxID, action: "cancel" }])
|
||||
expect(stops).toEqual([])
|
||||
})
|
||||
|
||||
test("reverting a delivered prompt leaves queued prompts alone, as in the TUI", async ({ page }) => {
|
||||
const mock = createQueueMock(
|
||||
["U2: queued follow-up"],
|
||||
[{ id: "msg_queue_delivered", type: "user", text: "First prompt", time: { created: 1 } }],
|
||||
)
|
||||
|
||||
const view = await openQueue(page, mock)
|
||||
const delivered = userRow(page, "msg_queue_delivered")
|
||||
|
||||
const staged = page.waitForResponse(
|
||||
(response) => new URL(response.url()).pathname === `/api/session/${sessionID}/revert/stage`,
|
||||
)
|
||||
|
||||
await delivered.hover()
|
||||
await delivered.getByRole("button", { name: "Revert message" }).click()
|
||||
expect((await staged).ok()).toBe(true)
|
||||
|
||||
await expect(view.input).toHaveText("First prompt")
|
||||
expect(mock.changes).toEqual([])
|
||||
await expect(view.rows).toHaveCount(1)
|
||||
})
|
||||
|
||||
test("a shell command cannot wait in the queue, as in the TUI", async ({ page }) => {
|
||||
const mock = createQueueMock([])
|
||||
const view = await openQueue(page, mock, "queue")
|
||||
const shells: string[] = []
|
||||
page.on("request", (request) => {
|
||||
if (new URL(request.url()).pathname.endsWith(`/${sessionID}/shell`)) shells.push(request.url())
|
||||
})
|
||||
|
||||
await view.input.pressSequentially("!")
|
||||
await view.input.pressSequentially("git status")
|
||||
await view.input.press("Enter")
|
||||
|
||||
await expect(page.getByText("This prompt cannot be queued")).toBeVisible()
|
||||
await expect(view.input).toHaveText("git status")
|
||||
expect(shells).toEqual([])
|
||||
expect(mock.prompts).toEqual([])
|
||||
})
|
||||
|
||||
test("/compact runs with the composer model and shows a queued compaction, as in the TUI", async ({ page }) => {
|
||||
const mock = createQueueMock(
|
||||
[],
|
||||
[{ id: "msg_queue_delivered", type: "user", text: "First prompt", time: { created: 1 } }],
|
||||
)
|
||||
|
||||
const view = await openQueue(page, mock)
|
||||
const models: unknown[] = []
|
||||
page.on("request", (request) => {
|
||||
if (new URL(request.url()).pathname !== `/api/session/${sessionID}/model`) return
|
||||
mock.log.push("model")
|
||||
models.push(request.postDataJSON())
|
||||
})
|
||||
|
||||
await expect(userRow(page, "msg_queue_delivered")).toContainText("First prompt")
|
||||
await runSlash(page, view.input, "compact")
|
||||
|
||||
await expect(page.locator('[data-timeline-row="CompactionQueued"]')).toHaveText("Session compaction queued")
|
||||
// The row is optimistic; the model switch still precedes the admission.
|
||||
await expect.poll(() => mock.log).toEqual(["model", "compact"])
|
||||
expect(models).toMatchObject([{ model: { id: "queue-model", providerID: "opencode" } }])
|
||||
})
|
||||
|
||||
for (const action of ["Move to queue", "Delete"] as const) {
|
||||
test(`${action} on a pending steer replaces Revert, as in the TUI`, async ({ page }) => {
|
||||
const mock = createQueueMock(["U2: Also check the retry path."])
|
||||
const inboxID = mock.rows[0].id
|
||||
mock.rows[0].delivery = "steer"
|
||||
const view = await openQueue(page, mock)
|
||||
const pending = userRow(page, inboxID)
|
||||
|
||||
await expect(pending).toContainText("U2: Also check the retry path.")
|
||||
await pending.hover()
|
||||
await expect(pending.getByRole("button", { name: "Revert message" })).toHaveCount(0)
|
||||
await pending.getByRole("button", { name: action }).click()
|
||||
|
||||
await expect(pending).toHaveCount(0)
|
||||
expect(mock.changes).toEqual([{ inboxID, action: action === "Delete" ? "cancel" : "queue" }])
|
||||
// Neither action returns the prompt to the composer; a moved steer waits in the queue.
|
||||
await expect(view.input).toHaveText("")
|
||||
await expect(view.rows).toHaveCount(action === "Delete" ? 0 : 1)
|
||||
|
||||
if (action === "Move to queue") await expect(view.rows).toContainText("U2: Also check the retry path.")
|
||||
})
|
||||
}
|
||||
|
||||
test("/undo returns a pending steer's review comment to the composer", async ({ page }) => {
|
||||
const display = "tighten this"
|
||||
const comment = "check the guard"
|
||||
|
||||
const mock = createQueueMock([
|
||||
`${display}\nThe user made the following comment regarding line 2 of /repo/app.ts: ${comment}`,
|
||||
])
|
||||
|
||||
const row = mock.rows[0]
|
||||
row.delivery = "steer"
|
||||
row.payload.metadata = {
|
||||
displayText: display,
|
||||
comments: [
|
||||
{
|
||||
path: "/repo/app.ts",
|
||||
comment,
|
||||
selection: { startLine: 2, startChar: 0, endLine: 2, endChar: 0 },
|
||||
origin: "review",
|
||||
},
|
||||
],
|
||||
}
|
||||
// The comment's context file, which a resubmission regenerates from the restored comment.
|
||||
row.payload.files = [
|
||||
{
|
||||
data: "aGk=",
|
||||
mime: "text/plain",
|
||||
source: { type: "uri", uri: "file:///repo/app.ts?start=2&end=2" },
|
||||
name: "app.ts",
|
||||
},
|
||||
]
|
||||
const view = await openQueue(page, mock)
|
||||
await view.rows.getByRole("button", { name: "Undo" }).click()
|
||||
await expect(page.getByText("Edit this prompt in the queue to preserve its file context")).toBeVisible()
|
||||
await expect(view.rows).toHaveCount(1)
|
||||
await expect(view.input).toHaveText("")
|
||||
expect(mock.changes).toEqual([])
|
||||
|
||||
const pending = userRow(page, row.id)
|
||||
|
||||
await expect(pending).toContainText(display)
|
||||
await runSlash(page, view.input, "undo")
|
||||
|
||||
await expect(pending).toHaveCount(0)
|
||||
await expect(view.input).toHaveText(display)
|
||||
// The comment card is the only card: its context file is regenerated from it, not restored twice.
|
||||
const cards = view.composer.locator('[data-component="composer-attachments"] [data-component="attachment-card"]')
|
||||
await expect(cards).toHaveCount(1)
|
||||
await expect(cards).toContainText(comment)
|
||||
expect(mock.changes).toEqual([{ inboxID: row.id, action: "cancel" }])
|
||||
})
|
||||
|
||||
for (const delivery of ["steer", "queue"] as const) {
|
||||
@@ -285,6 +814,7 @@ for (const delivery of ["steer", "queue"] as const) {
|
||||
const userID = "msg_queue_initial_user"
|
||||
const assistantID = "msg_queue_continued_assistant"
|
||||
const followUp = "U2: Also check the retry path."
|
||||
|
||||
const mock = createQueueMock(
|
||||
[],
|
||||
[
|
||||
@@ -300,6 +830,7 @@ for (const delivery of ["steer", "queue"] as const) {
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
const view = await openQueue(page, mock, delivery)
|
||||
const transcript = page.locator("[data-timeline-virtual-content]")
|
||||
const thinking = transcript.locator('[data-timeline-row="Thinking"]')
|
||||
@@ -313,6 +844,7 @@ for (const delivery of ["steer", "queue"] as const) {
|
||||
|
||||
const inboxID = mock.rows[0].id
|
||||
const pending = transcript.locator(`[data-timeline-row="UserMessage"][data-message-id="${inboxID}"]`)
|
||||
|
||||
if (delivery === "queue") {
|
||||
const queued = view.rows.filter({ hasText: followUp })
|
||||
await expect(queued).toBeVisible()
|
||||
@@ -322,6 +854,7 @@ for (const delivery of ["steer", "queue"] as const) {
|
||||
await queued.getByRole("button", { name: "Steer", exact: true }).click()
|
||||
await expect.poll(() => mock.changes).toEqual([{ inboxID, action: "steer" }])
|
||||
}
|
||||
|
||||
await expect(view.rows).toHaveCount(0)
|
||||
await expect(pending).toContainText(followUp)
|
||||
await expect(thinking).toHaveCount(0)
|
||||
@@ -334,6 +867,7 @@ for (const delivery of ["steer", "queue"] as const) {
|
||||
model,
|
||||
started: Date.now(),
|
||||
})
|
||||
|
||||
for (const tool of [
|
||||
{ id: "tool_queue_read", name: "read", input: { path: "src/queue.ts" } },
|
||||
{ id: "tool_queue_grep", name: "grep", input: { pattern: "retry", path: "src" } },
|
||||
@@ -348,6 +882,7 @@ for (const delivery of ["steer", "queue"] as const) {
|
||||
executed: true,
|
||||
})
|
||||
}
|
||||
|
||||
mock.emit("session.step.ended", {
|
||||
sessionID,
|
||||
assistantMessageID: assistantID,
|
||||
@@ -377,6 +912,7 @@ for (const delivery of ["steer", "queue"] as const) {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const boxes = await Promise.all([tools.boundingBox(), pending.boundingBox()])
|
||||
|
||||
return boxes.every((box) => box !== null) && boxes[0]!.y + boxes[0]!.height <= boxes[1]!.y
|
||||
})
|
||||
.toBe(true)
|
||||
@@ -395,9 +931,11 @@ for (const delivery of ["steer", "queue"] as const) {
|
||||
mock.emit("session.step.started", { ...later, agent: "build", model, started: Date.now() })
|
||||
mock.emit("session.text.started", { ...later, ordinal: 0 })
|
||||
mock.emit("session.text.ended", { ...later, ordinal: 0, text: "A3: Now checking the retry path for U2." })
|
||||
|
||||
const response = transcript
|
||||
.locator('[data-timeline-row="AssistantPart"]')
|
||||
.filter({ hasText: "A3: Now checking the retry path for U2." })
|
||||
|
||||
await expect(response).toHaveAttribute("data-message-id", inboxID)
|
||||
await expect(thinking).toHaveCount(0)
|
||||
await expect(tools.or(pending).or(response)).toHaveText([
|
||||
|
||||
@@ -134,6 +134,27 @@ test("a session in a worktree subfolder names its worktree and lists cached work
|
||||
list.release()
|
||||
})
|
||||
|
||||
test("a subagent shows its details but cannot move to another worktree", async ({ page }) => {
|
||||
const root = "C:/OpenCode/SmokeWorktrees"
|
||||
|
||||
await mockStressTimeline(page, {
|
||||
sessions: fixture.sessions.map((item) =>
|
||||
item.id === fixture.targetID
|
||||
? { ...item, parentID: fixture.sourceID, directory: `${root}/feature` }
|
||||
: { ...item },
|
||||
),
|
||||
worktrees: [{ directory: fixture.directory }, { directory: `${root}/feature`, strategy: "git" }],
|
||||
})
|
||||
await page.goto(sessionHref(fixture.targetID))
|
||||
|
||||
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await expect(summary.getByText("feature", { exact: true })).toBeVisible()
|
||||
await expect(summary.getByRole("button", { name: "feature", exact: true })).toHaveCount(0)
|
||||
await expect(summary.getByRole("button", { name: "Extensions", exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test("the details follow the routed session when it moves to another worktree", async ({ page }) => {
|
||||
const root = "C:/OpenCode/SmokeWorktrees"
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import type { SessionMessageInfo } from "@opencode/client/promise"
|
||||
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode/client/promise"
|
||||
import {
|
||||
NO_PROVIDER,
|
||||
REMOTE_SERVER,
|
||||
@@ -240,7 +240,111 @@ test("a remote tab stays busy while a child session runs", async ({ page }) => {
|
||||
await expect(tabA.locator('[data-component="session-progress-indicator-v2"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("inactive tabs load attention, but read transcript and inbox only on selection", async ({ page }) => {
|
||||
test("inactive tabs stay busy while work waits in their inbox, and pulse on a new prompt, as in the TUI", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await openSession(page, {
|
||||
name: "TabInbox",
|
||||
sessions: [a, b, c],
|
||||
inbox: [
|
||||
{
|
||||
id: "inb_tab_b",
|
||||
sessionID: b.id,
|
||||
time: { created: 1 },
|
||||
type: "user",
|
||||
payload: { text: "Queued follow-up" },
|
||||
delivery: "queue",
|
||||
},
|
||||
// Parked synthetic context, such as a user shell's output, waits without work.
|
||||
{
|
||||
id: "inb_tab_c",
|
||||
sessionID: c.id,
|
||||
time: { created: 1 },
|
||||
type: "synthetic",
|
||||
payload: { text: "Shell output", description: "Shell finished" },
|
||||
delivery: "steer",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const tab = (id: string) => page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(id)}"])`)
|
||||
const progress = '[data-component="session-progress-indicator-v2"]'
|
||||
|
||||
await expect(tab(b.id).locator(progress)).toBeVisible()
|
||||
await expect(tab(c.id).locator("[data-titlebar-tab-title]")).toHaveText(c.title)
|
||||
await expect(tab(c.id).locator(progress)).toHaveCount(0)
|
||||
await expect(tab(c.id).locator('[data-slot="tab-prompt-pulse"]')).toHaveCount(0)
|
||||
// The pulse lasts one animation, so record that it appeared rather than racing its removal.
|
||||
await tab(c.id).evaluate((element) => {
|
||||
const observer = new MutationObserver(() => {
|
||||
if (!element.querySelector('[data-slot="tab-prompt-pulse"]')) return
|
||||
element.setAttribute("data-test-pulsed", "")
|
||||
observer.disconnect()
|
||||
})
|
||||
|
||||
observer.observe(element, { childList: true, subtree: true })
|
||||
})
|
||||
|
||||
await workspace.push([
|
||||
{
|
||||
id: "evt_tab_c_prompt",
|
||||
created: 2,
|
||||
type: "session.inbox.enqueued",
|
||||
durable: { aggregateID: c.id, seq: 1, version: 1 },
|
||||
data: {
|
||||
sessionID: c.id,
|
||||
inboxID: "inb_tab_c_prompt",
|
||||
item: { type: "user", payload: { text: "Another client's prompt" }, delivery: "queue" },
|
||||
},
|
||||
} satisfies Extract<OpenCodeEvent, { type: "session.inbox.enqueued" }>,
|
||||
])
|
||||
|
||||
await expect(tab(c.id)).toHaveAttribute("data-test-pulsed", "")
|
||||
await expect(tab(c.id).locator('[data-slot="tab-prompt-pulse"]')).toHaveCount(0)
|
||||
await expect(tab(c.id).locator(progress)).toBeVisible()
|
||||
await expect(tab(a.id).locator('[data-slot="tab-prompt-pulse"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("selecting a tab with waiting work waits for its transcript instead of showing only the inbox", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openSession(page, {
|
||||
name: "TabInboxTranscript",
|
||||
sessions: [a, b],
|
||||
pageMessages: (id) => ({
|
||||
items:
|
||||
id === b.id ? [{ id: "msg_tab_b_history", type: "user", text: "Earlier prompt", time: { created: 1 } }] : [],
|
||||
}),
|
||||
inbox: [
|
||||
{
|
||||
id: "inb_tab_b_steer",
|
||||
sessionID: b.id,
|
||||
time: { created: 2 },
|
||||
type: "user",
|
||||
payload: { text: "Pending steer" },
|
||||
delivery: "steer",
|
||||
},
|
||||
],
|
||||
})
|
||||
const tabB = page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(b.id)}"])`)
|
||||
// The inactive tab read its inbox, which materializes the pending steer as a transcript row.
|
||||
await expect(tabB.locator('[data-component="session-progress-indicator-v2"]')).toBeVisible()
|
||||
|
||||
const transcript = await holdRoute(page, (url) => url.pathname === `/api/session/${b.id}/message`)
|
||||
await page.locator(`[data-titlebar-tab-link][href="${sessionHref(b.id)}"]`).click()
|
||||
await transcript.arrived
|
||||
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCount(0)
|
||||
|
||||
transcript.release()
|
||||
await expect(page.locator('[data-timeline-row="UserMessage"][data-message-id="msg_tab_b_history"]')).toContainText(
|
||||
"Earlier prompt",
|
||||
)
|
||||
await expect(page.locator('[data-timeline-row="UserMessage"][data-message-id="inb_tab_b_steer"]')).toContainText(
|
||||
"Pending steer",
|
||||
)
|
||||
})
|
||||
|
||||
test("inactive tabs load attention and inbox, but read the transcript only on selection", async ({ page }) => {
|
||||
const reads: string[] = []
|
||||
const mutations: string[] = []
|
||||
const errors: string[] = []
|
||||
@@ -282,7 +386,7 @@ test("inactive tabs load attention, but read transcript and inbox only on select
|
||||
|
||||
const attention = Promise.all(
|
||||
[fixture.targetID, fixture.childID].flatMap((id) =>
|
||||
["permission", "form"].map((kind) =>
|
||||
["permission", "form", "inbox"].map((kind) =>
|
||||
page.waitForResponse((response) => new URL(response.url()).pathname === `/api/session/${id}/${kind}`),
|
||||
),
|
||||
),
|
||||
@@ -307,16 +411,14 @@ test("inactive tabs load attention, but read transcript and inbox only on select
|
||||
state.text,
|
||||
)
|
||||
|
||||
for (const id of [fixture.sourceID, fixture.targetID]) {
|
||||
expect(reads.filter((path) => path === `/api/session/${id}/message`)).toHaveLength(1)
|
||||
// Every tab reads its inbox once, since waiting work keeps a tab busy; selection reuses that read.
|
||||
for (const id of [fixture.sourceID, fixture.targetID, fixture.childID])
|
||||
expect(reads.filter((path) => path === `/api/session/${id}/inbox`)).toHaveLength(1)
|
||||
}
|
||||
|
||||
expect(
|
||||
reads.filter(
|
||||
(path) => path === `/api/session/${fixture.childID}/message` || path === `/api/session/${fixture.childID}/inbox`,
|
||||
),
|
||||
).toEqual([])
|
||||
for (const id of [fixture.sourceID, fixture.targetID])
|
||||
expect(reads.filter((path) => path === `/api/session/${id}/message`)).toHaveLength(1)
|
||||
|
||||
expect(reads.filter((path) => path === `/api/session/${fixture.childID}/message`)).toEqual([])
|
||||
expect(mutations).toEqual([])
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
@@ -28,7 +28,9 @@ import {
|
||||
} from "../utils/timeline"
|
||||
|
||||
const detailed = timelinePresets[2].value
|
||||
|
||||
const user = { id: "msg_user", type: "user", text: "Run it", time: { created: 1 } } satisfies SessionMessageInfo
|
||||
|
||||
const completed = {
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
@@ -89,28 +91,34 @@ test.describe("static projection", () => {
|
||||
],
|
||||
{ summary: { diffs: Array.from({ length: 11 }, (_, index) => summaryDiff(index)) } },
|
||||
)
|
||||
|
||||
const aborted = assistantMessage([{ id: "prt_before_abort", type: "text", text: "Before interruption" }], {
|
||||
id: "msg_1001_assistant_aborted",
|
||||
error: { type: "MessageAbortedError", message: "Stopped" },
|
||||
})
|
||||
|
||||
const failed = assistantMessage([{ id: "prt_after_abort", type: "text", text: "After interruption" }], {
|
||||
id: "msg_1002_assistant_failed",
|
||||
error: { type: "APIError", message: "Visible provider failure" },
|
||||
created: 1700000003000,
|
||||
})
|
||||
|
||||
const nextUser = userMessage([userText("Second turn", { id: "prt_second_user" })], {
|
||||
id: "msg_2000_second_user",
|
||||
created: 1700000005000,
|
||||
})
|
||||
|
||||
const nextAssistant = assistantMessage([{ id: "prt_second_text", type: "text", text: "Second response" }], {
|
||||
id: "msg_2001_second_assistant",
|
||||
parentID: "msg_2000_second_user",
|
||||
created: 1700000006000,
|
||||
})
|
||||
|
||||
const timeline = await setupTimeline(page, {
|
||||
settings: { timelineDetail: detailed },
|
||||
messages: [firstUser, aborted, failed, nextUser, nextAssistant],
|
||||
})
|
||||
|
||||
await timeline.send(status("idle"))
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
await scroller.evaluate((element) => (element.scrollTop = 0))
|
||||
@@ -140,6 +148,7 @@ test.describe("static projection", () => {
|
||||
url: "data:text/plain;base64,bm90ZXM=",
|
||||
},
|
||||
])
|
||||
|
||||
message.metadata = {
|
||||
displayText: "what's goin on here",
|
||||
comments: [
|
||||
@@ -198,6 +207,7 @@ test.describe("static projection", () => {
|
||||
toolPart("prt_skill_break", "read", "completed", { path: "src/a.ts" }),
|
||||
toolPart("prt_skill_last", "skill", "completed", { id: "opencode" }),
|
||||
]
|
||||
|
||||
await setupTimeline(page, {
|
||||
settings: { timelineDetail: detailed },
|
||||
messages: [userMessage(), assistantMessage(parts)],
|
||||
@@ -217,6 +227,7 @@ test.describe("static projection", () => {
|
||||
toolPart("prt_collapsed_patch", "patch", "completed", { patchText: "Update src/value.ts" }),
|
||||
toolPart("prt_collapsed_read", "read", "completed", { path: "src/value.ts" }),
|
||||
]
|
||||
|
||||
await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage(parts)],
|
||||
settings: { shellToolPartsExpanded: true },
|
||||
@@ -235,6 +246,7 @@ test.describe("static projection", () => {
|
||||
|
||||
test("combines adjacent patch calls and repeated files into one group", async ({ page }) => {
|
||||
const [first, second] = ["prt_patch_first", "prt_patch_second"]
|
||||
|
||||
const timeline = await setupTimeline(page, {
|
||||
settings: { timelineDetail: { ...detailed, edit: { placement: "separate", details: "collapsed" } } },
|
||||
messages: [
|
||||
@@ -258,6 +270,7 @@ test("combines adjacent patch calls and repeated files into one group", async ({
|
||||
await expect(initialFile.getByRole("button")).toHaveAttribute("aria-expanded", "true")
|
||||
await initial.evaluate((element) => {
|
||||
const row = element.closest<HTMLElement>("[data-timeline-key]")
|
||||
|
||||
if (row) row.dataset.patchRow = "stable"
|
||||
})
|
||||
|
||||
@@ -292,6 +305,7 @@ test("combines adjacent patch calls and repeated files into one group", async ({
|
||||
test("keeps a failed patch in Used without losing the surviving file choice", async ({ page }) => {
|
||||
const failed = "prt_grouped_patch_failed"
|
||||
const surviving = "prt_grouped_patch_surviving"
|
||||
|
||||
const timeline = await setupTimeline(page, {
|
||||
settings: { timelineDetail: detailed },
|
||||
messages: [
|
||||
@@ -321,6 +335,7 @@ test("keeps a failed patch in Used without losing the surviving file choice", as
|
||||
await expect(file).toHaveAttribute("aria-expanded", "true")
|
||||
await group.evaluate((element) => {
|
||||
const row = element.closest<HTMLElement>("[data-timeline-key]")
|
||||
|
||||
if (row) row.dataset.groupIdentity = "preserved"
|
||||
})
|
||||
|
||||
@@ -331,9 +346,11 @@ test("keeps a failed patch in Used without losing the surviving file choice", as
|
||||
)
|
||||
|
||||
const failedRow = page.locator("[data-timeline-key]", { has: page.locator(`[data-timeline-part-id="${failed}"]`) })
|
||||
|
||||
const survivingRow = page.locator("[data-timeline-key]", {
|
||||
has: page.locator(`[data-timeline-part-id="${surviving}"]`),
|
||||
})
|
||||
|
||||
await expect(group).toHaveAttribute("data-timeline-part-ids", `${failed},${surviving}`)
|
||||
await expect(used).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(failedRow).toHaveAttribute("data-timeline-key", /^assistant-part:context:/)
|
||||
@@ -351,15 +368,21 @@ test("does not remount an edit diff when a sibling part arrives", async ({ page
|
||||
const attachShadow = Element.prototype.attachShadow
|
||||
Element.prototype.attachShadow = function (init) {
|
||||
count += 1
|
||||
|
||||
return attachShadow.call(this, init)
|
||||
}
|
||||
;(window as Window & { __shadowRoots?: { reset(): void; count(): number } }).__shadowRoots = {
|
||||
|
||||
// SAFETY: this init script owns `__shadowRoots`; the probe reads below run only after it installed it.
|
||||
const probe = window as Window & { __shadowRoots?: { reset(): void; count(): number } }
|
||||
|
||||
probe.__shadowRoots = {
|
||||
reset: () => {
|
||||
count = 0
|
||||
},
|
||||
count: () => count,
|
||||
}
|
||||
})
|
||||
|
||||
const timeline = await setupTimeline(page, {
|
||||
settings: { editToolPartsExpanded: true, shellToolPartsExpanded: true, showReasoningSummaries: true },
|
||||
messages: [
|
||||
@@ -401,8 +424,10 @@ test("does not remount an edit diff when a sibling part arrives", async ({ page
|
||||
),
|
||||
],
|
||||
})
|
||||
|
||||
const tool = page.locator(`[data-timeline-part-id="${editID}"]`)
|
||||
await expect(tool.locator('[data-component="file"][data-mode="diff"]')).toBeVisible()
|
||||
|
||||
const markers = () =>
|
||||
tool.evaluate((element) => {
|
||||
const nodes = [
|
||||
@@ -410,12 +435,15 @@ test("does not remount an edit diff when a sibling part arrives", async ({ page
|
||||
element.querySelector('[data-component="file"][data-mode="diff"]'),
|
||||
element.closest("[data-timeline-key]"),
|
||||
element.closest("[data-timeline-row]"),
|
||||
] as (HTMLElement | null)[]
|
||||
]
|
||||
|
||||
return {
|
||||
markers: nodes.map((node) => node?.dataset.timelineProbe),
|
||||
markers: nodes.map((node) => (node instanceof HTMLElement ? node.dataset.timelineProbe : undefined)),
|
||||
// SAFETY: the init script installed `__shadowRoots` before the page loaded.
|
||||
shadowRoots: (window as Window & { __shadowRoots?: { count(): number } }).__shadowRoots!.count(),
|
||||
}
|
||||
})
|
||||
|
||||
await tool.evaluate((element) => {
|
||||
;[
|
||||
element,
|
||||
@@ -426,7 +454,11 @@ test("does not remount an edit diff when a sibling part arrives", async ({ page
|
||||
if (!(node instanceof HTMLElement)) throw new Error("missing edit tool, diff, row, or frame")
|
||||
node.dataset.timelineProbe = "before"
|
||||
})
|
||||
;(window as Window & { __shadowRoots?: { reset(): void } }).__shadowRoots!.reset()
|
||||
|
||||
// SAFETY: the init script installed `__shadowRoots` before the page loaded.
|
||||
const probe = window as Window & { __shadowRoots?: { reset(): void } }
|
||||
|
||||
probe.__shadowRoots!.reset()
|
||||
})
|
||||
|
||||
await timeline.send(partUpdated(textPart("prt_sibling_text", "Streaming added a later assistant text part.")))
|
||||
@@ -438,10 +470,12 @@ for (const transition of ["reasoning-end", "idle", "retry"] as const) {
|
||||
test(`stops active Thinking on ${transition} without a following tool`, async ({ page }) => {
|
||||
const id = `prt_reasoning_stop_${transition}`
|
||||
const text = "## Inspecting stability\n\nThe timeline is ready for the next step."
|
||||
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([reasoningPart(id, text)], { completed: false })],
|
||||
settings: { timelineDetail: { ...detailed, thinking: { placement: "separate", details: "collapsed" } } },
|
||||
})
|
||||
|
||||
const part = page.locator(`[data-timeline-part-id="${renderedPartID(id)}"]`)
|
||||
const trigger = part.locator('[data-slot="collapsible-trigger"]')
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
@@ -465,6 +499,7 @@ for (const shellDefault of ["collapsed", "expanded"] as const) {
|
||||
const shellID = `prt_separate_shell_${shellDefault}`
|
||||
const output = (count: number) => Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
|
||||
const assistant = assistantMessage([reasoningPart(reasoningID, "## Inspecting stability")], { completed: false })
|
||||
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistant],
|
||||
settings: {
|
||||
@@ -475,9 +510,11 @@ for (const shellDefault of ["collapsed", "expanded"] as const) {
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const thought = page.locator(
|
||||
`[data-timeline-part-id="${renderedPartID(reasoningID)}"] [data-slot="collapsible-trigger"]`,
|
||||
)
|
||||
|
||||
const shellTrigger = page.locator(`[data-timeline-part-id="${shellID}"] [data-slot="collapsible-trigger"]`)
|
||||
const group = page.locator('[data-component="collapsed-tool-group"]')
|
||||
// One row opens the thought, the other opens and then closes it again.
|
||||
@@ -485,6 +522,7 @@ for (const shellDefault of ["collapsed", "expanded"] as const) {
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
await expect(thought).toHaveAttribute("aria-expanded", "false")
|
||||
await thought.click()
|
||||
|
||||
if (!thoughtOpen) await thought.click()
|
||||
await expect(thought).toHaveAttribute("aria-expanded", String(thoughtOpen))
|
||||
|
||||
@@ -518,6 +556,7 @@ test.describe("Working", () => {
|
||||
viewport: { width: 390, height: 900 },
|
||||
settings: { timelineDetail: { ...detailed, thinking: { placement: "separate", details: "collapsed" } } },
|
||||
})
|
||||
|
||||
const working = page.locator('[data-component="session-working"]')
|
||||
const shimmer = working.locator('[data-component="text-shimmer"]')
|
||||
await expect(working).toHaveCount(1)
|
||||
@@ -551,6 +590,7 @@ test.describe("Working", () => {
|
||||
],
|
||||
settings: { timelineDetail: detailed },
|
||||
})
|
||||
|
||||
const working = page.locator('[data-component="session-working"]')
|
||||
const group = page.locator('[data-component="collapsed-tool-group"]')
|
||||
await expect(working).toBeVisible()
|
||||
@@ -571,16 +611,20 @@ test.describe("Working", () => {
|
||||
timelineDetail: { ...timelinePresets[0].value, shell: { placement: "separate", details: "collapsed" } },
|
||||
},
|
||||
})
|
||||
|
||||
const working = page.locator('[data-component="session-working"]')
|
||||
await expect(working).toBeVisible()
|
||||
|
||||
const id = `prt_working_${name}`
|
||||
const input =
|
||||
name === "shell"
|
||||
? { command: "printf ready" }
|
||||
: name === "patch"
|
||||
? { patchText: "*** Begin Patch\n*** Add File: src/working.ts\n+export const ready = true\n*** End Patch" }
|
||||
: { agent: "general", description: "Inspect working indicator", prompt: "Inspect the timeline." }
|
||||
|
||||
const input = {
|
||||
shell: { command: "printf ready" },
|
||||
patch: {
|
||||
patchText: "*** Begin Patch\n*** Add File: src/working.ts\n+export const ready = true\n*** End Patch",
|
||||
},
|
||||
subagent: { agent: "general", description: "Inspect working indicator", prompt: "Inspect the timeline." },
|
||||
}[name]
|
||||
|
||||
await timeline.send(partUpdated(toolPart(id, name, "streaming", input)))
|
||||
const tool = page.locator(`[data-timeline-part-id="${id}"]`)
|
||||
await expect(tool).toBeVisible()
|
||||
@@ -590,9 +634,10 @@ test.describe("Working", () => {
|
||||
name === "patch"
|
||||
? { files: [{ ...patchFile("src/working.ts", "added"), patch: "@@ -0,0 +1 @@\n+export const ready = true" }] }
|
||||
: {}
|
||||
|
||||
await timeline.send(partUpdated(toolPart(id, name, "running", input, { metadata })))
|
||||
await expect(tool).toContainText(
|
||||
name === "shell" ? "printf ready" : name === "patch" ? "working.ts" : "Inspect working indicator",
|
||||
{ shell: "printf ready", patch: "working.ts", subagent: "Inspect working indicator" }[name],
|
||||
)
|
||||
await expect(working).toHaveCount(0)
|
||||
|
||||
@@ -627,14 +672,18 @@ test.describe("Working", () => {
|
||||
],
|
||||
})
|
||||
const working = page.locator('[data-component="session-working"]')
|
||||
|
||||
if (!grouped) {
|
||||
await expect(page.locator('[data-timeline-part-id="prt_background_active"]')).toBeVisible()
|
||||
await expect(working).toHaveCount(0)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const trigger = page
|
||||
.locator('[data-component="collapsed-tool-group"]')
|
||||
.getByRole("button", { name: "Used 2 Shell", exact: true, includeHidden: true })
|
||||
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(working).toBeVisible()
|
||||
await trigger.click()
|
||||
@@ -666,6 +715,7 @@ test.describe("Working", () => {
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([], { completed: false })],
|
||||
})
|
||||
|
||||
const working = page.locator('[data-component="session-working"]')
|
||||
await expect(working).toBeVisible()
|
||||
|
||||
@@ -708,7 +758,9 @@ test.describe("background shortcut", () => {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const [cardBox, hintBox] = await Promise.all([card.boundingBox(), hint.boundingBox()])
|
||||
|
||||
if (!cardBox || !hintBox) return undefined
|
||||
|
||||
return { aligned: Math.abs(cardBox.x - hintBox.x) < 2, ordered: cardBox.y < hintBox.y }
|
||||
})
|
||||
.toEqual({ aligned: true, ordered: true })
|
||||
@@ -729,11 +781,11 @@ test.describe("background shortcut", () => {
|
||||
"prt_grouped_active",
|
||||
name,
|
||||
"running",
|
||||
name === "shell"
|
||||
? { command: "sleep 10" }
|
||||
: name === "subagent"
|
||||
? { agent: "general", description: "Inspect the timeline", prompt: "Inspect it." }
|
||||
: { filePath: "src/working.ts" },
|
||||
{
|
||||
read: { filePath: "src/working.ts" },
|
||||
shell: { command: "sleep 10" },
|
||||
subagent: { agent: "general", description: "Inspect the timeline", prompt: "Inspect it." },
|
||||
}[name],
|
||||
),
|
||||
],
|
||||
{ completed: false },
|
||||
@@ -745,19 +797,24 @@ test.describe("background shortcut", () => {
|
||||
const trigger = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(working).toBeInViewport()
|
||||
|
||||
if (name !== "read") {
|
||||
await expect(backgroundHint(page)).toBeInViewport()
|
||||
await expect(page.locator('[data-component="session-background-hint-row"]')).toHaveCSS("height", "24px")
|
||||
}
|
||||
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
|
||||
if (name === "subagent") await expect(group.getByText("Inspect the timeline", { exact: true })).toBeVisible()
|
||||
|
||||
if (name !== "subagent")
|
||||
await expect(group.locator('[data-component="text-shimmer"][data-active="true"]')).toBeVisible()
|
||||
await expect(working).toBeVisible()
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(working).toBeVisible()
|
||||
|
||||
if (name !== "read") await expectBackgroundRequest(page)
|
||||
})
|
||||
}
|
||||
@@ -765,6 +822,7 @@ test.describe("background shortcut", () => {
|
||||
test("separates blocking and already-backgrounded work into two rows", async ({ page }) => {
|
||||
const backgroundID = "ses_background_existing"
|
||||
const blockingID = "ses_background_blocking"
|
||||
|
||||
const timeline = await setupTimeline(page, {
|
||||
settings: { timelineDetail: detailed },
|
||||
sessionMessages: [
|
||||
@@ -781,7 +839,7 @@ test.describe("background shortcut", () => {
|
||||
name: "subagent",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { description: "Background task" },
|
||||
input: { description: "Background task", agent: "explore" },
|
||||
content: [{ type: "text", text: "working" }],
|
||||
metadata: { sessionID: backgroundID, status: "running" },
|
||||
},
|
||||
@@ -814,7 +872,7 @@ test.describe("background shortcut", () => {
|
||||
name: "subagent",
|
||||
state: {
|
||||
status: "running",
|
||||
input: { description: "Foreground task" },
|
||||
input: { description: "Foreground task", agent: "build" },
|
||||
metadata: { sessionID: blockingID },
|
||||
},
|
||||
time: { created: 4 },
|
||||
@@ -825,8 +883,8 @@ test.describe("background shortcut", () => {
|
||||
],
|
||||
sessions: [
|
||||
session(),
|
||||
session({ id: backgroundID, parentID: sessionID, title: "Background task" }),
|
||||
session({ id: blockingID, parentID: sessionID, title: "Foreground task" }),
|
||||
session({ id: backgroundID, parentID: sessionID, title: "Background task", agent: "explore" }),
|
||||
session({ id: blockingID, parentID: sessionID, title: "Foreground task", agent: "build" }),
|
||||
],
|
||||
sessionStatus: {
|
||||
[sessionID]: { type: "busy" },
|
||||
@@ -855,27 +913,58 @@ test.describe("background shortcut", () => {
|
||||
})
|
||||
const backgroundCard = page.locator('[data-timeline-part-id="call_backgrounded"]')
|
||||
await expect(backgroundHint(page)).toBeVisible()
|
||||
|
||||
const used = page
|
||||
.locator('[data-timeline-part-ids="call_backgrounded,call_shell_backgrounded,call_blocking"]')
|
||||
.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
|
||||
|
||||
await expect(used).toHaveText(/^Used\s*3\s*Agent, Shell$/)
|
||||
await expect(used).toHaveAttribute("aria-expanded", "false")
|
||||
await used.click()
|
||||
await page.getByRole("button", { name: "3 running", exact: true }).click()
|
||||
const list = page.getByRole("menu", { name: "3 running", exact: true })
|
||||
await expect(list.getByRole("menuitem")).toHaveText([
|
||||
/^Build\s*Foreground task$/,
|
||||
/^Explore\s*Background task$/,
|
||||
/^Shell\s*sleep 120$/,
|
||||
])
|
||||
await list.getByRole("menuitem", { name: /sleep 120/ }).click()
|
||||
await expect(list).toHaveCount(0)
|
||||
await expect(used).toHaveAttribute("aria-expanded", "true")
|
||||
await page.getByRole("button", { name: "Session details" }).click()
|
||||
const summary = page.getByRole("button", { name: "2 background tasks running", exact: true })
|
||||
await expect(summary).toContainText("2")
|
||||
await summary.click()
|
||||
const list = page.locator('[data-component="session-background-list"]')
|
||||
await expect(list).toContainText("Background task")
|
||||
await expect(list).toContainText("sleep 120")
|
||||
await expect(list).not.toContainText("Foreground task")
|
||||
const shellCall = page.locator('[data-timeline-part-id="call_shell_backgrounded"]')
|
||||
await expect(shellCall.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(shellCall).toBeInViewport()
|
||||
|
||||
await page.getByRole("button", { name: "3 running", exact: true }).click()
|
||||
|
||||
const interrupt = page.waitForRequest(
|
||||
(request) =>
|
||||
request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${blockingID}/interrupt`,
|
||||
)
|
||||
|
||||
const foreground = list.getByRole("menuitem", { name: /Foreground task/ })
|
||||
|
||||
await foreground.hover()
|
||||
await foreground.getByRole("button", { name: "Interrupt subagent", exact: true }).click()
|
||||
await interrupt
|
||||
await expect(list).toBeVisible()
|
||||
|
||||
const kill = page.waitForRequest(
|
||||
(request) => request.method() === "DELETE" && new URL(request.url()).pathname === "/api/shell/shell_backgrounded",
|
||||
)
|
||||
|
||||
await list.getByRole("menuitem", { name: /sleep 120/ }).hover()
|
||||
await page.keyboard.press("Delete")
|
||||
await kill
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(list).toHaveCount(0)
|
||||
await expect(backgroundCard).toContainText("Background task (background)")
|
||||
await expect(backgroundCard.locator('[data-component="session-progress-indicator-v2"]')).toBeVisible()
|
||||
await expect(
|
||||
page.locator('[data-timeline-part-id="call_shell_backgrounded"] [data-component="text-shimmer"]'),
|
||||
).toHaveAttribute("data-active", "true")
|
||||
|
||||
// SAFETY: the transport forwards this payload unchanged, and the client marks a session idle from
|
||||
// `type` and `data.sessionID` alone; the child session's event needs no durable metadata here.
|
||||
await timeline.transport.send({
|
||||
id: "evt_background_succeeded",
|
||||
created: Date.now(),
|
||||
@@ -885,6 +974,116 @@ test.describe("background shortcut", () => {
|
||||
await expect(backgroundCard.locator('[data-component="session-progress-indicator-v2"]')).toHaveCount(0)
|
||||
await expect(backgroundCard).toContainText("Background task (background)")
|
||||
})
|
||||
|
||||
test("moves between siblings and to the parent's shell from inside a subagent", async ({ page }) => {
|
||||
const siblings = [
|
||||
{ id: "ses_sibling_one", agent: "explore", description: "Draft TUI proposal" },
|
||||
{ id: "ses_sibling_two", agent: "build", description: "Fix context controls" },
|
||||
]
|
||||
|
||||
const timeline = await setupTimeline(page, {
|
||||
settings: { timelineDetail: detailed },
|
||||
sessionMessages: [
|
||||
user,
|
||||
{
|
||||
id: "msg_sibling_shell",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_sibling_shell",
|
||||
name: "shell",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { command: "sleep 120" },
|
||||
content: [{ type: "text", text: "working" }],
|
||||
metadata: { shellID: "shell_sibling", status: "running" },
|
||||
},
|
||||
time: { created: 2, completed: 3 },
|
||||
},
|
||||
],
|
||||
time: { created: 2, completed: 3 },
|
||||
},
|
||||
{
|
||||
id: "msg_siblings",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: siblings.map((item) => ({
|
||||
type: "tool" as const,
|
||||
id: `call_${item.id}`,
|
||||
name: "subagent",
|
||||
state: {
|
||||
status: "running" as const,
|
||||
input: { description: item.description, agent: item.agent },
|
||||
metadata: { sessionID: item.id },
|
||||
},
|
||||
time: { created: 2 },
|
||||
})),
|
||||
time: { created: 2 },
|
||||
},
|
||||
],
|
||||
sessions: [
|
||||
session(),
|
||||
...siblings.map((item) =>
|
||||
session({ id: item.id, parentID: sessionID, title: item.description, agent: item.agent }),
|
||||
),
|
||||
],
|
||||
sessionStatus: Object.fromEntries(
|
||||
[sessionID, ...siblings.map((item) => item.id)].map((id) => [id, { type: "busy" }]),
|
||||
),
|
||||
})
|
||||
|
||||
await timeline.transport.send({
|
||||
id: "evt_sibling_shell_created",
|
||||
created: 3,
|
||||
type: "shell.created",
|
||||
location: { directory },
|
||||
data: {
|
||||
info: {
|
||||
id: "shell_sibling",
|
||||
status: "running",
|
||||
command: "sleep 120",
|
||||
cwd: directory,
|
||||
shell: "bash",
|
||||
file: "/tmp/sibling.out",
|
||||
metadata: { sessionID },
|
||||
time: { started: 2 },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const header = page.locator("[data-session-title]")
|
||||
const trigger = header.getByRole("button", { name: "3 running", exact: true })
|
||||
const list = page.getByRole("menu", { name: "3 running", exact: true })
|
||||
|
||||
await trigger.click()
|
||||
await list.getByRole("menuitem", { name: /Draft TUI proposal/ }).click()
|
||||
await expect(page).toHaveURL(/\/session\/ses_sibling_one$/)
|
||||
|
||||
await trigger.click()
|
||||
await expect(list.getByRole("menuitem")).toHaveText([
|
||||
/^Explore\s*Draft TUI proposal/,
|
||||
/^Build\s*Fix context controls/,
|
||||
/^Shell\s*sleep 120/,
|
||||
])
|
||||
await expect(list.getByRole("menuitem", { name: /Draft TUI proposal/ })).toHaveAttribute("aria-current", "page")
|
||||
await expect(list.getByRole("menuitem", { name: /Fix context controls/ })).not.toHaveAttribute("aria-current")
|
||||
await list.getByRole("menuitem", { name: /Fix context controls/ }).click()
|
||||
await expect(page).toHaveURL(/\/session\/ses_sibling_two$/)
|
||||
|
||||
// The shell call lives in the parent: the row opens it there, expanded.
|
||||
await trigger.click()
|
||||
await list.getByRole("menuitem", { name: /sleep 120/ }).click()
|
||||
await expect(page).toHaveURL(new RegExp(`/session/${sessionID}$`))
|
||||
|
||||
const shellCall = page.locator('[data-timeline-part-id="call_sibling_shell"]')
|
||||
|
||||
await expect(shellCall.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(shellCall).toBeInViewport()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("compaction", () => {
|
||||
@@ -916,6 +1115,7 @@ test.describe("compaction", () => {
|
||||
.poll(async () => {
|
||||
const summary = await compaction.locator('[data-component="text-part"]').boundingBox()
|
||||
const status = await running.boundingBox()
|
||||
|
||||
return !!summary && !!status && status.y >= summary.y + summary.height
|
||||
})
|
||||
.toBe(true)
|
||||
@@ -937,6 +1137,7 @@ test.describe("compaction", () => {
|
||||
.poll(async () => {
|
||||
const summary = await compaction.locator('[data-component="text-part"]').boundingBox()
|
||||
const done = await compaction.getByText("Session compacted", { exact: true }).boundingBox()
|
||||
|
||||
return !!summary && !!done && done.y >= summary.y + summary.height
|
||||
})
|
||||
.toBe(true)
|
||||
@@ -971,16 +1172,21 @@ test.describe("compaction", () => {
|
||||
shown: undefined,
|
||||
},
|
||||
} as const
|
||||
|
||||
const labels = Object.values(outcomes).map((outcome) => outcome.label)
|
||||
|
||||
// Failed then cancelled share one history, so each boundary must keep its own outcome.
|
||||
for (const names of [["failed", "cancelled"], ["interrupted"]] as const) {
|
||||
test(`ends running compactions as ${names.join(", then ")}`, async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, {
|
||||
sessionMessages: [user, completed],
|
||||
...(names[0] === "interrupted" ? { sessionStatus: { [sessionID]: { type: "busy" as const } } } : {}),
|
||||
})
|
||||
const timeline = await setupTimeline(
|
||||
page,
|
||||
names[0] === "interrupted"
|
||||
? { sessionMessages: [user, completed], sessionStatus: { [sessionID]: { type: "busy" } } }
|
||||
: { sessionMessages: [user, completed] },
|
||||
)
|
||||
|
||||
const compactions = page.locator('[data-component="session-compaction-message"]')
|
||||
|
||||
for (const [index, name] of names.entries()) {
|
||||
const outcome = outcomes[name]
|
||||
await timeline.send(compactionStarted({ sessionID, reason: outcome.reason, recent: "" }))
|
||||
@@ -988,36 +1194,45 @@ test.describe("compaction", () => {
|
||||
await expect(compactions).toHaveCount(index + 1)
|
||||
const compaction = compactions.nth(index)
|
||||
await expect(compaction).toContainText(outcome.partial)
|
||||
|
||||
if (name === "interrupted") {
|
||||
await expect(compaction.getByRole("status").getByLabel("Compacting", { exact: true })).toBeVisible()
|
||||
|
||||
const request = page.waitForRequest(
|
||||
(request) =>
|
||||
request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/interrupt`,
|
||||
)
|
||||
|
||||
await page.getByRole("button", { name: "Stop", exact: true }).click()
|
||||
await request
|
||||
}
|
||||
|
||||
await timeline.send(compactionFailed({ sessionID, reason: outcome.reason, error: outcome.error }))
|
||||
await expect(compaction.getByText(outcome.label, { exact: true })).toBeVisible()
|
||||
}
|
||||
|
||||
await expect(compactions).toHaveCount(names.length)
|
||||
|
||||
for (const [index, name] of names.entries()) {
|
||||
const outcome = outcomes[name]
|
||||
const compaction = compactions.nth(index)
|
||||
await expect(compaction.getByText("Session compaction started", { exact: true })).toBeVisible()
|
||||
await expect(compaction.getByText(outcome.label, { exact: true })).toBeVisible()
|
||||
|
||||
for (const other of labels.filter((label) => label !== outcome.label))
|
||||
await expect(compaction.getByText(other, { exact: true })).toHaveCount(0)
|
||||
await expect(compaction.getByText("Session compacted", { exact: true })).toHaveCount(0)
|
||||
await expect(compaction.getByRole("status")).toHaveCount(0)
|
||||
await expect(compaction).not.toContainText(outcome.partial)
|
||||
|
||||
if (outcome.shown) await expect(compaction.getByText(outcome.shown, { exact: true })).toBeVisible()
|
||||
|
||||
if (!outcome.shown) await expect(compaction).not.toContainText(outcome.error.message)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test("reducer-hardening: converges when idle arrives before final part and message completion", async ({ page }) => {
|
||||
const textID = "prt_event_order_text"
|
||||
const assistant = assistantMessage([textPart(textID, "Partial")], { completed: false })
|
||||
@@ -1048,11 +1263,13 @@ test("changes timeline presets and saves custom thinking details", async ({ page
|
||||
const slider = settings.getByRole("slider", { name: "Timeline detail", exact: true })
|
||||
await expect(slider).toBeEnabled()
|
||||
await slider.press("Home")
|
||||
|
||||
for (const [index, name] of ["Messages only", "Quiet", "Compact", "Detailed", "Everything"].entries()) {
|
||||
if (index) await slider.press("ArrowRight")
|
||||
await expect(slider).toHaveValue(String(index))
|
||||
await expect(slider).toHaveAttribute("aria-valuetext", name)
|
||||
}
|
||||
|
||||
await slider.press("End")
|
||||
await settings.getByRole("button", { name: "Advanced", exact: true }).click()
|
||||
const grouped = settings.getByRole("switch", { name: "Thinking grouped", exact: true })
|
||||
@@ -1132,6 +1349,7 @@ test.describe("shell completion", () => {
|
||||
],
|
||||
time: { created: 2, completed: 3 },
|
||||
}
|
||||
|
||||
const state = { finished: false, requests: 0 }
|
||||
// Larger than one server page (65,536 bytes), so the final output is read in two pages. The card shows its most
|
||||
// recent 64 KiB.
|
||||
@@ -1141,9 +1359,11 @@ test.describe("shell completion", () => {
|
||||
const reads: number[] = []
|
||||
page.on("request", (request) => {
|
||||
const url = new URL(request.url())
|
||||
|
||||
if (url.pathname === `/api/shell/${background.id}/output`)
|
||||
reads.push(Number(url.searchParams.get("cursor") ?? 0))
|
||||
})
|
||||
|
||||
const timeline = await setupTimeline(page, {
|
||||
viewport: { width: grouped ? 390 : 1400, height: 900 },
|
||||
settings: { shellToolPartsExpanded: !grouped },
|
||||
@@ -1156,15 +1376,19 @@ test.describe("shell completion", () => {
|
||||
shellOutput: ({ id }) => {
|
||||
if (id !== background.id) return "Checking project\n"
|
||||
state.requests++
|
||||
|
||||
return state.finished ? finished : "Checking project\n"
|
||||
},
|
||||
})
|
||||
|
||||
await page.clock.install()
|
||||
await page.reload()
|
||||
await timeline.transport.waitForConnection()
|
||||
|
||||
const groupTrigger = page
|
||||
.locator('[data-component="collapsed-tool-group"]')
|
||||
.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
|
||||
|
||||
if (grouped) {
|
||||
await expect(page.locator('[data-component="collapsed-tool-group"]')).toHaveAttribute(
|
||||
"data-timeline-part-ids",
|
||||
@@ -1173,11 +1397,13 @@ test.describe("shell completion", () => {
|
||||
await expect(groupTrigger).toHaveAttribute("aria-expanded", "false")
|
||||
await groupTrigger.click()
|
||||
}
|
||||
|
||||
const card = page.locator(`[data-timeline-part-id="call_${background.id}"]`)
|
||||
const shimmer = card.locator('[data-component="text-shimmer"]')
|
||||
const other = page.locator('[data-timeline-part-id="call_sh_other"] [data-component="text-shimmer"]')
|
||||
await expect(shimmer).toHaveAttribute("data-active", "true")
|
||||
await expect(other).toHaveAttribute("data-active", "true")
|
||||
|
||||
if (grouped) await card.locator('[data-slot="collapsible-trigger"]').click()
|
||||
await expect(card.locator('[data-slot="bash-result"]')).toHaveText("Checking project")
|
||||
|
||||
@@ -1202,9 +1428,11 @@ test.describe("shell completion", () => {
|
||||
|
||||
reads.length = 0
|
||||
await page.reload()
|
||||
|
||||
if (grouped) await groupTrigger.click()
|
||||
await expect(shimmer).toHaveAttribute("data-active", "false")
|
||||
await expect(other).toHaveAttribute("data-active", "true")
|
||||
|
||||
// An expanded card reads the exited shell's final output from the start, again in two pages.
|
||||
if (grouped) return
|
||||
await expect(card.locator('[data-slot="bash-result"]')).toHaveText(tail)
|
||||
@@ -1240,6 +1468,7 @@ test.describe("shell completion", () => {
|
||||
],
|
||||
shellOutput: ({ id }) => (id === background.id ? "Checking project\n" : undefined),
|
||||
})
|
||||
|
||||
const card = page.locator('[data-timeline-part-id="call_foreground"]')
|
||||
const shimmer = card.locator('[data-component="text-shimmer"]')
|
||||
await expect(shimmer).toHaveAttribute("data-active", "true")
|
||||
@@ -1290,6 +1519,7 @@ async function expectBackgroundRequest(page: Page) {
|
||||
(request) =>
|
||||
request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/background`,
|
||||
)
|
||||
|
||||
await page.keyboard.press("Control+b")
|
||||
await request
|
||||
}
|
||||
@@ -1298,12 +1528,11 @@ function patchFile(file: string, status: "added" | "modified" | "deleted") {
|
||||
return {
|
||||
file,
|
||||
status,
|
||||
patch:
|
||||
status === "added"
|
||||
? "@@ -0,0 +1 @@\n+export const after = true"
|
||||
: status === "deleted"
|
||||
? "@@ -1 +0,0 @@\n-export const before = true"
|
||||
: "@@ -1 +1 @@\n-export const before = true\n+export const after = true",
|
||||
patch: {
|
||||
added: "@@ -0,0 +1 @@\n+export const after = true",
|
||||
deleted: "@@ -1 +0,0 @@\n-export const before = true",
|
||||
modified: "@@ -1 +1 @@\n-export const before = true\n+export const after = true",
|
||||
}[status],
|
||||
additions: status === "deleted" ? 0 : 1,
|
||||
deletions: status === "added" ? 0 : 1,
|
||||
}
|
||||
|
||||
@@ -219,6 +219,12 @@ const Group = HttpApiGroup.make("mock")
|
||||
error: MockShellNotFound.pipe(HttpApiSchema.status(404)),
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.delete("shellRemove", "/api/shell/:id", {
|
||||
params: { id: Schema.String },
|
||||
success: NoContent,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("ptyList", "/api/pty", {
|
||||
success: Json,
|
||||
@@ -327,6 +333,13 @@ const Group = HttpApiGroup.make("mock")
|
||||
success: Json,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("sessionCompact", "/api/session/:sessionID/compact", {
|
||||
params: SessionParams,
|
||||
payload: JsonPayload,
|
||||
success: Json,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("sessionCommand", "/api/session/:sessionID/command", {
|
||||
params: SessionParams,
|
||||
|
||||
@@ -125,6 +125,7 @@ export interface MockServerConfig {
|
||||
sessionStatus?: Resolvable<Record<string, { type: string }>>
|
||||
inbox?: unknown[] | (() => unknown[])
|
||||
onPrompt?: (input: { sessionID: string; body: Schema.JsonObject }) => void
|
||||
onCompact?: (input: { sessionID: string; body: Schema.JsonObject }) => void
|
||||
generate?: (input: { sessionID: string; prompt: string }) => { text: string } | Promise<{ text: string }>
|
||||
onInboxChange?: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" | "queue" }) => void
|
||||
// Serves `/api/pty*` and mock PTY WebSockets. Created IDs are the first unused `${prefix}<n>` (prefix must start with "pty").
|
||||
@@ -1032,6 +1033,8 @@ function mockHandlers(
|
||||
},
|
||||
})
|
||||
}),
|
||||
// Like the server, which also answers 204 for a shell that already ended.
|
||||
shellRemove: () => noContent,
|
||||
ptyList: (ctx) =>
|
||||
ptyEnabled.pipe(
|
||||
Effect.map(() => {
|
||||
@@ -1155,7 +1158,12 @@ function mockHandlers(
|
||||
sessionFormReply: () => noContent,
|
||||
sessionFormCancel: () => noContent,
|
||||
sessionBackground: () => noContent,
|
||||
sessionInbox: () => Effect.sync(() => ({ data: resolve(config.inbox ?? []) })),
|
||||
sessionInbox: (ctx) =>
|
||||
Effect.sync(() => ({
|
||||
data: resolve(config.inbox ?? []).filter(
|
||||
(item) => Predicate.isObject(item) && item.sessionID === ctx.params.sessionID,
|
||||
),
|
||||
})),
|
||||
sessionPrompt: (ctx) =>
|
||||
Effect.sync(() => {
|
||||
const body = Option.getOrElse(decodeJsonObject(ctx.payload), () => ({}))
|
||||
@@ -1174,6 +1182,23 @@ function mockHandlers(
|
||||
},
|
||||
}
|
||||
}),
|
||||
// Like the server, a compaction is admitted as a steered inbox item under the proposed ID.
|
||||
sessionCompact: (ctx) =>
|
||||
Effect.sync(() => {
|
||||
const body = Option.getOrElse(decodeJsonObject(ctx.payload), (): Schema.JsonObject => ({}))
|
||||
config.onCompact?.({ sessionID: ctx.params.sessionID, body })
|
||||
|
||||
return {
|
||||
data: {
|
||||
id: Predicate.isString(body.id) ? body.id : `inb_mock_${Date.now()}`,
|
||||
sessionID: ctx.params.sessionID,
|
||||
time: { created: Date.now() },
|
||||
type: "compaction",
|
||||
payload: {},
|
||||
delivery: "steer",
|
||||
},
|
||||
}
|
||||
}),
|
||||
sessionGenerate: (ctx) =>
|
||||
Effect.promise(async () => ({
|
||||
data: (await config.generate?.({ sessionID: ctx.params.sessionID, prompt: ctx.payload.prompt })) ?? {
|
||||
|
||||
@@ -29,6 +29,7 @@ import { ProgressCircle } from "@opencode/ui/progress-circle"
|
||||
import type { Upload } from "../attachments/uploads"
|
||||
import { CommentCard } from "@opencode/session-ui/comment-card"
|
||||
import { typeLabel } from "@opencode/session-ui/message-file"
|
||||
import { getFilename } from "@opencode/util/path"
|
||||
import { Skill } from "@opencode/schema/skill"
|
||||
import type {
|
||||
ComposerAgentPart,
|
||||
@@ -196,6 +197,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
attachments={props.controller.attachments()}
|
||||
uploads={props.controller.uploads()}
|
||||
comments={props.controller.comments()}
|
||||
files={props.controller.files()}
|
||||
activeCommentID={state.activeContextID}
|
||||
removeLabel={i18n.t("ui.promptInput.removeAttachment")}
|
||||
onAttachmentClick={props.controller.openAttachment}
|
||||
@@ -203,6 +205,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
onUploadCancel={(upload) => props.controller.cancelUpload(upload.id)}
|
||||
onCommentClick={(comment) => props.controller.toggleContext(comment.key)}
|
||||
onCommentRemove={(comment) => props.controller.removeContext(comment.key)}
|
||||
onFileRemove={(file) => props.controller.removeContext(file.key)}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
@@ -568,6 +571,8 @@ export function ComposerAttachments(props: {
|
||||
attachments: ComposerAttachment[]
|
||||
uploads?: Upload[]
|
||||
comments?: ComposerComment[]
|
||||
// Files attached without a mention, such as one restored from a prompt another client sent.
|
||||
files?: Extract<ComposerComment, { type: "file" }>[]
|
||||
activeCommentID?: string
|
||||
removeLabel: string
|
||||
onAttachmentClick?: (attachment: ComposerAttachment) => void
|
||||
@@ -575,12 +580,20 @@ export function ComposerAttachments(props: {
|
||||
onUploadCancel?: (upload: Upload) => void
|
||||
onCommentClick?: (comment: ComposerComment) => void
|
||||
onCommentRemove?: (comment: ComposerComment) => void
|
||||
onFileRemove?: (file: Extract<ComposerComment, { type: "file" }>) => void
|
||||
}) {
|
||||
const i18n = useI18n()
|
||||
const percent = (upload: Upload) => (upload.size === 0 ? 100 : Math.floor((upload.loaded / upload.size) * 100))
|
||||
|
||||
return (
|
||||
<Show when={props.attachments.length > 0 || (props.uploads?.length ?? 0) > 0 || (props.comments?.length ?? 0) > 0}>
|
||||
<Show
|
||||
when={
|
||||
props.attachments.length > 0 ||
|
||||
(props.uploads?.length ?? 0) > 0 ||
|
||||
(props.comments?.length ?? 0) > 0 ||
|
||||
(props.files?.length ?? 0) > 0
|
||||
}
|
||||
>
|
||||
<div data-component="composer-attachments" data-slot="composer-attachments" class="relative">
|
||||
<div
|
||||
data-slot="composer-attachments-scroll"
|
||||
@@ -617,6 +630,35 @@ export function ComposerAttachments(props: {
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<For each={props.files ?? []}>
|
||||
{(file) => (
|
||||
<div class="relative group shrink-0" data-slot="composer-context-file">
|
||||
<Tooltip value={file.path} placement="top" contentClass="break-all">
|
||||
<AttachmentCard title={file.name ?? getFilename(file.path)} surface="base">
|
||||
<FileIcon node={{ path: file.path, type: "file" }} />
|
||||
<span>
|
||||
{typeLabel(file.name ?? file.path, "text/plain", i18n.t("ui.common.file"))}
|
||||
<Show when={file.selection}>
|
||||
{(selection) =>
|
||||
selection().startLine === selection().endLine
|
||||
? `:${selection().startLine}`
|
||||
: `:${selection().startLine}-${selection().endLine}`
|
||||
}
|
||||
</Show>
|
||||
</span>
|
||||
</AttachmentCard>
|
||||
</Tooltip>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onFileRemove?.(file)}
|
||||
class="absolute -top-1 -end-1 size-4 rounded-full bg-v2-icon-icon-muted outline-solid outline-1 outline-v2-icon-icon-contrast flex items-center justify-center hover-reveal group-hover:opacity-100"
|
||||
aria-label={props.removeLabel}
|
||||
>
|
||||
<Icon name="outline-xmark" class="text-v2-icon-icon-contrast" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<For each={props.attachments}>
|
||||
{(attachment) => (
|
||||
<div class="relative group shrink-0">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Predicate } from "effect"
|
||||
import { createEffect, type Accessor } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { useFilteredList } from "@opencode/ui/hooks"
|
||||
@@ -74,15 +75,21 @@ export function createComposerEditor(input: {
|
||||
let fileInput: HTMLInputElement | undefined
|
||||
const draft = createComposerEditorActions(input.store)
|
||||
const [state, setState] = input.state ?? createComposerEditorState(draft.state.mode)
|
||||
|
||||
function addPart(part: ComposerPersistedState["prompt"][number]) {
|
||||
if (isAttachment(part)) return false
|
||||
|
||||
if (part.type === "file" || part.type === "agent") {
|
||||
draft.addMention(part)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
draft.addText(part.content)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const attachments = input.attachments
|
||||
? createComposerAttachments({
|
||||
...input.attachments,
|
||||
@@ -97,24 +104,30 @@ export function createComposerEditor(input: {
|
||||
setDraggingType: (type) => dispatch({ type: type ? "drag.enter" : "drag.leave" }),
|
||||
})
|
||||
: undefined
|
||||
|
||||
const attach = () => {
|
||||
if (!attachments) {
|
||||
input.view.add?.onAttach()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// The add menu leaves focus on its trigger, so return it to the editor once files are picked.
|
||||
attachments.pick(
|
||||
() => fileInput?.click(),
|
||||
() => restoreFocus(),
|
||||
)
|
||||
}
|
||||
|
||||
const contextList = useFilteredList<ComposerSuggestion>({
|
||||
items: async (query) => {
|
||||
const fixed = input.context().filter((item) => item.kind !== "file")
|
||||
const recent = input.context().filter((item) => item.kind === "file" && item.recent)
|
||||
|
||||
if (!query.trim()) return [...fixed, ...recent]
|
||||
const seen = new Set(recent.map((item) => item.id))
|
||||
const files = (await input.searchContextFiles(query)).filter((item) => !seen.has(item.id))
|
||||
|
||||
return [...fixed, ...recent, ...files]
|
||||
},
|
||||
key: (item) => item.id,
|
||||
@@ -122,47 +135,66 @@ export function createComposerEditor(input: {
|
||||
skipFilter: (item) => item.kind === "file" && !item.recent,
|
||||
groupBy: (item) => {
|
||||
if (item.kind === "reference") return "reference"
|
||||
|
||||
if (item.kind === "skill") return "skill"
|
||||
|
||||
if (item.kind === "agent") return "agent"
|
||||
|
||||
if (item.kind === "resource") return "resource"
|
||||
|
||||
if (item.recent) return "recent"
|
||||
|
||||
return "file"
|
||||
},
|
||||
sortGroupsBy: (a, b) => {
|
||||
const order = ["reference", "skill", "agent", "resource", "recent", "file"]
|
||||
|
||||
return order.indexOf(a.category) - order.indexOf(b.category)
|
||||
},
|
||||
})
|
||||
|
||||
const commandList = useFilteredList<ComposerSuggestion>({
|
||||
items: () => input.commands(),
|
||||
key: (item) => item.id,
|
||||
filterKeys: ["trigger", "title"],
|
||||
})
|
||||
|
||||
const list = () => (state.popover.type === "context" ? contextList : commandList)
|
||||
const suggestions = () => list().flat()
|
||||
|
||||
const execute = (command: ComposerInteractionCommand) => {
|
||||
if (command.type === "draft.setText") {
|
||||
draft.setText(command.value)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (command.type === "draft.addText") {
|
||||
draft.addText(command.value)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (command.type === "mention.add") {
|
||||
if (command.item.mention) draft.addMention(command.item.mention, command.range)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (command.type === "popover.filter") {
|
||||
;(command.popover === "command" ? commandList : contextList).onInput(command.query)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (command.type === "suggestion.select") {
|
||||
const item = suggestions().find((entry) => entry.id === command.id)
|
||||
|
||||
if (item) dispatch({ type: "popover.select", item })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (command.type === "focus.editor") requestAnimationFrame(() => editor?.focus())
|
||||
}
|
||||
|
||||
@@ -170,26 +202,32 @@ export function createComposerEditor(input: {
|
||||
const mode = state.mode
|
||||
const result = transitionComposer(state, event, draft.state)
|
||||
const action = event.type === "popover.select" ? input.onSuggestionSelect?.(event.item) : undefined
|
||||
|
||||
if (event.type === "popover.select") {
|
||||
if (!action || state.popover.type !== "command-menu") result.commands.forEach(execute)
|
||||
|
||||
if (action && event.item.kind === "command" && state.popover.type !== "command-menu") {
|
||||
draft.setPrompt(
|
||||
draft.state.prompt.filter(isAttachment),
|
||||
0,
|
||||
)
|
||||
draft.setPrompt(draft.state.prompt.filter(isAttachment), 0)
|
||||
}
|
||||
}
|
||||
|
||||
setState(reconcile(result.state))
|
||||
|
||||
if (mode !== result.state.mode) draft.setMode(result.state.mode)
|
||||
|
||||
if (event.type !== "popover.select") result.commands.forEach(execute)
|
||||
|
||||
if (mode !== result.state.mode) {
|
||||
if (result.state.mode === "shell") input.view.shell?.onOpen()
|
||||
|
||||
if (result.state.mode === "normal") input.view.shell?.onClose()
|
||||
}
|
||||
|
||||
if (event.type === "popover.select") {
|
||||
if (!action) return result.handled
|
||||
action()
|
||||
}
|
||||
|
||||
return result.handled
|
||||
}
|
||||
|
||||
@@ -203,8 +241,10 @@ export function createComposerEditor(input: {
|
||||
) {
|
||||
event.preventDefault()
|
||||
attach()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const handled = dispatch({
|
||||
type: "key.down",
|
||||
key: event.key,
|
||||
@@ -213,28 +253,37 @@ export function createComposerEditor(input: {
|
||||
ids: suggestions().map((item) => item.id),
|
||||
empty: draft.state.prompt.every((part) => !("content" in part) || part.content.length === 0),
|
||||
})
|
||||
|
||||
if (handled) event.preventDefault()
|
||||
|
||||
if (handled && event.key !== "Enter" && event.key !== "Tab" && state.popover.type !== "closed") {
|
||||
const activeID = state.popover.activeID ?? ""
|
||||
requestAnimationFrame(() =>
|
||||
document.querySelector(`[data-suggestion-id="${CSS.escape(activeID)}"]`)?.scrollIntoView({ block: "nearest" }),
|
||||
)
|
||||
}
|
||||
|
||||
if (handled) return true
|
||||
|
||||
if (event.key === "Escape" && input.view.submit.queue?.editing()) {
|
||||
event.preventDefault()
|
||||
input.view.submit.queue.cancelEdit()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const stop =
|
||||
input.view.submit.working?.() &&
|
||||
((event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "g") ||
|
||||
event.key === "Escape")
|
||||
|
||||
if (stop) {
|
||||
event.preventDefault()
|
||||
input.view.submit.onStop()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (
|
||||
!event.altKey &&
|
||||
!event.ctrlKey &&
|
||||
@@ -243,14 +292,17 @@ export function createComposerEditor(input: {
|
||||
navigateHistory(event.key === "ArrowUp" ? "up" : "down")
|
||||
) {
|
||||
event.preventDefault()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return event.defaultPrevented
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (state.popover.type === "closed") return
|
||||
const ids = suggestions().map((item) => item.id)
|
||||
|
||||
if (state.popover.activeID ? ids.includes(state.popover.activeID) : ids.length === 0) return
|
||||
dispatch({ type: "popover.results", ids })
|
||||
})
|
||||
@@ -268,36 +320,48 @@ export function createComposerEditor(input: {
|
||||
draft.setPrompt(clonePrompt(entry.prompt), cursor)
|
||||
restoreFocus(cursor)
|
||||
}
|
||||
|
||||
const navigateHistory = (direction: "up" | "down") => {
|
||||
if (!input.history || !editor) return false
|
||||
const selection = window.getSelection()
|
||||
|
||||
if (!selection?.isCollapsed || !editor.contains(selection.anchorNode)) return false
|
||||
const text = draft.state.prompt.map((part) => ("content" in part ? part.content : "")).join("")
|
||||
|
||||
if (!canNavigateHistory(direction, text, editorCursor(editor), state.historyIndex >= 0)) return false
|
||||
const entries = input.history.entries(state.mode)
|
||||
|
||||
if (direction === "up") {
|
||||
if (entries.length === 0 || state.historyIndex >= entries.length - 1) return false
|
||||
|
||||
if (state.historyIndex === -1) {
|
||||
setState("savedHistory", {
|
||||
prompt: clonePrompt(draft.state.prompt),
|
||||
metadata: input.history.capture?.(),
|
||||
})
|
||||
}
|
||||
|
||||
const index = state.historyIndex + 1
|
||||
setState("historyIndex", index)
|
||||
applyHistory(entries[index]!, "start")
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (state.historyIndex < 0) return false
|
||||
|
||||
if (state.historyIndex > 0) {
|
||||
const index = state.historyIndex - 1
|
||||
setState("historyIndex", index)
|
||||
applyHistory(entries[index]!, "end")
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const saved = state.savedHistory ?? { prompt: [{ type: "text", content: "", start: 0, end: 0 }] }
|
||||
setState({ historyIndex: -1, savedHistory: undefined })
|
||||
applyHistory(saved, "end")
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -319,6 +383,9 @@ export function createComposerEditor(input: {
|
||||
comments() {
|
||||
return draft.state.context.items.filter((item) => !!item.comment?.trim())
|
||||
},
|
||||
files() {
|
||||
return draft.state.context.items.flatMap((item) => (item.type === "file" && !item.comment?.trim() ? [item] : []))
|
||||
},
|
||||
attachments(): ComposerAttachment[] {
|
||||
return draft.state.prompt.filter(isAttachment)
|
||||
},
|
||||
@@ -334,8 +401,10 @@ export function createComposerEditor(input: {
|
||||
},
|
||||
removeContext(id: string) {
|
||||
const item = draft.state.context.items.find((entry) => entry.key === id)
|
||||
|
||||
if (item) input.onContextRemove?.(item)
|
||||
draft.removeContext(id)
|
||||
|
||||
if (state.activeContextID === id) dispatch({ type: "context.active", id })
|
||||
},
|
||||
openAttachment(attachment: ComposerAttachment) {
|
||||
@@ -346,14 +415,20 @@ export function createComposerEditor(input: {
|
||||
},
|
||||
canSubmit() {
|
||||
if (input.view.submit.available?.() === false) return false
|
||||
|
||||
if (input.view.draftOnly) return false
|
||||
|
||||
if (attachments?.pending().length) return false
|
||||
const persisted = draft.state
|
||||
|
||||
if (state.mode === "shell") {
|
||||
return persisted.prompt.some((part) => "content" in part && !!part.content.trim())
|
||||
}
|
||||
|
||||
if (persisted.prompt.some(isAttachment)) return true
|
||||
|
||||
if (persisted.context.items.some((item) => !!item.comment?.trim())) return true
|
||||
|
||||
return persisted.prompt.some((part) => "content" in part && !!part.content.trim())
|
||||
},
|
||||
setEditor(element: HTMLElement) {
|
||||
@@ -363,6 +438,7 @@ export function createComposerEditor(input: {
|
||||
restoreFocus,
|
||||
onInput(value: string, prompt?: ComposerPersistedState["prompt"], cursor?: number) {
|
||||
if (prompt) draft.setPrompt(prompt, cursor)
|
||||
|
||||
if (input.view.draftOnly) return
|
||||
dispatch({ type: "input.changed", value, persist: !prompt })
|
||||
},
|
||||
@@ -380,7 +456,9 @@ export function createComposerEditor(input: {
|
||||
},
|
||||
submit(options?: { alternate?: boolean }) {
|
||||
if (input.view.submit.available?.() === false) return
|
||||
|
||||
if (input.view.draftOnly) return
|
||||
|
||||
if (attachments?.pending().length) return
|
||||
input.view.submit.onSubmit(options)
|
||||
dispatch({ type: "popover.close" })
|
||||
@@ -398,26 +476,32 @@ export function createComposerEditor(input: {
|
||||
onPaste(event: ClipboardEvent) {
|
||||
const clipboard = event.clipboardData
|
||||
const text = clipboard?.getData("text/plain")
|
||||
|
||||
if (attachments && shouldHandlePasteAsAttachment(clipboard, !!input.attachments?.readClipboardImage)) {
|
||||
void attachments.handlePaste(event)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (!text) return
|
||||
event.preventDefault()
|
||||
// insertText emits input events per line, repeatedly parsing and saving the draft.
|
||||
// Escaped HTML inserts multiline text once and preserves native selection and undo.
|
||||
const normalized = text.replace(/\r\n?/g, "\n")
|
||||
const multiline = normalized.includes("\n")
|
||||
|
||||
const value = multiline
|
||||
? normalized.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">")
|
||||
: normalized
|
||||
|
||||
if (
|
||||
typeof document.execCommand === "function" &&
|
||||
Predicate.isFunction(document.execCommand) &&
|
||||
document.execCommand(multiline ? "insertHTML" : "insertText", false, value)
|
||||
)
|
||||
return
|
||||
const target = event.currentTarget
|
||||
const selection = window.getSelection()
|
||||
|
||||
if (!(target instanceof HTMLElement) || !selection?.rangeCount || !target.contains(selection.anchorNode)) return
|
||||
const range = selection.getRangeAt(0)
|
||||
range.deleteContents()
|
||||
@@ -442,9 +526,11 @@ export function createComposerEditor(input: {
|
||||
onDrop(event: DragEvent) {
|
||||
event.preventDefault()
|
||||
dispatch({ type: "drag.leave" })
|
||||
|
||||
if (attachments) {
|
||||
event.stopPropagation()
|
||||
void attachments.handleDrop(event)
|
||||
|
||||
return
|
||||
}
|
||||
},
|
||||
@@ -467,23 +553,30 @@ export type ComposerEditorModel = ReturnType<typeof createComposerEditor>
|
||||
|
||||
export function shouldHandlePasteAsAttachment(clipboard: DataTransfer | null, readClipboardImage: boolean) {
|
||||
if (Array.from(clipboard?.items ?? []).some((item) => item.kind === "file")) return true
|
||||
|
||||
if (Array.from(clipboard?.types ?? []).some((type) => type.startsWith("text/"))) return false
|
||||
|
||||
return readClipboardImage
|
||||
}
|
||||
|
||||
function canNavigateHistory(direction: "up" | "down", text: string, cursor: number, inHistory: boolean) {
|
||||
const position = Math.max(0, Math.min(cursor, text.length))
|
||||
|
||||
if (inHistory) return position === 0 || position === text.length
|
||||
|
||||
if (direction === "up") return position === 0 && text.length === 0
|
||||
|
||||
return position === text.length
|
||||
}
|
||||
|
||||
function editorCursor(editor: HTMLElement) {
|
||||
const selection = window.getSelection()
|
||||
|
||||
if (!selection?.rangeCount || !editor.contains(selection.anchorNode)) return editor.textContent?.length ?? 0
|
||||
const range = selection.getRangeAt(0).cloneRange()
|
||||
range.selectNodeContents(editor)
|
||||
range.setEnd(selection.anchorNode!, selection.anchorOffset)
|
||||
|
||||
return range.toString().length
|
||||
}
|
||||
|
||||
@@ -492,8 +585,10 @@ function setEditorCursor(editor: HTMLElement | undefined, cursor: number) {
|
||||
const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT)
|
||||
let remaining = cursor
|
||||
let node = walker.nextNode()
|
||||
|
||||
while (node) {
|
||||
const length = node.textContent?.length ?? 0
|
||||
|
||||
if (remaining <= length) {
|
||||
const range = document.createRange()
|
||||
range.setStart(node, remaining)
|
||||
@@ -501,8 +596,10 @@ function setEditorCursor(editor: HTMLElement | undefined, cursor: number) {
|
||||
const selection = window.getSelection()
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
remaining -= length
|
||||
node = walker.nextNode()
|
||||
}
|
||||
|
||||
@@ -280,6 +280,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
title: language.t("prompt.toast.modelAgentRequired.title"),
|
||||
description: language.t("prompt.toast.modelAgentRequired.description"),
|
||||
}),
|
||||
unqueueable: () => showToast({ title: language.t("prompt.toast.unqueueable.title") }),
|
||||
failed: (kind, error) =>
|
||||
showToast({
|
||||
title: language.t(sendFailedTitle[kind]),
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionMessageUser } from "@opencode/client/promise"
|
||||
import { extractPromptComments, extractPromptFromMessage } from "./prompt"
|
||||
import { extractPromptContext, extractPromptFromMessage } from "./prompt"
|
||||
import { buildPromptRequest } from "./request"
|
||||
import { contextItemKey } from "./schema"
|
||||
|
||||
describe("extractPromptFromMessage", () => {
|
||||
test("restores uploaded attachments in order, optimistic data URLs, and review comments", () => {
|
||||
@@ -41,9 +43,10 @@ describe("extractPromptFromMessage", () => {
|
||||
blob: { id: url, url },
|
||||
})),
|
||||
])
|
||||
expect(extractPromptComments(message)).toMatchObject([
|
||||
{ path: "src/app.ts", comment: "check this", origin: "review" },
|
||||
])
|
||||
expect(extractPromptContext(message)).toMatchObject({
|
||||
comments: [{ type: "file", path: "src/app.ts", comment: "check this", commentOrigin: "review" }],
|
||||
files: [],
|
||||
})
|
||||
})
|
||||
|
||||
test("keeps the directory of a file mention without an at-sign", () => {
|
||||
@@ -81,6 +84,97 @@ describe("extractPromptFromMessage", () => {
|
||||
expect(extractPromptFromMessage(message)[0]).toMatchObject({ type: "text", content: "model text" })
|
||||
})
|
||||
|
||||
test("restores every input another client sent without duplicating review comment files", () => {
|
||||
const message = {
|
||||
id: "msg_1",
|
||||
type: "user",
|
||||
text: "model text",
|
||||
metadata: {
|
||||
displayText: "日本 @main.ts",
|
||||
comments: [
|
||||
{
|
||||
path: "/repo/app.ts",
|
||||
comment: "check this",
|
||||
selection: { startLine: 2, startChar: 0, endLine: 2, endChar: 0 },
|
||||
},
|
||||
],
|
||||
attachments: [{ name: "report.zip", mime: "application/zip", path: "/repo/report.zip" }],
|
||||
},
|
||||
files: [
|
||||
// Display-width offsets, as the TUI records them after wide characters.
|
||||
{
|
||||
data: "",
|
||||
mime: "text/plain",
|
||||
source: { type: "uri", uri: "file:///repo/main.ts" },
|
||||
name: "main.ts",
|
||||
mention: { text: "@main.ts", start: 5, end: 13 },
|
||||
},
|
||||
{
|
||||
data: "aGk=",
|
||||
mime: "text/plain",
|
||||
source: { type: "uri", uri: "file:///repo/app.ts?start=2&end=2" },
|
||||
name: "app.ts",
|
||||
},
|
||||
{
|
||||
data: "bm90ZXM=",
|
||||
mime: "text/markdown",
|
||||
source: { type: "uri", uri: "file:///repo/notes.md" },
|
||||
name: "notes.md",
|
||||
description: "the failing version",
|
||||
},
|
||||
{
|
||||
data: "c3JjLw==",
|
||||
mime: "application/x-directory",
|
||||
source: { type: "uri", uri: "file:///repo/src" },
|
||||
name: "src",
|
||||
},
|
||||
// The workspace root has no relative path.
|
||||
{ data: "", mime: "application/x-directory", source: { type: "uri", uri: "file:///repo" }, name: "repo" },
|
||||
// An empty file is stored with empty data, not missing data.
|
||||
{ data: "", mime: "text/plain", source: { type: "inline" }, name: "empty.txt" },
|
||||
],
|
||||
agents: [{ name: "plan" }],
|
||||
skills: [{ id: "review", name: "Review" }],
|
||||
time: { created: 1 },
|
||||
} satisfies SessionMessageUser
|
||||
|
||||
expect(extractPromptFromMessage(message, { directory: "/repo" })).toMatchObject([
|
||||
{ type: "text", content: "日本 " },
|
||||
{ type: "file", content: "@main.ts", url: "file:///repo/main.ts" },
|
||||
{ type: "text", content: " " },
|
||||
{ type: "agent", content: "@plan", name: "plan" },
|
||||
{ type: "text", content: " " },
|
||||
{ type: "skill", content: "@review", id: "review" },
|
||||
{ type: "image", filename: "empty.txt", mime: "text/plain", blob: { url: "data:text/plain;base64," } },
|
||||
{ type: "path", filename: "report.zip", path: "/repo/report.zip" },
|
||||
])
|
||||
|
||||
// Unmentioned file references return as context chips, not mention text, as the TUI keeps them.
|
||||
const context = extractPromptContext(message, { directory: "/repo" })
|
||||
|
||||
expect(context.files).toEqual([
|
||||
{ type: "file", path: "notes.md", name: "notes.md", description: "the failing version" },
|
||||
// A directory keeps its URI rather than turning into a snapshot of its listing.
|
||||
{ type: "file", path: "src", name: "src" },
|
||||
{ type: "file", path: "/repo", name: "repo" },
|
||||
])
|
||||
// Resubmitting the restored context sends each file once, under the URI, name, and description it had.
|
||||
expect(
|
||||
buildPromptRequest({
|
||||
prompt: [],
|
||||
context: [...context.comments, ...context.files].map((item) => ({ ...item, key: contextItemKey(item) })),
|
||||
images: [],
|
||||
text: "",
|
||||
sessionDirectory: "/repo",
|
||||
}).files.map((file) => [file.uri, file.name, file.description]),
|
||||
).toEqual([
|
||||
["file:///repo/app.ts?start=2&end=2", "app.ts", undefined],
|
||||
["file:///repo/notes.md", "notes.md", "the failing version"],
|
||||
["file:///repo/src", "src", undefined],
|
||||
["file:///repo", "repo", undefined],
|
||||
])
|
||||
})
|
||||
|
||||
test("restores skill mentions as structured Composer parts", () => {
|
||||
const message = {
|
||||
id: "msg_1",
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt, SkillPart } from "@/composer/state"
|
||||
import type { FileAttachmentPart, ImageAttachmentPart, PathAttachmentPart, Prompt } from "@/composer/state"
|
||||
import { createLegacyBlobReference } from "@/runtime/persistence/drafts"
|
||||
import type { SessionMessageUser } from "@opencode/client/promise"
|
||||
import { readPromptPresentation } from "./comment-note"
|
||||
import { commentContextItem, readPromptPresentation } from "./comment-note"
|
||||
import { buildPromptRequest } from "./request"
|
||||
import { contextItemKey, type FileContextItem } from "./schema"
|
||||
import { createPathHelpers, decodeFilePath, stripFileProtocol, stripQueryAndHash } from "@/workspaces/files/path"
|
||||
import { Skill } from "@opencode/schema/skill"
|
||||
|
||||
type Inline =
|
||||
@@ -17,8 +20,10 @@ type Inline =
|
||||
startChar: number
|
||||
endChar: number
|
||||
}
|
||||
url?: string
|
||||
mime?: string
|
||||
filename?: string
|
||||
description?: string
|
||||
}
|
||||
| {
|
||||
type: "agent"
|
||||
@@ -38,11 +43,14 @@ type Inline =
|
||||
|
||||
function selectionFromFileUrl(url: string): Extract<Inline, { type: "file" }>["selection"] {
|
||||
const queryIndex = url.indexOf("?")
|
||||
|
||||
if (queryIndex === -1) return undefined
|
||||
const params = new URLSearchParams(url.slice(queryIndex + 1))
|
||||
const startLine = Number(params.get("start"))
|
||||
const endLine = Number(params.get("end"))
|
||||
|
||||
if (!Number.isFinite(startLine) || !Number.isFinite(endLine)) return undefined
|
||||
|
||||
return {
|
||||
startLine,
|
||||
endLine,
|
||||
@@ -51,24 +59,38 @@ function selectionFromFileUrl(url: string): Extract<Inline, { type: "file" }>["s
|
||||
}
|
||||
}
|
||||
|
||||
// A user message or a pending inbox item: the materialized transcript row shares the inbox payload's shape.
|
||||
export type PromptSource = Pick<SessionMessageUser, "id" | "text" | "files" | "agents" | "skills" | "metadata">
|
||||
|
||||
// Restores the composer content that produced a prompt, losing nothing it carried. File references
|
||||
// without a mention return as context through extractPromptContext; unmentioned agents and skills
|
||||
// return as trailing mentions, and inline files as attachments.
|
||||
export function extractPromptFromMessage(
|
||||
message: SessionMessageUser,
|
||||
message: PromptSource,
|
||||
opts?: { directory?: string; attachmentName?: string },
|
||||
): Prompt {
|
||||
const text = readPromptPresentation(message.metadata)?.displayText ?? message.text
|
||||
const presentation = readPromptPresentation(message.metadata)
|
||||
const text = presentation?.displayText ?? message.text
|
||||
const directory = opts?.directory
|
||||
const attachmentName = opts?.attachmentName ?? "attachment"
|
||||
|
||||
const toRelative = (path: string) => {
|
||||
if (!directory) return path
|
||||
const prefix = directory.endsWith("/") ? directory : directory + "/"
|
||||
|
||||
if (path.startsWith(prefix)) return path.slice(prefix.length)
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
const inline: Inline[] = []
|
||||
const images: ImageAttachmentPart[] = []
|
||||
const trailing: Inline[] = []
|
||||
const attachments: (ImageAttachmentPart | PathAttachmentPart)[] = []
|
||||
|
||||
for (const file of message.files ?? []) {
|
||||
const mention = file.mention
|
||||
const uri = file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`
|
||||
|
||||
if (mention) {
|
||||
inline.push({
|
||||
type: "file",
|
||||
@@ -77,63 +99,130 @@ export function extractPromptFromMessage(
|
||||
value: mention.text,
|
||||
path: toRelative(mention.text.startsWith("@") ? mention.text.slice(1) : mention.text),
|
||||
selection: selectionFromFileUrl(uri),
|
||||
url: uri,
|
||||
mime: file.mime,
|
||||
filename: file.name,
|
||||
description: file.description,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// A file reference returns as a context chip through extractPromptContext.
|
||||
if (file.source.type === "uri" && file.source.uri.startsWith("file:")) continue
|
||||
|
||||
// Stored files carry their bytes, and an empty file has empty data; only a local handoff row
|
||||
// leaves data empty because its bytes live at a blob URL.
|
||||
const stored = file.source.type === "inline" || !!file.data
|
||||
|
||||
const dataUrl =
|
||||
file.source.type === "uri" && file.source.uri.startsWith("data:")
|
||||
? file.source.uri
|
||||
: file.data
|
||||
: stored
|
||||
? `data:${file.mime};base64,${file.data}`
|
||||
: undefined
|
||||
|
||||
if (!dataUrl) continue
|
||||
images.push({
|
||||
attachments.push({
|
||||
type: "image",
|
||||
id: `${message.id}:file:${images.length}`,
|
||||
id: `${message.id}:file:${attachments.length}`,
|
||||
filename: file.name ?? attachmentName,
|
||||
mime: file.mime,
|
||||
blob: createLegacyBlobReference(dataUrl),
|
||||
})
|
||||
}
|
||||
|
||||
for (const agent of message.agents ?? []) {
|
||||
const mention = agent.mention
|
||||
if (!mention) continue
|
||||
inline.push({
|
||||
type: "agent",
|
||||
start: mention.start,
|
||||
end: mention.end,
|
||||
value: mention.text,
|
||||
name: agent.name,
|
||||
})
|
||||
const part = { type: "agent" as const, name: agent.name }
|
||||
|
||||
if (!mention) trailing.push({ ...part, start: -1, end: -1, value: `@${agent.name}` })
|
||||
|
||||
if (mention) inline.push({ ...part, start: mention.start, end: mention.end, value: mention.text })
|
||||
}
|
||||
|
||||
for (const attached of message.skills ?? []) {
|
||||
const mention = attached.mention
|
||||
if (!mention) continue
|
||||
inline.push({
|
||||
type: "skill",
|
||||
start: mention.start,
|
||||
end: mention.end,
|
||||
value: mention.text,
|
||||
id: Skill.ID.make(attached.id),
|
||||
name: Skill.Name.make(attached.name),
|
||||
})
|
||||
const part = { type: "skill" as const, id: Skill.ID.make(attached.id), name: Skill.Name.make(attached.name) }
|
||||
|
||||
if (!mention) trailing.push({ ...part, start: -1, end: -1, value: `@${attached.id}` })
|
||||
|
||||
if (mention) inline.push({ ...part, start: mention.start, end: mention.end, value: mention.text })
|
||||
}
|
||||
return buildPrompt(text, inline, images)
|
||||
|
||||
attachments.push(
|
||||
...(presentation?.attachments ?? []).map(
|
||||
(file, index): PathAttachmentPart => ({
|
||||
type: "path",
|
||||
id: `${message.id}:path:${index}`,
|
||||
filename: file.name,
|
||||
mime: file.mime,
|
||||
path: file.path,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
return buildPrompt(text, inline, trailing, attachments)
|
||||
}
|
||||
|
||||
export function extractPromptComments(message: SessionMessageUser) {
|
||||
return readPromptPresentation(message.metadata)?.comments ?? []
|
||||
/**
|
||||
* The composer context a sent prompt restores: its comments, and the files it attached without a mention. Like the
|
||||
* TUI, an unmentioned file keeps its URI rather than becoming text; comment files are regenerated from the comments.
|
||||
*/
|
||||
export function extractPromptContext(message: PromptSource, opts?: { directory?: string }) {
|
||||
const comments = (readPromptPresentation(message.metadata)?.comments ?? []).map(commentContextItem)
|
||||
const directory = opts?.directory
|
||||
|
||||
const regenerated = new Set(
|
||||
buildPromptRequest({
|
||||
prompt: [],
|
||||
context: comments.map((item) => ({ ...item, key: contextItemKey(item) })),
|
||||
images: [],
|
||||
text: "",
|
||||
sessionDirectory: directory ?? "",
|
||||
}).files.map((file) => file.uri),
|
||||
)
|
||||
|
||||
const files = (message.files ?? []).flatMap((file): FileContextItem[] => {
|
||||
if (file.mention || file.source.type !== "uri" || !file.source.uri.startsWith("file:")) return []
|
||||
|
||||
if (regenerated.has(file.source.uri)) return []
|
||||
|
||||
const absolute = decodeFilePath(stripQueryAndHash(stripFileProtocol(file.source.uri))).replace(
|
||||
/^\/([A-Za-z]:)/,
|
||||
"$1",
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
type: "file",
|
||||
// The workspace root itself has no relative path, so it keeps its absolute one.
|
||||
path: (directory && createPathHelpers(() => directory).normalize(file.source.uri)) || absolute,
|
||||
selection: selectionFromFileUrl(file.source.uri),
|
||||
name: file.name,
|
||||
description: file.description,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
return { comments, files }
|
||||
}
|
||||
|
||||
function buildPrompt(text: string, inline: Inline[], images: ImageAttachmentPart[]): Prompt {
|
||||
function buildPrompt(
|
||||
text: string,
|
||||
inline: Inline[],
|
||||
trailing: Inline[],
|
||||
attachments: (ImageAttachmentPart | PathAttachmentPart)[],
|
||||
): Prompt {
|
||||
inline.sort((a, b) => {
|
||||
if (a.start !== b.start) return a.start - b.start
|
||||
|
||||
return a.end - b.end
|
||||
})
|
||||
|
||||
const result: Prompt = []
|
||||
let position = 0
|
||||
let cursor = 0
|
||||
let tail = ""
|
||||
|
||||
const pushText = (content: string) => {
|
||||
if (!content) return
|
||||
@@ -144,77 +233,70 @@ function buildPrompt(text: string, inline: Inline[], images: ImageAttachmentPart
|
||||
end: position + content.length,
|
||||
})
|
||||
position += content.length
|
||||
tail = content
|
||||
}
|
||||
|
||||
const pushFile = (item: Extract<Inline, { type: "file" }>) => {
|
||||
const pushPart = (item: Inline) => {
|
||||
const content = item.value
|
||||
const attachment: FileAttachmentPart = {
|
||||
const span = { content, start: position, end: position + content.length }
|
||||
position += content.length
|
||||
tail = content
|
||||
|
||||
if (item.type === "agent") {
|
||||
result.push({ type: "agent", name: item.name, ...span })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (item.type === "skill") {
|
||||
result.push({ type: "skill", id: item.id, name: item.name, ...span })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
result.push({
|
||||
type: "file",
|
||||
path: item.path,
|
||||
content,
|
||||
start: position,
|
||||
end: position + content.length,
|
||||
selection: item.selection,
|
||||
url: item.url,
|
||||
mime: item.mime,
|
||||
filename: item.filename,
|
||||
}
|
||||
result.push(attachment)
|
||||
position += content.length
|
||||
description: item.description,
|
||||
...span,
|
||||
} satisfies FileAttachmentPart)
|
||||
}
|
||||
|
||||
const pushAgent = (item: Extract<Inline, { type: "agent" }>) => {
|
||||
const content = item.value
|
||||
const mention: AgentPart = {
|
||||
type: "agent",
|
||||
name: item.name,
|
||||
content,
|
||||
start: position,
|
||||
end: position + content.length,
|
||||
}
|
||||
result.push(mention)
|
||||
position += content.length
|
||||
}
|
||||
// A mention whose recorded offsets no longer index the text (another client may count
|
||||
// display width) is found by its text; one missing from the text returns at the end.
|
||||
const unplaced = inline.flatMap((item) => {
|
||||
if (!item.value) return []
|
||||
|
||||
const pushSkill = (item: Extract<Inline, { type: "skill" }>) => {
|
||||
const content = item.value
|
||||
const skill: SkillPart = {
|
||||
type: "skill",
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
content,
|
||||
start: position,
|
||||
end: position + content.length,
|
||||
}
|
||||
result.push(skill)
|
||||
position += content.length
|
||||
}
|
||||
const mismatch =
|
||||
item.start < cursor ||
|
||||
item.end < item.start ||
|
||||
item.end > text.length ||
|
||||
text.slice(item.start, item.end) !== item.value
|
||||
|
||||
for (const item of inline) {
|
||||
if (item.start < 0 || item.end < item.start) continue
|
||||
|
||||
const expected = item.value
|
||||
if (!expected) continue
|
||||
|
||||
const mismatch = item.end > text.length || item.start < cursor || text.slice(item.start, item.end) !== expected
|
||||
const start = mismatch ? text.indexOf(expected, cursor) : item.start
|
||||
if (start === -1) continue
|
||||
const end = mismatch ? start + expected.length : item.end
|
||||
const start = mismatch ? text.indexOf(item.value, cursor) : item.start
|
||||
|
||||
if (start === -1) return [item]
|
||||
pushText(text.slice(cursor, start))
|
||||
pushPart(item)
|
||||
cursor = start + item.value.length
|
||||
|
||||
if (item.type === "file") pushFile(item)
|
||||
if (item.type === "agent") pushAgent(item)
|
||||
if (item.type === "skill") pushSkill(item)
|
||||
|
||||
cursor = end
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
pushText(text.slice(cursor))
|
||||
|
||||
for (const item of [...unplaced, ...trailing]) {
|
||||
if (tail && !/\s$/.test(tail)) pushText(" ")
|
||||
pushPart(item)
|
||||
}
|
||||
|
||||
if (result.length === 0) {
|
||||
result.push({ type: "text", content: "", start: 0, end: 0 })
|
||||
}
|
||||
|
||||
if (images.length === 0) return result
|
||||
return [...result, ...images]
|
||||
return [...result, ...attachments]
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import type {
|
||||
ContextItem,
|
||||
FileAttachmentPart,
|
||||
ImageAttachmentPart,
|
||||
NoteComment,
|
||||
NoteContextItem,
|
||||
PathAttachmentPart,
|
||||
Prompt,
|
||||
SkillPart,
|
||||
@@ -21,7 +23,13 @@ import {
|
||||
type PromptRequest = {
|
||||
text: string
|
||||
displayText: string
|
||||
files: { uri: string; mime: string; name?: string; mention?: { start: number; end: number; text: string } }[]
|
||||
files: {
|
||||
uri: string
|
||||
mime: string
|
||||
name?: string
|
||||
description?: string
|
||||
mention?: { start: number; end: number; text: string }
|
||||
}[]
|
||||
agents: { name: string; mention?: { start: number; end: number; text: string } }[]
|
||||
skills: { id: string; name: string; mention?: { start: number; end: number; text: string } }[]
|
||||
comments: PromptComment[]
|
||||
@@ -38,8 +46,11 @@ type BuildPromptRequestInput = {
|
||||
|
||||
const absolute = (directory: string, path: string) => {
|
||||
if (path.startsWith("/")) return path
|
||||
|
||||
if (/^[A-Za-z]:[\\/]/.test(path) || /^[A-Za-z]:$/.test(path)) return path
|
||||
|
||||
if (path.startsWith("\\\\") || path.startsWith("//")) return path
|
||||
|
||||
return `${directory.replace(/[\\/]+$/, "")}/${path}`
|
||||
}
|
||||
|
||||
@@ -51,28 +62,54 @@ const mention = /(^|[\s([{"'])@(\S+)/g
|
||||
const parseCommentMentions = (comment: string) => {
|
||||
return Array.from(comment.matchAll(mention)).flatMap((match) => {
|
||||
const path = (match[2] ?? "").replace(/[.,!?;:)}\]"']+$/, "")
|
||||
|
||||
if (!path) return []
|
||||
|
||||
return [path]
|
||||
})
|
||||
}
|
||||
|
||||
const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
|
||||
|
||||
const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
|
||||
|
||||
const isSkillAttachment = (part: Prompt[number]): part is SkillPart => part.type === "skill"
|
||||
|
||||
const isPathAttachment = (part: Prompt[number]): part is PathAttachmentPart => part.type === "path"
|
||||
|
||||
/** The sent form of a note: its optional link and live subject travel only when present. */
|
||||
export function noteComment(item: NoteContextItem, comment: string) {
|
||||
const note: NoteComment = {
|
||||
type: "note",
|
||||
origin: item.origin,
|
||||
label: item.label,
|
||||
icon: item.icon,
|
||||
subject: item.subject,
|
||||
comment,
|
||||
}
|
||||
|
||||
if (item.href) note.href = item.href
|
||||
|
||||
if (item.live) note.live = { ...item.live }
|
||||
|
||||
return note
|
||||
}
|
||||
|
||||
export function buildPromptRequest(input: BuildPromptRequestInput): PromptRequest {
|
||||
const skills = input.prompt.filter(isSkillAttachment).map((attachment) => ({
|
||||
id: attachment.id,
|
||||
name: attachment.name,
|
||||
mention: { start: attachment.start, end: attachment.end, text: attachment.content },
|
||||
}))
|
||||
|
||||
const files = input.prompt.filter(isFileAttachment).map((attachment) => {
|
||||
const path = absolute(input.sessionDirectory, attachment.path)
|
||||
|
||||
return {
|
||||
uri: attachment.url ?? `file://${encodeFilePath(path)}${fileQuery(attachment.selection)}`,
|
||||
mime: attachment.mime ?? "text/plain",
|
||||
name: attachment.filename ?? getFilename(attachment.path),
|
||||
description: attachment.description,
|
||||
mention: { start: attachment.start, end: attachment.end, text: attachment.content },
|
||||
}
|
||||
})
|
||||
@@ -84,36 +121,36 @@ export function buildPromptRequest(input: BuildPromptRequestInput): PromptReques
|
||||
|
||||
const used = new Set(files.map((file) => file.uri))
|
||||
const comments: PromptComment[] = []
|
||||
|
||||
const mentioned = (comment: string) =>
|
||||
parseCommentMentions(comment).flatMap((path) => {
|
||||
const uri = `file://${encodeFilePath(absolute(input.sessionDirectory, path))}`
|
||||
|
||||
if (used.has(uri)) return []
|
||||
used.add(uri)
|
||||
|
||||
return [{ uri, mime: "text/plain", name: getFilename(path) }]
|
||||
})
|
||||
|
||||
const context = input.context.flatMap((item) => {
|
||||
if (item.type === "note") {
|
||||
const comment = item.comment.trim()
|
||||
|
||||
if (!comment) return []
|
||||
comments.push({
|
||||
type: "note",
|
||||
origin: item.origin,
|
||||
label: item.label,
|
||||
icon: item.icon,
|
||||
subject: item.subject,
|
||||
...(item.href ? { href: item.href } : {}),
|
||||
...(item.live ? { live: { ...item.live } } : {}),
|
||||
comment,
|
||||
})
|
||||
comments.push(noteComment(item, comment))
|
||||
|
||||
return mentioned(comment)
|
||||
}
|
||||
|
||||
const path = absolute(input.sessionDirectory, item.path)
|
||||
const uri = `file://${encodeFilePath(path)}${fileQuery(item.selection)}`
|
||||
const comment = item.comment?.trim()
|
||||
|
||||
if (!comment && used.has(uri)) return []
|
||||
used.add(uri)
|
||||
|
||||
const file = { uri, mime: "text/plain", name: getFilename(item.path) }
|
||||
const file = { uri, mime: "text/plain", name: item.name ?? getFilename(item.path), description: item.description }
|
||||
|
||||
if (!comment) return [file]
|
||||
|
||||
comments.push({
|
||||
@@ -123,6 +160,7 @@ export function buildPromptRequest(input: BuildPromptRequestInput): PromptReques
|
||||
preview: item.preview,
|
||||
origin: item.commentOrigin,
|
||||
})
|
||||
|
||||
return [file, ...mentioned(comment)]
|
||||
})
|
||||
|
||||
@@ -131,6 +169,7 @@ export function buildPromptRequest(input: BuildPromptRequestInput): PromptReques
|
||||
mime: attachment.mime,
|
||||
name: attachment.sourcePath ?? attachment.filename,
|
||||
}))
|
||||
|
||||
// Like comments, path references reach the model as text and the message UI through metadata.
|
||||
const attachments = input.prompt
|
||||
.filter(isPathAttachment)
|
||||
|
||||
@@ -12,7 +12,9 @@ const PartBase = {
|
||||
}
|
||||
|
||||
const SourceText = Schema.Struct({ value: Schema.String, start: Schema.Number, end: Schema.Number })
|
||||
|
||||
const Position = Schema.Struct({ line: Schema.Number, character: Schema.Number })
|
||||
|
||||
const FilePartSource = Schema.Union([
|
||||
Schema.Struct({ type: Schema.Literal("file"), text: SourceText, path: Schema.String }),
|
||||
Schema.Struct({
|
||||
@@ -27,6 +29,7 @@ const FilePartSource = Schema.Union([
|
||||
])
|
||||
|
||||
export const TextPart = Persistence.struct({ type: Schema.Literal("text"), ...PartBase })
|
||||
|
||||
export type TextPart = typeof TextPart.Type
|
||||
|
||||
export const FileAttachmentPart = Persistence.struct({
|
||||
@@ -36,12 +39,15 @@ export const FileAttachmentPart = Persistence.struct({
|
||||
selection: Persistence.optional(FileSelection),
|
||||
mime: Persistence.optional(Schema.String),
|
||||
filename: Persistence.optional(Schema.String),
|
||||
description: Persistence.optional(Schema.String),
|
||||
url: Persistence.optional(Schema.String),
|
||||
source: Persistence.optional(FilePartSource),
|
||||
})
|
||||
|
||||
export type FileAttachmentPart = typeof FileAttachmentPart.Type
|
||||
|
||||
export const AgentPart = Persistence.struct({ type: Schema.Literal("agent"), ...PartBase, name: Schema.String })
|
||||
|
||||
export type AgentPart = typeof AgentPart.Type
|
||||
|
||||
export const SkillPart = Persistence.struct({
|
||||
@@ -50,6 +56,7 @@ export const SkillPart = Persistence.struct({
|
||||
id: Skill.ID,
|
||||
name: Skill.Name,
|
||||
})
|
||||
|
||||
export type SkillPart = typeof SkillPart.Type
|
||||
|
||||
const ImageFields = {
|
||||
@@ -59,6 +66,7 @@ const ImageFields = {
|
||||
sourcePath: Persistence.optional(Schema.String),
|
||||
mime: Schema.String,
|
||||
}
|
||||
|
||||
const Image = Persistence.struct({
|
||||
...ImageFields,
|
||||
// An empty URL is an image whose bytes are still in the draft store; see `resolveBlobUrl`.
|
||||
@@ -78,6 +86,7 @@ export const ImageAttachmentPart = Schema.Struct({
|
||||
decode: SchemaGetter.transform((value) => {
|
||||
const id = value.blob?.id ?? value.dataUrl ?? ""
|
||||
const url = value.blob?.url
|
||||
|
||||
return {
|
||||
type: value.type,
|
||||
id: value.id,
|
||||
@@ -93,6 +102,7 @@ export const ImageAttachmentPart = Schema.Struct({
|
||||
encode: SchemaGetter.transform((value) => value),
|
||||
}),
|
||||
)
|
||||
|
||||
export type ImageAttachmentPart = typeof ImageAttachmentPart.Type
|
||||
|
||||
// A file the model receives as a path on the server: its bytes never enter the draft store.
|
||||
@@ -103,6 +113,7 @@ export const PathAttachmentPart = Persistence.struct({
|
||||
mime: Schema.String,
|
||||
path: Schema.String,
|
||||
})
|
||||
|
||||
export type PathAttachmentPart = typeof PathAttachmentPart.Type
|
||||
|
||||
export const ContentPart = Schema.Union([
|
||||
@@ -113,8 +124,11 @@ export const ContentPart = Schema.Union([
|
||||
ImageAttachmentPart,
|
||||
PathAttachmentPart,
|
||||
])
|
||||
|
||||
export type ContentPart = typeof ContentPart.Type
|
||||
|
||||
export const Prompt = Persistence.array(ContentPart)
|
||||
|
||||
export type Prompt = typeof Prompt.Type
|
||||
|
||||
export const PromptModel = Persistence.struct({
|
||||
@@ -122,6 +136,7 @@ export const PromptModel = Persistence.struct({
|
||||
modelID: Schema.String,
|
||||
variant: Persistence.optional(Schema.NullOr(Schema.String)),
|
||||
})
|
||||
|
||||
export type PromptModel = typeof PromptModel.Type
|
||||
|
||||
export const FileContextItem = Persistence.struct({
|
||||
@@ -132,7 +147,11 @@ export const FileContextItem = Persistence.struct({
|
||||
commentID: Persistence.optional(Schema.String),
|
||||
commentOrigin: Persistence.optional(Schema.Literals(["review", "file"])),
|
||||
preview: Persistence.optional(Schema.String),
|
||||
// A file restored from a sent prompt keeps the name and description it was sent with.
|
||||
name: Persistence.optional(Schema.String),
|
||||
description: Persistence.optional(Schema.String),
|
||||
})
|
||||
|
||||
export type FileContextItem = typeof FileContextItem.Type
|
||||
|
||||
const NoteFields = {
|
||||
@@ -145,11 +164,16 @@ const NoteFields = {
|
||||
live: Persistence.optional(Persistence.struct({ subject: Schema.String, href: Persistence.optional(Schema.String) })),
|
||||
comment: Schema.String,
|
||||
}
|
||||
|
||||
/** An extension's comment on something other than workspace lines, as sent in message metadata. */
|
||||
export const NoteComment = Persistence.struct(NoteFields)
|
||||
|
||||
export type NoteComment = typeof NoteComment.Type
|
||||
|
||||
export const NoteContextItem = Persistence.struct({ ...NoteFields, commentID: Schema.String })
|
||||
|
||||
export type NoteContextItem = typeof NoteContextItem.Type
|
||||
|
||||
export type ContextItem = FileContextItem | NoteContextItem
|
||||
|
||||
/** A note's live part names state inside the app process that attached it; anything that may outlive it drops it. */
|
||||
@@ -174,6 +198,7 @@ const LegacyBrowserComment = Persistence.struct({
|
||||
}),
|
||||
comment: Schema.String,
|
||||
})
|
||||
|
||||
export const LegacyBrowserNote = LegacyBrowserComment.pipe(
|
||||
Schema.decodeTo(Schema.toType(NoteComment), {
|
||||
decode: SchemaGetter.transform(legacyBrowserNote),
|
||||
@@ -184,6 +209,7 @@ export const LegacyBrowserNote = LegacyBrowserComment.pipe(
|
||||
// The subject those builds sent for an element whose ref no longer applies.
|
||||
function legacyBrowserNote(item: typeof LegacyBrowserComment.Type): NoteComment {
|
||||
const element = item.element
|
||||
|
||||
const details = [
|
||||
element.role ? `role ${element.role}` : undefined,
|
||||
element.name ? `accessible name ${JSON.stringify(element.name)}` : undefined,
|
||||
@@ -192,6 +218,7 @@ function legacyBrowserNote(item: typeof LegacyBrowserComment.Type): NoteComment
|
||||
? `selector ${JSON.stringify(element.selector)}${element.selector.includes(" >>> ") ? ' (">>>" enters a shadow root)' : ""}`
|
||||
: undefined,
|
||||
].filter((detail) => detail !== undefined)
|
||||
|
||||
return {
|
||||
type: "note",
|
||||
origin: "browser",
|
||||
@@ -206,10 +233,13 @@ function legacyBrowserNote(item: typeof LegacyBrowserComment.Type): NoteComment
|
||||
export function contextItemKey(item: ContextItem) {
|
||||
if (item.type === "note") return `note:${item.origin}:c=${item.commentID}`
|
||||
const key = `${item.type}:${item.path}:${item.selection?.startLine}:${item.selection?.endLine}`
|
||||
|
||||
if (item.commentID) return `${key}:c=${item.commentID}`
|
||||
const comment = item.comment?.trim()
|
||||
|
||||
if (!comment) return key
|
||||
const digest = checksum(comment) ?? comment
|
||||
|
||||
return `${key}:c=${digest.slice(0, 8)}`
|
||||
}
|
||||
|
||||
@@ -219,6 +249,7 @@ const FileContextEntry = Schema.Struct({ ...FileContextItem.fields, key: Persist
|
||||
encode: SchemaGetter.transform((item) => item),
|
||||
}),
|
||||
)
|
||||
|
||||
const NoteContextEntry = Schema.Struct({
|
||||
...NoteContextItem.fields,
|
||||
key: Persistence.optional(Schema.String),
|
||||
@@ -229,6 +260,7 @@ const NoteContextEntry = Schema.Struct({
|
||||
encode: SchemaGetter.transform((item) => item),
|
||||
}),
|
||||
)
|
||||
|
||||
const LegacyBrowserContextEntry = Schema.Struct({
|
||||
...LegacyBrowserComment.fields,
|
||||
commentID: Schema.String,
|
||||
@@ -237,11 +269,13 @@ const LegacyBrowserContextEntry = Schema.Struct({
|
||||
Schema.decodeTo(Persistence.struct({ ...NoteContextItem.fields, key: Schema.String }).pipe(Schema.toType), {
|
||||
decode: SchemaGetter.transform((item) => {
|
||||
const note = { ...legacyBrowserNote(item), commentID: item.commentID }
|
||||
|
||||
return { ...note, key: contextItemKey(note) }
|
||||
}),
|
||||
encode: SchemaGetter.forbidden(() => "Legacy browser comments are read-only"),
|
||||
}),
|
||||
)
|
||||
|
||||
const ContextEntry = Schema.Union([FileContextEntry, NoteContextEntry, LegacyBrowserContextEntry])
|
||||
|
||||
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
||||
@@ -276,6 +310,7 @@ export const ComposerStore = Persistence.struct({
|
||||
),
|
||||
context: Persistence.struct({ items: Persistence.array(ContextEntry) }),
|
||||
})
|
||||
|
||||
export type ComposerStore = typeof ComposerStore.Type
|
||||
|
||||
export const LineComment = Persistence.struct({
|
||||
@@ -285,11 +320,13 @@ export const LineComment = Persistence.struct({
|
||||
comment: Schema.String,
|
||||
time: Schema.Number,
|
||||
})
|
||||
|
||||
export type LineComment = typeof LineComment.Type
|
||||
|
||||
export const CommentStore = Persistence.struct({
|
||||
comments: Schema.Record(Schema.String, Schema.mutableKey(Persistence.array(LineComment))),
|
||||
})
|
||||
|
||||
export type CommentStore = typeof CommentStore.Type
|
||||
|
||||
export const PromptHistoryComment = Persistence.struct({
|
||||
@@ -301,6 +338,7 @@ export const PromptHistoryComment = Persistence.struct({
|
||||
origin: Persistence.optional(Schema.Literals(["review", "file"])),
|
||||
preview: Persistence.optional(Schema.String),
|
||||
})
|
||||
|
||||
export type PromptHistoryComment = typeof PromptHistoryComment.Type
|
||||
|
||||
// History entries require a prompt array; only its individual parts recover.
|
||||
@@ -310,13 +348,16 @@ const HistoryPrompt = Schema.Array(Persistence.fallback(Schema.UndefinedOr(Conte
|
||||
encode: SchemaGetter.transform((parts) => parts),
|
||||
}),
|
||||
)
|
||||
|
||||
const HistoryEntry = Schema.Struct({ prompt: HistoryPrompt, comments: Persistence.array(PromptHistoryComment) })
|
||||
|
||||
export const PromptHistoryEntry = Schema.Union([HistoryEntry, HistoryPrompt]).pipe(
|
||||
Schema.decodeTo(Schema.toType(HistoryEntry), {
|
||||
decode: SchemaGetter.transform((entry) => ("prompt" in entry ? entry : { prompt: entry, comments: [] })),
|
||||
encode: SchemaGetter.transform((entry) => entry),
|
||||
}),
|
||||
)
|
||||
|
||||
export type PromptHistoryEntry = typeof PromptHistoryEntry.Type
|
||||
|
||||
export const PromptHistoryState = Persistence.struct({ entries: Persistence.array(PromptHistoryEntry) })
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "./schema"
|
||||
|
||||
export { DEFAULT_PROMPT } from "./schema"
|
||||
|
||||
export type {
|
||||
AgentPart,
|
||||
ComposerStore,
|
||||
@@ -45,13 +46,12 @@ export function isCommentItem(item: ContextItem | (ContextItem & { key: string }
|
||||
function createComposerActions(setStore: SetStoreFunction<ComposerStore>) {
|
||||
return {
|
||||
set(prompt: Prompt, cursorPosition?: number) {
|
||||
batch(() =>
|
||||
setStore({
|
||||
prompt: clonePrompt(prompt),
|
||||
...(cursorPosition !== undefined ? { cursor: cursorPosition } : {}),
|
||||
retry: undefined,
|
||||
}),
|
||||
)
|
||||
batch(() => {
|
||||
setStore({ prompt: clonePrompt(prompt), retry: undefined })
|
||||
|
||||
// An omitted cursor keeps the current one.
|
||||
if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
|
||||
})
|
||||
},
|
||||
reset() {
|
||||
batch(() => setStore({ prompt: clonePrompt(DEFAULT_PROMPT), cursor: 0, retry: undefined }))
|
||||
@@ -64,14 +64,14 @@ function composerTarget(serverScope: ServerScope, scope: PromptScope) {
|
||||
? Persist.prompt(Persist.draft(scope.draftID, "prompt"))
|
||||
: Persist.prompt({
|
||||
...Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt"),
|
||||
...(serverScope === ServerScope.local
|
||||
? { previousKey: `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2` }
|
||||
: {}),
|
||||
previousKey:
|
||||
serverScope === ServerScope.local ? `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2` : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
function initialComposerStore(initial?: InitialPrompt): ComposerStore {
|
||||
const text = initial?.prompt
|
||||
|
||||
return {
|
||||
prompt:
|
||||
text === undefined ? clonePrompt(DEFAULT_PROMPT) : [{ type: "text", content: text, start: 0, end: text.length }],
|
||||
@@ -85,11 +85,13 @@ function initialComposerStore(initial?: InitialPrompt): ComposerStore {
|
||||
|
||||
function createComposerStateValue(store: ComposerStore, setStore: SetStoreFunction<ComposerStore>) {
|
||||
const actions = createComposerActions(setStore)
|
||||
|
||||
const clearRetry = () => {
|
||||
if (untrack(() => store.retry) !== undefined) setStore("retry", undefined)
|
||||
}
|
||||
|
||||
const value = {
|
||||
store: [() => store, setStore] as [Accessor<ComposerStore>, SetStoreFunction<ComposerStore>],
|
||||
store: [() => store, setStore] satisfies [Accessor<ComposerStore>, SetStoreFunction<ComposerStore>],
|
||||
current: () => store.prompt,
|
||||
cursor: () => store.cursor,
|
||||
model: {
|
||||
@@ -114,6 +116,7 @@ function createComposerStateValue(store: ComposerStore, setStore: SetStoreFuncti
|
||||
items: () => store.context.items,
|
||||
add(item: ContextItem) {
|
||||
const key = contextItemKey(item)
|
||||
|
||||
if (store.context.items.find((x) => x.key === key)) return
|
||||
setStore("context", "items", (items) => [...items, { key, ...item }])
|
||||
clearRetry()
|
||||
@@ -132,13 +135,27 @@ function createComposerStateValue(store: ComposerStore, setStore: SetStoreFuncti
|
||||
setStore("context", "items", (items) =>
|
||||
items.map((item) => {
|
||||
if (item.commentID !== commentID) return item
|
||||
|
||||
const value =
|
||||
item.type === "file" ? { ...item, ...next } : { ...item, comment: next.comment ?? item.comment }
|
||||
|
||||
return { ...value, key: contextItemKey(value) }
|
||||
}),
|
||||
)
|
||||
clearRetry()
|
||||
},
|
||||
/** Replaces every item, as restoring a sent prompt replaces the whole draft. */
|
||||
replace(items: ContextItem[]) {
|
||||
setStore(
|
||||
"context",
|
||||
"items",
|
||||
[...new Map(items.map((item) => [contextItemKey(item), item] as const)).entries()].map(([key, item]) => ({
|
||||
...item,
|
||||
key,
|
||||
})),
|
||||
)
|
||||
clearRetry()
|
||||
},
|
||||
replaceComments(items: ContextItem[]) {
|
||||
setStore("context", "items", (current) => [
|
||||
...current.filter((item) => !isCommentItem(item)),
|
||||
@@ -151,6 +168,7 @@ function createComposerStateValue(store: ComposerStore, setStore: SetStoreFuncti
|
||||
reset: () => actions.reset(),
|
||||
capture: () => value,
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
@@ -160,6 +178,7 @@ function createPersistedComposer(
|
||||
platform?: Platform,
|
||||
) {
|
||||
const [store, setStore, _, ready] = persisted(target, ComposerStore, initialComposerStore(initial), platform)
|
||||
|
||||
return { ready, ...createComposerStateValue(store, setStore) }
|
||||
}
|
||||
|
||||
@@ -179,6 +198,7 @@ export function createDraftComposerState(draftID: string, initial?: InitialPromp
|
||||
export type ComposerState = ReturnType<typeof createComposerState>
|
||||
|
||||
export function createComposerReady(session: Accessor<ComposerState>) {
|
||||
// SAFETY: defineProperty returns the same function, now carrying the promise getter defined here.
|
||||
return Object.defineProperty(() => session().ready(), "promise", {
|
||||
get: () => session().ready.promise,
|
||||
}) as (() => boolean) & { readonly promise: Promise<unknown> | undefined }
|
||||
@@ -187,6 +207,7 @@ export function createComposerReady(session: Accessor<ComposerState>) {
|
||||
export function createMemoryComposerState(initial?: InitialPrompt) {
|
||||
const [store, setStore] = createStore<ComposerStore>(initialComposerStore(initial))
|
||||
const ready = Object.assign(() => true, { promise: Promise.resolve(true) })
|
||||
|
||||
return {
|
||||
ready,
|
||||
...createComposerStateValue(store, setStore),
|
||||
|
||||
@@ -7,6 +7,7 @@ import { createMemoryComposerState, type Prompt } from "./state"
|
||||
import { createComposerSubmit } from "./submit"
|
||||
import type { ComposerStateTarget } from "./submission-state"
|
||||
|
||||
// SAFETY: submission reads only the selected model's id, name, and provider id.
|
||||
const selectedModel = {
|
||||
id: "model-1",
|
||||
name: "Model 1",
|
||||
@@ -65,7 +66,11 @@ function fresh(state: ComposerStateTarget, start: NewSessionComposerAdapter["sta
|
||||
|
||||
function submitInput(
|
||||
adapter: ActiveComposerAdapter | NewSessionComposerAdapter,
|
||||
notify = { missingSelection() {}, failed(_kind: "shell" | "command" | "prompt", _error: unknown) {} },
|
||||
notify: Parameters<typeof createComposerSubmit>[0]["notify"] = {
|
||||
missingSelection() {},
|
||||
unqueueable() {},
|
||||
failed() {},
|
||||
},
|
||||
mode: "normal" | "shell" = "normal",
|
||||
commands: () => readonly { name: string }[] | undefined = () => [],
|
||||
history: string[] = [],
|
||||
@@ -97,7 +102,7 @@ function session(input: {
|
||||
statuses?: ("idle" | "running")[]
|
||||
current?: ComposerSession["current"]
|
||||
admitted?: (messageID: string) => boolean
|
||||
shell?: () => Promise<unknown>
|
||||
shell?: ComposerSession["api"]["shell"]
|
||||
command?: ComposerSession["api"]["command"]
|
||||
switchAgent?: ComposerSession["api"]["switchAgent"]
|
||||
switchModel?: ComposerSession["api"]["switchModel"]
|
||||
@@ -142,6 +147,7 @@ function session(input: {
|
||||
describe("Composer submission", () => {
|
||||
test("runs a client argument command without admitting it to the session", async () => {
|
||||
const state = createMemoryComposerState().capture()
|
||||
|
||||
const image = {
|
||||
type: "image" as const,
|
||||
id: "attachment",
|
||||
@@ -149,6 +155,7 @@ describe("Composer submission", () => {
|
||||
mime: "image/png",
|
||||
blob: { id: "attachment", url: "data:image/png;base64,YQ==" },
|
||||
}
|
||||
|
||||
state.set([{ type: "text", content: "/btw why this approach?", start: 0, end: 23 }, image])
|
||||
state.context.add({ type: "file", path: "src/retry.ts" })
|
||||
const calls: string[] = []
|
||||
@@ -156,6 +163,7 @@ describe("Composer submission", () => {
|
||||
|
||||
await submitInput(active(state, session({ calls })), undefined, "normal", undefined, history, (text) => {
|
||||
expect(text).toBe("/btw why this approach?")
|
||||
|
||||
return () => {
|
||||
calls.push("btw")
|
||||
}
|
||||
@@ -176,6 +184,7 @@ describe("Composer submission", () => {
|
||||
},
|
||||
])("applies the selection before sending one captured value: $calls", async (row) => {
|
||||
const state = createMemoryComposerState({ prompt: "ship it" }).capture()
|
||||
state.context.add({ type: "file", path: "notes.md", description: "the failing version" })
|
||||
const calls: string[] = []
|
||||
const admitted = Promise.withResolvers<Parameters<ComposerSession["data"]["session"]["prompt"]>[0]>()
|
||||
const target = session({ calls, current: () => row.current, prompt: async (value) => admitted.resolve(value) })
|
||||
@@ -192,7 +201,11 @@ describe("Composer submission", () => {
|
||||
agent: "build",
|
||||
model: { providerID: "provider-1", modelID: "model-1", variant: "balanced" },
|
||||
})
|
||||
// A file chip goes with this prompt only, like the TUI's mentionless files.
|
||||
expect(request.files).toMatchObject([{ name: "notes.md", description: "the failing version" }])
|
||||
expect(request.files?.[0]?.mention).toBeUndefined()
|
||||
expect(state.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
|
||||
expect(state.context.items()).toEqual([])
|
||||
})
|
||||
|
||||
test("applies the captured agent and model before a custom command without passing over its overrides", async () => {
|
||||
@@ -203,6 +216,7 @@ describe("Composer submission", () => {
|
||||
const started = Promise.withResolvers<void>()
|
||||
const committed = Promise.withResolvers<void>()
|
||||
const completed = Promise.withResolvers<void>()
|
||||
|
||||
const target = session({
|
||||
calls,
|
||||
switchAgent: async (request) => {
|
||||
@@ -224,6 +238,7 @@ describe("Composer submission", () => {
|
||||
completed.resolve()
|
||||
},
|
||||
})
|
||||
|
||||
selected.model.selection = {
|
||||
...selection,
|
||||
trackSessionCommit: (_id, value) => {
|
||||
@@ -233,6 +248,7 @@ describe("Composer submission", () => {
|
||||
variant: "balanced",
|
||||
})
|
||||
calls.push("track")
|
||||
|
||||
return () => calls.push("cancel")
|
||||
},
|
||||
}
|
||||
@@ -258,6 +274,7 @@ describe("Composer submission", () => {
|
||||
const selected = controls()
|
||||
const failed = Promise.withResolvers<unknown>()
|
||||
const error = new Error("model unavailable")
|
||||
|
||||
const target = session({
|
||||
calls,
|
||||
switchModel: async () => {
|
||||
@@ -267,10 +284,12 @@ describe("Composer submission", () => {
|
||||
calls.push("command")
|
||||
},
|
||||
})
|
||||
|
||||
selected.model.selection = {
|
||||
...selection,
|
||||
trackSessionCommit: () => {
|
||||
calls.push("track")
|
||||
|
||||
return () => {
|
||||
calls.push("cancel")
|
||||
}
|
||||
@@ -278,7 +297,7 @@ describe("Composer submission", () => {
|
||||
}
|
||||
await submitInput(
|
||||
active(state, target, () => selected),
|
||||
{ missingSelection() {}, failed: (_kind, error) => failed.resolve(error) },
|
||||
{ missingSelection() {}, unqueueable() {}, failed: (_kind, error) => failed.resolve(error) },
|
||||
"normal",
|
||||
() => [{ name: "review" }],
|
||||
).submit(new Event("submit"))
|
||||
@@ -289,6 +308,7 @@ describe("Composer submission", () => {
|
||||
|
||||
test("restores and retries an unacknowledged admission", async () => {
|
||||
const state = createMemoryComposerState().capture()
|
||||
|
||||
const prompt: Prompt = [
|
||||
{ type: "text", content: "retry ", start: 0, end: 6 },
|
||||
{ type: "file", path: "src/app.ts", content: "@src/app.ts", start: 6, end: 17 },
|
||||
@@ -300,11 +320,14 @@ describe("Composer submission", () => {
|
||||
blob: { id: "attachment", url: "data:image/png;base64,YQ==" },
|
||||
},
|
||||
]
|
||||
|
||||
state.set(prompt)
|
||||
state.context.add({ type: "file", path: "notes.md", description: "the failing version" })
|
||||
const attempts: string[] = []
|
||||
const statuses: ("idle" | "running")[] = []
|
||||
const first = Promise.withResolvers<void>()
|
||||
const second = Promise.withResolvers<void>()
|
||||
|
||||
const target = session({
|
||||
calls: [],
|
||||
statuses,
|
||||
@@ -313,10 +336,13 @@ describe("Composer submission", () => {
|
||||
throw new Error("network unavailable")
|
||||
},
|
||||
})
|
||||
|
||||
const notify = {
|
||||
missingSelection() {},
|
||||
unqueueable() {},
|
||||
failed: () => (attempts.length === 2 ? first.resolve() : second.resolve()),
|
||||
}
|
||||
|
||||
const history: string[] = []
|
||||
const submission = submitInput(active(state, target), notify, "normal", () => [], history)
|
||||
|
||||
@@ -329,6 +355,9 @@ describe("Composer submission", () => {
|
||||
expect(new Set(attempts).size).toBe(1)
|
||||
expect(statuses).toEqual(["running", "idle", "running", "idle"])
|
||||
expect(state.current()).toEqual(prompt)
|
||||
expect(state.context.items()).toMatchObject([
|
||||
{ type: "file", path: "notes.md", description: "the failing version" },
|
||||
])
|
||||
// The caret returns after the mention text; attachments take no caret positions.
|
||||
expect(state.cursor()).toBe(17)
|
||||
// The restored prompt is the draft again, so history does not also keep it (and its attachments).
|
||||
@@ -351,14 +380,18 @@ describe("Composer submission", () => {
|
||||
const promoted = createMemoryComposerState().capture()
|
||||
const failed = Promise.withResolvers<void>()
|
||||
const target = session({ calls: [], shell: async () => Promise.reject(new Error("send failed")) })
|
||||
|
||||
const adapter = fresh(draft, async (_selection, submission) => {
|
||||
submission.retarget(promoted)
|
||||
|
||||
return { session: target, cleanupReady: Promise.resolve() }
|
||||
})
|
||||
|
||||
await submitInput(adapter, { missingSelection() {}, failed: () => failed.resolve() }, "shell").submit(
|
||||
new Event("submit"),
|
||||
)
|
||||
await submitInput(
|
||||
adapter,
|
||||
{ missingSelection() {}, unqueueable() {}, failed: () => failed.resolve() },
|
||||
"shell",
|
||||
).submit(new Event("submit"))
|
||||
await failed.promise
|
||||
|
||||
expect(promoted.current()).toMatchObject([{ type: "text", content: "first prompt" }])
|
||||
@@ -370,10 +403,12 @@ describe("Composer submission", () => {
|
||||
const state = createMemoryComposerState({ prompt: "admitted prompt" }).capture()
|
||||
const checked = Promise.withResolvers<void>()
|
||||
const attempts: string[] = []
|
||||
|
||||
const target = session({
|
||||
calls: [],
|
||||
admitted: () => {
|
||||
checked.resolve()
|
||||
|
||||
return true
|
||||
},
|
||||
prompt: async (value) => {
|
||||
@@ -392,6 +427,7 @@ describe("Composer submission", () => {
|
||||
|
||||
test("hands off image-only first prompts and admits them before cleanup is ready", async () => {
|
||||
const draft = createMemoryComposerState().capture()
|
||||
|
||||
const prompt: Prompt = [
|
||||
{ type: "text", content: "", start: 0, end: 0 },
|
||||
{
|
||||
@@ -402,10 +438,12 @@ describe("Composer submission", () => {
|
||||
blob: { id: "attachment", url: "data:image/png;base64,YQ==" },
|
||||
},
|
||||
]
|
||||
|
||||
draft.set(prompt)
|
||||
const handedOff = Promise.withResolvers<SessionMessageUser>()
|
||||
const admitted = Promise.withResolvers<void>()
|
||||
const cleanup = Promise.withResolvers<void>()
|
||||
|
||||
const target = session({
|
||||
calls: [],
|
||||
handoff: { set: handedOff.resolve, clear() {} },
|
||||
@@ -415,6 +453,7 @@ describe("Composer submission", () => {
|
||||
const submitted = submitInput(
|
||||
fresh(draft, async () => ({ session: target, cleanupReady: cleanup.promise })),
|
||||
).submit(new Event("submit"))
|
||||
|
||||
await admitted.promise
|
||||
expect(draft.current()).toEqual(prompt)
|
||||
cleanup.resolve()
|
||||
@@ -471,6 +510,7 @@ describe("Composer submission", () => {
|
||||
const catalog = [{ name: "review" }]
|
||||
const sent = Promise.withResolvers<"prompt" | "command">()
|
||||
const requests: Parameters<ComposerSession["api"]["command"]>[0][] = []
|
||||
|
||||
const target = session({
|
||||
calls: [],
|
||||
prompt: async () => sent.resolve("prompt"),
|
||||
@@ -479,11 +519,14 @@ describe("Composer submission", () => {
|
||||
sent.resolve("command")
|
||||
},
|
||||
})
|
||||
|
||||
target.directory = "C:/new-worktree"
|
||||
target.data.location.command.list = () => undefined
|
||||
|
||||
const adapter = fresh(state, async () => {
|
||||
// The destination catalog has not loaded, and the source composer is leaving.
|
||||
catalog.splice(0)
|
||||
|
||||
return { session: target, cleanupReady: Promise.resolve() }
|
||||
})
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { ImageAttachmentPart, Prompt } from "./state"
|
||||
import { clonePrompt, promptLength } from "./prompt-parts"
|
||||
import type { ComposerAdapter, ComposerDelivery, ComposerSelection, ComposerSession } from "./adapter"
|
||||
import { createComposerSubmission } from "./submission-state"
|
||||
import { buildPromptRequest } from "./request"
|
||||
import { buildPromptRequest, noteComment } from "./request"
|
||||
import { setCursorPosition } from "./editor/dom"
|
||||
import { blobDataUrl, resolveBlobUrl } from "@/runtime/persistence/drafts"
|
||||
import { isAttachment } from "./prompt-parts"
|
||||
@@ -42,6 +42,8 @@ type ComposerSubmitInput = {
|
||||
clientCommand?: (text: string) => (() => void | Promise<void>) | undefined
|
||||
notify: {
|
||||
missingSelection: () => void
|
||||
unqueueable: () => void
|
||||
// oxlint-disable-next-line anti-slop/no-unknown-parameters -- a rejected send is opaque; the notifier formats it
|
||||
failed: (kind: "shell" | "command" | "prompt", error: unknown) => void
|
||||
}
|
||||
comments: {
|
||||
@@ -58,9 +60,11 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
const prompt = clonePrompt(input.adapter.state.current())
|
||||
const text = submissionText(prompt)
|
||||
const clientCommand = input.mode() === "normal" ? input.clientCommand?.(text) : undefined
|
||||
|
||||
if (clientCommand) {
|
||||
if (submitting.has(input.adapter.state)) return
|
||||
submitting.add(input.adapter.state)
|
||||
|
||||
try {
|
||||
clearClientCommand(input, prompt)
|
||||
await clientCommand()
|
||||
@@ -69,8 +73,10 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
} finally {
|
||||
submitting.delete(input.adapter.state)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const submission = createComposerSubmission({
|
||||
target: input.adapter.state,
|
||||
prompt,
|
||||
@@ -82,12 +88,24 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
: { ...item, selection: item.selection ? { ...item.selection } : undefined },
|
||||
),
|
||||
})
|
||||
|
||||
const read = readSubmission(input, submission.prompt, submission.context, text, options?.alternate ?? false)
|
||||
|
||||
if (!read) {
|
||||
if (input.adapter.working() && input.adapter.kind === "active-session") void input.adapter.interrupt()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Like the TUI, a shell command runs now or not at all; it cannot wait in the queue.
|
||||
if (read.mode === "shell" && read.delivery === "queue") {
|
||||
input.notify.unqueueable()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (submitting.has(input.adapter.state)) return
|
||||
|
||||
// Images restored from a draft or history carry ids only; the optimistic message shows their URLs.
|
||||
const value = {
|
||||
...read,
|
||||
@@ -98,6 +116,7 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
})),
|
||||
),
|
||||
}
|
||||
|
||||
submitting.add(input.adapter.state)
|
||||
const comments = input.comments.capture()
|
||||
// Capture command intent before starting a session in a worktree whose catalog has not loaded.
|
||||
@@ -108,6 +127,7 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
input.adapter.kind === "active-session"
|
||||
? { session: input.adapter.session(), cleanupReady: Promise.resolve() }
|
||||
: await input.adapter.start(value.selection, submission, handoffMessage(value))
|
||||
|
||||
if (!started) return
|
||||
const session = started.session
|
||||
|
||||
@@ -118,8 +138,10 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
if (value.mode === "normal" && !command) {
|
||||
session.handoff?.set(handoffMessage(value))
|
||||
const optimisticBusy = !input.adapter.working()
|
||||
|
||||
if (optimisticBusy && input.adapter.kind === "new-session")
|
||||
session.data.session.setStatus(session.id, "running")
|
||||
|
||||
const sending = sendPrompt(session, value, input.adapter.controls().model.selection.trackSessionCommit, () => {
|
||||
if (optimisticBusy && input.adapter.kind === "active-session")
|
||||
session.data.session.setStatus(session.id, "running")
|
||||
@@ -127,12 +149,12 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
() => ({ ok: true as const }),
|
||||
(error) => ({ ok: false as const, error }),
|
||||
)
|
||||
|
||||
await started.cleanupReady
|
||||
await started.complete?.()
|
||||
input.adapter.submitted()
|
||||
submission.context
|
||||
.filter((item) => !!item.comment?.trim())
|
||||
.forEach((item) => submission.target().context.remove(item.key))
|
||||
// Like the TUI, sent context goes with the prompt rather than riding along on the next one.
|
||||
submission.context.forEach((item) => submission.target().context.remove(item.key))
|
||||
input.comments.clear()
|
||||
clearSubmission(input, submission)
|
||||
void sending.then((result) => {
|
||||
@@ -141,6 +163,7 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
if (optimisticBusy) session.data.session.setStatus(session.id, "idle")
|
||||
})
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -151,6 +174,7 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
if (value.mode === "shell") {
|
||||
clearSubmission(input, submission)
|
||||
void sendShell(session, value).catch((error) => failSubmission(input, session, "shell", error, restore))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -159,6 +183,7 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
void sendCommand(session, value, command, input.adapter.controls().model.selection.trackSessionCommit).catch(
|
||||
(error) => failSubmission(input, session, "command", error, restore, value.id),
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
} finally {
|
||||
@@ -179,6 +204,14 @@ function clearClientCommand(input: ComposerSubmitInput, prompt: Prompt) {
|
||||
input.closePopover()
|
||||
}
|
||||
|
||||
function selectionModel(selection: ComposerSelection) {
|
||||
const model: ComposerSelection["model"] & { variant?: string } = { ...selection.model }
|
||||
|
||||
if (selection.variant) model.variant = selection.variant
|
||||
|
||||
return model
|
||||
}
|
||||
|
||||
function submissionText(prompt: Prompt) {
|
||||
return prompt.map((part) => ("content" in part ? part.content : "")).join("")
|
||||
}
|
||||
@@ -201,35 +234,23 @@ function handoffMessage(value: ComposerSubmission): SessionMessageUser {
|
||||
),
|
||||
comments: value.context.flatMap((item): PromptComment[] => {
|
||||
const comment = item.comment?.trim()
|
||||
|
||||
if (!comment) return []
|
||||
if (item.type === "note")
|
||||
return [
|
||||
{
|
||||
type: "note",
|
||||
origin: item.origin,
|
||||
label: item.label,
|
||||
icon: item.icon,
|
||||
subject: item.subject,
|
||||
...(item.href ? { href: item.href } : {}),
|
||||
...(item.live ? { live: { ...item.live } } : {}),
|
||||
comment,
|
||||
},
|
||||
]
|
||||
|
||||
if (item.type === "note") return [noteComment(item, comment)]
|
||||
|
||||
return [
|
||||
{
|
||||
path: item.path,
|
||||
comment,
|
||||
...(item.selection ? { selection: { ...item.selection } } : {}),
|
||||
...(item.preview !== undefined ? { preview: item.preview } : {}),
|
||||
...(item.commentOrigin ? { origin: item.commentOrigin } : {}),
|
||||
selection: item.selection && { ...item.selection },
|
||||
preview: item.preview,
|
||||
origin: item.commentOrigin,
|
||||
},
|
||||
]
|
||||
}),
|
||||
agent: value.selection.agent,
|
||||
model: {
|
||||
...value.selection.model,
|
||||
...(value.selection.variant ? { variant: value.selection.variant } : {}),
|
||||
},
|
||||
model: selectionModel(value.selection),
|
||||
},
|
||||
time: { created: Date.now() },
|
||||
}
|
||||
@@ -243,20 +264,27 @@ function readSubmission(
|
||||
alternate: boolean,
|
||||
): ComposerSubmission | undefined {
|
||||
const mode = input.mode()
|
||||
|
||||
if (mode === "shell" && !text.trim()) return
|
||||
const images = prompt.filter((part): part is ImageAttachmentPart => part.type === "image")
|
||||
// Like the TUI's blank Enter, file chips alone send nothing; they wait for text.
|
||||
const comments = context.filter((item) => !!item.comment?.trim()).length
|
||||
|
||||
if (!text.trim() && !prompt.some(isAttachment) && comments === 0) return
|
||||
|
||||
const controls = input.adapter.controls()
|
||||
const model = controls.model.selection.current()
|
||||
const agent = controls.agents.current
|
||||
|
||||
if (!model || !agent) {
|
||||
input.notify.missingSelection()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const variant = controls.model.selection.variant.current()
|
||||
const retry = input.adapter.state.retry.current()
|
||||
|
||||
const retryID =
|
||||
retry &&
|
||||
retry.agent === agent &&
|
||||
@@ -296,6 +324,7 @@ function restoreSubmission(
|
||||
comments: PromptHistoryComment[],
|
||||
) {
|
||||
const restored = submission.restore()
|
||||
|
||||
if (!restored) return false
|
||||
// The prompt is back in the composer; its history entry would only keep attachments referenced.
|
||||
input.removeFromHistory(value.prompt, value.mode, comments)
|
||||
@@ -328,6 +357,17 @@ function restoreSubmission(
|
||||
},
|
||||
),
|
||||
)
|
||||
restored.context.forEach((item) => {
|
||||
if (item.type === "file" && !item.comment?.trim())
|
||||
restored.target.context.add({
|
||||
type: "file",
|
||||
path: item.path,
|
||||
selection: item.selection,
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
})
|
||||
})
|
||||
|
||||
// A recovered follow-up changes the payload, so it must use a new admission ID.
|
||||
if (value.mode === "normal" && restored.prompt === submission.prompt) {
|
||||
restored.target.retry.set({
|
||||
@@ -338,6 +378,7 @@ function restoreSubmission(
|
||||
variant: value.selection.variant,
|
||||
})
|
||||
}
|
||||
|
||||
if (!submission.current(input.adapter.state)) return true
|
||||
|
||||
input.comments.restore(comments)
|
||||
@@ -345,11 +386,13 @@ function restoreSubmission(
|
||||
input.closePopover()
|
||||
requestAnimationFrame(() => {
|
||||
const editor = input.editor()
|
||||
|
||||
if (!editor) return
|
||||
editor.focus()
|
||||
setCursorPosition(editor, promptLength(value.prompt))
|
||||
input.queueScroll()
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -359,7 +402,9 @@ async function sendShell(session: ComposerSession, value: ComposerSubmission) {
|
||||
|
||||
function findCommand(commands: ReturnType<ComposerSubmitInput["commands"]>, text: string) {
|
||||
const parsed = parseSlashCommand(text)
|
||||
|
||||
if (!parsed || !commands?.some((item) => item.name === parsed.name)) return
|
||||
|
||||
return { command: parsed.name, arguments: parsed.input }
|
||||
}
|
||||
|
||||
@@ -370,13 +415,19 @@ async function sendCommand(
|
||||
track?: ModelSelection["trackSessionCommit"],
|
||||
) {
|
||||
const request = await buildSubmissionRequest(session, value)
|
||||
|
||||
// Like queued prompts, queued commands must not apply the composer's selection to active work.
|
||||
if (value.delivery === "steer") await applySelection(session, value.selection, track)
|
||||
await session.api.command({
|
||||
sessionID: session.id,
|
||||
name: command.command,
|
||||
text: command.arguments,
|
||||
files: request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })),
|
||||
files: request.files.map((file) => ({
|
||||
uri: file.uri,
|
||||
name: file.name,
|
||||
description: file.description,
|
||||
mention: file.mention,
|
||||
})),
|
||||
agents: request.agents,
|
||||
skills: request.skills,
|
||||
delivery: value.delivery,
|
||||
@@ -389,11 +440,14 @@ async function applySelection(
|
||||
track?: ModelSelection["trackSessionCommit"],
|
||||
) {
|
||||
const cancel = track?.(session.id, selection)
|
||||
|
||||
try {
|
||||
const current = session.current()
|
||||
|
||||
if (current?.agent !== selection.agent) {
|
||||
await session.api.switchAgent({ sessionID: session.id, agent: selection.agent })
|
||||
}
|
||||
|
||||
// The server deduplicates unchanged selections; cached SSE state may still be behind an earlier switch.
|
||||
await session.api.switchModel({
|
||||
sessionID: session.id,
|
||||
@@ -412,6 +466,7 @@ async function sendPrompt(
|
||||
onAdmit: () => void,
|
||||
) {
|
||||
const request = await buildSubmissionRequest(session, value)
|
||||
|
||||
// Switching agent or model reconfigures the session immediately, and with it
|
||||
// the remainder of a running turn. A steer targets that turn, so its
|
||||
// selection applies now; a queued follow-up must not reconfigure the turn it
|
||||
@@ -426,7 +481,12 @@ async function sendPrompt(
|
||||
sessionID: session.id,
|
||||
delivery: value.delivery,
|
||||
text: request.text,
|
||||
files: request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })),
|
||||
files: request.files.map((file) => ({
|
||||
uri: file.uri,
|
||||
name: file.name,
|
||||
description: file.description,
|
||||
mention: file.mention,
|
||||
})),
|
||||
agents: request.agents,
|
||||
skills: request.skills,
|
||||
metadata: {
|
||||
@@ -434,12 +494,10 @@ async function sendPrompt(
|
||||
comments: request.comments,
|
||||
attachments: request.attachments,
|
||||
agent: value.selection.agent,
|
||||
model: {
|
||||
...value.selection.model,
|
||||
...(value.selection.variant ? { variant: value.selection.variant } : {}),
|
||||
},
|
||||
model: selectionModel(value.selection),
|
||||
},
|
||||
}
|
||||
|
||||
const sending = session.data.session.prompt(admission).catch(() => session.data.session.prompt(admission))
|
||||
onAdmit()
|
||||
await sending
|
||||
@@ -452,6 +510,7 @@ async function buildSubmissionRequest(session: ComposerSession, value: ComposerS
|
||||
dataUrl: await blobDataUrl(attachment.blob, attachment.mime),
|
||||
})),
|
||||
)
|
||||
|
||||
return buildPromptRequest({
|
||||
prompt: value.prompt,
|
||||
context: value.context,
|
||||
@@ -465,12 +524,14 @@ function failSubmission(
|
||||
input: ComposerSubmitInput,
|
||||
session: ComposerSession,
|
||||
kind: "shell" | "command" | "prompt",
|
||||
// oxlint-disable-next-line anti-slop/no-unknown-parameters -- a rejected send is opaque; the notifier formats it
|
||||
error: unknown,
|
||||
restore: () => boolean,
|
||||
messageID?: string,
|
||||
rollback?: () => void,
|
||||
) {
|
||||
if (messageID && session.admitted(messageID)) return
|
||||
|
||||
if (messageID) session.handoff?.clear(messageID)
|
||||
rollback?.()
|
||||
restore()
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useSettingsSurface } from "@/settings/surface"
|
||||
import { useTabs, type DraftTab } from "@/shell/tabs/tabs"
|
||||
import { useSettingsServers } from "@/settings/servers/inventory"
|
||||
import { useSearchParams } from "@solidjs/router"
|
||||
import { createEffect, createMemo, createResource, untrack } from "solid-js"
|
||||
import { createEffect, createMemo, createResource, on, untrack } from "solid-js"
|
||||
import { createComposerModel } from "@/composer/model"
|
||||
import { useComposerCommands } from "@/composer/commands"
|
||||
import { createNewSessionComposerAdapter } from "./composer-adapter"
|
||||
@@ -18,17 +18,23 @@ export default function NewSessionPage(props: { draftId: string }) {
|
||||
const tabs = useTabs()
|
||||
const servers = useSettingsServers()
|
||||
const settingsSurface = useSettingsSurface()
|
||||
|
||||
const draftTab = createMemo(() =>
|
||||
tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId),
|
||||
)
|
||||
|
||||
const openWorkspaces = () => {
|
||||
const draft = draftTab()
|
||||
|
||||
if (servers().length > 1 && draft) {
|
||||
settingsSurface.openServer(draft.server, "workspaces")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
settingsSurface.open("workspaces")
|
||||
}
|
||||
|
||||
const workspace = createNewSessionWorkspaceController({
|
||||
selectedWorktree: () => draftTab()?.worktree,
|
||||
selectedBranch: () => draftTab()?.branch,
|
||||
@@ -40,7 +46,9 @@ export default function NewSessionPage(props: { draftId: string }) {
|
||||
},
|
||||
onViewAll: openWorkspaces,
|
||||
})
|
||||
|
||||
const mcp = createDraftMcpControls({ draftID: props.draftId, worktree: workspace.selection.value })
|
||||
|
||||
const composer = createNewSessionComposerAdapter({
|
||||
draftID: props.draftId,
|
||||
worktree: workspace.selection.value,
|
||||
@@ -48,12 +56,15 @@ export default function NewSessionPage(props: { draftId: string }) {
|
||||
submitted: workspace.selection.remember,
|
||||
mcp,
|
||||
})
|
||||
|
||||
const model = createComposerModel(composer.adapter)
|
||||
useComposerCommands({ model: composer.model })
|
||||
|
||||
const project = createPromptProjectController({
|
||||
controls: composer.project,
|
||||
onDone: model.restoreFocus,
|
||||
})
|
||||
|
||||
useNewSessionCommands({
|
||||
restoreFocus: model.restoreFocus,
|
||||
project: {
|
||||
@@ -65,20 +76,25 @@ export default function NewSessionPage(props: { draftId: string }) {
|
||||
cycle: workspace.selection.cycle,
|
||||
},
|
||||
})
|
||||
createEffect(() => {
|
||||
if (!composer.ready()) return
|
||||
model.restoreFocus()
|
||||
})
|
||||
// Focus once the draft loads. restoreFocus reads the stored cursor, so tracking it here would reapply
|
||||
// a stale cursor a frame after every key-up and yank the caret from a key pressed in that frame.
|
||||
createEffect(
|
||||
on(composer.ready, (ready) => {
|
||||
if (ready) model.restoreFocus()
|
||||
}),
|
||||
)
|
||||
createEffect(() => {
|
||||
if (!composer.ready()) return
|
||||
untrack(() => {
|
||||
const text = search.prompt
|
||||
|
||||
if (!text) return
|
||||
composer.adapter.state.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
|
||||
setSearch({ ...search, prompt: undefined })
|
||||
})
|
||||
})
|
||||
const ready = Promise.resolve()
|
||||
|
||||
const [suspendUntilPromptReady] = createResource(
|
||||
() => composer.ready.promise ?? ready,
|
||||
(promise) => promise.then(() => true),
|
||||
|
||||
@@ -23,17 +23,20 @@ const ModelKeySchema = Schema.Struct({
|
||||
modelID: Schema.String,
|
||||
variant: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
export type ModelKey = typeof ModelKeySchema.Type
|
||||
|
||||
const ChoiceSchema = Schema.Struct({
|
||||
model: Persistence.optional(ModelKeySchema),
|
||||
variant: Persistence.optional(Schema.NullOr(Schema.String)),
|
||||
})
|
||||
|
||||
const StateSchema = Schema.Struct({
|
||||
...ChoiceSchema.fields,
|
||||
agent: Persistence.optional(Schema.String),
|
||||
choices: Persistence.optional(Schema.Record(Schema.String, ChoiceSchema)),
|
||||
})
|
||||
|
||||
type State = typeof StateSchema.Type
|
||||
|
||||
const SessionsSchema = Schema.Record(
|
||||
@@ -61,12 +64,14 @@ export const ModelSelectionSchema = Persistence.migrate(
|
||||
)
|
||||
|
||||
const WORKSPACE_KEY = "__workspace__"
|
||||
|
||||
const handoff = new Map<string, State>()
|
||||
|
||||
const handoffKey = (scope: ServerScope, dir: string, id: string) => ScopedKey.from(scope, dir, id)
|
||||
|
||||
const clone = (value: State | undefined) => {
|
||||
if (!value) return
|
||||
|
||||
return {
|
||||
...value,
|
||||
model: value.model ? { ...value.model } : undefined,
|
||||
@@ -86,11 +91,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const configuredModel = useConfiguredModel()
|
||||
|
||||
const id = createMemo(() => params.id || undefined)
|
||||
|
||||
const list = createMemo(() =>
|
||||
normalizeAgentList(data.location.agent.list({ directory: sdk().directory }) ?? []).filter(
|
||||
(item) => item.mode !== "subagent" && !item.hidden,
|
||||
),
|
||||
)
|
||||
|
||||
const agentsVisible = createMemo(() => settings.visibility.customAgents() || hasCustomAgent(list()))
|
||||
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
|
||||
|
||||
@@ -111,13 +118,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
|
||||
const validModel = (model: ModelKey) => {
|
||||
const provider = providers.all().get(model.providerID)
|
||||
|
||||
return !!provider?.models[model.modelID] && connected().has(model.providerID)
|
||||
}
|
||||
|
||||
const firstModel = (...items: Array<() => ModelKey | undefined>) => {
|
||||
for (const item of items) {
|
||||
const model = item()
|
||||
|
||||
if (!model) continue
|
||||
|
||||
if (validModel(model)) return model
|
||||
}
|
||||
}
|
||||
@@ -128,30 +138,39 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
|
||||
createEffect(() => {
|
||||
const items = list()
|
||||
|
||||
if (items.length === 0) {
|
||||
if (store.current !== undefined) setStore("current", undefined)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (items.some((item) => item.name === store.current)) return
|
||||
setStore("current", items[0]?.name)
|
||||
})
|
||||
|
||||
const scope = createMemo<State | undefined>(() => {
|
||||
const session = id()
|
||||
|
||||
if (!session) return store.draft ?? store.promoting
|
||||
|
||||
return saved.session[session] ?? handoff.get(handoffKey(serverSDK.scope, sdk().directory, session))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const session = id()
|
||||
|
||||
if (!session) return
|
||||
|
||||
const key = handoffKey(serverSDK.scope, sdk().directory, session)
|
||||
const next = handoff.get(key)
|
||||
|
||||
if (!next) return
|
||||
|
||||
if (saved.session[session] !== undefined) {
|
||||
handoff.delete(key)
|
||||
setStore("promoting", undefined)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -169,15 +188,19 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const defaultModel = () => {
|
||||
for (const provider of providers.connected()) {
|
||||
const first = Object.values(provider.models)[0]
|
||||
|
||||
if (!first) continue
|
||||
const model = { providerID: provider.id, modelID: first.id }
|
||||
|
||||
if (validModel(model)) return model
|
||||
}
|
||||
}
|
||||
|
||||
const fallback = createMemo(() => firstModel(configuredModel, recentModel, defaultModel))
|
||||
|
||||
const durable = () => {
|
||||
const session = id()
|
||||
|
||||
return session ? data.session.get(session) : undefined
|
||||
}
|
||||
|
||||
@@ -189,43 +212,55 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
},
|
||||
set(name: string | undefined) {
|
||||
const item = pickAgent(name)
|
||||
|
||||
if (!item) {
|
||||
setStore("current", undefined)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
batch(() => {
|
||||
const previous = snapshot()
|
||||
|
||||
if (previous.agent === item.name) return
|
||||
const prev = scope()
|
||||
const choices = {
|
||||
...prev?.choices,
|
||||
...(previous.agent ? { [previous.agent]: { model: previous.model, variant: previous.variant } } : {}),
|
||||
}
|
||||
|
||||
const choices = { ...prev?.choices }
|
||||
|
||||
if (previous.agent) choices[previous.agent] = { model: previous.model, variant: previous.variant }
|
||||
|
||||
setStore("current", item.name)
|
||||
|
||||
const next = {
|
||||
agent: item.name,
|
||||
model: choices[item.name]?.model,
|
||||
variant: choices[item.name]?.variant,
|
||||
choices,
|
||||
} satisfies State
|
||||
|
||||
write(next)
|
||||
// Pin both choices while the agent and model acknowledgments arrive separately.
|
||||
const selected = current()
|
||||
|
||||
if (selected) model.set({ providerID: selected.provider.id, modelID: selected.id })
|
||||
})
|
||||
},
|
||||
move(direction: 1 | -1) {
|
||||
const items = list()
|
||||
|
||||
if (items.length === 0) {
|
||||
setStore("current", undefined)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
let next = items.findIndex((item) => item.name === agent.current()?.name) + direction
|
||||
|
||||
if (next < 0) next = items.length - 1
|
||||
|
||||
if (next >= items.length) next = 0
|
||||
const item = items[next]
|
||||
|
||||
if (!item) return
|
||||
agent.set(item.name)
|
||||
},
|
||||
@@ -233,26 +268,33 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
|
||||
const current = createMemo(() => {
|
||||
if (!configuredModel.ready()) return
|
||||
|
||||
const item = firstModel(
|
||||
() => scope()?.model,
|
||||
() => {
|
||||
const session = durable()
|
||||
|
||||
if (session?.agent && session.agent !== agent.current()?.name) return
|
||||
const model = session?.model
|
||||
|
||||
return model && { providerID: model.providerID, modelID: model.id }
|
||||
},
|
||||
() => agent.current()?.model,
|
||||
fallback,
|
||||
)
|
||||
|
||||
if (!item) return
|
||||
|
||||
return models.find(item)
|
||||
})
|
||||
|
||||
const configured = () => {
|
||||
const item = agent.current()
|
||||
const model = current()
|
||||
|
||||
if (!item || !model) return
|
||||
const global = configuredModel()
|
||||
|
||||
return (
|
||||
getConfiguredAgentVariant({
|
||||
agent: { model: item.model, variant: item.variant },
|
||||
@@ -267,15 +309,19 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
|
||||
const selected = () => {
|
||||
const draft = scope()
|
||||
|
||||
if (draft?.model && validModel(draft.model)) return draft.variant
|
||||
const session = durable()
|
||||
|
||||
if (session?.agent && session.agent !== agent.current()?.name) return
|
||||
const value = session?.model
|
||||
|
||||
if (value && validModel({ providerID: value.providerID, modelID: value.id })) return value.variant ?? null
|
||||
}
|
||||
|
||||
const snapshot = () => {
|
||||
const selected = current()
|
||||
|
||||
return {
|
||||
agent: agent.current()?.name,
|
||||
model: selected ? { providerID: selected.provider.id, modelID: selected.id } : undefined,
|
||||
@@ -291,15 +337,19 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
} satisfies State
|
||||
|
||||
const session = id()
|
||||
|
||||
if (session) {
|
||||
setSaved("session", session, state)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setStore("draft", state)
|
||||
}
|
||||
|
||||
const recent = createMemo(() => models.recent.list().filter(validModel).map(models.find).filter(Boolean))
|
||||
const pending = new Map<string, State>()
|
||||
|
||||
const sameSelection = (a: State, b: State) =>
|
||||
a.agent === b.agent &&
|
||||
a.model?.providerID === b.model?.providerID &&
|
||||
@@ -309,7 +359,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const reconcile = (sessionID: string) => {
|
||||
const expected = pending.get(sessionID)
|
||||
const session = data.session.get(sessionID)
|
||||
|
||||
if (!expected || !session?.model) return
|
||||
|
||||
if (
|
||||
!sameSelection(expected, {
|
||||
agent: session.agent,
|
||||
@@ -320,9 +372,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
return
|
||||
pending.delete(sessionID)
|
||||
const draft = saved.session[sessionID]
|
||||
|
||||
if (id() !== sessionID || !draft || !sameSelection(draft, expected)) return
|
||||
setSaved("session", sessionID, { agent: undefined, model: undefined, variant: undefined })
|
||||
}
|
||||
|
||||
onCleanup(serverSDK.event.on("session.model.selected", (event) => reconcile(event.data.sessionID)))
|
||||
onCleanup(serverSDK.event.on("session.agent.selected", (event) => reconcile(event.data.sessionID)))
|
||||
onCleanup(
|
||||
@@ -340,6 +394,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
trackSessionCommit(sessionID: string, selection: { agent: string; model: ModelKey; variant?: string }) {
|
||||
pending.set(sessionID, selection)
|
||||
reconcile(sessionID)
|
||||
|
||||
return () => {
|
||||
if (pending.get(sessionID) === selection) pending.delete(sessionID)
|
||||
}
|
||||
@@ -347,14 +402,18 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
cycle(direction: 1 | -1) {
|
||||
const items = recent()
|
||||
const item = current()
|
||||
|
||||
if (!item) return
|
||||
|
||||
const index = items.findIndex((entry) => entry?.provider.id === item.provider.id && entry?.id === item.id)
|
||||
let next = index === -1 ? (direction === 1 ? 0 : items.length - 1) : index + direction
|
||||
|
||||
if (next < 0) next = items.length - 1
|
||||
|
||||
if (next >= items.length) next = 0
|
||||
|
||||
const entry = items[next]
|
||||
|
||||
if (!entry) return
|
||||
model.set({ providerID: entry.provider.id, modelID: entry.id })
|
||||
},
|
||||
@@ -364,10 +423,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const previous = current()
|
||||
const same = item && previous?.provider.id === item.providerID && previous.id === item.modelID
|
||||
write({ model: item, variant: same ? (model.variant.current() ?? null) : undefined })
|
||||
|
||||
if (!item) return
|
||||
|
||||
// A session draft owns its variant even when preferences change in another session.
|
||||
if (id() && !same) write({ variant: model.variant.current() ?? null })
|
||||
models.setVisibility(item, true)
|
||||
|
||||
if (!options?.recent) return
|
||||
models.recent.push(item)
|
||||
})
|
||||
@@ -383,6 +445,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
selected,
|
||||
current() {
|
||||
const model = current()
|
||||
|
||||
return resolveModelVariant({
|
||||
variants: this.list(),
|
||||
selected: this.selected(),
|
||||
@@ -392,12 +455,15 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
},
|
||||
list() {
|
||||
const item = current()
|
||||
|
||||
if (!item?.variants) return []
|
||||
|
||||
return Object.keys(item.variants)
|
||||
},
|
||||
set(value: string | undefined) {
|
||||
batch(() => {
|
||||
const model = current()
|
||||
|
||||
if (!model) return
|
||||
write({ model: { providerID: model.provider.id, modelID: model.id }, variant: value ?? null })
|
||||
models.variant.set({ providerID: model.provider.id, modelID: model.id }, value)
|
||||
@@ -405,6 +471,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
},
|
||||
cycle() {
|
||||
const items = this.list()
|
||||
|
||||
if (items.length === 0) return
|
||||
this.set(
|
||||
cycleModelVariant({
|
||||
@@ -428,11 +495,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
},
|
||||
promote(dir: string, session: string, state?: State) {
|
||||
const next = clone(state ?? snapshot())
|
||||
|
||||
if (!next) return
|
||||
// Creation already owns the active selection; keep only agent memory once it is in the read model.
|
||||
// Otherwise a first-message command's configured overrides would stay hidden behind this handoff.
|
||||
const created = data.session.get(session)
|
||||
const selection = created?.model
|
||||
|
||||
const committed =
|
||||
selection &&
|
||||
sameSelection(next, {
|
||||
@@ -442,6 +511,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
})
|
||||
? { choices: next.choices }
|
||||
: next
|
||||
|
||||
const key = handoffKey(serverSDK.scope, dir, session)
|
||||
handoff.set(key, committed)
|
||||
|
||||
@@ -454,10 +524,15 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
},
|
||||
restore(msg: { sessionID: string; agent: string; model: ModelKey }) {
|
||||
const session = id()
|
||||
|
||||
if (!session) return
|
||||
|
||||
if (msg.sessionID !== session) return
|
||||
|
||||
if (durable()?.model) return
|
||||
|
||||
if (saved.session[session] !== undefined) return
|
||||
|
||||
if (handoff.has(handoffKey(serverSDK.scope, sdk().directory, session))) return
|
||||
|
||||
setSaved("session", session, {
|
||||
@@ -468,6 +543,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return result
|
||||
},
|
||||
})
|
||||
|
||||
@@ -14,7 +14,6 @@ import { Mark } from "@opencode/ui/logo"
|
||||
import { Keybind } from "@opencode/ui/keybind"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import { SessionReviewV2SidebarToggle } from "@opencode/session-ui/v2/session-review-v2"
|
||||
import {
|
||||
MenuItem,
|
||||
type MountedSession,
|
||||
@@ -160,13 +159,6 @@ export function SideRegion(props: {
|
||||
onCleanup(createTabStripScroll({ el, lead: props.region.lead }))
|
||||
}}
|
||||
>
|
||||
<div class="session-review-v2-sidebar-toggle-slot h-full shrink-0 sticky start-0 z-10 flex items-center justify-center bg-v2-background-bg-base">
|
||||
<SessionReviewV2SidebarToggle
|
||||
opened={props.sidebar.opened()}
|
||||
disabled={props.region.selected()?.tab.sidebar === "locked"}
|
||||
onToggle={props.sidebar.toggle}
|
||||
/>
|
||||
</div>
|
||||
<For each={props.region.keys()}>
|
||||
{(key) => (
|
||||
<Show when={props.region.entry(key)}>
|
||||
|
||||
@@ -295,6 +295,7 @@ export const dict = {
|
||||
"prompt.toast.uploadFailed.title": "Upload failed",
|
||||
"prompt.toast.modelAgentRequired.title": "Select an agent and model",
|
||||
"prompt.toast.modelAgentRequired.description": "Choose an agent and model before sending a prompt.",
|
||||
"prompt.toast.unqueueable.title": "This prompt cannot be queued",
|
||||
"prompt.toast.worktreeCreateFailed.title": "Failed to create worktree",
|
||||
"prompt.toast.sessionCreateFailed.title": "Failed to create session",
|
||||
"prompt.toast.shellSendFailed.title": "Failed to send shell command",
|
||||
@@ -503,11 +504,17 @@ export const dict = {
|
||||
"session.queue.remove": "Remove",
|
||||
"session.queue.undo": "Undo",
|
||||
"session.queue.undoShell": "Leave shell mode before undoing a queued prompt",
|
||||
"session.queue.undoUnavailable": "Edit this prompt in the queue to preserve its file context",
|
||||
"session.queue.reorder": "Reorder queued prompt",
|
||||
"session.queue.reverted": "Redo the revert before you reorder or edit queued prompts",
|
||||
"session.queue.attachments.one": "{{count}} attachment",
|
||||
"session.queue.attachments.other": "{{count}} attachments",
|
||||
"session.timeline.working": "Working",
|
||||
"session.running.working.one": "{{count}} working…",
|
||||
"session.running.working.other": "{{count}} working…",
|
||||
"session.running.running.one": "{{count}} running",
|
||||
"session.running.running.other": "{{count}} running",
|
||||
"session.running.stop.subagent": "Interrupt subagent",
|
||||
"session.running.stop.shell": "Kill shell command",
|
||||
|
||||
"session.files.selectToOpen": "Select a file to open",
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Schema, SchemaGetter } from "effect"
|
||||
import { Effect, Predicate, Schema, SchemaGetter } from "effect"
|
||||
import { Persistence } from "./schema"
|
||||
|
||||
describe("persistence schemas", () => {
|
||||
@@ -12,6 +12,7 @@ describe("persistence schemas", () => {
|
||||
}),
|
||||
{ enabled: true, appearance: { width: 240, font: "default" }, variant: "high" },
|
||||
)
|
||||
|
||||
const decode = Schema.decodeUnknownSync(schema)
|
||||
expect(decode({ appearance: { width: 300 } })).toEqual({
|
||||
enabled: true,
|
||||
@@ -32,6 +33,7 @@ describe("persistence schemas", () => {
|
||||
|
||||
test("legacy migration observes missing fields before initial defaults are applied", () => {
|
||||
const current = Persistence.struct({ mode: Schema.Literals(["compact", "full"]), enabled: Schema.Boolean })
|
||||
|
||||
const stored = Schema.Struct({
|
||||
mode: Schema.optional(Schema.Unknown),
|
||||
expanded: Schema.optional(Schema.Boolean),
|
||||
@@ -45,6 +47,7 @@ describe("persistence schemas", () => {
|
||||
encode: SchemaGetter.passthrough(),
|
||||
}),
|
||||
)
|
||||
|
||||
const schema = Persistence.withInitial(Persistence.migrate(current, stored), { mode: "compact", enabled: true })
|
||||
const decode = Schema.decodeUnknownSync(schema)
|
||||
expect(decode({ expanded: true, enabled: false })).toEqual({ mode: "full", enabled: false })
|
||||
@@ -58,6 +61,7 @@ describe("persistence schemas", () => {
|
||||
amount: Schema.NumberFromString.check(Schema.isFinite()),
|
||||
items: Schema.mutable(Schema.Array(Schema.String)),
|
||||
})
|
||||
|
||||
const schema = Persistence.withInitial(current, { amount: 7, items: ["initial"] })
|
||||
const decode = Schema.decodeUnknownSync(schema)
|
||||
expect(decode({ amount: "12", items: [] })).toEqual({ amount: 12, items: [] })
|
||||
@@ -69,6 +73,7 @@ describe("persistence schemas", () => {
|
||||
const schema = Persistence.struct({
|
||||
enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
|
||||
})
|
||||
|
||||
const decode = Schema.decodeUnknownSync(schema)
|
||||
expect(decode({})).toEqual({ enabled: true })
|
||||
expect(decode({ enabled: undefined })).toEqual({ enabled: true })
|
||||
@@ -84,6 +89,7 @@ describe("persistence schemas", () => {
|
||||
label: Persistence.fallback(Schema.String, () => "default"),
|
||||
amount: Persistence.fallback(Schema.NumberFromString.check(Schema.isFinite()), () => 7),
|
||||
})
|
||||
|
||||
const decode = Schema.decodeUnknownSync(schema)
|
||||
expect(decode({})).toEqual({ enabled: true, label: "default", amount: 7 })
|
||||
expect(decode({ enabled: "false", label: "saved", amount: "invalid" })).toEqual({
|
||||
@@ -142,14 +148,16 @@ describe("persistence schemas", () => {
|
||||
|
||||
test("recovers and migrates individual array entries", () => {
|
||||
const current = Schema.Struct({ name: Schema.String })
|
||||
|
||||
const schema = Persistence.array(
|
||||
Schema.Union([current, Schema.String]).pipe(
|
||||
Schema.decodeTo(current, {
|
||||
decode: SchemaGetter.transform((value) => (typeof value === "string" ? { name: value } : value)),
|
||||
decode: SchemaGetter.transform((value) => (Predicate.isString(value) ? { name: value } : value)),
|
||||
encode: SchemaGetter.passthrough(),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const decode = Schema.decodeUnknownSync(schema)
|
||||
const value = decode(["old", { name: "new" }, null, { name: false }])
|
||||
expect(value).toEqual([{ name: "old" }, { name: "new" }])
|
||||
|
||||
@@ -23,10 +23,13 @@ export function withInitial<S extends Schema.ConstraintCodec<object, unknown>>(
|
||||
initial: NoInfer<S["Type"]>,
|
||||
) {
|
||||
const schema = isMigrated(definition) ? definition.current : definition
|
||||
|
||||
const read = isMigrated(definition)
|
||||
? SchemaParser.decodeUnknownResult(definition.read, { onExcessProperty: "preserve" })
|
||||
: Result.succeed<unknown>
|
||||
|
||||
const encode = Schema.encodeUnknownSync(schema)
|
||||
|
||||
return Schema.Unknown.pipe(
|
||||
Schema.decode<Schema.Unknown>({
|
||||
decode: SchemaGetter.transformOrFail((value) =>
|
||||
@@ -39,25 +42,37 @@ export function withInitial<S extends Schema.ConstraintCodec<object, unknown>>(
|
||||
}
|
||||
|
||||
// Object-level codecs own their recovery. Plain structs can recover fields independently.
|
||||
// SAFETY: stored values and their defaults are untyped trees walked against `ast`; `withInitial` decodes the
|
||||
// recovered and merged result with the current schema before anyone reads it.
|
||||
// oxlint-disable-next-line anti-slop/no-unknown-parameters, anti-slop/no-unknown-returns -- see SAFETY above
|
||||
function recover(ast: SchemaAST.AST, value: unknown, initial: unknown): unknown {
|
||||
if (value === undefined) return initial
|
||||
if (ast._tag === "Objects" && !ast.encoding && ast.indexSignatures.length === 0 && Predicate.isObject(value)) {
|
||||
|
||||
if (SchemaAST.isObjects(ast) && !ast.encoding && ast.indexSignatures.length === 0 && Predicate.isObject(value)) {
|
||||
return Object.fromEntries(
|
||||
ast.propertySignatures.flatMap((field) => {
|
||||
const defaults = Predicate.isObject(initial) ? initial[field.name] : undefined
|
||||
const next = recover(field.type, value[field.name], defaults)
|
||||
|
||||
if (next === undefined && !Object.hasOwn(value, field.name) && defaults === undefined) return []
|
||||
|
||||
return [[field.name, next]]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const decoded = Schema.decodeUnknownOption(Schema.make<Schema.Codec<unknown, unknown>>(ast))(value)
|
||||
|
||||
return Option.isSome(decoded) ? decoded.value : initial
|
||||
}
|
||||
|
||||
// SAFETY: like `recover`, merges untyped trees that `withInitial` decodes with the current schema afterwards.
|
||||
// oxlint-disable-next-line anti-slop/no-unknown-parameters, anti-slop/no-unknown-returns -- see SAFETY above
|
||||
function merge(initial: unknown, value: unknown): unknown {
|
||||
if (value === undefined) return initial
|
||||
|
||||
if (!Predicate.isObject(initial) || !Predicate.isObject(value)) return value
|
||||
|
||||
return Object.fromEntries(
|
||||
[...new Set([...Object.keys(initial), ...Object.keys(value)])].map((key) => [key, merge(initial[key], value[key])]),
|
||||
)
|
||||
@@ -66,11 +81,13 @@ function merge(initial: unknown, value: unknown): unknown {
|
||||
// Unlike a decoding default, a fallback also replaces invalid persisted values.
|
||||
export function fallback<S extends Schema.ConstraintCodec<unknown, unknown>>(schema: S, value: () => S["Type"]) {
|
||||
const defaulted = Schema.withDecodingDefaultType<S>(Effect.sync(value))(schema)
|
||||
|
||||
return Schema.catchDecoding<typeof defaulted>(() => Effect.sync(() => Option.some(value())))(defaulted)
|
||||
}
|
||||
|
||||
export function optional<S extends Schema.ConstraintCodec<unknown, unknown>>(schema: S) {
|
||||
const field = Schema.optional(schema)
|
||||
|
||||
return Schema.catchDecoding<typeof field>(() => Effect.succeed(Option.none()))(field)
|
||||
}
|
||||
|
||||
@@ -80,6 +97,7 @@ export function struct<const Fields extends Schema.Struct.Fields>(fields: Fields
|
||||
|
||||
export function record<S extends Schema.ConstraintCodec<unknown, unknown>>(schema: S) {
|
||||
const entries = Schema.Record(Schema.String, Schema.mutableKey(schema))
|
||||
|
||||
return fallback(entries, () => Schema.decodeUnknownSync(entries)({}))
|
||||
}
|
||||
|
||||
@@ -87,6 +105,7 @@ export function record<S extends Schema.ConstraintCodec<unknown, unknown>>(schem
|
||||
export function array<S extends Schema.ConstraintCodec<unknown, unknown>>(schema: S) {
|
||||
const decode = Schema.decodeUnknownOption(schema)
|
||||
const encode = Schema.encodeSync(schema)
|
||||
|
||||
return fallback(
|
||||
Schema.Array(Schema.Unknown).pipe(
|
||||
Schema.decodeTo(Schema.mutable(Schema.Array(Schema.toType(schema))), {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Effect, Option, Schema, SchemaGetter } from "effect"
|
||||
import { Effect, Option, Predicate, Schema, SchemaGetter } from "effect"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
|
||||
export const ServerKey = Schema.String.pipe(Schema.brand("ServerConnection.Key"))
|
||||
@@ -19,8 +19,10 @@ export const ServerHttp = Persistence.struct({
|
||||
const StoredServer = Schema.Union([ServerHttp, ServerHttpBase, Schema.String]).pipe(
|
||||
Schema.decodeTo(ServerHttp, {
|
||||
decode: SchemaGetter.transform((value) => {
|
||||
if (typeof value === "string") return { type: "http", http: { url: value } }
|
||||
if (Predicate.isString(value)) return { type: "http", http: { url: value } }
|
||||
|
||||
if ("http" in value) return value
|
||||
|
||||
return { type: "http", http: value }
|
||||
}),
|
||||
encode: SchemaGetter.transform((value) => value),
|
||||
@@ -33,7 +35,9 @@ const ProjectList = Persistence.array(
|
||||
expanded: Persistence.fallback(Schema.Boolean, () => true),
|
||||
}),
|
||||
)
|
||||
|
||||
const Projects = Persistence.record(ProjectList)
|
||||
|
||||
const LastProject = Persistence.record(Schema.String.pipe(Schema.catchDecoding(() => Effect.succeed(Option.none()))))
|
||||
|
||||
const State = Persistence.struct({
|
||||
@@ -57,12 +61,15 @@ export function serverState(canonicalLocalServer: () => string | undefined = ()
|
||||
Schema.decode({
|
||||
decode: SchemaGetter.transform((value) => {
|
||||
const canonical = canonicalLocalServer()
|
||||
|
||||
if (!canonical || canonical === "local") return value
|
||||
const previous = value.projects[canonical]
|
||||
const last = value.lastProject[canonical]
|
||||
|
||||
if (!previous && last === undefined) return value
|
||||
|
||||
const projects = { ...value.projects }
|
||||
|
||||
if (previous) {
|
||||
const local = projects.local ?? []
|
||||
const worktrees = new Set(local.map((project) => project.worktree))
|
||||
@@ -71,16 +78,20 @@ export function serverState(canonicalLocalServer: () => string | undefined = ()
|
||||
...previous.filter((project) => {
|
||||
if (worktrees.has(project.worktree)) return false
|
||||
worktrees.add(project.worktree)
|
||||
|
||||
return true
|
||||
}),
|
||||
]
|
||||
delete projects[canonical]
|
||||
}
|
||||
|
||||
const lastProject = { ...value.lastProject }
|
||||
|
||||
if (last !== undefined) {
|
||||
lastProject.local ??= last
|
||||
delete lastProject[canonical]
|
||||
}
|
||||
|
||||
return { ...value, projects, lastProject }
|
||||
}),
|
||||
encode: SchemaGetter.transform((value) => value),
|
||||
|
||||
@@ -9,8 +9,7 @@ import { showToast } from "@/shell/notifications/toast"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { commentContextItem } from "@/composer/comment-note"
|
||||
import { extractPromptComments, extractPromptFromMessage } from "@/composer/prompt"
|
||||
import { extractPromptContext, extractPromptFromMessage } from "@/composer/prompt"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
import { sessionHref } from "@/shell/routes/session"
|
||||
@@ -38,6 +37,7 @@ export const DialogFork: Component = () => {
|
||||
|
||||
const messages = createMemo((): ForkableMessage[] => {
|
||||
const sessionID = params.id
|
||||
|
||||
if (!sessionID) return []
|
||||
|
||||
const msgs = data.session.message.list(sessionID)
|
||||
@@ -60,13 +60,18 @@ export const DialogFork: Component = () => {
|
||||
if (!item) return
|
||||
|
||||
const sessionID = params.id
|
||||
|
||||
if (!sessionID) return
|
||||
const message = data.session.message.get(sessionID, item.id)
|
||||
|
||||
if (message?.type !== "user") return
|
||||
|
||||
const restored = extractPromptFromMessage(message, {
|
||||
directory: location().directory,
|
||||
attachmentName: language.t("common.attachment"),
|
||||
})
|
||||
|
||||
const context = extractPromptContext(message, { directory: location().directory })
|
||||
const dir = base64Encode(location().directory)
|
||||
|
||||
serverSDK.api.session
|
||||
@@ -76,11 +81,11 @@ export const DialogFork: Component = () => {
|
||||
dialog.close()
|
||||
const target = prompt.capture({ dir, id: forked.id })
|
||||
target.set(restored)
|
||||
target.context.replaceComments(extractPromptComments(message).map(commentContextItem))
|
||||
target.context.replace([...context.comments, ...context.files])
|
||||
navigate(sessionHref(server.key, forked.id))
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
.catch((cause: unknown) => {
|
||||
const message = cause instanceof Error ? cause.message : String(cause)
|
||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useLayout } from "@/shell/state/layout"
|
||||
import { useComposerState } from "@/composer/persistence"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { formatServerError } from "@/runtime/server/errors"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { fetchSessionExport, saveSessionExport, sessionExportFilename } from "@/session/commands/export"
|
||||
@@ -34,6 +36,8 @@ export type SessionCommandContext = {
|
||||
navigateMessageByOffset: (offset: number) => void
|
||||
revert: Pick<SessionRevert, "undo" | "redo">
|
||||
focusInput: () => void
|
||||
// The composer's model, which a compaction runs with.
|
||||
model: () => { id: string; providerID: string; variant?: string } | undefined
|
||||
}
|
||||
|
||||
const withCategory = (category: string) => {
|
||||
@@ -50,6 +54,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
const language = useLanguage()
|
||||
const prompt = useComposerState()
|
||||
const serverSDK = useServerSDK()
|
||||
const data = useData()
|
||||
const settings = useSettings()
|
||||
const platform = usePlatform()
|
||||
const layout = useLayout()
|
||||
@@ -243,7 +248,9 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
|
||||
if (!sessionID) return
|
||||
|
||||
await serverSDK.api.session.compact({ sessionID })
|
||||
await data.session.compact({ sessionID, model: actions.model() }).catch((cause: unknown) => {
|
||||
showToast({ title: formatServerError(cause, language.t, language.t("common.requestFailed")) })
|
||||
})
|
||||
}
|
||||
|
||||
const fork = () => {
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { createEffect, createMemo, onCleanup, type Accessor } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { Option, Schema } from "effect"
|
||||
import type { SessionInboxInfo } from "@opencode/client/promise"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import { Skill } from "@opencode/schema/skill"
|
||||
import type { ComposerDelivery } from "@/composer/adapter"
|
||||
import type { ComposerStateTarget } from "@/composer/submission-state"
|
||||
import type { ImageAttachmentPart, PathAttachmentPart, Prompt } from "@/composer/state"
|
||||
import type { ContextItem, ImageAttachmentPart, PathAttachmentPart, Prompt } from "@/composer/state"
|
||||
import { appendPrompt, clonePrompt, isAttachment, promptLength } from "@/composer/prompt-parts"
|
||||
import { buildPromptRequest } from "@/composer/request"
|
||||
import { blobDataUrl, createLegacyBlobReference } from "@/runtime/persistence/drafts"
|
||||
import { readPromptPresentation } from "@/composer/comment-note"
|
||||
import { extractPromptContext, extractPromptFromMessage } from "@/composer/prompt"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
@@ -39,11 +40,12 @@ export function createSessionQueue(input: {
|
||||
const language = useLanguage()
|
||||
const [state, setState] = createStore<{ editing?: { id: string; stash: EditStash } }>({})
|
||||
const notify = () => showToast({ title: language.t("common.requestFailed") })
|
||||
|
||||
const mutation = useMutation(() => ({
|
||||
mutationFn: async (
|
||||
change:
|
||||
| { type: "reorder"; inboxIDs: string[] }
|
||||
| { type: "undo"; item: QueuedPrompt; prompt: Prompt }
|
||||
| { type: "undo"; item: QueuedPrompt; prompt: Prompt; context: ContextItem[] }
|
||||
| {
|
||||
type: "edit"
|
||||
inboxIDs: string[]
|
||||
@@ -56,16 +58,25 @@ export function createSessionQueue(input: {
|
||||
},
|
||||
) => {
|
||||
if (change.type === "reorder") return rewrite(change.inboxIDs)
|
||||
|
||||
if (change.type === "undo") {
|
||||
await server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: change.item.id })
|
||||
const draft = input.draft.current()
|
||||
const prompt = promptLength(draft)
|
||||
? appendPrompt(draft, change.prompt)
|
||||
: [...change.prompt, ...draft.filter(isAttachment)]
|
||||
|
||||
// A prompt of only comments or attachments adds no text, so it needs no paragraph break.
|
||||
const prompt = !promptLength(draft)
|
||||
? [...change.prompt, ...draft.filter(isAttachment)]
|
||||
: promptLength(change.prompt)
|
||||
? appendPrompt(draft, change.prompt)
|
||||
: [...clonePrompt(draft), ...change.prompt.filter(isAttachment)]
|
||||
|
||||
input.draft.set(prompt, promptLength(prompt))
|
||||
change.context.forEach((item) => input.draft.context.add(item))
|
||||
input.restoreFocus(promptLength(prompt))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const replacement = await editedPromptInput(
|
||||
input.sessionID,
|
||||
location().directory,
|
||||
@@ -73,17 +84,26 @@ export function createSessionQueue(input: {
|
||||
change.prompt,
|
||||
change.text,
|
||||
)
|
||||
// Admit before cancelling so a failed replacement never discards the original.
|
||||
const admitted = await data.session.prompt({
|
||||
...replacement,
|
||||
id: change.replacement,
|
||||
delivery: change.delivery,
|
||||
...(change.delivery === "queue" ? { resume: false } : {}),
|
||||
})
|
||||
|
||||
// Admit before cancelling so a failed replacement never discards the original. A queued edit
|
||||
// rebuilds its position in one rewrite, which leaves the queue unchanged if any admission fails.
|
||||
const admission = { ...replacement, id: change.replacement, delivery: change.delivery }
|
||||
|
||||
if (change.delivery === "queue") {
|
||||
await rewrite(change.inboxIDs, { original: change.original, admission: { ...admission, resume: false } })
|
||||
cancelEdit()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Like a queued edit, an original the server delivered meanwhile keeps the edit draft instead of sending twice.
|
||||
const pending = await server.api.session.inbox.list({ sessionID: input.sessionID })
|
||||
|
||||
if (!pending.some((item) => item.id === change.original && item.type === "user" && item.delivery === "queue"))
|
||||
throw new Error("Queued prompt was delivered before the edit")
|
||||
await data.session.prompt(admission)
|
||||
await server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: change.original })
|
||||
cancelEdit()
|
||||
if (change.delivery === "queue")
|
||||
await rewrite(change.inboxIDs.map((id) => (id === change.original ? admitted.id : id)))
|
||||
},
|
||||
onError: notify,
|
||||
onSettled: () => data.session.pending.sync(input.sessionID).catch(() => undefined),
|
||||
@@ -94,8 +114,10 @@ export function createSessionQueue(input: {
|
||||
.list(input.sessionID)
|
||||
.filter((item): item is QueuedPrompt => item.type === "user" && item.delivery === "queue"),
|
||||
)
|
||||
|
||||
const rows = createMemo(() => {
|
||||
const replacement = mutation.isPending ? mutation.variables : undefined
|
||||
|
||||
return queuedPromptRows(
|
||||
queued(),
|
||||
replacement?.type === "edit" && replacement.delivery === "queue" ? replacement : undefined,
|
||||
@@ -104,78 +126,137 @@ export function createSessionQueue(input: {
|
||||
|
||||
createEffect(() => {
|
||||
const editing = state.editing
|
||||
|
||||
if (!editing || mutation.isPending || queued().some((item) => item.id === editing.id)) return
|
||||
setState("editing", undefined)
|
||||
})
|
||||
onCleanup(() => cancelEdit())
|
||||
|
||||
const rewrite = async (inboxIDs: string[]) => {
|
||||
// `replace` substitutes an edited prompt for the original at its position in the same rewrite.
|
||||
const rewrite = async (
|
||||
inboxIDs: string[],
|
||||
replace?: { original: string; admission: Parameters<typeof data.session.prompt>[0] },
|
||||
) => {
|
||||
const pending = await server.api.session.inbox.list({ sessionID: input.sessionID })
|
||||
|
||||
if (pending.some((item) => item.delivery === "queue" && item.type !== "user"))
|
||||
throw new Error("Queued control items block reordering")
|
||||
const current = pending.filter((item): item is QueuedPrompt => item.type === "user" && item.delivery === "queue")
|
||||
const ordered = inboxIDs.flatMap((id) => current.filter((item) => item.id === id))
|
||||
if (ordered.length !== current.length) throw new Error("Queued prompts changed before reordering")
|
||||
const changed = ordered.findIndex((item, index) => item.id !== current[index]?.id)
|
||||
|
||||
// An edited prompt delivered meanwhile is no longer queued; fail so the edit draft stays.
|
||||
if (ordered.length !== current.length || (replace && !current.some((item) => item.id === replace.original)))
|
||||
throw new Error("Queued prompts changed before reordering")
|
||||
const changed = ordered.findIndex((item, index) => item.id !== current[index]?.id || item.id === replace?.original)
|
||||
|
||||
if (changed < 0) return
|
||||
|
||||
// Existing inbox APIs cannot reorder rows, so replace only the changed suffix.
|
||||
// Existing inbox APIs cannot reorder rows, so replace only the changed suffix. A replacement
|
||||
// can fail (its file may be gone), so withdraw the ones already sent to keep the queue intact.
|
||||
// Each ID is recorded before sending: the server can admit a prompt whose response then fails.
|
||||
const replacements: string[] = []
|
||||
|
||||
for (const item of ordered.slice(changed)) {
|
||||
await data.session.prompt({
|
||||
sessionID: input.sessionID,
|
||||
text: item.payload.text,
|
||||
files: item.payload.files?.map((file) => ({
|
||||
uri: `data:${file.mime};base64,${file.data}`,
|
||||
name: file.name,
|
||||
description: file.description,
|
||||
mention: file.mention,
|
||||
})),
|
||||
agents: item.payload.agents,
|
||||
skills: item.payload.skills,
|
||||
metadata: item.payload.metadata,
|
||||
delivery: "queue",
|
||||
resume: false,
|
||||
const admission =
|
||||
item.id === replace?.original
|
||||
? replace.admission
|
||||
: {
|
||||
id: SessionMessage.ID.create(),
|
||||
sessionID: input.sessionID,
|
||||
text: item.payload.text,
|
||||
files: item.payload.files?.map((file) => ({
|
||||
uri: storedFileUri(file),
|
||||
name: file.name,
|
||||
description: file.description,
|
||||
mention: file.mention,
|
||||
})),
|
||||
agents: item.payload.agents,
|
||||
skills: item.payload.skills,
|
||||
metadata: item.payload.metadata,
|
||||
delivery: "queue" as const,
|
||||
resume: false,
|
||||
}
|
||||
|
||||
const id = admission.id ?? SessionMessage.ID.create()
|
||||
|
||||
replacements.push(id)
|
||||
await data.session.prompt({ ...admission, id }).catch(async (error) => {
|
||||
await Promise.all(
|
||||
replacements.map((inboxID) =>
|
||||
server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID }).catch(() => undefined),
|
||||
),
|
||||
)
|
||||
throw error
|
||||
})
|
||||
}
|
||||
|
||||
for (const item of current.slice(changed)) {
|
||||
await server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: item.id })
|
||||
}
|
||||
}
|
||||
|
||||
const steer = (id: string) => {
|
||||
if (state.editing?.id === id) cancelEdit()
|
||||
|
||||
return server.api.session.inbox
|
||||
.update({ sessionID: input.sessionID, inboxID: id, delivery: "steer" })
|
||||
.catch(() => notify())
|
||||
}
|
||||
|
||||
const remove = (id: string) => {
|
||||
if (state.editing?.id === id) cancelEdit()
|
||||
|
||||
return server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: id }).catch(() => notify())
|
||||
}
|
||||
|
||||
const undo = (id: string) => {
|
||||
if (mutation.isPending || state.editing) return
|
||||
const item = queued().find((entry) => entry.id === id)
|
||||
|
||||
if (!item) return
|
||||
|
||||
if (input.draft.mode.current() !== "normal") {
|
||||
showToast({ title: language.t("session.queue.undoShell") })
|
||||
|
||||
return
|
||||
}
|
||||
const prompt = queuedPromptUndoDraft(item)
|
||||
if (!prompt) {
|
||||
showToast({ title: language.t("session.queue.undoUnavailable") })
|
||||
return
|
||||
}
|
||||
mutation.mutate({ type: "undo", item, prompt })
|
||||
|
||||
const source = { id: item.id, ...item.payload }
|
||||
const context = extractPromptContext(source, { directory: location().directory })
|
||||
|
||||
mutation.mutate({
|
||||
type: "undo",
|
||||
item,
|
||||
prompt: extractPromptFromMessage(source, {
|
||||
directory: location().directory,
|
||||
attachmentName: language.t("common.attachment"),
|
||||
}),
|
||||
context: [...context.comments, ...context.files],
|
||||
})
|
||||
}
|
||||
|
||||
// Re-admitting a queued prompt commits a staged revert, which drops every prompt queued after its boundary.
|
||||
const refuseReverted = () => {
|
||||
if (!data.session.get(input.sessionID)?.revert) return false
|
||||
showToast({ title: language.t("session.queue.reverted") })
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const reorder = (inboxIDs: string[]) => {
|
||||
if (mutation.isPending) return Promise.resolve()
|
||||
if (mutation.isPending || refuseReverted()) return Promise.resolve()
|
||||
|
||||
return mutation.mutateAsync({ type: "reorder", inboxIDs }).catch(() => undefined)
|
||||
}
|
||||
|
||||
const edit = (id: string) => {
|
||||
if (mutation.isPending) return false
|
||||
if (mutation.isPending || refuseReverted()) return false
|
||||
|
||||
if (state.editing?.id === id) return true
|
||||
const item = queued().find((entry) => entry.id === id)
|
||||
|
||||
if (!item) return false
|
||||
|
||||
if (state.editing) cancelEdit()
|
||||
const draft = input.draft.current()
|
||||
setState("editing", {
|
||||
@@ -194,33 +275,42 @@ export function createSessionQueue(input: {
|
||||
text.length,
|
||||
)
|
||||
input.restoreFocus(text.length)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const cancelEdit = () => {
|
||||
const editing = state.editing
|
||||
|
||||
if (!editing) return
|
||||
setState("editing", undefined)
|
||||
// Mode first, then prompt, then retry: mode and prompt writes both clear
|
||||
// the retry marker.
|
||||
input.draft.mode.set(editing.stash.mode)
|
||||
input.draft.set(editing.stash.prompt, editing.stash.cursor)
|
||||
|
||||
if (editing.stash.retry) input.draft.retry.set(editing.stash.retry)
|
||||
input.restoreFocus(editing.stash.cursor)
|
||||
}
|
||||
|
||||
const confirmEdit = (delivery: ComposerDelivery) => {
|
||||
const editing = state.editing
|
||||
if (!editing || mutation.isPending) return
|
||||
|
||||
if (!editing || mutation.isPending || refuseReverted()) return
|
||||
const prompt = clonePrompt(input.draft.current())
|
||||
const text = prompt.map((part) => ("content" in part ? part.content : "")).join("")
|
||||
const attachments = prompt.filter(isAttachment)
|
||||
|
||||
if (!text.trim() && !attachments.length) return cancelEdit()
|
||||
const item = queued().find((entry) => entry.id === editing.id)
|
||||
const original = item ? queuedPromptAttachments(item) : []
|
||||
|
||||
const pristine =
|
||||
item &&
|
||||
text.trim() === queuedPromptText(item) &&
|
||||
attachments.length === original.length &&
|
||||
attachments.every((attachment, index) => attachment.id === original[index].id)
|
||||
|
||||
if (pristine && delivery === "queue") return cancelEdit()
|
||||
mutation.mutate({
|
||||
type: "edit",
|
||||
@@ -233,9 +323,12 @@ export function createSessionQueue(input: {
|
||||
delivery,
|
||||
})
|
||||
}
|
||||
|
||||
const editFirst = () => {
|
||||
const first = queued()[0]
|
||||
|
||||
if (!first) return false
|
||||
|
||||
return edit(first.id)
|
||||
}
|
||||
|
||||
@@ -244,7 +337,9 @@ export function createSessionQueue(input: {
|
||||
delivery: () => (input.working() ? input.behavior() : "steer"),
|
||||
alternate: () => {
|
||||
if (state.editing) return "steer"
|
||||
|
||||
if (!input.working()) return undefined
|
||||
|
||||
return input.behavior() === "queue" ? "steer" : "queue"
|
||||
},
|
||||
editing: () => state.editing?.id,
|
||||
@@ -273,19 +368,29 @@ export type SessionQueueView = Pick<
|
||||
|
||||
export function queuedPromptRows(items: QueuedPrompt[], replacement?: { original: string; replacement: string }) {
|
||||
const replaced = replacement && items.some((item) => item.id === replacement.replacement)
|
||||
return items
|
||||
.filter((item) => !replaced || item.id !== replacement.original)
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
text: queuedPromptText(item),
|
||||
attachments:
|
||||
(item.payload.files?.length ?? 0) + (readPromptPresentation(item.payload.metadata)?.attachments.length ?? 0),
|
||||
}))
|
||||
|
||||
return items.flatMap((item) =>
|
||||
replaced && item.id === replacement.original
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: item.id,
|
||||
text: queuedPromptText(item),
|
||||
attachments:
|
||||
(item.payload.files?.length ?? 0) +
|
||||
(readPromptPresentation(item.payload.metadata)?.attachments.length ?? 0),
|
||||
},
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
const decodeDisplayText = Schema.decodeUnknownOption(Schema.Struct({ displayText: Schema.NonEmptyString }))
|
||||
|
||||
export function queuedPromptText(item: QueuedPrompt) {
|
||||
const display = item.payload.metadata?.["displayText"]
|
||||
return typeof display === "string" && display.length > 0 ? display : item.payload.text
|
||||
return Option.match(decodeDisplayText(item.payload.metadata), {
|
||||
onNone: () => item.payload.text,
|
||||
onSome: (metadata) => metadata.displayText,
|
||||
})
|
||||
}
|
||||
|
||||
// Inline attachments are the files the composer added itself, so they return
|
||||
@@ -317,86 +422,10 @@ export function queuedPromptAttachments(item: QueuedPrompt): (ImageAttachmentPar
|
||||
]
|
||||
}
|
||||
|
||||
// Use the full model-visible text so comment notes and path references remain
|
||||
// in the draft. Convert mentioned files, agents, and skills back into editor
|
||||
// parts; a detached draft cannot represent non-mentioned file context.
|
||||
function queuedPromptUndoDraft(item: QueuedPrompt): Prompt | undefined {
|
||||
if (
|
||||
item.payload.files?.some((file) => !isComposerAttachment(file) && !file.mention) ||
|
||||
item.payload.agents?.some((agent) => !agent.mention) ||
|
||||
item.payload.skills?.some((skill) => !skill.mention)
|
||||
)
|
||||
return
|
||||
const text = item.payload.text
|
||||
const references = [
|
||||
...(item.payload.files ?? []).flatMap((file) =>
|
||||
file.mention
|
||||
? [
|
||||
{
|
||||
type: "file" as const,
|
||||
content: file.mention.text,
|
||||
start: file.mention.start,
|
||||
end: file.mention.end,
|
||||
path: file.name ?? file.mention.text.replace(/^@/, ""),
|
||||
filename: file.name,
|
||||
mime: file.mime,
|
||||
url: `data:${file.mime};base64,${file.data}`,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
...(item.payload.agents ?? []).flatMap((agent) =>
|
||||
agent.mention
|
||||
? [
|
||||
{
|
||||
type: "agent" as const,
|
||||
content: agent.mention.text,
|
||||
start: agent.mention.start,
|
||||
end: agent.mention.end,
|
||||
name: agent.name,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
...(item.payload.skills ?? []).flatMap((skill) =>
|
||||
skill.mention
|
||||
? [
|
||||
{
|
||||
type: "skill" as const,
|
||||
content: skill.mention.text,
|
||||
start: skill.mention.start,
|
||||
end: skill.mention.end,
|
||||
id: Skill.ID.make(skill.id),
|
||||
name: Skill.Name.make(skill.name),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
].sort((left, right) => left.start - right.start)
|
||||
if (
|
||||
references.some(
|
||||
(part, index) =>
|
||||
part.start < (references[index - 1]?.end ?? 0) || text.slice(part.start, part.end) !== part.content,
|
||||
)
|
||||
)
|
||||
return
|
||||
const parts: Prompt = references.flatMap((part, index) => {
|
||||
const start = references[index - 1]?.end ?? 0
|
||||
return [
|
||||
...(part.start > start
|
||||
? [{ type: "text" as const, content: text.slice(start, part.start), start, end: part.start }]
|
||||
: []),
|
||||
part,
|
||||
]
|
||||
})
|
||||
const start = references.at(-1)?.end ?? 0
|
||||
return [
|
||||
...parts,
|
||||
...(text.length > start || !parts.length
|
||||
? [{ type: "text" as const, content: text.slice(start), start, end: text.length }]
|
||||
: []),
|
||||
...queuedPromptAttachments(item).filter((part) => part.type === "image"),
|
||||
]
|
||||
// A stored file re-admits by the URI it came from, as the TUI does, so a file:// reference keeps its
|
||||
// provenance (review comment files stay recognizable) instead of turning into an inline snapshot.
|
||||
function storedFileUri(file: NonNullable<QueuedPrompt["payload"]["files"]>[number]) {
|
||||
return file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`
|
||||
}
|
||||
|
||||
function isComposerAttachment(file: NonNullable<QueuedPrompt["payload"]["files"]>[number]) {
|
||||
@@ -422,51 +451,76 @@ async function editedPromptInput(
|
||||
.filter((part): part is ImageAttachmentPart => part.type === "image")
|
||||
.map(async (part) => ({ ...part, dataUrl: await blobDataUrl(part.blob, part.mime) })),
|
||||
)
|
||||
|
||||
const request = buildPromptRequest({ prompt, context: [], images, text, sessionDirectory: directory })
|
||||
const payload = item?.payload
|
||||
const display = item ? queuedPromptText(item) : ""
|
||||
const notes = payload && display && payload.text.startsWith(display) ? payload.text.slice(display.length) : ""
|
||||
|
||||
const mention = (value: { start: number; end: number; text: string } | undefined) => {
|
||||
if (!value) return undefined
|
||||
const start = text.indexOf(value.text)
|
||||
|
||||
if (start < 0) return undefined
|
||||
|
||||
return { text: value.text, start, end: start + value.text.length }
|
||||
}
|
||||
|
||||
// Structured mentions degrade to plain text in the editor, so an original
|
||||
// agent or skill reference survives the edit as long as its mention text
|
||||
// still appears; newly typed structured mentions come from the request.
|
||||
const agents = [
|
||||
...(payload?.agents?.filter(
|
||||
(agent) =>
|
||||
agent.mention &&
|
||||
text.includes(agent.mention.text) &&
|
||||
(!agent.mention || text.includes(agent.mention.text)) &&
|
||||
!request.agents.some((entry) => entry.name === agent.name),
|
||||
) ?? []),
|
||||
...request.agents,
|
||||
]
|
||||
|
||||
const skills = [
|
||||
...(payload?.skills?.filter(
|
||||
(skill) =>
|
||||
skill.mention && text.includes(skill.mention.text) && !request.skills.some((entry) => entry.id === skill.id),
|
||||
(!skill.mention || text.includes(skill.mention.text)) && !request.skills.some((entry) => entry.id === skill.id),
|
||||
) ?? []),
|
||||
...request.skills,
|
||||
]
|
||||
|
||||
return {
|
||||
sessionID,
|
||||
text: request.text + notes,
|
||||
files: [
|
||||
// A stored file whose mention the edit deleted is dropped; unmentioned context stays.
|
||||
...(payload?.files
|
||||
?.filter((file) => !isComposerAttachment(file))
|
||||
?.filter(
|
||||
(file) =>
|
||||
!isComposerAttachment(file) &&
|
||||
(!file.mention || text.includes(file.mention.text)) &&
|
||||
!request.files.some((entry) => entry.uri === storedFileUri(file)),
|
||||
)
|
||||
.map((file) => ({
|
||||
uri: `data:${file.mime};base64,${file.data}`,
|
||||
uri: storedFileUri(file),
|
||||
name: file.name,
|
||||
description: file.description,
|
||||
mention: mention(file.mention),
|
||||
})) ?? []),
|
||||
...request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })),
|
||||
...request.files.map((file) => ({
|
||||
uri: file.uri,
|
||||
name: file.name,
|
||||
description: file.description,
|
||||
mention: file.mention,
|
||||
})),
|
||||
],
|
||||
agents: agents.map((agent) => ({ name: agent.name, mention: mention(agent.mention) })),
|
||||
skills: skills.map((skill) => ({ id: skill.id, mention: mention(skill.mention) })),
|
||||
metadata: { ...payload?.metadata, displayText: request.displayText, attachments: request.attachments },
|
||||
// Presentation metadata reads only with a comments list, which prompts from other clients lack.
|
||||
// Comments survive while their notes stay out of the edited text; when the editor showed the
|
||||
// notes as text (a prompt with no display text), the edit owns them and the comments go.
|
||||
metadata: {
|
||||
...payload?.metadata,
|
||||
comments: notes ? (payload?.metadata?.["comments"] ?? []) : [],
|
||||
displayText: request.displayText,
|
||||
attachments: request.attachments,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,9 @@ import { setCursorPosition } from "@/composer/editor/dom"
|
||||
import { promptLength } from "@/composer/prompt-parts"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { useLocal } from "@/providers/models/selection"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
@@ -38,6 +41,8 @@ export function createActiveSessionRegion(input: {
|
||||
visible: Accessor<boolean>
|
||||
}) {
|
||||
const command = useCommand()
|
||||
const data = useData()
|
||||
const server = useServerSDK()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const local = useLocal()
|
||||
@@ -56,6 +61,7 @@ export function createActiveSessionRegion(input: {
|
||||
const message = input.timeline.lastUserMessage()
|
||||
const info = input.session.data.info()
|
||||
const selection = resolveSessionComposerSelection(info, message?.metadata)
|
||||
|
||||
if (info && selection.agent && selection.model) {
|
||||
local.session.restore({ sessionID: info.id, agent: selection.agent, model: selection.model })
|
||||
}
|
||||
@@ -64,6 +70,7 @@ export function createActiveSessionRegion(input: {
|
||||
)
|
||||
createEffect(() => {
|
||||
const id = input.session.identity.params.id
|
||||
|
||||
if (!id || !prompt.ready() || !local.session.ready()) return
|
||||
// Prompt model is a submission mirror. Local drafts and durable session state own selection.
|
||||
syncPromptModel(local, prompt)
|
||||
@@ -73,6 +80,7 @@ export function createActiveSessionRegion(input: {
|
||||
() => ({ directory: location().directory, id: input.session.identity.params.id }),
|
||||
(next, previous) => {
|
||||
if (!previous || (next.directory === previous.directory && next.id === previous.id)) return
|
||||
|
||||
if (previous.id && !next.id) local.session.reset()
|
||||
},
|
||||
{ defer: true },
|
||||
@@ -81,42 +89,55 @@ export function createActiveSessionRegion(input: {
|
||||
|
||||
const openAttachment: NonNullable<SessionUserActions["openAttachment"]> = (file) => {
|
||||
const url = file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`
|
||||
|
||||
const download = () => {
|
||||
const anchor = document.createElement("a")
|
||||
anchor.href = url
|
||||
anchor.download = getFilename(file.name) || "attachment"
|
||||
anchor.click()
|
||||
}
|
||||
|
||||
const path = file.name ?? ""
|
||||
const absolute = path.startsWith("/") || path.startsWith("\\\\") || /^[a-zA-Z]:[\\/]/.test(path)
|
||||
|
||||
if (!platform.revealPath || !absolute) return download()
|
||||
void platform.revealPath(path).then((revealed) => {
|
||||
if (!revealed) download()
|
||||
}, download)
|
||||
}
|
||||
|
||||
const focus = () => {
|
||||
if (!input.session.data.isChild()) promptRef?.focus()
|
||||
}
|
||||
|
||||
const openParent = () => {
|
||||
const id = input.session.data.parentID()
|
||||
|
||||
if (id) navigate(sessionHref(requireServerKey(input.session.identity.params.serverKey), id))
|
||||
}
|
||||
|
||||
const editable = (target: EventTarget | null | undefined) => {
|
||||
if (!(target instanceof HTMLElement)) return false
|
||||
|
||||
return /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(target.tagName) || target.isContentEditable
|
||||
}
|
||||
|
||||
const activeElement = () => {
|
||||
let current: Element | null = document.activeElement
|
||||
|
||||
while (current instanceof HTMLElement && current.shadowRoot?.activeElement) {
|
||||
current = current.shadowRoot.activeElement
|
||||
}
|
||||
|
||||
return current instanceof HTMLElement ? current : undefined
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.defaultPrevented) return
|
||||
const path = event.composedPath()
|
||||
const target = path.find((item): item is HTMLElement => item instanceof HTMLElement)
|
||||
const active = activeElement()
|
||||
|
||||
if (
|
||||
path.some((item) => item instanceof HTMLElement && item.closest("[data-prevent-autofocus]") !== null) ||
|
||||
editable(target) ||
|
||||
@@ -125,34 +146,71 @@ export function createActiveSessionRegion(input: {
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === "Escape" && input.session.data.isChild()) {
|
||||
event.preventDefault()
|
||||
openParent()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (active === promptRef) {
|
||||
if (event.key === "Escape") promptRef?.blur()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const key = scrollKey(event)
|
||||
|
||||
if (key) {
|
||||
const scroller = input.timeline.scroller()
|
||||
|
||||
if (!scroller || !isScrollKeyTarget(target ?? null, key)) return
|
||||
|
||||
if (scrollKeyOwner(scroller, target ?? null, key) !== scroller) return
|
||||
input.timeline.view.markUserScroll(scroller)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key.length !== 1 || event.key === "Unidentified" || event.ctrlKey || event.metaKey) return
|
||||
|
||||
if (state.blocked() || input.session.data.isChild() || !promptRef) return
|
||||
promptRef.focus()
|
||||
setCursorPosition(promptRef, prompt.cursor() ?? promptLength(prompt.current()))
|
||||
}
|
||||
|
||||
onMount(() => makeEventListener(document, "keydown", handleKeyDown))
|
||||
|
||||
const revert = createSessionRevert({
|
||||
session: input.session,
|
||||
setActiveMessage: input.timeline.actions.setActiveMessage,
|
||||
})
|
||||
|
||||
const revertMessage: NonNullable<SessionUserActions["revert"]> = ({ messageID }) => revert.to(messageID)
|
||||
|
||||
const notifyFailed = () => {
|
||||
showToast({ title: language.t("common.requestFailed") })
|
||||
}
|
||||
|
||||
const pendingSteer: NonNullable<SessionUserActions["pending"]> = {
|
||||
steer: (messageID) => {
|
||||
const sessionID = input.session.identity.params.id
|
||||
|
||||
if (!sessionID) return false
|
||||
|
||||
return data.session.pending
|
||||
.list(sessionID)
|
||||
.some((item) => item.id === messageID && item.type === "user" && item.delivery === "steer")
|
||||
},
|
||||
queue: ({ sessionID, messageID }) =>
|
||||
server.api.session.inbox
|
||||
.update({ sessionID, inboxID: messageID, delivery: "queue" })
|
||||
.then(() => undefined, notifyFailed),
|
||||
remove: ({ sessionID, messageID }) =>
|
||||
server.api.session.inbox.cancel({ sessionID, inboxID: messageID }).then(() => undefined, notifyFailed),
|
||||
}
|
||||
|
||||
useComposerCommands()
|
||||
useSessionCommands({
|
||||
session: input.session,
|
||||
@@ -164,6 +222,12 @@ export function createActiveSessionRegion(input: {
|
||||
navigateMessageByOffset: input.timeline.actions.navigateMessage,
|
||||
revert,
|
||||
focusInput: focus,
|
||||
model: () => {
|
||||
const selection = controls().model.selection
|
||||
const model = selection.current()
|
||||
|
||||
return model ? { id: model.id, providerID: model.provider.id, variant: selection.variant.current() } : undefined
|
||||
},
|
||||
})
|
||||
command.register("session-palette", () => [
|
||||
{
|
||||
@@ -185,6 +249,7 @@ export function createActiveSessionRegion(input: {
|
||||
},
|
||||
setDockRef: input.timeline.view.setDockRef,
|
||||
}
|
||||
|
||||
const active = createMemo(
|
||||
on(
|
||||
() => (input.visible() ? input.session.identity.sessionID() : undefined),
|
||||
@@ -202,8 +267,14 @@ export function createActiveSessionRegion(input: {
|
||||
timeline: {
|
||||
get revert() {
|
||||
if (input.session.data.isChild()) return
|
||||
|
||||
return revertMessage
|
||||
},
|
||||
get pending() {
|
||||
if (input.session.data.isChild()) return
|
||||
|
||||
return pendingSteer
|
||||
},
|
||||
openAttachment,
|
||||
} satisfies SessionUserActions,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import type { BackgroundTask } from "@opencode/gui-extensions/sdk"
|
||||
import { SessionProgressIndicatorV2 } from "@opencode/session-ui/v2/session-progress-indicator-v2"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import { TextShimmer } from "@opencode/ui/text-shimmer"
|
||||
import { createMemo, For, Show } from "solid-js"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
import { useOpenSessionRoute } from "@/session/session-identity-header"
|
||||
import { errorMessage } from "@/shell/layout/helpers"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
|
||||
const neutral = "light-dark(var(--v2-text-text-base), #ffffff)"
|
||||
|
||||
type RunningItem = {
|
||||
key: string
|
||||
type: "subagent" | "shell"
|
||||
label?: string
|
||||
agent?: string
|
||||
sessionID?: string
|
||||
target: string
|
||||
}
|
||||
|
||||
export function SessionRunningMenu(props: {
|
||||
sessionID?: string
|
||||
// The session whose running work is listed: this one, or its parent inside a subagent.
|
||||
owner?: string
|
||||
blocking: readonly { type: "shell" | "subagent"; partID: string; id?: string; label?: string }[]
|
||||
tasks: readonly BackgroundTask[]
|
||||
onReveal: (target: string) => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const openRoute = useOpenSessionRoute()
|
||||
const server = useServer()
|
||||
const sdk = useServerSDK()
|
||||
const sessionAgent = (id: string | undefined) => (id ? server.ctx.data.session.get(id)?.agent : undefined)
|
||||
|
||||
// Foreground shells stay out: the timeline already shows them at the bottom.
|
||||
const items = createMemo<RunningItem[]>(() => [
|
||||
...props.blocking.flatMap((task) =>
|
||||
task.type === "subagent"
|
||||
? [
|
||||
{
|
||||
...task,
|
||||
key: task.id ?? task.partID,
|
||||
agent: sessionAgent(task.id),
|
||||
sessionID: task.id,
|
||||
target: task.partID,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
...props.tasks.flatMap((task) =>
|
||||
task.type === "subagent"
|
||||
? [{ ...task, key: task.id, agent: task.agent ?? sessionAgent(task.id), sessionID: task.id, target: task.id }]
|
||||
: [],
|
||||
),
|
||||
...props.tasks.flatMap((task) => (task.type === "shell" ? [{ ...task, key: task.id, target: task.id }] : [])),
|
||||
])
|
||||
|
||||
const label = createMemo(() => {
|
||||
const count = items().length
|
||||
|
||||
if (items().some((item) => item.type === "shell")) return language.plural("session.running.running", count)
|
||||
|
||||
return language.plural("session.running.working", count)
|
||||
})
|
||||
|
||||
// Inside a subagent, its own row is listed among its siblings.
|
||||
const viewing = (item: RunningItem) => !!item.sessionID && item.sessionID === props.sessionID
|
||||
|
||||
const open = (item: RunningItem) => {
|
||||
const current = props.sessionID
|
||||
|
||||
if (!current || viewing(item)) return
|
||||
|
||||
if (item.sessionID) return openRoute(current, item.sessionID)
|
||||
|
||||
// Shell calls and starting subagent calls live in the owner's timeline, which reveals them once open.
|
||||
if (props.owner && props.owner !== current) return openRoute(current, props.owner, item.target)
|
||||
|
||||
props.onReveal(item.target)
|
||||
}
|
||||
|
||||
// A subagent can only be interrupted once its child session exists.
|
||||
const stoppable = (item: RunningItem) => item.type === "shell" || !!item.sessionID
|
||||
|
||||
const stop = (item: RunningItem) => {
|
||||
const request = item.sessionID
|
||||
? sdk.api.session.interrupt({ sessionID: item.sessionID })
|
||||
: sdk.api.shell.remove({
|
||||
id: item.target,
|
||||
location: { directory: server.ctx.data.shell.get(item.target)?.location.directory },
|
||||
})
|
||||
|
||||
void request.catch((error) =>
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: errorMessage(error, language.t("common.requestFailed")),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={items().length > 0}>
|
||||
<Menu gutter={6} placement="bottom-start">
|
||||
<Menu.Trigger
|
||||
as="button"
|
||||
type="button"
|
||||
aria-label={label()}
|
||||
// 8px from the ··· menu, or from the padded title inside a subagent, which has no menu.
|
||||
class={`${props.owner === props.sessionID ? "ms-1.5" : "ms-0.5"} flex h-7 shrink-0 items-center rounded-[6px] px-2 text-[13px] font-[530] leading-text-compact tracking-[-0.04px] whitespace-nowrap text-v2-text-text-base outline-none hover:bg-v2-overlay-simple-overlay-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-v2-border-border-focus data-[expanded]:bg-v2-overlay-simple-overlay-hover`}
|
||||
>
|
||||
<TextShimmer text={label()} active />
|
||||
</Menu.Trigger>
|
||||
<Menu.Portal>
|
||||
<Menu.Content class="w-60" aria-label={label()}>
|
||||
<For each={items()}>
|
||||
{(item) => (
|
||||
<Menu.Item
|
||||
class="group/running-item"
|
||||
classList={{ "!bg-v2-overlay-simple-overlay-hover": viewing(item) }}
|
||||
aria-current={viewing(item) ? "page" : undefined}
|
||||
onSelect={() => open(item)}
|
||||
onKeyDown={(event) => {
|
||||
if ((event.key !== "Delete" && event.key !== "Backspace") || !stoppable(item)) return
|
||||
|
||||
event.preventDefault()
|
||||
stop(item)
|
||||
}}
|
||||
>
|
||||
<Show
|
||||
when={item.type === "subagent"}
|
||||
fallback={<Icon name="console" class="shrink-0 text-v2-icon-icon-muted" />}
|
||||
>
|
||||
{/* Built-in agents have theme tokens; any other agent keeps the neutral color. */}
|
||||
<SessionProgressIndicatorV2
|
||||
class="shrink-0"
|
||||
style={{
|
||||
color: item.agent ? `var(--v2-agent-${item.agent.toLowerCase()}-solid, ${neutral})` : neutral,
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
<span class="shrink-0 font-[530]">
|
||||
{item.type === "shell"
|
||||
? language.t("ui.tool.shell")
|
||||
: item.agent
|
||||
? `${item.agent[0].toUpperCase()}${item.agent.slice(1)}`
|
||||
: language.t("ui.tool.agent.default")}
|
||||
</span>
|
||||
{/* Menu rows end 6px in for trailing controls; text alone ends 12px in, like the leading edge. */}
|
||||
<span
|
||||
dir="auto"
|
||||
class="me-1.5 min-w-0 flex-1 truncate text-v2-text-text-muted"
|
||||
classList={{
|
||||
"group-hover/running-item:me-0 group-data-[highlighted]/running-item:me-0 [@media(hover:none)]:me-0":
|
||||
stoppable(item),
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
<Show when={stoppable(item)}>
|
||||
{/* The button's own display rule outranks utilities, so this wrapper shows and hides it. */}
|
||||
<span class="hidden shrink-0 group-hover/running-item:flex group-data-[highlighted]/running-item:flex [@media(hover:none)]:flex">
|
||||
{/* The row selects on press, so the stop button keeps its pointer events to itself. */}
|
||||
<IconButton
|
||||
type="button"
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
tabIndex={-1}
|
||||
icon={<Icon name="outline-xmark" />}
|
||||
aria-label={language.t(
|
||||
item.type === "shell" ? "session.running.stop.shell" : "session.running.stop.subagent",
|
||||
)}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onPointerUp={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
stop(item)
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</Show>
|
||||
</Menu.Item>
|
||||
)}
|
||||
</For>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -21,14 +21,15 @@ export function createSessionRequestModel() {
|
||||
const settings = useSettings()
|
||||
createEffect(() => {
|
||||
const id = params.id
|
||||
|
||||
if (!id || serverSDK.connection.status() !== "connected") return
|
||||
void Promise.all([
|
||||
data.shell.sync({ directory: sdk().directory }),
|
||||
data.session.permission.sync(id),
|
||||
]).catch(() => undefined)
|
||||
void Promise.all([data.shell.sync({ directory: sdk().directory }), data.session.permission.sync(id)]).catch(
|
||||
() => undefined,
|
||||
)
|
||||
})
|
||||
createEffect(() => {
|
||||
const id = params.id
|
||||
|
||||
if (!id || serverSDK.connection.status() !== "connected") return
|
||||
void Promise.all(
|
||||
sessionTreeIDs(data.session.list(), id).map((sessionID) => data.session.form.sync(sessionID)),
|
||||
@@ -38,54 +39,80 @@ export function createSessionRequestModel() {
|
||||
const formRequest = createMemo((): FormInfo | undefined => {
|
||||
return sessionFormRequest(data.session.list(), data.session.form.list, params.id)
|
||||
})
|
||||
|
||||
const websearch = createWebSearchRequest({
|
||||
owner: () => params.id,
|
||||
connected: () => serverSDK.connection.status() === "connected",
|
||||
request: () => {
|
||||
const form = formRequest()
|
||||
|
||||
return form?.metadata?.kind === "websearch.provider" ? form : undefined
|
||||
},
|
||||
providers: async (sessionID) => {
|
||||
const session = data.session.get(sessionID) ?? (await serverSDK.api.session.get({ sessionID }))
|
||||
|
||||
const result = await serverSDK.api.websearch.providers({
|
||||
location: { directory: session.location.directory },
|
||||
})
|
||||
|
||||
return result.data.map((provider) => ({ value: provider.id, label: provider.name }))
|
||||
},
|
||||
reply: (input) => data.session.form.reply(input),
|
||||
events: serverSDK.event,
|
||||
})
|
||||
|
||||
const questionRequest = createMemo(() => {
|
||||
if (websearch.request()) return
|
||||
const form = formRequest()
|
||||
|
||||
return form?.metadata?.kind === "question" ? form : undefined
|
||||
})
|
||||
|
||||
const permissionRequest = createMemo((): PermissionRequest | undefined => {
|
||||
if (settings.permissions.autoApprove()) return undefined
|
||||
|
||||
return sessionPermissionRequest(data.session.list(), data.session.permission.list, params.id)
|
||||
})
|
||||
|
||||
const blocked = createMemo(() => {
|
||||
const id = params.id
|
||||
|
||||
if (!id) return false
|
||||
|
||||
return !!permissionRequest() || !!questionRequest() || !!websearch.request()
|
||||
})
|
||||
|
||||
const primary = () => {
|
||||
const id = params.id
|
||||
|
||||
return !!id && !data.session.get(id)?.parentID
|
||||
}
|
||||
const background = createSessionBackground({
|
||||
sessionID: () => (primary() ? params.id : undefined),
|
||||
|
||||
// Inside a subagent, running work is the parent's, so the header lists its siblings.
|
||||
const owner = createMemo(() => {
|
||||
const id = params.id
|
||||
|
||||
if (!id) return
|
||||
|
||||
return data.session.get(id)?.parentID ?? id
|
||||
})
|
||||
|
||||
const running = createSessionBackground({
|
||||
sessionID: owner,
|
||||
messages: data.session.message.list,
|
||||
sessions: data.session.list,
|
||||
status: data.session.status,
|
||||
shells: () => data.shell.list({ directory: sdk().directory }),
|
||||
})
|
||||
|
||||
// Moving to the background and the extensions' background list stay with the primary session's own work.
|
||||
const blocking = createMemo(() => (primary() ? running.blocking() : []))
|
||||
const tasks = createMemo(() => (primary() ? running.tasks() : []))
|
||||
|
||||
const moveToBackground = async () => {
|
||||
if (!primary()) return
|
||||
const sessionID = params.id
|
||||
|
||||
if (!sessionID) return
|
||||
await serverSDK.api.session.background({ sessionID }).catch((error) => {
|
||||
showToast({
|
||||
@@ -95,27 +122,31 @@ export function createSessionRequestModel() {
|
||||
})
|
||||
}
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
responding: undefined as string | undefined,
|
||||
})
|
||||
const [store, setStore] = createStore<{ responding: string | undefined }>({ responding: undefined })
|
||||
|
||||
const permissionResponding = createMemo(() => {
|
||||
const perm = permissionRequest()
|
||||
|
||||
if (!perm) return false
|
||||
|
||||
return store.responding === perm.id
|
||||
})
|
||||
|
||||
const decide = (response: "once" | "always" | "reject") => {
|
||||
const perm = permissionRequest()
|
||||
|
||||
if (!perm) return
|
||||
|
||||
if (store.responding === perm.id) return
|
||||
|
||||
setStore("responding", perm.id)
|
||||
serverSDK.api.permission
|
||||
.reply({ sessionID: perm.sessionID, requestID: perm.id, decision: response })
|
||||
.catch((err: unknown) => {
|
||||
const description = err instanceof Error ? err.message : String(err)
|
||||
showToast({ title: language.t("common.requestFailed"), description })
|
||||
.catch((error) => {
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
setStore("responding", (id) => (id === perm.id ? undefined : id))
|
||||
@@ -129,8 +160,9 @@ export function createSessionRequestModel() {
|
||||
permissionRequest,
|
||||
permissionResponding,
|
||||
background: {
|
||||
blocking: background.blocking,
|
||||
tasks: background.tasks,
|
||||
blocking,
|
||||
tasks,
|
||||
running: { sessionID: owner, blocking: running.blocking, tasks: running.tasks },
|
||||
move: moveToBackground,
|
||||
},
|
||||
decide,
|
||||
|
||||
@@ -4,8 +4,9 @@ import { useData } from "@/runtime/server/current"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { commentContextItem } from "@/composer/comment-note"
|
||||
import { extractPromptComments, extractPromptFromMessage } from "@/composer/prompt"
|
||||
|
||||
import { extractPromptContext, extractPromptFromMessage } from "@/composer/prompt"
|
||||
import { promptLength } from "@/composer/prompt-parts"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import type { SessionModel } from "./model"
|
||||
|
||||
@@ -19,7 +20,7 @@ export function createSessionRevert(input: {
|
||||
const location = useWorkspaceLocation()
|
||||
const language = useLanguage()
|
||||
|
||||
const request = async (action: () => Promise<unknown>) =>
|
||||
const request = async <A>(action: () => Promise<A>) =>
|
||||
action()
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
@@ -27,48 +28,50 @@ export function createSessionRevert(input: {
|
||||
title: language.t("common.requestFailed"),
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
const restore = (target: ReturnType<typeof prompt.capture>, message: SessionMessageUser) => {
|
||||
target.set(
|
||||
extractPromptFromMessage(message, {
|
||||
directory: location().directory,
|
||||
}),
|
||||
)
|
||||
target.context.replaceComments(extractPromptComments(message).map(commentContextItem))
|
||||
const restored = extractPromptFromMessage(message, {
|
||||
directory: location().directory,
|
||||
attachmentName: language.t("common.attachment"),
|
||||
})
|
||||
|
||||
const context = extractPromptContext(message, { directory: location().directory })
|
||||
|
||||
target.set(restored, promptLength(restored))
|
||||
// The restored prompt replaces the draft, so chips from an earlier restore do not ride along.
|
||||
target.context.replace([...context.comments, ...context.files])
|
||||
}
|
||||
|
||||
const stage = async (message: SessionMessageUser, previous: SessionMessageUser | undefined) => {
|
||||
const sessionID = input.session.identity.params.id
|
||||
|
||||
if (!sessionID) return
|
||||
const owner = input.session.ownership.capture()
|
||||
const target = prompt.capture()
|
||||
if (data.session.status(sessionID) === "running") {
|
||||
await server.api.session.interrupt({ sessionID }).catch(() => undefined)
|
||||
|
||||
// An undelivered prompt has no history to rewind. Withdraw it like the TUI
|
||||
// instead of interrupting the work it is waiting behind.
|
||||
if (data.session.input.has(sessionID, message.id)) {
|
||||
if (!(await request(() => server.api.session.inbox.cancel({ sessionID, inboxID: message.id })))) return
|
||||
restore(target, message)
|
||||
owner.run(() => input.setActiveMessage(previous))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Interrupt acknowledges before the execution settles, and staging a busy Session fails. The
|
||||
// local status can lag the server either way, so always settle first; both are idle no-ops.
|
||||
// Like the TUI, stop at the first failure instead of waiting on work that was never interrupted.
|
||||
if (!(await request(() => server.api.session.interrupt({ sessionID })))) return
|
||||
|
||||
if (!(await request(() => server.api.session.wait({ sessionID })))) return
|
||||
|
||||
if (!(await request(() => server.api.session.revert.stage({ sessionID, messageID: message.id })))) return
|
||||
// Reverting to a previous prompt discards the pending queue (and pending
|
||||
// steers): they were written against the history being rewound. Cancel
|
||||
// the authoritative inbox merged with the local snapshot, fire-and-forget
|
||||
// so a slow request cannot delay restoring the composer. The cutoff keeps
|
||||
// the asynchronous sweep away from prompts admitted after the revert; an
|
||||
// old admission still in flight when the list is fetched can survive it,
|
||||
// and fully closing that race needs a server-side revert-discards-inbox
|
||||
// rule.
|
||||
const cutoff = Date.now()
|
||||
const local = data.session.pending
|
||||
.list(sessionID)
|
||||
.filter((item) => item.type === "user")
|
||||
.map((item) => item.id)
|
||||
void server.api.session.inbox
|
||||
.list({ sessionID })
|
||||
.then((rows) => rows.filter((row) => row.type === "user" && row.time.created <= cutoff).map((row) => row.id))
|
||||
.catch(() => [])
|
||||
.then((authoritative) => {
|
||||
new Set([...local, ...authoritative]).forEach(
|
||||
(inboxID) => void server.api.session.inbox.cancel({ sessionID, inboxID }).catch(() => undefined),
|
||||
)
|
||||
})
|
||||
// Like the TUI, pending inputs are left alone: the revert hides them, committing it drops them,
|
||||
// and redo delivers them.
|
||||
restore(target, message)
|
||||
owner.run(() => input.setActiveMessage(previous))
|
||||
}
|
||||
@@ -77,6 +80,7 @@ export function createSessionRevert(input: {
|
||||
const messages = input.session.history.userMessages()
|
||||
const index = messages.findIndex((message) => message.id === messageID)
|
||||
const message = messages[index]
|
||||
|
||||
if (!message) return
|
||||
await stage(message, messages[index - 1])
|
||||
}
|
||||
@@ -85,29 +89,30 @@ export function createSessionRevert(input: {
|
||||
const messages = input.session.history.userMessages()
|
||||
const reverted = input.session.data.revertMessageID()
|
||||
const boundary = reverted ? messages.findIndex((message) => message.id === reverted) : messages.length
|
||||
|
||||
if (boundary <= 0) return
|
||||
const message = messages[boundary - 1]
|
||||
|
||||
if (message) await stage(message, messages[boundary - 2])
|
||||
}
|
||||
|
||||
const redo = async () => {
|
||||
const sessionID = input.session.identity.params.id
|
||||
const reverted = input.session.data.revertMessageID()
|
||||
|
||||
if (!sessionID || !reverted) return
|
||||
const messages = input.session.history.userMessages()
|
||||
const boundary = messages.findIndex((message) => message.id === reverted)
|
||||
if (boundary < 0) return
|
||||
const next = messages[boundary + 1]
|
||||
if (next) {
|
||||
await stage(next, messages[boundary])
|
||||
return
|
||||
}
|
||||
const owner = input.session.ownership.capture()
|
||||
const target = prompt.capture()
|
||||
|
||||
// Like the TUI, redo restores every reverted message at once and leaves the composer alone.
|
||||
if (!(await request(() => server.api.session.revert.clear({ sessionID })))) return
|
||||
target.reset()
|
||||
target.context.replaceComments([])
|
||||
owner.run(() => input.setActiveMessage(messages.at(-1)))
|
||||
owner.run(() =>
|
||||
input.setActiveMessage(
|
||||
input.session.history
|
||||
.userMessages()
|
||||
.filter((message) => !data.session.input.has(sessionID, message.id))
|
||||
.at(-1),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return { to, undo, redo }
|
||||
|
||||
@@ -260,6 +260,7 @@ function SessionScreenContent(props: {
|
||||
setContentRef={timeline.view.setContentRef}
|
||||
anchor={timeline.view.anchor}
|
||||
setRevealMessage={timeline.view.setRevealMessage}
|
||||
reveal={timeline.view.reveal}
|
||||
setScrollToEnd={timeline.view.setScrollToEnd}
|
||||
search={
|
||||
<Show when={active()}>
|
||||
|
||||
@@ -55,15 +55,19 @@ export function SessionProjectMenu(props: {
|
||||
const layout = useLayout()
|
||||
const settingsSurface = useSettingsSurface()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [state, setState] = createStore({
|
||||
open: false,
|
||||
projectTruncated: false,
|
||||
pathTruncated: false,
|
||||
pathFocused: false,
|
||||
})
|
||||
|
||||
const projectName = createMemo(() => displayName(props.project ?? { worktree: props.directory ?? "" }))
|
||||
|
||||
const canOpenPath = () =>
|
||||
platform.platform === "desktop" && !!platform.openPath && server.isLocal && !!props.directory
|
||||
|
||||
const openPath = () => {
|
||||
if (!canOpenPath() || !platform.openPath || !props.directory) return
|
||||
void platform.openPath(props.directory).catch((cause: unknown) =>
|
||||
@@ -73,8 +77,10 @@ export function SessionProjectMenu(props: {
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const openProjectSettings = () => {
|
||||
const current = props.project
|
||||
|
||||
if (!current) return
|
||||
settingsSurface.openProject({
|
||||
server: ServerConnection.key(server.conn),
|
||||
@@ -119,6 +125,7 @@ export function SessionProjectMenu(props: {
|
||||
disabled={!props.project}
|
||||
onSelect={() => {
|
||||
const project = props.project
|
||||
|
||||
if (!project) return
|
||||
server.ctx.projects.open(project.worktree)
|
||||
layout.home.setSelection({ server: server.key, directory: project.worktree })
|
||||
@@ -208,13 +215,14 @@ export function SessionAncestorTrail(props: {
|
||||
trailing: boolean
|
||||
}) {
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const navigate = useNavigate()
|
||||
const language = useLanguage()
|
||||
const open = useOpenSessionRoute()
|
||||
|
||||
const ancestors = createMemo(() => {
|
||||
const path: { id: string; title: string; direct: boolean }[] = []
|
||||
const seen = new Set([props.sessionID])
|
||||
let id: string | undefined = props.parentID
|
||||
|
||||
while (id && !seen.has(id)) {
|
||||
seen.add(id)
|
||||
const info = server.ctx.data.session.get(id)
|
||||
@@ -227,18 +235,9 @@ export function SessionAncestorTrail(props: {
|
||||
})
|
||||
id = info?.parentID
|
||||
}
|
||||
|
||||
return path
|
||||
})
|
||||
const open = (id: string) => {
|
||||
const tab = tabs.store.find(
|
||||
(item) =>
|
||||
item.type === "session" &&
|
||||
item.server === server.key &&
|
||||
(item.sessionId === props.sessionID || item.routeSessionId === props.sessionID),
|
||||
)
|
||||
if (tab?.type === "session") tabs.rememberSessionRoute(tab, id, server.ctx.data.session.get(id)?.parentID)
|
||||
navigate(sessionHref(server.key, id))
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flex min-w-0 max-w-full items-center">
|
||||
@@ -256,7 +255,7 @@ export function SessionAncestorTrail(props: {
|
||||
title={ancestor.title}
|
||||
dir="auto"
|
||||
class="max-w-[min(200px,40vw)] shrink-0 truncate pl-2 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:text-v2-text-text-muted"
|
||||
onClick={() => open(ancestor.id)}
|
||||
onClick={() => open(props.sessionID, ancestor.id)}
|
||||
>
|
||||
{ancestor.title}
|
||||
</button>
|
||||
@@ -286,11 +285,32 @@ export function SessionAncestorTrail(props: {
|
||||
)
|
||||
}
|
||||
|
||||
// Opens a related session in the tab showing `from`, and records its parent so the tab's route stays consistent.
|
||||
// `reveal` names a tool call or shell the opened timeline scrolls to and expands.
|
||||
export function useOpenSessionRoute() {
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (from: string, id: string, reveal?: string) => {
|
||||
const tab = tabs.store.find(
|
||||
(item) =>
|
||||
item.type === "session" &&
|
||||
item.server === server.key &&
|
||||
(item.sessionId === from || item.routeSessionId === from),
|
||||
)
|
||||
|
||||
if (tab?.type === "session") tabs.rememberSessionRoute(tab, id, server.ctx.data.session.get(id)?.parentID)
|
||||
navigate(sessionHref(server.key, id), reveal ? { state: { reveal } } : undefined)
|
||||
}
|
||||
}
|
||||
|
||||
export function SessionIdentityHeader(props: { sessionID: string; session?: SessionInfo }) {
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const language = useLanguage()
|
||||
const pending = createMemo(() => tabs.pendingSession(server.key, props.sessionID))
|
||||
|
||||
const tab = createMemo(() =>
|
||||
tabs.store.find(
|
||||
(item) =>
|
||||
@@ -299,48 +319,64 @@ export function SessionIdentityHeader(props: { sessionID: string; session?: Sess
|
||||
(item.sessionId === props.sessionID || item.routeSessionId === props.sessionID),
|
||||
),
|
||||
)
|
||||
|
||||
const info = createMemo(() => {
|
||||
const current = tab()
|
||||
|
||||
return current ? tabs.info[tabKey(current)] : undefined
|
||||
})
|
||||
|
||||
const parentID = createMemo(() => {
|
||||
if (props.session?.parentID) return props.session.parentID
|
||||
const current = tab()
|
||||
|
||||
if (current?.type !== "session" || current.routeSessionId !== props.sessionID) return
|
||||
|
||||
return current.routeParentId ?? current.sessionId
|
||||
})
|
||||
|
||||
const parent = createMemo(() => {
|
||||
const id = parentID()
|
||||
|
||||
return id ? server.ctx.data.session.get(id) : undefined
|
||||
})
|
||||
|
||||
const parentTitle = createMemo(() => {
|
||||
const id = parentID()
|
||||
const current = tab()
|
||||
|
||||
return sessionTitle(
|
||||
parent()?.title ?? (current?.type === "session" && current.sessionId === id ? info()?.title : undefined),
|
||||
)
|
||||
})
|
||||
|
||||
const directory = createMemo(
|
||||
() => props.session?.location.directory ?? pending()?.draft.directory ?? info()?.directory,
|
||||
)
|
||||
|
||||
const title = createMemo(() =>
|
||||
pending()
|
||||
? language.t("session.tab.session")
|
||||
: sessionTitle(props.session?.title ?? (parentID() ? undefined : info()?.title)),
|
||||
)
|
||||
|
||||
const project = createMemo(() => {
|
||||
if (props.session) return server.ctx.projects.forSession(props.session)
|
||||
const projects = server.ctx.projects.list()
|
||||
const value = directory()
|
||||
|
||||
if (!value) return undefined
|
||||
const key = pathKey(value)
|
||||
|
||||
return (
|
||||
projects.find(
|
||||
(item) => pathKey(item.worktree) === key || item.sandboxes?.some((sandbox) => pathKey(sandbox) === key),
|
||||
) ?? server.ctx.sync.data.project.find((item) => isProjectDirectory(item, value))
|
||||
)
|
||||
})
|
||||
|
||||
const workspaceSession = createMemo(() => !!pending() || isWorkspaceDirectory(project(), directory() ?? ""))
|
||||
|
||||
return (
|
||||
<Show when={title() || parentTitle()}>
|
||||
<SessionTitleHeader>
|
||||
|
||||
@@ -28,6 +28,7 @@ describe("visibleTimelineMessages", () => {
|
||||
delivery: "steer",
|
||||
payload: { text: "queued" },
|
||||
} satisfies SessionInboxInfo
|
||||
|
||||
const work = {
|
||||
id: "msg_5",
|
||||
type: "assistant",
|
||||
@@ -65,8 +66,9 @@ describe("visibleTimelineMessages", () => {
|
||||
shellToolDefaultOpen: () => false,
|
||||
editToolDefaultOpen: () => false,
|
||||
timelineDetail: () => timelinePresets[2].value,
|
||||
pendingUserMessageIDs: () => new Set([steer.id]),
|
||||
pendingInputIDs: () => new Set([steer.id]),
|
||||
})
|
||||
|
||||
expect(projection.activeMessageID()).toBe("msg_1")
|
||||
expect(projection.rows().map((row) => [row._tag, row.userMessageID])).toEqual([
|
||||
["UserMessage", "msg_1"],
|
||||
@@ -113,7 +115,7 @@ describe("visibleTimelineMessages", () => {
|
||||
expect(visibleTimelineMessages(delivered, [])).toBe(delivered)
|
||||
})
|
||||
|
||||
test("preserves steer order and excludes reverted steers", () => {
|
||||
test("preserves pending input order and excludes reverted steers", () => {
|
||||
const source = [...messages, work]
|
||||
const pending = [steer, { ...steer, id: "msg_4" }]
|
||||
expect(visibleTimelineMessages(source, pending).map((message) => message.id)).toEqual([
|
||||
@@ -123,6 +125,30 @@ describe("visibleTimelineMessages", () => {
|
||||
"msg_3",
|
||||
"msg_4",
|
||||
])
|
||||
|
||||
// A notice admitted after the steers, before the next step, sinks with them in admission order. The
|
||||
// server delivers steers in that order and the store moves each delivered input to the end, so the
|
||||
// rendered order does not change at delivery.
|
||||
const notice = {
|
||||
id: "msg_4a",
|
||||
sessionID: "ses_1",
|
||||
time: { created: 4 },
|
||||
type: "synthetic",
|
||||
delivery: "steer",
|
||||
payload: { text: "", description: "Task finished" },
|
||||
} satisfies SessionInboxInfo
|
||||
|
||||
const noticeMessage = {
|
||||
id: notice.id,
|
||||
type: "synthetic",
|
||||
...notice.payload,
|
||||
time: notice.time,
|
||||
} satisfies SessionMessageInfo
|
||||
|
||||
const pendingOrder = visibleTimelineMessages([...messages, noticeMessage, work], [...pending, notice])
|
||||
|
||||
// The order the server delivers in: active work, then steers and notices by admission.
|
||||
expect(pendingOrder.map((message) => message.id)).toEqual(["msg_1", "msg_2", "msg_5", "msg_3", "msg_4", "msg_4a"])
|
||||
expect(visibleTimelineMessages(source, pending, "msg_4").map((message) => message.id)).toEqual([
|
||||
"msg_1",
|
||||
"msg_2",
|
||||
@@ -185,6 +211,7 @@ describe("applyTimelineMessageHandoff", () => {
|
||||
...handoff,
|
||||
files: [{ data: "YQ==", mime: "image/png", source: { type: "inline" } }],
|
||||
} satisfies SessionMessageInfo
|
||||
|
||||
expect(applyTimelineMessageHandoff([durable], handoff)).toEqual([durable])
|
||||
})
|
||||
})
|
||||
@@ -3,9 +3,12 @@ import type { SessionInboxInfo, SessionMessageInfo, SessionMessageUser } from "@
|
||||
export function applyTimelineMessageHandoff(messages: SessionMessageInfo[], handoff?: SessionMessageUser) {
|
||||
if (!handoff) return messages
|
||||
const index = messages.findIndex((message) => message.id === handoff.id)
|
||||
|
||||
if (index < 0) return [...messages, handoff]
|
||||
const message = messages[index]
|
||||
|
||||
if (message.type !== "user" || message.files?.length) return messages
|
||||
|
||||
return messages.map((item, current) => (current === index ? { ...message, files: handoff.files } : item))
|
||||
}
|
||||
|
||||
@@ -17,18 +20,26 @@ export function visibleTimelineMessages(
|
||||
const queued = new Set(
|
||||
pending.flatMap((item) => (item.type === "user" && item.delivery === "queue" ? [item.id] : [])),
|
||||
)
|
||||
const steers = new Set(
|
||||
pending.flatMap((item) => (item.type === "user" && item.delivery === "steer" ? [item.id] : [])),
|
||||
|
||||
const inputs = new Set(
|
||||
pending.flatMap((item) =>
|
||||
(item.type === "user" && item.delivery === "steer") || item.type === "synthetic" ? [item.id] : [],
|
||||
),
|
||||
)
|
||||
if (queued.size === 0 && steers.size === 0 && !revertMessageID) return messages
|
||||
|
||||
if (queued.size === 0 && inputs.size === 0 && !revertMessageID) return messages
|
||||
|
||||
const visible = messages.filter(
|
||||
(message) => !queued.has(message.id) && (!revertMessageID || message.id < revertMessageID),
|
||||
)
|
||||
if (steers.size === 0) return visible
|
||||
// Pending steers do not own assistant work until they are delivered.
|
||||
|
||||
if (inputs.size === 0) return visible
|
||||
|
||||
// Undelivered inputs do not own assistant work, so they stay below the active work like the TUI.
|
||||
// They keep admission order: the server delivers steers in that order, so delivery moves nothing.
|
||||
return [
|
||||
...visible.filter((message) => !steers.has(message.id)),
|
||||
...visible.filter((message) => steers.has(message.id)),
|
||||
...visible.filter((message) => !inputs.has(message.id)),
|
||||
...visible.filter((message) => inputs.has(message.id)),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -39,6 +50,8 @@ export function timelineChildTitle(input: {
|
||||
fallback: string
|
||||
}) {
|
||||
if (!input.parentID) return input.title ?? ""
|
||||
|
||||
if (input.taskDescription) return input.taskDescription
|
||||
|
||||
return input.title?.replace(/\s+\(@[^)]+ subagent\)$/, "") || input.fallback
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { Button } from "@opencode/ui/button"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { createEffect, createMemo, on } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Option, Predicate, Schema } from "effect"
|
||||
import { notifySessionTabsRemoved } from "@/shell/titlebar/session-events"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
@@ -25,18 +26,27 @@ import { getSessionMessageHandoff } from "@/session/handoff"
|
||||
import type { ReasoningMode } from "@opencode/session-ui/timeline/projection"
|
||||
|
||||
const emptyMessages: SessionMessageInfo[] = []
|
||||
|
||||
const decodeErrorData = Schema.decodeUnknownOption(Schema.Struct({ data: Schema.Struct({ message: Schema.String }) }))
|
||||
|
||||
const taskDescription = (message: SessionMessageInfo, sessionID: string): string | undefined => {
|
||||
if (message.type !== "assistant") return
|
||||
|
||||
const tool = message.content.findLast((item) => {
|
||||
if (item.type !== "tool" || (item.name !== "task" && item.name !== "subagent")) return false
|
||||
|
||||
const metadata =
|
||||
item.state.status === "running" || item.state.status === "completed" ? item.state.metadata : undefined
|
||||
|
||||
return metadata?.sessionId === sessionID || metadata?.sessionID === sessionID
|
||||
})
|
||||
|
||||
if (tool?.type !== "tool") return
|
||||
const input = typeof tool.state.input === "string" ? undefined : tool.state.input
|
||||
const input = Predicate.isString(tool.state.input) ? undefined : tool.state.input
|
||||
const value = input?.description
|
||||
if (typeof value === "string" && value) return value
|
||||
|
||||
if (Predicate.isString(value) && value) return value
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -57,44 +67,63 @@ export function createTimelineController(input: { session: TimelineSessionSource
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
|
||||
const handedOffMessages = createMemo(() =>
|
||||
applyTimelineMessageHandoff(
|
||||
input.session.history.messages(),
|
||||
getSessionMessageHandoff(input.session.identity.sessionKey()),
|
||||
),
|
||||
)
|
||||
|
||||
const projectedMessages = createMemo(() => {
|
||||
const id = input.session.identity.sessionID()
|
||||
|
||||
return visibleTimelineMessages(
|
||||
handedOffMessages(),
|
||||
id ? data.session.pending.list(id) : [],
|
||||
input.session.data.info()?.revert?.messageID,
|
||||
)
|
||||
})
|
||||
const pendingUserMessageIDs = createMemo(() => {
|
||||
|
||||
const pendingInputIDs = createMemo(() => {
|
||||
const id = input.session.identity.sessionID()
|
||||
|
||||
return new Set(
|
||||
(id ? data.session.pending.list(id) : []).flatMap((item) =>
|
||||
item.type === "user" && item.delivery === "steer" ? [item.id] : [],
|
||||
(item.type === "user" && item.delivery === "steer") || item.type === "synthetic" ? [item.id] : [],
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const queuedCompactionIDs = createMemo(() => {
|
||||
const id = input.session.identity.sessionID()
|
||||
|
||||
return (id ? data.session.pending.list(id) : []).flatMap((item) => (item.type === "compaction" ? [item.id] : []))
|
||||
})
|
||||
|
||||
const titleValue = createMemo(() => input.session.data.info()?.title)
|
||||
const titleLabel = createMemo(() => sessionTitle(titleValue()) ?? language.t("session.tab.session"))
|
||||
|
||||
const parentMessages = createMemo(() => {
|
||||
const id = input.session.data.parentID()
|
||||
|
||||
return id ? data.session.message.list(id) : emptyMessages
|
||||
})
|
||||
|
||||
const parentTitle = createMemo(
|
||||
() => sessionTitle(input.session.data.parent()?.title) ?? language.t("session.tab.session"),
|
||||
)
|
||||
|
||||
const childTaskDescription = createMemo(() => {
|
||||
const id = input.session.identity.sessionID()
|
||||
|
||||
if (!id) return undefined
|
||||
|
||||
return parentMessages()
|
||||
.map((message) => taskDescription(message, id))
|
||||
.findLast((value): value is string => !!value)
|
||||
})
|
||||
|
||||
const childTitle = createMemo(() => {
|
||||
return timelineChildTitle({
|
||||
parentID: input.session.data.parentID(),
|
||||
@@ -103,9 +132,12 @@ export function createTimelineController(input: { session: TimelineSessionSource
|
||||
fallback: language.t("session.tab.session"),
|
||||
})
|
||||
})
|
||||
|
||||
const showHeader = createMemo(() => !!input.session.identity.sessionID())
|
||||
|
||||
const timelineDetail = createMemo(() => {
|
||||
const detail = settings.general.timelineDetail()
|
||||
|
||||
return {
|
||||
shell: { ...detail.shell },
|
||||
edit: { ...detail.edit },
|
||||
@@ -115,14 +147,17 @@ export function createTimelineController(input: { session: TimelineSessionSource
|
||||
tools: { ...detail.tools },
|
||||
}
|
||||
})
|
||||
|
||||
const reasoningMode = (): ReasoningMode =>
|
||||
timelineDetail().thinking.placement === "hidden"
|
||||
? "hidden"
|
||||
: timelineDetail().thinking.details === "expanded"
|
||||
? "full"
|
||||
: "compact"
|
||||
|
||||
const shellToolPartsExpanded = () => timelineDetail().shell.details === "expanded"
|
||||
const editToolPartsExpanded = () => timelineDetail().edit.details === "expanded"
|
||||
|
||||
const projection = createTimelineProjection({
|
||||
sessionMessages: projectedMessages,
|
||||
status: input.session.data.status,
|
||||
@@ -130,48 +165,63 @@ export function createTimelineController(input: { session: TimelineSessionSource
|
||||
shellToolDefaultOpen: shellToolPartsExpanded,
|
||||
editToolDefaultOpen: editToolPartsExpanded,
|
||||
timelineDetail,
|
||||
pendingUserMessageIDs,
|
||||
pendingInputIDs,
|
||||
queuedCompactionIDs,
|
||||
})
|
||||
|
||||
const [pending, setPending] = createStore({ rename: false })
|
||||
|
||||
const errorMessage = (error: unknown) => {
|
||||
if (error && typeof error === "object" && "data" in error) {
|
||||
const data = error.data
|
||||
if (data && typeof data === "object" && "message" in data && typeof data.message === "string") return data.message
|
||||
}
|
||||
if (error instanceof Error) return error.message
|
||||
return language.t("common.requestFailed")
|
||||
}
|
||||
const errorMessage = (cause: unknown) =>
|
||||
Option.match(decodeErrorData(cause), {
|
||||
onSome: (error) => error.data.message,
|
||||
onNone: () => (cause instanceof Error ? cause.message : language.t("common.requestFailed")),
|
||||
})
|
||||
|
||||
const rename = async (title: string) => {
|
||||
const id = input.session.identity.sessionID()
|
||||
|
||||
if (!id || pending.rename) return false
|
||||
const next = title.trim()
|
||||
|
||||
if (!next || next === (titleLabel() ?? "")) return true
|
||||
setPending("rename", true)
|
||||
|
||||
const success = await serverSDK.api.session
|
||||
.update({ sessionID: id, title: next })
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
showToast({ title: language.t("common.requestFailed"), description: errorMessage(error) })
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
setPending("rename", false)
|
||||
|
||||
if (!success) return false
|
||||
const current = data.session.get(id)
|
||||
|
||||
if (current) data.session.remember({ ...current, title: next })
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const href = (id: string) => sessionHref(server.key, id)
|
||||
|
||||
const navigateAfterRemoval = (id: string, parent?: string, next?: string) => {
|
||||
if (input.session.identity.params.id !== id) return
|
||||
|
||||
if (parent) return navigate(href(parent))
|
||||
|
||||
if (next) return navigate(href(next))
|
||||
|
||||
return tabs.newDraft({ server: server.key, directory: sdk().directory })
|
||||
}
|
||||
|
||||
const exportSession = async (id: string) => {
|
||||
try {
|
||||
const data = await fetchSessionExport({ sessionID: id, api: serverSDK.api })
|
||||
const filename = sessionExportFilename(data.info)
|
||||
|
||||
if (!(await saveSessionExport(filename, data, platform))) return
|
||||
showToast({
|
||||
variant: "success",
|
||||
@@ -187,23 +237,29 @@ export function createTimelineController(input: { session: TimelineSessionSource
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const remove = async (id: string) => {
|
||||
const session = data.session.get(id)
|
||||
|
||||
if (!session) return false
|
||||
const sessions = data.session.list().filter((item) => !item.parentID && !item.time?.archived)
|
||||
const index = sessions.findIndex((item) => item.id === id)
|
||||
const next = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
|
||||
const removed = sessionTreeIDs(data.session.list(), id)
|
||||
|
||||
const success = await data.session
|
||||
.remove(id)
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
showToast({ title: language.t("session.delete.failed.title"), description: errorMessage(error) })
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
if (!success) return false
|
||||
void navigateAfterRemoval(id, session.parentID, next?.id)
|
||||
notifySessionTabsRemoved({ server: server.key, directory: sdk().directory, sessionIDs: removed })
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -211,10 +267,12 @@ export function createTimelineController(input: { session: TimelineSessionSource
|
||||
const name = createMemo(
|
||||
() => sessionTitle(data.session.get(props.sessionID)?.title) ?? language.t("session.tab.session"),
|
||||
)
|
||||
|
||||
const confirm = async () => {
|
||||
await remove(props.sessionID)
|
||||
dialog.close()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog fit>
|
||||
<DialogHeader hideClose>
|
||||
@@ -272,6 +330,7 @@ export function createTimelineController(input: { session: TimelineSessionSource
|
||||
showDelete: (id: string) => dialog.show(() => <DeleteDialog sessionID={id} />),
|
||||
navigateParent: () => {
|
||||
const id = input.session.data.parentID()
|
||||
|
||||
if (id) navigate(href(id))
|
||||
},
|
||||
},
|
||||
|
||||
@@ -10,11 +10,22 @@ import { createTimelineModel } from "./model"
|
||||
|
||||
export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
const layout = useLayout()
|
||||
const location = useLocation()
|
||||
const location = useLocation<{ reveal?: string }>()
|
||||
const timeline = createTimelineModel({ session })
|
||||
const [state, setState] = createStore({
|
||||
messageID: undefined as string | undefined,
|
||||
pendingMessage: undefined as string | undefined,
|
||||
|
||||
const [state, setState] = createStore<{
|
||||
messageID: string | undefined
|
||||
pendingMessage: string | undefined
|
||||
scroll: { overflow: boolean; jump: boolean }
|
||||
follow: { sessionKey: string; pinned: boolean }
|
||||
refs: {
|
||||
scroller: HTMLDivElement | undefined
|
||||
content: HTMLDivElement | undefined
|
||||
dock: HTMLDivElement | undefined
|
||||
}
|
||||
}>({
|
||||
messageID: undefined,
|
||||
pendingMessage: undefined,
|
||||
scroll: {
|
||||
overflow: false,
|
||||
jump: false,
|
||||
@@ -24,23 +35,32 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
pinned: true,
|
||||
},
|
||||
refs: {
|
||||
scroller: undefined as HTMLDivElement | undefined,
|
||||
content: undefined as HTMLDivElement | undefined,
|
||||
dock: undefined as HTMLDivElement | undefined,
|
||||
scroller: undefined,
|
||||
content: undefined,
|
||||
dock: undefined,
|
||||
},
|
||||
})
|
||||
|
||||
// The single source of truth for "follow the newest content". The virtualizer pins and unpins
|
||||
// it from scroll geometry; everything else only expresses explicit intent.
|
||||
const pinned = () => state.follow.sessionKey !== session.identity.sessionKey() || state.follow.pinned
|
||||
const pin = () => setState("follow", { sessionKey: session.identity.sessionKey(), pinned: true })
|
||||
|
||||
const unpin = () => {
|
||||
if (!scroller || scroller.scrollHeight - scroller.clientHeight <= 1) return
|
||||
setState("follow", { sessionKey: session.identity.sessionKey(), pinned: false })
|
||||
}
|
||||
|
||||
let scroller: HTMLDivElement | undefined
|
||||
let dockHeight = 0
|
||||
// The history entry whose tool call was already revealed.
|
||||
let revealed: string | undefined
|
||||
|
||||
// The active timeline's navigation, registered by its virtualizer.
|
||||
let revealMessage = (_id: string, _partID?: string) => {}
|
||||
|
||||
let scrollToEnd = () => {}
|
||||
|
||||
let scrollMark = 0
|
||||
let messageMark = 0
|
||||
let scrollStateFrame: number | undefined
|
||||
@@ -55,49 +75,65 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
if (!scroller) return state.messageID
|
||||
const box = scroller.getBoundingClientRect()
|
||||
const line = box.top + 100
|
||||
const list = [...scroller.querySelectorAll<HTMLElement>("[data-message-id]")]
|
||||
.map((element) => {
|
||||
const id = element.dataset.messageId
|
||||
if (!id) return undefined
|
||||
const rect = element.getBoundingClientRect()
|
||||
return { id, top: rect.top, bottom: rect.bottom }
|
||||
})
|
||||
.filter((item): item is { id: string; top: number; bottom: number } => !!item)
|
||||
|
||||
const list = [...scroller.querySelectorAll<HTMLElement>("[data-message-id]")].flatMap((element) => {
|
||||
const id = element.dataset.messageId
|
||||
|
||||
if (!id) return []
|
||||
const rect = element.getBoundingClientRect()
|
||||
|
||||
return [{ id, top: rect.top, bottom: rect.bottom }]
|
||||
})
|
||||
|
||||
const shown = list.filter((item) => item.bottom > box.top && item.top < box.bottom)
|
||||
const hit = shown.find((item) => item.top <= line && item.bottom >= line)
|
||||
|
||||
if (hit) return hit.id
|
||||
|
||||
const near = [...shown].sort((a, b) => {
|
||||
const aDistance = Math.abs(a.top - line)
|
||||
const bDistance = Math.abs(b.top - line)
|
||||
|
||||
if (aDistance !== bDistance) return aDistance - bDistance
|
||||
|
||||
return a.top - b.top
|
||||
})[0]
|
||||
|
||||
if (near) return near.id
|
||||
|
||||
return list.filter((item) => item.top <= line).at(-1)?.id ?? list[0]?.id ?? state.messageID
|
||||
}
|
||||
|
||||
const setActiveMessage = (message: SessionMessageUser | undefined) => {
|
||||
messageMark = scrollMark
|
||||
setState("messageID", message?.id)
|
||||
}
|
||||
|
||||
const jumpThreshold = (element: HTMLDivElement) => Math.max(400, element.clientHeight)
|
||||
|
||||
const updateScrollState = (element: HTMLDivElement) => {
|
||||
const max = element.scrollHeight - element.clientHeight
|
||||
const distance = max - element.scrollTop
|
||||
const overflow = max > 1
|
||||
const jump = overflow && distance > jumpThreshold(element)
|
||||
|
||||
if (state.scroll.overflow === overflow && state.scroll.jump === jump) return
|
||||
setState("scroll", { overflow, jump })
|
||||
}
|
||||
|
||||
const scheduleScrollState = (element: HTMLDivElement) => {
|
||||
scrollStateTarget = element
|
||||
|
||||
if (scrollStateFrame !== undefined) return
|
||||
scrollStateFrame = requestAnimationFrame(() => {
|
||||
scrollStateFrame = undefined
|
||||
const target = scrollStateTarget
|
||||
scrollStateTarget = undefined
|
||||
|
||||
if (target) updateScrollState(target)
|
||||
})
|
||||
}
|
||||
|
||||
const { clearMessageHash, scrollToMessage } = useSessionHashScroll({
|
||||
sessionKey: session.identity.sessionKey,
|
||||
sessionID: () => session.identity.params.id,
|
||||
@@ -123,64 +159,85 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
scheduleScrollState,
|
||||
consumePendingMessage: (key) => layout.pendingMessage.consume(key),
|
||||
})
|
||||
|
||||
const resume = () => {
|
||||
setState("messageID", undefined)
|
||||
pin()
|
||||
scrollToEnd()
|
||||
clearMessageHash()
|
||||
|
||||
if (scroller) scheduleScrollState(scroller)
|
||||
}
|
||||
|
||||
const navigateMessage = (offset: number) => {
|
||||
const messages = visibleUserMessages()
|
||||
|
||||
if (messages.length === 0) return
|
||||
const current = state.messageID && messageMark === scrollMark ? state.messageID : cursor()
|
||||
const base = current ? messages.findIndex((message) => message.id === current) : messages.length
|
||||
const target = (base === -1 ? messages.length : base) + offset
|
||||
|
||||
if (target < 0 || target > messages.length) return
|
||||
|
||||
if (target === messages.length) {
|
||||
resume()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
unpin()
|
||||
scrollToMessage(messages[target], "auto")
|
||||
}
|
||||
|
||||
// A gesture inside a nested scrollable region scrolls that region, not the timeline.
|
||||
const markUserScroll = (target?: EventTarget | null) => {
|
||||
if (!scroller) return
|
||||
const element = target instanceof Element ? target : undefined
|
||||
const nested = element?.closest("[data-scrollable]")
|
||||
|
||||
if (nested && nested !== scroller) return
|
||||
scrollMark += 1
|
||||
}
|
||||
|
||||
const selectionInteraction = () => {
|
||||
const selection = window.getSelection()
|
||||
|
||||
if (selection && selection.toString().length > 0) unpin()
|
||||
}
|
||||
|
||||
const setScrollRef = (element: HTMLDivElement | undefined) => {
|
||||
scroller = element
|
||||
setState("refs", "scroller", element)
|
||||
|
||||
if (!element) return
|
||||
scheduleScrollState(element)
|
||||
fill()
|
||||
}
|
||||
|
||||
async function loadOlder() {
|
||||
const owner = session.ownership.capture()
|
||||
|
||||
if (timeline.history.loading() || historyRequests.has(owner.key)) return
|
||||
historyRequests.add(owner.key)
|
||||
const before = timeline.messages().length
|
||||
|
||||
try {
|
||||
await timeline.history.loadOlder()
|
||||
} finally {
|
||||
historyRequests.delete(owner.key)
|
||||
}
|
||||
|
||||
if (!owner.current() || timeline.messages().length <= before) return
|
||||
|
||||
if (pinned() || !scroller || scroller.scrollTop >= 200 || !timeline.history.more()) return
|
||||
|
||||
if (historyContinuationFrame !== undefined) cancelAnimationFrame(historyContinuationFrame)
|
||||
historyContinuationFrame = requestAnimationFrame(() => {
|
||||
historyContinuationFrame = undefined
|
||||
owner.run(onHistoryScroll)
|
||||
})
|
||||
}
|
||||
|
||||
const onHistoryScroll = () => {
|
||||
if (
|
||||
historyRequests.has(session.ownership.key()) ||
|
||||
@@ -192,16 +249,21 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
return
|
||||
void loadOlder()
|
||||
}
|
||||
|
||||
const fill = () => {
|
||||
if (fillFrame !== undefined) return
|
||||
fillFrame = requestAnimationFrame(() => {
|
||||
fillFrame = undefined
|
||||
|
||||
if (!session.identity.params.id || !timeline.ready()) return
|
||||
|
||||
if (!pinned() || timeline.history.loading() || !scroller) return
|
||||
|
||||
if (scroller.scrollHeight > scroller.clientHeight + 1 || !timeline.history.more()) return
|
||||
void loadOlder()
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => visibleUserMessages().at(-1)?.id,
|
||||
@@ -272,20 +334,27 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
() => state.refs.dock,
|
||||
({ height }) => {
|
||||
const next = Math.ceil(height)
|
||||
|
||||
if (next === dockHeight) return
|
||||
const delta = next - dockHeight
|
||||
|
||||
const stick = scroller
|
||||
? pinned() || scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop < 10 + Math.max(0, delta)
|
||||
: false
|
||||
|
||||
dockHeight = next
|
||||
|
||||
if (stick) scrollToEnd()
|
||||
|
||||
if (scroller) scheduleScrollState(scroller)
|
||||
fill()
|
||||
},
|
||||
)
|
||||
onCleanup(() => {
|
||||
if (historyContinuationFrame !== undefined) cancelAnimationFrame(historyContinuationFrame)
|
||||
|
||||
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
|
||||
|
||||
if (fillFrame !== undefined) cancelAnimationFrame(fillFrame)
|
||||
})
|
||||
|
||||
@@ -311,6 +380,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
scheduleScrollState,
|
||||
setContentRef: (element: HTMLDivElement | undefined) => {
|
||||
setState("refs", "content", element)
|
||||
|
||||
if (scroller) scheduleScrollState(scroller)
|
||||
},
|
||||
setDockRef: (element: HTMLDivElement | undefined) => {
|
||||
@@ -319,6 +389,13 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
setRevealMessage: (reveal: (id: string, partID?: string) => void) => {
|
||||
revealMessage = reveal
|
||||
},
|
||||
// The tool call the route opened the session on, revealed once per history entry.
|
||||
reveal: {
|
||||
target: () => (revealed === location.key ? undefined : location.state?.reveal),
|
||||
done: () => {
|
||||
revealed = location.key
|
||||
},
|
||||
},
|
||||
setScrollRef,
|
||||
setScrollToEnd: (scroll: () => void) => {
|
||||
scrollToEnd = scroll
|
||||
|
||||
@@ -26,10 +26,18 @@ import { parseCommentNote, readPromptPresentation } from "@/composer/comment-not
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { SessionAncestorTrail, SessionProjectMenu, SessionTitleHeader } from "../session-identity-header"
|
||||
import { SessionHeaderSpacer } from "@/session/header/session-header"
|
||||
import { SessionRunningMenu } from "@/session/header/session-running-menu"
|
||||
|
||||
type BlockingTask = { type: "shell" | "subagent"; partID: string; id?: string; label?: string }
|
||||
|
||||
type SessionBackground = {
|
||||
blocking: Accessor<{ type: "shell" | "subagent"; partID: string; id?: string; label?: string }[]>
|
||||
blocking: Accessor<BlockingTask[]>
|
||||
tasks: Accessor<readonly BackgroundTask[]>
|
||||
running: {
|
||||
sessionID: Accessor<string | undefined>
|
||||
blocking: Accessor<BlockingTask[]>
|
||||
tasks: Accessor<readonly BackgroundTask[]>
|
||||
}
|
||||
move: () => Promise<void>
|
||||
}
|
||||
|
||||
@@ -79,6 +87,7 @@ type MessageTimelineProps = {
|
||||
anchor: (id: string) => string
|
||||
setRevealMessage?: (fn: (id: string, partID?: string) => void) => void
|
||||
setScrollToEnd?: (fn: () => void) => void
|
||||
reveal?: { target: () => string | undefined; done: () => void }
|
||||
search?: JSX.Element
|
||||
}
|
||||
|
||||
@@ -174,7 +183,12 @@ function MessageTimelineView(
|
||||
onUserScroll: props.onUserScroll,
|
||||
onHistoryScroll: props.onHistoryScroll,
|
||||
canRenderImmediately: (row, disclosure) => {
|
||||
if (Predicate.isTagged(row, "TurnGap") || Predicate.isTagged(row, "TurnDivider")) return true
|
||||
if (
|
||||
Predicate.isTagged(row, "TurnGap") ||
|
||||
Predicate.isTagged(row, "TurnDivider") ||
|
||||
Predicate.isTagged(row, "CompactionQueued")
|
||||
)
|
||||
return true
|
||||
|
||||
if (Predicate.isTagged(row, "Notice")) {
|
||||
const message = messageByID().get(row.messageID)
|
||||
@@ -217,6 +231,7 @@ function MessageTimelineView(
|
||||
},
|
||||
setRevealMessage: props.setRevealMessage,
|
||||
setScrollToEnd: props.setScrollToEnd,
|
||||
reveal: props.reveal,
|
||||
})
|
||||
|
||||
const VirtualizedTimeline = virtualized.View
|
||||
@@ -543,6 +558,13 @@ function MessageTimelineView(
|
||||
</Menu>
|
||||
)}
|
||||
</Show>
|
||||
<SessionRunningMenu
|
||||
sessionID={sessionID()}
|
||||
owner={props.background.running.sessionID()}
|
||||
blocking={props.background.running.blocking()}
|
||||
tasks={props.background.running.tasks()}
|
||||
onReveal={virtualized.revealPart}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={sessionID()} keyed>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { createMemo, createResource, type Accessor } from "solid-js"
|
||||
import type { SessionMessageInfo } from "@opencode/client/promise"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { getSessionMessageHandoff } from "@/session/handoff"
|
||||
import type { SessionModel } from "../model"
|
||||
|
||||
const leadingTurnPageDelay = 200
|
||||
|
||||
const leadingTurnPageLimit = 3
|
||||
|
||||
export function createTimelineModel(input: { session: Pick<SessionModel, "identity" | "history" | "ownership"> }) {
|
||||
@@ -24,22 +26,36 @@ export function createTimelineModel(input: { session: Pick<SessionModel, "identi
|
||||
pause: () => new Promise((resolve) => setTimeout(resolve, leadingTurnPageDelay)),
|
||||
maxPages: leadingTurnPageLimit,
|
||||
}).catch(() => undefined)
|
||||
|
||||
return id
|
||||
},
|
||||
)
|
||||
|
||||
const ready = createMemo(() => {
|
||||
const id = input.session.identity.sessionID()
|
||||
// Enrich the partial leading group without withholding the already loaded tail.
|
||||
return !id || data.session.message.list(id).length > 0 || (!resource.loading && resource.latest === id)
|
||||
|
||||
if (!id || (!resource.loading && resource.latest === id)) return true
|
||||
|
||||
// Enrich the partial leading group without withholding the already loaded tail. Undelivered inbox rows
|
||||
// load with the inbox, not the transcript, so alone they are no tail; a first prompt shows through its handoff.
|
||||
return (
|
||||
data.session.message.list(id).some((message) => !data.session.input.has(id, message.id)) ||
|
||||
!!getSessionMessageHandoff(input.session.identity.sessionKey())
|
||||
)
|
||||
})
|
||||
|
||||
const more = () => {
|
||||
const id = input.session.identity.sessionID()
|
||||
|
||||
return id ? data.session.message.more(id) : false
|
||||
}
|
||||
|
||||
const loading = () => {
|
||||
const id = input.session.identity.sessionID()
|
||||
|
||||
return id ? data.session.message.loading(id) : false
|
||||
}
|
||||
|
||||
const loadOlder = async (options?: { before?: () => void; after?: (done: boolean) => void }) => {
|
||||
return loadOlderTimeline({
|
||||
sessionID: input.session.identity.sessionID,
|
||||
@@ -74,18 +90,24 @@ export async function enrichLeadingTurn(input: {
|
||||
if (!input.current() || pages >= input.maxPages || !leadingTurnNeedsParent(input.messages()) || !input.more())
|
||||
return
|
||||
await input.pause()
|
||||
|
||||
if (!input.current() || !leadingTurnNeedsParent(input.messages()) || !input.more()) return
|
||||
|
||||
if (input.loading()) return load(pages)
|
||||
await input.loadMore()
|
||||
|
||||
return load(pages + 1)
|
||||
}
|
||||
|
||||
return load(0)
|
||||
}
|
||||
|
||||
function leadingTurnNeedsParent(messages: SessionMessageInfo[]) {
|
||||
const assistant = messages.findIndex((message) => message.type === "assistant")
|
||||
|
||||
if (assistant === -1) return false
|
||||
const boundary = messages.findIndex((message) => message.type === "user" || message.type === "shell")
|
||||
|
||||
return boundary === -1 || assistant < boundary
|
||||
}
|
||||
|
||||
@@ -98,6 +120,7 @@ export async function loadOlderTimeline(input: {
|
||||
after?: (done: boolean) => void
|
||||
}) {
|
||||
const id = input.sessionID()
|
||||
|
||||
if (!id || !input.more() || input.loading()) return
|
||||
|
||||
input.before?.()
|
||||
@@ -105,6 +128,7 @@ export async function loadOlderTimeline(input: {
|
||||
if (input.sessionID() === id) input.after?.(true)
|
||||
throw error
|
||||
})
|
||||
|
||||
if (input.sessionID() !== id) return
|
||||
input.after?.(true)
|
||||
}
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
type VirtualItem,
|
||||
} from "@tanstack/solid-virtual"
|
||||
import { isScrollKeyTarget, scrollKey, scrollKeyOwner, ScrollView } from "@opencode/ui/scroll-view"
|
||||
import { TimelineRow } from "@opencode/session-ui/timeline/projection"
|
||||
import { Predicate } from "effect"
|
||||
import { Timeline, TimelineRow } from "@opencode/session-ui/timeline/projection"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import {
|
||||
batch,
|
||||
@@ -29,11 +30,15 @@ import { observeElementOffsetReconnectAware } from "./observe-element-offset"
|
||||
import { filterVirtualIndexes } from "./virtual-items"
|
||||
|
||||
const fallbackItemSize = 60
|
||||
|
||||
const pendingMarkdown = '[data-component="markdown"]:not([data-markdown-ready])'
|
||||
|
||||
// Distance from the bottom that counts as "at the end". Deliberately tight: a collapse clamps
|
||||
// exactly to the end, while a one-pixel nudge upward is a deliberate move away from it.
|
||||
const endEpsilon = 0.5
|
||||
|
||||
const upwardKeys = new Set(["up", "page-up", "home"])
|
||||
|
||||
const cache = new Map<
|
||||
string,
|
||||
{
|
||||
@@ -46,7 +51,7 @@ const cache = new Map<
|
||||
|
||||
type Projection = Pick<
|
||||
ReturnType<typeof createTimelineProjection>,
|
||||
"activeMessageID" | "messageLastRowIndex" | "messageRowIndex" | "rowByKey" | "rows"
|
||||
"activeMessageID" | "messageByID" | "messageLastRowIndex" | "messageRowIndex" | "rowByKey" | "rows"
|
||||
>
|
||||
|
||||
type Input = {
|
||||
@@ -73,6 +78,8 @@ type Input = {
|
||||
) => boolean
|
||||
setRevealMessage?: (fn: (id: string, partID?: string) => void) => void
|
||||
setScrollToEnd?: (fn: () => void) => void
|
||||
/** A tool call the route asks this timeline to open on; `done` clears it once revealed. */
|
||||
reveal?: { target: () => string | undefined; done: () => void }
|
||||
}
|
||||
|
||||
type ViewProps = {
|
||||
@@ -106,11 +113,14 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
const [rendering, setRendering] = createStore({ initialTail: coldBottomMount, scrollAdjustment: 0 })
|
||||
const rows = input.projection.rows
|
||||
const rowByKey = input.projection.rowByKey
|
||||
|
||||
const rowKeys = createMemo(() => rows().map(TimelineRow.key), undefined, {
|
||||
equals: (previous, next) => previous.length === next.length && previous.every((key, index) => key === next[index]),
|
||||
})
|
||||
|
||||
const knownKeys = new Set(rowKeys())
|
||||
const addedKeys = new Set<string>()
|
||||
|
||||
const getItemKey = createMemo(() => {
|
||||
const keys = rowKeys()
|
||||
keys
|
||||
@@ -119,39 +129,47 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
knownKeys.add(key)
|
||||
addedKeys.add(key)
|
||||
})
|
||||
|
||||
return (index: number) => keys[index] ?? `removed:${index}`
|
||||
})
|
||||
|
||||
const rangeExtractor = createMemo(() => {
|
||||
const id = input.projection.activeMessageID()
|
||||
const active = id ? (input.projection.messageLastRowIndex().get(id) ?? -1) : -1
|
||||
const initialTail = rendering.initialTail && input.pinned()
|
||||
|
||||
return (range: Range) => {
|
||||
// Batch a bounded cheap suffix, but stop before unknown/large content.
|
||||
// A large tail still mounts alone before estimates expose earlier history.
|
||||
const start = Math.max(0, range.startIndex - 2)
|
||||
|
||||
const boundary = initialTail
|
||||
? rows()
|
||||
.slice(start, range.count)
|
||||
.findLastIndex(
|
||||
(row) =>
|
||||
!(
|
||||
row._tag === "AssistantPart" &&
|
||||
Predicate.isTagged(row, "AssistantPart") &&
|
||||
row.group.type === "context" &&
|
||||
row.group.refs.length <= 64 &&
|
||||
!toolOpen[`context:${row.group.key}`]
|
||||
) && !input.canRenderImmediately?.(row, toolOpen),
|
||||
)
|
||||
: -1
|
||||
|
||||
const first = Math.min(range.count - 1, start + boundary + 1)
|
||||
|
||||
const indexes = initialTail
|
||||
? Array.from({ length: range.count - first }, (_, index) => first + index)
|
||||
: defaultRangeExtractor({ ...range, overscan: 2 })
|
||||
|
||||
return filterVirtualIndexes(
|
||||
[...new Set([...indexes, ...(active < 0 ? [] : [active])])].sort((a, b) => a - b),
|
||||
range.count,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const measuredElements = new WeakSet<Element>()
|
||||
let touchStart: number | undefined
|
||||
let touchTarget: EventTarget | null = null
|
||||
@@ -173,6 +191,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
getScrollElement: () => listRoot() ?? null,
|
||||
observeElementRect: (instance, callback) => {
|
||||
reportRect = callback
|
||||
|
||||
return observeElementRect(instance, (rect) => {
|
||||
if (active()) callback(rect)
|
||||
})
|
||||
@@ -190,19 +209,23 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
// clamping row translations lets the compositor paint between
|
||||
// corrections and makes the content oscillate at the top.
|
||||
const root = listRoot()
|
||||
|
||||
if (
|
||||
rendering.scrollAdjustment !== 0 &&
|
||||
root &&
|
||||
(logicalOffset <= 0 || offset <= 0 || (touchStart !== undefined && offset <= root.clientHeight))
|
||||
)
|
||||
flushTouchAdjustment()
|
||||
|
||||
if (!scrolling && touchStart === undefined) finishTouchScroll()
|
||||
})
|
||||
settleColdBottom()
|
||||
}
|
||||
|
||||
return observeElementOffsetReconnectAware(instance, reportOffset, () => {
|
||||
if (!active()) return
|
||||
virtualContent?.querySelectorAll<HTMLDivElement>("[data-index]").forEach(virtualizer.measureElement)
|
||||
|
||||
if (input.pinned()) virtualizer.scrollToEnd()
|
||||
settleColdBottom()
|
||||
})
|
||||
@@ -222,17 +245,23 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
const initial = !measuredElements.has(element)
|
||||
measuredElements.add(element)
|
||||
const box = entry?.borderBoxSize[0]
|
||||
|
||||
if (box) return Math.round(box.blockSize)
|
||||
|
||||
if (initial) {
|
||||
const size = instance.itemSizeCache.get(instance.options.getItemKey(instance.indexFromElement(element)))
|
||||
|
||||
if (size !== undefined || coldPending) return size ?? fallbackItemSize
|
||||
}
|
||||
|
||||
return element.offsetHeight
|
||||
},
|
||||
scrollToFn: (offset, options, instance) => {
|
||||
if (!active()) return
|
||||
|
||||
if (batchingColdSizes && input.pinned()) return
|
||||
setRendering("scrollAdjustment", 0)
|
||||
|
||||
if (virtualContent) virtualContent.style.height = `${instance.getTotalSize()}px`
|
||||
elementScroll(offset, options, instance)
|
||||
},
|
||||
@@ -255,6 +284,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
return rangeExtractor()
|
||||
},
|
||||
})
|
||||
|
||||
const resizeItem = virtualizer.resizeItem
|
||||
const pendingSizes = new Map<number, { key: string; size: number }>()
|
||||
let resizeScheduled = false
|
||||
@@ -263,20 +293,27 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
virtualizer.resizeItem = (index, size) => {
|
||||
if (!active()) return
|
||||
const row = rows()[index]
|
||||
|
||||
if (!row) return
|
||||
const key = TimelineRow.key(row)
|
||||
|
||||
if ((virtualizer.itemSizeCache.get(key) ?? fallbackItemSize) === size) {
|
||||
pendingSizes.delete(index)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
pendingSizes.set(index, { key, size })
|
||||
|
||||
if (resizeScheduled) return
|
||||
resizeScheduled = true
|
||||
queueMicrotask(() => {
|
||||
resizeScheduled = false
|
||||
|
||||
if (!pendingSizes.size) return
|
||||
const sizes = [...pendingSizes]
|
||||
pendingSizes.clear()
|
||||
|
||||
if (!active()) return
|
||||
// The hidden pinned mount needs one bottom write after the whole batch,
|
||||
// not a layout-forcing scroll adjustment for every measured row.
|
||||
@@ -284,40 +321,52 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
batch(() => {
|
||||
sizes.forEach(([index, value]) => {
|
||||
const row = rows()[index]
|
||||
|
||||
if (!row || TimelineRow.key(row) !== value.key) return
|
||||
resizeItem(index, value.size)
|
||||
|
||||
// TanStack recalculates its range after each resize. Advance the
|
||||
// logical fold before deciding whether the next row needs anchoring.
|
||||
if (!touchAdjustment) return
|
||||
setRendering("scrollAdjustment", (value) => value + touchAdjustment)
|
||||
touchAdjustment = 0
|
||||
const root = listRoot()
|
||||
|
||||
if (root) reportOffset?.(root.scrollTop, virtualizer.isScrolling)
|
||||
})
|
||||
})
|
||||
batchingColdSizes = false
|
||||
|
||||
if (coldPending) pinColdBottom()
|
||||
settleColdBottom()
|
||||
|
||||
if (coldPending) return
|
||||
|
||||
if (!input.pinned()) return
|
||||
const root = listRoot()
|
||||
|
||||
// Reopening a settled scroll-to-end operation can fight subsequent keyboard scrolling.
|
||||
if (root && Math.abs(root.scrollHeight - root.clientHeight - root.scrollTop) > endEpsilon)
|
||||
virtualizer.scrollToEnd()
|
||||
})
|
||||
}
|
||||
|
||||
onCleanup(() => pendingSizes.clear())
|
||||
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, delta, instance) => {
|
||||
// Prepended rows can resize more than once as deferred content mounts. Keep
|
||||
// compensating while they remain entirely above the visible content fold.
|
||||
const first = instance.range?.startIndex
|
||||
|
||||
const adjust = addedKeys.has(String(item.key))
|
||||
? item.end <= (instance.scrollOffset ?? 0) + instance.scrollAdjustments + instance.options.scrollMargin
|
||||
: first !== undefined && item.index < first
|
||||
|
||||
if (!touchScrolling || input.pinned()) return adjust
|
||||
|
||||
// iOS defers native scroll writes until momentum ends. Keep the same visual
|
||||
// anchor now, rather than moving rows now and snapping the viewport back later.
|
||||
if (adjust) touchAdjustment += delta
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -326,6 +375,46 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
flushTouchAdjustment()
|
||||
}
|
||||
|
||||
// Scrolls to a tool call, named by its ID or by the shell ID a backgrounded shell call reports, and expands it and
|
||||
// the collapsed context group that hides it. Returns whether the call is in the list.
|
||||
function revealPart(target: string) {
|
||||
if (!active()) return false
|
||||
|
||||
const matches = (ref: { messageID: string; partID: string }) => {
|
||||
const content = Timeline.resolveContent(input.projection.messageByID().get(ref.messageID), ref.partID)
|
||||
|
||||
return (
|
||||
content?.type === "tool" &&
|
||||
(content.id === target || (content.state.status !== "streaming" && content.state.metadata?.shellID === target))
|
||||
)
|
||||
}
|
||||
|
||||
const found = rows()
|
||||
.flatMap((row, index) =>
|
||||
Predicate.isTagged(row, "AssistantPart")
|
||||
? (row.group.type === "part" ? [row.group.ref] : row.group.refs).flatMap((ref) =>
|
||||
matches(ref) ? [{ group: row.group, index, partID: ref.partID }] : [],
|
||||
)
|
||||
: [],
|
||||
)
|
||||
.at(-1)
|
||||
|
||||
if (!found) return false
|
||||
|
||||
const key = found.group.key
|
||||
|
||||
setToolOpen(
|
||||
found.group.type === "context"
|
||||
? { [`context:${key}`]: true, [`${key}:tool:${found.partID}`]: true }
|
||||
: { [key]: true },
|
||||
)
|
||||
input.onUnpin()
|
||||
prepareNavigation()
|
||||
virtualizer.scrollToIndex(found.index, { align: "center" })
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function prepareNavigation() {
|
||||
if (touchStart === undefined) touchScrolling = false
|
||||
flushTouchAdjustment()
|
||||
@@ -334,41 +423,57 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
function flushTouchAdjustment() {
|
||||
const adjustment = rendering.scrollAdjustment
|
||||
const root = listRoot()
|
||||
|
||||
if (!adjustment || !root) return
|
||||
// Transfer the translation into the native offset in the same paint.
|
||||
batch(() => {
|
||||
setRendering("scrollAdjustment", 0)
|
||||
|
||||
if (virtualContent) virtualContent.style.height = `${virtualizer.getTotalSize()}px`
|
||||
elementScroll(Math.max(0, root.scrollTop + adjustment), {}, virtualizer)
|
||||
})
|
||||
}
|
||||
|
||||
const virtualItemByKey = createMemo(
|
||||
() => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)),
|
||||
)
|
||||
|
||||
const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => String(item.key)))
|
||||
|
||||
createEffect(() => {
|
||||
if (!active()) return
|
||||
const root = listRoot()
|
||||
|
||||
if (root) input.setScrollRef(root)
|
||||
|
||||
if (virtualContent) input.setContentRef(virtualContent)
|
||||
queueMicrotask(() => {
|
||||
if (!active() || !root?.isConnected) return
|
||||
// A detached view can miss its nonzero ResizeObserver delivery. Publish
|
||||
// its real viewport before restoring the offset and admitting rows.
|
||||
reportRect?.({ width: root.offsetWidth, height: root.offsetHeight })
|
||||
if (input.pinned()) virtualizer.scrollToEnd()
|
||||
|
||||
// A route can open this timeline on a tool call, e.g. a shell picked from inside a subagent, instead of its end.
|
||||
const target = input.reveal?.target()
|
||||
|
||||
if (target && revealPart(target)) input.reveal?.done()
|
||||
else if (input.pinned()) virtualizer.scrollToEnd()
|
||||
|
||||
reportOffset?.(root.scrollTop, false)
|
||||
settleColdBottom()
|
||||
})
|
||||
input.setRevealMessage?.((id, partID) => {
|
||||
if (!active()) return
|
||||
|
||||
const partIndex = partID
|
||||
? rows().findIndex(
|
||||
(row) => row._tag === "AssistantPart" && row.group.type === "part" && row.group.ref.partID === partID,
|
||||
(row) =>
|
||||
Predicate.isTagged(row, "AssistantPart") && row.group.type === "part" && row.group.ref.partID === partID,
|
||||
)
|
||||
: -1
|
||||
|
||||
const index = partIndex >= 0 ? partIndex : input.projection.messageRowIndex().get(id)
|
||||
|
||||
if (index === undefined) return
|
||||
prepareNavigation()
|
||||
virtualizer.scrollToIndex(index, { align: "center" })
|
||||
@@ -385,32 +490,42 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
let settleQueued = false
|
||||
let contentObserver: MutationObserver | undefined
|
||||
let viewportObserver: ResizeObserver | undefined
|
||||
|
||||
const pinColdBottom = () => {
|
||||
if (!active()) return
|
||||
const root = listRoot()
|
||||
|
||||
if (!input.pinned() || !virtualContent || !root) return
|
||||
// scrollToEnd computes its target from the DOM, not the new size cache.
|
||||
virtualContent.style.height = `${virtualizer.getTotalSize()}px`
|
||||
|
||||
if (Math.abs(root.scrollHeight - root.clientHeight - root.scrollTop) > endEpsilon) virtualizer.scrollToEnd()
|
||||
|
||||
// Report after core size adjustments finish so they cannot apply a delta
|
||||
// twice. This avoids waiting a frame for the native scroll event.
|
||||
if (virtualizer.scrollOffset !== root.scrollTop) reportOffset?.(root.scrollTop, false)
|
||||
}
|
||||
|
||||
const pendingMeasurements = () => {
|
||||
const items = virtualizer.getVirtualItems()
|
||||
|
||||
return (
|
||||
(rows().length > 0 && items.length === 0) ||
|
||||
items.some((item) => !virtualizer.elementsCache.get(item.key)?.isConnected)
|
||||
)
|
||||
}
|
||||
|
||||
const settleColdBottom = () => {
|
||||
if (!active() || !coldPending || settleQueued) return
|
||||
settleQueued = true
|
||||
queueMicrotask(() => {
|
||||
settleQueued = false
|
||||
const root = listRoot()
|
||||
|
||||
if (!coldPending || !virtualContent?.isConnected || !root) return
|
||||
|
||||
if (virtualContent.querySelector(pendingMarkdown)) return
|
||||
|
||||
if (!root.clientHeight) return
|
||||
// Markdown can finish before ResizeObserver delivers its new box. The
|
||||
// normal measureElement path skips reads while scrolling; this gate needs
|
||||
@@ -418,17 +533,23 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
virtualizer.elementsCache.forEach((element) => {
|
||||
if (element.isConnected) virtualizer.resizeItem(virtualizer.indexFromElement(element), element.offsetHeight)
|
||||
})
|
||||
|
||||
if (pendingSizes.size || pendingMeasurements()) return
|
||||
pinColdBottom()
|
||||
|
||||
if (input.pinned() && Math.abs(root.scrollHeight - root.clientHeight - root.scrollTop) > 1) return
|
||||
|
||||
// The scroll event must update the range before newly exposed rows can reveal.
|
||||
if (root.scrollHeight > root.clientHeight && Math.abs((virtualizer.scrollOffset ?? 0) - root.scrollTop) > 1)
|
||||
return
|
||||
|
||||
if (rendering.initialTail) {
|
||||
setRendering("initialTail", false)
|
||||
settleColdBottom()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (pendingSizes.size || pendingMeasurements() || virtualContent.querySelector(pendingMarkdown)) return
|
||||
coldPending = false
|
||||
contentObserver?.disconnect()
|
||||
@@ -436,6 +557,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
virtualContent.style.removeProperty("visibility")
|
||||
})
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (!coldPending || !virtualContent) return
|
||||
contentObserver = new MutationObserver(settleColdBottom)
|
||||
@@ -447,6 +569,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
})
|
||||
viewportObserver = new ResizeObserver(settleColdBottom)
|
||||
const root = listRoot()
|
||||
|
||||
if (root) viewportObserver.observe(root)
|
||||
settleColdBottom()
|
||||
})
|
||||
@@ -454,6 +577,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
let measuredSessionKey = input.sessionKey()
|
||||
createEffect(() => {
|
||||
const key = input.sessionKey()
|
||||
|
||||
if (measuredSessionKey !== key) {
|
||||
measuredSessionKey = key
|
||||
virtualizer.measure()
|
||||
@@ -467,6 +591,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
setListRoot(root)
|
||||
scrollTop = root.scrollTop
|
||||
maxScroll = root.scrollHeight - root.clientHeight
|
||||
|
||||
if (active()) input.setScrollRef(root)
|
||||
viewportObserver?.observe(root)
|
||||
settleColdBottom()
|
||||
@@ -476,6 +601,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
// the end must stop following, even though the resulting position still looks like the end.
|
||||
const handleListWheel = (event: WheelEvent & { currentTarget: HTMLDivElement }) => {
|
||||
input.onUserScroll(event.target)
|
||||
|
||||
if (event.deltaY < 0) input.onUnpin()
|
||||
}
|
||||
|
||||
@@ -493,20 +619,25 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
touchTarget?.addEventListener("touchmove", handleListTouchMove, { passive: true })
|
||||
touchTarget?.addEventListener("touchend", handleListTouchEnd, { passive: true })
|
||||
touchTarget?.addEventListener("touchcancel", handleListTouchEnd, { passive: true })
|
||||
|
||||
if (root) reportOffset?.(root.scrollTop, virtualizer.isScrolling)
|
||||
}
|
||||
|
||||
const handleListTouchMove = (event: Event) => {
|
||||
if (!(event instanceof TouchEvent)) return
|
||||
const current = event.touches[0]?.clientY
|
||||
|
||||
if (current === undefined || touchStart === undefined) return
|
||||
const previous = touchStart
|
||||
touchStart = current
|
||||
|
||||
// A retained target can outlive its whole session view. Only the active
|
||||
// timeline may change the shared follow state; release still cleans up below.
|
||||
if (!active()) return
|
||||
|
||||
// Dragging the content downward reveals earlier messages.
|
||||
if (current <= previous) return
|
||||
|
||||
// A nested scrollport owns the intent. If it chains into the timeline at a
|
||||
// boundary, the resulting native timeline scroll below will unpin instead.
|
||||
if (touchNested) return
|
||||
@@ -516,6 +647,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
const handleListTouchEnd = () => {
|
||||
clearTouchTarget()
|
||||
touchStart = undefined
|
||||
|
||||
if (!virtualizer.isScrolling) finishTouchScroll()
|
||||
}
|
||||
|
||||
@@ -525,6 +657,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
touchTarget?.removeEventListener("touchcancel", handleListTouchEnd)
|
||||
touchTarget = null
|
||||
}
|
||||
|
||||
onCleanup(clearTouchTarget)
|
||||
|
||||
// Drag-selecting past the edge and dragging the scrollbar both scroll without a wheel or key,
|
||||
@@ -533,9 +666,11 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
input.onUserScroll(event.target)
|
||||
pointerHeld = true
|
||||
}
|
||||
|
||||
const releasePointer = () => {
|
||||
pointerHeld = false
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener("pointerup", releasePointer)
|
||||
window.addEventListener("pointercancel", releasePointer)
|
||||
@@ -547,10 +682,14 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
|
||||
const handleListKeyDown = (event: KeyboardEvent & { currentTarget: HTMLDivElement }) => {
|
||||
const key = scrollKey(event)
|
||||
|
||||
if (!key) return
|
||||
|
||||
if (!isScrollKeyTarget(event.target, key)) return
|
||||
|
||||
if (scrollKeyOwner(event.currentTarget, event.target, key) !== event.currentTarget) return
|
||||
input.onUserScroll(event.currentTarget)
|
||||
|
||||
if (upwardKeys.has(key)) input.onUnpin()
|
||||
}
|
||||
|
||||
@@ -566,6 +705,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
maxScroll = root.scrollHeight - root.clientHeight
|
||||
const atEnd = maxScroll - scrollTop <= endEpsilon
|
||||
const arrived = scrollTop > previousTop + endEpsilon || maxScroll < previousMaxScroll
|
||||
|
||||
if (maxScroll <= 1 || (atEnd && arrived)) input.onPin()
|
||||
else if ((pointerHeld || touchScrolling) && scrollTop < previousTop - endEpsilon) input.onUnpin()
|
||||
settleColdBottom()
|
||||
@@ -601,13 +741,19 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
width: "100%",
|
||||
height: `${item().size}px`,
|
||||
overflow: "clip",
|
||||
"overflow-clip-margin": row()._tag === "TurnGap" ? undefined : "0.5px",
|
||||
"overflow-clip-margin": Predicate.isTagged(row(), "TurnGap") ? undefined : "0.5px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={(value) => {
|
||||
element = value
|
||||
if (row()._tag !== "UserMessage" || !addedKeys.has(rowProps.rowKey) || !input.pinned() || coldPending)
|
||||
|
||||
if (
|
||||
!Predicate.isTagged(row(), "UserMessage") ||
|
||||
!addedKeys.has(rowProps.rowKey) ||
|
||||
!input.pinned() ||
|
||||
coldPending
|
||||
)
|
||||
return
|
||||
// The optimistic row can paint before ResizeObserver corrects the tail estimates.
|
||||
// Measure the mounted tail and pin it in this render's microtask instead.
|
||||
@@ -626,9 +772,11 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
>
|
||||
{props.renderRow(row, () => {
|
||||
setReady(true)
|
||||
|
||||
if (contentMeasureFrame !== undefined) cancelAnimationFrame(contentMeasureFrame)
|
||||
contentMeasureFrame = requestAnimationFrame(() => {
|
||||
contentMeasureFrame = undefined
|
||||
|
||||
if (active() && element.isConnected) virtualizer.measureElement(element)
|
||||
})
|
||||
})}
|
||||
@@ -692,6 +840,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
data-timeline-virtual-content
|
||||
ref={(element) => {
|
||||
virtualContent = element
|
||||
|
||||
if (active()) input.setContentRef(element)
|
||||
}}
|
||||
style={{
|
||||
@@ -723,10 +872,12 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
patchGroupKeys,
|
||||
presentationKey: input.presentationKey?.(),
|
||||
})
|
||||
|
||||
while (cache.size > 16) cache.delete(cache.keys().next().value!)
|
||||
coldPending = false
|
||||
contentObserver?.disconnect()
|
||||
viewportObserver?.disconnect()
|
||||
|
||||
if (active()) {
|
||||
input.setScrollRef(undefined)
|
||||
input.setRevealMessage?.(() => {})
|
||||
@@ -740,6 +891,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
value: (key: string) => toolOpen[key],
|
||||
set: (key: string, open: boolean) => setToolOpen(key, open),
|
||||
},
|
||||
revealPart,
|
||||
View,
|
||||
}
|
||||
}
|
||||
@@ -27,17 +27,20 @@ export const useSessionHashScroll = (input: {
|
||||
let pendingKey = ""
|
||||
let clearing = false
|
||||
|
||||
const location = useLocation()
|
||||
const location = useLocation<{ reveal?: string }>()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const frames = new Set<number>()
|
||||
|
||||
const queue = (fn: () => void) => {
|
||||
const id = requestAnimationFrame(() => {
|
||||
frames.delete(id)
|
||||
fn()
|
||||
})
|
||||
|
||||
frames.add(id)
|
||||
}
|
||||
|
||||
const cancel = () => {
|
||||
for (const id of frames) cancelAnimationFrame(id)
|
||||
frames.clear()
|
||||
@@ -46,7 +49,9 @@ export const useSessionHashScroll = (input: {
|
||||
const clearMessageHash = () => {
|
||||
cancel()
|
||||
input.consumePendingMessage(input.sessionKey())
|
||||
|
||||
if (input.pendingMessage()) input.setPendingMessage(undefined)
|
||||
|
||||
if (!location.hash) return
|
||||
clearing = true
|
||||
navigate(location.pathname + location.search, { replace: true })
|
||||
@@ -54,6 +59,7 @@ export const useSessionHashScroll = (input: {
|
||||
|
||||
const updateHash = (id: string) => {
|
||||
const hash = `#${input.anchor(id)}`
|
||||
|
||||
if (location.hash === hash) return
|
||||
clearing = false
|
||||
navigate(location.pathname + location.search + hash, {
|
||||
@@ -63,6 +69,7 @@ export const useSessionHashScroll = (input: {
|
||||
|
||||
const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => {
|
||||
const root = input.scroller()
|
||||
|
||||
if (!root) return false
|
||||
|
||||
const a = el.getBoundingClientRect()
|
||||
@@ -71,27 +78,33 @@ export const useSessionHashScroll = (input: {
|
||||
const inset = sticky instanceof HTMLElement ? sticky.offsetHeight : 0
|
||||
const top = Math.max(0, a.top - b.top + root.scrollTop - inset)
|
||||
root.scrollTo({ top, behavior })
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const seek = (id: string, behavior: ScrollBehavior, left = 4): boolean => {
|
||||
input.revealMessage?.(id)
|
||||
const el = document.getElementById(input.anchor(id))
|
||||
|
||||
if (el) return scrollToElement(el, behavior)
|
||||
|
||||
if (left <= 0) return false
|
||||
queue(() => {
|
||||
seek(id, behavior, left - 1)
|
||||
})
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const scrollToMessage = (message: SessionMessageUser, behavior: ScrollBehavior = "smooth") => {
|
||||
cancel()
|
||||
|
||||
if (input.currentMessageId() !== message.id) input.setActiveMessage(message)
|
||||
input.revealMessage?.(message.id)
|
||||
|
||||
if (seek(message.id, behavior)) {
|
||||
updateHash(message.id)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -100,39 +113,54 @@ export const useSessionHashScroll = (input: {
|
||||
|
||||
const applyHash = (behavior: ScrollBehavior) => {
|
||||
const hash = location.hash.slice(1)
|
||||
|
||||
if (!hash) {
|
||||
// A route that opens the session on a tool call leaves the scroll to the timeline, which reveals the call.
|
||||
if (location.state?.reveal) return
|
||||
|
||||
input.follow.toBottom()
|
||||
const el = input.scroller()
|
||||
|
||||
if (el) input.scheduleScrollState(el)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const messageId = messageIdFromHash(hash)
|
||||
|
||||
if (messageId) {
|
||||
input.follow.unpin()
|
||||
const msg = messageById().get(messageId)
|
||||
|
||||
if (msg) {
|
||||
scrollToMessage(msg, behavior)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const target = document.getElementById(hash)
|
||||
|
||||
if (target) {
|
||||
input.follow.unpin()
|
||||
scrollToElement(target, behavior)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
input.follow.toBottom()
|
||||
const el = input.scroller()
|
||||
|
||||
if (el) input.scheduleScrollState(el)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const hash = location.hash
|
||||
|
||||
if (!hash) clearing = false
|
||||
|
||||
if (!input.sessionID() || !input.messagesReady()) return
|
||||
cancel()
|
||||
queue(() => applyHash("auto"))
|
||||
@@ -144,11 +172,14 @@ export const useSessionHashScroll = (input: {
|
||||
visibleUserMessages()
|
||||
|
||||
let targetId = input.pendingMessage()
|
||||
|
||||
if (!targetId) {
|
||||
const key = input.sessionKey()
|
||||
|
||||
if (pendingKey !== key) {
|
||||
pendingKey = key
|
||||
const next = input.consumePendingMessage(key)
|
||||
|
||||
if (next) {
|
||||
input.setPendingMessage(next)
|
||||
targetId = next
|
||||
@@ -157,13 +188,16 @@ export const useSessionHashScroll = (input: {
|
||||
}
|
||||
|
||||
if (!targetId && !clearing) targetId = messageIdFromHash(location.hash)
|
||||
|
||||
if (!targetId) return
|
||||
|
||||
const pending = input.pendingMessage() === targetId
|
||||
const msg = messageById().get(targetId)
|
||||
|
||||
if (!msg) return
|
||||
|
||||
if (pending) input.setPendingMessage(undefined)
|
||||
|
||||
if (input.currentMessageId() === targetId && !pending) return
|
||||
|
||||
input.follow.unpin()
|
||||
@@ -173,14 +207,19 @@ export const useSessionHashScroll = (input: {
|
||||
|
||||
createEffect(() => {
|
||||
const sessionID = input.sessionID()
|
||||
|
||||
if (!sessionID || !input.messagesReady()) return
|
||||
|
||||
visibleUserMessages()
|
||||
|
||||
let targetId = input.pendingMessage()
|
||||
|
||||
if (!targetId && !clearing) targetId = messageIdFromHash(location.hash)
|
||||
|
||||
if (!targetId) return
|
||||
|
||||
if (messageById().has(targetId)) return
|
||||
|
||||
if (!input.historyMore() || input.historyLoading()) return
|
||||
|
||||
void input.loadMore(sessionID)
|
||||
|
||||
@@ -13,40 +13,61 @@ export function useSessionTabAvatarState(
|
||||
const settings = useSettings()
|
||||
const connection = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === server()))
|
||||
const serverCtx = useServerCtx(connection)
|
||||
|
||||
const sessions = createMemo(() => {
|
||||
const data = serverCtx()?.data
|
||||
|
||||
if (!data) return []
|
||||
|
||||
if (!root?.()) return data.session.list()
|
||||
const id = sessionId()
|
||||
|
||||
return [...new Set([id, ...data.session.family(id)])].flatMap((id) => {
|
||||
const info = data.session.get(id)
|
||||
|
||||
return info ? [info] : []
|
||||
})
|
||||
})
|
||||
|
||||
const hasPermissions = createMemo(() => {
|
||||
if (settings.permissions.autoApprove()) return false
|
||||
const ctx = serverCtx()
|
||||
|
||||
if (!ctx) return false
|
||||
|
||||
return !!sessionPermissionRequest(sessions(), ctx.data.session.permission.list, sessionId())
|
||||
})
|
||||
|
||||
const hasForms = createMemo(() => {
|
||||
const data = serverCtx()?.data
|
||||
|
||||
if (!data) return false
|
||||
|
||||
return !!sessionFormRequest(sessions(), data.session.form.list, sessionId())
|
||||
})
|
||||
|
||||
const needsAttention = createMemo(() => hasPermissions() || hasForms())
|
||||
|
||||
const unread = createMemo(
|
||||
() => needsAttention() || (serverCtx()?.notification.session.unseenCount(sessionId()) ?? 0) > 0,
|
||||
)
|
||||
|
||||
const loading = createMemo(() => {
|
||||
const data = serverCtx()?.data
|
||||
|
||||
if (!data) return false
|
||||
|
||||
if (needsAttention()) return false
|
||||
|
||||
// Like the TUI, work waiting in the inbox counts as busy; parked synthetic context does not.
|
||||
const busy = (id: string) =>
|
||||
data.session.status(id) === "running" || data.session.pending.list(id).some((item) => item.type !== "synthetic")
|
||||
|
||||
if (root?.())
|
||||
return data.session
|
||||
.list()
|
||||
.some((session) => data.session.root(session.id) === sessionId() && data.session.status(session.id) === "running")
|
||||
return data.session.status(sessionId()) === "running"
|
||||
return data.session.list().some((session) => data.session.root(session.id) === sessionId() && busy(session.id))
|
||||
|
||||
return busy(sessionId())
|
||||
})
|
||||
|
||||
return { unread, loading }
|
||||
}
|
||||
@@ -272,3 +272,30 @@
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
/* A prompt that arrives in a background tab rings it once, like the TUI's prompt pulse. */
|
||||
[data-titlebar-tab] [data-slot="tab-prompt-pulse"] {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
box-shadow: inset 0 0 0 1px var(--v2-border-border-strong);
|
||||
animation: tab-prompt-pulse 900ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes tab-prompt-pulse {
|
||||
20% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-titlebar-tab] [data-slot="tab-prompt-pulse"] {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createEffect, createMemo, createSignal, onCleanup, Show, type Ref } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Predicate } from "effect"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { createMutation } from "@tanstack/solid-query"
|
||||
@@ -51,44 +52,75 @@ export function TabNavItem(props: {
|
||||
event.stopPropagation()
|
||||
props.onClose()
|
||||
}
|
||||
|
||||
const servers = useServers()
|
||||
const serverCtx = useServerCtx(() => servers.list.find((item) => ServerConnection.key(item) === props.server))
|
||||
|
||||
const project = createMemo(() => {
|
||||
const session = props.session
|
||||
|
||||
if (!session) return
|
||||
|
||||
return serverCtx()?.projects.forSession(session)
|
||||
})
|
||||
|
||||
const title = createMemo(() => {
|
||||
const session = props.session
|
||||
|
||||
return sessionTabTitle(session ? session.title : props.fallbackTitle, language.t("session.tab.session"))
|
||||
})
|
||||
|
||||
const projectName = createMemo(() => {
|
||||
const session = props.session
|
||||
|
||||
if (!session) return
|
||||
|
||||
return displayName(project() ?? { worktree: session.location.directory })
|
||||
})
|
||||
|
||||
const previewPath = createMemo(() => {
|
||||
const session = props.session
|
||||
|
||||
if (!session) return
|
||||
const home = serverCtx()?.sync.data.path.home
|
||||
|
||||
return home ? session.location.directory.replace(home, "~") : session.location.directory
|
||||
})
|
||||
|
||||
// Only label the server when multiple servers are connected.
|
||||
const serverLabel = createMemo(() => {
|
||||
if (servers.list.length <= 1) return
|
||||
const conn = servers.list.find((item) => ServerConnection.key(item) === props.server)
|
||||
|
||||
return conn ? serverName(conn) : undefined
|
||||
})
|
||||
|
||||
// Like the TUI, a prompt sent to a background tab's session pulses the tab once.
|
||||
const [promptPulse, setPromptPulse] = createSignal(0)
|
||||
createEffect(() => {
|
||||
const data = serverCtx()?.data
|
||||
const sessionID = props.session?.id
|
||||
|
||||
if (!data || !sessionID) return
|
||||
onCleanup(
|
||||
data.on("session.inbox.enqueued", (event) => {
|
||||
if (props.active || event.data.item.type !== "user" || data.session.root(event.data.sessionID) !== sessionID)
|
||||
return
|
||||
setPromptPulse((count) => count + 1)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const [popoverOpen, setPopoverOpen] = createSignal(false)
|
||||
const previewBlocked = () => !!props.dragging || editing() || menu.open || !!props.pressed || !props.session
|
||||
|
||||
const measureTitleOverflow = () => {
|
||||
if (!titleEl || editing()) {
|
||||
setTitleOverflowing(false)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setTitleOverflowing(titleEl.scrollWidth > titleEl.clientWidth)
|
||||
}
|
||||
|
||||
@@ -140,8 +172,10 @@ export function TabNavItem(props: {
|
||||
|
||||
createEffect(() => {
|
||||
if (editing()) return
|
||||
|
||||
if (!titleEl) return
|
||||
const value = title()
|
||||
|
||||
if (value === undefined) return
|
||||
titleEl.textContent = value
|
||||
})
|
||||
@@ -149,8 +183,10 @@ export function TabNavItem(props: {
|
||||
const openRename = (event?: MouseEvent) => {
|
||||
event?.preventDefault()
|
||||
event?.stopPropagation()
|
||||
|
||||
if (props.dragging || editing() || rename.isPending) return
|
||||
const session = props.session
|
||||
|
||||
if (!session) return
|
||||
titleEl.textContent = session.title ?? ""
|
||||
setEditing(true)
|
||||
@@ -169,7 +205,9 @@ export function TabNavItem(props: {
|
||||
"pointerdown",
|
||||
(event) => {
|
||||
const target = event.target
|
||||
|
||||
if (!(target instanceof Node)) return
|
||||
|
||||
if (tabRoot.contains(target)) return
|
||||
void closeRename(true)
|
||||
},
|
||||
@@ -183,7 +221,8 @@ export function TabNavItem(props: {
|
||||
<div
|
||||
ref={(el) => {
|
||||
tabRoot = el
|
||||
if (typeof props.ref === "function") props.ref(el)
|
||||
|
||||
if (Predicate.isFunction(props.ref)) props.ref(el)
|
||||
}}
|
||||
data-titlebar-tab
|
||||
data-slot="titlebar-tab-item"
|
||||
@@ -205,6 +244,9 @@ export function TabNavItem(props: {
|
||||
closeTab(event)
|
||||
}}
|
||||
>
|
||||
<Show when={promptPulse()} keyed>
|
||||
<span data-slot="tab-prompt-pulse" aria-hidden="true" onAnimationEnd={() => setPromptPulse(0)} />
|
||||
</Show>
|
||||
<Menu.Context.Trigger
|
||||
as="a"
|
||||
disabled={editing() || props.dragging}
|
||||
@@ -221,15 +263,20 @@ export function TabNavItem(props: {
|
||||
onMouseDown={(event) => {
|
||||
// Navigate on mousedown to shave the press-release delay off tab switches.
|
||||
if (event.button !== 0) return
|
||||
|
||||
if (editing()) return
|
||||
|
||||
if (props.suppressNavigation) return
|
||||
props.onNavigate()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
|
||||
// Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
|
||||
if (event.detail > 0) return
|
||||
|
||||
if (editing()) return
|
||||
|
||||
if (props.suppressNavigation) return
|
||||
props.onNavigate()
|
||||
}}
|
||||
@@ -277,11 +324,14 @@ export function TabNavItem(props: {
|
||||
onDblClick={openRename}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void closeRename(true)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key !== "Escape") return
|
||||
event.preventDefault()
|
||||
titleEl.textContent = props.session?.title ?? ""
|
||||
@@ -321,6 +371,7 @@ export function TabNavItem(props: {
|
||||
modal={false}
|
||||
onOpenChange={(open) => {
|
||||
setMenu("open", open)
|
||||
|
||||
if (open) setPopoverOpen(false)
|
||||
}}
|
||||
>
|
||||
@@ -372,15 +423,17 @@ export function DraftTabItem(props: {
|
||||
orientation?: "horizontal" | "vertical"
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
|
||||
const closeTab = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
props.onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(el) => {
|
||||
if (typeof props.ref === "function") props.ref(el)
|
||||
if (Predicate.isFunction(props.ref)) props.ref(el)
|
||||
}}
|
||||
data-titlebar-tab
|
||||
data-slot="titlebar-tab-item"
|
||||
@@ -412,13 +465,16 @@ export function DraftTabItem(props: {
|
||||
onMouseDown={(event) => {
|
||||
// Navigate on mousedown to shave the press-release delay off tab switches.
|
||||
if (event.button !== 0) return
|
||||
|
||||
if (props.suppressNavigation) return
|
||||
props.onNavigate()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
|
||||
// Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
|
||||
if (event.detail > 0) return
|
||||
|
||||
if (props.suppressNavigation) return
|
||||
props.onNavigate()
|
||||
}}
|
||||
|
||||
@@ -40,6 +40,7 @@ function SessionTabSlot(props: {
|
||||
return props.index
|
||||
},
|
||||
})
|
||||
|
||||
let ref!: HTMLDivElement
|
||||
|
||||
return (
|
||||
@@ -92,10 +93,12 @@ function SessionTabEntry(props: {
|
||||
const pending = createMemo(() => tabs.pendingSession(props.tab.server, props.tab.sessionId))
|
||||
const cachedSession = createMemo(() => props.serverCtx?.data.session.get(props.tab.sessionId))
|
||||
const persisted = createMemo(() => tabs.info[props.id])
|
||||
|
||||
const [loadedSession] = createResource(
|
||||
() => {
|
||||
if (pending()) return null
|
||||
const ctx = props.serverCtx
|
||||
|
||||
return ctx ? { id: props.tab.sessionId, ctx } : null
|
||||
},
|
||||
({ id, ctx }) =>
|
||||
@@ -104,6 +107,7 @@ function SessionTabEntry(props: {
|
||||
.then(() => ctx.data.session.get(id))
|
||||
.catch(() => undefined),
|
||||
)
|
||||
|
||||
const session = createMemo(() => (pending() ? undefined : (cachedSession() ?? loadedSession())))
|
||||
const missingSession = createMemo(() => !pending() && !!props.serverCtx && !loadedSession.loading && !session())
|
||||
const visible = createMemo(() => !!pending() || !!session() || missingSession() || !!persisted()?.title)
|
||||
@@ -111,14 +115,17 @@ function SessionTabEntry(props: {
|
||||
const rename = async (title: string) => {
|
||||
const value = session()
|
||||
const ctx = props.serverCtx
|
||||
|
||||
if (!value || !ctx) return
|
||||
|
||||
ctx.data.session.remember({ ...value, title })
|
||||
|
||||
try {
|
||||
await ctx.sdk.api.session.update({ sessionID: value.id, title })
|
||||
} catch (err) {
|
||||
const current = session()
|
||||
const currentCtx = props.serverCtx
|
||||
|
||||
if (current && currentCtx) currentCtx.data.session.remember({ ...current, title: value.title })
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
@@ -132,25 +139,32 @@ function SessionTabEntry(props: {
|
||||
createEffect(() => {
|
||||
const ctx = props.serverCtx
|
||||
const value = session()
|
||||
|
||||
if (!ctx || !value || props.active || ctx.sdk.connection.status() !== "connected") return
|
||||
|
||||
const timer = window.setTimeout(
|
||||
() =>
|
||||
void Promise.allSettled([
|
||||
ctx.data.session.sync(value.id, { children: true }),
|
||||
// The selected timeline loads transcript and inbox data; inactive tabs need only attention and metadata.
|
||||
// The selected timeline loads the transcript; inactive tabs need attention, metadata, and the inbox,
|
||||
// whose waiting work keeps the tab busy.
|
||||
ctx.data.session.permission.sync(value.id),
|
||||
ctx.data.session.form.sync(value.id),
|
||||
ctx.data.session.pending.sync(value.id),
|
||||
]),
|
||||
300 + props.index * 50,
|
||||
)
|
||||
|
||||
onCleanup(() => window.clearTimeout(timer))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const value = session()
|
||||
|
||||
if (!value) return
|
||||
tabs.rememberSessionInfo(props.tab, value)
|
||||
const current = sdk()
|
||||
|
||||
if (!current) return
|
||||
createTabComposerState(tabs, props.tab, current.scope, {
|
||||
dir: base64Encode(value.location.directory),
|
||||
@@ -199,6 +213,7 @@ function DraftTabSlot(props: {
|
||||
return props.index
|
||||
},
|
||||
})
|
||||
|
||||
let ref!: HTMLDivElement
|
||||
|
||||
return (
|
||||
@@ -270,6 +285,7 @@ export function TitlebarTabStrip(props: {
|
||||
const current = props.currentTab
|
||||
const key = adjacentTabKey(visibleTabIds(), current ? tabKey(current) : undefined, offset)
|
||||
const next = props.tabs.find((tab) => tabKey(tab) === key)
|
||||
|
||||
if (next) props.onNavigate(next)
|
||||
}
|
||||
|
||||
@@ -311,9 +327,12 @@ export function TitlebarTabStrip(props: {
|
||||
]}
|
||||
onDragStart={(event) => {
|
||||
const source = event.operation.source
|
||||
|
||||
if (!source) return
|
||||
const tab = props.tabs.find((item) => tabKey(item) === source.id.toString())
|
||||
|
||||
if (!tab) return
|
||||
|
||||
if (vertical()) return
|
||||
const tabEl = source.element?.querySelector<HTMLDivElement>("[data-titlebar-tab]")
|
||||
props.onNavigate(tab, tabEl ?? undefined)
|
||||
@@ -321,9 +340,11 @@ export function TitlebarTabStrip(props: {
|
||||
onDragEnd={(event) => {
|
||||
const current = visibleTabIds()
|
||||
const source = event.operation.source
|
||||
|
||||
if (event.canceled || !isSortable(source)) return
|
||||
|
||||
const { initialIndex, index } = source
|
||||
|
||||
if (initialIndex !== index) {
|
||||
props.onReorder(
|
||||
mergeVisibleTabOrder(
|
||||
@@ -348,8 +369,10 @@ export function TitlebarTabStrip(props: {
|
||||
let ref!: HTMLDivElement
|
||||
const visibleIndex = () => visibleTabs().findIndex((item) => tabKey(item) === id)
|
||||
useTabShortcut(visibleIndex, () => props.onNavigate(tab, ref))
|
||||
|
||||
const serverCtx = useServerCtx(() => {
|
||||
if (tab.type !== "session") return
|
||||
|
||||
return global.servers.list().find((item) => ServerConnection.key(item) === tab.server)
|
||||
})
|
||||
|
||||
@@ -413,7 +436,9 @@ function useTabShortcut(index: () => number, onSelect: () => void) {
|
||||
|
||||
command.register(() => {
|
||||
const number = index() + 1
|
||||
|
||||
if (number < 1 || number > 9) return []
|
||||
|
||||
return [
|
||||
{
|
||||
id: `tab.${number}`,
|
||||
|
||||
@@ -25,7 +25,8 @@ for (const factory of [createTimelineProjection, createReactiveTimelineProjectio
|
||||
test("only crosses the renderable boundary on text deltas, while retaining live content", () => {
|
||||
createRoot((dispose) => {
|
||||
let visits = 0
|
||||
const [state, setState] = createStore({
|
||||
|
||||
const [state, setState] = createStore<{ messages: SessionMessageInfo[] }>({
|
||||
messages: [
|
||||
{
|
||||
id: "old",
|
||||
@@ -33,22 +34,25 @@ for (const factory of [createTimelineProjection, createReactiveTimelineProjectio
|
||||
text: "history",
|
||||
get time() {
|
||||
visits++
|
||||
|
||||
return { created: 0 }
|
||||
},
|
||||
},
|
||||
assistant("old-answer", [{ type: "text", text: "History remains visible." }]),
|
||||
{ id: "user", type: "user", text: "question", time: { created: 1 } },
|
||||
assistant("answer", [{ type: "text", text: "" }]),
|
||||
] as SessionMessageInfo[],
|
||||
],
|
||||
})
|
||||
|
||||
const projection = factory({
|
||||
sessionMessages: () => state.messages,
|
||||
status: () => ({ type: "busy" }),
|
||||
reasoningMode: () => "compact",
|
||||
shellToolDefaultOpen: () => false,
|
||||
editToolDefaultOpen: () => false,
|
||||
pendingUserMessageIDs: () => new Set(),
|
||||
pendingInputIDs: () => new Set(),
|
||||
})
|
||||
|
||||
const update = (text: string) =>
|
||||
setState(
|
||||
"messages",
|
||||
@@ -57,6 +61,7 @@ for (const factory of [createTimelineProjection, createReactiveTimelineProjectio
|
||||
if (message.type === "assistant" && message.content[0].type === "text") message.content[0].text = text
|
||||
}),
|
||||
)
|
||||
|
||||
const empty = projection.rows()
|
||||
visits = 0
|
||||
update(" \n\t")
|
||||
@@ -82,7 +87,14 @@ for (const factory of [createTimelineProjection, createReactiveTimelineProjectio
|
||||
|
||||
test("matches full construction through grouping, notices, history and preference transitions", () => {
|
||||
createRoot((dispose) => {
|
||||
const [state, setState] = createStore({
|
||||
const [state, setState] = createStore<{
|
||||
messages: SessionMessageInfo[]
|
||||
status: SessionStatus
|
||||
reasoning: ReasoningMode
|
||||
shell: boolean
|
||||
edit: boolean
|
||||
pending: Set<string>
|
||||
}>({
|
||||
messages: [
|
||||
assistant("answer", [
|
||||
{ type: "reasoning", text: "Inspect the source", time: { created: 1 } },
|
||||
@@ -109,22 +121,25 @@ for (const factory of [createTimelineProjection, createReactiveTimelineProjectio
|
||||
time: { created: 1 },
|
||||
},
|
||||
]),
|
||||
] as SessionMessageInfo[],
|
||||
status: { type: "busy" } as SessionStatus,
|
||||
reasoning: "compact" as ReasoningMode,
|
||||
],
|
||||
status: { type: "busy" },
|
||||
reasoning: "compact",
|
||||
shell: false,
|
||||
edit: false,
|
||||
pending: new Set<string>(),
|
||||
})
|
||||
|
||||
const projection = factory({
|
||||
sessionMessages: () => state.messages,
|
||||
status: () => state.status,
|
||||
reasoningMode: () => state.reasoning,
|
||||
shellToolDefaultOpen: () => state.shell,
|
||||
editToolDefaultOpen: () => state.edit,
|
||||
pendingUserMessageIDs: () => state.pending,
|
||||
pendingInputIDs: () => state.pending,
|
||||
})
|
||||
|
||||
let previous: TimelineRow.TimelineRow[] | undefined
|
||||
|
||||
const verify = () => {
|
||||
const full = Timeline.constructSessionMessageRows(
|
||||
state.messages,
|
||||
@@ -134,6 +149,7 @@ for (const factory of [createTimelineProjection, createReactiveTimelineProjectio
|
||||
state.shell,
|
||||
state.edit,
|
||||
)
|
||||
|
||||
previous = reuseTimelineRows(previous, full.rows)
|
||||
expect(projection.rows()).toEqual(previous)
|
||||
expect(projection.activeMessageID()).toBe(full.activeMessageID)
|
||||
@@ -149,6 +165,7 @@ for (const factory of [createTimelineProjection, createReactiveTimelineProjectio
|
||||
expect(projection.rowByKey().get(TimelineRow.key(row))).toBe(projection.rows()[index])
|
||||
})
|
||||
}
|
||||
|
||||
const change = (update: (message: SessionMessageAssistant) => void) => {
|
||||
setState(
|
||||
"messages",
|
||||
@@ -159,6 +176,7 @@ for (const factory of [createTimelineProjection, createReactiveTimelineProjectio
|
||||
)
|
||||
verify()
|
||||
}
|
||||
|
||||
verify()
|
||||
change((message) => {
|
||||
if (message.content[2].type === "text") message.content[2].text = "Split the context group"
|
||||
@@ -215,6 +233,7 @@ for (const factory of [createTimelineProjection, createReactiveTimelineProjectio
|
||||
verify()
|
||||
change((message) => {
|
||||
const tool = message.content.at(-1)
|
||||
|
||||
if (tool?.type === "tool" && tool.state.status === "completed")
|
||||
tool.state.metadata = { files: [{ status: "modified" }] }
|
||||
})
|
||||
@@ -224,6 +243,7 @@ for (const factory of [createTimelineProjection, createReactiveTimelineProjectio
|
||||
expect(projection.rows().at(-1)?._tag).toBe("Thinking")
|
||||
change((message) => {
|
||||
const part = message.content.at(-1)
|
||||
|
||||
if (part?.type === "reasoning") part.time = { created: 3, completed: 4 }
|
||||
})
|
||||
change((message) => {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { $ } from "bun"
|
||||
import { mkdir, rm } from "fs/promises"
|
||||
import path from "path"
|
||||
import { discoverEffectSpecifiers } from "@opencode/plugin/runtime-modules.bun"
|
||||
import { Script } from "@opencode/script"
|
||||
import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
|
||||
import type { BunPlugin } from "bun"
|
||||
@@ -78,6 +79,28 @@ const appAssetsPlugin: BunPlugin = {
|
||||
}))
|
||||
},
|
||||
}
|
||||
const pluginRuntimeSpecifiers = [
|
||||
"@opencode/plugin",
|
||||
"@opencode/plugin/effect",
|
||||
"@opencode/plugin/effect/plugin",
|
||||
"@opencode/plugin/effect/tool",
|
||||
"@opencode/plugin/promise/plugin",
|
||||
"@opencode/plugin/promise/tool",
|
||||
"@opencode/plugin/rpc",
|
||||
...discoverEffectSpecifiers().map(([specifier]) => specifier),
|
||||
]
|
||||
const pluginRuntimeModulesSource = `const modules = {\n${pluginRuntimeSpecifiers
|
||||
.map((specifier) => ` ${JSON.stringify(specifier)}: () => require(${JSON.stringify(specifier)}),`)
|
||||
.join("\n")}\n}\nexport function pluginRuntimeModules() {\n return modules\n}\n`
|
||||
const pluginRuntimeModulesPlugin: BunPlugin = {
|
||||
name: "opencode-plugin-runtime-modules",
|
||||
setup(build) {
|
||||
build.onLoad({ filter: /plugin[/\\]src[/\\]runtime-modules\.bun\.ts$/ }, () => ({
|
||||
contents: pluginRuntimeModulesSource,
|
||||
loader: "js",
|
||||
}))
|
||||
},
|
||||
}
|
||||
|
||||
for (const item of targets) {
|
||||
const opencodePty = await resolveOpencodePty({
|
||||
@@ -124,7 +147,14 @@ export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sh
|
||||
const result = await Bun.build({
|
||||
entrypoints: ["./src/index.ts"],
|
||||
tsconfig: "./tsconfig.json",
|
||||
plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin, opencodePtyPlugin, simulationGraphPlugin],
|
||||
plugins: [
|
||||
appAssetsPlugin,
|
||||
solidPlugin,
|
||||
parcelWatcherPlugin,
|
||||
opencodePtyPlugin,
|
||||
pluginRuntimeModulesPlugin,
|
||||
simulationGraphPlugin,
|
||||
],
|
||||
external: ["node-gyp"],
|
||||
format: "esm",
|
||||
minify: true,
|
||||
|
||||
@@ -30,8 +30,9 @@ export default Runtime.handler(
|
||||
...(links[0]
|
||||
? [
|
||||
"",
|
||||
// uqr separates rows with "\n" on every platform, so splitting on EOL ("\r\n" on Windows) indents only the first row.
|
||||
renderUnicodeCompact(links[0], { border: 2 })
|
||||
.split(EOL)
|
||||
.split("\n")
|
||||
.map((line) => " " + line)
|
||||
.join(EOL),
|
||||
]
|
||||
|
||||
@@ -95,7 +95,14 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
} else timeouts = undefined
|
||||
if (service !== undefined) {
|
||||
spawnDelay = timing.spawnDelay
|
||||
const compatible = service.compatible && matchesVersion(service.version, options)
|
||||
const versionMatches = matchesVersion(service.version, options)
|
||||
const compatible = service.compatible && versionMatches
|
||||
if (!service.compatible && versionMatches)
|
||||
return yield* Effect.fail(
|
||||
new Error(
|
||||
"Background service uses an incompatible health protocol. Update this client or explicitly restart the service.",
|
||||
),
|
||||
)
|
||||
if (compatible && service.state === "ready") {
|
||||
yield* Effect.tryPromise(() => PtyHandoff.complete(options.file ?? fallback(), service.info))
|
||||
return Option.some(service)
|
||||
@@ -205,10 +212,7 @@ const probe = Effect.fnUntraced(function* (info: Info) {
|
||||
return (yield* probeResult(info)).service
|
||||
})
|
||||
|
||||
const probeResult = Effect.fnUntraced(function* (
|
||||
info: Info,
|
||||
timeout = defaultEnsureTiming.requestTimeout,
|
||||
) {
|
||||
const probeResult = Effect.fnUntraced(function* (info: Info, timeout = defaultEnsureTiming.requestTimeout) {
|
||||
const endpoint = {
|
||||
url: info.url,
|
||||
auth:
|
||||
@@ -230,8 +234,8 @@ const probeResult = Effect.fnUntraced(function* (
|
||||
)
|
||||
if ("cause" in result) return { service: undefined, timedOut: signal.aborted }
|
||||
const response = result.value.response
|
||||
// The previous V2 service exposes /api/status instead. Its authenticated 404 is enough
|
||||
// to recognize the registered daemon as incompatible and route it through replacement.
|
||||
// A missing health endpoint identifies protocol incompatibility, not an older
|
||||
// version. Only an unmet version requirement lets ensure replace this owner.
|
||||
if (response.status === 404)
|
||||
return {
|
||||
service: {
|
||||
|
||||
@@ -76,7 +76,12 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
if (registration.service !== undefined) {
|
||||
spawnDelay = timing.spawnDelay
|
||||
const service = registration.service
|
||||
const compatible = service.compatible && matchesVersion(service.version, options)
|
||||
const versionMatches = matchesVersion(service.version, options)
|
||||
const compatible = service.compatible && versionMatches
|
||||
if (!service.compatible && versionMatches)
|
||||
throw new Error(
|
||||
"Background service uses an incompatible health protocol. Update this client or explicitly restart the service.",
|
||||
)
|
||||
if (compatible && service.state === "ready") {
|
||||
await PtyHandoff.complete(options.file ?? fallback(), service.info)
|
||||
return service.endpoint
|
||||
@@ -177,8 +182,8 @@ async function probeResult(info: Info, timeout = defaultEnsureTiming.requestTime
|
||||
)
|
||||
if ("cause" in result) return { service: undefined, timedOut: signal.aborted }
|
||||
const response = result.value.response
|
||||
// The previous V2 service exposes /api/status instead. Its authenticated 404 is enough
|
||||
// to recognize the registered daemon as incompatible and route it through replacement.
|
||||
// A missing health endpoint identifies protocol incompatibility, not an older
|
||||
// version. Only an unmet version requirement lets ensure replace this owner.
|
||||
if (response.status === 404)
|
||||
return {
|
||||
service: {
|
||||
|
||||
@@ -44,8 +44,12 @@ const handoff = {
|
||||
}
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(request) {
|
||||
async fetch(request): Promise<Response> {
|
||||
const pathname = new URL(request.url).pathname
|
||||
if (mode === "protocol" && pathname === "/api/experimental/persistent-pty/handoff") {
|
||||
await writeFile(registration + ".handoff-request", "")
|
||||
return Response.json({ handoff: null })
|
||||
}
|
||||
if (pathname === "/api/experimental/persistent-pty/handoff" && mode === "handoff") {
|
||||
if (request.method !== "POST" || request.headers.get("authorization") !== "Basic " + btoa("opencode:private"))
|
||||
return new Response(null, { status: 401 })
|
||||
@@ -55,6 +59,8 @@ const server = Bun.serve({
|
||||
if (pathname === "/api/experimental/persistent-pty/handoff" && mode === "handoff-broken")
|
||||
return new Response(null, { status: 500 })
|
||||
if (pathname !== "/api/info") return new Response(null, { status: 404 })
|
||||
if (mode === "protocol" && (await Bun.file(registration + ".missing-health").exists()))
|
||||
return new Response(null, { status: 404 })
|
||||
requests += 1
|
||||
if (mode === "starting") await writeFile(registration + ".status-request", "")
|
||||
if (mode === "hanging") {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Service } from "../src/effect/service"
|
||||
import { ensure, stop } from "../src/promise/service"
|
||||
import type { EnsureOptions, EnsureReason, StopOptions } from "../src/service"
|
||||
import { serviceFixture } from "./fixture/service-fixture"
|
||||
import { accelerate } from "./fixture/service-timing"
|
||||
|
||||
const effect = accelerate(Service.ensure)
|
||||
const clients = [
|
||||
{ name: "Promise", ensure: accelerate(ensure), stop },
|
||||
{
|
||||
name: "Effect",
|
||||
ensure: (options: EnsureOptions) => Effect.runPromise(effect(options).pipe(Effect.provide(NodeFileSystem.layer))),
|
||||
stop: (options: StopOptions) => Effect.runPromise(Service.stop(options).pipe(Effect.provide(NodeFileSystem.layer))),
|
||||
},
|
||||
]
|
||||
|
||||
for (const client of clients) {
|
||||
for (const policy of [
|
||||
{ name: "no version requirement", version: undefined },
|
||||
{ name: "matching exact version", version: "test" },
|
||||
{ name: "accepting version predicate", version: (version: string) => version === "test" },
|
||||
]) {
|
||||
test(`${client.name} reconnect preserves a protocol-incompatible owner with ${policy.name}`, async () => {
|
||||
await using fixture = await serviceFixture()
|
||||
const owner = fixture.spawn("protocol")
|
||||
await fixture.waitForFile()
|
||||
const original = await Bun.file(fixture.registration).json()
|
||||
const starts: EnsureReason[] = []
|
||||
const options = {
|
||||
file: fixture.registration,
|
||||
version: policy.version,
|
||||
command: fixture.command("record-start"),
|
||||
onStart: (reason: EnsureReason) => starts.push(reason),
|
||||
}
|
||||
expect((await client.ensure(options)).url).toBe(original.url)
|
||||
|
||||
// The same process remains alive. Only its health endpoint becomes unavailable.
|
||||
await Bun.write(fixture.registration + ".missing-health", "")
|
||||
const error = await client.ensure(options).catch((error: unknown) => error)
|
||||
expect(await Bun.file(fixture.registration + ".signal").exists()).toBe(false)
|
||||
expect(await Bun.file(fixture.registration + ".handoff-request").exists()).toBe(false)
|
||||
expect(await Bun.file(fixture.registration + ".started").exists()).toBe(false)
|
||||
expect(starts).toEqual([])
|
||||
expect(owner.exitCode).toBe(null)
|
||||
expect(await Bun.file(fixture.registration).json()).toEqual(original)
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
if (!(error instanceof Error)) throw error
|
||||
expect(error.message).toContain("incompatible health protocol")
|
||||
|
||||
await Bun.file(fixture.registration + ".missing-health").delete()
|
||||
expect((await client.ensure(options)).url).toBe(original.url)
|
||||
expect(owner.exitCode).toBe(null)
|
||||
})
|
||||
}
|
||||
|
||||
test(`${client.name} can explicitly replace a protocol-incompatible owner with a nonmatching version`, async () => {
|
||||
await using fixture = await serviceFixture()
|
||||
const owner = fixture.spawn("protocol")
|
||||
await fixture.waitForFile()
|
||||
await Bun.write(fixture.registration + ".missing-health", "")
|
||||
const starts: EnsureReason[] = []
|
||||
const endpoint = await client.ensure({
|
||||
file: fixture.registration,
|
||||
version: "2.1.0-next.1",
|
||||
command: fixture.command("compatible"),
|
||||
onStart: (reason) => starts.push(reason),
|
||||
})
|
||||
const replacement = await Bun.file(fixture.registration).json()
|
||||
fixture.track(replacement.pid)
|
||||
|
||||
expect(await owner.exited).toBe(0)
|
||||
expect(await Bun.file(fixture.registration + ".signal").text()).toBe("SIGTERM")
|
||||
expect(await Bun.file(fixture.registration + ".handoff-request").exists()).toBe(true)
|
||||
expect(starts).toEqual(["version-mismatch"])
|
||||
expect(replacement.pid).not.toBe(owner.pid)
|
||||
expect(replacement.version).toBe("2.1.0-next.1")
|
||||
expect(endpoint.url).toBe(replacement.url)
|
||||
})
|
||||
|
||||
test(`${client.name} can explicitly stop a protocol-incompatible owner`, async () => {
|
||||
await using fixture = await serviceFixture()
|
||||
const owner = fixture.spawn("protocol")
|
||||
await fixture.waitForFile()
|
||||
await Bun.write(fixture.registration + ".missing-health", "")
|
||||
|
||||
await client.stop({ file: fixture.registration })
|
||||
expect(await owner.exited).toBe(0)
|
||||
expect(await Bun.file(fixture.registration + ".signal").text()).toBe("SIGTERM")
|
||||
expect(await Bun.file(fixture.registration).exists()).toBe(false)
|
||||
})
|
||||
}
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"deepinfra":{"id":"deepinfra","env":["DEEPINFRA_API_KEY"],"npm":"@ai-sdk/deepinfra","name":"Deep Infra","doc":"https://deepinfra.com/models","models":{"tencent/Hy3":{"id":"tencent/Hy3","name":"Hy3","description":"Tencent Hy reasoning model for coding, instruction following, and agent tasks","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-07-06","last_updated":"2026-07-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":262144,"input":192000,"output":128000},"cost":{"input":0.13,"output":0.53,"cache_read":0.033},"canonical_model_id":"tencent/hy3"},"tencent/Hy4-preview":{"id":"tencent/Hy4-preview","name":"Hy4 preview","description":"A next-generation productivity model with significantly enhanced Agent and complex task execution capabilities.","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","high"]}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-08-28","last_updated":"2026-08-28","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":64000},"cost":{"input":0.834,"output":2.501,"cache_read":0.042},"canonical_model_id":"tencent/hy4-preview"},"meta-llama/Llama-3.3-70B-Instruct-Turbo":{"id":"meta-llama/Llama-3.3-70B-Instruct-Turbo","name":"Llama 3.3 70B Turbo","description":"Compact Llama instruction model for fast chat and local deployment","family":"llama","attachment":false,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2024-12-06","last_updated":"2024-12-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":131072,"output":16384},"cost":{"input":0.1,"output":0.32}},"meta-llama/Llama-4-Scout-17B-16E-Instruct":{"id":"meta-llama/Llama-4-Scout-17B-16E-Instruct","name":"Llama 4 Scout 17B","description":"Open multimodal Llama model for long-context analysis and efficient agents","family":"llama","attachment":true,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":327680,"output":16384},"cost":{"input":0.1,"output":0.3}},"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8":{"id":"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8","name":"Llama 4 Maverick 17B FP8","description":"Open multimodal Llama model for strong reasoning and fast responses","family":"llama","attachment":true,"reasoning":false,"tool_call":false,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"status":"deprecated","cost":{"input":0.2,"output":0.8}},"XiaomiMiMo/MiMo-V2.6-Pro":{"id":"XiaomiMiMo/MiMo-V2.6-Pro","name":"MiMo-V2.6-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.43,"output":0.87,"cache_read":0.0036},"canonical_model_id":"xiaomi/mimo-v2.6-pro"},"XiaomiMiMo/MiMo-V2.5-Pro":{"id":"XiaomiMiMo/MiMo-V2.5-Pro","name":"MiMo-V2.5-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"interleaved":{"field":"reasoning_content"},"structured_output":true,"temperature":true,"knowledge":"2024-12","release_date":"2026-04-22","last_updated":"2026-04-22","modalities":{"input":["text","audio"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"status":"deprecated","cost":{"input":1,"output":3,"cache_read":0.2},"canonical_model_id":"xiaomi/mimo-v2.5-pro"},"XiaomiMiMo/MiMo-V2.6-Flash":{"id":"XiaomiMiMo/MiMo-V2.6-Flash","name":"MiMo-V2.6-Flash","description":"MiMo Flash model for multimodal coding agents and long-context automation","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.14,"output":0.28,"cache_read":0.0028},"canonical_model_id":"xiaomi/mimo-v2.6-flash"},"XiaomiMiMo/MiMo-V2.5":{"id":"XiaomiMiMo/MiMo-V2.5","name":"MiMo-V2.5","description":"Open MiMo model for multimodal coding agents and long-coLine truncated
|
||||
{"deepinfra":{"id":"deepinfra","env":["DEEPINFRA_API_KEY"],"npm":"@ai-sdk/deepinfra","name":"Deep Infra","doc":"https://deepinfra.com/models","models":{"tencent/Hy3":{"id":"tencent/Hy3","name":"Hy3","description":"Tencent Hy reasoning model for coding, instruction following, and agent tasks","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-07-06","last_updated":"2026-07-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":262144,"input":192000,"output":128000},"cost":{"input":0.13,"output":0.53,"cache_read":0.033},"canonical_model_id":"tencent/hy3"},"tencent/Hy4-preview":{"id":"tencent/Hy4-preview","name":"Hy4 preview","description":"A next-generation productivity model with significantly enhanced Agent and complex task execution capabilities.","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","high"]}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-08-28","last_updated":"2026-08-28","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":64000},"cost":{"input":0.834,"output":2.501,"cache_read":0.042},"canonical_model_id":"tencent/hy4-preview"},"meta-llama/Llama-3.3-70B-Instruct-Turbo":{"id":"meta-llama/Llama-3.3-70B-Instruct-Turbo","name":"Llama 3.3 70B Turbo","description":"Compact Llama instruction model for fast chat and local deployment","family":"llama","attachment":false,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2024-12-06","last_updated":"2024-12-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":131072,"output":16384},"cost":{"input":0.1,"output":0.32}},"meta-llama/Llama-4-Scout-17B-16E-Instruct":{"id":"meta-llama/Llama-4-Scout-17B-16E-Instruct","name":"Llama 4 Scout 17B","description":"Open multimodal Llama model for long-context analysis and efficient agents","family":"llama","attachment":true,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":327680,"output":16384},"cost":{"input":0.1,"output":0.3}},"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8":{"id":"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8","name":"Llama 4 Maverick 17B FP8","description":"Open multimodal Llama model for strong reasoning and fast responses","family":"llama","attachment":true,"reasoning":false,"tool_call":false,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"status":"deprecated","cost":{"input":0.2,"output":0.8}},"XiaomiMiMo/MiMo-V2.6-Pro":{"id":"XiaomiMiMo/MiMo-V2.6-Pro","name":"MiMo-V2.6-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.43,"output":0.87,"cache_read":0.0036},"canonical_model_id":"xiaomi/mimo-v2.6-pro"},"XiaomiMiMo/MiMo-V2.5-Pro":{"id":"XiaomiMiMo/MiMo-V2.5-Pro","name":"MiMo-V2.5-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"interleaved":{"field":"reasoning_content"},"structured_output":true,"temperature":true,"knowledge":"2024-12","release_date":"2026-04-22","last_updated":"2026-04-22","modalities":{"input":["text","audio"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"status":"deprecated","cost":{"input":1,"output":3,"cache_read":0.2},"canonical_model_id":"xiaomi/mimo-v2.5-pro"},"XiaomiMiMo/MiMo-V2.6-Flash":{"id":"XiaomiMiMo/MiMo-V2.6-Flash","name":"MiMo-V2.6-Flash","description":"MiMo Flash model for multimodal coding agents and long-context automation","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.14,"output":0.28,"cache_read":0.0028},"canonical_model_id":"xiaomi/mimo-v2.6-flash"},"XiaomiMiMo/MiMo-V2.5":{"id":"XiaomiMiMo/MiMo-V2.5","name":"MiMo-V2.5","description":"Open MiMo model for multimodal coding agents and long-coLine truncated
|
||||
@@ -61,9 +61,17 @@ const layer = Layer.effect(
|
||||
release: holdUnsafe(),
|
||||
})
|
||||
})
|
||||
const exit = yield* Effect.suspend(() =>
|
||||
plugin.effect({ ...host, storage: PluginHost.storage(kv, plugin.id) }),
|
||||
).pipe(
|
||||
const exit = yield* Effect.suspend(() => {
|
||||
const effect = plugin.effect({ ...host, storage: PluginHost.storage(kv, plugin.id) })
|
||||
if (!Effect.isEffect(effect) || effect.pipe !== Effect.void.pipe) {
|
||||
return Effect.die(
|
||||
new Error(
|
||||
`Plugin "${plugin.id}" returned an Effect from a bundled copy of effect. Declare "effect" as a peerDependency and do not bundle it.`,
|
||||
),
|
||||
)
|
||||
}
|
||||
return effect
|
||||
}).pipe(
|
||||
grouped,
|
||||
inherit,
|
||||
Effect.updateContext((context: Context.Context<never>) =>
|
||||
|
||||
@@ -128,6 +128,17 @@ const load = Effect.fn("PluginModule.load")(function* (
|
||||
target: operation.target,
|
||||
...(installed?.version ? { version: installed.version } : {}),
|
||||
},
|
||||
effect: (host) => plugin.effect({ ...host, options: operation.options }),
|
||||
effect: (host) =>
|
||||
Effect.suspend(() => {
|
||||
const effect = plugin.effect({ ...host, options: operation.options })
|
||||
if (!Effect.isEffect(effect) || effect.pipe !== Effect.void.pipe) {
|
||||
return Effect.die(
|
||||
new Error(
|
||||
`Plugin "${plugin.id}" returned an Effect from a bundled copy of effect. Declare "effect" as a peerDependency and do not bundle it.`,
|
||||
),
|
||||
)
|
||||
}
|
||||
return effect
|
||||
}),
|
||||
} satisfies Generation
|
||||
})
|
||||
@@ -1,17 +1,27 @@
|
||||
import { expect } from "bun:test"
|
||||
import { cp, mkdir, readFile, writeFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { Deferred, Effect, Exit, Fiber, Layer, Schedule, Scope, Stream } from "effect"
|
||||
import { Brand, Deferred, Effect, Exit, Fiber, Layer, Option, Schedule, Schema, Scope, Stream } from "effect"
|
||||
import { Agent } from "@opencode/schema/agent"
|
||||
import { Session } from "@opencode/schema/session"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
|
||||
import { Watcher } from "@opencode/core/filesystem/watcher"
|
||||
import { Plugin } from "@opencode/core/plugin"
|
||||
import { PluginModule } from "@opencode/core/plugin/module"
|
||||
import { Rpc } from "@opencode/core/rpc"
|
||||
import { Tool } from "@opencode/core/tool"
|
||||
import { execute } from "@opencode/core/tool/runtime"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { Npm } from "@opencode/util/npm"
|
||||
import { tempGlobalLayer } from "../fixture/global"
|
||||
import { tmpdirScoped } from "../fixture/tmpdir"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(
|
||||
Layer.merge(
|
||||
Layer.mergeAll(
|
||||
PluginTestLayer,
|
||||
AppNodeBuilder.build(Npm.node, [Global.node.replace(tempGlobalLayer)]),
|
||||
Watcher.layer().pipe(Layer.provide(Watcher.nativeLayer)),
|
||||
),
|
||||
@@ -90,3 +100,252 @@ it.live("interrupts pending watcher setup when the loader scope closes during mo
|
||||
yield* Effect.promise(() => Bun.sleep(50))
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("loads plugins and their transitive dependencies against the host's Effect instance", () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* tmpdirScoped()
|
||||
const pluginDir = path.join(directory.path, "plugin")
|
||||
const pluginEffectDir = path.join(pluginDir, "node_modules/effect")
|
||||
const hostEffectDir = path.dirname(Bun.resolveSync("effect/package.json", import.meta.dir))
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
await mkdir(path.join(pluginDir, "node_modules"), { recursive: true })
|
||||
await cp(hostEffectDir, pluginEffectDir, { recursive: true })
|
||||
|
||||
const pkgPath = path.join(pluginEffectDir, "package.json")
|
||||
const pkg = { ...(await Bun.file(pkgPath).json()), version: "4.0.0-rc.111" }
|
||||
await writeFile(pkgPath, JSON.stringify(pkg, null, 2))
|
||||
|
||||
// Reproduce the version-skew failure modes on the plugin's own Effect copy so loading it would crash:
|
||||
// 1. Effect.log reading an incompatible fiber log-level property (crashing host logger with logLevel.toUpperCase)
|
||||
// 2. Effect.runPromise calling fiber.succeedWith on a host fiber
|
||||
// 3. Schema.withDecodingDefault / Schema.Int / Schema.isPattern / Schema.Trim using foreign parser sentinels
|
||||
const internalEffectPath = path.join(pluginEffectDir, "dist/internal/effect.js")
|
||||
const internalEffect = (await readFile(internalEffectPath, "utf8"))
|
||||
.replace(
|
||||
"const logLevel = level ?? fiber.currentLogLevel;\n if (isLogLevelGreaterThan(fiber.minimumLogLevel, logLevel)) {",
|
||||
"const logLevel = level ?? fiber.cache?.logLevel;\n if (isLogLevelGreaterThan(fiber.cache?.minimumLogLevel, logLevel)) {",
|
||||
)
|
||||
.replace(
|
||||
"export const runPromiseWith = context => {",
|
||||
"export const runPromiseWith = context => {\n return (effect) => Promise.resolve().then(() => { const fiber = {}; return fiber.succeedWith(effect); });",
|
||||
)
|
||||
await writeFile(internalEffectPath, internalEffect)
|
||||
|
||||
const depDir = path.join(pluginDir, "node_modules/transitive-dep")
|
||||
await mkdir(depDir, { recursive: true })
|
||||
await writeFile(
|
||||
path.join(depDir, "package.json"),
|
||||
JSON.stringify({ name: "transitive-dep", type: "module", exports: { ".": "./index.js" } }),
|
||||
)
|
||||
await writeFile(
|
||||
path.join(depDir, "index.js"),
|
||||
`import { Effect as DepEffect, Schema as DepSchema } from "effect"
|
||||
import { some as depSome } from "effect/Option"
|
||||
export const depToolInput = DepSchema.Struct({
|
||||
mode: DepSchema.String.pipe(DepSchema.withDecodingDefault(DepEffect.succeed("from-dep"))),
|
||||
})
|
||||
export { DepEffect, DepSchema, depSome }`,
|
||||
)
|
||||
|
||||
const foreignPluginPkgDir = path.join(pluginDir, "node_modules/@opencode/plugin")
|
||||
await mkdir(foreignPluginPkgDir, { recursive: true })
|
||||
await writeFile(
|
||||
path.join(foreignPluginPkgDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "@opencode/plugin",
|
||||
type: "module",
|
||||
exports: { "./effect": "./effect.js", "./rpc": "./rpc.js" },
|
||||
}),
|
||||
)
|
||||
await writeFile(
|
||||
path.join(foreignPluginPkgDir, "effect.js"),
|
||||
"export const Plugin = { define: (p) => p }",
|
||||
)
|
||||
await writeFile(path.join(foreignPluginPkgDir, "rpc.js"), "export const Rpc = { define: (d) => d }")
|
||||
|
||||
await writeFile(
|
||||
path.join(pluginDir, "index.ts"),
|
||||
`import { Plugin } from "@opencode/plugin/effect"
|
||||
import { Rpc } from "@opencode/plugin/rpc"
|
||||
import { Effect as PluginEffect, Schema as PluginSchema } from "effect"
|
||||
import { some as pluginSome } from "effect/Option"
|
||||
import { nominal as pluginNominal } from "effect/Brand"
|
||||
import { DepEffect, DepSchema, depSome, depToolInput } from "transitive-dep"
|
||||
|
||||
export const captured = {
|
||||
PluginEffect,
|
||||
PluginSchema,
|
||||
pluginSome,
|
||||
pluginNominal,
|
||||
DepEffect,
|
||||
DepSchema,
|
||||
depSome,
|
||||
pluginCount: -1,
|
||||
}
|
||||
|
||||
const Contract = Rpc.define({
|
||||
id: "host-effect-rpc",
|
||||
methods: {
|
||||
check: {
|
||||
input: PluginSchema.Struct({
|
||||
count: PluginSchema.Int.pipe(PluginSchema.withDecodingDefault(PluginEffect.succeed(5))),
|
||||
tag: PluginSchema.Trim.check(PluginSchema.isPattern(/^v[0-9]+$/)),
|
||||
}),
|
||||
output: PluginSchema.Struct({
|
||||
value: PluginSchema.String,
|
||||
}),
|
||||
},
|
||||
},
|
||||
events: {},
|
||||
})
|
||||
|
||||
export default Plugin.define({
|
||||
id: "host-effect-fixture",
|
||||
effect: (ctx) =>
|
||||
PluginEffect.gen(function* () {
|
||||
yield* PluginEffect.log("setup log from plugin")
|
||||
const listed = yield* PluginEffect.promise(() =>
|
||||
PluginEffect.runPromise(ctx.plugin.list().pipe(PluginEffect.orDie)),
|
||||
)
|
||||
captured.pluginCount = listed.data.length
|
||||
|
||||
yield* ctx.tool.transform((editor) => {
|
||||
editor.add({
|
||||
name: "default_tool",
|
||||
description: "Tool with decoding default from transitive dependency",
|
||||
input: depToolInput,
|
||||
output: PluginSchema.Struct({ mode: PluginSchema.String }),
|
||||
execute: ({ mode }) =>
|
||||
PluginEffect.log("executing default_tool").pipe(
|
||||
PluginEffect.as({ output: { mode }, content: mode }),
|
||||
),
|
||||
})
|
||||
editor.add({
|
||||
name: "check_tool",
|
||||
description: "Tool with Int, Trim, and isPattern checks",
|
||||
input: PluginSchema.Struct({
|
||||
count: PluginSchema.Int,
|
||||
code: PluginSchema.Trim.check(PluginSchema.isPattern(/^v[0-9]+$/)),
|
||||
}),
|
||||
output: PluginSchema.Struct({ formatted: PluginSchema.String }),
|
||||
execute: ({ count, code }) =>
|
||||
PluginEffect.succeed({
|
||||
output: { formatted: \`\${code}:\${count}\` },
|
||||
content: \`\${code}:\${count}\`,
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
yield* ctx.rpc.register(Contract, {
|
||||
check: ({ count, tag }) =>
|
||||
PluginEffect.log("executing rpc check").pipe(
|
||||
PluginEffect.as({ value: \`\${tag}#\${count}\` }),
|
||||
),
|
||||
}).pipe(PluginEffect.orDie)
|
||||
}),
|
||||
})`,
|
||||
)
|
||||
})
|
||||
|
||||
const modules = yield* PluginModule.make()
|
||||
const plugins = yield* Plugin.Service
|
||||
const tools = yield* Tool.Service
|
||||
const rpc = yield* Rpc.Service
|
||||
|
||||
const definition = yield* modules.load({ type: "add", target: pluginDir, options: {} })
|
||||
if ("pending" in definition) return yield* Effect.die(new Error("Local plugin was not loaded"))
|
||||
yield* plugins.activate([definition])
|
||||
yield* plugins.awaitActivation
|
||||
|
||||
expect(yield* plugins.list()).toMatchObject([{ id: "host-effect-fixture", state: { status: "active" } }])
|
||||
|
||||
const imported = (yield* Effect.promise(() => import(path.join(pluginDir, "index.ts")))) as {
|
||||
captured: {
|
||||
PluginEffect: unknown
|
||||
PluginSchema: unknown
|
||||
pluginSome: unknown
|
||||
pluginNominal: unknown
|
||||
DepEffect: unknown
|
||||
DepSchema: unknown
|
||||
depSome: unknown
|
||||
pluginCount: number
|
||||
}
|
||||
}
|
||||
expect(imported.captured.PluginEffect).toBe(Effect)
|
||||
expect(imported.captured.PluginSchema).toBe(Schema)
|
||||
expect(imported.captured.pluginSome).toBe(Option.some)
|
||||
expect(imported.captured.pluginNominal).toBe(Brand.nominal)
|
||||
expect(imported.captured.DepEffect).toBe(Effect)
|
||||
expect(imported.captured.DepSchema).toBe(Schema)
|
||||
expect(imported.captured.depSome).toBe(Option.some)
|
||||
expect(imported.captured.pluginCount).toBe(0)
|
||||
|
||||
const registeredTools = yield* tools.list()
|
||||
const defaultTool = registeredTools.find((tool) => tool.id === "default_tool")
|
||||
const checkTool = registeredTools.find((tool) => tool.id === "check_tool")
|
||||
expect(defaultTool).toBeDefined()
|
||||
expect(checkTool).toBeDefined()
|
||||
if (!defaultTool || !checkTool) return
|
||||
|
||||
const context = {
|
||||
sessionID: Session.ID.make("ses_host_effect"),
|
||||
agent: Agent.ID.make("build"),
|
||||
messageID: SessionMessage.ID.make("msg_host_effect"),
|
||||
id: Tool.CallID.make("call_host_effect"),
|
||||
progress: () => Effect.void,
|
||||
}
|
||||
|
||||
expect(yield* execute(defaultTool, {}, context)).toEqual({
|
||||
output: { mode: "from-dep" },
|
||||
content: [{ type: "text", text: "from-dep" }],
|
||||
})
|
||||
expect(yield* execute(checkTool, { count: 3, code: " v42 " }, context)).toEqual({
|
||||
output: { formatted: "v42:3" },
|
||||
content: [{ type: "text", text: "v42:3" }],
|
||||
})
|
||||
expect(yield* rpc.call("host-effect-rpc", "check", { tag: " v9 " })).toEqual({ value: "v9#5" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("fails activation when a plugin returns an Effect from a bundled copy of effect", () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* tmpdirScoped()
|
||||
const pluginDir = path.join(directory.path, "bundled-plugin")
|
||||
const bundledEffectDir = path.join(pluginDir, "bundled-effect")
|
||||
const hostEffectDir = path.dirname(Bun.resolveSync("effect/package.json", import.meta.dir))
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
await mkdir(pluginDir, { recursive: true })
|
||||
await cp(hostEffectDir, bundledEffectDir, { recursive: true })
|
||||
await writeFile(
|
||||
path.join(pluginDir, "index.ts"),
|
||||
`import { Effect as BundledEffect } from "./bundled-effect/dist/index.js"
|
||||
export default {
|
||||
id: "bundled-effect-plugin",
|
||||
effect: () => BundledEffect.void,
|
||||
}`,
|
||||
)
|
||||
})
|
||||
|
||||
const modules = yield* PluginModule.make()
|
||||
const plugins = yield* Plugin.Service
|
||||
|
||||
const definition = yield* modules.load({ type: "add", target: pluginDir, options: {} })
|
||||
if ("pending" in definition) return yield* Effect.die(new Error("Local plugin was not loaded"))
|
||||
yield* plugins.activate([definition])
|
||||
yield* plugins.awaitActivation
|
||||
|
||||
expect(yield* plugins.list()).toMatchObject([
|
||||
{
|
||||
id: "bundled-effect-plugin",
|
||||
state: {
|
||||
status: "failed",
|
||||
error: expect.stringContaining(
|
||||
'Plugin "bundled-effect-plugin" returned an Effect from a bundled copy of effect. Declare "effect" as a peerDependency and do not bundle it.',
|
||||
),
|
||||
},
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -13,15 +13,19 @@ export function createPickedFileAuthorizations(
|
||||
add(sender: number, paths: string[]) {
|
||||
const token = randomUUID()
|
||||
selections.set(token, { sender, paths: new Set(paths), remaining: MAX_ATTACHMENT_BYTES })
|
||||
|
||||
return token
|
||||
},
|
||||
read: Effect.fn("DesktopFiles.readPickedFile")(function* (sender: number, token: string, path: string) {
|
||||
const selection = selections.get(token)
|
||||
|
||||
if (selection?.sender !== sender || !selection.paths.delete(path))
|
||||
throw new Error(nativeT("desktop.picker.error.notSelected"))
|
||||
const bytes = yield* read(path, selection.remaining)
|
||||
selection.remaining -= bytes.byteLength
|
||||
|
||||
if (selection.paths.size === 0) selections.delete(token)
|
||||
|
||||
return bytes
|
||||
}),
|
||||
release(sender: number, token: string) {
|
||||
@@ -32,6 +36,7 @@ export function createPickedFileAuthorizations(
|
||||
|
||||
export function assertAttachmentBudget(files: { size: number }[]) {
|
||||
const total = files.reduce((sum, file) => sum + file.size, 0)
|
||||
|
||||
if (total <= MAX_ATTACHMENT_BYTES) return
|
||||
throw new Error(nativeT("desktop.picker.error.sizeLimit", { limit: MAX_ATTACHMENT_BYTES / 1024 / 1024 }))
|
||||
}
|
||||
@@ -42,16 +47,20 @@ export function readAttachment(filePath: string, maxBytes = MAX_ATTACHMENT_BYTES
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const file = yield* fs.open(filePath, { flag: "r" })
|
||||
const info = yield* file.stat
|
||||
|
||||
if (info.size > FileSystem.Size(maxBytes))
|
||||
throw new Error(nativeT("desktop.picker.error.sizeLimit", { limit: MAX_ATTACHMENT_BYTES / 1024 / 1024 }))
|
||||
|
||||
const bytes = new Uint8Array(Number(info.size))
|
||||
let offset = 0
|
||||
|
||||
while (offset < bytes.byteLength) {
|
||||
const read = Number(yield* file.read(bytes.subarray(offset)))
|
||||
|
||||
if (read === 0) break
|
||||
offset += read
|
||||
}
|
||||
|
||||
return bytes.buffer.slice(0, offset)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
|
||||
import { EventEmitter } from "node:events"
|
||||
import { MessageChannel } from "node:worker_threads"
|
||||
import type { MessagePortMain, WebContents } from "electron"
|
||||
import { Effect, Layer, ManagedRuntime, Schema, Stream } from "effect"
|
||||
import { Effect, Layer, ManagedRuntime, Predicate, Schema, Stream } from "effect"
|
||||
import { Rpc, RpcGroup, RpcMessage, RpcServer } from "effect/unstable/rpc"
|
||||
import { Transferable } from "effect/unstable/workers"
|
||||
import { omitUndefined } from "../shared/ipc-transport"
|
||||
@@ -13,13 +13,16 @@ describe("desktop RPC transport", () => {
|
||||
test("decodes renderer payloads whose optional fields are undefined", async () => {
|
||||
let received: unknown
|
||||
const rpcs = RpcGroup.make(FilesOpenFilePicker)
|
||||
|
||||
const handlers = rpcs.toLayer({
|
||||
FilesOpenFilePicker: ({ options }) =>
|
||||
Effect.sync(() => {
|
||||
received = options
|
||||
|
||||
return null
|
||||
}),
|
||||
})
|
||||
|
||||
const live = RpcServer.layer(rpcs).pipe(Layer.provide(handlers), Layer.provideMerge(IpcServerProtocolLive))
|
||||
const runtime = ManagedRuntime.make(live)
|
||||
const handoff = await runtime.runPromise(IpcPortHandoff)
|
||||
@@ -30,13 +33,10 @@ describe("desktop RPC transport", () => {
|
||||
|
||||
// Structured clone keeps a present-but-undefined key, so it reaches the JSON codec.
|
||||
const rejected = await call(channel.port2, 0, "FilesOpenFilePicker", payload)
|
||||
expect(rejected.exit).toMatchObject({
|
||||
_tag: "Failure",
|
||||
cause: [{ _tag: "Die", defect: expect.stringContaining('["options"]["title"]') }],
|
||||
})
|
||||
expect(rejected.exit).toMatchObject(died(expect.stringContaining('["options"]["title"]')))
|
||||
|
||||
const accepted = await call(channel.port2, 1, "FilesOpenFilePicker", omitUndefined(payload))
|
||||
expect(accepted.exit).toEqual({ _tag: "Success", value: null })
|
||||
expect(accepted.exit).toEqual(success(null))
|
||||
expect(received).toEqual({ multiple: true, defaultPath: "C:\\project" })
|
||||
|
||||
channel.port2.close()
|
||||
@@ -56,13 +56,16 @@ describe("desktop RPC transport", () => {
|
||||
|
||||
test("keeps multiple renderer ports independent", async () => {
|
||||
let received: unknown
|
||||
|
||||
const handlers = TestRpcs.toLayer(
|
||||
Effect.gen(function* () {
|
||||
const handoff = yield* IpcPortHandoff
|
||||
|
||||
return TestRpcs.of({
|
||||
"test.focused": (_request, context) => Effect.succeed(handoff.sender(context.client.id)?.id === 1),
|
||||
"test.blob.put": ({ data }) => {
|
||||
received = data
|
||||
|
||||
return Effect.succeed([...data].join(","))
|
||||
},
|
||||
"test.blob.get": () => Effect.succeed(new Uint8Array([3, 1, 4])),
|
||||
@@ -70,6 +73,7 @@ describe("desktop RPC transport", () => {
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
const live = RpcServer.layer(TestRpcs).pipe(Layer.provide(handlers), Layer.provideMerge(IpcServerProtocolLive))
|
||||
const runtime = ManagedRuntime.make(live)
|
||||
const handoff = await runtime.runPromise(IpcPortHandoff)
|
||||
@@ -82,34 +86,36 @@ describe("desktop RPC transport", () => {
|
||||
call(first.port2, 0, "test.focused", null),
|
||||
call(second.port2, 0, "test.focused", null),
|
||||
])
|
||||
expect(focused.exit).toEqual({ _tag: "Success", value: true })
|
||||
expect(unfocused.exit).toEqual({ _tag: "Success", value: false })
|
||||
|
||||
expect(focused.exit).toEqual(success(true))
|
||||
expect(unfocused.exit).toEqual(success(false))
|
||||
const put = await call(first.port2, 1, "test.blob.put", omitUndefined({ data: new Uint8Array([2, 7, 1]) }))
|
||||
expect(put.exit).toEqual({ _tag: "Success", value: "2,7,1" })
|
||||
expect(put.exit).toEqual(success("2,7,1"))
|
||||
// Binary payloads arrive as bytes, not as base64 text or a plain object.
|
||||
expect(received).toBeInstanceOf(Uint8Array)
|
||||
expect((await call(first.port2, 2, "test.blob.get", null)).exit).toEqual({
|
||||
_tag: "Success",
|
||||
value: new Uint8Array([3, 1, 4]),
|
||||
})
|
||||
expect((await call(first.port2, 2, "test.blob.get", null)).exit).toEqual(success(new Uint8Array([3, 1, 4])))
|
||||
expect((await call(first.port2, 3, "test.events", null)).chunks).toEqual([
|
||||
{ _tag: "TestEvent", value: "session.new" },
|
||||
Schema.encodeSync(TestEvent)(new TestEvent({ value: "session.new" })),
|
||||
])
|
||||
|
||||
const reloaded = new MessageChannel()
|
||||
handoff.bind(sender(1), serverPort(reloaded.port1))
|
||||
|
||||
const [reloadedFocused, stillUnfocused] = await Promise.all([
|
||||
call(reloaded.port2, 0, "test.focused", null),
|
||||
call(second.port2, 1, "test.focused", null),
|
||||
])
|
||||
expect(reloadedFocused.exit).toEqual({ _tag: "Success", value: true })
|
||||
expect(stillUnfocused.exit).toEqual({ _tag: "Success", value: false })
|
||||
|
||||
expect(reloadedFocused.exit).toEqual(success(true))
|
||||
expect(stillUnfocused.exit).toEqual(success(false))
|
||||
|
||||
for (const port of [first.port2, second.port2, reloaded.port2]) port.close()
|
||||
await runtime.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
class TestEvent extends Schema.TaggedClass<TestEvent>()("TestEvent", { value: Schema.String }) {}
|
||||
|
||||
const TestRpcs = RpcGroup.make(
|
||||
Rpc.make("test.focused", { success: Schema.Boolean }),
|
||||
Rpc.make("test.blob.put", { payload: { data: Transferable.Uint8Array }, success: Schema.String }),
|
||||
@@ -117,28 +123,47 @@ const TestRpcs = RpcGroup.make(
|
||||
Rpc.make("test.events", { success: TestEvent, stream: true }),
|
||||
)
|
||||
|
||||
// SAFETY: these are the RPC wire format's plain encoded messages and exits, which the tests post and expect as is.
|
||||
/* oxlint-disable anti-slop-effect/no-manual-tagged-construction -- see SAFETY above */
|
||||
function success<A>(value: A) {
|
||||
return { _tag: "Success", value } as const
|
||||
}
|
||||
|
||||
function died<A>(defect: A) {
|
||||
return { _tag: "Failure", cause: [{ _tag: "Die", defect }] } as const
|
||||
}
|
||||
|
||||
// Speaks the wire format the way src/renderer/ipc-client.ts does: post a request, ack each chunk,
|
||||
// and settle on the exit. The payload is posted as given so a test can send what omitUndefined drops.
|
||||
function call(port: MessageChannel["port2"], id: number, tag: string, payload: unknown) {
|
||||
function call(port: MessageChannel["port2"], id: number, tag: string, payload: RpcMessage.RequestEncoded["payload"]) {
|
||||
const chunks: unknown[] = []
|
||||
|
||||
return new Promise<{ chunks: unknown[]; exit: RpcMessage.ResponseExitEncoded["exit"] }>((resolve) => {
|
||||
const onMessage = (message: RpcMessage.FromServerEncoded) => {
|
||||
if (!("requestId" in message) || Number(message.requestId) !== id) return
|
||||
if (message._tag === "Chunk") {
|
||||
|
||||
if (Predicate.isTagged(message, "Chunk")) {
|
||||
chunks.push(...message.values)
|
||||
port.postMessage({ _tag: "Ack", requestId: message.requestId } satisfies RpcMessage.AckEncoded)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
port.off("message", onMessage)
|
||||
resolve({ chunks, exit: message.exit })
|
||||
}
|
||||
|
||||
port.on("message", onMessage)
|
||||
port.postMessage({ _tag: "Request", id, tag, payload, headers: [] })
|
||||
})
|
||||
}
|
||||
/* oxlint-enable anti-slop-effect/no-manual-tagged-construction */
|
||||
|
||||
function sender(id: number) {
|
||||
const events = new EventEmitter()
|
||||
|
||||
// SAFETY: the transport reads only a sender's `id` and `isDestroyed`, and its `destroyed` event.
|
||||
// oxlint-disable-next-line anti-slop/no-chained-type-assertions -- see SAFETY above
|
||||
return {
|
||||
id,
|
||||
isDestroyed: () => false,
|
||||
@@ -148,27 +173,38 @@ function sender(id: number) {
|
||||
}
|
||||
|
||||
function serverPort(port: MessageChannel["port1"]) {
|
||||
const listeners = new Map<(event: Electron.MessageEvent) => void, (data: unknown) => void>()
|
||||
return {
|
||||
const listeners = new Map<(event: Electron.MessageEvent) => void, (data: Electron.MessageEvent["data"]) => void>()
|
||||
|
||||
const fake = {
|
||||
on(event: string, listener: (event: Electron.MessageEvent) => void) {
|
||||
if (event !== "message") {
|
||||
port.on(event, listener)
|
||||
|
||||
return
|
||||
}
|
||||
const wrapped = (data: unknown) => listener({ data } as Electron.MessageEvent)
|
||||
|
||||
// SAFETY: the transport reads only the `data` of a message event.
|
||||
const wrapped = (data: Electron.MessageEvent["data"]) => listener({ data } as Electron.MessageEvent)
|
||||
listeners.set(listener, wrapped)
|
||||
port.on("message", wrapped)
|
||||
},
|
||||
off(event: string, listener: (event: Electron.MessageEvent) => void) {
|
||||
if (event !== "message") {
|
||||
port.off(event, listener)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const wrapped = listeners.get(listener)
|
||||
|
||||
if (wrapped) port.off("message", wrapped)
|
||||
},
|
||||
postMessage: port.postMessage.bind(port),
|
||||
start: port.start.bind(port),
|
||||
close: port.close.bind(port),
|
||||
} as unknown as MessagePortMain
|
||||
}
|
||||
|
||||
// SAFETY: the transport uses only these methods of a port.
|
||||
// oxlint-disable-next-line anti-slop/no-chained-type-assertions -- see SAFETY above
|
||||
return fake as unknown as MessagePortMain
|
||||
}
|
||||
@@ -39,8 +39,10 @@ export const IpcServerProtocolLive = Layer.unwrap(
|
||||
|
||||
const disconnect = Effect.fnUntraced(function* (id: number) {
|
||||
const binding = bindings.get(id)
|
||||
|
||||
if (!binding) return
|
||||
bindings.delete(id)
|
||||
|
||||
if (senderBindings.get(binding.sender.id) === id) senderBindings.delete(binding.sender.id)
|
||||
binding.port.off("message", binding.onMessage)
|
||||
binding.port.off("close", binding.onClose)
|
||||
@@ -52,16 +54,23 @@ export const IpcServerProtocolLive = Layer.unwrap(
|
||||
|
||||
const bind = Effect.fnUntraced(function* (sender: WebContents, port: MessagePortMain) {
|
||||
const previous = senderBindings.get(sender.id)
|
||||
|
||||
if (previous !== undefined) yield* disconnect(previous)
|
||||
|
||||
if (sender.isDestroyed()) {
|
||||
port.close()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const id = nextClientId++
|
||||
|
||||
const onMessage = (event: Electron.MessageEvent) => {
|
||||
// SAFETY: the other end of this port is the renderer's ipc-client, which posts only RPC client
|
||||
// messages; the RPC server decodes each request's payload with that RPC's schema.
|
||||
Queue.offerUnsafe(inbound, [id, event.data as RpcMessage.FromClientEncoded] as const)
|
||||
}
|
||||
|
||||
const onClose = () => runFork(disconnect(id))
|
||||
const unbindEvents = yield* bindIpcEvents(sender.id)
|
||||
const binding = { id, sender, port, onMessage, onClose, unbindEvents }
|
||||
|
||||
@@ -22,6 +22,7 @@ import { DesktopCli } from "./service/desktop-cli"
|
||||
import { getLastFocusedWindow } from "./windows"
|
||||
|
||||
const services = Layer.mergeAll(DesktopFiles.layer, Extensions.layer)
|
||||
|
||||
const handlers = Layer.mergeAll(
|
||||
appHandlers,
|
||||
storageHandlers,
|
||||
@@ -31,6 +32,7 @@ const handlers = Layer.mergeAll(
|
||||
eventHandlers,
|
||||
extensionHandlers,
|
||||
)
|
||||
|
||||
export const layer = RpcServer.layer(DesktopRpcs, { disableFatalDefects: true }).pipe(
|
||||
Layer.provide(handlers),
|
||||
Layer.provideMerge(IpcServerProtocolLive),
|
||||
@@ -42,9 +44,11 @@ export const registerIpcHandlers = Effect.gen(function* () {
|
||||
const lifecycle = yield* ApplicationLifecycle.Service
|
||||
const desktopCli = yield* DesktopCli.Service
|
||||
const runFork = Effect.runForkWith(yield* Effect.context())
|
||||
|
||||
const menu = {
|
||||
trigger: (id: string) => {
|
||||
const win = getLastFocusedWindow()
|
||||
|
||||
if (win) sendMenuCommand(win, id)
|
||||
},
|
||||
installCli: () => runFork(showCliInstaller(desktopCli)),
|
||||
@@ -52,26 +56,34 @@ export const registerIpcHandlers = Effect.gen(function* () {
|
||||
openExternal: (url: string) => runFork(openExternalURL(url)),
|
||||
relaunch: lifecycle.relaunch,
|
||||
}
|
||||
const wire = (_event: Electron.Event, win: BrowserWindow) => {
|
||||
|
||||
const wire = (win: BrowserWindow) => {
|
||||
win.webContents.on("before-input-event", (_event, input) => {
|
||||
if (input.type !== "keyDown" || input.key !== "Escape") return
|
||||
win.webContents.send(DragCancelEvent)
|
||||
})
|
||||
|
||||
const post = () => {
|
||||
if (win.isDestroyed() || win.webContents.isDestroyed()) return
|
||||
const channel = new MessageChannelMain()
|
||||
handoff.bind(win.webContents, channel.port1)
|
||||
win.webContents.postMessage(IpcTransportPort, null, [channel.port2])
|
||||
}
|
||||
|
||||
win.webContents.on("did-finish-load", post)
|
||||
|
||||
// The first window starts loading before the layers exist and may already be done.
|
||||
if (!win.webContents.isLoading() && win.webContents.getURL()) post()
|
||||
}
|
||||
|
||||
const onWindowCreated = (_event: Electron.Event, win: BrowserWindow) => wire(win)
|
||||
|
||||
yield* Effect.sync(() => {
|
||||
app.on("browser-window-created", wire)
|
||||
BrowserWindow.getAllWindows().forEach((win) => wire({} as Electron.Event, win))
|
||||
app.on("browser-window-created", onWindowCreated)
|
||||
BrowserWindow.getAllWindows().forEach((win) => wire(win))
|
||||
})
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => app.off("browser-window-created", wire)))
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => app.off("browser-window-created", onWindowCreated)))
|
||||
|
||||
return {
|
||||
installMenu: () => createMenu(menu),
|
||||
}
|
||||
|
||||
@@ -11,33 +11,47 @@ import { IpcTransportPort, omitUndefined } from "../shared/ipc-transport"
|
||||
// out of the renderer's initial module graph is worth about a third of its startup script.
|
||||
|
||||
type EventTag = DesktopEvent["_tag"]
|
||||
|
||||
type InvokeTag = Exclude<keyof DesktopRpcClient, "DesktopEvents">
|
||||
|
||||
type InvokeArgs<Tag extends InvokeTag> = Parameters<DesktopRpcClient[Tag]>
|
||||
|
||||
type InvokeResult<Tag extends InvokeTag> =
|
||||
ReturnType<DesktopRpcClient[Tag]> extends Effect.Effect<infer Value, unknown> ? Value : never
|
||||
|
||||
type EventValue<Tag extends EventTag> = Extract<DesktopEvent, { readonly _tag: Tag }>
|
||||
|
||||
type Pending = {
|
||||
// SAFETY: the reply is the RPC's success value as the trusted main process encoded it; see the note above.
|
||||
// oxlint-disable-next-line anti-slop/no-unknown-parameters -- see SAFETY above
|
||||
readonly resolve: (value: unknown) => void
|
||||
readonly reject: (error: unknown) => void
|
||||
readonly reject: (cause: unknown) => void
|
||||
readonly chunk?: (values: ReadonlyArray<unknown>) => void
|
||||
}
|
||||
|
||||
const pending = new Map<number, Pending>()
|
||||
const listeners = new Map<EventTag, Set<(value: unknown) => void>>()
|
||||
|
||||
const listeners = new Map<EventTag, Set<(value: DesktopEvent) => void>>()
|
||||
|
||||
// SAFETY: pagehide only waits for these callbacks to settle and ignores what they resolve to.
|
||||
// oxlint-disable-next-line anti-slop/no-unknown-returns -- see SAFETY above
|
||||
const beforeDispose = new Set<() => Promise<unknown> | void>()
|
||||
|
||||
let nextId = 0
|
||||
|
||||
const port = new Promise<MessagePort>((resolve) => {
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
if (event.source !== window || event.data !== IpcTransportPort) return
|
||||
const value = event.ports[0]
|
||||
|
||||
if (!value) return
|
||||
window.removeEventListener("message", onMessage)
|
||||
// SAFETY: the port comes from the trusted main process, whose RPC server posts only its own messages.
|
||||
value.addEventListener("message", (message) => receive(value, message.data as RpcMessage.FromServerEncoded))
|
||||
value.start()
|
||||
resolve(value)
|
||||
}
|
||||
|
||||
window.addEventListener("message", onMessage)
|
||||
})
|
||||
|
||||
@@ -49,15 +63,20 @@ window.addEventListener(
|
||||
)
|
||||
|
||||
void request("DesktopEvents", null, (values) => {
|
||||
// SAFETY: DesktopEvents streams desktop events, whose encoded fields are JSON-native and read as decoded.
|
||||
for (const value of values as ReadonlyArray<DesktopEvent>) listeners.get(value._tag)?.forEach((fn) => fn(value))
|
||||
})
|
||||
|
||||
// SAFETY: as for `beforeDispose`, the callback's result is only awaited, never read.
|
||||
// oxlint-disable-next-line anti-slop/no-unknown-returns -- see SAFETY above
|
||||
export function onBeforeDispose(callback: () => Promise<unknown> | void) {
|
||||
beforeDispose.add(callback)
|
||||
|
||||
return () => beforeDispose.delete(callback)
|
||||
}
|
||||
|
||||
export function invoke<Tag extends InvokeTag>(tag: Tag, ...payload: InvokeArgs<Tag>): Promise<InvokeResult<Tag>> {
|
||||
// SAFETY: main replies with the success value of `tag`'s RPC; its encoding is JSON-native or bytes, as decoded.
|
||||
return request(tag, payload[0] ?? null) as Promise<InvokeResult<Tag>>
|
||||
}
|
||||
|
||||
@@ -71,37 +90,55 @@ export function cancellable<Tag extends InvokeTag>(
|
||||
payload: InvokeArgs<Tag>[0],
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<InvokeResult<Tag>> {
|
||||
// SAFETY: as in `invoke`, the reply is the success value of `tag`'s RPC.
|
||||
return request(tag, payload ?? null, undefined, signal) as Promise<InvokeResult<Tag>>
|
||||
}
|
||||
|
||||
export function listen<Tag extends EventTag>(tag: Tag, listener: (value: EventValue<Tag>) => void) {
|
||||
const callback = listener as (value: unknown) => void
|
||||
// SAFETY: events dispatch by tag, so this listener only receives `Tag` events.
|
||||
const callback = listener as (value: DesktopEvent) => void
|
||||
const callbacks = listeners.get(tag) ?? new Set()
|
||||
callbacks.add(callback)
|
||||
listeners.set(tag, callbacks)
|
||||
|
||||
return () => {
|
||||
callbacks.delete(callback)
|
||||
|
||||
if (callbacks.size === 0) listeners.delete(tag)
|
||||
}
|
||||
}
|
||||
|
||||
function request(tag: string, payload: unknown, chunk?: Pending["chunk"], signal?: AbortSignal) {
|
||||
// SAFETY: this side speaks the RPC wire format by hand, whose messages are plain tagged objects. Effect's Match,
|
||||
// Predicate, and message constructors would load the Effect runtime into the renderer; see the note above.
|
||||
/* oxlint-disable anti-slop-effect/no-manual-tag-comparison, anti-slop-effect/no-manual-tagged-construction -- see SAFETY above */
|
||||
function request(
|
||||
tag: InvokeTag | "DesktopEvents",
|
||||
payload: InvokeArgs<InvokeTag>[0] | null,
|
||||
chunk?: Pending["chunk"],
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const id = nextId++
|
||||
|
||||
return new Promise<unknown>((resolve, reject) => {
|
||||
if (signal?.aborted) return reject(signal.reason)
|
||||
|
||||
const abort = () => {
|
||||
if (!pending.delete(id)) return
|
||||
reject(signal?.reason)
|
||||
void port.then((p) => p.postMessage({ _tag: "Interrupt", requestId: id } satisfies RpcMessage.InterruptEncoded))
|
||||
}
|
||||
|
||||
signal?.addEventListener("abort", abort, { once: true })
|
||||
|
||||
const settle =
|
||||
<Value>(callback: (value: Value) => void) =>
|
||||
(value: Value) => {
|
||||
signal?.removeEventListener("abort", abort)
|
||||
callback(value)
|
||||
}
|
||||
|
||||
pending.set(id, { resolve: settle(resolve), reject: settle(reject), chunk })
|
||||
|
||||
const message: RpcMessage.RequestEncoded = {
|
||||
_tag: "Request",
|
||||
id,
|
||||
@@ -109,6 +146,7 @@ function request(tag: string, payload: unknown, chunk?: Pending["chunk"], signal
|
||||
payload: omitUndefined(payload),
|
||||
headers: [],
|
||||
}
|
||||
|
||||
void port.then((p) => p.postMessage(message))
|
||||
})
|
||||
}
|
||||
@@ -118,24 +156,33 @@ function receive(p: MessagePort, message: RpcMessage.FromServerEncoded) {
|
||||
case "Chunk": {
|
||||
pending.get(Number(message.requestId))?.chunk?.(message.values)
|
||||
p.postMessage({ _tag: "Ack", requestId: message.requestId } satisfies RpcMessage.AckEncoded)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
case "Exit": {
|
||||
const id = Number(message.requestId)
|
||||
const entry = pending.get(id)
|
||||
pending.delete(id)
|
||||
|
||||
if (!entry) return
|
||||
|
||||
if (message.exit._tag === "Success") return entry.resolve(message.exit.value)
|
||||
|
||||
return entry.reject(failure(message.exit.cause))
|
||||
}
|
||||
|
||||
case "Defect": {
|
||||
const error = new Error("Desktop IPC defect", { cause: message.defect })
|
||||
pending.forEach((entry) => entry.reject(error))
|
||||
pending.clear()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
case "ClientProtocolError": {
|
||||
console.error("[desktop-ipc] protocol error", message.error)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -145,8 +192,12 @@ function receive(p: MessagePort, message: RpcMessage.FromServerEncoded) {
|
||||
// and interrupts surface as errors.
|
||||
function failure(cause: ReadonlyArray<{ readonly _tag: string; readonly error?: unknown; readonly defect?: unknown }>) {
|
||||
const failed = cause.find((item) => item._tag === "Fail")
|
||||
|
||||
if (failed) return failed.error
|
||||
const died = cause.find((item) => item._tag === "Die")
|
||||
|
||||
if (died) return new Error("Desktop IPC handler failed", { cause: died.defect })
|
||||
|
||||
return new Error("Desktop IPC request interrupted")
|
||||
}
|
||||
/* oxlint-enable anti-slop-effect/no-manual-tag-comparison, anti-slop-effect/no-manual-tagged-construction */
|
||||
Loaded 100 of 223 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user