mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-05 06:56:16 +00:00
Compare commits
47
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6320dfc25 | ||
|
|
a61ed90513 | ||
|
|
f8dd0ae352 | ||
|
|
e8cbdb8a7f | ||
|
|
d768766dc4 | ||
|
|
fa69d1d6dc | ||
|
|
5bcebfa47d | ||
|
|
005e195065 | ||
|
|
89b4f2cf9b | ||
|
|
454d47497d | ||
|
|
3993dce7c9 | ||
|
|
e7ae5a7725 | ||
|
|
8a6f4a11ab | ||
|
|
81926b2e1b | ||
|
|
66329a0e4f | ||
|
|
1cdc33d33b | ||
|
|
2470f6027a | ||
|
|
527cfd6037 | ||
|
|
bd7ab857a0 | ||
|
|
2cc542c9d0 | ||
|
|
f37512680a | ||
|
|
6dd193c70d | ||
|
|
46d263f2eb | ||
|
|
1764faded7 | ||
|
|
7fc28a7361 | ||
|
|
32f62edf9e | ||
|
|
b9c934a93d | ||
|
|
9926646c77 | ||
|
|
0974946864 | ||
|
|
d0e57b4697 | ||
|
|
f3bcd5372a | ||
|
|
0abbbe007f | ||
|
|
6d9efafbfe | ||
|
|
f6d9a842b6 | ||
|
|
25c54cbdf7 | ||
|
|
ae57dd0cf5 | ||
|
|
e86ec98711 | ||
|
|
c542a43503 | ||
|
|
2db9c2e1b3 | ||
|
|
3a861ff8b0 | ||
|
|
22803892bc | ||
|
|
69ba898a7b | ||
|
|
375ff827aa | ||
|
|
ed747091e9 | ||
|
|
44193bd71f | ||
|
|
238d4cf5ca | ||
|
|
3e77d5c170 |
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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
||||
@@ -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,204 @@
|
||||
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 { createStore } from "solid-js/store"
|
||||
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 [menu, setMenu] = createStore({ open: false })
|
||||
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" open={menu.open} onOpenChange={(open) => setMenu("open", open)}>
|
||||
<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={() => {
|
||||
// Close before navigating: the router's transition would hold the close until this
|
||||
// timeline detaches, and the menu would flash at the viewport origin.
|
||||
setMenu("open", false)
|
||||
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) => {
|
||||
|
||||
@@ -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)
|
||||
@@ -150,10 +157,8 @@ export const stop = Effect.fn("service.stop")(function* (options: StopOptions =
|
||||
options.pty === "handoff" && info !== undefined
|
||||
? PtyHandoff.prepare(options.file ?? fallback(), info, defaultEnsureTiming.requestTimeout)
|
||||
: PtyHandoff.clear(options.file ?? fallback()),
|
||||
).pipe(
|
||||
Effect.catch((cause) => Effect.logWarning("Failed to prepare persistent terminals for replacement", cause)),
|
||||
)
|
||||
if (info !== undefined) yield* terminate(info, options, defaultEnsureTiming)
|
||||
).pipe(Effect.catch((cause) => Effect.logWarning("Failed to prepare persistent terminals for replacement", cause)))
|
||||
if (info !== undefined) yield* terminate(info, options, ensureTiming(options))
|
||||
})
|
||||
|
||||
function fallback() {
|
||||
@@ -205,10 +210,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 +232,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: {
|
||||
@@ -294,9 +296,9 @@ const terminate = Effect.fnUntraced(function* (info: Info, options: { readonly f
|
||||
if (current === undefined || !same(current, info)) return
|
||||
yield* signal(info.pid, "SIGTERM")
|
||||
const done = yield* stopped(info.pid).pipe(Effect.retry(poll(timing)), Effect.option)
|
||||
// The registration can disappear or change hands before this process exits. Only the PID we
|
||||
// signalled can tell us whether it has stopped, so escalate based on that process.
|
||||
if (Option.isNone(done)) {
|
||||
const latest = yield* read(options.file)
|
||||
if (latest === undefined || !same(latest, info)) return
|
||||
yield* signal(info.pid, "SIGKILL")
|
||||
yield* stopped(info.pid).pipe(Effect.retry(poll(timing)))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -124,7 +129,7 @@ export async function stop(options: StopOptions = {}) {
|
||||
? PtyHandoff.prepare(options.file ?? fallback(), info, defaultEnsureTiming.requestTimeout)
|
||||
: PtyHandoff.clear(options.file ?? fallback())
|
||||
).catch((cause: unknown) => console.warn("Failed to prepare persistent terminals for replacement", cause))
|
||||
if (info !== undefined) await terminate(info, options, defaultEnsureTiming)
|
||||
if (info !== undefined) await terminate(info, options, ensureTiming(options))
|
||||
}
|
||||
|
||||
function fallback() {
|
||||
@@ -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: {
|
||||
@@ -253,9 +258,9 @@ async function terminate(info: Info, options: { readonly file?: string }, timing
|
||||
const current = await read(options.file)
|
||||
if (current === undefined || !same(current, info)) return
|
||||
signal(info.pid, "SIGTERM")
|
||||
// The registration can disappear or change hands before this process exits. Only the PID we
|
||||
// signalled can tell us whether it has stopped, so escalate based on that process.
|
||||
if (!(await waitUntilStopped(info.pid, timing))) {
|
||||
const latest = await read(options.file)
|
||||
if (latest === undefined || !same(latest, info)) return
|
||||
signal(info.pid, "SIGKILL")
|
||||
if (!(await waitUntilStopped(info.pid, timing))) throw new Error(`Server process ${info.pid} is still running`)
|
||||
}
|
||||
|
||||
@@ -25,6 +25,16 @@ export async function serviceFixture() {
|
||||
processes.push(subprocess)
|
||||
return subprocess
|
||||
},
|
||||
// The service's parent execs `sleep`, which never reaps it, so SIGKILL leaves a zombie that
|
||||
// still answers `kill(pid, 0)`. Terminating the returned parent lets init reap the service.
|
||||
spawnUnreaped(mode: string, ...args: string[]) {
|
||||
const subprocess = Bun.spawn(["sh", "-c", '"$@" & exec sleep 60', "sh", ...command(mode, ...args)], {
|
||||
stdout: "ignore",
|
||||
stderr: "inherit",
|
||||
})
|
||||
processes.push(subprocess)
|
||||
return subprocess
|
||||
},
|
||||
// Service.ensure detaches contenders; track the elected process before asserting.
|
||||
track(pid: number) {
|
||||
pids.add(pid)
|
||||
@@ -54,3 +64,8 @@ export async function serviceFixture() {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectPortAvailable(url: string) {
|
||||
const server = Bun.serve({ port: Number(new URL(url).port), fetch: () => new Response() })
|
||||
await server.stop(true)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { appendFile, rename, writeFile } from "node:fs/promises"
|
||||
import { appendFile, rename, rm, writeFile } from "node:fs/promises"
|
||||
|
||||
const [registration, mode, delay] = process.argv.slice(2)
|
||||
if (registration === undefined || mode === undefined) throw new Error("Missing service fixture arguments")
|
||||
@@ -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") {
|
||||
@@ -85,6 +91,10 @@ const server = Bun.serve({
|
||||
},
|
||||
})
|
||||
|
||||
// Install handlers before publishing: a test may signal as soon as the registration appears.
|
||||
process.on("SIGTERM", () => void shutdown("SIGTERM"))
|
||||
process.on("SIGINT", () => void shutdown("SIGINT"))
|
||||
|
||||
await writeFile(
|
||||
registration + ".tmp",
|
||||
JSON.stringify({
|
||||
@@ -100,8 +110,12 @@ await rename(registration + ".tmp", registration)
|
||||
|
||||
async function shutdown(signal?: NodeJS.Signals) {
|
||||
if (signal !== undefined) await writeFile(registration + ".signal", signal)
|
||||
// A lingering server unregisters on SIGTERM but keeps running, and holds its port, until killed.
|
||||
if (mode === "lingering") {
|
||||
await rm(registration, { force: true })
|
||||
await writeFile(registration + ".unregistered", "")
|
||||
await Bun.sleep(Number(delay))
|
||||
}
|
||||
server.stop(true)
|
||||
process.exit()
|
||||
}
|
||||
process.on("SIGTERM", () => void shutdown("SIGTERM"))
|
||||
process.on("SIGINT", () => void shutdown("SIGINT"))
|
||||
@@ -1,9 +1,10 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Service, type EnsureReason } from "../src/promise/service"
|
||||
import { serviceFixture } from "./fixture/service-fixture"
|
||||
import { expectPortAvailable, serviceFixture } from "./fixture/service-fixture"
|
||||
import { accelerate } from "./fixture/service-timing"
|
||||
|
||||
const ensure = accelerate(Service.ensure)
|
||||
const stop = accelerate(Service.stop)
|
||||
|
||||
test("discovers a registered service", async () => {
|
||||
await using fixture = await serviceFixture()
|
||||
@@ -171,3 +172,66 @@ test("signals the registered service process", async () => {
|
||||
expect(await Bun.file(registration + ".signal").text()).toBe("SIGTERM")
|
||||
expect(await Bun.file(registration).exists()).toBe(false)
|
||||
})
|
||||
|
||||
test("stop escalates when the registration disappears before the process exits", async () => {
|
||||
await using fixture = await serviceFixture()
|
||||
const registration = fixture.registration
|
||||
const existing = fixture.spawn("lingering", "5000")
|
||||
await fixture.waitForFile()
|
||||
const original = await Bun.file(registration).json()
|
||||
|
||||
await stop({ file: registration })
|
||||
|
||||
expect(await Bun.file(registration + ".signal").text()).toBe("SIGTERM")
|
||||
expect(() => process.kill(original.pid, 0)).toThrow()
|
||||
await expectPortAvailable(original.url)
|
||||
expect(await Bun.file(registration).exists()).toBe(false)
|
||||
await existing.exited
|
||||
}, 15_000)
|
||||
|
||||
test.skipIf(process.platform === "win32")(
|
||||
"stop fails when the process survives SIGKILL",
|
||||
async () => {
|
||||
await using fixture = await serviceFixture()
|
||||
const registration = fixture.registration
|
||||
fixture.spawnUnreaped("lingering", "60000")
|
||||
await fixture.waitForFile()
|
||||
const original = await Bun.file(registration).json()
|
||||
|
||||
await expect(stop({ file: registration })).rejects.toThrow(`Server process ${original.pid} is still running`)
|
||||
expect(await Bun.file(registration + ".signal").text()).toBe("SIGTERM")
|
||||
},
|
||||
15_000,
|
||||
)
|
||||
|
||||
test("stop waits for the original process while preserving a newly registered successor", async () => {
|
||||
await using fixture = await serviceFixture()
|
||||
const registration = fixture.registration
|
||||
const existing = fixture.spawn("lingering", "15000")
|
||||
await fixture.waitForFile()
|
||||
const original = await Bun.file(registration).json()
|
||||
// Default timing keeps the grace period open long enough for the successor to register first.
|
||||
const stopping = Service.stop({ file: registration })
|
||||
|
||||
try {
|
||||
await fixture.waitForFile(registration + ".unregistered")
|
||||
const successor = fixture.spawn("graceful")
|
||||
await fixture.waitForFile()
|
||||
const replacement = await Bun.file(registration).json()
|
||||
expect(existing.exitCode).toBe(null)
|
||||
expect(replacement.pid).toBe(successor.pid)
|
||||
|
||||
await stopping
|
||||
|
||||
expect(() => process.kill(original.pid, 0)).toThrow()
|
||||
await expectPortAvailable(original.url)
|
||||
expect(await Bun.file(registration).json()).toEqual(replacement)
|
||||
expect(await fetch(new URL("/api/info", replacement.url)).then((response) => response.json())).toMatchObject({
|
||||
pid: successor.pid,
|
||||
})
|
||||
expect(successor.exitCode).toBe(null)
|
||||
} finally {
|
||||
existing.kill("SIGKILL")
|
||||
await stopping
|
||||
}
|
||||
}, 20_000)
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -3,10 +3,11 @@ import { expect, test } from "bun:test"
|
||||
import { Effect, FileSystem } from "effect"
|
||||
import { writeFile } from "node:fs/promises"
|
||||
import { Service, type EnsureReason } from "../src/effect/service"
|
||||
import { serviceFixture } from "./fixture/service-fixture"
|
||||
import { expectPortAvailable, serviceFixture } from "./fixture/service-fixture"
|
||||
import { accelerate } from "./fixture/service-timing"
|
||||
|
||||
const ensure = accelerate(Service.ensure)
|
||||
const stop = accelerate(Service.stop)
|
||||
|
||||
test("a concurrent same-version start cannot invalidate a resolved endpoint", async () => {
|
||||
await using fixture = await serviceFixture()
|
||||
@@ -161,6 +162,67 @@ test("signals an unresponsive registered service process", async () => {
|
||||
expect(await Bun.file(registration).exists()).toBe(false)
|
||||
})
|
||||
|
||||
test("stop escalates when the registration disappears before the process exits", async () => {
|
||||
await using fixture = await serviceFixture()
|
||||
const registration = fixture.registration
|
||||
const existing = fixture.spawn("lingering", "5000")
|
||||
await fixture.waitForFile()
|
||||
const original = await Bun.file(registration).json()
|
||||
|
||||
await run(stop({ file: registration }))
|
||||
|
||||
expect(await Bun.file(registration + ".signal").text()).toBe("SIGTERM")
|
||||
expect(() => process.kill(original.pid, 0)).toThrow()
|
||||
await expectPortAvailable(original.url)
|
||||
expect(await Bun.file(registration).exists()).toBe(false)
|
||||
await existing.exited
|
||||
}, 15_000)
|
||||
|
||||
test.skipIf(process.platform === "win32")(
|
||||
"stop fails when the process survives SIGKILL",
|
||||
async () => {
|
||||
await using fixture = await serviceFixture()
|
||||
const registration = fixture.registration
|
||||
fixture.spawnUnreaped("lingering", "60000")
|
||||
await fixture.waitForFile()
|
||||
const original = await Bun.file(registration).json()
|
||||
|
||||
await expect(run(stop({ file: registration }))).rejects.toThrow(`Server process ${original.pid} is still running`)
|
||||
expect(await Bun.file(registration + ".signal").text()).toBe("SIGTERM")
|
||||
},
|
||||
15_000,
|
||||
)
|
||||
|
||||
test("stop waits for the original process while preserving a newly registered successor", async () => {
|
||||
await using fixture = await serviceFixture()
|
||||
const registration = fixture.registration
|
||||
const existing = fixture.spawn("lingering", "15000")
|
||||
await fixture.waitForFile()
|
||||
const original = await Bun.file(registration).json()
|
||||
// Default timing keeps the grace period open long enough for the successor to register first.
|
||||
const stopping = run(Service.stop({ file: registration }))
|
||||
|
||||
try {
|
||||
await fixture.waitForFile(registration + ".unregistered")
|
||||
const successor = fixture.spawn("graceful")
|
||||
await fixture.waitForFile()
|
||||
const replacement = await Bun.file(registration).json()
|
||||
expect(existing.exitCode).toBe(null)
|
||||
expect(replacement.pid).toBe(successor.pid)
|
||||
|
||||
await stopping
|
||||
|
||||
expect(() => process.kill(original.pid, 0)).toThrow()
|
||||
await expectPortAvailable(original.url)
|
||||
expect(await Bun.file(registration).json()).toEqual(replacement)
|
||||
expect(await status(replacement.url)).toMatchObject({ pid: successor.pid })
|
||||
expect(successor.exitCode).toBe(null)
|
||||
} finally {
|
||||
existing.kill("SIGKILL")
|
||||
await stopping
|
||||
}
|
||||
}, 20_000)
|
||||
|
||||
test("signals an incompatible service before starting its replacement", async () => {
|
||||
await using fixture = await serviceFixture()
|
||||
const registration = fixture.registration
|
||||
|
||||
@@ -210,7 +210,10 @@ export const scanPortable = Effect.fnUntraced(function* (command: string, shell:
|
||||
catch: (cause) => new Error(`Portable shell scanner failed to load: ${cause}`, { cause }),
|
||||
})
|
||||
const powershell = ShellSelect.ps(shell)
|
||||
const result = powershell ? ShellScan.scanPowerShell(command) : ShellScan.scan(command)
|
||||
const name = ShellSelect.name(shell)
|
||||
const result = powershell
|
||||
? ShellScan.scanPowerShell(command)
|
||||
: ShellScan.scan(command, name === "bash" || name === "zsh" ? name : "posix")
|
||||
if (result.kind === "opaque")
|
||||
return yield* Effect.fail(new Error(`Portable shell scanner cannot analyze command: ${result.reason}`))
|
||||
|
||||
@@ -225,15 +228,21 @@ export const scanPortable = Effect.fnUntraced(function* (command: string, shell:
|
||||
if (CWD.has(name)) {
|
||||
output.directories.push(
|
||||
...directoryArgs(
|
||||
words.flatMap((text): Part[] => {
|
||||
const parameter = powershell ? /^(-(?:literalpath|path)):(.*)$/i.exec(text) : undefined
|
||||
if (parameter)
|
||||
return [
|
||||
{ type: "command_parameter", text: parameter[1] },
|
||||
{ type: "word", text: parameter[2] },
|
||||
]
|
||||
return [{ type: powershell && text.startsWith("-") ? "command_parameter" : "word", text }]
|
||||
}),
|
||||
powershell
|
||||
? words.flatMap((text): Part[] => {
|
||||
const parameter = /^(-(?:literalpath|path)):(.*)$/i.exec(text)
|
||||
if (parameter)
|
||||
return [
|
||||
{ type: "command_parameter", text: parameter[1] },
|
||||
{ type: "word", text: parameter[2] },
|
||||
]
|
||||
return [{ type: text.startsWith("-") ? "command_parameter" : "word", text }]
|
||||
})
|
||||
: item.rawWords.map((text, index) => ({
|
||||
type: "word",
|
||||
// Only literal quoting resolves to a static directory.
|
||||
text: text.startsWith("$'") || (!/[$`~\\]/.test(text) && /['"]/.test(text)) ? item.words[index] : text,
|
||||
})),
|
||||
powershell,
|
||||
cwd,
|
||||
shell,
|
||||
|
||||
+1457
-772
File diff suppressed because it is too large.
Load diff
@@ -42,6 +42,21 @@ describe("ShellScan adversarial corpus", () => {
|
||||
expect(result.commands.map((command) => command.words[0])).toEqual([...names])
|
||||
})
|
||||
|
||||
// Only shell sinks evaluate subscripts; ordinary arguments, heredoc bodies, and bound data without a sink are safe.
|
||||
test.each([
|
||||
"bun -e 'f(`a[${x}]`)'",
|
||||
"rg 'a[$(x)]'",
|
||||
"cat <<'EOF'\na[$(x)]\nEOF",
|
||||
"echo 'a[$(x)]'",
|
||||
'BODY="- [x] Fix \\`scan.ts\\`"; gh pr create --title "fix" --body "$BODY"',
|
||||
"MSG='[feat] Fix `foo`'; git commit -m \"$MSG\"",
|
||||
"PATTERN='a[${b}]'; rg \"$PATTERN\"",
|
||||
"export REGEX='[0-9]+${foo}'",
|
||||
"cat <<< 'const a = arr[0]; const b = `${a}`'",
|
||||
])("scans subscript-shaped text outside shell sinks: %s", (input) => {
|
||||
expect(ShellScan.scan(input).kind).toBe("scanned")
|
||||
})
|
||||
|
||||
test.each(['printf "unterminated', "printf ok &&", "printf ok >", "echo > >out"])(
|
||||
"keeps structurally uncertain Bash input opaque: %s",
|
||||
(input) => {
|
||||
@@ -49,6 +64,19 @@ describe("ShellScan adversarial corpus", () => {
|
||||
},
|
||||
)
|
||||
|
||||
test.each([
|
||||
["repeated assignment value operators", "x[a]" + "=]".repeat(32_000)],
|
||||
["unclosed assignment subscripts", "a[\n".repeat(21_000)],
|
||||
["case patterns with substitutions", "case x in " + "[$(:)".repeat(10_000)],
|
||||
["nested groups with bracket words", "{ ".repeat(31) + "echo " + "a[] ".repeat(15_000) + "; }".repeat(31)],
|
||||
["continued heredoc lines", "cat <<E\n" + "x\\\n".repeat(20_000) + "E\n"],
|
||||
["heredoc backslash runs", "cat <<E\n" + "\\".repeat(60_000) + "x\nE\n"],
|
||||
])("scans adversarial Bash input in bounded time: %s", (_, input) => {
|
||||
const start = performance.now()
|
||||
ShellScan.scan(input)
|
||||
expect(performance.now() - start).toBeLessThan(500)
|
||||
})
|
||||
|
||||
test.each([
|
||||
['pwsh --command "Remove-Item victim.txt"', ["pwsh"]],
|
||||
["Import-Module ./evil.psm1", ["Import-Module"]],
|
||||
|
||||
@@ -60,23 +60,41 @@ const fixtures = [
|
||||
["! scan_probe", ["scan_probe"]],
|
||||
["time scan_probe", ["time"]],
|
||||
["{fd}>/dev/null scan_probe", ["scan_probe"]],
|
||||
["case $r in a) ls | head;; esac", ["ls", "head"]],
|
||||
["case $r in a) ls && echo;; esac", ["ls", "echo"]],
|
||||
["{ find . -exec echo {} \\; ; }", ["find"]],
|
||||
["{ echo {a,{b,c}}; }", ["echo"]],
|
||||
["if true; then\\\n echo hi; fi", ["true", "echo"]],
|
||||
["for ((i=0; i<2; i++)) do echo hi; done", ["echo"]],
|
||||
['echo "$(case x in @(a)) echo hi;; esac)"', ["echo", "echo"]],
|
||||
["set -- 1; for x do scan_probe; done", ["set", "scan_probe"]],
|
||||
["'q'; x=1 a", ["q", "a"]],
|
||||
["cat <<E; ( true\nscan_ignored\nE\n)", ["cat", "true"]],
|
||||
["cat <<E; f() { true\nscan_ignored\nE\n}; f", ["cat", "true", "f"]],
|
||||
["cat <<E; case x in\nscan_ignored\nE\nx) scan_probe;; esac", ["cat", "scan_probe"]],
|
||||
["time [[ ( -f foo ) ]]", []],
|
||||
["time ! { echo a; echo b; }", ["echo", "echo"]],
|
||||
] as const
|
||||
|
||||
describe("ordinary Bash and Zsh syntax", () => {
|
||||
test.each(fixtures)("extracts actual command nodes: %s", (source, names) => {
|
||||
const result = ShellScan.scan(source)
|
||||
expect(result.kind).toBe("scanned")
|
||||
if (result.kind !== "scanned") throw new Error(result.reason)
|
||||
expect(result.commands.map((command) => command.words[0])).toEqual([...names])
|
||||
for (const dialect of ["bash", "zsh"] as const) {
|
||||
const result = ShellScan.scan(source, dialect)
|
||||
expect(result.kind).toBe("scanned")
|
||||
if (result.kind !== "scanned") throw new Error(result.reason)
|
||||
expect(result.commands.map((command) => command.words[0])).toEqual([...names])
|
||||
}
|
||||
})
|
||||
|
||||
for (const shell of ["bash", "zsh"]) {
|
||||
const executable = Bun.which(shell)
|
||||
for (const [source] of fixtures) {
|
||||
// These are Bash spellings; Zsh's fd allocation is a standalone statement.
|
||||
// These are Bash spellings; Zsh's fd allocation is a standalone statement. Bash parses extglob
|
||||
// patterns only when extglob is enabled.
|
||||
test.skipIf(
|
||||
!executable ||
|
||||
(shell === "zsh" && (source.includes('$"') || source.startsWith("{fd}") || source.includes("$["))),
|
||||
(shell === "zsh" && (source.includes('$"') || source.startsWith("{fd}") || source.includes("$["))) ||
|
||||
(shell === "bash" && source.includes("@(")),
|
||||
)(`${shell} accepts the source grammar: ${source}`, () => {
|
||||
const result = Bun.spawnSync([
|
||||
executable ?? shell,
|
||||
@@ -132,10 +150,15 @@ describe("ordinary Bash and Zsh syntax", () => {
|
||||
"cat <<EOF\nunclosed",
|
||||
"echo ${missing",
|
||||
"echo $'missing",
|
||||
"case $r in a) ls |;; esac",
|
||||
])("rejects incomplete syntax: %s", (source) => {
|
||||
expect(ShellScan.scan(source).kind).toBe("opaque")
|
||||
})
|
||||
|
||||
test("scans a Zsh brace group closed after a redirect", () => {
|
||||
expect(ShellScan.scan("{ a && >f }")).toMatchObject({ kind: "scanned", commands: [{ words: ["a"] }] })
|
||||
})
|
||||
|
||||
test("preserves raw lexical spelling of ANSI-C and locale quoted words", () => {
|
||||
expect(ShellScan.scan("$'pri\\x6etf' $'line\\n' $\"text\"")).toMatchObject({
|
||||
kind: "scanned",
|
||||
@@ -145,7 +168,7 @@ describe("ordinary Bash and Zsh syntax", () => {
|
||||
|
||||
test.each([
|
||||
["coproc job { scan_probe; }", ["scan_probe"]],
|
||||
["printf '%s' @(one|$(scan_probe))", ["printf", "scan_probe"]],
|
||||
["case x in @(one|$(scan_probe))) ;; esac", ["scan_probe"]],
|
||||
["printf '%s' $((1 + '$(scan_probe)'))", ["printf", "scan_probe"]],
|
||||
["printf '%s' $(((1 + '$(scan_probe)')))", ["printf", "scan_probe"]],
|
||||
['printf %s "${ scan_probe; }"', ["printf", "scan_probe"]],
|
||||
@@ -241,3 +264,54 @@ describe("Bash shared heredoc delimiter grammar", () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Bash dialects", () => {
|
||||
const heads = (source: string, dialect?: ShellScan.Dialect) => {
|
||||
const result = ShellScan.scan(source, dialect)
|
||||
return result.kind === "scanned" ? result.commands.map((command) => command.words[0]) : result.kind
|
||||
}
|
||||
|
||||
test("posix reports both readings of a double parenthesis", () => {
|
||||
expect(heads("(( x = 1 )); y", "bash")).toEqual(["y"])
|
||||
expect(heads("(( x = 1 )); y", "zsh")).toEqual(["y"])
|
||||
expect(heads("(( x = 1 )); y", "posix")).toEqual(["x", "y"])
|
||||
expect(heads("(( x = 1 )); y")).toEqual(["x", "y"])
|
||||
expect(heads("(( (1) + (2) ))", "bash")).toEqual([])
|
||||
expect(heads("(( (1) + (2) ))", "posix")).toBe("opaque")
|
||||
})
|
||||
|
||||
test.each([
|
||||
["true &>/dev/null next", ["true"], "opaque"],
|
||||
["X=$[1 + 2] next", ["next"], "opaque"],
|
||||
] as const)("reads Bash and Zsh syntax precisely where Dash diverges: %s", (source, precise, posix) => {
|
||||
expect(heads(source, "bash")).toEqual([...precise])
|
||||
expect(heads(source, "zsh")).toEqual([...precise])
|
||||
expect(heads(source, "posix")).toEqual(posix)
|
||||
})
|
||||
|
||||
test("reads a reserved word after a redirect as a Zsh keyword", () => {
|
||||
const source = "if true; then >/dev/null fi; next"
|
||||
expect(heads(source, "zsh")).toEqual(["true", "next"])
|
||||
expect(heads(source, "bash")).toBe("opaque")
|
||||
expect(heads(source, "posix")).toBe("opaque")
|
||||
})
|
||||
|
||||
test("scans safe Zsh parameter flags, Zsh repeat loops, and Bash extglob arguments", () => {
|
||||
expect(heads("print -l ${(M)files:#*.ts}", "zsh")).toEqual(["print"])
|
||||
expect(heads("print -l ${(ps:\\n:)text}", "zsh")).toEqual(["print"])
|
||||
expect(heads("repeat 3; do echo hi; done", "zsh")).toEqual(["echo"])
|
||||
expect(heads("repeat 3; do echo hi; done", "posix")).toEqual(["echo"])
|
||||
expect(heads("ls @(foo|bar)", "bash")).toEqual(["ls"])
|
||||
})
|
||||
|
||||
test.each([
|
||||
["/bin/bash", ["y"]],
|
||||
["/usr/local/bin/zsh", ["y"]],
|
||||
["/bin/sh", ["x = 1", "y"]],
|
||||
["/bin/dash", ["x = 1", "y"]],
|
||||
["/opt/bin/mksh", ["x = 1", "y"]],
|
||||
] as const)("derives the dialect from the shell executable: %s", async (shell, resources) => {
|
||||
const result = await Effect.runPromise(ShellParse.scanPortable("(( x = 1 )); y", shell, "/workspace"))
|
||||
expect(result.commands.map((command) => command.resource)).toEqual([...resources])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,496 @@
|
||||
import { afterAll, describe, expect, test } from "bun:test"
|
||||
import { Effect, Exit } from "effect"
|
||||
import fs from "fs"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { ShellParse } from "../../src/shell/parse.js"
|
||||
import { ShellScan } from "../../src/shell/scan.js"
|
||||
|
||||
const shellCandidates = ["/bin/bash", "/opt/homebrew/bin/bash", "/usr/local/bin/bash", "bash", "zsh", "dash"]
|
||||
const shells = [
|
||||
...new Set(
|
||||
shellCandidates
|
||||
.map((item) => (item.startsWith("/") ? (fs.existsSync(item) ? item : undefined) : Bun.which(item)))
|
||||
.filter((item): item is string => Boolean(item)),
|
||||
),
|
||||
]
|
||||
// Fixtures target specific shells and versions (bash 3.2 and 5.3, zsh, dash). Set SHELL_ORACLE_STRICT=1 on a
|
||||
// machine with all of them to also verify that every fixture still executes somewhere.
|
||||
const strict = process.env.SHELL_ORACLE_STRICT === "1"
|
||||
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-shell-oracle-"))
|
||||
const bin = path.join(root, "bin")
|
||||
const target = path.join(root, "target")
|
||||
const log = path.join(root, "log")
|
||||
fs.mkdirSync(bin)
|
||||
fs.mkdirSync(target)
|
||||
fs.writeFileSync(
|
||||
path.join(bin, "scan_probe"),
|
||||
'#!/bin/sh\nprintf \'%s\\n\' "scan_probe${1:+ $*}" >> "$SCAN_PROBE_LOG"\n',
|
||||
{
|
||||
mode: 0o755,
|
||||
},
|
||||
)
|
||||
// Dash runs `scan_probe[x y]=1` as the command `scan_probe[x`.
|
||||
fs.copyFileSync(path.join(bin, "scan_probe"), path.join(bin, "scan_probe[x"))
|
||||
// Zsh treats a trailing parenthesized group after an existing file name as glob qualifiers.
|
||||
fs.writeFileSync(path.join(root, "a="), "")
|
||||
|
||||
afterAll(() => fs.rmSync(root, { recursive: true, force: true }))
|
||||
|
||||
function shellFlags(executable: string) {
|
||||
if (executable.endsWith("/bash")) return ["--noprofile", "--norc"]
|
||||
if (executable.endsWith("/zsh")) return ["-f"]
|
||||
return []
|
||||
}
|
||||
|
||||
// Runs source in a real shell and returns the lines it logged.
|
||||
function observe(executable: string, source: string) {
|
||||
fs.writeFileSync(log, "")
|
||||
Bun.spawnSync([executable, ...shellFlags(executable), "-c", source], {
|
||||
cwd: root,
|
||||
env: { PATH: `${bin}:/usr/bin:/bin`, HOME: root, LC_ALL: "C", SCAN_PROBE_LOG: log },
|
||||
timeout: 2_000,
|
||||
})
|
||||
return fs
|
||||
.readFileSync(log, "utf8")
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
// Each dialect must report what its shells run; posix, the default, covers every shell.
|
||||
const dialects = {
|
||||
bash: (executable: string) => path.basename(executable) === "bash",
|
||||
zsh: (executable: string) => path.basename(executable) === "zsh",
|
||||
posix: () => true,
|
||||
} satisfies Record<ShellScan.Dialect, (executable: string) => boolean>
|
||||
|
||||
function expectProbesReported(source: string) {
|
||||
const runs = shells.map((executable) => [executable, observe(executable, source)] as const)
|
||||
if (strict)
|
||||
expect(
|
||||
runs.some(([, invocations]) => invocations.length > 0),
|
||||
`Fixture never executed scan_probe in any real shell: ${source}`,
|
||||
).toBe(true)
|
||||
expect(ShellScan.scan(source)).toEqual(ShellScan.scan(source, "posix"))
|
||||
for (const [dialect, runsIn] of Object.entries(dialects)) {
|
||||
const result = ShellScan.scan(source, dialect as ShellScan.Dialect)
|
||||
if (result.kind === "opaque") continue
|
||||
const reported = result.commands.filter((command) => !command.declaration).map((command) => command.words.join(" "))
|
||||
for (const [executable, invocations] of runs.filter(([executable]) => runsIn(executable)))
|
||||
for (const invocation of invocations)
|
||||
expect(reported, `${executable} executed ${invocation} with ${dialect} in: ${source}`).toContain(invocation)
|
||||
}
|
||||
}
|
||||
|
||||
// Each fixture runs scan_probe in at least one real shell, which the scanner must report or reject.
|
||||
const fixtures = [
|
||||
"a=(1)scan_probe",
|
||||
"a=(\n1)scan_probe; ",
|
||||
"a=(1)'scan_probe'",
|
||||
"alias p=scan_probe\np",
|
||||
"command alias p=scan_probe\np",
|
||||
"x='*(e:scan_probe:)'; echo $^~x",
|
||||
"x='$(scan_probe)'; echo \"${\\\n(e)x}\"",
|
||||
"x='$(scan_probe)'; echo ${(j:):e)x}",
|
||||
"x='*(e:scan_probe:)'; echo ${(f)~x}",
|
||||
"x='*(e:scan_probe:)'; echo ${=~x}",
|
||||
"x='*(e:scan_probe:)'; echo $=~x",
|
||||
"x='$(scan_probe)'; echo ${(%e)x}",
|
||||
"x='$(scan_probe)'; echo ${(s.:.e)x}",
|
||||
"x='$(scan_probe)'; echo ${(s(:)e)x}",
|
||||
"x='$(scan_probe)'; echo ${(ej: :)x}",
|
||||
"x='*(e:scan_probe:)'; echo ${(s: :)~x}",
|
||||
"x='*(e:scan_probe:)'; echo ${^~x}",
|
||||
"printf -v x 'a[$(scan_probe)]'; echo $((x))",
|
||||
"a=(1); getopts a: x -a 'a[$(scan_probe)]'; echo $((OPTARG))",
|
||||
"command -- declare 'a[$(scan_probe)]=1'",
|
||||
"a=(1); unset 'a[b[$\\\n(scan_probe)0]]'",
|
||||
'declare \'a["\\"]"$(scan_probe)0]=1\'',
|
||||
"declare -a 'a=(+ [$(scan_probe)]=1)'",
|
||||
"a=(1); echo ${a[b[\\$(scan_probe)]]}",
|
||||
'a["b[\\$(scan_probe)]"]=1',
|
||||
'b=1; a["b[\\$(scan_probe)1]"]=1',
|
||||
"a=(1); echo $[ ${x:-a[\\$(scan_probe)1]} ]",
|
||||
"a=(1); s=abc; echo ${s:'a[$(scan_probe)0]'}",
|
||||
"a=(1); s=abc; echo ${s:${x:-'a[$(scan_probe)1]'}}",
|
||||
"read x <<< 'a[$(scan_probe f)]'; echo $((x))",
|
||||
"mapfile -t a <<< 'b[$(scan_probe g)]'; echo $((a[0]))",
|
||||
"x=1; echo $(( x[\\$(scan_probe i)] ))",
|
||||
"a=(1); echo ${a[@]:'a[$(scan_probe k)]'}",
|
||||
"builtin -- declare 'a[$(scan_probe)]=1'",
|
||||
"command -p declare 'a[$(scan_probe)]=1'",
|
||||
'echo "${unset:+${x[\'"\']}}"]}} \'$(scan_probe)\' " # "',
|
||||
"echo \"${unset:+${x['}}\"']}}'; scan_probe # \"",
|
||||
"echo \"${x:-$'\\''}\"; scan_probe # '\"",
|
||||
"cat <<E\n${x:-'}'}' $(scan_probe)\nE",
|
||||
'echo "${x:-a\'b}c\'d}"$(scan_probe)"\'"',
|
||||
"echo \"${PATH//:/$'\\n'}\"; scan_probe ok",
|
||||
"echo $(( : # ))'\n); scan_probe ) # '",
|
||||
"echo $(( $(echo 1) # ))'\n); scan_probe ) # '",
|
||||
"(( : # ))'\n); scan_probe ) # '",
|
||||
"echo $(( 16#ff + 2#1 + $# + ${#x} )); scan_probe ok",
|
||||
"(( scan_probe ))",
|
||||
"(( (scan_probe) & (scan_probe) ))",
|
||||
"(( (scan_probe)\n(scan_probe) ))",
|
||||
"cat <<'}'; {\n:\n}\nscan_probe; cat <<'}'; }\n}",
|
||||
"cat <<'x)'; case x in\nx)\nx) scan_probe; cat <<'x)'\nx)\n;; esac",
|
||||
"cat <<'if'; f()\nif\nif scan_probe; cat <<'if'\nif\ntrue; then :; fi; f",
|
||||
"cat <<E; ( true\nE\nscan_probe g\n)",
|
||||
"cat <<E; { true\nE\nscan_probe g\n}",
|
||||
"cat <<E; f() { true\nE\nscan_probe f\n}; f",
|
||||
"cat <<E; if true\nE\nscan_probe i\nthen :; fi",
|
||||
"cat <<E; echo $(true\nscan_probe s\n)\nE\nscan_probe after",
|
||||
"cat <<E; echo `true\nscan_probe b\n`\nE\nscan_probe after",
|
||||
"cat <<E; cat <(true\nscan_probe s\n)\nE\nscan_probe after",
|
||||
"cat <<E; x=$(cat <<F\nF\n)\nE\nscan_probe out",
|
||||
"{ cat <<E; }\nscan_probe x\nE\nscan_probe y",
|
||||
"[[ a]]b# ]] && scan_probe",
|
||||
'[[ "a]]"# ]] && scan_probe',
|
||||
"[[ 'a]]'# ]] && scan_probe",
|
||||
"[[ a]]b = a]]b ]] && scan_probe",
|
||||
'{ export X="a"{}# ; scan_probe; }',
|
||||
"{ export X=a}# ; scan_probe; }",
|
||||
"{ case esac in (esac) scan_probe;; esac\n}",
|
||||
"unset a[b\nscan_probe\necho ]+=1",
|
||||
"export a[b; scan_probe; echo ]=1",
|
||||
"a[b; scan_probe; echo ]=1",
|
||||
"a[0 #]\n]=1; scan_probe",
|
||||
"for i in 1; { scan_probe; }; while false; do export X=1; done",
|
||||
"for ((i=0; i<1; i++)); { scan_probe; }; while false; do export X=1; done",
|
||||
"for i in 1; scan_probe",
|
||||
"((( echo '\"' ); scan_probe; ( echo '\"' )))",
|
||||
"set=1; export X=${set:-${x['0\"0']}}; scan_probe; : '\"]}}' # '",
|
||||
"export X=$$$$'\\'; scan_probe # '",
|
||||
'export X="`export Y=\\"\'\\" ; scan_probe; export Z=\\"\'\\"`"',
|
||||
'export X="`echo \\"(\\"; scan_probe; echo \\")\\"`"',
|
||||
": <<-export\n\tex\\\n\tport\n$(scan_probe)\nexport",
|
||||
'export X="${\\\n scan_probe; }"',
|
||||
'export X="${\n scan_probe; }"',
|
||||
'export X="${|\\\n REPLY=$(scan_probe); }"',
|
||||
"!(scan_probe; true)",
|
||||
"if !(scan_probe); then :; fi",
|
||||
"f+() { scan_probe; }; f+",
|
||||
': <<$"export"\n$export\nscan_probe\nexport',
|
||||
': <<$"export"\nexport\nscan_probe\n$export',
|
||||
"export X=$[1; scan_probe; : ]",
|
||||
"X=$[1 scan_probe ]",
|
||||
'export X=$["]"]; scan_probe # ]',
|
||||
"export X=${#}# ; scan_probe",
|
||||
"export X=$#a# ; scan_probe",
|
||||
"export X=~+# ; scan_probe",
|
||||
"{ export X=1; }# ; scan_probe; }",
|
||||
"if true; then export X=1; fi# ; scan_probe; fi",
|
||||
"for i in 1; do export X=1; done# ; scan_probe; done",
|
||||
"case esac# in x) export X=1;; esac#) scan_probe;; esac",
|
||||
": <<A <<B\nA\n$(scan_probe)\nB",
|
||||
'export X="$(cat <<EOF\n)\nEOF\nscan_probe)"',
|
||||
"cat <<EOF | scan_probe\nhello\nEOF",
|
||||
"cat <<EOF && scan_probe\nhello\nEOF",
|
||||
"cat <<'E'\"O\"F\n# $(scan_probe)\nEOF\nscan_probe",
|
||||
"cat <<E\\\nOF\n$(scan_probe)\nEOF",
|
||||
"cat <<-EOF\n\t EOF\n$(scan_probe)\n\tEOF",
|
||||
"cat <<export\nex\\\nport\n# $(scan_probe)\nexport",
|
||||
"cat <<< 'a'\"$(scan_probe)\"",
|
||||
'export X=${x:-"}"}; scan_probe',
|
||||
"export X=${x:-'}'}; scan_probe",
|
||||
'export X="${x:-"}"}"; scan_probe',
|
||||
'export X="${x:-"$(scan_probe)"}"',
|
||||
"export X=${x//\\}/}; scan_probe; : }",
|
||||
'export X="${x//\\}/}"; scan_probe; : "}"',
|
||||
"export X=${x#${y}}; scan_probe",
|
||||
'export X="${x#${y:-"}"}}"; scan_probe',
|
||||
"export X=${!prefix*}; scan_probe",
|
||||
"export X=${x@Q}; scan_probe",
|
||||
"export X=\"${unset:+${x['\"']}}\"; scan_probe; : '\"}]}'",
|
||||
'export X="${unset:+${x[\'}}\"; scan_probe; : \"\']}}"',
|
||||
'echo $(case x in x) echo ")" ;; esac; scan_probe)',
|
||||
"echo \"$(case x in (x) echo ')' ;; esac; scan_probe)\"",
|
||||
'echo "`echo \\"$(scan_probe)\\"`"',
|
||||
"echo `echo \\`scan_probe\\``",
|
||||
"echo $( (echo a); scan_probe )",
|
||||
"echo $((echo a); scan_probe)",
|
||||
"if true; then scan_probe; elif true; then :; else :; fi",
|
||||
"for i in in do done esac; do scan_probe; done",
|
||||
"for do in 1; do scan_probe; done",
|
||||
"for in in 1; do scan_probe; done",
|
||||
"for x\nin 1; do scan_probe; done",
|
||||
"set -- 1; for x\ndo scan_probe; done",
|
||||
"set -- 1; for x do scan_probe; done",
|
||||
"case in in in) scan_probe;; esac",
|
||||
"case esac in a|esac) scan_probe;; esac",
|
||||
"case y in x) ;; y) scan_probe;; esac",
|
||||
"case x in x) scan_probe ;& y) : ;;& z) : ;; esac",
|
||||
"case x in x) scan_probe ;| y) : ;; esac",
|
||||
"case [ in [) scan_probe & ( scan_probe q ]) ;; esac",
|
||||
"case x in (x|[) scan_probe & ( scan_probe q ]) ;; esac",
|
||||
"while false; do :; done & scan_probe",
|
||||
"{ scan_probe & }",
|
||||
"( scan_probe & )",
|
||||
"[[ b =~ b ]] && scan_probe",
|
||||
"[[ ( a == a ) && ( b == b ) ]] && scan_probe",
|
||||
'[[ "$(scan_probe)" == "]]" ]]',
|
||||
"(( 1 + $(scan_probe) ))",
|
||||
"(( a = 1 )) && scan_probe",
|
||||
"(( (1) + (2) )); scan_probe",
|
||||
"(( (echo a); scan_probe ))",
|
||||
">/dev/null scan_probe",
|
||||
"2>&1 scan_probe",
|
||||
"A=1 >/dev/null B=2 scan_probe",
|
||||
'export A=1 B="$(scan_probe)"',
|
||||
'declare -a arr=(1 "$(scan_probe)")',
|
||||
"export X=$'a'\\\n; scan_probe",
|
||||
"case x in \\\nx) \\\nscan_probe;; \\\nesac",
|
||||
"if true; then \\\nscan_probe; fi",
|
||||
'() { :; } "$(scan_probe)"',
|
||||
"function f() ( scan_probe ); f",
|
||||
"{ export X=1 } && scan_probe",
|
||||
"{ export X=1 } ; scan_probe ; }",
|
||||
|
||||
// Dialects disagree about reserved words after redirects and operators split by line continuations.
|
||||
"if true; then >/dev/null fi; scan_probe; fi",
|
||||
"if true; then >/dev/null fi; scan_probe",
|
||||
"case x in x) >/dev/null esac; scan_probe; esac",
|
||||
"true &>/dev/null scan_probe",
|
||||
"true &>>/dev/null scan_probe",
|
||||
"true &\\\n>/dev/null scan_probe",
|
||||
"cat <<\\\n-EOF\nEOF\nscan_probe h1\n-EOF",
|
||||
"cat <<\\\n-EOF\n-EOF\nscan_probe z\nEOF",
|
||||
"cat <\\\n(scan_probe i)",
|
||||
"[[ -n <\\\n(scan_probe c1) ]]",
|
||||
"a=(<\\\n(scan_probe a1))",
|
||||
"cat <<E\n$\\\n(scan_probe h)\nE",
|
||||
"echo ${x:-$\\\n(scan_probe p)}",
|
||||
"(( $\\\n(scan_probe a) ))",
|
||||
"[[ $\\\n(scan_probe c) ]]",
|
||||
"a=($\\\n(scan_probe arr))",
|
||||
|
||||
// Dash splits assignment subscripts at blanks.
|
||||
"scan_probe[x y]=1",
|
||||
"a[x '$(scan_probe)']=1",
|
||||
"a[1 + $(scan_probe)]=1",
|
||||
|
||||
// Expansions inside parameter words, arithmetic, and subscripts.
|
||||
"echo \"${x:-$'$(scan_probe q1)'}\"",
|
||||
"echo ${x:-<(scan_probe p1)}",
|
||||
"echo ${x:->(scan_probe g)}",
|
||||
"x=${y:-<(scan_probe p4)}",
|
||||
"[[ x == ${y:-<(scan_probe p5)} ]]",
|
||||
"echo $(( $'$(scan_probe a)' ))",
|
||||
"(( x = $'$(scan_probe b)' ))",
|
||||
"echo $(( ${x:-'$(scan_probe a)'} ))",
|
||||
"(( ${x:-'$(scan_probe b)'} ))",
|
||||
"a[${x:-'$(scan_probe c)'}]=1",
|
||||
"echo $[ ${x:-'$(scan_probe d)'} ]",
|
||||
"echo $[ $'$(scan_probe h)' ]",
|
||||
"echo ${a[${x:-'$(scan_probe e)'}]}",
|
||||
"echo \"${a[${x:-'$(scan_probe f)'}]}\"",
|
||||
"cat <<E\n${x:-'$(scan_probe h)'}\nE",
|
||||
|
||||
// Zsh glob qualifiers and extglob groups run code in globbed words.
|
||||
"echo @(<(scan_probe e1))",
|
||||
"printf '%s' @(one|$(scan_probe))",
|
||||
"echo *(e:'scan_probe q1':)",
|
||||
"echo *(+scan_probe)",
|
||||
"a=(*(e:'scan_probe g':))",
|
||||
"declare -a a=(*(e:'scan_probe g':))",
|
||||
"export a=(*(e:'scan_probe h':))",
|
||||
"for f in *(e:'scan_probe h':); do :; done",
|
||||
"echo ${x:-target(e:'scan_probe p2':)}",
|
||||
"echo ${x:-*(e:'scan_probe j':)}",
|
||||
"echo a=(e:'scan_probe p3':)",
|
||||
"echo >*(e:'scan_probe f':)",
|
||||
"cat <*(e:'scan_probe g':)",
|
||||
"echo $x*(e:'scan_probe h':)",
|
||||
"echo \"\"*(e:'scan_probe i':)",
|
||||
"echo {a,*(e:'scan_probe m':)}",
|
||||
|
||||
// Builtins and arithmetic evaluate subscripts in decoded literal text.
|
||||
"declare -i x='a[$(scan_probe)]'",
|
||||
"declare 'a[$(scan_probe)]=1'",
|
||||
"declare -a 'a=([$(scan_probe)]=1)'",
|
||||
"a=(1); unset 'a[$(scan_probe)]'",
|
||||
'a=(1); unset "a[\\$(scan_probe)]"',
|
||||
"[[ 'a[$(scan_probe)]' -eq 1 ]]",
|
||||
"[[ -v 'a[$(scan_probe)]' ]]",
|
||||
"read 'a[$(scan_probe)]' </dev/null",
|
||||
"printf -v 'a[$(scan_probe)]' x",
|
||||
"x='a[$(scan_probe)]'; echo $((x))",
|
||||
"s=abc; x='a[$(scan_probe)0]'; printf '%s' \"${s:x}\"",
|
||||
"ref='x[$(scan_probe)0]'; printf '%s' \"${!ref}\"",
|
||||
"declare ${x:-'a[$(scan_probe)]=1'}",
|
||||
'declare "${x:-a[\\$(scan_probe)]=1}"',
|
||||
"read ${x:-'a[$(scan_probe)]'} </dev/null",
|
||||
"declare \"$(echo 'a[$(scan_probe)]=1')\"",
|
||||
"declare \"${ echo a; }\"'[$(scan_probe)]=1'",
|
||||
"n=a; declare \"$n\"'[$(scan_probe)]=1'",
|
||||
"declare \"$(echo a)\"'[$(scan_probe)]=1'",
|
||||
"builtin declare 'a[$(scan_probe)]=1'",
|
||||
"printf -v'a[$(scan_probe)]' x",
|
||||
"set -- 'a[$(scan_probe)]'; echo $(($1))",
|
||||
"for x in 'a[$(scan_probe)]'; do echo $((x)); done",
|
||||
"a=(1 'a[$(scan_probe)]'); echo $((a[1]))",
|
||||
"x='a[$(scan_probe)]' eval 'echo $((x))'",
|
||||
"a=(1); echo $(( a[\\$(scan_probe)] ))",
|
||||
"a=(1); (( a[\\$(scan_probe)] ))",
|
||||
|
||||
// Explicit evaluation operators.
|
||||
"x='$(scan_probe)'; echo ${x@P}",
|
||||
"x='$(scan_probe)'; echo \"${x@P}\"",
|
||||
"x='$(scan_probe)'; echo ${(e)x}",
|
||||
"x='$(scan_probe)'; echo ${(ee)x}",
|
||||
"echo ${(e):-'$(scan_probe)'}",
|
||||
"x='*(e:scan_probe:)'; echo ${~x}",
|
||||
"x='*(e:scan_probe:)'; echo $~x",
|
||||
|
||||
// Precommand modifiers, bundled/ordered binding options, repeat, compgen, and setopt.
|
||||
"noglob typeset 'a[$(scan_probe)]=1'",
|
||||
"nocorrect typeset 'a[$(scan_probe)]=1'",
|
||||
"a=(1); noglob unset 'a[$(scan_probe)]'",
|
||||
"noglob read 'a[$(scan_probe)]' <<< x",
|
||||
'a=(1); noglob let "a[\\$(scan_probe)]"',
|
||||
"print -r -v 'a[$(scan_probe)]' x",
|
||||
"print -rv 'a[$(scan_probe)]' x",
|
||||
"a=(1); print -r -v x 'a[$(scan_probe)]'; echo $((x))",
|
||||
"a=(1); print -rv x 'a[$(scan_probe)]'; echo $((x))",
|
||||
": & wait -np 'a[$(scan_probe)]'",
|
||||
": & wait -n -p'a[$(scan_probe)]'",
|
||||
": & wait -fp 'a[$(scan_probe)]' $!",
|
||||
"time declare 'a[$(scan_probe)]=1'",
|
||||
"coproc declare 'a[$(scan_probe)]=1'",
|
||||
"a=(1); time unset 'a[$(scan_probe)]'",
|
||||
"a=(1); time let 'a[$(scan_probe)]'",
|
||||
"a=(1); time [[ -v 'a[$(scan_probe)]' ]]",
|
||||
"a=(1); time [[ 'a[$(scan_probe)]' -eq 1 ]]",
|
||||
"a=(1); coproc [[ -v 'a[$(scan_probe)]' ]]",
|
||||
'export X="${ time ! { :; }; scan_probe; }"',
|
||||
'export X="${ time\\\n { :; }; scan_probe; }"',
|
||||
'export X="${ time -- { :; }; scan_probe; }"',
|
||||
"repeat 1 scan_probe",
|
||||
"a=(1); repeat 'a[$(scan_probe)]' :",
|
||||
"setopt globsubst; x='*(e:scan_probe:)'; echo $x",
|
||||
"set -o globsubst; x='*(e:scan_probe:)'; echo $x",
|
||||
"compgen -C scan_probe",
|
||||
"compgen -C 'scan_probe'",
|
||||
|
||||
// Combined literal/output subscripts, deferred bindings, continuations, Zsh flags, and ANSI-C escapes.
|
||||
"declare \"a[$(echo '$(scan_probe)')]=1\"",
|
||||
"declare \"$(echo 'a[')\"'$(scan_probe)]=1'",
|
||||
"a=(1); unset \"a[$(echo '$(scan_probe)')]\"",
|
||||
"a=(1); echo $(( a[$(echo '$(scan_probe)')] ))",
|
||||
"x='$(scan_probe)'; declare \"a[$x]=1\"",
|
||||
"x='$(scan_probe)'; a=(1); unset \"a[$x]\"",
|
||||
"x='$(scan_probe)'; let \"a[$x]=1\"",
|
||||
"x='$(scan_probe)'; read \"a[$x]\" <<< 1",
|
||||
"x='$(scan_probe)'; printf -v \"a[$x]\" 1",
|
||||
"a=(1); x='$(scan_probe)'; echo $((a[$x]))",
|
||||
"a=(1); x='$(scan_probe)'; [[ -v \"a[$x]\" ]]",
|
||||
"a=(1); x='$(scan_probe)'; [[ \"a[$x]\" -eq 1 ]]",
|
||||
"a=(1); echo ${a\\\n['$(scan_probe)']}",
|
||||
"a=(1); s=abc; echo ${s\\\n:'a[$(scan_probe)0]'}",
|
||||
"a=(1); echo ${a[0]\\\n:'a[$(scan_probe)0]'}",
|
||||
"x='$(scan_probe)'; echo ${x@\\\nP}",
|
||||
"x='$(scan_probe)'; echo \"${x@\\\nP}\"",
|
||||
"x='$(scan_probe)'; echo ${x@P\\\n}",
|
||||
"x='*(e:scan_probe:)'; echo $\\\n~x",
|
||||
"x='*(e:scan_probe:)'; echo $^\\\n~x",
|
||||
"x='*(e:scan_probe:)'; echo ${^\\\n~x}",
|
||||
"x='*(e:scan_probe:)'; echo ${(f)\\\n~x}",
|
||||
"a=(1); echo ${(f)a['a[$(scan_probe)]']}",
|
||||
"a=(1); echo ${=a['a[$(scan_probe)]']}",
|
||||
"a=(1); echo ${^a['a[$(scan_probe)]']}",
|
||||
"a=(1); s=abc; echo ${(f)s:'a[$(scan_probe)0]'}",
|
||||
"a=(1); s=abc; echo ${=s:'a[$(scan_probe)0]'}",
|
||||
"a=(1); s=abc; echo ${^s:'a[$(scan_probe)0]'}",
|
||||
"a=(1); [[ x != *(a)]] && 0 -eq 'a[$(scan_probe)]' ]]",
|
||||
"a=(1); [[ x != *(a)]] && -v 'a[$(scan_probe)]' ]]",
|
||||
"a=(1); [[ x != (a)]] && 0 -eq 'a[$(scan_probe)]' ]]",
|
||||
"shopt -s extglob\na=(1); [[ x != *(a)]] && 0 -eq 'a[$(scan_probe)]' ]]",
|
||||
"a=(1); echo $(( $'a\\x5b\\x24\\x28scan_probe\\x29\\x5d' ))",
|
||||
"a=(1); (( $'a\\x5b\\x24\\x28scan_probe\\x29\\x5d' ))",
|
||||
"a=(1); echo ${a[$'a\\x5b\\x24\\x28scan_probe\\x29\\x5d']}",
|
||||
"a=(1); declare \"${x:-$'a\\x5b\\x24\\x28scan_probe\\x29\\x5d=1'}\"",
|
||||
"a=(1); unset \"${x:-$'a\\x5b\\x24\\x28scan_probe\\x29\\x5d'}\"",
|
||||
"(( $'\\' )) # ' )); scan_probe",
|
||||
"declare -A a; a[$'\\']=1 # ']=1; scan_probe",
|
||||
"a=(1); echo ${a[$'\\']} # ']} $(scan_probe)",
|
||||
": <<$'export'\n$export\nscan_probe\nexport",
|
||||
": <<$'EOF'\n$EOF\nscan_probe\nEOF",
|
||||
"echo $'\\'; scan_probe; : ' # '",
|
||||
"export X=$'\\'; scan_probe; : ' # '",
|
||||
"export X=$'\\c\\\\'; scan_probe # '",
|
||||
"export X=$'\\c'; scan_probe; : ' # '",
|
||||
"$'scan_probe\\0x'",
|
||||
"$'scan_probe\\x00x'",
|
||||
"$'scan_probe\\u0000x'",
|
||||
"$'scan_probe\\c@x'",
|
||||
"$'declare\\0x' 'a[$(scan_probe)]=1'",
|
||||
"a=(1); $'unset\\0x' 'a[$(scan_probe)]'",
|
||||
"$'let\\0x' 'a[$(scan_probe)]'",
|
||||
"x='x y'; scan_probe[$x]=1",
|
||||
] as const
|
||||
|
||||
// These also run inside every wrapper below.
|
||||
const nestedFixtures = [
|
||||
"[[ a]]# ]] && scan_probe",
|
||||
"{ export X={}# ; scan_probe; }",
|
||||
"unset a[b; scan_probe; echo ]=1",
|
||||
"case esac in (esac) scan_probe;; esac",
|
||||
"case esac in (a|esac) scan_probe;; esac",
|
||||
"for i in 1; { if true; then scan_probe; fi }; while false; do export X=1; done",
|
||||
"(( echo '\"' ); scan_probe; ( echo '\"' ))",
|
||||
"export X=${unset:+${x['0\"0']}}; scan_probe; : '\"]}}' # '",
|
||||
"export X=$$'\\'; scan_probe # '",
|
||||
": <<-export\n\tex\\\n\tport\n# $(scan_probe)\nexport",
|
||||
"!(scan_probe)",
|
||||
"f+() case x in x) scan_probe;; esac; f+",
|
||||
"f@g() case x in x) scan_probe;; esac; f@g",
|
||||
"f@g() for i in 1; do scan_probe; done; f@g",
|
||||
] as const
|
||||
|
||||
const wrappers: Array<[name: string, wrap: (inner: string) => string]> = [
|
||||
["$(...)", (inner) => `export OUTER=$( ${inner}\n)`],
|
||||
['"$(...)"', (inner) => `export OUTER="$( ${inner}\n)"`],
|
||||
["backticks", (inner) => `export OUTER=\` ${inner}\n\``],
|
||||
["heredoc", (inner) => `: <<EOF\n$( ${inner}\n)\nEOF`],
|
||||
["case arm", (inner) => `case x in x) ${inner}\n;; esac`],
|
||||
["for loop", (inner) => `for k in 1; do ${inner}\ndone`],
|
||||
["function", (inner) => `wrap_fn() {\n${inner}\n}; wrap_fn`],
|
||||
["brace group", (inner) => `{ ${inner}\n}`],
|
||||
]
|
||||
|
||||
describe.skipIf(process.platform === "win32")("real-shell soundness oracle", () => {
|
||||
test("discovers at least bash on PATH", () => {
|
||||
expect(shells.some((item) => item.endsWith("/bash"))).toBe(true)
|
||||
})
|
||||
|
||||
test.each([...fixtures, ...nestedFixtures])("reports or rejects real-shell execution: %j", (source) => {
|
||||
expectProbesReported(source)
|
||||
})
|
||||
|
||||
test.each(wrappers.flatMap(([name, wrap]) => nestedFixtures.map((source) => [name, source, wrap(source)])))(
|
||||
"reports or rejects in %s: %j",
|
||||
(_, __, source) => {
|
||||
expectProbesReported(source)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe.skipIf(process.platform === "win32")("real-shell directory oracle", () => {
|
||||
test.each(["cd >/dev/null TARGET", "cd 2>/dev/null TARGET", "cd $'TARGET'", 'cd "TARGET"'])(
|
||||
"reports the directory a real shell changes to: %s",
|
||||
async (template) => {
|
||||
const source = template.replace("TARGET", target)
|
||||
const targets = [target, fs.realpathSync(target)]
|
||||
const changed = shells.filter((executable) =>
|
||||
targets.includes(observe(executable, `${source}\npwd >> "$SCAN_PROBE_LOG"`).at(-1) ?? ""),
|
||||
)
|
||||
expect(changed.length, `Fixture never changed directory in any real shell: ${source}`).toBeGreaterThan(0)
|
||||
for (const executable of changed) {
|
||||
const parsed = await Effect.runPromiseExit(ShellParse.scanPortable(source, executable, root))
|
||||
if (Exit.isSuccess(parsed)) expect(parsed.value.directories, `${executable} in: ${source}`).toContain(target)
|
||||
}
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -38,6 +38,11 @@ describe("Bash redirect resource oracle", () => {
|
||||
`${redirect} FOO=bar git status 3>tail`,
|
||||
`npm run ${redirect} test`,
|
||||
]) {
|
||||
// Dash reads `&>` as `&` and `>`, so words after its target start another command there.
|
||||
if (redirect.startsWith("&") && !command.endsWith(redirect)) {
|
||||
expect(ShellScan.scan(command).kind).toBe("opaque")
|
||||
continue
|
||||
}
|
||||
await parity(command)
|
||||
for (const separator of separators) {
|
||||
await parity(`printf ok${separator}${command}`)
|
||||
@@ -80,7 +85,6 @@ describe("Bash redirect resource oracle", () => {
|
||||
"pwd | cat 2\\>out",
|
||||
"if true; then printf ok && cat >$(printf path); fi",
|
||||
"if true; then printf ok && git >out status; else cat >log; fi",
|
||||
"pwd && cd >out /outside",
|
||||
"time git status",
|
||||
"time -p git status",
|
||||
"coproc git status",
|
||||
@@ -151,4 +155,12 @@ describe("Bash redirect resource oracle", () => {
|
||||
{ resource: "FOO=bar >output git status", save: "git status *" },
|
||||
])
|
||||
})
|
||||
|
||||
test("known gap: redirect before cd operand retains the target directory natively", async () => {
|
||||
const source = "pwd && cd >out /outside"
|
||||
const legacy = await Effect.runPromise(ShellParse.scan(source, "/bin/bash", "/workspace"))
|
||||
const native = await Effect.runPromise(ShellParse.scanPortable(source, "/bin/bash", "/workspace"))
|
||||
expect(legacy).toEqual({ commands: [{ resource: "pwd", save: "pwd *" }], directories: [] })
|
||||
expect(native).toEqual({ commands: [{ resource: "pwd", save: "pwd *" }], directories: ["/outside"] })
|
||||
})
|
||||
})
|
||||
@@ -234,8 +234,6 @@ describe("ShellScan", () => {
|
||||
"{fd}>/tmp/log touch /tmp/victim",
|
||||
"time touch /tmp/victim",
|
||||
"printf '%s' \"$(printf safe ${x%)}; touch /tmp/victim)\"",
|
||||
"s=abc; x='a[$(touch /tmp/victim)0]'; printf '%s' \"${s:x}\"",
|
||||
"ref='x[$(touch /tmp/victim)0]'; printf '%s' \"${!ref}\"",
|
||||
"if true; then echo safe; fi > /tmp/victim",
|
||||
"if true; then :; 'if' victim; fi",
|
||||
])("scans Bash lexical forms without interpreting shell values: %s", (command) => {
|
||||
|
||||
@@ -27,6 +27,8 @@ type Live = {
|
||||
revision: number
|
||||
/** Strip writes waiting for the session's location, without which the strip cannot be written. */
|
||||
held: { mirror?: () => void; focus?: Browser.TabID }
|
||||
/** The agent's previews waiting for the session's screen, which resolves their paths. */
|
||||
previews: string[]
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
@@ -110,6 +112,7 @@ export function createModel(ctx: SetupContext<typeof definition>) {
|
||||
ref,
|
||||
revision: 0,
|
||||
held: {},
|
||||
previews: [],
|
||||
dispose: () => undefined,
|
||||
connection: createConnection({
|
||||
client,
|
||||
@@ -132,7 +135,7 @@ export function createModel(ctx: SetupContext<typeof definition>) {
|
||||
|
||||
layout.open(key(tabID), ref, { tab: "select" })
|
||||
},
|
||||
preview: (path) => preview(ref, path),
|
||||
preview: (path) => preview(entry, path),
|
||||
inspect: (event) => inspectors.get(id)?.forEach((listener) => listener(event)),
|
||||
change: (next, mirror) => {
|
||||
if (next.error === "browser.pane.unsupported") {
|
||||
@@ -217,6 +220,12 @@ export function createModel(ctx: SetupContext<typeof definition>) {
|
||||
}),
|
||||
)
|
||||
|
||||
// Previews made while another session was on screen open once the user returns to this one.
|
||||
createKeyed(
|
||||
() => sessions.current()?.key === ref.key && ctx.screen.current(),
|
||||
() => entry.previews.splice(0).forEach((path) => preview(entry, path)),
|
||||
)
|
||||
|
||||
return dispose
|
||||
}, owner)
|
||||
|
||||
@@ -348,11 +357,18 @@ export function createModel(ctx: SetupContext<typeof definition>) {
|
||||
return { view, path }
|
||||
}
|
||||
|
||||
// The agent's browser.preview tool: the link router picks the browser for HTML, the file panel otherwise.
|
||||
const preview = (ref: SessionRef, path: string) => {
|
||||
// The agent's browser.preview tool: the link router picks the browser for HTML, the file panel otherwise. Only the
|
||||
// session's screen resolves workspace paths, so a preview for a session that is not on screen waits for it.
|
||||
const preview = (entry: Live, path: string) => {
|
||||
const view = sessions.current()
|
||||
|
||||
if (view?.key === ref.key) links.open({ href: path, session: view, background: true })
|
||||
if (view?.key === entry.ref.key && ctx.screen.current()) {
|
||||
links.open({ href: path, session: view, background: true })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (!entry.previews.includes(path)) entry.previews.push(path)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { DataProvider } from "@opencode/session-ui/context"
|
||||
import { BackgroundWork } from "./background"
|
||||
|
||||
const tasks = [
|
||||
{ id: "task_explore", type: "subagent" as const, agent: "explore", label: "Reviewing component implementation" },
|
||||
{ id: "task_status", type: "shell" as const, label: "opencode2 service status" },
|
||||
{ id: "task_openapi", type: "shell" as const, label: "opencode2 api get /openapi.json" },
|
||||
{ id: "task_tests", type: "shell" as const, label: "bun test packages/app" },
|
||||
]
|
||||
|
||||
export default {
|
||||
title: "OpenCode/Session/Background work",
|
||||
id: "session-background-work",
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: "Production controls for moving blocking work and inspecting active background tasks.",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const DetailsPanelEntry = {
|
||||
render: () => (
|
||||
<div class="w-[280px] rounded-[6px] bg-v2-background-bg-base px-0.5 py-1.5 shadow-[var(--v2-elevation-raised)]">
|
||||
<DataProvider
|
||||
data={{ session: [], session_status: {}, session_diff: {} }}
|
||||
directory="/project"
|
||||
onSessionHref={(id) => `#${id}`}
|
||||
>
|
||||
<BackgroundWork tasks={tasks} />
|
||||
</DataProvider>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { Popover } from "@kobalte/core/popover"
|
||||
import { useData } from "@opencode/session-ui/context"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { TextShimmer } from "@opencode/ui/text-shimmer"
|
||||
import { createMemo, createSignal, For, on, Show } from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { useExtension, type BackgroundTask } from "../sdk"
|
||||
|
||||
export function BackgroundWork(props: { tasks: readonly BackgroundTask[]; mobile?: boolean }) {
|
||||
const ctx = useExtension()
|
||||
const locale = ctx.locale
|
||||
const data = useData()
|
||||
const running = createMemo(() => props.tasks.length > 0)
|
||||
// A new period each time work starts or ends: the list closes when the last task ends and stays closed when work returns.
|
||||
const period = createMemo(on(running, () => ({})))
|
||||
const [opened, setOpened] = createSignal<object>()
|
||||
const open = () => opened() === period()
|
||||
const setOpen = (value: boolean) => setOpened(value ? period() : undefined)
|
||||
|
||||
const taskType = (task: BackgroundTask) => {
|
||||
if (task.type === "shell") return ctx.t("ui.tool.shell")
|
||||
|
||||
if (!task.agent) return ctx.t("ui.tool.agent.default")
|
||||
|
||||
return task.agent.slice(0, 1).toUpperCase() + task.agent.slice(1)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open()}
|
||||
placement={props.mobile ? "top-end" : locale.direction() === "rtl" ? "right-end" : "left-end"}
|
||||
gutter={4}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<Show when={running()}>
|
||||
<Popover.Trigger
|
||||
as="button"
|
||||
type="button"
|
||||
data-component="session-background-summary"
|
||||
class="session-summary-row"
|
||||
aria-label={ctx.plural("background.tasksRunning", props.tasks.length)}
|
||||
>
|
||||
<Icon name="outline-arrow-to-corner-top-right" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<TextShimmer
|
||||
as="span"
|
||||
text={ctx.plural("background.tasksRunning", props.tasks.length)}
|
||||
active
|
||||
class="session-summary-label"
|
||||
/>
|
||||
</Popover.Trigger>
|
||||
</Show>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
data-component="session-background-list"
|
||||
class="session-service-menu"
|
||||
aria-label={ctx.plural("background.tasksRunning", props.tasks.length)}
|
||||
>
|
||||
<For each={props.tasks.slice(0, 10)}>
|
||||
{(task) => (
|
||||
<Dynamic
|
||||
component={task.type === "subagent" ? "a" : "div"}
|
||||
data-component="session-background-list-item"
|
||||
class="session-service-row"
|
||||
classList={{
|
||||
"hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none":
|
||||
task.type === "subagent",
|
||||
}}
|
||||
href={task.type === "subagent" ? data.sessionHref?.(task.id) : undefined}
|
||||
onClick={(event: MouseEvent) => {
|
||||
if (task.type !== "subagent" || !data.navigateToSession) return
|
||||
|
||||
if (event.button !== 0 || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
|
||||
event.preventDefault()
|
||||
setOpen(false)
|
||||
data.navigateToSession(task.id)
|
||||
}}
|
||||
>
|
||||
<span class="shrink-0">{taskType(task)}</span>
|
||||
<span class="session-summary-label text-v2-text-text-faint">{task.label}</span>
|
||||
</Dynamic>
|
||||
)}
|
||||
</For>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -3,8 +3,6 @@ export default {
|
||||
tooltip: "ማጠቃለያ",
|
||||
"command.toggle": "ማጠቃለያ ቀያይር",
|
||||
noBranch: "ቅርንጫፍ የለም",
|
||||
"background.tasksRunning.one": "{{count}} የዳራ ተግባር እየሰራ ነው",
|
||||
"background.tasksRunning.other": "{{count}} የዳራ ተግባራት እየሰሩ ናቸው",
|
||||
"move.title": "ወደ workspace አንቀሳቅስ",
|
||||
"move.menu": "ክፍለ ጊዜን አንቀሳቅስ ወደ",
|
||||
"move.failed": "ክፍለ ጊዜን ማንቀሳቀስ አልተሳካም",
|
||||
|
||||
Loaded 100 of 188 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user