diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b479c8d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog + +Notable changes to OpenCode Workbench are documented here. + +## Unreleased + +- Improved Marketplace discovery metadata with a more descriptive display name, focused search keywords, and AI/Chat categories. +- Reworked the Marketplace overview to foreground the primary workflow, representative screenshots, privacy details, and support resources. + +## 0.2.0 - 2026-07-18 + +- Migrated chat streaming and hydration to the raw OpenCode SDK event flow. +- Refreshed the chat interface around the OpenCode design system. +- Updated the tested OpenCode SDK and server compatibility contract. diff --git a/README.md b/README.md index cb6b712..9ad9df2 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,18 @@ -# OpenCode UI for VS Code +# OpenCode Workbench — VS Code UI -[![VS Code Marketplace](https://img.shields.io/badge/VS%20Code%20Marketplace-Open-007ACC?logo=visualstudiocode&logoColor=white)](https://marketplace.visualstudio.com/items?itemName=chryzxc.opencode-vscode-chryzxc) -[![Open VSX](https://img.shields.io/badge/Open%20VSX-Open-C160EF)](https://open-vsx.org/user/chryzxc) -[![Installs](https://img.shields.io/badge/Installs-View%20on%20Marketplace-6E7781)](https://marketplace.visualstudio.com/items?itemName=chryzxc.opencode-vscode-chryzxc) -[![GitHub Repo stars](https://img.shields.io/github/stars/chryzxc/vscode-opencode?style=social)](https://github.com/chryzxc/vscode-opencode/stargazers) -[![GitHub issues](https://img.shields.io/github/issues/chryzxc/vscode-opencode)](https://github.com/chryzxc/vscode-opencode/issues) +[![VS Code Marketplace installs](https://img.shields.io/visual-studio-marketplace/i/chryzxc.opencode-vscode-chryzxc?label=VS%20Code%20installs)](https://marketplace.visualstudio.com/items?itemName=chryzxc.opencode-vscode-chryzxc) +[![VS Code Marketplace rating](https://img.shields.io/visual-studio-marketplace/r/chryzxc.opencode-vscode-chryzxc)](https://marketplace.visualstudio.com/items?itemName=chryzxc.opencode-vscode-chryzxc&ssr=false#review-details) +[![GitHub stars](https://img.shields.io/github/stars/chryzxc/vscode-opencode?style=social)](https://github.com/chryzxc/vscode-opencode/stargazers) -A OpenCode GUI for VS Code. +An independent VS Code client for the OpenCode coding agent, powered by [`@opencode-ai/sdk`](https://www.npmjs.com/package/@opencode-ai/sdk). Chat with your codebase, build implementation plans, track subagents, review changes, and monitor model quotas without leaving the editor. -OpenCode GUI brings the OpenCode coding agent directly into Visual Studio Code with streaming chat, code context, file and image attachments, implementation plans, subagent tracking, session history, and live quota monitoring. +![OpenCode Workbench conversation view](./assets/conversation-1.png) -It is built for developers who use OpenCode but want a smoother IDE workflow instead of switching between the terminal and editor. +- Review implementation plans with annotations before execution +- Follow live subagent activity, tool calls, todos, and session progress +- Attach code and images, review diffs, manage sessions, and monitor provider quotas -If this extension helps your workflow, consider giving the repo a star. - ---- - -## Demo - -### Main Conversation Flow - -![Main conversation flow demo](./assets/demo.gif) - -### Interactive Plan Viewer - -![Interactive plan viewer demo](./assets/demo-1.gif) +[Install from the VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=chryzxc.opencode-vscode-chryzxc) --- @@ -69,6 +57,7 @@ If you already use OpenCode in the terminal, this extension gives you a more int - View subagent activity, tool calls, and execution progress - Monitor live quota usage for GitHub Copilot, Z.ai, OpenAI, and other supported providers - Keep persistent session history across VS Code restarts +- Fork a completed AI response into a new conversation branch - Use slash-command skills, MCP status, LSP status, and agent panels from one UI --- @@ -87,6 +76,7 @@ This gives you the power of OpenCode while keeping your planning, prompting, rev - Markdown rendering with syntax highlighting - Collapsible reasoning/thinking sections - Copy message content to clipboard +- Fork a conversation from any completed assistant response - Inline image previews - Unified error cards for API failures and timeout issues - Token usage tracking for prompt, response, reasoning, and cache activity @@ -133,7 +123,8 @@ This gives you the power of OpenCode while keeping your planning, prompting, rev ### Session History - Persistent chat sessions across VS Code restarts -- Rename, delete, and fork sessions +- Rename and delete sessions +- Fork from a completed assistant response to branch the conversation at that point - Keep long-running work organized - Automatically compact sessions when context usage gets too high - Continue working without losing important context @@ -173,26 +164,20 @@ Plugin-provided agents, skills, and capabilities can be surfaced directly inside ## Screenshots +Prefer motion? Watch the [conversation flow demo](./assets/demo.gif) or the [interactive plan viewer demo](./assets/demo-1.gif). + ### Conversation View Full chat flow with streaming responses, structured outputs, and session continuity. -![Conversation view 1](./assets/conversation-1.png) -![Conversation view 2](./assets/conversation-2.png) ![Conversation view 3](./assets/conversation-3.png) -![Conversation view](./assets/conversation.png) ### Plan Builder and Annotations Plan workflow from generation to review, including comments and annotations before execution. ![Interactive planner](./assets/interactive-planner.png) -![Plan builder](./assets/plan-builder.png) -![Plan builder 1](./assets/plan-builder-1.png) -![Interactive plan viewer](./assets/interactive-plan-viewer.png) ![Interactive plan viewer with annotations](./assets/interactive-plan-viewer-with-annotations.png) -![Plan annotations](./assets/plan-annotations.png) -![Adding comments in plan as annotations](./assets/add-comments-in-plan.png) ### Attachments and Code Context @@ -204,9 +189,7 @@ Attach file references and highlighted code lines directly in the chat box. Agent-generated tasks, subagent activity, and side-panel visibility for active execution state. -![Extended panel](./assets/extended-panel.png) ![Subagents](./assets/subagents.png) -![Todo panel](./assets/todo.png) ### Quota and Interactive Questions @@ -214,8 +197,6 @@ Interactive question flow with selectable answers, custom responses, and live qu ![Quota view](./assets/quota.png) ![Interactive question](./assets/interactive-question.png) -![Interactive question 2](./assets/interactive-question-2.png) -![Custom answer](./assets/custom-answer.png) --- @@ -279,7 +260,8 @@ You can: - Click the history icon in the chat header to open the session list. - Sessions are listed with title, date, and token count. -- Use the session menu to rename, delete, or fork a session. +- Use the session menu to rename or delete a session. +- To branch a conversation, click the **Fork conversation from this response** icon beside **Copy** below a completed assistant response. The new child session includes history through that response and opens ready for your next prompt. ### Stopping a Response @@ -333,6 +315,17 @@ or add them to `settings.json`. --- +## Privacy and Data Handling + +- Prompts, attachments, and code context are sent to the locally managed OpenCode server. OpenCode then communicates with the model providers configured by the user. +- Quota monitoring reads supported OpenCode authentication files on the local machine and contacts the corresponding provider usage endpoints. Token refreshes may update those local authentication files. +- Model capability metadata may be fetched from [models.dev](https://models.dev/). +- Session metadata, token usage, budgets, and optional logs are stored locally. This extension does not include a product analytics or telemetry service. + +See [SUPPORT.md](./SUPPORT.md) for diagnostics, troubleshooting, and responsible vulnerability reporting. + +--- + ## Architecture ```txt diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 0000000..6943ab2 --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,28 @@ +# OpenCode Workbench Support + +## Before Reporting an Issue + +1. Confirm that `opencode --version` works in the VS Code integrated terminal. +2. Update OpenCode to a version supported by the extension. +3. Run **OpenCode: Restart Server** from the Command Palette. +4. Check **View → Output → OpenCode** for an actionable error. +5. Reload the VS Code window and try the workflow again. + +## Report a Bug + +[Open a GitHub issue](https://github.com/chryzxc/vscode-opencode/issues) and include: + +- OpenCode Workbench version +- VS Code version and operating system +- OpenCode CLI version +- Clear reproduction steps +- Expected and actual behavior +- Relevant OpenCode Output logs with tokens, credentials, file contents, and other sensitive data removed + +## Feature Requests + +Feature requests are welcome in [GitHub Issues](https://github.com/chryzxc/vscode-opencode/issues). Describe the workflow problem first, then the behavior you would like the extension to provide. + +## Security Reports + +Do not post credentials, tokens, private prompts, or proprietary source code in a public issue. For a potentially sensitive vulnerability, open an issue containing only a high-level description and request private coordination with the maintainer. diff --git a/package.json b/package.json index f3a8746..1d2f837 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "opencode-vscode-chryzxc", - "displayName": "OpenCode UI for VS Code", - "description": "OpenCode CLI UI for VS Code with streaming chat, code context, implementation plans, subagents, session history, and quota monitoring.", + "displayName": "OpenCode Workbench — VS Code UI", + "description": "A VS Code client for the OpenCode coding agent, powered by the OpenCode SDK, with streaming chat, plans, subagents, sessions, diff review, and quota monitoring.", "license": "MIT", "repository": { "type": "git", @@ -41,31 +41,36 @@ "engines": { "vscode": "^1.85.0" }, - "keywords": [ - "opencode", - "opencode cli", - "opencode ui", - "opencode vscode", - "copilot style", - "ai assistant", - "ai coding agent", - "coding assistant", - "agentic coding", - "code assistant", - "subagents", - "implementation plan", - "code context", - "session history", - "mcp", - "quota monitor", - "github copilot quota", - "openai quota", - "z.ai quota" - ], - "categories": [ - "AI", - "Other" - ], + "keywords": [ + "opencode", + "open code", + "opencode ai", + "opencode cli", + "opencode gui", + "opencode vscode", + "opencode extension", + "ai coding agent", + "ai code assistant", + "coding assistant", + "agentic coding", + "ai chat", + "code context", + "code review", + "diff review", + "implementation plan", + "plan mode", + "subagents", + "session history", + "mcp", + "quota monitoring", + "openai", + "github copilot", + "gemini" + ], + "categories": [ + "AI", + "Chat" + ], "activationEvents": [ "onStartupFinished" ], diff --git a/scripts/test-impact-map.json b/scripts/test-impact-map.json index 57733ca..63459df 100644 --- a/scripts/test-impact-map.json +++ b/scripts/test-impact-map.json @@ -30,7 +30,8 @@ "src/providers/ChatViewProvider.ts", "src/services/MessageStreamService.ts", "src/services/SubagentTracker.ts", - "webview/shared/src/chat/lib/messageHandler.ts" + "webview/shared/src/chat/lib/messageHandler.ts", + "webview/shared/src/chat/lib/truncateLargeStrings.ts" ], "tests": [ "tests/unit/providers/ChatViewProvider.unit.test.mjs", @@ -40,6 +41,7 @@ "tests/unit/subagent-ui-contract.test.mjs", "tests/webview/message-handler-processing.test.mjs", "tests/webview/interactive-events-contract.test.mjs", + "tests/webview/truncate-large-strings-behavior.test.mjs", "tests/unit/system-prompt-history-filter.test.mjs" ] }, @@ -79,7 +81,10 @@ "tests": [ "tests/regression/chat-css-regression.test.mjs", "tests/webview/message-handler-processing.test.mjs", - "tests/webview/interactive-events-contract.test.mjs" + "tests/webview/interactive-events-contract.test.mjs", + "tests/webview/truncate-large-strings-behavior.test.mjs", + "tests/webview/toast-events-behavior.test.mjs", + "tests/webview/subagent-duration-behavior.test.mjs" ] }, { diff --git a/src/providers/ChatViewProvider.ts b/src/providers/ChatViewProvider.ts index 6e42b6f..f0c8abd 100644 --- a/src/providers/ChatViewProvider.ts +++ b/src/providers/ChatViewProvider.ts @@ -329,7 +329,17 @@ export class ChatViewProvider * response is still streaming from the server. */ private activeStreamSessionId: string | undefined; private turnEpochBySession = new Map(); - private pendingStreamWebviewEvents: Array<{ event: unknown; sessionId?: string }> = []; + private pendingStreamWebviewEvents: Array<{ + event: unknown; + sessionId?: string; + immediate?: boolean; + }> = []; + /** + * The first event of a newly-started turn is presentation-critical: it is + * what replaces the generic thinking bubble with the live assistant card. + * Later events remain batched to protect the webview during token streams. + */ + private firstStreamWebviewEventPendingBySession = new Set(); private streamWebviewFlushTimer: ReturnType | undefined; private lastStreamPerformanceLogAt = 0; /** @@ -340,6 +350,57 @@ export class ChatViewProvider private liveEventDebugFlushTimer: ReturnType | undefined; private currentTodoItems: unknown[] = []; private compatibilityWarningsOverride: CompatibilityResult[] | null = null; + private subagentProjectionWrites = new Map>(); + + private getSubagentProjectionStorageKey(sessionId: string): string { + return `opencode.session.subagentProjection.${sessionId}`; + } + + /** Persist the extension-owned subagent projection; SDK history does not expose it. */ + private persistSubagentProjection( + sessionId: string, + projection: SubagentUpdatePayload, + ): Promise { + const previous = this.subagentProjectionWrites.get(sessionId) ?? Promise.resolve(); + const write = previous + .catch(() => undefined) + .then(() => + this.context.workspaceState.update(this.getSubagentProjectionStorageKey(sessionId), { + sessionId, + savedAt: Date.now(), + ...projection, + }), + ); + this.subagentProjectionWrites.set(sessionId, write); + void write.finally(() => { + if (this.subagentProjectionWrites.get(sessionId) === write) { + this.subagentProjectionWrites.delete(sessionId); + } + }); + return write; + } + + private async restorePersistedSubagentProjection(sessionId: string): Promise { + const projection = this.context.workspaceState.get< + SubagentUpdatePayload & { sessionId?: string } + >(this.getSubagentProjectionStorageKey(sessionId)); + if (projection?.sessionId && projection.sessionId !== sessionId) { + return; + } + if ( + !projection || + (!Object.keys(projection.summariesByParentMessageId ?? {}).length && + !Object.keys(projection.detailsById ?? {}).length) + ) { + return; + } + this.view?.webview.postMessage({ + type: "subagentUpdate", + sessionId, + summariesByParentMessageId: projection.summariesByParentMessageId, + detailsById: projection.detailsById, + }); + } private getTodoStorageKey(sessionId: string): string { return `opencode.session.todos.${sessionId}`; @@ -389,8 +450,9 @@ export class ChatViewProvider event: unknown, sessionId: string | undefined, flushImmediately = false, + immediate = false, ): void { - this.pendingStreamWebviewEvents.push({ event, sessionId }); + this.pendingStreamWebviewEvents.push({ event, sessionId, immediate }); if (flushImmediately) { // Lifecycle events should start delivery immediately, but must not @@ -508,6 +570,7 @@ export class ChatViewProvider type: "streamEvent", event: item.event, sessionId: item.sessionId, + immediate: item.immediate === true, }); this.logStreamPerformance("provider-webview-flush", startedAt, 1); this.scheduleStreamWebviewBacklogFlush(hasBacklog); @@ -519,6 +582,7 @@ export class ChatViewProvider events: pending.map((item) => ({ event: item.event, sessionId: item.sessionId, + immediate: item.immediate === true, })), }); this.logStreamPerformance("provider-webview-flush", startedAt, pending.length); @@ -884,7 +948,6 @@ export class ChatViewProvider private readonly sessionDiffFromStream = new Map>(); private readonly recentUiErrorToastTimestamps = new Map(); private readonly UI_ERROR_TOAST_DEDUPE_WINDOW_MS = 15_000; - private lastCompatibilityWarningSignature: string | undefined; private readonly installedSdkVersion = detectInstalledOpencodeSdkVersion(); /** ===== NEW: Module instances ===== */ @@ -1702,6 +1765,7 @@ export class ChatViewProvider private markTurnStreamStarted(sessionId: string): void { const currentEpoch = (this.turnEpochBySession.get(sessionId) ?? 0) + 1; this.turnEpochBySession.set(sessionId, currentEpoch); + this.firstStreamWebviewEventPendingBySession.add(sessionId); } private async schedulePostTurnSdkRefresh(sessionId: string, _messageId?: string): Promise { @@ -1731,6 +1795,7 @@ export class ChatViewProvider contextInputTokens: history.contextInputTokens, processingSessionIds: this.getEffectiveProcessingSessionIds(), }); + await this.restorePersistedSubagentProjection(sessionId); await this.refreshPendingInteractionsFromSdk(sessionId); } catch (err) { this.logger.warn("Post-turn SDK refresh failed", { @@ -1806,10 +1871,19 @@ export class ChatViewProvider continue; } const childSessionId = this.firstNonEmptyString(subtaskPart.sessionID, subtaskPart.sessionId); - const childSession = childSessionId ? childrenById.get(childSessionId) : undefined; - if (!childSessionId || !childSession) { + if (!childSessionId) { continue; } + // A parent transcript can expose its `subtask` part before the + // child session is included in `session.children`. The subtask is + // already an authoritative SDK record of the invocation, so retain + // it as a pending projection and enrich it once child metadata is + // available instead of dropping the entire card on rehydration. + const childSession = childrenById.get(childSessionId) ?? { + id: childSessionId, + model: subtaskPart.model, + time: {}, + }; const baseDetail = sdkAdaptSubtaskPart(subtaskPart, info); const detail = this.enrichSdkSubtaskDetail(baseDetail as any, subtaskPart, childSession, parentSessionId); @@ -1944,8 +2018,19 @@ export class ChatViewProvider ); } - private async handleLoadSession(sessionId: string): Promise { + private async handleLoadSession( + sessionId: string, + options?: { suppressSessionLoading?: boolean }, + ): Promise { const flow = log.startFeatureFlow('LoadSession', { sessionId }); + this.logger.warn("[FORK_TRACE][HOST] handleLoadSession entered", { + sessionId, + suppressSessionLoading: options?.suppressSessionLoading === true, + currentSessionId: this.currentSessionId, + activeStreamSessionId: this.activeStreamSessionId, + processingSessionIds: Array.from(this.processingSessionIds), + effectiveProcessingSessionIds: this.getEffectiveProcessingSessionIds(), + }); if (!sessionId) { log.endFeatureFlow(flow, { status: 'failed', reason: 'No sessionId provided' }); @@ -2019,9 +2104,20 @@ export class ChatViewProvider messages: messages, sdkMessages: sessionHistory.sdkMessages, contextInputTokens: sessionHistory.contextInputTokens, - + // A fork is already a completed snapshot. It needs history hydration, + // but must not present as a newly-running assistant turn. + suppressSessionLoading: options?.suppressSessionLoading === true, processingSessionIds: this.getEffectiveProcessingSessionIds(), }); + await this.restorePersistedSubagentProjection(sessionId); + this.logger.warn("[FORK_TRACE][HOST] chatHistory posted", { + sessionId, + suppressSessionLoading: options?.suppressSessionLoading === true, + messageCount: messages.length, + sdkMessageCount: sessionHistory.sdkMessages.length, + processingSessionIds: this.getEffectiveProcessingSessionIds(), + activeStreamSessionId: this.activeStreamSessionId, + }); await this.compactionManager.sendCompactionViewStateForMessages( sessionId, messages, @@ -2029,7 +2125,6 @@ export class ChatViewProvider // Step 4: NOW send initState with the updated session ID // This comes AFTER chatHistory so the session switch is already detected - this.maybeShowCompatibilityWarningNotice(this.getCompatibilityWarnings()); this.view?.webview.postMessage({ type: "initState", serverStatus: this.serverManager.getStatus(), @@ -2045,6 +2140,12 @@ export class ChatViewProvider showLogger: vscode.workspace.getConfiguration("opencode.logging").get("showLogger", true), todoItems: [], }); + this.logger.warn("[FORK_TRACE][HOST] initState posted", { + sessionId: this.currentSessionId, + suppressSessionLoading: options?.suppressSessionLoading === true, + processingSessionIds: this.getEffectiveProcessingSessionIds(), + activeStreamSessionId: this.activeStreamSessionId, + }); void this.refreshSdkTodosForSession(this.currentSessionId); void this.refreshPendingInteractionsFromSdk(sessionId); @@ -3242,7 +3343,6 @@ export class ChatViewProvider } // Send refreshed init state reflecting the session-specific selections - this.maybeShowCompatibilityWarningNotice(this.getCompatibilityWarnings()); this.view?.webview.postMessage({ type: "initState", serverStatus: this.serverManager.getStatus(), @@ -3338,6 +3438,7 @@ export class ChatViewProvider }); await this.sendPersistedCompactionViewState(currentSession.id); this.subagentTracker.resetForSession(currentSession.id); + await this.restorePersistedSubagentProjection(currentSession.id); this.sendQueueUpdate(currentSession.id); } else { this.subagentTracker.resetForSession(null); @@ -3726,6 +3827,13 @@ export class ChatViewProvider await vscode.env.clipboard.writeText(text); break; } + case "forkSession": { + await this.handleForkSession( + this.firstNonEmptyString(message.sessionId), + this.firstNonEmptyString(message.messageId), + ); + break; + } case "openFile": { await this.handleOpenFile(message.file); break; @@ -4463,6 +4571,12 @@ export class ChatViewProvider type: "subagentUpdate", ...subagentUpdate, }); + if (subagentParentSessionId) { + void this.persistSubagentProjection( + subagentParentSessionId, + this.subagentTracker.getSnapshotPayload(), + ); + } this.sendProcessingSessionsUpdate(); } @@ -4752,7 +4866,23 @@ export class ChatViewProvider // mutated — only the webview-bound copy is slimmed. Session ownership // still travels in the surrounding webview protocol envelope. const eventForWebview = this.buildWebviewStreamEvent(enrichedEvent || event); + // Status/heartbeat transport events can precede the assistant payload; + // do not let one consume the turn's one immediate presentation slot. + const canMountAssistantResponse = + eventType.startsWith("message.") || + eventType === "question.asked" || + eventType === "permission.asked" || + eventType === "permission.request"; + const isInitialTurnEvent = Boolean( + resolvedSessionId && + canMountAssistantResponse && + this.firstStreamWebviewEventPendingBySession.delete(resolvedSessionId), + ); const shouldFlushWebviewImmediately = + // Do not leave the first real assistant event behind the presentation + // batch. The webview uses it to mount the response card in place of + // the generic loading bubble; subsequent events are still batched. + isInitialTurnEvent || eventType === "question.asked" || eventType === "permission.asked" || eventType === "permission.request" || @@ -4769,6 +4899,7 @@ export class ChatViewProvider eventForWebview, resolvedSessionId, shouldFlushWebviewImmediately, + isInitialTurnEvent, ); if ( eventType === "question.asked" || @@ -9110,7 +9241,6 @@ export class ChatViewProvider * Refreshes the view with current state */ private refreshView(): void { - this.maybeShowCompatibilityWarningNotice(this.getCompatibilityWarnings()); this.view?.webview.postMessage({ type: "initState", serverStatus: this.serverManager.getStatus(), @@ -9158,7 +9288,6 @@ export class ChatViewProvider ): void { this.compatibilityWarningsOverride = warnings; const nextWarnings = this.getCompatibilityWarnings(); - this.maybeShowCompatibilityWarningNotice(nextWarnings); this.view?.webview.postMessage({ type: "compatibilityStatus", compatibilityWarnings: nextWarnings, @@ -9166,39 +9295,8 @@ export class ChatViewProvider this.refreshView(); } - private maybeShowCompatibilityWarningNotice( - compatibilityWarnings: ReturnType, - ): void { - if (compatibilityWarnings.length === 0) { - this.lastCompatibilityWarningSignature = undefined; - return; - } - - const signature = compatibilityWarnings - .map((warning) => - [ - warning.component, - warning.status, - warning.version ?? "unknown", - warning.supportedRange, - ].join(":"), - ) - .join("|"); - - if (this.lastCompatibilityWarningSignature === signature) { - return; - } - - this.lastCompatibilityWarningSignature = signature; - const summary = compatibilityWarnings - .map((warning) => warning.message) - .join("\n"); - vscode.window.showWarningMessage(summary); - } - private broadcastCompatibilityWarnings(): void { const compatibilityWarnings = this.getCompatibilityWarnings(); - this.maybeShowCompatibilityWarningNotice(compatibilityWarnings); this.view?.webview.postMessage({ type: "compatibilityStatus", compatibilityWarnings: compatibilityWarnings, @@ -9938,6 +10036,130 @@ export class ChatViewProvider ); } + private async handleForkSession( + requestedSessionId?: string, + requestedMessageId?: string, + ): Promise { + const sourceSessionId = this.firstNonEmptyString( + requestedSessionId, + this.currentSessionId, + ); + const sourceMessageId = this.firstNonEmptyString(requestedMessageId); + const sendResult = ( + success: boolean, + error?: string, + forkedSessionId?: string, + ) => { + this.view?.webview.postMessage({ + type: "forkSessionResult", + success, + sessionId: sourceSessionId, + messageId: sourceMessageId, + forkedSessionId, + error, + }); + }; + + if (!sourceSessionId || !sourceMessageId) { + const error = "Unable to fork conversation: missing session or message identifier."; + this.logger.warn("Session fork skipped", { + sourceSessionId, + sourceMessageId, + error, + }); + vscode.window.showWarningMessage(error); + sendResult(false, error); + return; + } + + try { + const client = await this.serverManager.ensureRunning(); + const workspaceDir = this.getWorkspaceDirectory(); + this.logger.warn("[FORK_TRACE][HOST] calling session.fork", { + sourceSessionId, + sourceMessageId, + currentSessionId: this.currentSessionId, + workspaceDir: workspaceDir ?? null, + activeStreamSessionId: this.activeStreamSessionId, + processingSessionIds: Array.from(this.processingSessionIds), + effectiveProcessingSessionIds: this.getEffectiveProcessingSessionIds(), + }); + const forkResult = await client.session.fork({ + sessionID: sourceSessionId, + messageID: sourceMessageId, + ...(workspaceDir ? { directory: workspaceDir } : {}), + }); + const forkError = getSdkResponseError(forkResult); + const forkData = getSdkResponseData(forkResult) as { id?: unknown } | undefined; + const forkedSessionId = this.firstNonEmptyString(forkData?.id); + + if (forkError || !forkedSessionId) { + const error = forkError + ? forkError instanceof Error + ? forkError.message + : typeof forkError === "object" && forkError !== null + ? String( + (forkError as Record).message ?? + (forkError as Record).data ?? + JSON.stringify(forkError), + ) + : String(forkError) + : "OpenCode did not return the new forked session."; + this.logger.error("Failed to fork session", { + sourceSessionId, + sourceMessageId, + error, + }); + vscode.window.showErrorMessage(`Failed to fork conversation: ${error}`); + sendResult(false, error); + return; + } + + this.logger.info("Session fork created", { + sourceSessionId, + sourceMessageId, + forkedSessionId, + }); + let forkedSessionStatus: unknown = null; + try { + const statusResult = await client.session.status( + workspaceDir ? { directory: workspaceDir } : {}, + ); + const statuses = getSdkResponseData(statusResult) as + | Record + | undefined; + forkedSessionStatus = statuses?.[forkedSessionId] ?? null; + } catch (statusError) { + forkedSessionStatus = { + statusLookupError: + statusError instanceof Error ? statusError.message : String(statusError), + }; + } + this.logger.warn("[FORK_TRACE][HOST] fork response and server status", { + sourceSessionId, + sourceMessageId, + forkedSessionId, + forkedSessionStatus, + activeStreamSessionId: this.activeStreamSessionId, + processingSessionIds: Array.from(this.processingSessionIds), + effectiveProcessingSessionIds: this.getEffectiveProcessingSessionIds(), + }); + sendResult(true, undefined, forkedSessionId); + await this.handleLoadSession(forkedSessionId, { + suppressSessionLoading: true, + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + this.logger.error("Failed to fork session", { + sourceSessionId, + sourceMessageId, + error: errorMessage, + }); + vscode.window.showErrorMessage(`Failed to fork conversation: ${errorMessage}`); + sendResult(false, errorMessage); + } + } + private async handleUndoMessageChanges( messageId?: string, requestedSessionId?: string, diff --git a/tests/helpers/webview-module.mjs b/tests/helpers/webview-module.mjs new file mode 100644 index 0000000..882e94c --- /dev/null +++ b/tests/helpers/webview-module.mjs @@ -0,0 +1,52 @@ +// Runtime loader for webview TypeScript modules from .mjs test files. +// +// Why this exists: +// - The primary CI test command is `node --test tests/**/*.test.mjs`, which cannot +// import `.ts` files natively. +// - Colocated `.test.ts` files under `webview/shared/src/` are not picked up by +// `npm test` today, so runtime coverage of webview pure functions is effectively +// zero in CI. +// - `tsx` is already a project devDep and exposes a programmatic ESM loader +// (`tsx/esm/api`) that compiles and imports TypeScript on demand. +// +// This helper lets `.test.mjs` regression tests import the real TypeScript +// implementation and assert on actual runtime behavior (rather than only +// matching source text with regex, like `source-utils.mjs`). +// +// Usage: +// import { importWebviewModule } from "../helpers/webview-module.mjs"; +// const { truncateLargeStrings } = await importWebviewModule( +// "webview/shared/src/chat/lib/truncateLargeStrings.ts", +// ); +// +// Notes: +// - Modules that transitively import `react`, `./vscode`, or other host-side +// code will fail to load here. Stick to pure-TypeScript lib modules with +// no runtime side effects. Type-only imports are erased by tsx and are safe. +// - `tsImport` caches compiled modules per file path, so repeated calls in the +// same process are cheap. + +import { tsImport } from "tsx/esm/api"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = path.resolve(__dirname, "../.."); + +/** + * Import a TypeScript module from the webview by repo-relative path. + * + * @param {string} repoRelativePath - e.g. "webview/shared/src/chat/lib/truncateLargeStrings.ts" + * @returns {Promise>} The module's exports. + */ +export async function importWebviewModule(repoRelativePath) { + if (typeof repoRelativePath !== "string" || !repoRelativePath.trim()) { + throw new Error("importWebviewModule: repoRelativePath must be a non-empty string"); + } + const resolved = path.resolve(repoRoot, repoRelativePath); + const mod = await tsImport(resolved, import.meta.url); + return mod; +} + +export { repoRoot }; diff --git a/tests/regression/chat-css-regression.test.mjs b/tests/regression/chat-css-regression.test.mjs index 964fb22..f080c81 100644 --- a/tests/regression/chat-css-regression.test.mjs +++ b/tests/regression/chat-css-regression.test.mjs @@ -38,6 +38,50 @@ test('chat css pipeline keeps Tailwind enabled for utility-heavy React chat comp assert.match(panelComponentsSource, /className="[^"]*\bpx-3\b[^"]*"/, 'panel components should continue using spacing utility classes'); }); +test('details overview uses the same content gutter as the other tabs', () => { + assert.match( + chatCssSource, + /\.oc-details-sheet\s+\.oc-details-tab-content--overview\s*\{[^}]*padding:\s*8px\s+10px\s+10px;/s, + 'the Active Task overview should align with Quota, Integrations, and Tools content', + ); + assert.match( + chatCssSource, + /\.oc-details-sheet\s+\.oc-details-tab-content--overview\s*>\s*\.oc-active-task-panel\s*\{[^}]*margin-top:\s*0;/s, + 'the overview panel should not add a second top inset inside the shared tab gutter', + ); +}); + +test('Active Task is a peer overview section instead of a parent heading', () => { + assert.match( + panelComponentsSource, + /className="oc-active-task-content"[\s\S]*?/, + 'Active Task should use the same MiniSection row as Context, Runtime, and Session', + ); + assert.doesNotMatch( + panelComponentsSource, + /oc-panel-title">Active Task/, + 'the checklist content should not introduce a second nested section title', + ); +}); + +test('overview section headers use labels and chevrons without decorative dots', () => { + assert.doesNotMatch( + panelComponentsSource, + /inline-block h-1\.5 w-1\.5 rounded-full transition-colors/, + 'MiniSection headers should not render a low-contrast decorative status dot', + ); + assert.doesNotMatch( + chatCssSource, + /\.oc-details-sheet\s+\.oc-inspector-section-toggle\s*>\s*span:first-child\s*\{[^}]*width:\s*5px/s, + 'the first section-header span is its label, not a dot that needs dot sizing styles', + ); +}); + // ── Markdown list styling regression tests ─────────────────────────────────────── test('markdown-body ordered lists must display with decimal numbers (not bullets)', () => { diff --git a/tests/regression/session-fork-action-regression.test.mjs b/tests/regression/session-fork-action-regression.test.mjs new file mode 100644 index 0000000..6131d00 --- /dev/null +++ b/tests/regression/session-fork-action-regression.test.mjs @@ -0,0 +1,86 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { joinFromRoot, readSource } from "../helpers/source-utils.mjs"; + +const messageSource = readSource( + [joinFromRoot("webview", "shared", "src", "chat", "MessageComponents.tsx")], + "MessageComponents.tsx", +); +const providerSource = readSource( + [joinFromRoot("src", "providers", "ChatViewProvider.ts")], + "ChatViewProvider.ts", +); +const messageHandlerSource = readSource( + [ + joinFromRoot( + "webview", + "shared", + "src", + "chat", + "lib", + "messageHandler.ts", + ), + ], + "messageHandler.ts", +); + +test("assistant fork action follows the copy footer visibility contract", () => { + assert.match(messageSource, /GitFork/, "assistant response should import a fork icon"); + assert.match( + messageSource, + /!isStreamingActive &&[\s\S]*showResponseSection &&[\s\S]*hasCopyableResponseContent && \(/, + "fork should only appear in the completed, copyable response footer", + ); + assert.match( + messageSource, + /type:\s*"forkSession"[\s\S]*sessionId:\s*centralizedSessionId[\s\S]*messageId:\s*assistantMessageId/s, + "fork requests should target the rendered response's session and assistant message", + ); +}); + +test("host forks through the v2 SDK and loads the returned child session", () => { + assert.match( + providerSource, + /case "forkSession"[\s\S]*handleForkSession/s, + "provider should accept the webview fork message", + ); + assert.match( + providerSource, + /client\.session\.fork\(\{[\s\S]*sessionID:\s*sourceSessionId,[\s\S]*messageID:\s*sourceMessageId,/s, + "provider should fork from the selected assistant message", + ); + assert.match( + providerSource, + /getSdkResponseError\(forkResult\)[\s\S]*getSdkResponseData\(forkResult\)/s, + "provider should handle SDK responses that return errors instead of throwing", + ); + assert.match( + providerSource, + /await this\.handleLoadSession\(forkedSessionId,\s*\{\s*suppressSessionLoading:\s*true,/s, + "successful forks should open the child session without presenting it as AI work", + ); + assert.match( + providerSource, + /type:\s*"chatHistory"[\s\S]*suppressSessionLoading:\s*options\?\.suppressSessionLoading === true/s, + "host should carry the fork hydration marker to the webview", + ); +}); + +test("fork hydration bypasses generic session-loading state", () => { + assert.match( + messageHandlerSource, + /const suppressSessionLoading = asBoolean\([\s\S]*data as UnknownRecord\)\.suppressSessionLoading/s, + "chat history should read the explicit non-loading hydration marker", + ); + assert.match( + messageHandlerSource, + /if \(isSwitchingSession && !suppressSessionLoading\)/, + "only ordinary session switches should start the loading state", + ); + assert.match( + messageHandlerSource, + /\(!isSwitchingSession \|\| suppressSessionLoading\)/, + "fork hydration should clear any stale processing UI before showing copied history", + ); +}); diff --git a/tests/regression/subagent-history-rehydration-regression.test.mjs b/tests/regression/subagent-history-rehydration-regression.test.mjs new file mode 100644 index 0000000..f31e3a1 --- /dev/null +++ b/tests/regression/subagent-history-rehydration-regression.test.mjs @@ -0,0 +1,27 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { joinFromRoot, readSource } from "../helpers/source-utils.mjs"; + +const provider = readSource( + [joinFromRoot("src", "providers", "ChatViewProvider.ts")], + "ChatViewProvider.ts", +); + +test("extension-owned subagent projection is restored after chatHistory replaces live state", () => { + assert.match( + provider, + /type:\s*"chatHistory",[\s\S]{0,800}await this\.restorePersistedSubagentProjection\(sessionId\);/, + "the post-turn history refresh must restore subagent cards after replacing live state", + ); + assert.match( + provider, + /void this\.persistSubagentProjection\([\s\S]*?this\.subagentTracker\.getSnapshotPayload\(\)/, + "live tracker updates must persist the canonical projection independently of message.subagents", + ); + assert.match( + provider, + /getSubagentProjectionStorageKey\(sessionId: string\)[\s\S]*?opencode\.session\.subagentProjection\./, + "subagent projections should be persisted per session in workspace storage", + ); +}); diff --git a/tests/unit/activity-timeline-collapse.test.mjs b/tests/unit/activity-timeline-collapse.test.mjs index c00106a..61b00bc 100644 --- a/tests/unit/activity-timeline-collapse.test.mjs +++ b/tests/unit/activity-timeline-collapse.test.mjs @@ -16,6 +16,10 @@ const assistantBlockPresentationSource = readSource( [joinFromRoot("webview", "shared", "src", "chat", "lib", "assistantBlockPresentation.ts")], "assistantBlockPresentation.ts", ); +const fadedCollapseOverlaySource = readSource( + [joinFromRoot("webview", "shared", "src", "components", "ui", "FadedCollapseOverlay.tsx")], + "FadedCollapseOverlay.tsx", +); test("activity timeline tracks an explicit expanded-vs-collapsed assistant turn state", () => { @@ -98,6 +102,14 @@ test("render-loop lifecycle marker detection keys off partType to keep hiding co ); }); +test("the final visible activity row ends its connector before hidden lifecycle markers", () => { + assert.match( + messageComponentsSource, + /const isLastVisibleEventInGroup = !group\.events[\s\S]*?\.some\(\(candidate\) => !isHiddenLifecycleTimelineEvent\(candidate\)\);[\s\S]*?const isLast =[\s\S]*?isLastVisibleEventInGroup;/, + "hidden lifecycle markers should not leave a trailing timeline connector and empty gap before the subagent panel", + ); +}); + test("collapsed activity timeline renders the worked-for summary affordance", () => { assert.match( messageComponentsSource, @@ -116,16 +128,16 @@ test("collapsed activity timeline renders the worked-for summary affordance", () ); }); -test("collapsed mode keeps only the last response chunk visible while earlier assistant chunks stay collapsed", () => { +test("activity collapse leaves the full assistant response under its own local control", () => { assert.match( messageComponentsSource, /showResponseSection && hasVisibleResponseSectionContent/, - "response section should stay mounted even when the activity timeline is collapsed", + "response section should remain independent from the activity timeline state", ); assert.match( messageComponentsSource, - /const responseChunksVisibleInCurrentView =[\s\S]*isAssistantTurnCollapsed[\s\S]*responseChunksToRender\.slice\(-1\)[\s\S]*:\s*responseChunksToRender;/, - "collapsed mode should keep only the final assistant response chunk visible", + /const responseChunksVisibleInCurrentView = responseChunksToRender;/, + "activity collapse must not truncate assistant response chunks", ); assert.match( messageComponentsSource, @@ -139,6 +151,63 @@ test("collapsed mode keeps only the last response chunk visible while earlier as ); }); +test("completed assistant responses use rendered overflow for their card preview", () => { + assert.match( + messageComponentsSource, + /const \[hasResponseOverflow, setHasResponseOverflow\] = useState\(false\);/, + "response cards should track whether their rendered body overflows", + ); + assert.match( + messageComponentsSource, + /const \[isResponseExpanded, setIsResponseExpanded\] = useState\(false\);/, + "long responses should start in preview mode", + ); + assert.match( + messageComponentsSource, + /const shouldConstrainResponsePreview =[\s\S]*!isStreamingActive[\s\S]*showResponseBody[\s\S]*!isResponseExpanded;/, + "every completed response body should receive the shared preview boundary", + ); + assert.match( + messageComponentsSource, + /preview\.scrollHeight > preview\.clientHeight \+ 1/, + "the fade should be based on real rendered overflow rather than character count", + ); + assert.match( + messageComponentsSource, + /data-assistant-section="response"[\s\S]*max-h-\[28rem\][\s\S]* { + assert.match( + fadedCollapseOverlaySource, + /label = "Show full"/, + "the shared control should provide the standard action text", + ); + assert.match( + fadedCollapseOverlaySource, + /items-end justify-center/, + "the shared control should use the standard centered bottom alignment", + ); + for (const relativePath of [ + "webview/shared/src/chat/MessageComponents.tsx", + "webview/shared/src/chat/components/activity-steps/DiffPreviewStep.tsx", + "webview/shared/src/chat/components/activity-steps/SearchActivityPreview.tsx", + ]) { + assert.match( + readSource([joinFromRoot(...relativePath.split("/"))], relativePath), + /FadedCollapseOverlay/, + `${relativePath} should reuse the standard fade affordance`, + ); + } +}); + test("multi-card block collapse maintains visibility of the final text-containing card", () => { assert.match( assistantBlockPresentationSource, diff --git a/tests/unit/todo-session-switch.test.mjs b/tests/unit/todo-session-switch.test.mjs index c3e1d9a..893a5a0 100644 --- a/tests/unit/todo-session-switch.test.mjs +++ b/tests/unit/todo-session-switch.test.mjs @@ -13,10 +13,13 @@ const handlerSource = readSource( ); test('handleLoadSession clears in-memory todos and posts rehydrated todo snapshot', () => { - // Ensure switching sessions clears in-memory todo cache before rehydration + // Ensure switching sessions clears in-memory todo cache before rehydration. + // Signature relaxation: `handleLoadSession` now takes an optional options + // object (e.g. `{ suppressSessionLoading }`) on a multi-line parameter list, + // but the clear-then-rehydrate contract is unchanged. assert.match( providerSource, - /private async handleLoadSession\(sessionId: string\): Promise[\s\S]*this\.clearSessionTodos\(sessionId\);[\s\S]*todoItems:\s*\[\s*\]/, + /private async handleLoadSession\(\s*sessionId: string[\s\S]*?\):\s*Promise[\s\S]*this\.clearSessionTodos\(sessionId\);[\s\S]*todoItems:\s*\[\s*\]/, 'handleLoadSession should clear in-memory todos and then post initState with empty todoItems', ); }); diff --git a/tests/webview/live-stream-response-rendering.test.mjs b/tests/webview/live-stream-response-rendering.test.mjs index 1143941..9936ff2 100644 --- a/tests/webview/live-stream-response-rendering.test.mjs +++ b/tests/webview/live-stream-response-rendering.test.mjs @@ -15,6 +15,10 @@ const shellSource = readSource( [joinFromRoot("webview", "shared", "src", "chat", "ChatShell.tsx")], "ChatShell.tsx", ); +const handlerSource = readSource( + [joinFromRoot("webview", "shared", "src", "chat", "lib", "messageHandler.ts")], + "messageHandler.ts", +); test("renderable stream text paints immediately and centralized transcript takes over after completion", () => { assert.match( @@ -33,3 +37,20 @@ test("renderable stream text paints immediately and centralized transcript takes "the loading bubble should end when renderable streamed text is available", ); }); + +test("raw text delta envelopes update the live response before final hydration", () => { + assert.match( + handlerSource, + /const isRawDeltaTextField =[\s\S]*?deltaField === "text"[\s\S]*?deltaField === "content"/, + ); + assert.match( + handlerSource, + /partType === "message" \|\|[\s\S]*?isRawDeltaTextField/, + "text deltas without part.type must be accepted by the streaming content path", + ); + assert.match( + handlerSource, + /renderable:[\s\S]*?isRawDeltaTextField/, + "a raw text delta must dismiss the loading bubble by becoming renderable", + ); +}); diff --git a/tests/webview/opencode-compatibility-banner.test.mjs b/tests/webview/opencode-compatibility-banner.test.mjs index f0964f1..ec6a0a0 100644 --- a/tests/webview/opencode-compatibility-banner.test.mjs +++ b/tests/webview/opencode-compatibility-banner.test.mjs @@ -60,26 +60,11 @@ test("message handler ingests compatibility warnings from init state and live up ); }); -test("chat shell renders a visible compatibility banner", () => { - assert.match( - shellSource, - /state\.compatibilityWarnings\.length > 0/, - "ChatShell should check for compatibility warnings", - ); - assert.match( +test("chat shell does not render compatibility warnings", () => { + assert.doesNotMatch( shellSource, /OpenCode compatibility warning/i, - "ChatShell should render a titled compatibility warning banner", - ); - assert.match( - shellSource, - /Dismiss compatibility warning/i, - "ChatShell should render a dismiss button for the warning banner", - ); - assert.match( - shellSource, - /dismissedCompatibilityWarningSignature/, - "ChatShell should track dismiss state for the warning banner", + "compatibility diagnostics must not interrupt the chat UI", ); }); @@ -94,4 +79,9 @@ test("provider sends compatibility warnings to the webview", () => { /type:\s*"compatibilityStatus",[\s\S]*compatibilityWarnings:/, "ChatViewProvider should emit compatibility status updates", ); + assert.doesNotMatch( + providerSource, + /showWarningMessage\(summary\)/, + "compatibility diagnostics must not create VS Code warning notifications", + ); }); diff --git a/tests/webview/reasoning-stream-dedupe-regression.test.mjs b/tests/webview/reasoning-stream-dedupe-regression.test.mjs new file mode 100644 index 0000000..dff8b5b --- /dev/null +++ b/tests/webview/reasoning-stream-dedupe-regression.test.mjs @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; + +const messageHandlerSource = readFileSync( + new URL("../../webview/shared/src/chat/lib/messageHandler.ts", import.meta.url), + "utf8", +); +const messageComponentsSource = readFileSync( + new URL("../../webview/shared/src/chat/MessageComponents.tsx", import.meta.url), + "utf8", +); + +test("reasoning chunks are appended only once by the part-event handler", () => { + const partHandler = messageHandlerSource.slice( + messageHandlerSource.indexOf("case 'message.part.updated':"), + messageHandlerSource.indexOf("case 'message.updated':"), + ); + const explicitGuard = partHandler.slice( + partHandler.indexOf("const hasExplicitReasoningOnlyChunk"), + partHandler.indexOf("const isReasoningPart ="), + ); + + assert.doesNotMatch( + explicitGuard, + /type:\s*["']UPDATE_STREAMING_REASONING["']/, + "explicit and embedded reasoning guards must classify the chunk without dispatching it before the canonical reasoning branch", + ); +}); + +test("final and streaming reasoning snapshots are deduplicated during normalization", () => { + assert.match( + messageHandlerSource, + /function mergeReasoningEventSnapshots\(/, + "normalization should merge reasoning snapshots through one dedupe helper", + ); + assert.match( + messageHandlerSource, + /const mergedReasoningEvents = mergeReasoningEventSnapshots\(\s*existingReasoningEvents,\s*streaming\?\.reasoningEvents \?\? \[\],\s*\)/s, + "the final and live reasoning arrays must not be concatenated directly", + ); +}); + +test("text and reasoning response parts do not become empty activity rows", () => { + const rawPartProjector = messageComponentsSource.slice( + messageComponentsSource.indexOf("function progressItemsFromRawResponseParts("), + messageComponentsSource.indexOf("function progressItemsFromCentralizedData("), + ); + + for (const partType of ["reasoning", "thinking", "thought", "text", "message", "output_text"]) { + assert.match( + rawPartProjector, + new RegExp(`partType === ["']${partType}["']`), + `${partType} parts should stay in their response lane`, + ); + } +}); diff --git a/tests/webview/stream-event-main-thread-performance.test.mjs b/tests/webview/stream-event-main-thread-performance.test.mjs index f341b58..01a62fc 100644 --- a/tests/webview/stream-event-main-thread-performance.test.mjs +++ b/tests/webview/stream-event-main-thread-performance.test.mjs @@ -78,6 +78,29 @@ test("extension-host stream delivery is capped below token event frequency", () ); }); +test("the first event of a new assistant turn mounts the live response before batching", () => { + assert.match( + providerSource, + /firstStreamWebviewEventPendingBySession = new Set\(\)/, + ); + assert.match( + providerSource, + /this\.firstStreamWebviewEventPendingBySession\.add\(sessionId\)/, + ); + assert.match( + providerSource, + /this\.firstStreamWebviewEventPendingBySession\.delete\(resolvedSessionId\)/, + ); + assert.match( + providerSource, + /immediate: item\.immediate === true/, + ); + assert.match( + handlerSource, + /data\.immediate === true \|\| isImmediateActivityStreamPayload\(payload\)/, + ); +}); + test("webview appends a streamed live-event debug batch with one reducer update", () => { assert.match( handlerSource, diff --git a/tests/webview/subagent-duration-behavior.test.mjs b/tests/webview/subagent-duration-behavior.test.mjs new file mode 100644 index 0000000..6318087 --- /dev/null +++ b/tests/webview/subagent-duration-behavior.test.mjs @@ -0,0 +1,297 @@ +// Runtime regression tests for webview/shared/src/chat/lib/subagentDuration.ts +// +// Why this file exists: +// - The existing colocated subagent-duration test under `webview/shared/src/chat/` +// is not picked up by `npm test`, so CI does not exercise this behavior. +// - That orphaned test only covers 2 duration cases, while the runtime helpers have +// multiple status, timestamp, detail-fallback, and stale-activity branches. +// - Duration math is user-visible in subagent cards; regressions here can make live +// tasks appear frozen, completed tasks keep counting, or terminal rows show stale text. +// +// These tests execute the actual TypeScript implementation via `tsx` so they catch +// real behavior regressions, not source-text or mock drift. + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { importWebviewModule } from "../helpers/webview-module.mjs"; + +const MODULE_PATH = "webview/shared/src/chat/lib/subagentDuration.ts"; +const { + getSubagentDisplayDurationMs, + getSubagentDisplayActivity, +} = await importWebviewModule(MODULE_PATH); + +function makeSubagent(overrides = {}) { + return { + id: "subagent-1", + parentSessionId: "parent-session", + parentMessageId: "parent-message", + status: "running", + latestActivity: "Running", + references: [], + ...overrides, + }; +} + +function makeDetail(overrides = {}) { + return { + ...makeSubagent({ id: "subagent-detail" }), + thinkingEvents: [], + progressEvents: [], + timelineEvents: [], + ...overrides, + }; +} + +describe("subagentDuration runtime behavior", () => { + describe("getSubagentDisplayDurationMs", () => { + describe("live running and pending durations", () => { + it("uses live elapsed time for running subagents when backend duration went quiet", () => { + const subagent = makeSubagent({ startedAt: 1_000, durationMs: 3_100 }); + + assert.strictEqual( + getSubagentDisplayDurationMs(subagent, undefined, 8_250), + 7_250, + ); + }); + + it("keeps the larger backend duration when live elapsed is smaller", () => { + const subagent = makeSubagent({ startedAt: 1_000, durationMs: 10_000 }); + + assert.strictEqual( + getSubagentDisplayDurationMs(subagent, undefined, 8_250), + 10_000, + ); + }); + + it("uses live elapsed time for running subagents with a zero backend duration", () => { + const subagent = makeSubagent({ startedAt: 2_000, durationMs: 0 }); + + assert.strictEqual( + getSubagentDisplayDurationMs(subagent, undefined, 5_500), + 3_500, + ); + }); + + it("returns backend duration only for running subagents without a startedAt timestamp", () => { + const subagent = makeSubagent({ durationMs: 4_200 }); + + assert.strictEqual( + getSubagentDisplayDurationMs(subagent, undefined, 9_999), + 4_200, + ); + }); + + it("treats pending subagents as live and lets elapsed time win", () => { + const subagent = makeSubagent({ status: "pending", startedAt: 10_000, durationMs: 250 }); + + assert.strictEqual( + getSubagentDisplayDurationMs(subagent, undefined, 14_500), + 4_500, + ); + }); + }); + + describe("terminal and non-live durations", () => { + it("keeps completed subagent duration fixed from startedAt and endedAt, ignoring now", () => { + const subagent = makeSubagent({ + status: "done", + startedAt: 1_000, + endedAt: 4_100, + durationMs: 99_999, + }); + + assert.strictEqual( + getSubagentDisplayDurationMs(subagent, undefined, 20_000), + 3_100, + ); + }); + + it("falls back to backend duration for done subagents without ended timestamps", () => { + const subagent = makeSubagent({ status: "done", startedAt: 1_000, durationMs: 3_100 }); + + assert.strictEqual( + getSubagentDisplayDurationMs(subagent, undefined, 8_250), + 3_100, + ); + }); + + it("falls back to backend duration for error and orphaned subagents without ended timestamps", () => { + assert.strictEqual( + getSubagentDisplayDurationMs( + makeSubagent({ status: "error", startedAt: 1_000, durationMs: 5_500 }), + undefined, + 20_000, + ), + 5_500, + ); + assert.strictEqual( + getSubagentDisplayDurationMs( + makeSubagent({ status: "orphaned", startedAt: 1_000, durationMs: 6_600 }), + undefined, + 20_000, + ), + 6_600, + ); + }); + + it("clamps negative endedAt-minus-startedAt values to zero for clock skew", () => { + const subagent = makeSubagent({ status: "done", startedAt: 5_000, endedAt: 4_000 }); + + assert.strictEqual( + getSubagentDisplayDurationMs(subagent, undefined, 10_000), + 0, + ); + }); + }); + + describe("fallbacks, overrides, and numeric guards", () => { + it("uses detail startedAt, endedAt, durationMs, and status when summary fields are missing", () => { + const subagent = makeSubagent({ status: undefined, latestActivity: "" }); + const detail = makeDetail({ + status: "done", + startedAt: 2_000, + endedAt: 7_250, + durationMs: 123, + }); + + assert.strictEqual( + getSubagentDisplayDurationMs(subagent, detail, 99_999), + 5_250, + ); + }); + + it("lets resolvedStatus override detail.status and subagent.status", () => { + const subagent = makeSubagent({ status: "running", startedAt: 1_000, endedAt: 2_500, durationMs: 250 }); + const detail = makeDetail({ status: "pending" }); + + assert.strictEqual( + getSubagentDisplayDurationMs(subagent, detail, 10_000, "done"), + 1_500, + ); + }); + + it("ignores non-finite summary numbers and safely falls back to finite detail values", () => { + const subagent = makeSubagent({ + startedAt: Number.NaN, + endedAt: Number.POSITIVE_INFINITY, + durationMs: Number.NEGATIVE_INFINITY, + }); + const detail = makeDetail({ startedAt: 100, endedAt: 175, durationMs: 25 }); + + assert.strictEqual( + getSubagentDisplayDurationMs(subagent, detail, 175), + 75, + ); + }); + + it("uses Date.now() as the default now value when now is omitted", () => { + const originalDateNow = Date.now; + Date.now = () => 5_000; + + try { + const subagent = makeSubagent({ startedAt: 4_500, durationMs: 0 }); + + assert.strictEqual(getSubagentDisplayDurationMs(subagent), 500); + } finally { + Date.now = originalDateNow; + } + }); + }); + }); + + describe("getSubagentDisplayActivity", () => { + describe("activity selection", () => { + it("returns trimmed subagent latestActivity when present and not stale", () => { + const subagent = makeSubagent({ latestActivity: " Generating plan " }); + + assert.strictEqual( + getSubagentDisplayActivity(subagent, undefined, "running", "Running"), + "Generating plan", + ); + }); + + it("falls back to statusText when activity is empty or whitespace", () => { + assert.strictEqual( + getSubagentDisplayActivity(makeSubagent({ latestActivity: "" }), undefined, "running", "Running"), + "Running", + ); + assert.strictEqual( + getSubagentDisplayActivity(makeSubagent({ latestActivity: " " }), undefined, "running", "Running"), + "Running", + ); + }); + + it("prefers subagent.latestActivity and then detail.latestActivity", () => { + assert.strictEqual( + getSubagentDisplayActivity( + makeSubagent({ latestActivity: "Reading files" }), + makeDetail({ latestActivity: "Writing files" }), + "running", + "Running", + ), + "Reading files", + ); + assert.strictEqual( + getSubagentDisplayActivity( + makeSubagent({ latestActivity: "" }), + makeDetail({ latestActivity: "Writing files" }), + "running", + "Running", + ), + "Writing files", + ); + }); + }); + + describe("stale terminal activity", () => { + it("replaces stale non-terminal activity with statusText for terminal statuses", () => { + assert.strictEqual( + getSubagentDisplayActivity(makeSubagent({ latestActivity: "running" }), undefined, "done", "Completed"), + "Completed", + ); + assert.strictEqual( + getSubagentDisplayActivity(makeSubagent({ latestActivity: "pending" }), undefined, "error", "Failed"), + "Failed", + ); + assert.strictEqual( + getSubagentDisplayActivity(makeSubagent({ latestActivity: "initializing" }), undefined, "orphaned", "Lost"), + "Lost", + ); + assert.strictEqual( + getSubagentDisplayActivity( + makeSubagent({ latestActivity: "waiting for next progress..." }), + undefined, + "done", + "Completed", + ), + "Completed", + ); + }); + + it("preserves non-stale activity even when resolvedStatus is terminal", () => { + const subagent = makeSubagent({ status: "done", latestActivity: "Generating plan" }); + + assert.strictEqual( + getSubagentDisplayActivity(subagent, undefined, "done", "Completed"), + "Generating plan", + ); + }); + + it("performs stale activity checks case-insensitively", () => { + assert.strictEqual( + getSubagentDisplayActivity(makeSubagent({ latestActivity: "RUNNING" }), undefined, "done", "Completed"), + "Completed", + ); + assert.strictEqual( + getSubagentDisplayActivity(makeSubagent({ latestActivity: "Running" }), undefined, "done", "Completed"), + "Completed", + ); + assert.strictEqual( + getSubagentDisplayActivity(makeSubagent({ latestActivity: "running" }), undefined, "done", "Completed"), + "Completed", + ); + }); + }); + }); +}); diff --git a/tests/webview/toast-events-behavior.test.mjs b/tests/webview/toast-events-behavior.test.mjs new file mode 100644 index 0000000..6989eb1 --- /dev/null +++ b/tests/webview/toast-events-behavior.test.mjs @@ -0,0 +1,317 @@ +// Runtime regression tests for webview/shared/src/chat/lib/toastEvents.ts +// +// Why this file exists: +// - `toastEvents.ts` had ZERO runtime behavior coverage for user-visible toast parsing. +// - The module is 318 lines of fallback-heavy normalization for live OpenCode events. +// - A silent behavior change would break centralized notifications and live session status UI. +// +// These tests execute the actual TypeScript implementation via `tsx` so they +// catch real behavior regressions, not just source-text drift. + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { importWebviewModule } from "../helpers/webview-module.mjs"; + +const MODULE_PATH = "webview/shared/src/chat/lib/toastEvents.ts"; +const { + toastNotificationFromPayload, + liveSessionStatusFromPayload, + extractCentralizedToastNotifications, +} = await importWebviewModule(MODULE_PATH); + +describe("toastEvents", () => { + describe("toastNotificationFromPayload", () => { + it("returns null for non-record payloads", () => { + assert.strictEqual(toastNotificationFromPayload(null), null); + assert.strictEqual(toastNotificationFromPayload(undefined), null); + assert.strictEqual(toastNotificationFromPayload("tui.toast.show"), null); + assert.strictEqual(toastNotificationFromPayload(42), null); + assert.strictEqual(toastNotificationFromPayload(true), null); + assert.strictEqual(toastNotificationFromPayload([]), null); + }); + + it("returns null when the normalized event type is not a toast event", () => { + assert.strictEqual(toastNotificationFromPayload({ type: "session.status" }), null); + assert.strictEqual(toastNotificationFromPayload({ type: "tui.toast.hide" }), null); + assert.strictEqual(toastNotificationFromPayload({ event: "other.event", message: "ignored" }), null); + }); + + it("accepts a bare tui.toast.show payload and extracts core fields", () => { + const notification = toastNotificationFromPayload({ + type: "tui.toast.show", + id: "toast-1", + title: "Saved", + message: "Settings persisted", + variant: "SUCCESS", + durationMs: 2500, + sessionId: "session-a", + }); + + assert.equal(notification.key, "toast-1"); + assert.equal(notification.id, "toast-1"); + assert.equal(notification.type, "tui.toast.show"); + assert.equal(notification.title, "Saved"); + assert.equal(notification.message, "Settings persisted"); + assert.equal(notification.variant, "success"); + assert.equal(notification.durationMs, 2500); + assert.equal(notification.sessionId, "session-a"); + }); + + it("uses the variant fallback chain from properties.variant to severity to level", () => { + assert.equal( + toastNotificationFromPayload({ + type: "tui.show", + properties: { title: "A", message: "B", variant: "WARNING", severity: "error", level: "success" }, + }).variant, + "warning", + ); + assert.equal( + toastNotificationFromPayload({ + type: "tui.show", + properties: { title: "A", message: "B", severity: "ERROR", level: "success" }, + }).variant, + "error", + ); + assert.equal( + toastNotificationFromPayload({ + type: "tui.show", + properties: { title: "A", message: "B", level: "SUCCESS" }, + }).variant, + "success", + ); + }); + + it("unwraps payload envelopes, sync-event data, and combined wrapped sync-event data", () => { + const wrapped = toastNotificationFromPayload({ + payload: { + type: "tui.toast.show", + title: "Wrapped title", + message: "Wrapped message", + sessionID: "wrapped-session", + }, + }); + const sync = toastNotificationFromPayload({ + syncEvent: { + data: { + type: "tui.toast.show", + title: "Sync title", + text: "Sync text", + sessionId: "sync-session", + }, + }, + }); + const combined = toastNotificationFromPayload({ + payload: { + syncEvent: { + data: { + type: "tui.show", + title: "Combined title", + body: "Combined body", + sessionId: "combined-session", + }, + }, + }, + }); + + assert.equal(wrapped.title, "Wrapped title"); + assert.equal(wrapped.message, "Wrapped message"); + assert.equal(wrapped.sessionId, "wrapped-session"); + assert.equal(sync.title, "Sync title"); + assert.equal(sync.message, "Sync text"); + assert.equal(sync.sessionId, "sync-session"); + assert.equal(combined.type, "tui.show"); + assert.equal(combined.title, "Combined title"); + assert.equal(combined.message, "Combined body"); + assert.equal(combined.sessionId, "combined-session"); + }); + + it("falls back to OpenCode title and empty message when no text fields resolve", () => { + const notification = toastNotificationFromPayload({ type: "tui.toast.show" }); + + assert.equal(notification.title, "OpenCode"); + assert.equal(notification.message, ""); + assert.equal(notification.variant, "info"); + assert.equal(notification.durationMs, 4000); + }); + + it("honors duration priority before falling back to defaults", () => { + assert.equal( + toastNotificationFromPayload({ + type: "tui.toast.show", + properties: { title: "A", message: "B", duration: 100, durationMs: 200, timeout: 300 }, + syncEvent: { data: { duration: 400 } }, + duration: 500, + }).durationMs, + 100, + ); + assert.equal( + toastNotificationFromPayload({ + type: "tui.toast.show", + properties: { title: "A", message: "B", durationMs: 200, timeout: 300 }, + syncEvent: { data: { duration: 400 } }, + duration: 500, + }).durationMs, + 200, + ); + assert.equal( + toastNotificationFromPayload({ + type: "tui.toast.show", + properties: { title: "A", message: "B", timeout: 300 }, + syncEvent: { data: { duration: 400 } }, + duration: 500, + }).durationMs, + 300, + ); + assert.equal( + toastNotificationFromPayload({ + type: "tui.toast.show", + syncEvent: { data: { title: "A", message: "B", duration: 400, durationMs: 450 } }, + duration: 500, + }).durationMs, + 400, + ); + assert.equal( + toastNotificationFromPayload({ type: "tui.toast.show", title: "A", message: "B", duration: 0 }).durationMs, + 4000, + ); + assert.equal( + toastNotificationFromPayload({ type: "tui.toast.show", title: "A", message: "B", duration: -1 }).durationMs, + 4000, + ); + }); + + it("normalizes variants case-insensitively and falls back to info for unknown values", () => { + assert.equal(toastNotificationFromPayload({ type: "tui.show", variant: "eRrOr" }).variant, "error"); + assert.equal(toastNotificationFromPayload({ type: "tui.show", variant: "mystery" }).variant, "info"); + assert.equal(toastNotificationFromPayload({ type: "tui.show", severity: "warning" }).variant, "warning"); + }); + + it("strips numeric event-type suffixes before accepting toast events", () => { + const notification = toastNotificationFromPayload({ + type: "tui.toast.show.123", + title: "Numbered", + message: "Suffix", + }); + + assert.equal(notification.type, "tui.toast.show"); + assert.equal(notification.title, "Numbered"); + assert.equal(notification.message, "Suffix"); + }); + + it("uses supported id-like fields for keys before building composite fallback keys", () => { + assert.equal(toastNotificationFromPayload({ type: "tui.show", eventID: "event-upper" }).key, "event-upper"); + assert.equal(toastNotificationFromPayload({ type: "tui.show", eventId: "event-camel" }).key, "event-camel"); + assert.equal(toastNotificationFromPayload({ type: "tui.show", messageID: "message-upper" }).key, "message-upper"); + assert.equal(toastNotificationFromPayload({ type: "tui.show", messageId: "message-camel" }).key, "message-camel"); + + const composite = toastNotificationFromPayload( + { type: "tui.show", properties: { title: "Composite", message: "Fallback", sessionId: "session-z" } }, + 7, + ); + assert.equal(composite.key, "toast:tui.show:Composite:Fallback:session-z:7"); + assert.equal(composite.id, undefined); + }); + }); + + describe("liveSessionStatusFromPayload", () => { + it("returns null for non-session.status events and status blocks without a resolvable type", () => { + assert.strictEqual(liveSessionStatusFromPayload(null), null); + assert.strictEqual(liveSessionStatusFromPayload({ type: "tui.toast.show" }), null); + assert.strictEqual(liveSessionStatusFromPayload({ type: "session.status", status: { message: "missing type" } }), null); + assert.strictEqual(liveSessionStatusFromPayload({ type: "session.status", properties: { message: "missing status" } }), null); + }); + + it("extracts status fields, lowercases statusType, parses timestamps, and falls back source to event type", () => { + const timestamp = "2026-07-19T12:34:56.000Z"; + const status = liveSessionStatusFromPayload({ + type: "session.status", + timestamp, + sessionID: "session-1", + properties: { + message: "Attempting reconnect", + status: { type: "RECONNECTING", attempt: 2, next: 5000 }, + }, + }); + + assert.equal(status.statusType, "reconnecting"); + assert.equal(status.message, "Attempting reconnect"); + assert.equal(status.attempt, 2); + assert.equal(status.next, 5000); + assert.equal(status.sessionId, "session-1"); + assert.equal(status.source, "session.status"); + assert.equal(status.updatedAt, Date.parse(timestamp)); + }); + + it("prefers status.message, accepts status.status, preserves source, and omits invalid updatedAt", () => { + const status = liveSessionStatusFromPayload({ + event: "session.status", + source: "sdk-stream", + timestamp: "not a date", + status: { status: "IDLE", message: "Idle now", attempt: 1, next: 10 }, + sessionId: "session-2", + properties: { message: "Fallback message" }, + }); + + assert.equal(status.statusType, "idle"); + assert.equal(status.message, "Idle now"); + assert.equal(status.attempt, 1); + assert.equal(status.next, 10); + assert.equal(status.sessionId, "session-2"); + assert.equal(status.source, "sdk-stream"); + assert.equal(status.updatedAt, undefined); + }); + + it("reads wrapped sync-event status data and leaves updatedAt undefined when timestamp is missing", () => { + const status = liveSessionStatusFromPayload({ + payload: { + syncEvent: { + data: { + type: "session.status", + sessionId: "session-3", + status: { type: "RUNNING" }, + }, + }, + }, + }); + + assert.equal(status.statusType, "running"); + assert.equal(status.sessionId, "session-3"); + assert.equal(status.source, "session.status"); + assert.equal(status.updatedAt, undefined); + }); + }); + + describe("extractCentralizedToastNotifications", () => { + it("returns an empty array for non-array and empty-array input", () => { + assert.deepEqual(extractCentralizedToastNotifications(undefined), []); + assert.deepEqual(extractCentralizedToastNotifications(null), []); + assert.deepEqual(extractCentralizedToastNotifications({ type: "tui.show" }), []); + assert.deepEqual(extractCentralizedToastNotifications([]), []); + }); + + it("skips entries that do not parse and preserves parsed notification order", () => { + const notifications = extractCentralizedToastNotifications([ + { type: "session.status" }, + { type: "tui.show", properties: { title: "First", message: "One" } }, + null, + { type: "tui.toast.show", properties: { title: "Second", message: "Two" } }, + ]); + + assert.equal(notifications.length, 2); + assert.equal(notifications[0].title, "First"); + assert.equal(notifications[0].message, "One"); + assert.equal(notifications[1].title, "Second"); + assert.equal(notifications[1].message, "Two"); + }); + + it("passes the original input index into composite-key generation", () => { + const notifications = extractCentralizedToastNotifications([ + { type: "not.toast" }, + { type: "tui.show", properties: { title: "Indexed", message: "Original", sessionId: "session-index" } }, + ]); + + assert.equal(notifications.length, 1); + assert.equal(notifications[0].key, "toast:tui.show:Indexed:Original:session-index:1"); + }); + }); +}); diff --git a/tests/webview/truncate-large-strings-behavior.test.mjs b/tests/webview/truncate-large-strings-behavior.test.mjs new file mode 100644 index 0000000..20c8fbc --- /dev/null +++ b/tests/webview/truncate-large-strings-behavior.test.mjs @@ -0,0 +1,180 @@ +// Runtime regression tests for webview/shared/src/chat/lib/truncateLargeStrings.ts +// +// Why this file exists: +// - `truncateLargeStrings` had ZERO test coverage (search: grep -rn "truncateLargeStrings" tests/) +// - It is used by both the extension host (`src/providers/ChatViewProvider.ts`) and +// the webview to cap oversized payloads before logging/postMessage. +// - A silent behavior change (e.g., off-by-one in the slice, missing array branch, +// maxLen default drift) would corrupt diagnostics across the IPC boundary. +// +// These tests execute the actual TypeScript implementation via `tsx` so they +// catch real behavior regressions, not just source-text drift. + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { importWebviewModule } from "../helpers/webview-module.mjs"; + +const MODULE_PATH = "webview/shared/src/chat/lib/truncateLargeStrings.ts"; + +const { truncateLargeStrings } = await importWebviewModule(MODULE_PATH); + +describe("truncateLargeStrings", () => { + describe("primitive passthrough", () => { + it("returns numbers unchanged", () => { + assert.strictEqual(truncateLargeStrings(42), 42); + assert.strictEqual(truncateLargeStrings(0), 0); + assert.strictEqual(truncateLargeStrings(-1.5), -1.5); + assert.strictEqual(truncateLargeStrings(Number.POSITIVE_INFINITY), Number.POSITIVE_INFINITY); + }); + + it("returns booleans unchanged", () => { + assert.strictEqual(truncateLargeStrings(true), true); + assert.strictEqual(truncateLargeStrings(false), false); + }); + + it("returns null and undefined unchanged", () => { + assert.strictEqual(truncateLargeStrings(null), null); + assert.strictEqual(truncateLargeStrings(undefined), undefined); + }); + + it("returns short strings unchanged", () => { + assert.strictEqual(truncateLargeStrings("hello"), "hello"); + assert.strictEqual(truncateLargeStrings(""), ""); + assert.strictEqual(truncateLargeStrings("a"), "a"); + }); + }); + + describe("string truncation", () => { + it("truncates strings exceeding maxLen and appends the truncated-chars marker", () => { + const input = "0123456789"; // length 10 + const result = truncateLargeStrings(input, 5); + assert.equal(typeof result, "string"); + assert.ok(result.startsWith("01234"), "truncated string keeps the first maxLen chars"); + assert.match(result, /\n\.\.\.\[truncated 5 chars\]/, "marker reports the number of dropped chars"); + }); + + it("does not truncate when length equals maxLen exactly", () => { + // Source contract: `obj.length > maxLen`, so length === maxLen must pass through. + const exact = "abcde"; // length 5 + assert.strictEqual(truncateLargeStrings(exact, 5), exact); + }); + + it("defaults maxLen to 200000 when omitted", () => { + const shortInput = "x".repeat(100); + assert.strictEqual(truncateLargeStrings(shortInput), shortInput); + + const longInput = "y".repeat(200001); + const result = truncateLargeStrings(longInput); + assert.ok(result.startsWith("y".repeat(200000))); + assert.match(result, /\[truncated 1 chars\]/); + }); + + it("honors a custom maxLen of 0 by truncating every non-empty string", () => { + const result = truncateLargeStrings("abc", 0); + // length 3 > 0, so it truncates to empty prefix + marker + assert.match(result, /^\n\.\.\.\[truncated 3 chars\]/); + }); + }); + + describe("array recursion", () => { + it("recursively truncates each string element, preserving array shape", () => { + const input = ["short", "wayyyyyyyyyyy-too-long"]; + const result = truncateLargeStrings(input, 5); + assert.ok(Array.isArray(result)); + assert.equal(result.length, 2); + assert.strictEqual(result[0], "short"); + assert.match(result[1], /^wayyy/); + assert.match(result[1], /\[truncated/); + }); + + it("preserves non-string element types inside arrays", () => { + const input = [1, true, null, { nested: "x".repeat(20) }]; + const result = truncateLargeStrings(input, 5); + assert.strictEqual(result[0], 1); + assert.strictEqual(result[1], true); + assert.strictEqual(result[2], null); + assert.equal(typeof result[3], "object"); + }); + + it("handles empty arrays", () => { + const result = truncateLargeStrings([], 5); + assert.deepEqual(result, []); + }); + }); + + describe("object recursion", () => { + it("deeply truncates nested object string values", () => { + const input = { + outer: "x".repeat(20), + nested: { + inner: "y".repeat(20), + number: 7, + }, + }; + const result = truncateLargeStrings(input, 5); + assert.match(result.outer, /^xxxxx/); + assert.match(result.outer, /\[truncated/); + assert.match(result.nested.inner, /^yyyyy/); + assert.match(result.nested.inner, /\[truncated/); + assert.strictEqual(result.nested.number, 7); + }); + + it("only iterates own enumerable properties (hasOwnProperty guard)", () => { + // If the implementation dropped the hasOwnProperty check, a prototype + // property like `toString` would surface as a string and get truncated. + const input = { a: "short" }; + const result = truncateLargeStrings(input, 5); + assert.deepEqual(Object.keys(result), ["a"]); + assert.strictEqual(result.a, "short"); + // Sanity: prototype keys did not leak in + assert.equal(typeof result.toString, "function"); + }); + + it("handles empty objects", () => { + const result = truncateLargeStrings({}, 5); + assert.deepEqual(result, {}); + }); + + it("preserves null-valued properties (does not treat null as object-to-recurse)", () => { + // typeof null === "object" but the source contract explicitly checks `obj !== null` + const input = { keep: null, str: "x".repeat(20) }; + const result = truncateLargeStrings(input, 5); + assert.strictEqual(result.keep, null); + assert.match(result.str, /\[truncated/); + }); + }); + + describe("mixed nested structures", () => { + it("truncates strings inside arrays inside objects inside arrays", () => { + const input = [ + { + messages: ["a".repeat(50), "ok"], + }, + "b".repeat(50), + ]; + const result = truncateLargeStrings(input, 10); + assert.ok(Array.isArray(result)); + assert.match(result[0].messages[0], /^aaaaaaaaaa/); + assert.match(result[0].messages[0], /\[truncated 40 chars\]/); + assert.strictEqual(result[0].messages[1], "ok"); + assert.match(result[1], /^bbbbbbbbbb/); + }); + }); + + describe("return-value invariants", () => { + it("never returns the same array reference for nested arrays (defensive copy)", () => { + const input = ["hello"]; + const result = truncateLargeStrings(input, 100); + assert.notEqual(result, input); + assert.deepEqual(result, input); + }); + + it("never returns the same object reference for nested objects (defensive copy)", () => { + const input = { a: "hello" }; + const result = truncateLargeStrings(input, 100); + assert.notEqual(result, input); + assert.deepEqual(result, input); + }); + }); +}); diff --git a/webview/shared/src/chat/ChatShell.tsx b/webview/shared/src/chat/ChatShell.tsx index 229d517..458427c 100644 --- a/webview/shared/src/chat/ChatShell.tsx +++ b/webview/shared/src/chat/ChatShell.tsx @@ -2726,7 +2726,6 @@ function ChatContent() { compactionDividerAfterMessageId: appState.compactionDividerAfterMessageId, compactionDividerBeforeMessageId: appState.compactionDividerBeforeMessageId, compactionDividerIndex: appState.compactionDividerIndex, - compatibilityWarnings: appState.compatibilityWarnings, currentSessionId: appState.currentSessionId, errorMessages: appState.errorMessages, interactiveEvents: appState.interactiveEvents, @@ -2735,6 +2734,7 @@ function ChatContent() { isProcessing: appState.isProcessing, isSessionModalOpen: appState.isSessionModalOpen, lastCompactedAt: appState.lastCompactedAt, + loadingSessionId: appState.loadingSessionId, liveToastNotificationsBySessionId: appState.liveToastNotificationsBySessionId, pendingUserMessagesBySessionId: appState.pendingUserMessagesBySessionId, processingSessionIds: appState.processingSessionIds, @@ -2771,8 +2771,6 @@ function ChatContent() { viewportHeight: 0, }); const [showSkillInstaller, setShowSkillInstaller] = useState(false); - const [dismissedCompatibilityWarningSignature, setDismissedCompatibilityWarningSignature] = - useState(null); // Shared collapse/expand state for contiguous assistant message blocks. // Keyed by blockGroupKey (the parentID shared by all assistant messages in // the same block). A missing key means "collapsed" (the default). @@ -3229,19 +3227,6 @@ function ChatContent() { const isSwitchingSession = false; const isConnecting = false; - const compatibilityWarningSignature = state.compatibilityWarnings - .map((warning) => `${warning.component}:${warning.version ?? "unknown"}:${warning.status}:${warning.supportedRange}`) - .join("|"); - useEffect(() => { - if (!compatibilityWarningSignature) { - setDismissedCompatibilityWarningSignature(null); - return; - } - setDismissedCompatibilityWarningSignature((current) => - current === compatibilityWarningSignature ? current : null, - ); - }, [compatibilityWarningSignature]); - if (isConnecting) { return (
@@ -3405,6 +3390,47 @@ function ChatContent() { !hasRenderableStreamingContent && hasLiveAssistantTurn); // Extended for minimum duration + useEffect(() => { + if (!state.isLoadingSession && !showAiResponseLoading && !showExtendedLoading) { + return; + } + logger.warn("[FORK_TRACE][SHELL] loading UI derived", { + currentSessionId: state.currentSessionId, + isLoadingSession: state.isLoadingSession, + loadingSessionId: state.loadingSessionId, + isProcessing: state.isProcessing, + processingSessionIds: state.processingSessionIds, + assistantTurnPending: state.assistantTurnPending, + assistantTurnMessageId: state.assistantTurnMessageId, + streamingActive: state.streaming?.isActive ?? false, + streamingMessageId: state.streaming?.messageId ?? null, + hasLiveAssistantTurn, + isAiResponding, + hasRenderableStreamingContent, + isAiResponseBlockFinished, + showAiResponseLoading, + showExtendedLoading: Boolean(showExtendedLoading), + renderedMessageCount: renderMessages.length, + }); + }, [ + hasLiveAssistantTurn, + hasRenderableStreamingContent, + isAiResponding, + isAiResponseBlockFinished, + renderMessages.length, + showAiResponseLoading, + showExtendedLoading, + state.assistantTurnMessageId, + state.assistantTurnPending, + state.currentSessionId, + state.isLoadingSession, + state.isProcessing, + state.loadingSessionId, + state.processingSessionIds, + state.streaming?.isActive, + state.streaming?.messageId, + ]); + const compactionDividerIndex = typeof state.compactionDividerIndex === "number" ? Math.max( @@ -3661,7 +3687,6 @@ function ChatContent() { .filter((messageId): messageId is string => typeof messageId === "string" && messageId.length > 0), [renderMessages], ); - const hasCompatibilityWarnings = state.compatibilityWarnings.length > 0; const errorToasts = state.errorMessages; useEffect(() => { @@ -3793,62 +3818,6 @@ function ChatContent() {
) : ( <> - {hasCompatibilityWarnings && - dismissedCompatibilityWarningSignature !== compatibilityWarningSignature ? ( -
-
-
-
- OpenCode compatibility warning -
- -
-
- {state.compatibilityWarnings.map((warning) => ( -
-
-
- {warning.component === "sdk" - ? "OpenCode SDK" - : "OpenCode TUI"} -
-
- {warning.status} -
-
-
-
- Detected: {warning.version ?? "unknown"} -
-
- Supported: {warning.supportedRange} -
-
- {warning.message} -
-
-
- ))} -
-
-
- ) : null} - {!hasAnyRenderableConversation && !state.streaming && !isAiResponding ? ( diff --git a/webview/shared/src/chat/MessageComponents.tsx b/webview/shared/src/chat/MessageComponents.tsx index cc81048..ec69265 100644 --- a/webview/shared/src/chat/MessageComponents.tsx +++ b/webview/shared/src/chat/MessageComponents.tsx @@ -13,6 +13,7 @@ import { ChevronRight, ChevronUp, Copy, + GitFork, FileText, FileText as FileTextIcon, Loader2, @@ -44,6 +45,10 @@ import { Stepper, StepperItem } from "@/components/ui/stepper"; import { TerminalBlock } from "@/components/ui/TerminalBlock"; import { SearchBlock } from "@/components/ui/SearchBlock"; import { ExpandableStep } from "@/components/ui/ExpandableStep"; +import { + FadedCollapseOverlay, + useFadedContentOverflow, +} from "@/components/ui/FadedCollapseOverlay"; import { StepIndicator } from "@/components/ui/StepIndicator"; import { cn, formatDuration, toWorkspaceRelativePath } from "@/utils"; @@ -2095,6 +2100,7 @@ function CollapsedMarkdownPreview({ variant = "card", }: CollapsedMarkdownPreviewProps) { const [isOpen, setIsOpen] = useState(false); + const { ref: previewRef, hasOverflow } = useFadedContentOverflow(); const hasContent = content.trim().length > 0; if (!hasContent) { @@ -2118,29 +2124,20 @@ function CollapsedMarkdownPreview({
+ )} ref={previewRef}>
-
-
-
- + : "from-oc-panel-soft/80 via-oc-panel-soft/30 to-transparent" + } + />}
(); const hasCommand = command.trim().length > 0; if (!hasCommand) { return null; @@ -2257,12 +2255,9 @@ function CollapsedTerminalBlockPreview({ )} aria-label={`Open ${title} preview`} > -
+
-
-
-
- + {hasOverflow && }
(); const hasContent = !!pattern || !!scope || @@ -2407,8 +2403,8 @@ function CollapsedSearchBlockPreview({ className="oc-timeline-surface oc-timeline-soft-frame group relative w-full overflow-hidden rounded-lg text-left transition-colors hover:bg-oc-panel-soft/50" aria-label={`Open ${title} details`} > -
-
+
+
-
-
-
- + {hasOverflow && }
({ assistantTurnPending: state.assistantTurnPending, assistantTurnMessageId: state.assistantTurnMessageId, + isLoadingSession: state.isLoadingSession, + isProcessing: state.isProcessing, + processingSessionIds: state.processingSessionIds, + streaming: state.streaming, streamingBySessionId: state.streamingBySessionId, selectedSubagentId: state.selectedSubagentId, }), @@ -7600,6 +7613,7 @@ function ResponseMessageInner({ const [showAllSubagents, setShowAllSubagents] = useState(false); const [showTodoChecklist, setShowTodoChecklist] = useState(true); const [copied, setCopied] = useState(false); + const [isForking, setIsForking] = useState(false); const [previewImageSrc, setPreviewImageSrc] = useState(null); const messageBodyRef = useRef(null); const progressTimelineRef = useRef(null); @@ -8304,6 +8318,12 @@ const centralizedRawResponse = message?.rawResponse; showExpandedActivityTimeline: false, expandedReasoningSteps: new Set(), }); + // Long response bodies start as a bounded preview. This state is entirely + // local to the response card; activity and assistant-block collapse do not + // affect it. + const [isResponseExpanded, setIsResponseExpanded] = useState(false); + const [hasResponseOverflow, setHasResponseOverflow] = useState(false); + const responsePreviewRef = useRef(null); // The centralized event tape is the source of truth for ordering. Do not // pin streaming-only reasoning blocks or other migration artifacts to the // end of the timeline, because that can duplicate or reorder the final turn. @@ -9390,10 +9410,38 @@ const responseBodyChunks = useMemo(() => { const hasVisibleResponseSectionContent = showResponseBody || (shouldShowPlanCard && !!plan); - const responseChunksVisibleInCurrentView = - isAssistantTurnCollapsed && responseChunksToRender.length > 0 - ? responseChunksToRender.slice(-1) - : responseChunksToRender; + // Response visibility belongs to the response card itself. Activity/block + // collapse may summarize work around the card, but must never truncate the + // assistant's response text. + const responseChunksVisibleInCurrentView = responseChunksToRender; + // Every completed response body is constrained to the preview height first. + // Whether it receives a fade is determined from its rendered height rather + // than an arbitrary character count, so code-heavy and markdown-heavy cards + // behave consistently too. + const shouldConstrainResponsePreview = + !isStreamingActive && showResponseBody && !isResponseExpanded; + const canPreviewResponse = shouldConstrainResponsePreview && hasResponseOverflow; + const isResponsePreviewCollapsed = canPreviewResponse; + + useEffect(() => { + const preview = responsePreviewRef.current; + if (!preview || !shouldConstrainResponsePreview) { + setHasResponseOverflow(false); + return; + } + + const updateOverflow = () => { + const nextHasOverflow = preview.scrollHeight > preview.clientHeight + 1; + setHasResponseOverflow((current) => + current === nextHasOverflow ? current : nextHasOverflow, + ); + }; + + updateOverflow(); + const observer = new ResizeObserver(updateOverflow); + observer.observe(preview); + return () => observer.disconnect(); + }, [responseChunksToRender, shouldConstrainResponsePreview]); const responseSectionClass = hasResponseContent ? "rounded-md border border-oc-border-soft bg-background p-2.5 shadow-sm" @@ -9416,6 +9464,88 @@ const hasVisibleResponseSectionContent = setTimeout(() => setCopied(false), 1200); } }; + const handleForkSession = () => { + if (!centralizedSessionId || !assistantMessageId || isForking) { + logger.warn("[FORK_TRACE][WEBVIEW] fork click ignored", { + centralizedSessionId, + assistantMessageId, + isForking, + currentSessionId, + isProcessing, + isLoadingSession, + processingSessionIds, + streamingActive: currentStreaming?.isActive ?? false, + }); + return; + } + + logger.warn("[FORK_TRACE][WEBVIEW] fork requested", { + sourceSessionId: centralizedSessionId, + sourceMessageId: assistantMessageId, + currentSessionId, + isProcessing, + isLoadingSession, + processingSessionIds, + assistantTurnPending, + assistantTurnMessageId, + streamingActive: currentStreaming?.isActive ?? false, + streamingMessageId: currentStreaming?.messageId ?? null, + }); + setIsForking(true); + vscode.postMessage({ + type: "forkSession", + sessionId: centralizedSessionId, + messageId: assistantMessageId, + }); + }; + + useEffect(() => { + const onMessage = (event: MessageEvent) => { + const data = event?.data as + | { + type?: string; + sessionId?: string; + messageId?: string; + success?: boolean; + forkedSessionId?: string; + error?: string; + } + | undefined; + if ( + data?.type === "forkSessionResult" && + data.sessionId === centralizedSessionId && + data.messageId === assistantMessageId + ) { + logger.warn("[FORK_TRACE][WEBVIEW] fork result received", { + sourceSessionId: data.sessionId, + sourceMessageId: data.messageId, + success: data.success === true, + forkedSessionId: data.forkedSessionId ?? null, + error: data.error ?? null, + currentSessionId, + isProcessing, + isLoadingSession, + processingSessionIds, + streamingActive: currentStreaming?.isActive ?? false, + }); + setIsForking(false); + } + }; + + window.addEventListener("message", onMessage); + return () => window.removeEventListener("message", onMessage); + }, [ + assistantMessageId, + centralizedSessionId, + currentSessionId, + isLoadingSession, + isProcessing, + processingSessionIds, + assistantTurnMessageId, + assistantTurnPending, + currentStreaming?.isActive, + currentStreaming?.messageId, + ]); const retryLastMessage = (retryWithoutStructuredOutput: boolean) => { dispatch({ type: "SET_PROCESSING", payload: true }); const targetMessageIndex = (messages || []).findIndex((candidate) => { @@ -9766,7 +9896,16 @@ const retryLastMessage = (retryWithoutStructuredOutput: boolean) => { autoScrollToBottom={isStreamingActive && groupIdx === nonQuestionTimelineDisplayEventGroups.length - 1} > {group.events.map((event, index) => { - const isLast = groupIdx === nonQuestionTimelineDisplayEventGroups.length - 1 && index === group.events.length - 1; + // Lifecycle markers remain in the tape for ordering and + // reconciliation, but they have no visible row. Treat the + // last visible item as final so its timeline connector does + // not stretch through the empty space before subagents. + const isLastVisibleEventInGroup = !group.events + .slice(index + 1) + .some((candidate) => !isHiddenLifecycleTimelineEvent(candidate)); + const isLast = + groupIdx === nonQuestionTimelineDisplayEventGroups.length - 1 && + isLastVisibleEventInGroup; const indicatorNode = ( { )} {showResponseSection && hasVisibleResponseSectionContent && ( -
{showResponseBody && ( -
+
{responseChunksVisibleInCurrentView.map((chunk, index) => ( { variant="bare" /> ))} + {isResponsePreviewCollapsed && ( + setIsResponseExpanded(true)} + backgroundClassName="from-background via-background/90 to-transparent" + /> + )} +
+ )} + + {canPreviewResponse && isResponseExpanded && ( +
+
)} @@ -10424,6 +10591,24 @@ const retryLastMessage = (retryWithoutStructuredOutput: boolean) => { )} + {(() => { const ts = formatMessageTime(getMessageTimestamp(cardMessage)); return ts ? ( @@ -11148,27 +11333,24 @@ export function ErrorBanner({ return (
-
-
+
+
- + - - Request failed - -
- -
-
- Error message -
-
- {errorDetails} +
+
+ Request failed +
+

+ {errorDetails} +

- {onRetry && ( -
+ {(onRetry || retryHint) && ( +
+ {onRetry && ( + )} + {retryHint ? ( +
{retryHint}
+ ) : null}
)} - {retryHint ? ( -
{retryHint}
- ) : null}
); diff --git a/webview/shared/src/chat/PanelComponents.tsx b/webview/shared/src/chat/PanelComponents.tsx index 809eabe..d1f9709 100644 --- a/webview/shared/src/chat/PanelComponents.tsx +++ b/webview/shared/src/chat/PanelComponents.tsx @@ -531,10 +531,6 @@ function MiniSection({ aria-expanded={open} className="oc-inspector-section-toggle flex w-full items-center gap-2 px-2.5 py-1.5 text-left text-xs rounded-none" > - - {/* Panel title */} -
-
-
-
Active Task
-
-
- -
+
{sortedTodoItems.length > 0 ? ( - +
{completedTodoCount} / {sortedTodoItems.length} done diff --git a/webview/shared/src/chat/components/activity-steps/BackgroundOutputStep.tsx b/webview/shared/src/chat/components/activity-steps/BackgroundOutputStep.tsx index 55ee14b..ea8e621 100644 --- a/webview/shared/src/chat/components/activity-steps/BackgroundOutputStep.tsx +++ b/webview/shared/src/chat/components/activity-steps/BackgroundOutputStep.tsx @@ -1,12 +1,16 @@ import { useEffect, useMemo, useState } from "react"; import { createPortal } from "react-dom"; -import { ArrowRight, Bot, Clock3, Copy, Sparkles, X, Terminal, ChevronDown } from "lucide-react"; +import { ArrowRight, Bot, Clock3, Copy, Sparkles, X, Terminal } from "lucide-react"; import { cn, formatDuration } from "@/utils"; import { Stepper, StepperItem } from "@/components/ui/stepper"; import { usePersistentModalOpen } from "../../lib/usePersistentModalOpen"; import { StepIndicator } from "@/components/ui/StepIndicator"; +import { + FadedCollapseOverlay, + useFadedContentOverflow, +} from "@/components/ui/FadedCollapseOverlay"; import { MarkdownRenderer } from "../../../components/MarkdownRenderer"; import { ActivityStepStatusChip } from "./ActivityStepStatusChip"; @@ -410,6 +414,7 @@ export function BackgroundOutputStep({ const [isModalOpen, setIsModalOpen] = usePersistentModalOpen( `background-output:${taskId || sessionLabel || description}`, ); + const { ref: previewRef, hasOverflow } = useFadedContentOverflow(); const isDone = status === "done"; const isError = status === "error"; @@ -443,7 +448,7 @@ export function BackgroundOutputStep({ className="group relative w-full overflow-hidden rounded-lg border border-oc-border-soft bg-oc-bg-soft/60 text-left transition-colors hover:border-oc-border hover:bg-oc-panel-soft/60" aria-label="View background output details" > -
+
{previewOutput || "Background output received"}
@@ -453,9 +458,7 @@ export function BackgroundOutputStep({
)}
-
- -
+ {hasOverflow && }
diff --git a/webview/shared/src/chat/components/activity-steps/CallOmoAgentStep.tsx b/webview/shared/src/chat/components/activity-steps/CallOmoAgentStep.tsx index fc6642f..13c801a 100644 --- a/webview/shared/src/chat/components/activity-steps/CallOmoAgentStep.tsx +++ b/webview/shared/src/chat/components/activity-steps/CallOmoAgentStep.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from "react"; import { createPortal } from "react-dom"; -import { Bot, Clock3, Copy, Sparkles, ArrowRight, X, ChevronDown } from "lucide-react"; +import { Bot, Clock3, Copy, Sparkles, ArrowRight, X } from "lucide-react"; import { cn, formatDuration } from "@/utils"; @@ -12,6 +12,10 @@ import { Stepper, StepperItem } from "@/components/ui/stepper"; import { shallowEqual, useAppState } from "../../lib/store"; import { buildBackgroundTaskPresentation } from "../../lib/backgroundTaskPresentation"; import { usePersistentModalOpen } from "../../lib/usePersistentModalOpen"; +import { + FadedCollapseOverlay, + useFadedContentOverflow, +} from "@/components/ui/FadedCollapseOverlay"; import type { ActivityDetail } from "../../lib/types"; @@ -309,6 +313,7 @@ export function CallOmoAgentStep({ const [isModalOpen, setIsModalOpen] = usePersistentModalOpen( `call-omo-agent:${resolvedBackgroundTaskId || sessionValue || description}`, ); + const { ref: previewRef, hasOverflow } = useFadedContentOverflow(); const modalTitle = "call_omo_agent"; const summaryLine = description || @@ -337,7 +342,7 @@ export function CallOmoAgentStep({ className="group relative w-full overflow-hidden rounded-lg border border-oc-border-soft bg-oc-bg-soft/60 text-left transition-colors hover:border-oc-border hover:bg-oc-panel-soft/60" aria-label="View subagent details" > -
+
{summaryLine}
@@ -347,9 +352,7 @@ export function CallOmoAgentStep({
)}
-
- -
+ {hasOverflow && }
diff --git a/webview/shared/src/chat/components/activity-steps/DiffPreviewStep.tsx b/webview/shared/src/chat/components/activity-steps/DiffPreviewStep.tsx index 8a16cc5..6257adf 100644 --- a/webview/shared/src/chat/components/activity-steps/DiffPreviewStep.tsx +++ b/webview/shared/src/chat/components/activity-steps/DiffPreviewStep.tsx @@ -4,6 +4,10 @@ import { createPortal } from "react-dom"; import { ChevronDown, Copy, Diff, FileCode2, X } from "lucide-react"; import { cn } from "../../../utils"; +import { + FadedCollapseOverlay, + useFadedContentOverflow, +} from "@/components/ui/FadedCollapseOverlay"; import { ActivityDiffExcerpt } from "../ActivityDiffExcerpt"; import { usePersistentModalOpen } from "../../lib/usePersistentModalOpen"; @@ -286,6 +290,7 @@ export function DiffPreviewStep({ const [isModalOpen, setIsModalOpen] = usePersistentModalOpen( `diff-preview:${filePath || title || activityDetail?.id || "unknown"}`, ); + const { ref: excerptPreviewRef, hasOverflow } = useFadedContentOverflow(); const derivedExcerpt = useMemo(() => { if (excerpt && Array.isArray(excerpt.lines) && excerpt.lines.length > 0) { return excerpt; @@ -310,8 +315,6 @@ export function DiffPreviewStep({ const deleted = Math.max(0, diffStats?.deleted ?? derivedExcerpt?.deleted ?? counts.deleted); const summaryTitle = title || "Diff Preview"; const previewPath = compactPath(filePath); - const shouldCollapseExcerpt = (derivedExcerpt?.lines?.length ?? 0) > 8; - return ( <>
); diff --git a/webview/shared/src/chat/index.css b/webview/shared/src/chat/index.css index c0eb74c..749dc24 100644 --- a/webview/shared/src/chat/index.css +++ b/webview/shared/src/chat/index.css @@ -551,12 +551,6 @@ body { } .oc-details-sheet .oc-inspector-section-toggle > span:first-child { - width: 5px; - height: 5px; - background: color-mix(in srgb, var(--oc-accent) 56%, transparent); -} - -.oc-details-sheet .oc-inspector-section-toggle > span:nth-child(2) { color: var(--oc-text-secondary); font-size: 10px; font-weight: 600; @@ -833,7 +827,7 @@ body { } .oc-details-sheet .oc-details-tab-content--overview { - padding: 0; + padding: 8px 10px 10px; } .oc-details-sheet .oc-details-tab-stack { @@ -855,7 +849,7 @@ body { } .oc-details-sheet .oc-details-tab-content--overview > .oc-active-task-panel { - margin-top: 8px; + margin-top: 0; } .oc-details-sheet .oc-active-task-heading, @@ -1329,7 +1323,7 @@ body { /* Overview typography: one compact hierarchy shared by Active Task, Context, * Runtime, and Session. */ .oc-details-sheet .oc-active-task-heading .oc-panel-title, -.oc-details-sheet .oc-inspector-section-toggle > span:nth-child(2) { +.oc-details-sheet .oc-inspector-section-toggle > span:first-child { font-size: 10px !important; font-weight: 600; line-height: 1.2; @@ -6192,29 +6186,43 @@ body { border-color: var(--oc-border-soft); } -/* Error card - red semantic */ +/* Error notice — neutral surface with a local semantic failure signal. */ .oc-error { - @apply rounded-lg border p-2.5 transition-all duration-200; - border-color: rgba(239, 68, 68, 0.5); - background-color: rgba(127, 29, 29, 0.15); - color: rgba(254, 226, 226, 1); + @apply rounded-lg border px-3 py-2.5 transition-colors duration-150; + border-color: var(--oc-surface-border); + background: var(--oc-surface-fill); + box-shadow: inset 2px 0 0 var(--oc-red); } .oc-error-icon { - @apply inline-flex h-5 w-5 items-center justify-center rounded-md; - border-color: rgba(239, 68, 68, 0.5); - background-color: rgba(239, 68, 68, 0.15); + @apply inline-flex h-5 w-5 shrink-0 items-center justify-center; + color: var(--oc-red); +} + +.oc-error-footer { + @apply mt-2 flex flex-wrap items-center gap-2; } .oc-error-action { - @apply inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-[11px] font-medium transition-all active:scale-95; - border-color: rgba(239, 68, 68, 0.5); - background-color: rgba(239, 68, 68, 0.15); - color: rgba(254, 226, 226, 1); + @apply inline-flex min-h-7 items-center gap-1.5 rounded-md border px-2.5 py-1 text-[11px] font-medium transition-colors duration-150; + border-color: var(--oc-surface-border); + background: transparent; + color: var(--oc-text); } .oc-error-action:hover { - background-color: rgba(239, 68, 68, 0.25); + background: color-mix(in srgb, var(--oc-panel-soft) 72%, transparent); + border-color: var(--oc-border); +} + +.oc-error-action:focus-visible { + outline: 1px solid var(--vscode-focusBorder, var(--oc-accent)); + outline-offset: 2px; +} + +.oc-error-hint { + @apply text-[11px] leading-snug; + color: var(--oc-text-secondary); } /* Warning card - amber semantic */ @@ -6407,23 +6415,6 @@ body { border-color: rgba(210, 153, 34, 0.3); } -/* Error detail box - red semantic with subdued styling */ -.oc-error-detail { - @apply rounded-md border p-1.5; - border-color: rgba(239, 68, 68, 0.25); - background-color: rgba(69, 10, 10, 0.35); -} - -.oc-error-detail-title { - @apply mb-1 text-[9px] font-semibold uppercase tracking-[0.08em]; - color: #fca5a5; -} - -.oc-error-detail-content { - @apply overflow-hidden text-[11px] leading-snug whitespace-pre-wrap break-words; - color: #fee2e2; -} - /* Warning icon and text colors */ .oc-warning-icon-color { color: #fbbf24; diff --git a/webview/shared/src/chat/lib/messageHandler.subagents.test.ts b/webview/shared/src/chat/lib/messageHandler.subagents.test.ts new file mode 100644 index 0000000..3e3bd3e --- /dev/null +++ b/webview/shared/src/chat/lib/messageHandler.subagents.test.ts @@ -0,0 +1,44 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { applyStructuredSubagentPayload } from "./messageHandler"; +import { appReducer, initialState, type AppAction } from "./store"; +import type { AppState } from "./types"; + +test("structured subagent payloads populate the inline-card state", () => { + let state: AppState = { + ...initialState, + currentSessionId: "ses-parent", + }; + const dispatch = (action: AppAction) => { + state = appReducer(state, action); + }; + + applyStructuredSubagentPayload( + dispatch, + () => state, + { + responseType: "subagents", + subagents: [ + { + id: "bg_research", + backgroundTaskId: "bg_research", + agentRole: "explorer", + status: "running", + latestActivity: "Searching the codebase", + }, + ], + }, + "msg-parent", + ); + + assert.equal(state.subagentsByParentMessageId["msg-parent"]?.length, 1); + const subagent = state.subagentsByParentMessageId["msg-parent"]?.[0]; + assert.equal(subagent?.id, "bg_research"); + assert.equal(subagent?.backgroundTaskId, "bg_research"); + assert.equal(subagent?.parentSessionId, "ses-parent"); + assert.equal(subagent?.parentMessageId, "msg-parent"); + assert.equal(subagent?.agentRole, "explorer"); + assert.equal(subagent?.status, "running"); + assert.equal(subagent?.latestActivity, "Searching the codebase"); +}); diff --git a/webview/shared/src/chat/lib/messageHandler.test.ts b/webview/shared/src/chat/lib/messageHandler.test.ts index 9c8316c..b4ef48a 100644 --- a/webview/shared/src/chat/lib/messageHandler.test.ts +++ b/webview/shared/src/chat/lib/messageHandler.test.ts @@ -153,6 +153,132 @@ describe('createMessageHandler - terminal turn identity', () => { assert.equal(state.assistantTurnPending, false); assert.equal(state.streaming?.isActive, false); }); + + it('keeps loading locked off for the same message after finish stop and heartbeat events', () => { + let state = { + ...initialState, + currentSessionId: 'ses-terminal-lock', + isProcessing: true, + assistantTurnPending: true, + assistantTurnMessageId: 'msg-terminal-lock', + streaming: { + messageId: 'msg-terminal-lock', + content: 'Completed response', + reasoning: '', + reasoningEvents: [], + steps: [], + progressEvents: [], + edits: [], + isActive: true, + }, + } as AppState; + const dispatch = (action: Parameters[1]) => { + state = appReducer(state, action); + }; + const handler = createMessageHandler(dispatch, () => state); + + handler({ + data: { + type: 'streamEvent', + processing: true, + sessionId: 'ses-terminal-lock', + event: { + type: 'message.part.updated', + properties: { + part: { + type: 'step-finish', + messageID: 'msg-terminal-lock', + reason: 'stop', + }, + }, + }, + }, + } as MessageEvent); + + handler({ + data: { + type: 'streamEvent', + processing: true, + sessionId: 'ses-terminal-lock', + event: { type: 'server.heartbeat' }, + }, + } as MessageEvent); + + handler({ + data: { + type: 'streamEvent', + processing: true, + sessionId: 'ses-terminal-lock', + event: { + type: 'message.updated', + properties: { + info: { + id: 'msg-terminal-lock', + role: 'assistant', + }, + }, + }, + }, + } as MessageEvent); + + assert.equal(state.isProcessing, false); + assert.equal(state.assistantTurnPending, false); + assert.equal(state.streaming?.isActive, false); + }); + + it('preserves a visible assistant phase when the stream changes message id', () => { + let state = { + ...initialState, + currentSessionId: 'ses-multi-phase', + isProcessing: true, + assistantTurnPending: true, + assistantTurnMessageId: 'msg-phase-1', + messages: [ + { id: 'msg-user', role: 'user', content: 'Inspect the project' }, + ], + streaming: { + messageId: 'msg-phase-1', + content: 'The first visible response block must remain.', + hasRenderableContent: true, + reasoning: '', + reasoningEvents: [], + steps: [], + progressEvents: [], + edits: [], + isActive: true, + }, + } as AppState; + const dispatch = (action: Parameters[1]) => { + state = appReducer(state, action); + }; + const handler = createMessageHandler(dispatch, () => state); + + handler({ + data: { + type: 'streamEvent', + sessionId: 'ses-multi-phase', + event: { + type: 'message.updated', + properties: { + info: { + id: 'msg-phase-2', + role: 'assistant', + }, + }, + }, + }, + } as MessageEvent); + + assert.equal(state.streaming?.messageId, 'msg-phase-2'); + assert.ok( + state.messages.some( + (message) => + (message.id === 'msg-phase-1' || message.info?.id === 'msg-phase-1') && + message.content === 'The first visible response block must remain.', + ), + 'the populated response phase must be materialized before the live stream is re-keyed', + ); + }); }); describe('createMessageHandler - chatHistory hydration guards', () => { diff --git a/webview/shared/src/chat/lib/messageHandler.ts b/webview/shared/src/chat/lib/messageHandler.ts index 6b6fc49..dcba096 100644 --- a/webview/shared/src/chat/lib/messageHandler.ts +++ b/webview/shared/src/chat/lib/messageHandler.ts @@ -79,8 +79,7 @@ import { normalizeHydratedSubagentDetail as compatNormalizeHydratedSubagentDetail, hydrateSubagentSummary as compatHydrateSubagentSummary, areSubagentListsEquivalent as compatAreSubagentListsEquivalent, - shouldFreezeSubagentForPresentation as compatShouldFreezeSubagentForPresentation, - applyStructuredSubagentPayload + shouldFreezeSubagentForPresentation as compatShouldFreezeSubagentForPresentation } from './subagents/compatUtils'; import { latestSubagentEventTimestamp, @@ -137,6 +136,19 @@ export function extractEventMessageId(event: unknown): string | null { // Sometimes the raw stream wraps the actual payload in a `payload` property. const payloadRecord = asRecord(eventRecord.payload); + // Webview transport messages wrap the semantic SDK event in `event`. Resolve + // that layer before falling back to wrapper metadata so terminal turn locks + // remain keyed to the assistant message rather than the transport envelope. + const semanticEventRecord = + asRecord(eventRecord.event) ?? + asRecord(payloadRecord?.event); + + if (semanticEventRecord && semanticEventRecord !== eventRecord) { + const semanticMessageId = extractEventMessageId(semanticEventRecord); + if (semanticMessageId) { + return semanticMessageId; + } + } const info = asRecord(asRecord(eventRecord.properties)?.info) || @@ -2582,6 +2594,99 @@ type StructuredOutput = { }; }; +/** + * Project structured-output subagent data into the canonical subagent store. + * + * SDK stream events remain the preferred source, but structured output is also + * a supported transport. Without this projection, valid `subagents` payloads + * never reach `subagentsByParentMessageId`, leaving the inline card empty. + */ +export function applyStructuredSubagentPayload( + dispatch: Dispatch, + getState: () => AppState, + structuredOutput: StructuredOutput, + messageId: string, +): void { + const state = getState(); + const fallbackParentMessageId = + structuredOutput.subagentsDelta?.parentMessageId || messageId; + const fallbackParentSessionId = state.currentSessionId; + if (!fallbackParentMessageId || !fallbackParentSessionId) { + return; + } + + const incoming = [ + ...(Array.isArray(structuredOutput.subagents) + ? structuredOutput.subagents + : []), + ...(Array.isArray(structuredOutput.subagentsDelta?.items) + ? structuredOutput.subagentsDelta.items + : []), + ]; + if (incoming.length === 0) { + return; + } + + const summariesByParentMessageId: Record = {}; + const detailsById: Record = {}; + + for (const item of incoming) { + if (!item?.id) { + continue; + } + const parentMessageId = item.parentMessageId || fallbackParentMessageId; + const parentSessionId = item.parentSessionId || fallbackParentSessionId; + if (!parentMessageId || !parentSessionId) { + continue; + } + const status = + item.status === "running" || + item.status === "done" || + item.status === "error" || + item.status === "orphaned" || + item.status === "cancelled" + ? item.status + : "pending"; + const latestActivity = + sanitizeSubagentLabel( + item.latestActivity || item.description || item.name || item.agentId || "", + ) || "Subagent update"; + const detail: SubagentDetail = { + id: item.id, + backgroundTaskId: item.backgroundTaskId, + parentSessionId, + parentMessageId, + childSessionId: item.childSessionId, + agentId: item.agentId || item.agent || item.name, + agentRole: item.agentRole || item.agentType, + status, + latestActivity, + references: [], + thinkingEvents: item.thinkingEvents || [], + conversationEvents: [], + progressEvents: item.progressEvents || [], + timelineEvents: item.timelineEvents || [], + }; + detailsById[detail.id] = detail; + (summariesByParentMessageId[parentMessageId] ??= []).push(detail); + } + + if (!hasSubagentSummaryEntries(summariesByParentMessageId)) { + return; + } + dispatch({ + type: "UPSERT_SUBAGENT_SUMMARIES", + payload: mergeSubagentSummaryPayload( + state.subagentsByParentMessageId, + summariesByParentMessageId, + ), + }); + dispatch({ + type: "UPSERT_SUBAGENT_DETAIL", + payload: mergeSubagentDetailPayload(state.subagentDetailsById, detailsById), + }); +} + function buildStructuredOutputLogPreview(value: unknown): { type: string; preview: string; @@ -4366,6 +4471,20 @@ function shouldBootstrapStreamingFromPart(part: UnknownRecord | null): boolean { return true; } + // Raw message.part.delta events can arrive without a typed `part`; their + // properties object is passed here as the fallback part. Let a textual delta + // establish the live snapshot so its first token can render immediately. + const deltaField = asString(part.field).trim().toLowerCase(); + if ( + typeof part.delta === "string" && + (deltaField === "text" || + deltaField === "content" || + deltaField === "message" || + deltaField === "output_text") + ) { + return true; + } + const reasoningLike = asString(part.reasoning) || asString(part.thought) || @@ -5236,6 +5355,60 @@ function cloneRawSnapshot(value: T): T { return value; } +function mergeReasoningEventSnapshots( + finalizedEvents: ReasoningEvent[], + streamingEvents: ReasoningEvent[], +): ReasoningEvent[] { + const merged: ReasoningEvent[] = []; + + for (const event of [...finalizedEvents, ...streamingEvents]) { + const textKey = normalizeComparableText(asString(event?.text)); + if (!textKey) { + continue; + } + + const matchingIndex = merged.findIndex((existing) => { + const samePart = + !!event.partID && + !!existing.partID && + event.partID === existing.partID; + if (samePart) { + return true; + } + + const sameText = + normalizeComparableText(asString(existing.text)) === textKey; + const compatibleMessage = + !event.messageID || + !existing.messageID || + event.messageID === existing.messageID; + return sameText && compatibleMessage; + }); + + if (matchingIndex < 0) { + merged.push({ ...event }); + continue; + } + + const existing = merged[matchingIndex]; + merged[matchingIndex] = { + ...event, + ...existing, + text: + normalizeComparableText(asString(event.text)).length > + normalizeComparableText(asString(existing.text)).length + ? event.text + : existing.text, + partID: existing.partID ?? event.partID, + messageID: existing.messageID ?? event.messageID, + startedAt: existing.startedAt ?? event.startedAt, + endedAt: existing.endedAt ?? event.endedAt, + }; + } + + return merged; +} + export function normalizeMessage(message: Message, streaming: StreamingState | null): Message | undefined { const rec = asRecord(message); if (!rec) { @@ -5678,10 +5851,10 @@ export function normalizeMessage(message: Message, streaming: StreamingState | n const existingReasoningEvents = Array.isArray(message.reasoningEvents) ? message.reasoningEvents : []; - const mergedReasoningEvents = [ - ...existingReasoningEvents, - ...(streaming?.reasoningEvents ?? []) - ]; + const mergedReasoningEvents = mergeReasoningEventSnapshots( + existingReasoningEvents, + streaming?.reasoningEvents ?? [], + ); const reasoningSources = new Set(); if (Array.isArray(streaming?.reasoningEvents) && streaming.reasoningEvents.length > 0) { reasoningSources.add("stream"); @@ -8815,7 +8988,7 @@ function handleStreamEvent( return; } - if (!isHeartbeatEvent && ensureStreamingBootstrapFromCentralizedPayload( + if (!shouldSuppressProcessingBootstrap && !isHeartbeatEvent && ensureStreamingBootstrapFromCentralizedPayload( dispatch, getState, bootstrapContext, @@ -8883,7 +9056,7 @@ function handleStreamEvent( }, }); } - if (!getState().assistantTurnPending) { + if (!shouldSuppressProcessingBootstrap && !getState().assistantTurnPending) { dispatch({ type: "SET_ASSISTANT_TURN_PENDING", payload: { pending: true, messageId }, @@ -9027,6 +9200,16 @@ function handleStreamEvent( const isRawDeltaReasoningField = eventType === "message.part.delta" && (deltaField === "reasoning" || deltaField === "thinking"); + // Token streams may be emitted as a bare message.part.delta envelope: + // `properties.field` identifies the content field while `part.type` is + // absent. Treat text/content deltas as normal assistant output so they + // update the live card instead of waiting for final history hydration. + const isRawDeltaTextField = + eventType === "message.part.delta" && + (deltaField === "text" || + deltaField === "content" || + deltaField === "message" || + deltaField === "output_text"); // Some providers emit an intermediate thought as a `message.part.updated` // text snapshot instead of a typed reasoning part. Its full `part.text` // exactly equals the delta. Normal response token streaming uses the @@ -9164,24 +9347,6 @@ function handleStreamEvent( const streamingState = getState().streaming; - // SKIP CONTENT PROCESSING for reasoning parts, but allow all other event processing to continue - // This prevents reasoning from being rendered in the UI while still processing steps, tools, and interactive events - if (hasExplicitReasoningOnlyChunk) { - const sanitized = sanitizeReasoningChunk(reasoningChunk); - if (sanitized) { - dispatch({ - type: "UPDATE_STREAMING_REASONING", - payload: { - reasoning: sanitized, - append: true, - delta: isDeltaReasoningChunk, - partID: reasoningPartID, - messageID: messageId || undefined, - }, - }); - } - } - // Guard: parts with embedded reasoning fields (type is "text" but carries // reasoning/thinking/thought data). Route the reasoning content to the // reasoning pipeline so it stays out of the visible assistant body. @@ -9189,22 +9354,6 @@ function handleStreamEvent( !isReasoning && !hasExplicitReasoningOnlyChunk && reasoningChunk.trim().length > 0; - if (hasEmbeddedReasoning) { - const sanitized = sanitizeReasoningChunk(reasoningChunk); - if (sanitized) { - dispatch({ - type: "UPDATE_STREAMING_REASONING", - payload: { - reasoning: sanitized, - append: true, - delta: isDeltaReasoningChunk, - partID: reasoningPartID, - messageID: messageId || undefined, - }, - }); - } - } - const isReasoningPart = partType === 'reasoning' || structuredKind === 'thinking' || @@ -9245,7 +9394,9 @@ function handleStreamEvent( structuredKind !== "thinking" && (structuredKind === "message" || ((!structuredKind || structuredKind === "message") && - (partType === "text" || partType === "message"))); + (partType === "text" || + partType === "message" || + isRawDeltaTextField))); if (canAppendMainContent) { let candidateChunk = textChunk; @@ -9366,7 +9517,9 @@ function handleStreamEvent( payload: { content: contentPatch.content, append: contentPatch.append, - renderable: isRenderableStreamingPartType(partType), + renderable: + isRenderableStreamingPartType(partType) || + isRawDeltaTextField, }, }); } @@ -9853,6 +10006,12 @@ function handleStreamEvent( // streaming snapshot is still hanging around in state. Re-key the live // snapshot immediately so the old assistant card does not keep loading // and the new turn gets its own timeline / response card. + // OpenCode can emit multiple assistant message envelopes for one user + // turn (for example, text -> tool -> final text). Persist the populated + // phase before re-keying; otherwise SET_STREAMING replaces its content + // with an empty snapshot and the already-rendered response disappears + // until a later history hydration happens to restore it. + flushVisibleStreamingSnapshotToMessages(dispatch, getState); const eventAgent = asString(infoRecord?.agent) || asString(payload.agent); const eventModel = asRecord(infoRecord?.model) || asRecord(payload.model); const eventModelID = asString(infoRecord?.modelID) || asString(payload.modelID); @@ -11130,9 +11289,13 @@ export function createMessageHandler(dispatch: Dispatch, getState: () // (session.diff, replayed parent-user messages) don't carry the assistant id, // so we fall back to the live assistantTurnMessageId latch. const currentAssistantTurnMessageId = getState().assistantTurnMessageId || null; + const currentStreamingMessageId = getState().streaming?.messageId || null; const currentAssistantTurnIsClosed = !!currentAssistantTurnMessageId && closedAssistantTurnMessageIds.has(currentAssistantTurnMessageId); + const currentStreamingTurnIsClosed = + !!currentStreamingMessageId && + closedAssistantTurnMessageIds.has(currentStreamingMessageId); const shouldSuppressProcessingBootstrap = !!( awaitingInteractiveTurnStart && asBoolean(data.processing, false) && @@ -11143,7 +11306,7 @@ export function createMessageHandler(dispatch: Dispatch, getState: () ) || ( !!eventMessageId && closedAssistantTurnMessageIds.has(eventMessageId) - ) || currentAssistantTurnIsClosed; + ) || currentAssistantTurnIsClosed || currentStreamingTurnIsClosed; if ( asBoolean(data.processing, false) && type !== "compactionStatus" && @@ -12014,7 +12177,27 @@ export function createMessageHandler(dispatch: Dispatch, getState: () chatHistorySessionId && currentState.currentSessionId !== chatHistorySessionId ); - if (isSwitchingSession) { + const suppressSessionLoading = asBoolean( + (data as UnknownRecord).suppressSessionLoading, + false, + ); + if (suppressSessionLoading) { + logger.warn("[FORK_TRACE][WEBVIEW] fork chatHistory received", { + chatHistorySessionId, + previousSessionId: currentState.currentSessionId, + isSwitchingSession, + isSessionProcessing, + payloadProcessingSessionIds, + effectiveProcessingSessionIds, + priorIsProcessing: currentState.isProcessing, + priorIsLoadingSession: currentState.isLoadingSession, + priorStreamingActive: currentStreamingSnapshot?.isActive ?? false, + priorStreamingMessageId: currentStreamingSnapshot?.messageId ?? null, + shouldPreserveActiveStreaming, + messageCount: dedupedSystemMessages.length, + }); + } + if (isSwitchingSession && !suppressSessionLoading) { // Look up the session title from the sessions list const session = (currentState.sessionsList ?? []).find(s => s.id === chatHistorySessionId); const sessionTitle = session?.title || chatHistorySessionId; @@ -12035,7 +12218,10 @@ export function createMessageHandler(dispatch: Dispatch, getState: () latestStreamingSnapshot = null; } - if (!shouldPreserveActiveStreaming && !isSwitchingSession) { + if ( + !shouldPreserveActiveStreaming && + (!isSwitchingSession || suppressSessionLoading) + ) { dispatch({ type: "SET_STREAMING", payload: null }); dispatch({ type: "SET_PROCESSING", payload: isSessionProcessing }); } @@ -12045,6 +12231,20 @@ export function createMessageHandler(dispatch: Dispatch, getState: () } dispatch({ type: "SET_MESSAGES", payload: canonicalMessages }); + if (suppressSessionLoading) { + const stateAfterForkHistory = getState(); + logger.warn("[FORK_TRACE][WEBVIEW] fork chatHistory applied", { + currentSessionId: stateAfterForkHistory.currentSessionId, + isProcessing: stateAfterForkHistory.isProcessing, + isLoadingSession: stateAfterForkHistory.isLoadingSession, + processingSessionIds: stateAfterForkHistory.processingSessionIds, + streamingActive: stateAfterForkHistory.streaming?.isActive ?? false, + streamingMessageId: stateAfterForkHistory.streaming?.messageId ?? null, + assistantTurnPending: stateAfterForkHistory.assistantTurnPending, + assistantTurnMessageId: stateAfterForkHistory.assistantTurnMessageId, + messageCount: stateAfterForkHistory.messages.length, + }); + } } catch (error) { dispatch({ type: "END_SESSION_LOADING" }); throw error; @@ -12951,7 +13151,7 @@ export function createMessageHandler(dispatch: Dispatch, getState: () }, }); }; - if (isImmediateActivityStreamPayload(payload)) { + if (data.immediate === true || isImmediateActivityStreamPayload(payload)) { processScopedStreamEvent(); } else { startTransition(processScopedStreamEvent); @@ -12961,7 +13161,7 @@ export function createMessageHandler(dispatch: Dispatch, getState: () const processStreamEvent = () => { handleStreamEvent(dispatch, getState, payload, terminalErrorReached, shouldSuppressProcessingBootstrap, markAssistantTurnClosed); }; - if (isImmediateActivityStreamPayload(payload)) { + if (data.immediate === true || isImmediateActivityStreamPayload(payload)) { processStreamEvent(); } else { startTransition(processStreamEvent); @@ -13138,12 +13338,19 @@ export function createMessageHandler(dispatch: Dispatch, getState: () break; } }; + const scopedEventMessageId = extractEventMessageId(evtPayload); + const scopedTurnIsClosed = !!( + (scopedEventMessageId && + closedAssistantTurnMessageIds.has(scopedEventMessageId)) || + (scopedState.streaming?.messageId && + closedAssistantTurnMessageIds.has(scopedState.streaming.messageId)) + ); handleStreamEvent( scopedDispatch, scopedGetState, evtPayload, terminalErrorReached, - false, + scopedTurnIsClosed, markAssistantTurnClosed, ); dispatch({ @@ -13154,7 +13361,22 @@ export function createMessageHandler(dispatch: Dispatch, getState: () }, }); } else { - handleStreamEvent(trackedDispatch, getState, evtPayload, terminalErrorReached, false, markAssistantTurnClosed); + const batchEventMessageId = extractEventMessageId(evtPayload); + const activeStreamingMessageId = getState().streaming?.messageId; + const batchTurnIsClosed = !!( + (batchEventMessageId && + closedAssistantTurnMessageIds.has(batchEventMessageId)) || + (activeStreamingMessageId && + closedAssistantTurnMessageIds.has(activeStreamingMessageId)) + ); + handleStreamEvent( + trackedDispatch, + getState, + evtPayload, + terminalErrorReached, + batchTurnIsClosed, + markAssistantTurnClosed, + ); } if ( @@ -13187,6 +13409,7 @@ export function createMessageHandler(dispatch: Dispatch, getState: () }); }; const hasImmediateActivity = events.some((item) => + item.immediate === true || isImmediateActivityStreamPayload(asRecord(item.event) ?? item), ); if (hasImmediateActivity) { diff --git a/webview/shared/src/components/ui/FadedCollapseOverlay.tsx b/webview/shared/src/components/ui/FadedCollapseOverlay.tsx new file mode 100644 index 0000000..0813f41 --- /dev/null +++ b/webview/shared/src/components/ui/FadedCollapseOverlay.tsx @@ -0,0 +1,80 @@ +import { useEffect, useRef, useState } from "react"; +import { ChevronDown } from "lucide-react"; + +import { cn } from "@/utils"; + +type FadedCollapseOverlayProps = { + /** Action label shared by all shortened-content surfaces. */ + label?: string; + /** Makes the centered affordance interactive when the preview owns expansion. */ + onClick?: () => void; + /** Lets a preview fade into its own surface background. */ + backgroundClassName?: string; +}; + +/** Reports whether a max-height preview is actually hiding content. */ +export function useFadedContentOverflow(enabled = true) { + const ref = useRef(null); + const [hasOverflow, setHasOverflow] = useState(false); + + useEffect(() => { + const element = ref.current; + if (!element || !enabled) { + setHasOverflow(false); + return; + } + + const update = () => { + const next = element.scrollHeight > element.clientHeight + 1; + setHasOverflow((current) => (current === next ? current : next)); + }; + update(); + const observer = new ResizeObserver(update); + observer.observe(element); + return () => observer.disconnect(); + }, [enabled]); + + return { ref, hasOverflow }; +} + +/** + * Standard bottom fade and centered affordance for shortened chat content. + * Parent previews control the expanded state; this component only provides + * the consistent visual treatment and optional expand action. + */ +export function FadedCollapseOverlay({ + label = "Show full", + onClick, + backgroundClassName = "from-oc-bg-soft via-oc-bg-soft/90 to-transparent", +}: FadedCollapseOverlayProps) { + const contents = ( + <> + + {label} + + ); + + return ( +
+ {onClick ? ( + + ) : ( +
+ {contents} +
+ )} +
+ ); +}