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:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
fd8972a7d9
commit
bee9d19f3a
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user