feat(web): add WebSocket transport for message event streaming with SSE fallback (#764)

* feat: add websocket message stream transport

* fix: avoid false missing session directories in sidebar

* fix: re-probe project root session directories

* refactor: use button group for message stream transport

* fix: resolve chat input hook dependency warning

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
Yifan
2026-04-17 11:07:26 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent fd8972a7d9
commit bee9d19f3a
26 changed files with 1515 additions and 163 deletions
@@ -2880,6 +2880,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
};
}, [fetchBranches, runtimeGit, selectedDraftProject, selectedDraftProjectBranches?.all, selectedDraftProjectPath, showDraftTargetSelectors]);
const selectedDraftProjectCurrentBranch = selectedDraftProjectBranches?.current?.trim() ?? '';
const projectRootBranchOption = React.useMemo(() => {
if (!selectedDraftProject) {
return null;
@@ -2888,15 +2890,14 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
if (!value) {
return null;
}
const projectRootBranch = selectedDraftProjectBranches?.current?.trim() ?? '';
if (!projectRootBranch) {
if (!selectedDraftProjectCurrentBranch) {
return null;
}
return {
value,
label: projectRootBranch,
label: selectedDraftProjectCurrentBranch,
};
}, [selectedDraftProject, selectedDraftProjectBranches]);
}, [selectedDraftProject, selectedDraftProjectCurrentBranch]);
const worktreeBranchOptions = React.useMemo(() => {
if (!selectedDraftProject) {
@@ -2914,11 +2915,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
return buildSessionTargetOptions({
projectRoot: normalizePath(selectedDraftProject.path) ?? '',
rootBranch: selectedDraftProjectBranches?.current?.trim() ?? '',
rootBranch: selectedDraftProjectCurrentBranch,
worktrees,
pendingBootstrapDirectory: newSessionDraft?.bootstrapPendingDirectory ?? null,
});
}, [availableWorktreesByProject, newSessionDraft?.bootstrapPendingDirectory, selectedDraftProject, selectedDraftProjectBranches?.current, selectedDraftProjectPath]);
}, [availableWorktreesByProject, newSessionDraft?.bootstrapPendingDirectory, selectedDraftProject, selectedDraftProjectCurrentBranch, selectedDraftProjectPath]);
const selectedDraftDirectory = React.useMemo(
() => normalizePath(newSessionDraft?.bootstrapPendingDirectory ?? null)
@@ -122,7 +122,7 @@ const VisualSectionContent: React.FC = () => {
// Chat section: User message rendering, Diff layout, Mobile status bar, Show reasoning traces, Queue mode, Persist draft
const ChatSectionContent: React.FC = () => {
return <OpenChamberVisualSettings visibleSettings={['chatRenderMode', 'activityRenderMode', 'userMessageRendering', 'mermaidRendering', 'reasoning', 'showToolFileIcons', 'expandedTools', 'stickyUserHeader', 'diffLayout', 'mobileStatusBar', 'dotfiles', 'queueMode', 'persistDraft', 'inputSpellcheck']} />;
return <OpenChamberVisualSettings visibleSettings={['chatRenderMode', 'messageTransport', 'activityRenderMode', 'userMessageRendering', 'mermaidRendering', 'reasoning', 'showToolFileIcons', 'expandedTools', 'stickyUserHeader', 'diffLayout', 'mobileStatusBar', 'dotfiles', 'queueMode', 'persistDraft', 'inputSpellcheck']} />;
};
// Sessions section: Default model & agent, Session retention
@@ -23,6 +23,7 @@ import { isDesktopShell, isVSCodeRuntime, isWebRuntime } from '@/lib/desktop';
import { useDeviceInfo } from '@/lib/device';
import { usePwaDetection } from '@/hooks/usePwaDetection';
import { updateDesktopSettings } from '@/lib/persistence';
import { useConfigStore } from '@/stores/useConfigStore';
import {
setDirectoryShowHidden,
useDirectoryShowHidden,
@@ -126,6 +127,24 @@ const CHAT_RENDER_MODE_OPTIONS: Option<'sorted' | 'live'>[] = [
},
];
const MESSAGE_STREAM_TRANSPORT_OPTIONS: Option<'auto' | 'ws' | 'sse'>[] = [
{
id: 'auto',
label: 'Auto',
description: 'Prefer WebSocket and fall back to SSE if needed.',
},
{
id: 'ws',
label: 'WebSocket',
description: 'Use WebSocket for message streaming.',
},
{
id: 'sse',
label: 'SSE',
description: 'Use Server-Sent Events for message streaming.',
},
];
const ACTIVITY_RENDER_MODE_OPTIONS: Option<'collapsed' | 'summary'>[] = [
{
id: 'collapsed',
@@ -177,7 +196,7 @@ const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain'
return mode === 'markdown' ? 'markdown' : 'plain';
};
export type VisibleSetting = 'theme' | 'pwaInstallName' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'activityRenderMode' | 'stickyUserHeader' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'reasoning' | 'showToolFileIcons' | 'expandedTools' | 'queueMode' | 'terminalQuickKeys' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage';
export type VisibleSetting = 'theme' | 'pwaInstallName' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'stickyUserHeader' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'reasoning' | 'showToolFileIcons' | 'expandedTools' | 'queueMode' | 'terminalQuickKeys' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage';
interface OpenChamberVisualSettingsProps {
/** Which settings to show. If undefined, shows all. */
@@ -233,6 +252,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const setWeekStartPreference = useUIStore(state => state.setWeekStartPreference);
const showMobileSessionStatusBar = useUIStore(state => state.showMobileSessionStatusBar);
const setShowMobileSessionStatusBar = useUIStore(state => state.setShowMobileSessionStatusBar);
const messageStreamTransport = useConfigStore((state) => state.settingsMessageStreamTransport);
const setMessageStreamTransport = useConfigStore((state) => state.setSettingsMessageStreamTransport);
const isSettingsDialogOpen = useUIStore(state => state.isSettingsDialogOpen);
const {
themeMode,
@@ -323,6 +344,11 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
void updateDesktopSettings({ chatRenderMode: mode });
}, [setChatRenderMode]);
const handleMessageStreamTransportChange = React.useCallback((mode: 'auto' | 'ws' | 'sse') => {
setMessageStreamTransport(mode);
void updateDesktopSettings({ messageStreamTransport: mode });
}, [setMessageStreamTransport]);
const handleActivityRenderModeChange = React.useCallback((mode: 'collapsed' | 'summary') => {
setActivityRenderMode(mode);
void updateDesktopSettings({ activityRenderMode: mode });
@@ -399,6 +425,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const hasBehaviorSettings = shouldShow('mermaidRendering')
|| shouldShow('userMessageRendering')
|| shouldShow('chatRenderMode')
|| shouldShow('messageTransport')
|| (shouldShow('activityRenderMode') && chatRenderMode === 'sorted')
|| shouldShow('stickyUserHeader')
|| shouldShow('diffLayout')
@@ -848,7 +875,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
{(shouldShow('userMessageRendering') || shouldShow('mermaidRendering') || shouldShow('chatRenderMode') || (shouldShow('activityRenderMode') && chatRenderMode === 'sorted') || (shouldShow('diffLayout') && !isVSCode)) && (
{(shouldShow('userMessageRendering') || shouldShow('mermaidRendering') || shouldShow('chatRenderMode') || shouldShow('messageTransport') || (shouldShow('activityRenderMode') && chatRenderMode === 'sorted') || (shouldShow('diffLayout') && !isVSCode)) && (
<div className="grid grid-cols-1 gap-y-2 md:grid-cols-[minmax(0,16rem)_minmax(0,16rem)] md:justify-start md:gap-x-2">
{shouldShow('chatRenderMode') && (
<section className="p-2 md:col-span-2">
@@ -934,6 +961,35 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
</section>
)}
{shouldShow('messageTransport') && (
<section className="p-2 md:col-span-2">
<h4 className="typography-ui-header font-medium text-foreground">Message Stream Transport</h4>
<div className="mt-1 flex max-w-[24rem] flex-col gap-2">
<div className="flex flex-wrap items-center gap-1">
{MESSAGE_STREAM_TRANSPORT_OPTIONS.map((option) => (
<Button
key={option.id}
variant="outline"
size="xs"
className={cn(
'!font-normal',
messageStreamTransport === option.id
? 'border-[var(--primary-base)] text-[var(--primary-base)] bg-[var(--primary-base)]/10 hover:text-[var(--primary-base)]'
: 'text-foreground'
)}
onClick={() => handleMessageStreamTransportChange(option.id)}
>
{option.label}
</Button>
))}
</div>
<span className="typography-meta text-muted-foreground">
{MESSAGE_STREAM_TRANSPORT_OPTIONS.find((option) => option.id === messageStreamTransport)?.description}
</span>
</div>
</section>
)}
{shouldShow('activityRenderMode') && chatRenderMode === 'sorted' && (
<section className="p-2 md:col-span-2">
<h4 className="typography-ui-header font-medium text-foreground">Activity Default</h4>
@@ -44,18 +44,29 @@ async function probeDirectory(directory: string): Promise<DirectoryStatusValue>
try {
await opencodeClient.listLocalDirectory(directory);
return 'exists';
} catch {
} catch (error) {
const looksLikeSdkWorktree =
directory.includes('/opencode/worktree/') ||
directory.includes('/.opencode/data/worktree/') ||
directory.includes('/.local/share/opencode/worktree/');
if (looksLikeSdkWorktree) {
const ok = await opencodeClient.probeDirectory(directory).catch(() => false);
if (ok) return 'exists';
const reachable = await opencodeClient.probeDirectory(directory).catch(() => false);
if (reachable) {
return 'exists';
}
return 'missing';
const message = error instanceof Error ? error.message.toLowerCase() : '';
const definitelyMissing =
message.includes('enoent') ||
message.includes('not found') ||
message.includes('does not exist') ||
message.includes('no such file');
if (definitelyMissing || looksLikeSdkWorktree) {
return 'missing';
}
return 'unknown';
}
}
@@ -75,13 +86,17 @@ export const useDirectoryStatusProbe = ({
React.useEffect(() => {
const directories = new Set<string>();
const normalizedProjectRoots = new Set<string>();
sortedSessions.forEach((session) => {
const dir = normalizePath((session as Session & { directory?: string | null }).directory ?? null);
if (dir) directories.add(dir);
});
projects.forEach((project) => {
const normalized = normalizePath(project.path);
if (normalized) directories.add(normalized);
if (normalized) {
directories.add(normalized);
normalizedProjectRoots.add(normalized);
}
});
const now = Date.now();
@@ -90,12 +105,25 @@ export const useDirectoryStatusProbe = ({
const preseeded = new Map<string, DirectoryStatusValue>();
for (const directory of directories) {
const isProjectRoot = normalizedProjectRoots.has(directory);
const known = directoryStatusRef.current.get(directory);
if (known && known !== 'unknown') continue;
if (known === 'exists') continue;
const cachedAt = missingCache[directory];
if (known === 'missing') {
if (isProjectRoot) {
toProbe.push(directory);
continue;
}
if (cachedAt && now - cachedAt < MISSING_REPROBE_MS) {
continue;
}
toProbe.push(directory);
continue;
}
// Use cached "missing" status if fresh enough — skip the HTTP probe
const cachedAt = missingCache[directory];
if (cachedAt && now - cachedAt < MISSING_REPROBE_MS) {
if (!isProjectRoot && cachedAt && now - cachedAt < MISSING_REPROBE_MS) {
preseeded.set(directory, 'missing');
continue;
}