refactor(chat): complete turn-based pipeline and stabilize streaming, scroll, and tool UX (#629)
* refactor: add canonical turn projection pipeline for chat Centralizes turn/activity/summary derivation, migrates render consumers to shared projection sources, and adds projector regression coverage for retries, malformed parent fallbacks, and activity segmentation. * refactor: stream assistant text live in turn-first render path * refactor: complete phase-3 turn timeline history/navigation * refactor: complete phase-4 turn pipeline cutover Remove legacy grouping adapters and lock key edge cases with executable regression tests so turn-centric rendering stays stable across retry/history/navigation flows. * fix: align chat history loading path with opencode parity * fix: stabilize chat streaming and reduce render churn * feat: add blurIn streaming text animation and smooth lerp-based auto-scroll Streaming markdown now uses Streamdown blurIn animation (150ms, ease-out) for per-word reveal Auto-scroll during streaming uses continuous exponential smoothing (lerp) instead of restarting spring animations One-shot scroll-to-bottom (button click) uses motion spring for natural deceleration * fix: respect user scroll-up during streaming and re-pin on scroll back to bottom * fix: stabilize activity stream and tool progress rendering Activity now stays chronologically consistent and avoids duplicate reasoning/justification. Tool rows show running state and duration earlier with smoother pending behavior. Removed legacy trim/timestamp paths to prevent message drops and simplify Activity UI. * fix: reverted regression of revert functionality * fix: stabilize chat activity layout and tool progress behavior Stopped user messages from re-animating when new user messages appear. Made Activity and aggregated tool rows wrap inline consistently without misaligned headers/icons. Fixed tool part matching so duration and shine no longer reset across pending/in-progress status updates. * fix: restore activity mode behavior and stabilize tool rendering Re-enabled Collapsed/Summary/Detailed/Changes defaults for Activity and tool expansion. Fixed tool row wrapping/alignment, removed user fade re-animations, and disabled FadeInOnReveal animations. Improved tool status handling to reduce timer/shine resets and made reasoning bold render as normal text. * refactor: simplify chat tool and message rendering Replace activity-group rendering with flat inline part rendering Remove obsolete tool output and justification activity settings Keep user message animation and improve edit/apply-patch file path headers * style: narrow chat and composer columns equally * fix: Tool status checks and abort flow regression * fix: improve multi-file apply patch header rendering * fix: make timeline message jump scroll to selected chat entry Conversation Timeline clicks now reliably scroll to the selected message in chat Timeline dialog waits for navigation result before closing to avoid silent failures Chat message scrolling now resolves the active chat scroller consistently * chore: remove branch-added chat tests and clean lint leftovers Removes newly added chat and message test files from this branch Fixes lint blockers in ProgressiveGroup, ToolPart, and messageStore Keeps type-check, lint, and build passing after cleanup * fix: keep composer status row closest to the input Status/todo row now renders below queued messages and attachments. Linked GitHub issue/PR chips stay above status so input context order is consistent. Improves chat composer readability while assistant is working. * fix: disable assistant header animations and polish chat status behavior Assistant message header now renders without transition effects Removed a lint issue from the working placeholder props Chat status and message flow updates improve consistency during active turns * feat: increase bottom spacer in mobile chat for better scrolling * feat: add sorted chat mode with progressive activity rendering Adds global chat render mode settings for sorted and live behavior Renders non-stop assistant output in Activity as messages complete Disables streaming text animation in sorted mode and restores classic reasoning/justification blocks * fix: stabilize chat timeline navigation and older history loading Prevents auto-loading older history from scroll and keeps loading behind the button Fixes timeline jump-to-message behavior that snapped back to the bottom Reduces chat list flicker and empty-state glitches on large session pagination * fix: stabilize sorted activity text and hide reasoning timers Prevented sorted justification from appearing before message completion by requiring explicit non-stop finish. Removed duration values for sorted Thinking and Justification blocks. Kept smooth sorted auto-follow behavior tuned to reduce bottom-jump jitter. * refactor: simplify chat tool rendering paths and add parts docs Centralized tool icon mapping into a shared presentation helper used by grouped and expandable rows. Removed static-tool-specific branches from ToolPart to keep it focused on expandable tools. Added a new DOCUMENTATION.md explaining where to change tool descriptions and rendering behavior. * feat: enhance event stream handling for message parts - Refactor event key building functions to improve clarity and reduce redundancy. - Introduce `applyPartDelta` method in message store for handling delta updates to message parts. - Update `useEventStream` to utilize the new `applyPartDelta` method for processing incoming deltas. - Enhance text extraction logic to accommodate new part types and improve merging behavior. - Implement logic to skip delta-driven updates for ongoing text/reasoning parts to prevent overwriting. - Adjust server-side text merging logic to handle trimmed text comparisons and improve consistency. * feat: enhance OpencodeService with delta handling and stale delta management - Introduced globalSseStaleDeltas to track stale deltas. - Added methods to generate keys for delta and updated part events. - Updated global SSE loop to skip processing stale deltas. - Refactored global SSE event handling to improve performance and reliability. refactor: remove unused streaming part handling in messageStore - Eliminated streaming part queue and associated logic. - Simplified message merging logic by removing redundant checks. - Streamlined message handling to improve clarity and maintainability. chore: clean up unused streaming text normalization functions - Removed deprecated functions related to streaming text identity and merging. - Simplified normalizeStreamingTextPayload to focus on current functionality. * refactor: simplify streaming event passthrough Use SDK-native global event streaming in the UI client Remove dead server-side SSE normalization helpers Keep streaming payloads closer to upstream behavior * revert: remove message memory settings UI Drop the restored message memory settings screen Keep session settings focused on current defaults Preserve chat history limits as internal behavior * feat: add compact collapsed Activity preview in sorted mode Collapsed Activity now shows a live preview of the latest 7 entries Older activity rows roll up behind a +N more indicator The +N more indicator is clickable to expand the full Activity * fix: adjusted activity changed files text prominence * fix: make sorted mode snap to bottom when not streaming * perf: show chat scrollbar only on user-driven scrolling Chat scrollbar now stays hidden during automatic follow-to-bottom Scrollbar visibility is gated by real user scroll intent Reduced scroll overhead by removing timer-based intent tracking * refactor: align web and vscode session activity streaming Route VS Code SSE through SDK-based proxy flow Restore controlled web session activity tracking and cooldowns Keep chat activity behavior consistent across runtimes * fix: adjusted muted foreground themes color * fix: stabilize session activity and attention updates Keep streamed chat state in sync as sessions complete Restore reliable attention indicators for inactive sessions Tighten server and UI event handling for chat updates * feat: add chat render previews and file icon visibility setting Added animated chat render mode previews in Chat settings. Added a new toggle to show or hide file icons across tool rows. Persisted chat render, activity, and mermaid modes to shared settings file. * fix: update default GitHub OAuth client ID Use the new OpenChamber org GitHub OAuth app ID by default. Keep web server and VS Code auth defaults in sync. * fix: sync chat settings across VS Code chat views Broadcast settings changes to both sidebar and editor chat webviews. Apply synced settings in each webview via shared settings refresh. Ensures chat render mode updates appear immediately in editor tabs. * fix: stop writing session folders file in VS Code workspaces Disable disk persistence for session folders in VS Code webview runtime Keep session folder state in VS Code/webview storage instead of project files Preserve existing behavior for non-VS Code runtimes * style: polish expanded tool panels and bash output readability Removed extra borders/labels in expanded tool cards for a cleaner UI Added softer scroll shadows and full-height decorative activity line Switched bash command/output to code-style typography with tighter spacing * feat: add default-open tool controls for chat activity Added Chat settings toggles to open Bash and Edit tools by default Default-open behavior now applies across both sorted and live activity rendering Users can still manually collapse default-open tools, plus bash output height was reduced * fix: align chat loading skeleton with message layout Match skeleton width and horizontal alignment to chat message column Add one-line tool-style skeleton rows with subtle circular placeholders Increase top spacing so loading state no longer touches chat header * fix: prevent blank screen when adding inline diff comments Sanitize persisted inline comment drafts to ignore invalid data Harden diff annotations against malformed line numbers Fail safely when annotation updates error instead of crashing the view * fix: streamline VS Code archived sessions sidebar Show archived sessions in VS Code only for the active workspace directory. Render archived sessions as a flat list in VS Code without project-root subfolders. Hide the "Open in Side Panel" session action in VS Code.
This commit is contained in:
committed by
GitHub
parent
d969ff4af1
commit
fab32ca992
@@ -4,6 +4,7 @@ import { getThemeKindName } from './theme';
|
||||
import type { OpenCodeManager, ConnectionStatus } from './opencode';
|
||||
import { getWebviewShikiThemes } from './shikiThemes';
|
||||
import { getWebviewHtml } from './webviewHtml';
|
||||
import { openSseProxy } from './sseProxy';
|
||||
|
||||
export class AgentManagerPanelProvider {
|
||||
public static readonly viewType = 'openchamber.agentManager';
|
||||
@@ -15,7 +16,6 @@ export class AgentManagerPanelProvider {
|
||||
private _cachedError?: string;
|
||||
private _sseCounter = 0;
|
||||
private _sseStreams = new Map<string, AbortController>();
|
||||
private _sseHeartbeats = new Map<string, ReturnType<typeof setInterval>>();
|
||||
|
||||
constructor(
|
||||
private readonly _context: vscode.ExtensionContext,
|
||||
@@ -65,11 +65,6 @@ export class AgentManagerPanelProvider {
|
||||
}
|
||||
this._sseStreams.clear();
|
||||
|
||||
for (const heartbeat of this._sseHeartbeats.values()) {
|
||||
clearInterval(heartbeat);
|
||||
}
|
||||
this._sseHeartbeats.clear();
|
||||
|
||||
this._panel = undefined;
|
||||
}, null, this._context.subscriptions);
|
||||
|
||||
@@ -142,28 +137,13 @@ export class AgentManagerPanelProvider {
|
||||
};
|
||||
}
|
||||
|
||||
private _collectHeaders(headers: Headers): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
headers.forEach((value, key) => {
|
||||
result[key] = value;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
private async _startSseProxy(message: BridgeRequest): Promise<BridgeResponse> {
|
||||
const { id, type, payload } = message;
|
||||
const apiBaseUrl = this._openCodeManager?.getApiUrl();
|
||||
|
||||
const { path, headers } = (payload || {}) as { path?: string; headers?: Record<string, string> };
|
||||
const normalizedPath = typeof path === 'string' && path.trim().length > 0 ? path.trim() : '/event';
|
||||
const normalizedPathname = (() => {
|
||||
const rawPathname = normalizedPath.split('?')[0];
|
||||
if (rawPathname === '/') return '/';
|
||||
return rawPathname.replace(/\/+$/, '');
|
||||
})();
|
||||
const shouldInjectActivity = normalizedPathname === '/event' || normalizedPathname === '/global/event';
|
||||
|
||||
if (!apiBaseUrl) {
|
||||
if (!this._openCodeManager) {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
@@ -175,36 +155,43 @@ export class AgentManagerPanelProvider {
|
||||
const streamId = `sse_${++this._sseCounter}_${Date.now()}`;
|
||||
const controller = new AbortController();
|
||||
|
||||
const base = `${apiBaseUrl.replace(/\/+$/, '')}/`;
|
||||
const targetUrl = new URL(normalizedPath.replace(/^\/+/, ''), base).toString();
|
||||
|
||||
let response: Response;
|
||||
let wrapAsGlobal = false;
|
||||
|
||||
const requestHeaders = this._buildSseHeaders({
|
||||
...(headers || {}),
|
||||
...(this._openCodeManager?.getOpenCodeAuthHeaders() || {}),
|
||||
});
|
||||
|
||||
try {
|
||||
response = await fetch(targetUrl, {
|
||||
method: 'GET',
|
||||
headers: requestHeaders,
|
||||
const start = await openSseProxy({
|
||||
manager: this._openCodeManager,
|
||||
path: normalizedPath,
|
||||
headers: this._buildSseHeaders(headers),
|
||||
signal: controller.signal,
|
||||
onChunk: (chunk) => {
|
||||
this._panel?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk });
|
||||
},
|
||||
});
|
||||
|
||||
// Fallback for OpenCode versions without /global/event.
|
||||
if ((!response.ok || !response.body) && normalizedPathname === '/global/event') {
|
||||
const fallbackUrl = new URL('event', base).toString();
|
||||
response = await fetch(fallbackUrl, {
|
||||
method: 'GET',
|
||||
headers: requestHeaders,
|
||||
signal: controller.signal,
|
||||
this._sseStreams.set(streamId, controller);
|
||||
|
||||
start.run
|
||||
.then(() => {
|
||||
this._panel?.webview.postMessage({ type: 'api:sse:end', streamId });
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!controller.signal.aborted) {
|
||||
const messageText = error instanceof Error ? error.message : String(error);
|
||||
this._panel?.webview.postMessage({ type: 'api:sse:end', streamId, error: messageText });
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this._sseStreams.delete(streamId);
|
||||
});
|
||||
if (response.ok && response.body) {
|
||||
wrapAsGlobal = true;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
status: 200,
|
||||
headers: start.headers,
|
||||
streamId,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
@@ -214,120 +201,6 @@ export class AgentManagerPanelProvider {
|
||||
data: { status: 502, headers: { 'content-type': 'application/json' }, streamId: null, error: message },
|
||||
};
|
||||
}
|
||||
|
||||
const responseHeaders = this._collectHeaders(response.headers);
|
||||
const responseBody = response.body;
|
||||
if (!response.ok || !responseBody) {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
status: response.status,
|
||||
headers: responseHeaders,
|
||||
streamId: null,
|
||||
error: `SSE failed: ${response.status}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
this._sseStreams.set(streamId, controller);
|
||||
|
||||
const fallbackDirectory = this._openCodeManager?.getWorkingDirectory()
|
||||
|| vscode.workspace.workspaceFolders?.[0]?.uri.fsPath
|
||||
|| 'global';
|
||||
|
||||
if (shouldInjectActivity) {
|
||||
const heartbeatTimer = setInterval(() => {
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
const heartbeatChunk = `${buildHeartbeatEventBlock()}\n\n`;
|
||||
this._panel?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: heartbeatChunk });
|
||||
}, 30000);
|
||||
this._sseHeartbeats.set(streamId, heartbeatTimer);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const reader = responseBody.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let sseBuffer = '';
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (controller.signal.aborted) break;
|
||||
if (value && value.length > 0) {
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
if (!chunk) continue;
|
||||
|
||||
// Reduce webview message pressure by forwarding complete SSE blocks.
|
||||
sseBuffer += chunk.replace(/\r\n/g, '\n');
|
||||
const blocks = sseBuffer.split('\n\n');
|
||||
sseBuffer = blocks.pop() ?? '';
|
||||
if (blocks.length > 0) {
|
||||
const routedBlocks = wrapAsGlobal
|
||||
? wrapSseBlocksAsGlobal(blocks, fallbackDirectory)
|
||||
: blocks;
|
||||
const outboundBlocks = shouldInjectActivity ? expandSseBlocksWithActivity(routedBlocks) : routedBlocks;
|
||||
const joined = outboundBlocks.map((block: string) => `${block}\n\n`).join('');
|
||||
this._panel?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: joined });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const tail = decoder.decode();
|
||||
if (tail) {
|
||||
sseBuffer += tail.replace(/\r\n/g, '\n');
|
||||
}
|
||||
if (sseBuffer) {
|
||||
if (shouldInjectActivity) {
|
||||
const baseBlocks = wrapAsGlobal
|
||||
? wrapSseBlocksAsGlobal([sseBuffer], fallbackDirectory)
|
||||
: [sseBuffer];
|
||||
const outboundBlocks = expandSseBlocksWithActivity(baseBlocks);
|
||||
const joined = outboundBlocks.map((block: string) => `${block}\n\n`).join('');
|
||||
this._panel?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: joined });
|
||||
} else {
|
||||
this._panel?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: sseBuffer });
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
this._panel?.webview.postMessage({ type: 'api:sse:end', streamId });
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this._panel?.webview.postMessage({ type: 'api:sse:end', streamId, error: message });
|
||||
}
|
||||
} finally {
|
||||
this._sseStreams.delete(streamId);
|
||||
const heartbeat = this._sseHeartbeats.get(streamId);
|
||||
if (heartbeat) {
|
||||
clearInterval(heartbeat);
|
||||
this._sseHeartbeats.delete(streamId);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
status: response.status,
|
||||
headers: responseHeaders,
|
||||
streamId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async _stopSseProxy(message: BridgeRequest): Promise<BridgeResponse> {
|
||||
@@ -339,12 +212,6 @@ export class AgentManagerPanelProvider {
|
||||
controller.abort();
|
||||
this._sseStreams.delete(streamId);
|
||||
}
|
||||
|
||||
const heartbeat = this._sseHeartbeats.get(streamId);
|
||||
if (heartbeat) {
|
||||
clearInterval(heartbeat);
|
||||
this._sseHeartbeats.delete(streamId);
|
||||
}
|
||||
}
|
||||
return { id, type, success: true, data: { stopped: true } };
|
||||
}
|
||||
@@ -363,190 +230,3 @@ export class AgentManagerPanelProvider {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
type SessionActivityPhase = 'idle' | 'busy' | 'cooldown';
|
||||
|
||||
type SessionActivity = {
|
||||
sessionId: string;
|
||||
phase: SessionActivityPhase;
|
||||
};
|
||||
|
||||
const parseSseDataPayload = (block: string): Record<string, unknown> | null => {
|
||||
if (!block) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dataLines = block
|
||||
.split('\n')
|
||||
.filter((line) => line.startsWith('data:'))
|
||||
.map((line) => line.slice(5).replace(/^\s/, ''));
|
||||
|
||||
if (dataLines.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payloadText = dataLines.join('\n').trim();
|
||||
if (!payloadText) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(payloadText) as unknown;
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
const record = parsed as Record<string, unknown>;
|
||||
const nestedPayload = record.payload;
|
||||
if (nestedPayload && typeof nestedPayload === 'object') {
|
||||
return nestedPayload as Record<string, unknown>;
|
||||
}
|
||||
return record;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const deriveSessionActivity = (payload: Record<string, unknown> | null): SessionActivity | null => {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const type = payload.type;
|
||||
const properties = payload.properties as Record<string, unknown> | undefined;
|
||||
|
||||
if (type === 'session.status') {
|
||||
const status = properties?.status as Record<string, unknown> | undefined;
|
||||
const sessionId = properties?.sessionID ?? properties?.sessionId;
|
||||
const statusType = status?.type;
|
||||
|
||||
if (typeof sessionId === 'string' && sessionId.length > 0 && typeof statusType === 'string') {
|
||||
const phase: SessionActivityPhase = statusType === 'busy' || statusType === 'retry' ? 'busy' : 'idle';
|
||||
return { sessionId, phase };
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'message.updated') {
|
||||
const info = properties?.info as Record<string, unknown> | undefined;
|
||||
const sessionId = info?.sessionID ?? info?.sessionId ?? properties?.sessionID ?? properties?.sessionId;
|
||||
const role = info?.role;
|
||||
const finish = info?.finish;
|
||||
if (typeof sessionId === 'string' && sessionId.length > 0 && role === 'assistant' && finish === 'stop') {
|
||||
return { sessionId, phase: 'cooldown' };
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'message.part.updated' || type === 'message.part.delta') {
|
||||
const info = properties?.info as Record<string, unknown> | undefined;
|
||||
const sessionId = info?.sessionID ?? info?.sessionId ?? properties?.sessionID ?? properties?.sessionId;
|
||||
const role = info?.role;
|
||||
const finish = info?.finish;
|
||||
if (typeof sessionId === 'string' && sessionId.length > 0 && role === 'assistant' && finish === 'stop') {
|
||||
return { sessionId, phase: 'cooldown' };
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'session.idle') {
|
||||
const sessionId = properties?.sessionID ?? properties?.sessionId;
|
||||
if (typeof sessionId === 'string' && sessionId.length > 0) {
|
||||
return { sessionId, phase: 'idle' };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const buildActivityEventBlock = (activity: SessionActivity): string => {
|
||||
return `data: ${JSON.stringify({
|
||||
type: 'openchamber:session-activity',
|
||||
properties: {
|
||||
sessionId: activity.sessionId,
|
||||
phase: activity.phase,
|
||||
},
|
||||
})}`;
|
||||
};
|
||||
|
||||
const buildHeartbeatEventBlock = (): string => {
|
||||
return `data: ${JSON.stringify({ type: 'openchamber:heartbeat', timestamp: Date.now() })}`;
|
||||
};
|
||||
|
||||
const parseSseBlockForGlobalWrap = (block: string): { id?: string; payload: Record<string, unknown> } | null => {
|
||||
if (!block) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lines = block.split('\n');
|
||||
const dataLines: string[] = [];
|
||||
let eventId: string | undefined;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data:')) {
|
||||
dataLines.push(line.slice(5).replace(/^\s/, ''));
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('id:')) {
|
||||
const candidate = line.slice(3).trim();
|
||||
if (candidate) {
|
||||
eventId = candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (dataLines.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payloadText = dataLines.join('\n').trim();
|
||||
if (!payloadText) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(payloadText) as unknown;
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = parsed as Record<string, unknown>;
|
||||
const nestedPayload = record.payload;
|
||||
const payload = nestedPayload && typeof nestedPayload === 'object'
|
||||
? (nestedPayload as Record<string, unknown>)
|
||||
: record;
|
||||
|
||||
return eventId ? { id: eventId, payload } : { payload };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const wrapSseBlocksAsGlobal = (blocks: string[], directory: string): string[] => {
|
||||
const normalizedDirectory = typeof directory === 'string' && directory.trim().length > 0
|
||||
? directory.trim().replace(/\\/g, '/')
|
||||
: 'global';
|
||||
|
||||
return blocks.map((block) => {
|
||||
const parsed = parseSseBlockForGlobalWrap(block);
|
||||
if (!parsed) {
|
||||
return block;
|
||||
}
|
||||
|
||||
const envelope = {
|
||||
directory: normalizedDirectory,
|
||||
payload: parsed.payload,
|
||||
};
|
||||
|
||||
const idPrefix = parsed.id ? `id: ${parsed.id}\n` : '';
|
||||
return `${idPrefix}data: ${JSON.stringify(envelope)}`;
|
||||
});
|
||||
};
|
||||
|
||||
const expandSseBlocksWithActivity = (blocks: string[]): string[] => {
|
||||
const expanded: string[] = [];
|
||||
for (const block of blocks) {
|
||||
expanded.push(block);
|
||||
const activity = deriveSessionActivity(parseSseDataPayload(block));
|
||||
if (activity) {
|
||||
expanded.push(buildActivityEventBlock(activity));
|
||||
}
|
||||
}
|
||||
return expanded;
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getThemeKindName } from './theme';
|
||||
import type { OpenCodeManager, ConnectionStatus } from './opencode';
|
||||
import { getWebviewShikiThemes } from './shikiThemes';
|
||||
import { getWebviewHtml } from './webviewHtml';
|
||||
import { openSseProxy } from './sseProxy';
|
||||
|
||||
export class ChatViewProvider implements vscode.WebviewViewProvider {
|
||||
public static readonly viewType = 'openchamber.chatView';
|
||||
@@ -19,7 +20,6 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
||||
private _cachedError?: string;
|
||||
private _sseCounter = 0;
|
||||
private _sseStreams = new Map<string, AbortController>();
|
||||
private _sseHeartbeats = new Map<string, ReturnType<typeof setInterval>>();
|
||||
|
||||
constructor(
|
||||
private readonly _context: vscode.ExtensionContext,
|
||||
@@ -69,6 +69,10 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
||||
context: this._context,
|
||||
});
|
||||
webviewView.webview.postMessage(response);
|
||||
|
||||
if (message.type === 'api:config/settings:save' && response.success) {
|
||||
void vscode.commands.executeCommand('openchamber.internal.settingsSynced', response.data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -149,6 +153,18 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
||||
}
|
||||
}
|
||||
|
||||
public notifySettingsSynced(settings: unknown): void {
|
||||
if (!this._view) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._view.webview.postMessage({
|
||||
type: 'command',
|
||||
command: 'settingsSynced',
|
||||
payload: settings,
|
||||
});
|
||||
}
|
||||
|
||||
private _sendCachedState() {
|
||||
if (!this._view) {
|
||||
return;
|
||||
@@ -170,28 +186,13 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
||||
};
|
||||
}
|
||||
|
||||
private _collectHeaders(headers: Headers): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
headers.forEach((value, key) => {
|
||||
result[key] = value;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
private async _startSseProxy(message: BridgeRequest): Promise<BridgeResponse> {
|
||||
const { id, type, payload } = message;
|
||||
const apiBaseUrl = this._openCodeManager?.getApiUrl();
|
||||
|
||||
const { path, headers } = (payload || {}) as { path?: string; headers?: Record<string, string> };
|
||||
const normalizedPath = typeof path === 'string' && path.trim().length > 0 ? path.trim() : '/event';
|
||||
const normalizedPathname = (() => {
|
||||
const rawPathname = normalizedPath.split('?')[0];
|
||||
if (rawPathname === '/') return '/';
|
||||
return rawPathname.replace(/\/+$/, '');
|
||||
})();
|
||||
const shouldInjectActivity = normalizedPathname === '/event' || normalizedPathname === '/global/event';
|
||||
|
||||
if (!apiBaseUrl) {
|
||||
if (!this._openCodeManager) {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
@@ -203,37 +204,43 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
||||
const streamId = `sse_${++this._sseCounter}_${Date.now()}`;
|
||||
const controller = new AbortController();
|
||||
|
||||
const base = `${apiBaseUrl.replace(/\/+$/, '')}/`;
|
||||
const targetUrl = new URL(normalizedPath.replace(/^\/+/, ''), base).toString();
|
||||
|
||||
let response: Response;
|
||||
let wrapAsGlobal = false;
|
||||
|
||||
const requestHeaders = this._buildSseHeaders({
|
||||
...(headers || {}),
|
||||
...(this._openCodeManager?.getOpenCodeAuthHeaders() || {}),
|
||||
});
|
||||
|
||||
try {
|
||||
response = await fetch(targetUrl, {
|
||||
method: 'GET',
|
||||
headers: requestHeaders,
|
||||
const start = await openSseProxy({
|
||||
manager: this._openCodeManager,
|
||||
path: normalizedPath,
|
||||
headers: this._buildSseHeaders(headers),
|
||||
signal: controller.signal,
|
||||
onChunk: (chunk) => {
|
||||
this._view?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk });
|
||||
},
|
||||
});
|
||||
|
||||
// Fallback: OpenCode versions without /global/event.
|
||||
// VS Code is single-workspace, so we can wrap /event into { directory, payload }.
|
||||
if ((!response.ok || !response.body) && normalizedPathname === '/global/event') {
|
||||
const fallbackUrl = new URL('event', base).toString();
|
||||
response = await fetch(fallbackUrl, {
|
||||
method: 'GET',
|
||||
headers: requestHeaders,
|
||||
signal: controller.signal,
|
||||
this._sseStreams.set(streamId, controller);
|
||||
|
||||
start.run
|
||||
.then(() => {
|
||||
this._view?.webview.postMessage({ type: 'api:sse:end', streamId });
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!controller.signal.aborted) {
|
||||
const messageText = error instanceof Error ? error.message : String(error);
|
||||
this._view?.webview.postMessage({ type: 'api:sse:end', streamId, error: messageText });
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this._sseStreams.delete(streamId);
|
||||
});
|
||||
if (response.ok && response.body) {
|
||||
wrapAsGlobal = true;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
status: 200,
|
||||
headers: start.headers,
|
||||
streamId,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
@@ -243,122 +250,6 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
||||
data: { status: 502, headers: { 'content-type': 'application/json' }, streamId: null, error: message },
|
||||
};
|
||||
}
|
||||
|
||||
const responseHeaders = this._collectHeaders(response.headers);
|
||||
const responseBody = response.body;
|
||||
if (!response.ok || !responseBody) {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
status: response.status,
|
||||
headers: responseHeaders,
|
||||
streamId: null,
|
||||
error: `SSE failed: ${response.status}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
this._sseStreams.set(streamId, controller);
|
||||
|
||||
const fallbackDirectory = this._openCodeManager?.getWorkingDirectory()
|
||||
|| vscode.workspace.workspaceFolders?.[0]?.uri.fsPath
|
||||
|| 'global';
|
||||
|
||||
if (shouldInjectActivity) {
|
||||
const heartbeatTimer = setInterval(() => {
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
const heartbeatChunk = `${buildHeartbeatEventBlock()}\n\n`;
|
||||
this._view?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: heartbeatChunk });
|
||||
}, 30000);
|
||||
this._sseHeartbeats.set(streamId, heartbeatTimer);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const reader = responseBody.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let sseBuffer = '';
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (controller.signal.aborted) break;
|
||||
if (value && value.length > 0) {
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
if (!chunk) continue;
|
||||
|
||||
// Reduce webview message pressure by forwarding complete SSE blocks.
|
||||
// The SDK SSE parser is block-based (\n\n delimited) and can consume
|
||||
// partial chunks, but VS Code's postMessage channel can be a bottleneck.
|
||||
sseBuffer += chunk.replace(/\r\n/g, '\n');
|
||||
const blocks = sseBuffer.split('\n\n');
|
||||
sseBuffer = blocks.pop() ?? '';
|
||||
if (blocks.length > 0) {
|
||||
const routedBlocks = wrapAsGlobal
|
||||
? wrapSseBlocksAsGlobal(blocks, fallbackDirectory)
|
||||
: blocks;
|
||||
const outboundBlocks = shouldInjectActivity ? expandSseBlocksWithActivity(routedBlocks) : routedBlocks;
|
||||
const joined = outboundBlocks.map((block: string) => `${block}\n\n`).join('');
|
||||
this._view?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: joined });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const tail = decoder.decode();
|
||||
if (tail) {
|
||||
sseBuffer += tail.replace(/\r\n/g, '\n');
|
||||
}
|
||||
if (sseBuffer) {
|
||||
if (shouldInjectActivity) {
|
||||
const baseBlocks = wrapAsGlobal
|
||||
? wrapSseBlocksAsGlobal([sseBuffer], fallbackDirectory)
|
||||
: [sseBuffer];
|
||||
const outboundBlocks = expandSseBlocksWithActivity(baseBlocks);
|
||||
const joined = outboundBlocks.map((block: string) => `${block}\n\n`).join('');
|
||||
this._view?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: joined });
|
||||
} else {
|
||||
this._view?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: sseBuffer });
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
this._view?.webview.postMessage({ type: 'api:sse:end', streamId });
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this._view?.webview.postMessage({ type: 'api:sse:end', streamId, error: message });
|
||||
}
|
||||
} finally {
|
||||
this._sseStreams.delete(streamId);
|
||||
const heartbeat = this._sseHeartbeats.get(streamId);
|
||||
if (heartbeat) {
|
||||
clearInterval(heartbeat);
|
||||
this._sseHeartbeats.delete(streamId);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
status: response.status,
|
||||
headers: responseHeaders,
|
||||
streamId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async _stopSseProxy(message: BridgeRequest): Promise<BridgeResponse> {
|
||||
@@ -370,12 +261,6 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
||||
controller.abort();
|
||||
this._sseStreams.delete(streamId);
|
||||
}
|
||||
|
||||
const heartbeat = this._sseHeartbeats.get(streamId);
|
||||
if (heartbeat) {
|
||||
clearInterval(heartbeat);
|
||||
this._sseHeartbeats.delete(streamId);
|
||||
}
|
||||
}
|
||||
return { id, type, success: true, data: { stopped: true } };
|
||||
}
|
||||
@@ -395,190 +280,3 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
type SessionActivityPhase = 'idle' | 'busy' | 'cooldown';
|
||||
|
||||
type SessionActivity = {
|
||||
sessionId: string;
|
||||
phase: SessionActivityPhase;
|
||||
};
|
||||
|
||||
const parseSseDataPayload = (block: string): Record<string, unknown> | null => {
|
||||
if (!block) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dataLines = block
|
||||
.split('\n')
|
||||
.filter((line) => line.startsWith('data:'))
|
||||
.map((line) => line.slice(5).replace(/^\s/, ''));
|
||||
|
||||
if (dataLines.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payloadText = dataLines.join('\n').trim();
|
||||
if (!payloadText) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(payloadText) as unknown;
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
const record = parsed as Record<string, unknown>;
|
||||
const nestedPayload = record.payload;
|
||||
if (nestedPayload && typeof nestedPayload === 'object') {
|
||||
return nestedPayload as Record<string, unknown>;
|
||||
}
|
||||
return record;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const deriveSessionActivity = (payload: Record<string, unknown> | null): SessionActivity | null => {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const type = payload.type;
|
||||
const properties = payload.properties as Record<string, unknown> | undefined;
|
||||
|
||||
if (type === 'session.status') {
|
||||
const status = properties?.status as Record<string, unknown> | undefined;
|
||||
const sessionId = properties?.sessionID ?? properties?.sessionId;
|
||||
const statusType = status?.type;
|
||||
|
||||
if (typeof sessionId === 'string' && sessionId.length > 0 && typeof statusType === 'string') {
|
||||
const phase: SessionActivityPhase = statusType === 'busy' || statusType === 'retry' ? 'busy' : 'idle';
|
||||
return { sessionId, phase };
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'message.updated') {
|
||||
const info = properties?.info as Record<string, unknown> | undefined;
|
||||
const sessionId = info?.sessionID ?? info?.sessionId ?? properties?.sessionID ?? properties?.sessionId;
|
||||
const role = info?.role;
|
||||
const finish = info?.finish;
|
||||
if (typeof sessionId === 'string' && sessionId.length > 0 && role === 'assistant' && finish === 'stop') {
|
||||
return { sessionId, phase: 'cooldown' };
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'message.part.updated' || type === 'message.part.delta') {
|
||||
const info = properties?.info as Record<string, unknown> | undefined;
|
||||
const sessionId = info?.sessionID ?? info?.sessionId ?? properties?.sessionID ?? properties?.sessionId;
|
||||
const role = info?.role;
|
||||
const finish = info?.finish;
|
||||
if (typeof sessionId === 'string' && sessionId.length > 0 && role === 'assistant' && finish === 'stop') {
|
||||
return { sessionId, phase: 'cooldown' };
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'session.idle') {
|
||||
const sessionId = properties?.sessionID ?? properties?.sessionId;
|
||||
if (typeof sessionId === 'string' && sessionId.length > 0) {
|
||||
return { sessionId, phase: 'idle' };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const buildActivityEventBlock = (activity: SessionActivity): string => {
|
||||
return `data: ${JSON.stringify({
|
||||
type: 'openchamber:session-activity',
|
||||
properties: {
|
||||
sessionId: activity.sessionId,
|
||||
phase: activity.phase,
|
||||
},
|
||||
})}`;
|
||||
};
|
||||
|
||||
const buildHeartbeatEventBlock = (): string => {
|
||||
return `data: ${JSON.stringify({ type: 'openchamber:heartbeat', timestamp: Date.now() })}`;
|
||||
};
|
||||
|
||||
const parseSseBlockForGlobalWrap = (block: string): { id?: string; payload: Record<string, unknown> } | null => {
|
||||
if (!block) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lines = block.split('\n');
|
||||
const dataLines: string[] = [];
|
||||
let eventId: string | undefined;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data:')) {
|
||||
dataLines.push(line.slice(5).replace(/^\s/, ''));
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('id:')) {
|
||||
const candidate = line.slice(3).trim();
|
||||
if (candidate) {
|
||||
eventId = candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (dataLines.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payloadText = dataLines.join('\n').trim();
|
||||
if (!payloadText) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(payloadText) as unknown;
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = parsed as Record<string, unknown>;
|
||||
const nestedPayload = record.payload;
|
||||
const payload = nestedPayload && typeof nestedPayload === 'object'
|
||||
? (nestedPayload as Record<string, unknown>)
|
||||
: record;
|
||||
|
||||
return eventId ? { id: eventId, payload } : { payload };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const wrapSseBlocksAsGlobal = (blocks: string[], directory: string): string[] => {
|
||||
const normalizedDirectory = typeof directory === 'string' && directory.trim().length > 0
|
||||
? directory.trim().replace(/\\/g, '/')
|
||||
: 'global';
|
||||
|
||||
return blocks.map((block) => {
|
||||
const parsed = parseSseBlockForGlobalWrap(block);
|
||||
if (!parsed) {
|
||||
return block;
|
||||
}
|
||||
|
||||
const envelope = {
|
||||
directory: normalizedDirectory,
|
||||
payload: parsed.payload,
|
||||
};
|
||||
|
||||
const idPrefix = parsed.id ? `id: ${parsed.id}\n` : '';
|
||||
return `${idPrefix}data: ${JSON.stringify(envelope)}`;
|
||||
});
|
||||
};
|
||||
|
||||
const expandSseBlocksWithActivity = (blocks: string[]): string[] => {
|
||||
const expanded: string[] = [];
|
||||
for (const block of blocks) {
|
||||
expanded.push(block);
|
||||
const activity = deriveSessionActivity(parseSseDataPayload(block));
|
||||
if (activity) {
|
||||
expanded.push(buildActivityEventBlock(activity));
|
||||
}
|
||||
}
|
||||
return expanded;
|
||||
};
|
||||
|
||||
@@ -4,11 +4,11 @@ import { getThemeKindName } from './theme';
|
||||
import type { OpenCodeManager, ConnectionStatus } from './opencode';
|
||||
import { getWebviewShikiThemes } from './shikiThemes';
|
||||
import { getWebviewHtml } from './webviewHtml';
|
||||
import { openSseProxy } from './sseProxy';
|
||||
|
||||
type SessionPanelState = {
|
||||
panel: vscode.WebviewPanel;
|
||||
sseStreams: Map<string, AbortController>;
|
||||
sseHeartbeats: Map<string, ReturnType<typeof setInterval>>;
|
||||
};
|
||||
|
||||
export class SessionEditorPanelProvider {
|
||||
@@ -70,7 +70,6 @@ export class SessionEditorPanelProvider {
|
||||
const state: SessionPanelState = {
|
||||
panel,
|
||||
sseStreams: new Map(),
|
||||
sseHeartbeats: new Map(),
|
||||
};
|
||||
|
||||
this._panels.set(panelId, state);
|
||||
@@ -107,6 +106,10 @@ export class SessionEditorPanelProvider {
|
||||
context: this._context,
|
||||
});
|
||||
state.panel.webview.postMessage(response);
|
||||
|
||||
if (message.type === 'api:config/settings:save' && response.success) {
|
||||
void vscode.commands.executeCommand('openchamber.internal.settingsSynced', response.data);
|
||||
}
|
||||
}, null, this._context.subscriptions);
|
||||
}
|
||||
|
||||
@@ -131,6 +134,16 @@ export class SessionEditorPanelProvider {
|
||||
}
|
||||
}
|
||||
|
||||
public notifySettingsSynced(settings: unknown): void {
|
||||
for (const entry of this._panels.values()) {
|
||||
entry.panel.webview.postMessage({
|
||||
type: 'command',
|
||||
command: 'settingsSynced',
|
||||
payload: settings,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _sendCachedStateToPanel(entry: SessionPanelState) {
|
||||
entry.panel.webview.postMessage({
|
||||
type: 'connectionStatus',
|
||||
@@ -148,11 +161,6 @@ export class SessionEditorPanelProvider {
|
||||
}
|
||||
entry.sseStreams.clear();
|
||||
|
||||
for (const heartbeat of entry.sseHeartbeats.values()) {
|
||||
clearInterval(heartbeat);
|
||||
}
|
||||
entry.sseHeartbeats.clear();
|
||||
|
||||
this._panels.delete(sessionId);
|
||||
}
|
||||
|
||||
@@ -165,28 +173,13 @@ export class SessionEditorPanelProvider {
|
||||
};
|
||||
}
|
||||
|
||||
private _collectHeaders(headers: Headers): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
headers.forEach((value, key) => {
|
||||
result[key] = value;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
private async _startSseProxy(message: BridgeRequest, entry: SessionPanelState): Promise<BridgeResponse> {
|
||||
const { id, type, payload } = message;
|
||||
const apiBaseUrl = this._openCodeManager?.getApiUrl();
|
||||
|
||||
const { path, headers } = (payload || {}) as { path?: string; headers?: Record<string, string> };
|
||||
const normalizedPath = typeof path === 'string' && path.trim().length > 0 ? path.trim() : '/event';
|
||||
const normalizedPathname = (() => {
|
||||
const rawPathname = normalizedPath.split('?')[0];
|
||||
if (rawPathname === '/') return '/';
|
||||
return rawPathname.replace(/\/+$/, '');
|
||||
})();
|
||||
const shouldInjectActivity = normalizedPathname === '/event' || normalizedPathname === '/global/event';
|
||||
|
||||
if (!apiBaseUrl) {
|
||||
if (!this._openCodeManager) {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
@@ -198,37 +191,43 @@ export class SessionEditorPanelProvider {
|
||||
const streamId = `sse_${++this._sseCounter}_${Date.now()}`;
|
||||
const controller = new AbortController();
|
||||
|
||||
const base = `${apiBaseUrl.replace(/\/+$/, '')}/`;
|
||||
const targetUrl = new URL(normalizedPath.replace(/^\/+/, ''), base).toString();
|
||||
|
||||
let response: Response;
|
||||
let wrapAsGlobal = false;
|
||||
|
||||
const requestHeaders = this._buildSseHeaders({
|
||||
...(headers || {}),
|
||||
...(this._openCodeManager?.getOpenCodeAuthHeaders() || {}),
|
||||
});
|
||||
|
||||
try {
|
||||
response = await fetch(targetUrl, {
|
||||
method: 'GET',
|
||||
headers: requestHeaders,
|
||||
const start = await openSseProxy({
|
||||
manager: this._openCodeManager,
|
||||
path: normalizedPath,
|
||||
headers: this._buildSseHeaders(headers),
|
||||
signal: controller.signal,
|
||||
onChunk: (chunk) => {
|
||||
entry.panel.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk });
|
||||
},
|
||||
});
|
||||
|
||||
// Fallback: OpenCode versions without /global/event.
|
||||
// VS Code is single-workspace, so we can wrap /event into { directory, payload }.
|
||||
if ((!response.ok || !response.body) && normalizedPathname === '/global/event') {
|
||||
const fallbackUrl = new URL('event', base).toString();
|
||||
response = await fetch(fallbackUrl, {
|
||||
method: 'GET',
|
||||
headers: requestHeaders,
|
||||
signal: controller.signal,
|
||||
entry.sseStreams.set(streamId, controller);
|
||||
|
||||
start.run
|
||||
.then(() => {
|
||||
entry.panel.webview.postMessage({ type: 'api:sse:end', streamId });
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!controller.signal.aborted) {
|
||||
const messageText = error instanceof Error ? error.message : String(error);
|
||||
entry.panel.webview.postMessage({ type: 'api:sse:end', streamId, error: messageText });
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
entry.sseStreams.delete(streamId);
|
||||
});
|
||||
if (response.ok && response.body) {
|
||||
wrapAsGlobal = true;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
status: 200,
|
||||
headers: start.headers,
|
||||
streamId,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
const messageText = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
@@ -238,119 +237,6 @@ export class SessionEditorPanelProvider {
|
||||
data: { status: 502, headers: { 'content-type': 'application/json' }, streamId: null, error: messageText },
|
||||
};
|
||||
}
|
||||
|
||||
const responseHeaders = this._collectHeaders(response.headers);
|
||||
const responseBody = response.body;
|
||||
if (!response.ok || !responseBody) {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
status: response.status,
|
||||
headers: responseHeaders,
|
||||
streamId: null,
|
||||
error: `SSE failed: ${response.status}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
entry.sseStreams.set(streamId, controller);
|
||||
|
||||
const fallbackDirectory = this._openCodeManager?.getWorkingDirectory()
|
||||
|| vscode.workspace.workspaceFolders?.[0]?.uri.fsPath
|
||||
|| 'global';
|
||||
|
||||
if (shouldInjectActivity) {
|
||||
const heartbeatTimer = setInterval(() => {
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
const heartbeatChunk = `${buildHeartbeatEventBlock()}\n\n`;
|
||||
entry.panel.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: heartbeatChunk });
|
||||
}, 30000);
|
||||
entry.sseHeartbeats.set(streamId, heartbeatTimer);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const reader = responseBody.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let sseBuffer = '';
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (controller.signal.aborted) break;
|
||||
if (value && value.length > 0) {
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
if (!chunk) continue;
|
||||
|
||||
sseBuffer += chunk.replace(/\r\n/g, '\n');
|
||||
const blocks = sseBuffer.split('\n\n');
|
||||
sseBuffer = blocks.pop() ?? '';
|
||||
if (blocks.length > 0) {
|
||||
const routedBlocks = wrapAsGlobal
|
||||
? wrapSseBlocksAsGlobal(blocks, fallbackDirectory)
|
||||
: blocks;
|
||||
const outboundBlocks = shouldInjectActivity ? expandSseBlocksWithActivity(routedBlocks) : routedBlocks;
|
||||
const joined = outboundBlocks.map((block: string) => `${block}\n\n`).join('');
|
||||
entry.panel.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: joined });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const tail = decoder.decode();
|
||||
if (tail) {
|
||||
sseBuffer += tail.replace(/\r\n/g, '\n');
|
||||
}
|
||||
if (sseBuffer) {
|
||||
if (shouldInjectActivity) {
|
||||
const baseBlocks = wrapAsGlobal
|
||||
? wrapSseBlocksAsGlobal([sseBuffer], fallbackDirectory)
|
||||
: [sseBuffer];
|
||||
const outboundBlocks = expandSseBlocksWithActivity(baseBlocks);
|
||||
const joined = outboundBlocks.map((block: string) => `${block}\n\n`).join('');
|
||||
entry.panel.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: joined });
|
||||
} else {
|
||||
entry.panel.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: sseBuffer });
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
entry.panel.webview.postMessage({ type: 'api:sse:end', streamId });
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) {
|
||||
const messageText = error instanceof Error ? error.message : String(error);
|
||||
entry.panel.webview.postMessage({ type: 'api:sse:end', streamId, error: messageText });
|
||||
}
|
||||
} finally {
|
||||
entry.sseStreams.delete(streamId);
|
||||
const heartbeat = entry.sseHeartbeats.get(streamId);
|
||||
if (heartbeat) {
|
||||
clearInterval(heartbeat);
|
||||
entry.sseHeartbeats.delete(streamId);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
status: response.status,
|
||||
headers: responseHeaders,
|
||||
streamId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async _stopSseProxy(message: BridgeRequest, entry: SessionPanelState): Promise<BridgeResponse> {
|
||||
@@ -362,12 +248,6 @@ export class SessionEditorPanelProvider {
|
||||
controller.abort();
|
||||
entry.sseStreams.delete(streamId);
|
||||
}
|
||||
|
||||
const heartbeat = entry.sseHeartbeats.get(streamId);
|
||||
if (heartbeat) {
|
||||
clearInterval(heartbeat);
|
||||
entry.sseHeartbeats.delete(streamId);
|
||||
}
|
||||
}
|
||||
return { id, type, success: true, data: { stopped: true } };
|
||||
}
|
||||
@@ -389,161 +269,3 @@ export class SessionEditorPanelProvider {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
type SessionActivityPhase = 'idle' | 'busy' | 'cooldown';
|
||||
|
||||
type SessionActivity = {
|
||||
sessionId: string;
|
||||
phase: SessionActivityPhase;
|
||||
};
|
||||
|
||||
const parseSseDataPayload = (block: string): Record<string, unknown> | null => {
|
||||
if (!block) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dataLines = block
|
||||
.split('\n')
|
||||
.filter((line) => line.startsWith('data:'))
|
||||
.map((line) => line.slice(5).replace(/^\s/, ''));
|
||||
|
||||
if (dataLines.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payloadText = dataLines.join('\n').trim();
|
||||
if (!payloadText) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(payloadText) as unknown;
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
const record = parsed as Record<string, unknown>;
|
||||
const nestedPayload = record.payload;
|
||||
if (nestedPayload && typeof nestedPayload === 'object') {
|
||||
return nestedPayload as Record<string, unknown>;
|
||||
}
|
||||
return record;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const deriveSessionActivity = (payload: Record<string, unknown> | null): SessionActivity | null => {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const type = payload.type;
|
||||
const properties = payload.properties as Record<string, unknown> | undefined;
|
||||
|
||||
if (type === 'session.status') {
|
||||
const status = properties?.status as Record<string, unknown> | undefined;
|
||||
const sessionId = properties?.sessionID ?? properties?.sessionId;
|
||||
const statusType = status?.type;
|
||||
|
||||
if (typeof sessionId === 'string' && sessionId.length > 0 && typeof statusType === 'string') {
|
||||
const phase: SessionActivityPhase = statusType === 'busy' || statusType === 'retry' ? 'busy' : 'idle';
|
||||
return { sessionId, phase };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (type === 'message.updated') {
|
||||
const info = payload.info as Record<string, unknown> | undefined;
|
||||
const role = info?.role;
|
||||
const finish = info?.finish;
|
||||
const sessionId = info?.sessionID ?? info?.sessionId ?? properties?.sessionID ?? properties?.sessionId;
|
||||
|
||||
if (typeof sessionId === 'string' && sessionId.length > 0 && role === 'assistant' && finish === 'stop') {
|
||||
return { sessionId, phase: 'cooldown' };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (type === 'message.complete') {
|
||||
const info = payload.info as Record<string, unknown> | undefined;
|
||||
const role = info?.role;
|
||||
const finish = info?.finish;
|
||||
const sessionId = info?.sessionID ?? info?.sessionId ?? properties?.sessionID ?? properties?.sessionId;
|
||||
|
||||
if (typeof sessionId === 'string' && sessionId.length > 0 && role === 'assistant' && finish === 'stop') {
|
||||
return { sessionId, phase: 'cooldown' };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (type === 'session.idle') {
|
||||
const sessionId = properties?.sessionID ?? properties?.sessionId;
|
||||
if (typeof sessionId === 'string' && sessionId.length > 0) {
|
||||
return { sessionId, phase: 'idle' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const buildActivityEventBlock = (activity: SessionActivity): string => {
|
||||
return [
|
||||
'event: message',
|
||||
`data: ${JSON.stringify({
|
||||
type: 'openchamber:session-activity',
|
||||
properties: {
|
||||
sessionId: activity.sessionId,
|
||||
phase: activity.phase,
|
||||
at: Date.now(),
|
||||
},
|
||||
})}`,
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
const buildHeartbeatEventBlock = (): string => {
|
||||
return [
|
||||
'event: message',
|
||||
`data: ${JSON.stringify({
|
||||
type: 'openchamber:heartbeat',
|
||||
properties: {
|
||||
at: Date.now(),
|
||||
},
|
||||
})}`,
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
const expandSseBlocksWithActivity = (blocks: string[]): string[] => {
|
||||
const expanded: string[] = [];
|
||||
|
||||
for (const block of blocks) {
|
||||
expanded.push(block);
|
||||
|
||||
const activity = deriveSessionActivity(parseSseDataPayload(block));
|
||||
if (activity) {
|
||||
expanded.push(buildActivityEventBlock(activity));
|
||||
}
|
||||
}
|
||||
|
||||
return expanded;
|
||||
};
|
||||
|
||||
const wrapSseBlocksAsGlobal = (blocks: string[], directory: string): string[] => {
|
||||
return blocks.map((block) => {
|
||||
const payload = parseSseDataPayload(block);
|
||||
const wrappedPayload = payload
|
||||
? { directory, payload }
|
||||
: { directory, payload: null };
|
||||
|
||||
const updatedLines = block
|
||||
.split('\n')
|
||||
.filter((line) => !line.startsWith('data:'));
|
||||
|
||||
updatedLines.push(`data: ${JSON.stringify(wrappedPayload)}`);
|
||||
|
||||
return updatedLines.join('\n');
|
||||
});
|
||||
};
|
||||
|
||||
@@ -214,7 +214,8 @@ const createVirtualOriginalDiffUri = (modifiedPath: string, content: string): vs
|
||||
const fileName = path.basename(modifiedPath) || 'file';
|
||||
return vscode.Uri.from({
|
||||
scheme: VIRTUAL_DIFF_SCHEME,
|
||||
path: `/${fileName} (before)`,
|
||||
// Keep real filename (incl extension) so VS Code can infer language for syntax highlighting.
|
||||
path: `/${fileName}`,
|
||||
query: `key=${encodeURIComponent(key)}`,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -161,6 +161,13 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
agentManagerProvider = new AgentManagerPanelProvider(context, context.extensionUri, openCodeManager);
|
||||
sessionEditorProvider = new SessionEditorPanelProvider(context, context.extensionUri, openCodeManager);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('openchamber.internal.settingsSynced', (settings: unknown) => {
|
||||
chatViewProvider?.notifySettingsSynced(settings);
|
||||
sessionEditorProvider?.notifySettingsSynced(settings);
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('openchamber.openAgentManager', () => {
|
||||
agentManagerProvider?.createOrShow();
|
||||
|
||||
@@ -8,7 +8,7 @@ const API_USER_URL = 'https://api.github.com/user';
|
||||
const API_EMAILS_URL = 'https://api.github.com/user/emails';
|
||||
const DEVICE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
|
||||
|
||||
export const DEFAULT_GITHUB_CLIENT_ID = 'Ov23liNd8TxDcMXtAHHM';
|
||||
export const DEFAULT_GITHUB_CLIENT_ID = 'Ov23lizomPOC3eFYo56r';
|
||||
export const DEFAULT_GITHUB_SCOPES = 'repo read:org workflow read:user user:email';
|
||||
|
||||
type StoredAuth = {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createOpencodeClient } from '@opencode-ai/sdk/v2';
|
||||
import type { OpenCodeManager } from './opencode';
|
||||
|
||||
// Session activity tracking (mirrors web server and desktop Tauri behavior)
|
||||
@@ -102,36 +103,6 @@ const deriveSessionActivity = (payload: Record<string, unknown>): SessionActivit
|
||||
return null;
|
||||
};
|
||||
|
||||
const parseSseDataPayload = (block: string): Record<string, unknown> | null => {
|
||||
if (!block) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lines = block.split('\n');
|
||||
const dataLines: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data:')) {
|
||||
dataLines.push(line.slice(5).replace(/^\s/, ''));
|
||||
}
|
||||
}
|
||||
|
||||
if (dataLines.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payloadText = dataLines.join('\n').trim();
|
||||
if (!payloadText) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(payloadText) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const waitForOpenCodePort = async (manager: OpenCodeManager, timeoutMs = 30000): Promise<number | null> => {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
@@ -151,11 +122,6 @@ const waitForOpenCodePort = async (manager: OpenCodeManager, timeoutMs = 30000):
|
||||
return null;
|
||||
};
|
||||
|
||||
const buildOpenCodeUrl = (pathname: string, baseUrl: string): string => {
|
||||
const normalized = baseUrl.replace(/\/+$/, '');
|
||||
return `${normalized}${pathname}`;
|
||||
};
|
||||
|
||||
export const startGlobalEventWatcher = async (
|
||||
manager: OpenCodeManager,
|
||||
provider: { postMessage: (message: unknown) => void }
|
||||
@@ -181,8 +147,6 @@ export const startGlobalEventWatcher = async (
|
||||
const run = async (): Promise<void> => {
|
||||
while (!signal.aborted) {
|
||||
attempt += 1;
|
||||
let upstream: Response | null = null;
|
||||
let reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
|
||||
|
||||
try {
|
||||
const baseUrl = manager.getApiUrl();
|
||||
@@ -190,64 +154,38 @@ export const startGlobalEventWatcher = async (
|
||||
throw new Error('OpenCode API URL not available');
|
||||
}
|
||||
|
||||
const url = buildOpenCodeUrl('/global/event', baseUrl);
|
||||
const authHeaders = manager.getOpenCodeAuthHeaders();
|
||||
upstream = await fetch(url, {
|
||||
headers: {
|
||||
Accept: 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
...authHeaders,
|
||||
},
|
||||
signal,
|
||||
const client = createOpencodeClient({
|
||||
baseUrl,
|
||||
headers: manager.getOpenCodeAuthHeaders(),
|
||||
});
|
||||
const result = await client.global.event({
|
||||
signal,
|
||||
sseMaxRetryAttempts: 0,
|
||||
onSseEvent: (event) => {
|
||||
const payload = event.data;
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return;
|
||||
}
|
||||
const activity = deriveSessionActivity(payload as Record<string, unknown>);
|
||||
if (activity) {
|
||||
setSessionActivityPhase(activity.sessionId, activity.phase);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (!upstream.ok || !upstream.body) {
|
||||
throw new Error(`bad status ${upstream.status}`);
|
||||
}
|
||||
|
||||
console.log('[VSCode:Activity] connected');
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
reader = upstream.body.getReader();
|
||||
let buffer = '';
|
||||
|
||||
while (!signal.aborted) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) {
|
||||
for await (const _ of result.stream) {
|
||||
void _;
|
||||
if (signal.aborted) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n');
|
||||
|
||||
let separatorIndex: number;
|
||||
while ((separatorIndex = buffer.indexOf('\n\n')) !== -1) {
|
||||
const block = buffer.slice(0, separatorIndex);
|
||||
buffer = buffer.slice(separatorIndex + 2);
|
||||
const payload = parseSseDataPayload(block);
|
||||
if (payload) {
|
||||
const activity = deriveSessionActivity(payload);
|
||||
if (activity) {
|
||||
setSessionActivityPhase(activity.sessionId, activity.phase);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
console.warn('[VSCode:Activity] disconnected', error instanceof Error ? error.message : error);
|
||||
} finally {
|
||||
try {
|
||||
if (reader) {
|
||||
await reader.cancel();
|
||||
} else if (upstream?.body && !(upstream.body as ReadableStream<Uint8Array>).locked) {
|
||||
await upstream.body.cancel();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const backoffMs = Math.min(1000 * Math.pow(2, Math.min(attempt, 5)), 30000);
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { createOpencodeClient } from '@opencode-ai/sdk/v2';
|
||||
import type { OpenCodeManager } from './opencode';
|
||||
|
||||
type StreamEvent<TData = unknown> = {
|
||||
data: TData;
|
||||
event?: string;
|
||||
id?: string;
|
||||
retry?: number;
|
||||
};
|
||||
|
||||
type OpenSseProxyOptions = {
|
||||
manager: OpenCodeManager;
|
||||
path: string;
|
||||
headers?: Record<string, string>;
|
||||
signal: AbortSignal;
|
||||
onChunk: (chunk: string) => void;
|
||||
};
|
||||
|
||||
type OpenSseProxyResult = {
|
||||
headers: Record<string, string>;
|
||||
run: Promise<void>;
|
||||
};
|
||||
|
||||
const SSE_RESPONSE_HEADERS = {
|
||||
'content-type': 'text/event-stream',
|
||||
'cache-control': 'no-cache',
|
||||
} as const;
|
||||
|
||||
const serializeSseEventBlock = (event: StreamEvent<unknown>): string => {
|
||||
const lines: string[] = [];
|
||||
if (typeof event.id === 'string' && event.id.length > 0) {
|
||||
lines.push(`id: ${event.id}`);
|
||||
}
|
||||
if (typeof event.event === 'string' && event.event.length > 0) {
|
||||
lines.push(`event: ${event.event}`);
|
||||
}
|
||||
if (typeof event.retry === 'number' && Number.isFinite(event.retry)) {
|
||||
lines.push(`retry: ${event.retry}`);
|
||||
}
|
||||
lines.push(`data: ${JSON.stringify(event.data)}`);
|
||||
return lines.join('\n');
|
||||
};
|
||||
|
||||
const normalizeSsePath = (path: string): { pathname: '/event' | '/global/event'; directory: string | null } => {
|
||||
const parsed = new URL(path, 'https://openchamber.invalid');
|
||||
const pathname = parsed.pathname === '/global/event' ? '/global/event' : '/event';
|
||||
const directory = parsed.searchParams.get('directory');
|
||||
return {
|
||||
pathname,
|
||||
directory: typeof directory === 'string' && directory.trim().length > 0 ? directory.trim() : null,
|
||||
};
|
||||
};
|
||||
|
||||
const resolveDefaultDirectory = (manager: OpenCodeManager): string => {
|
||||
return manager.getWorkingDirectory() || 'global';
|
||||
};
|
||||
|
||||
const createAuthedClient = (manager: OpenCodeManager, headers?: Record<string, string>) => {
|
||||
const baseUrl = manager.getApiUrl();
|
||||
if (!baseUrl) {
|
||||
throw new Error('OpenCode API URL not available');
|
||||
}
|
||||
|
||||
return createOpencodeClient({
|
||||
baseUrl,
|
||||
headers: {
|
||||
...(headers || {}),
|
||||
...manager.getOpenCodeAuthHeaders(),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const getSseOptions = (
|
||||
signal: AbortSignal,
|
||||
onChunk: (chunk: string) => void,
|
||||
wrapDirectory?: string,
|
||||
) => ({
|
||||
signal,
|
||||
sseMaxRetryAttempts: 0,
|
||||
onSseEvent: (event: StreamEvent<unknown>) => {
|
||||
const nextEvent = wrapDirectory
|
||||
? {
|
||||
...event,
|
||||
data: {
|
||||
directory: wrapDirectory,
|
||||
payload: event.data,
|
||||
},
|
||||
}
|
||||
: event;
|
||||
onChunk(`${serializeSseEventBlock(nextEvent)}\n\n`);
|
||||
},
|
||||
});
|
||||
|
||||
export const openSseProxy = async ({
|
||||
manager,
|
||||
path,
|
||||
headers,
|
||||
signal,
|
||||
onChunk,
|
||||
}: OpenSseProxyOptions): Promise<OpenSseProxyResult> => {
|
||||
const client = createAuthedClient(manager, headers);
|
||||
const { pathname, directory } = normalizeSsePath(path);
|
||||
const resolvedDirectory = directory || resolveDefaultDirectory(manager);
|
||||
|
||||
const connect = async () => {
|
||||
if (pathname === '/global/event') {
|
||||
try {
|
||||
return await client.global.event(getSseOptions(signal, onChunk));
|
||||
} catch (error) {
|
||||
if ((error as Error)?.name === 'AbortError' || signal.aborted) {
|
||||
throw error;
|
||||
}
|
||||
return client.event.subscribe(
|
||||
{ directory: resolvedDirectory },
|
||||
getSseOptions(signal, onChunk, resolvedDirectory),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return client.event.subscribe(
|
||||
{ directory: resolvedDirectory },
|
||||
getSseOptions(signal, onChunk),
|
||||
);
|
||||
};
|
||||
|
||||
const result = await connect();
|
||||
const run = (async () => {
|
||||
for await (const _ of result.stream) {
|
||||
void _;
|
||||
if (signal.aborted) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
headers: { ...SSE_RESPONSE_HEADERS },
|
||||
run,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user