From 1ad470cead5f2bce109c222afecb4249fb8c1594 Mon Sep 17 00:00:00 2001 From: Leonid Skorobogatyy Date: Mon, 22 Jun 2026 02:09:50 +1100 Subject: [PATCH 001/299] fix(worktree): hide non-matching branches during search --- .../src/components/session/NewWorktreeDialog.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/components/session/NewWorktreeDialog.tsx b/packages/ui/src/components/session/NewWorktreeDialog.tsx index 70be749d..363c91c4 100644 --- a/packages/ui/src/components/session/NewWorktreeDialog.tsx +++ b/packages/ui/src/components/session/NewWorktreeDialog.tsx @@ -1142,7 +1142,7 @@ export function NewWorktreeDialog({ )} - {existingBranchRankedGroups.otherLocal.length > 0 && ( + {!hasExistingBranchQuery && existingBranchRankedGroups.otherLocal.length > 0 && (
{hasExistingBranchQuery ? t('session.newWorktree.otherLocalBranches') : t('session.newWorktree.localBranches')} @@ -1174,7 +1174,7 @@ export function NewWorktreeDialog({
)} - {existingBranchRankedGroups.otherRemote.length > 0 && ( + {!hasExistingBranchQuery && existingBranchRankedGroups.otherRemote.length > 0 && (
{hasExistingBranchQuery ? t('session.newWorktree.otherRemoteBranches') : t('session.newWorktree.remoteBranches')} @@ -1401,7 +1401,7 @@ export function NewWorktreeDialog({
)} - {sourceBranchRankedGroups.otherLocal.length > 0 && ( + {!hasSourceBranchQuery && sourceBranchRankedGroups.otherLocal.length > 0 && (
{hasSourceBranchQuery ? t('session.newWorktree.otherLocalBranches') : t('session.newWorktree.localBranches')} @@ -1428,7 +1428,7 @@ export function NewWorktreeDialog({
)} - {sourceBranchRankedGroups.otherRemote.length > 0 && ( + {!hasSourceBranchQuery && sourceBranchRankedGroups.otherRemote.length > 0 && (
{hasSourceBranchQuery ? t('session.newWorktree.otherRemoteBranches') : t('session.newWorktree.remoteBranches')} @@ -1607,7 +1607,7 @@ export function NewWorktreeDialog({
)} - {existingBranchRankedGroups.otherLocal.length > 0 && ( + {!hasExistingBranchQuery && existingBranchRankedGroups.otherLocal.length > 0 && ( <> {hasExistingBranchQuery && } @@ -1632,7 +1632,7 @@ export function NewWorktreeDialog({ )} - {existingBranchRankedGroups.otherRemote.length > 0 && ( + {!hasExistingBranchQuery && existingBranchRankedGroups.otherRemote.length > 0 && ( <> {(existingBranchRankedGroups.otherLocal.length > 0 || hasExistingBranchQuery) && ( @@ -1843,7 +1843,7 @@ export function NewWorktreeDialog({
)} - {sourceBranchRankedGroups.otherLocal.length > 0 && ( + {!hasSourceBranchQuery && sourceBranchRankedGroups.otherLocal.length > 0 && ( <> {hasSourceBranchQuery && } @@ -1863,7 +1863,7 @@ export function NewWorktreeDialog({ )} - {sourceBranchRankedGroups.otherRemote.length > 0 && ( + {!hasSourceBranchQuery && sourceBranchRankedGroups.otherRemote.length > 0 && ( <> {(sourceBranchRankedGroups.otherLocal.length > 0 || hasSourceBranchQuery) && ( From 20f2a2635baf2d5f7dc013cb00700a661c9c0aa7 Mon Sep 17 00:00:00 2001 From: Leonid Skorobogatyy Date: Wed, 24 Jun 2026 03:51:56 +1100 Subject: [PATCH 002/299] chore(worktree): drop dead otherBranches labels after search guard The mobile and desktop (cmdk) pickers in NewWorktreeDialog now hide non-matching branches during search via a !hasExistingBranchQuery / !hasSourceBranchQuery outer guard. That makes the inner heading ternaries (which switched between 'localBranches' and 'otherLocalBranches' depending on the search state) unreachable: they always resolve to the non-query label, and the 'hasExistingBranchQuery && ' lines were dead code. This commit: - Replaces 8 heading ternaries with static non-query labels (mobile and desktop, existing/source, local/remote). - Drops the two unreachable renders in the otherLocal blocks. - Simplifies the otherRemote separator conditions by removing the always-false '|| hasExistingBranchQuery' / '|| hasSourceBranchQuery' terms. - Removes the now-unused 'session.newWorktree.otherLocalBranches' and 'session.newWorktree.otherRemoteBranches' keys from all 9 locales. Behavior is unchanged; this is dead-branch cleanup only. --- .../components/session/NewWorktreeDialog.tsx | 22 +++++++++---------- packages/ui/src/lib/i18n/messages/en.ts | 2 -- packages/ui/src/lib/i18n/messages/es.ts | 2 -- packages/ui/src/lib/i18n/messages/fr.ts | 2 -- packages/ui/src/lib/i18n/messages/ko.ts | 2 -- packages/ui/src/lib/i18n/messages/pl.ts | 2 -- packages/ui/src/lib/i18n/messages/pt-BR.ts | 2 -- packages/ui/src/lib/i18n/messages/uk.ts | 2 -- packages/ui/src/lib/i18n/messages/zh-CN.ts | 2 -- packages/ui/src/lib/i18n/messages/zh-TW.ts | 2 -- 10 files changed, 10 insertions(+), 30 deletions(-) diff --git a/packages/ui/src/components/session/NewWorktreeDialog.tsx b/packages/ui/src/components/session/NewWorktreeDialog.tsx index 363c91c4..8d4c16f5 100644 --- a/packages/ui/src/components/session/NewWorktreeDialog.tsx +++ b/packages/ui/src/components/session/NewWorktreeDialog.tsx @@ -1145,7 +1145,7 @@ export function NewWorktreeDialog({ {!hasExistingBranchQuery && existingBranchRankedGroups.otherLocal.length > 0 && (
- {hasExistingBranchQuery ? t('session.newWorktree.otherLocalBranches') : t('session.newWorktree.localBranches')} + {t('session.newWorktree.localBranches')}
{existingBranchRankedGroups.otherLocal.map((branch) => ( @@ -1177,7 +1177,7 @@ export function NewWorktreeDialog({ {!hasExistingBranchQuery && existingBranchRankedGroups.otherRemote.length > 0 && (
- {hasExistingBranchQuery ? t('session.newWorktree.otherRemoteBranches') : t('session.newWorktree.remoteBranches')} + {t('session.newWorktree.remoteBranches')}
{existingBranchRankedGroups.otherRemote.map((branch) => ( @@ -1404,7 +1404,7 @@ export function NewWorktreeDialog({ {!hasSourceBranchQuery && sourceBranchRankedGroups.otherLocal.length > 0 && (
- {hasSourceBranchQuery ? t('session.newWorktree.otherLocalBranches') : t('session.newWorktree.localBranches')} + {t('session.newWorktree.localBranches')}
{sourceBranchRankedGroups.otherLocal.map((branch) => ( @@ -1431,7 +1431,7 @@ export function NewWorktreeDialog({ {!hasSourceBranchQuery && sourceBranchRankedGroups.otherRemote.length > 0 && (
- {hasSourceBranchQuery ? t('session.newWorktree.otherRemoteBranches') : t('session.newWorktree.remoteBranches')} + {t('session.newWorktree.remoteBranches')}
{sourceBranchRankedGroups.otherRemote.map((branch) => ( @@ -1609,8 +1609,7 @@ export function NewWorktreeDialog({ {!hasExistingBranchQuery && existingBranchRankedGroups.otherLocal.length > 0 && ( <> - {hasExistingBranchQuery && } - + {existingBranchRankedGroups.otherLocal.map((branch) => ( 0 && ( <> - {(existingBranchRankedGroups.otherLocal.length > 0 || hasExistingBranchQuery) && ( + {existingBranchRankedGroups.otherLocal.length > 0 && ( )} - + {existingBranchRankedGroups.otherRemote.map((branch) => ( 0 && ( <> - {hasSourceBranchQuery && } - + {sourceBranchRankedGroups.otherLocal.map((branch) => ( 0 && ( <> - {(sourceBranchRankedGroups.otherLocal.length > 0 || hasSourceBranchQuery) && ( + {sourceBranchRankedGroups.otherLocal.length > 0 && ( )} - + {sourceBranchRankedGroups.otherRemote.map((branch) => ( = { "session.newWorktree.noMatchingBranches": "No hay ramas coincidentes", "session.newWorktree.localBranches": "Ramas locales", "session.newWorktree.remoteBranches": "Ramas remotas", - "session.newWorktree.otherLocalBranches": "Otras ramas locales", - "session.newWorktree.otherRemoteBranches": "Otras ramas remotas", "session.newWorktree.branchName": "Nombre de la rama", "session.newWorktree.branchNamePlaceholder": "feature/my-awesome-feature", "session.newWorktree.actions.change": "Cambiar", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 9d66af07..a37fb929 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -1414,8 +1414,6 @@ export const dict = { 'session.newWorktree.noMatchingBranches': 'Aucune branche correspondante', 'session.newWorktree.localBranches': 'Branches locales', 'session.newWorktree.remoteBranches': 'Branches du dépôt distant', - 'session.newWorktree.otherLocalBranches': 'Autres branches locales', - 'session.newWorktree.otherRemoteBranches': 'Autres branches du remote', 'session.newWorktree.branchName': 'Nom de la branche', 'session.newWorktree.branchNamePlaceholder': 'fonctionnalité/ma-fonctionnalité-géniale', 'session.newWorktree.actions.change': 'Changement', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 06532993..1e41603f 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1551,8 +1551,6 @@ export const dict: Record = { 'session.newWorktree.noMatchingBranches': '일치하는 브랜치가 없습니다', 'session.newWorktree.localBranches': '로컬 브랜치', 'session.newWorktree.remoteBranches': '리모트 브랜치', - 'session.newWorktree.otherLocalBranches': '기타 로컬 브랜치', - 'session.newWorktree.otherRemoteBranches': '기타 리모트 브랜치', 'session.newWorktree.branchName': '브랜치 이름', 'session.newWorktree.branchNamePlaceholder': 'feature/my-awesome-feature', 'session.newWorktree.actions.change': '변경', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 664f62ed..b0bb914e 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -2354,8 +2354,6 @@ export const dict: Record = { 'session.newWorktree.newSessionTitle': 'Nowa sesja', 'session.newWorktree.noBranchesFound': 'Nie znaleziono gałęzi', 'session.newWorktree.noMatchingBranches': 'Brak pasujących gałęzi', - 'session.newWorktree.otherLocalBranches': 'Pozostałe lokalne gałęzie', - 'session.newWorktree.otherRemoteBranches': 'Pozostałe zdalne gałęzie', 'session.newWorktree.prNumber': 'PR #{number}', 'session.newWorktree.remoteBranches': 'Zdalne gałęzie', 'session.newWorktree.resetToMatchBranchName': 'Zresetuj do nazwy zgodnej z gałęzią', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 8f345e85..c86dd4a6 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1515,8 +1515,6 @@ export const dict: Record = { "session.newWorktree.noMatchingBranches": "Não há branches coincidentes", "session.newWorktree.localBranches": "Branches locais", "session.newWorktree.remoteBranches": "Branches remotas", - "session.newWorktree.otherLocalBranches": "Outras branches locais", - "session.newWorktree.otherRemoteBranches": "Outras branches remotas", "session.newWorktree.branchName": "Nome da branch", "session.newWorktree.branchNamePlaceholder": "feature/my-awesome-feature", "session.newWorktree.actions.change": "Alterar", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 42c09339..ce5b08e2 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1515,8 +1515,6 @@ export const dict: Record = { "session.newWorktree.noMatchingBranches": "Немає відповідних гілок", "session.newWorktree.localBranches": "Локальні гілки", "session.newWorktree.remoteBranches": "Віддалені гілки", - "session.newWorktree.otherLocalBranches": "Інші локальні гілки", - "session.newWorktree.otherRemoteBranches": "Інші віддалені гілки", "session.newWorktree.branchName": "Назва гілки", "session.newWorktree.branchNamePlaceholder": "feature/my-awesome-feature", "session.newWorktree.actions.change": "Змінити", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 1ffd934d..5e4c71b5 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1515,8 +1515,6 @@ export const dict: Record = { 'session.newWorktree.noMatchingBranches': '没有匹配分支', 'session.newWorktree.localBranches': '本地分支', 'session.newWorktree.remoteBranches': '远程分支', - 'session.newWorktree.otherLocalBranches': '其他本地分支', - 'session.newWorktree.otherRemoteBranches': '其他远程分支', 'session.newWorktree.branchName': '分支名', 'session.newWorktree.branchNamePlaceholder': 'feature/my-awesome-feature', 'session.newWorktree.actions.change': '更改', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 08d30254..825fad27 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -1519,8 +1519,6 @@ export const dict: Record = { 'session.newWorktree.noMatchingBranches': '沒有符合分支', 'session.newWorktree.localBranches': '本地分支', 'session.newWorktree.remoteBranches': '遠端分支', - 'session.newWorktree.otherLocalBranches': '其他本地分支', - 'session.newWorktree.otherRemoteBranches': '其他遠端分支', 'session.newWorktree.branchName': '分支名稱', 'session.newWorktree.branchNamePlaceholder': 'feature/my-awesome-feature', 'session.newWorktree.actions.change': '變更', From ecc70fa15d967bf0a43abed519a05a574b29d80f Mon Sep 17 00:00:00 2001 From: Tom Rochette Date: Wed, 24 Jun 2026 05:24:48 +0000 Subject: [PATCH 003/299] feat(debug): add fetch requests-in-flight tracker with age percentiles Adds a 'Requests' tab to the debug panel (Ctrl/Cmd+Shift+D) that wraps window.fetch while the panel is open to track every request as in-flight from call to promise settle, sampled once per second over a 5-minute rolling window. Charts: - in-flight request count over time (current + peak) - p50/p90/p99/max age distribution of currently in-flight requests, with the legend below the chart and a y-axis max label for scale Tracking is fully gated behind the panel: closing it stops the sampling interval, unwraps window.fetch, and drops all state (zero overhead when not debugging). i18n keys added to all 9 locales. --- packages/ui/src/App.tsx | 8 + .../ui/src/components/ui/MemoryDebugPanel.tsx | 180 +++++++++- packages/ui/src/lib/i18n/messages/en.ts | 11 + packages/ui/src/lib/i18n/messages/es.ts | 11 + packages/ui/src/lib/i18n/messages/fr.ts | 11 + packages/ui/src/lib/i18n/messages/ko.ts | 11 + packages/ui/src/lib/i18n/messages/pl.ts | 11 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 11 + packages/ui/src/lib/i18n/messages/uk.ts | 11 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 11 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 11 + .../ui/src/stores/utils/requestsInFlight.ts | 314 ++++++++++++++++++ 12 files changed, 594 insertions(+), 7 deletions(-) create mode 100644 packages/ui/src/stores/utils/requestsInFlight.ts diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 583d4b9d..a0c977ce 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -6,6 +6,7 @@ import { Toaster } from '@/components/ui/sonner'; import { Button } from '@/components/ui/button'; import { MemoryDebugPanel } from '@/components/ui/MemoryDebugPanel'; import { setStreamPerfEnabled } from '@/stores/utils/streamDebug'; +import { setRequestsInFlightTrackingEnabled } from '@/stores/utils/requestsInFlight'; import { ErrorBoundary } from '@/components/ui/ErrorBoundary'; // useEventStream removed — replaced by SyncProvider + SyncBridge import { useMenuActions } from '@/hooks/useMenuActions'; @@ -256,6 +257,13 @@ function App({ apis }: AppProps) { }; }, [showMemoryDebug]); + React.useEffect(() => { + setRequestsInFlightTrackingEnabled(showMemoryDebug); + return () => { + setRequestsInFlightTrackingEnabled(false); + }; + }, [showMemoryDebug]); + React.useEffect(() => { applyMobileKeyboardMode(mobileKeyboardMode); }, [mobileKeyboardMode]); diff --git a/packages/ui/src/components/ui/MemoryDebugPanel.tsx b/packages/ui/src/components/ui/MemoryDebugPanel.tsx index 901d8f80..957e654b 100644 --- a/packages/ui/src/components/ui/MemoryDebugPanel.tsx +++ b/packages/ui/src/components/ui/MemoryDebugPanel.tsx @@ -7,6 +7,7 @@ import { MEMORY_LIMITS } from '@/stores/types/sessionTypes'; import { useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore'; import { getBackgroundTrimLimit } from '@/stores/types/sessionTypes'; import { getStreamPerfSnapshot, getVsCodeStreamPerfSnapshot, resetStreamPerf, type StreamPerfSnapshot } from '@/stores/utils/streamDebug'; +import { getRequestsInFlightSnapshot, resetRequestsInFlight, type RequestsInFlightSnapshot } from '@/stores/utils/requestsInFlight'; import { Card } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'; @@ -18,7 +19,7 @@ interface DebugPanelProps { onClose?: () => void; } -type DebugTab = 'memory' | 'streaming'; +type DebugTab = 'memory' | 'streaming' | 'requests'; const formatDuration = (durationMs: number): string => { if (durationMs < 1000) { @@ -35,6 +36,10 @@ const formatDuration = (durationMs: number): string => { return `${minutes}m ${remainderSeconds}s`; }; +// Fixed-width seconds format ("XX.XX s") for the percentile series so the +// legend/labels don't jitter as values change. Pair with `tabular-nums`. +const formatSeconds = (durationMs: number): string => `${(durationMs / 1000).toFixed(2)} s`; + const MetricCard: React.FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => { return (
= ({ series, peak, windowSeconds, ariaLabel, maxLabel }) => { + const width = windowSeconds; + const height = 56; + const padTop = 4; + const n = series.reduce((max, s) => Math.max(max, s.samples.length), 0); + const scale = peak > 0 ? (height - padTop) / peak : 0; + const xFor = (i: number): number => width - n + i; + const yFor = (v: number): number => height - v * scale; + const baseline = height; + + return ( +
+ {maxLabel} + + + {series.map((s, si) => { + const sn = s.samples.length; + if (sn === 0) return null; + const points = s.samples.map((v, i) => `${xFor(i)},${yFor(v).toFixed(2)}`); + const linePath = `M ${points.join(' L ')}`; + return ( + + {s.filled ? ( + + ) : null} + + + ); + })} + +
+ ); +}; + export const DebugPanel: React.FC = ({ onClose }) => { const { t } = useI18n(); const [activeTab, setActiveTab] = React.useState('memory'); @@ -110,6 +183,15 @@ export const DebugPanel: React.FC = ({ onClose }) => { const totalGitHubRequests = useGitHubPrStatusStore((state) => state.totalRequestCount); const [streamSnapshot, setStreamSnapshot] = React.useState(() => getStreamPerfSnapshot()); const [vscodeStreamSnapshot, setVsCodeStreamSnapshot] = React.useState(() => getVsCodeStreamPerfSnapshot()); + const [requestsSnapshot, setRequestsSnapshot] = React.useState(() => getRequestsInFlightSnapshot()); + const ageLines = [ + { label: 'p50', current: requestsSnapshot.ageP50, samples: requestsSnapshot.p50Samples, color: 'var(--status-success)' }, + { label: 'p90', current: requestsSnapshot.ageP90, samples: requestsSnapshot.p90Samples, color: 'var(--status-info)' }, + { label: 'p99', current: requestsSnapshot.ageP99, samples: requestsSnapshot.p99Samples, color: 'var(--status-warning)' }, + { label: 'max', current: requestsSnapshot.ageMax, samples: requestsSnapshot.maxSamples, color: 'var(--status-error)' }, + ]; + const countMax = requestsSnapshot.samples.reduce((m, v) => Math.max(m, v), 0); + const percentileMax = ageLines.reduce((m, l) => l.samples.reduce((mm, v) => Math.max(mm, v), m), 0); const streamMetricCounts = React.useMemo(() => { const counts = new Map(); streamSnapshot.entries.forEach((entry) => { @@ -130,6 +212,7 @@ export const DebugPanel: React.FC = ({ onClose }) => { const refresh = () => { setStreamSnapshot(getStreamPerfSnapshot()); setVsCodeStreamSnapshot(getVsCodeStreamPerfSnapshot()); + setRequestsSnapshot(getRequestsInFlightSnapshot()); }; refresh(); @@ -218,11 +301,10 @@ export const DebugPanel: React.FC = ({ onClose }) => { >
- {activeTab === 'memory' ? ( - - ) : ( - - )} +

{t('memoryDebugPanel.title')}

@@ -244,6 +326,18 @@ export const DebugPanel: React.FC = ({ onClose }) => { ) : null} + {activeTab === 'requests' ? ( + + ) : null} {onClose ? ( +
{activeTab === 'memory' ? ( @@ -366,7 +468,7 @@ export const DebugPanel: React.FC = ({ onClose }) => {
- ) : ( + ) : activeTab === 'streaming' ? (
@@ -409,6 +511,70 @@ export const DebugPanel: React.FC = ({ onClose }) => { /> ) : null}
+ ) : ( +
+
+ + +
+ + {requestsSnapshot.samples.length === 0 ? ( +
+ {t('memoryDebugPanel.requests.noSamples')} +
+ ) : ( +
+
+ {t('memoryDebugPanel.requests.inFlight')} + + {requestsSnapshot.inFlight} + · {t('memoryDebugPanel.requests.peak')} + {requestsSnapshot.peak} + +
+ + +
+ {t('memoryDebugPanel.requests.duration')} + {formatSeconds(requestsSnapshot.peakAgeMs)} +
+ ({ samples: line.samples, color: line.color }))} + peak={percentileMax} + windowSeconds={requestsSnapshot.windowSeconds} + ariaLabel={t('memoryDebugPanel.requests.percentileChartLabel')} + maxLabel={formatSeconds(percentileMax)} + /> + +
+ {ageLines.map((line) => ( + + + {line.label} + {formatSeconds(line.current)} + + ))} +
+ +
+ {t('memoryDebugPanel.requests.windowHint', { seconds: requestsSnapshot.windowSeconds })} + {t('memoryDebugPanel.requests.now')} +
+
+ )} +
)} ); diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index dba49cb5..c0f6b69e 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -2537,6 +2537,7 @@ export const dict = { 'memoryDebugPanel.title': 'Debug Panel', 'memoryDebugPanel.tabs.memory': 'Memory', 'memoryDebugPanel.tabs.streaming': 'Streaming', + 'memoryDebugPanel.tabs.requests': 'Requests', 'memoryDebugPanel.section.sessionsInMemory': 'Sessions in Memory', 'memoryDebugPanel.section.uiStreamingMetrics': 'UI Streaming Metrics', 'memoryDebugPanel.section.vscodeBridgeMetrics': 'VS Code Bridge Metrics', @@ -2574,6 +2575,16 @@ export const dict = { 'memoryDebugPanel.streaming.copy.copied': 'Streaming debug JSON copied', 'memoryDebugPanel.streaming.copy.failed': 'Failed to copy JSON', 'memoryDebugPanel.streaming.copy.hint': 'Copy exports both UI and VS Code streaming metrics as JSON', + 'memoryDebugPanel.requests.inFlight': 'In flight', + 'memoryDebugPanel.requests.peak': 'Peak', + 'memoryDebugPanel.requests.duration': 'Duration', + 'memoryDebugPanel.requests.totalRequests': 'Total Requests', + 'memoryDebugPanel.requests.tracking': 'Tracking', + 'memoryDebugPanel.requests.now': 'now', + 'memoryDebugPanel.requests.noSamples': 'No requests tracked yet. Keep this panel open to record fetch activity.', + 'memoryDebugPanel.requests.chartLabel': 'Fetch requests in flight over time, peak {peak}', + 'memoryDebugPanel.requests.windowHint': 'last {seconds}s', + 'memoryDebugPanel.requests.percentileChartLabel': 'In-flight request age percentiles (p50, p90, p99, max) over time', 'memoryDebugPanel.common.idle': 'idle', 'memoryDebugPanel.common.live': 'live', 'memoryDebugPanel.common.notAvailable': 'n/a', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index a92e4127..4df60271 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -2503,6 +2503,7 @@ export const dict: Record = { "memoryDebugPanel.title": "Panel de depuración", "memoryDebugPanel.tabs.memory": "Memoria", "memoryDebugPanel.tabs.streaming": "Transmisión", + "memoryDebugPanel.tabs.requests": "Solicitudes", "memoryDebugPanel.section.sessionsInMemory": "Sesiones en memoria", "memoryDebugPanel.section.uiStreamingMetrics": "Métricas de streaming de UI", "memoryDebugPanel.section.vscodeBridgeMetrics": "Métricas del puente de VS Code", @@ -2540,6 +2541,16 @@ export const dict: Record = { "memoryDebugPanel.streaming.copy.copied": "JSON de depuración en streaming copiado", "memoryDebugPanel.streaming.copy.failed": "No se pudo copiar JSON", "memoryDebugPanel.streaming.copy.hint": "Copia exportaciones de métricas de UI como de métricas de VS Code en formato JSON", + "memoryDebugPanel.requests.inFlight": "En curso", + "memoryDebugPanel.requests.peak": "Pico", + "memoryDebugPanel.requests.duration": "Duración", + "memoryDebugPanel.requests.totalRequests": "Solicitudes totales", + "memoryDebugPanel.requests.tracking": "Seguimiento", + "memoryDebugPanel.requests.now": "ahora", + "memoryDebugPanel.requests.noSamples": "Aún no se han registrado solicitudes. Mantén este panel abierto para registrar la actividad de fetch.", + "memoryDebugPanel.requests.chartLabel": "Solicitudes fetch en curso a lo largo del tiempo, pico {peak}", + "memoryDebugPanel.requests.windowHint": "últimos {seconds}s", + "memoryDebugPanel.requests.percentileChartLabel": "Percentiles de antigüedad de solicitudes en curso (p50, p90, p99, máx) a lo largo del tiempo", "memoryDebugPanel.common.idle": "inactivo", "memoryDebugPanel.common.live": "en vivo", "memoryDebugPanel.common.notAvailable": "n/a", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 9d66af07..08026779 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -2346,6 +2346,7 @@ export const dict = { 'memoryDebugPanel.title': 'Panneau de débogage', 'memoryDebugPanel.tabs.memory': 'Mémoire', 'memoryDebugPanel.tabs.streaming': 'Streaming', + 'memoryDebugPanel.tabs.requests': 'Requêtes', 'memoryDebugPanel.section.sessionsInMemory': 'Sessions en mémoire', 'memoryDebugPanel.section.uiStreamingMetrics': 'Métriques de streaming de l\'interface utilisateur', 'memoryDebugPanel.section.vscodeBridgeMetrics': 'Métriques du pont VS Code', @@ -2383,6 +2384,16 @@ export const dict = { 'memoryDebugPanel.streaming.copy.copied': 'Débogage en streaming JSON copié', 'memoryDebugPanel.streaming.copy.failed': 'Échec de la copie de JSON', 'memoryDebugPanel.streaming.copy.hint': 'La copie exporte les métriques de streaming de l\'interface utilisateur et de VS Code en tant que JSON.', + 'memoryDebugPanel.requests.inFlight': 'En cours', + 'memoryDebugPanel.requests.peak': 'Pic', + 'memoryDebugPanel.requests.duration': 'Durée', + 'memoryDebugPanel.requests.totalRequests': 'Requêtes totales', + 'memoryDebugPanel.requests.tracking': 'Suivi', + 'memoryDebugPanel.requests.now': 'maintenant', + 'memoryDebugPanel.requests.noSamples': 'Aucune requête enregistrée. Gardez ce panneau ouvert pour enregistrer l\'activité fetch.', + 'memoryDebugPanel.requests.chartLabel': 'Requêtes fetch en cours dans le temps, pic {peak}', + 'memoryDebugPanel.requests.windowHint': '{seconds}s dernières', + 'memoryDebugPanel.requests.percentileChartLabel': 'Percentiles d\'âge des requêtes en cours (p50, p90, p99, max) dans le temps', 'memoryDebugPanel.common.idle': 'inactif', 'memoryDebugPanel.common.live': 'en direct', 'memoryDebugPanel.common.notAvailable': 'n / A', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 06532993..d0d69989 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -2537,6 +2537,7 @@ export const dict: Record = { 'memoryDebugPanel.title': '디버그 패널', 'memoryDebugPanel.tabs.memory': '메모리', 'memoryDebugPanel.tabs.streaming': '스트리밍', + 'memoryDebugPanel.tabs.requests': '요청', 'memoryDebugPanel.section.sessionsInMemory': '메모리 내 세션', 'memoryDebugPanel.section.uiStreamingMetrics': 'UI 스트리밍 지표', 'memoryDebugPanel.section.vscodeBridgeMetrics': 'VS Code 브리지 지표', @@ -2574,6 +2575,16 @@ export const dict: Record = { 'memoryDebugPanel.streaming.copy.copied': '스트리밍 디버그 JSON 복사 완료', 'memoryDebugPanel.streaming.copy.failed': 'JSON 복사 실패', 'memoryDebugPanel.streaming.copy.hint': 'UI와 VS Code 스트리밍 메트릭을 JSON으로 복사합니다', + 'memoryDebugPanel.requests.inFlight': '진행 중', + 'memoryDebugPanel.requests.peak': '최대', + 'memoryDebugPanel.requests.duration': '지속 시간', + 'memoryDebugPanel.requests.totalRequests': '전체 요청', + 'memoryDebugPanel.requests.tracking': '추적 중', + 'memoryDebugPanel.requests.now': '현재', + 'memoryDebugPanel.requests.noSamples': '아직 기록된 요청이 없습니다. fetch 활동을 기록하려면 이 패널을 열어 두세요.', + 'memoryDebugPanel.requests.chartLabel': '시간에 따른 진행 중인 fetch 요청, 최대 {peak}', + 'memoryDebugPanel.requests.windowHint': '최근 {seconds}초', + 'memoryDebugPanel.requests.percentileChartLabel': '진행 중 요청 수명 백분위수(p50, p90, p99, max)의 시간별 변화', 'memoryDebugPanel.common.idle': '유휴', 'memoryDebugPanel.common.live': '실시간', 'memoryDebugPanel.common.notAvailable': 'n/a', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 664f62ed..cf228b44 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -2135,8 +2135,19 @@ export const dict: Record = { 'memoryDebugPanel.streaming.copy.copied': 'Skopiowano JSON debugowania streamingu', 'memoryDebugPanel.streaming.copy.failed': 'Nie udało się skopiować JSON', 'memoryDebugPanel.streaming.copy.hint': 'Kopiowanie eksportuje metryki streamingu zarówno UI, jak i VS Code w formacie JSON', + 'memoryDebugPanel.requests.inFlight': 'W trakcie', + 'memoryDebugPanel.requests.peak': 'Szczyt', + 'memoryDebugPanel.requests.duration': 'Czas trwania', + 'memoryDebugPanel.requests.totalRequests': 'Łączne żądania', + 'memoryDebugPanel.requests.tracking': 'Śledzenie', + 'memoryDebugPanel.requests.now': 'teraz', + 'memoryDebugPanel.requests.noSamples': 'Brak żądań. Utrzymuj ten panel otwarty, aby rejestrować aktywność fetch.', + 'memoryDebugPanel.requests.chartLabel': 'Żądania fetch w trakcie w czasie, szczyt {peak}', + 'memoryDebugPanel.requests.windowHint': 'ostatnie {seconds}s', + 'memoryDebugPanel.requests.percentileChartLabel': 'Percentyle wieku żądań w trakcie (p50, p90, p99, max) w czasie', 'memoryDebugPanel.tabs.memory': 'Pamięć', 'memoryDebugPanel.tabs.streaming': 'Streaming', + 'memoryDebugPanel.tabs.requests': 'Żądania', 'memoryDebugPanel.title': 'Panel debugowania', 'memoryDebugPanel.tooltip.logCurrentState': 'Zaloguj bieżący stan pamięci do konsoli przeglądarki', 'openChamberLogo.aria.logo': 'Logo OpenChamber', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 8f345e85..08591730 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -2503,6 +2503,7 @@ export const dict: Record = { "memoryDebugPanel.title": "Painel de depuração", "memoryDebugPanel.tabs.memory": "Memória", "memoryDebugPanel.tabs.streaming": "Transmissão", + "memoryDebugPanel.tabs.requests": "Solicitações", "memoryDebugPanel.section.sessionsInMemory": "Sessões em memória", "memoryDebugPanel.section.uiStreamingMetrics": "Métricas de streaming de UI", "memoryDebugPanel.section.vscodeBridgeMetrics": "Métricas da ponte do VS Code", @@ -2540,6 +2541,16 @@ export const dict: Record = { "memoryDebugPanel.streaming.copy.copied": "JSON de depuração em streaming copiado", "memoryDebugPanel.streaming.copy.failed": "Não foi possível copiar JSON", "memoryDebugPanel.streaming.copy.hint": "Copia exportações de métricas da UI e do VS Code em formato JSON", + "memoryDebugPanel.requests.inFlight": "Em curso", + "memoryDebugPanel.requests.peak": "Pico", + "memoryDebugPanel.requests.duration": "Duração", + "memoryDebugPanel.requests.totalRequests": "Solicitações totais", + "memoryDebugPanel.requests.tracking": "Rastreamento", + "memoryDebugPanel.requests.now": "agora", + "memoryDebugPanel.requests.noSamples": "Nenhuma solicitação registrada. Mantenha este painel aberto para registrar a atividade de fetch.", + "memoryDebugPanel.requests.chartLabel": "Solicitações fetch em curso ao longo do tempo, pico {peak}", + "memoryDebugPanel.requests.windowHint": "últimos {seconds}s", + "memoryDebugPanel.requests.percentileChartLabel": "Percentis de idade das solicitações em curso (p50, p90, p99, máx) ao longo do tempo", "memoryDebugPanel.common.idle": "inativo", "memoryDebugPanel.common.live": "ao vivo", "memoryDebugPanel.common.notAvailable": "n/a", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 42c09339..d2232dae 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -2503,6 +2503,7 @@ export const dict: Record = { "memoryDebugPanel.title": "Панель налагодження", "memoryDebugPanel.tabs.memory": "Пам'ять", "memoryDebugPanel.tabs.streaming": "Потокове передавання", + "memoryDebugPanel.tabs.requests": "Запити", "memoryDebugPanel.section.sessionsInMemory": "Сесії в пам'яті", "memoryDebugPanel.section.uiStreamingMetrics": "Потокові показники інтерфейсу користувача", "memoryDebugPanel.section.vscodeBridgeMetrics": "Метрики мосту VS Code", @@ -2540,6 +2541,16 @@ export const dict: Record = { "memoryDebugPanel.streaming.copy.copied": "Потокове налагодження JSON скопійовано", "memoryDebugPanel.streaming.copy.failed": "Не вдалося скопіювати JSON", "memoryDebugPanel.streaming.copy.hint": "Копіювання експортує метрики потокового інтерфейсу користувача та VS Code як JSON", + "memoryDebugPanel.requests.inFlight": "Виконуються", + "memoryDebugPanel.requests.peak": "Пік", + "memoryDebugPanel.requests.duration": "Тривалість", + "memoryDebugPanel.requests.totalRequests": "Усього запитів", + "memoryDebugPanel.requests.tracking": "Відстеження", + "memoryDebugPanel.requests.now": "зараз", + "memoryDebugPanel.requests.noSamples": "Запитів ще немає. Тримайте цю панель відкритою, щоб фіксувати активність fetch.", + "memoryDebugPanel.requests.chartLabel": "Запити fetch у виконанні з часом, пік {peak}", + "memoryDebugPanel.requests.windowHint": "останні {seconds}s", + "memoryDebugPanel.requests.percentileChartLabel": "Перцентилі віку запитів у виконанні (p50, p90, p99, max) з часом", "memoryDebugPanel.common.idle": "очікування", "memoryDebugPanel.common.live": "live", "memoryDebugPanel.common.notAvailable": "n/a", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 1ffd934d..3fd91956 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -2503,6 +2503,7 @@ export const dict: Record = { 'memoryDebugPanel.title': '调试面板', 'memoryDebugPanel.tabs.memory': '内存', 'memoryDebugPanel.tabs.streaming': '流式', + 'memoryDebugPanel.tabs.requests': '请求', 'memoryDebugPanel.section.sessionsInMemory': '内存中的会话', 'memoryDebugPanel.section.uiStreamingMetrics': 'UI 流式指标', 'memoryDebugPanel.section.vscodeBridgeMetrics': 'VS Code 桥接指标', @@ -2540,6 +2541,16 @@ export const dict: Record = { 'memoryDebugPanel.streaming.copy.copied': '流式调试 JSON 已复制', 'memoryDebugPanel.streaming.copy.failed': '复制 JSON 失败', 'memoryDebugPanel.streaming.copy.hint': '复制会导出 UI 与 VS Code 的流式指标 JSON', + 'memoryDebugPanel.requests.inFlight': '进行中', + 'memoryDebugPanel.requests.peak': '峰值', + 'memoryDebugPanel.requests.duration': '时长', + 'memoryDebugPanel.requests.totalRequests': '请求总数', + 'memoryDebugPanel.requests.tracking': '跟踪', + 'memoryDebugPanel.requests.now': '当前', + 'memoryDebugPanel.requests.noSamples': '尚未记录请求。保持此面板打开以记录 fetch 活动。', + 'memoryDebugPanel.requests.chartLabel': '随时间变化的进行中 fetch 请求,峰值 {peak}', + 'memoryDebugPanel.requests.windowHint': '最近 {seconds}秒', + 'memoryDebugPanel.requests.percentileChartLabel': '进行中请求年龄百分位(p50、p90、p99、最大值)随时间的变化', 'memoryDebugPanel.common.idle': '空闲', 'memoryDebugPanel.common.live': '实时', 'memoryDebugPanel.common.notAvailable': '无', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 08d30254..407ed0e1 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -2500,6 +2500,7 @@ export const dict: Record = { 'memoryDebugPanel.title': '偵錯面板', 'memoryDebugPanel.tabs.memory': '記憶體', 'memoryDebugPanel.tabs.streaming': '串流', + 'memoryDebugPanel.tabs.requests': '請求', 'memoryDebugPanel.section.sessionsInMemory': '記憶體中的會話', 'memoryDebugPanel.section.uiStreamingMetrics': 'UI 串流指標', 'memoryDebugPanel.section.vscodeBridgeMetrics': 'VS Code 橋接指標', @@ -2537,6 +2538,16 @@ export const dict: Record = { 'memoryDebugPanel.streaming.copy.copied': '串流偵錯 JSON 已複製', 'memoryDebugPanel.streaming.copy.failed': '複製 JSON 失敗', 'memoryDebugPanel.streaming.copy.hint': '複製會匯出 UI 與 VS Code 的串流指標 JSON', + 'memoryDebugPanel.requests.inFlight': '進行中', + 'memoryDebugPanel.requests.peak': '峰值', + 'memoryDebugPanel.requests.duration': '時長', + 'memoryDebugPanel.requests.totalRequests': '請求總數', + 'memoryDebugPanel.requests.tracking': '追蹤', + 'memoryDebugPanel.requests.now': '目前', + 'memoryDebugPanel.requests.noSamples': '尚未記錄請求。保持此面板開啟以記錄 fetch 活動。', + 'memoryDebugPanel.requests.chartLabel': '隨時間變化的進行中 fetch 請求,峰值 {peak}', + 'memoryDebugPanel.requests.windowHint': '最近 {seconds}秒', + 'memoryDebugPanel.requests.percentileChartLabel': '進行中請求年齡百分位(p50、p90、p99、最大值)隨時間的變化', 'memoryDebugPanel.common.idle': '閒置', 'memoryDebugPanel.common.live': '即時', 'memoryDebugPanel.common.notAvailable': '無', diff --git a/packages/ui/src/stores/utils/requestsInFlight.ts b/packages/ui/src/stores/utils/requestsInFlight.ts new file mode 100644 index 00000000..9b263f93 --- /dev/null +++ b/packages/ui/src/stores/utils/requestsInFlight.ts @@ -0,0 +1,314 @@ +// Tracks every fetch() request as "in flight" from call to promise settle, +// samples two series once per second, and keeps a 5-minute rolling window for +// plotting: +// 1. in-flight request count +// 2. percentile distribution of currently in-flight request ages: p50, p90, +// p99, max (ms since each unsettled fetch started; 0 when nothing is in +// flight) +// Mirrors the streamDebug.ts pattern: collection is gated behind an +// enable/disable toggle (driven by the debug panel), state lives on `window` +// to survive HMR, and the UI polls a serializable snapshot instead of +// subscribing to a store (this is high-frequency debug data, see stores docs). + +const STORAGE_KEY = 'openchamber_requests_in_flight'; +const SAMPLE_INTERVAL_MS = 1000; +const WINDOW_MS = 5 * 60 * 1000; +const MAX_SAMPLES = Math.ceil(WINDOW_MS / SAMPLE_INTERVAL_MS); + +type RequestsInFlightState = { + enabled: boolean; + startedAt: number; + inFlight: number; + peak: number; + totalStarted: number; + totalSettled: number; + samples: number[]; + p50Samples: number[]; + p90Samples: number[]; + p99Samples: number[]; + maxSamples: number[]; + peakAgeMs: number; + inFlightStarts: Map; + sampleCount: number; + lastSampleAt: number | null; + fetchWrapped: boolean; + originalFetch: typeof window.fetch | null; + sampleTimer: number | null; +}; + +export type RequestsInFlightSnapshot = { + enabled: boolean; + startedAt: number | null; + durationMs: number; + inFlight: number; + peak: number; + totalStarted: number; + totalSettled: number; + samples: number[]; + ageP50: number; + ageP90: number; + ageP99: number; + ageMax: number; + peakAgeMs: number; + p50Samples: number[]; + p90Samples: number[]; + p99Samples: number[]; + maxSamples: number[]; + sampleCount: number; + lastSampleAt: number | null; + windowSeconds: number; +}; + +declare global { + interface Window { + __openchamberRequestsInFlight__?: RequestsInFlightState; + } +} + +export const requestsInFlightEnabled = (): boolean => { + if (typeof window === 'undefined') return false; + try { + return window.localStorage.getItem(STORAGE_KEY) === '1'; + } catch { + return false; + } +}; + +const createState = (): RequestsInFlightState => { + const startedAt = Date.now(); + return { + enabled: true, + startedAt, + inFlight: 0, + peak: 0, + totalStarted: 0, + totalSettled: 0, + samples: [], + p50Samples: [], + p90Samples: [], + p99Samples: [], + maxSamples: [], + peakAgeMs: 0, + inFlightStarts: new Map(), + sampleCount: 0, + lastSampleAt: null, + fetchWrapped: false, + originalFetch: null, + sampleTimer: null, + }; +}; + +let nextRequestId = 1; + +const recordStart = (id: number, startMs: number): void => { + const state = window.__openchamberRequestsInFlight__; + if (!state || !state.enabled) return; + state.inFlight += 1; + state.totalStarted += 1; + if (state.inFlight > state.peak) state.peak = state.inFlight; + state.inFlightStarts.set(id, startMs); +}; + +const recordSettle = (id: number): void => { + const state = window.__openchamberRequestsInFlight__; + if (!state || !state.enabled) return; + state.inFlight = Math.max(0, state.inFlight - 1); + state.totalSettled += 1; + state.inFlightStarts.delete(id); +}; + +// Sorted ages (ms) of every currently in-flight request. Empty when nothing +// is in flight. Used both for live snapshot reporting and per-second sampling. +const currentAges = (state: RequestsInFlightState): number[] => { + if (state.inFlightStarts.size === 0) return []; + const now = Date.now(); + const ages: number[] = []; + for (const start of state.inFlightStarts.values()) { + ages.push(Math.max(0, now - start)); + } + ages.sort((a, b) => a - b); + return ages; +}; + +// Linear-interpolation percentile of a pre-sorted array. +const percentile = (sorted: number[], p: number): number => { + const n = sorted.length; + if (n === 0) return 0; + if (n === 1) return sorted[0]; + const rank = (p / 100) * (n - 1); + const lo = Math.floor(rank); + const hi = Math.ceil(rank); + if (lo === hi) return sorted[lo]; + return sorted[lo] + (sorted[hi] - sorted[lo]) * (rank - lo); +}; + +const installFetchTracker = (): void => { + if (typeof window === 'undefined') return; + const state = window.__openchamberRequestsInFlight__; + if (!state || state.fetchWrapped) return; + const original = window.fetch.bind(window); + state.originalFetch = original; + state.fetchWrapped = true; + const tracker = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const id = nextRequestId++; + recordStart(id, Date.now()); + try { + return await original(input, init); + } finally { + recordSettle(id); + } + }; + window.fetch = tracker as typeof window.fetch; +}; + +const uninstallFetchTracker = (): void => { + if (typeof window === 'undefined') return; + const state = window.__openchamberRequestsInFlight__; + if (!state || !state.fetchWrapped || !state.originalFetch) return; + window.fetch = state.originalFetch; + state.fetchWrapped = false; + state.originalFetch = null; +}; + +const trimSamples = (arr: number[]): void => { + if (arr.length > MAX_SAMPLES) arr.splice(0, arr.length - MAX_SAMPLES); +}; + +const pushSample = (): void => { + const state = window.__openchamberRequestsInFlight__; + if (!state || !state.enabled) return; + state.samples.push(state.inFlight); + const ages = currentAges(state); + const mx = ages.length > 0 ? ages[ages.length - 1] : 0; + state.p50Samples.push(percentile(ages, 50)); + state.p90Samples.push(percentile(ages, 90)); + state.p99Samples.push(percentile(ages, 99)); + state.maxSamples.push(mx); + if (mx > state.peakAgeMs) state.peakAgeMs = mx; + state.sampleCount += 1; + trimSamples(state.samples); + trimSamples(state.p50Samples); + trimSamples(state.p90Samples); + trimSamples(state.p99Samples); + trimSamples(state.maxSamples); + state.lastSampleAt = Date.now(); +}; + +const startSampling = (): void => { + if (typeof window === 'undefined') return; + const state = window.__openchamberRequestsInFlight__; + if (!state || state.sampleTimer != null) return; + state.sampleTimer = window.setInterval(pushSample, SAMPLE_INTERVAL_MS); +}; + +const stopSampling = (): void => { + if (typeof window === 'undefined') return; + const state = window.__openchamberRequestsInFlight__; + if (!state || state.sampleTimer == null) return; + window.clearInterval(state.sampleTimer); + state.sampleTimer = null; +}; + +export const setRequestsInFlightTrackingEnabled = (enabled: boolean): void => { + if (typeof window === 'undefined') return; + + try { + if (enabled) { + // Idempotent: tear down any prior tracking first so a repeated + // enable can never wrap window.fetch twice (which would double-count). + stopSampling(); + uninstallFetchTracker(); + window.localStorage.setItem(STORAGE_KEY, '1'); + window.__openchamberRequestsInFlight__ = createState(); + installFetchTracker(); + startSampling(); + return; + } + + window.localStorage.removeItem(STORAGE_KEY); + stopSampling(); + uninstallFetchTracker(); + delete window.__openchamberRequestsInFlight__; + } catch { + // ignore storage failures in debug helper + } +}; + +export const resetRequestsInFlight = (): void => { + if (typeof window === 'undefined') return; + const state = window.__openchamberRequestsInFlight__; + if (!state) return; + const fresh = createState(); + state.startedAt = fresh.startedAt; + state.inFlight = fresh.inFlight; + state.peak = fresh.peak; + state.totalStarted = fresh.totalStarted; + state.totalSettled = fresh.totalSettled; + state.samples = fresh.samples; + state.p50Samples = fresh.p50Samples; + state.p90Samples = fresh.p90Samples; + state.p99Samples = fresh.p99Samples; + state.maxSamples = fresh.maxSamples; + state.peakAgeMs = fresh.peakAgeMs; + state.inFlightStarts = fresh.inFlightStarts; + state.sampleCount = fresh.sampleCount; + state.lastSampleAt = fresh.lastSampleAt; +}; + +export const getRequestsInFlightSnapshot = (): RequestsInFlightSnapshot => { + if (typeof window === 'undefined') { + return emptySnapshot(); + } + + const state = window.__openchamberRequestsInFlight__; + if (!requestsInFlightEnabled() || !state) { + return emptySnapshot(); + } + + const ages = currentAges(state); + return { + enabled: true, + startedAt: state.startedAt, + durationMs: Math.max(0, Date.now() - state.startedAt), + inFlight: state.inFlight, + peak: state.peak, + totalStarted: state.totalStarted, + totalSettled: state.totalSettled, + samples: state.samples.slice(), + ageP50: percentile(ages, 50), + ageP90: percentile(ages, 90), + ageP99: percentile(ages, 99), + ageMax: ages.length > 0 ? ages[ages.length - 1] : 0, + peakAgeMs: state.peakAgeMs, + p50Samples: state.p50Samples.slice(), + p90Samples: state.p90Samples.slice(), + p99Samples: state.p99Samples.slice(), + maxSamples: state.maxSamples.slice(), + sampleCount: state.sampleCount, + lastSampleAt: state.lastSampleAt, + windowSeconds: MAX_SAMPLES, + }; +}; + +const emptySnapshot = (): RequestsInFlightSnapshot => ({ + enabled: false, + startedAt: null, + durationMs: 0, + inFlight: 0, + peak: 0, + totalStarted: 0, + totalSettled: 0, + samples: [], + ageP50: 0, + ageP90: 0, + ageP99: 0, + ageMax: 0, + peakAgeMs: 0, + p50Samples: [], + p90Samples: [], + p99Samples: [], + maxSamples: [], + sampleCount: 0, + lastSampleAt: null, + windowSeconds: MAX_SAMPLES, +}); From 94e4bce990c888c401725dabaa558b9825564927 Mon Sep 17 00:00:00 2001 From: "PC2\\micha" Date: Sun, 28 Jun 2026 20:07:39 +0800 Subject: [PATCH 004/299] fix: kill process tree on Windows via taskkill before SIGTERM fallback On Windows, child.kill('SIGTERM') only terminates the cmd.exe wrapper, leaving the inner opencode.exe serve running as an orphan. This adds killProcessTree() which runs taskkill /PID /T /F first, then falls back to child.kill('SIGTERM') for the close() method. Fixes #1889 --- packages/vscode/src/opencode.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/vscode/src/opencode.ts b/packages/vscode/src/opencode.ts index 540a9aba..95f7f239 100644 --- a/packages/vscode/src/opencode.ts +++ b/packages/vscode/src/opencode.ts @@ -133,6 +133,19 @@ function shouldUseWindowsShell(binary: string): boolean { return !ext && !trimmed.includes('\\') && !trimmed.includes('/'); } +function killProcessTree(pid: number | undefined): void { + if (!Number.isInteger(pid)) return; + if (process.platform === 'win32') { + try { + spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { + stdio: 'ignore', timeout: 5000, windowsHide: true, + }); + } catch { + // ignore + } + } +} + function appendToPath(dir: string) { const trimmed = (dir || '').trim(); if (!trimmed) return; @@ -695,6 +708,7 @@ async function spawnManagedOpenCodeServer( return { url, close: () => { + killProcessTree(child.pid); try { child.kill('SIGTERM'); } catch { From 224a948693febbf977ac87878d27d0c3d2a4e6ae Mon Sep 17 00:00:00 2001 From: Divyam <47589864+divyam234@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:41:07 +0530 Subject: [PATCH 005/299] fix: load symlinked custom themes --- .../web/server/lib/opencode/theme-runtime.js | 2 +- .../server/lib/opencode/theme-runtime.test.js | 129 ++++++++++++++++++ 2 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 packages/web/server/lib/opencode/theme-runtime.test.js diff --git a/packages/web/server/lib/opencode/theme-runtime.js b/packages/web/server/lib/opencode/theme-runtime.js index df2639e3..2655f826 100644 --- a/packages/web/server/lib/opencode/theme-runtime.js +++ b/packages/web/server/lib/opencode/theme-runtime.js @@ -116,7 +116,7 @@ export const createThemeRuntime = (dependencies) => { const seen = new Set(); for (const entry of entries) { - if (!entry.isFile()) continue; + if (!entry.isFile() && !entry.isSymbolicLink()) continue; if (!entry.name.toLowerCase().endsWith('.json')) continue; const filePath = path.join(themesDir, entry.name); diff --git a/packages/web/server/lib/opencode/theme-runtime.test.js b/packages/web/server/lib/opencode/theme-runtime.test.js new file mode 100644 index 00000000..38316a14 --- /dev/null +++ b/packages/web/server/lib/opencode/theme-runtime.test.js @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest'; + +import { createThemeRuntime } from './theme-runtime.js'; + +const validTheme = (id = 'custom-theme') => ({ + metadata: { + id, + name: 'Custom Theme', + variant: 'dark', + }, + colors: { + primary: { + base: '#ffffff', + foreground: '#000000', + }, + surface: { + background: '#000000', + foreground: '#ffffff', + muted: '#111111', + mutedForeground: '#eeeeee', + elevated: '#222222', + elevatedForeground: '#dddddd', + subtle: '#333333', + }, + interactive: { + border: '#444444', + selection: '#555555', + selectionForeground: '#ffffff', + focusRing: '#666666', + hover: '#777777', + }, + status: { + error: '#ff0000', + errorForeground: '#ffffff', + errorBackground: '#330000', + errorBorder: '#660000', + warning: '#ffaa00', + warningForeground: '#000000', + warningBackground: '#332200', + warningBorder: '#664400', + success: '#00ff00', + successForeground: '#000000', + successBackground: '#003300', + successBorder: '#006600', + info: '#0000ff', + infoForeground: '#ffffff', + infoBackground: '#000033', + infoBorder: '#000066', + }, + syntax: { + base: { + background: '#000000', + foreground: '#ffffff', + keyword: '#ff00ff', + string: '#00ff00', + number: '#ffaa00', + function: '#00ffff', + variable: '#ffffff', + type: '#ffff00', + comment: '#888888', + operator: '#ffffff', + }, + highlights: { + diffAdded: '#003300', + diffRemoved: '#330000', + lineNumber: '#888888', + }, + }, + }, +}); + +const fileEntry = (name, type = 'file') => ({ + name, + isFile: () => type === 'file', + isDirectory: () => type === 'directory', + isSymbolicLink: () => type === 'symlink', +}); + +const createTestRuntime = ({ entries, files, stats }) => createThemeRuntime({ + fsPromises: { + readdir: async () => entries, + stat: async (filePath) => stats[filePath], + readFile: async (filePath) => files[filePath], + }, + path: { join: (...parts) => parts.join('/') }, + themesDir: '/themes', + maxThemeJsonBytes: 512 * 1024, + logger: { warn: () => {} }, +}); + +describe('theme runtime', () => { + describe('readCustomThemesFromDisk', () => { + it('loads valid theme files', async () => { + const runtime = createTestRuntime({ + entries: [fileEntry('direct.json')], + files: { '/themes/direct.json': JSON.stringify(validTheme('direct-theme')) }, + stats: { '/themes/direct.json': { isFile: () => true, size: 1024 } }, + }); + + const themes = await runtime.readCustomThemesFromDisk(); + + expect(themes.map((theme) => theme.metadata.id)).toEqual(['direct-theme']); + }); + + it('loads JSON themes whose directory entry is a symbolic link', async () => { + const runtime = createTestRuntime({ + entries: [fileEntry('linked.json', 'symlink')], + files: { '/themes/linked.json': JSON.stringify(validTheme('linked-theme')) }, + stats: { '/themes/linked.json': { isFile: () => true, size: 1024 } }, + }); + + const themes = await runtime.readCustomThemesFromDisk(); + + expect(themes.map((theme) => theme.metadata.id)).toEqual(['linked-theme']); + }); + + it('skips JSON directories after stat resolution', async () => { + const runtime = createTestRuntime({ + entries: [fileEntry('directory.json', 'directory')], + files: { '/themes/directory.json': JSON.stringify(validTheme('directory-theme')) }, + stats: { '/themes/directory.json': { isFile: () => false, size: 1024 } }, + }); + + const themes = await runtime.readCustomThemesFromDisk(); + + expect(themes).toEqual([]); + }); + }); +}); From 72a24c388f6dbb6eb4ccc93619edf91b4dcf9c88 Mon Sep 17 00:00:00 2001 From: Brian Ketelsen Date: Tue, 30 Jun 2026 23:26:51 -0400 Subject: [PATCH 006/299] fix(pwa): focus existing window on notification click The service worker's notificationclick handler called self.clients.openWindow(url) unconditionally, spawning a new window/PWA instance on every notification click even when one was already open. Focus an existing window client and navigate it to the (relative) deep-link, resolved against self.location.origin, falling back to openWindow only when no window is available. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/web/src/sw.ts | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/web/src/sw.ts b/packages/web/src/sw.ts index 7ed2a8cc..3583d41b 100644 --- a/packages/web/src/sw.ts +++ b/packages/web/src/sw.ts @@ -67,5 +67,30 @@ self.addEventListener('notificationclick', (event) => { const data = (event.notification.data ?? null) as { url?: string } | null; const url = data?.url ?? '/'; - event.waitUntil(self.clients.openWindow(url)); + event.waitUntil((async () => { + // Prefer focusing an already-open window (e.g. the installed PWA) and + // navigating it to the target, instead of always spawning a new window. + const target = new URL(url, self.location.origin).href; + const windowClients = await self.clients.matchAll({ + type: 'window', + includeUncontrolled: true, + }); + + for (const client of windowClients) { + try { + if ('navigate' in client) { + await client.navigate(target); + } + } catch { + // navigate() can reject for uncontrolled clients; fall back to focus. + } + if ('focus' in client) { + return client.focus(); + } + } + + if (self.clients.openWindow) { + return self.clients.openWindow(target); + } + })()); }); From adb6ca2b08686fe043a325643c71cec38845a6ab Mon Sep 17 00:00:00 2001 From: Tang <1024830255@qq.com> Date: Sat, 4 Jul 2026 10:32:19 +0800 Subject: [PATCH 007/299] fix(cli): externalize Windows startup PowerShell to .ps1 wrapper (schtasks /TR 261-char limit) The inline PowerShell env-parsing script exceeded the Task Scheduler /TR 261-char limit, causing startup enable to fail on Windows. Extract the script into a .ps1 wrapper file and reduce /TR to a short powershell.exe -File command (~115 chars). Mirrors the macOS writeMacosStartupWrapper pattern. Adds regression tests pinning /TR < 200 (default) and < 261 (worst-case). Apply fix to refactored lib/cli-startup.js (was cli.js before refactor). --- packages/web/bin/cli.test.js | 35 ++++++++++++++++++++++++++ packages/web/bin/lib/cli-startup.js | 38 ++++++++++++++++++++++------- 2 files changed, 64 insertions(+), 9 deletions(-) diff --git a/packages/web/bin/cli.test.js b/packages/web/bin/cli.test.js index 9faa2100..ff7e291d 100644 --- a/packages/web/bin/cli.test.js +++ b/packages/web/bin/cli.test.js @@ -30,6 +30,7 @@ import { parseArgs, resolveServeHost, } from './cli.js'; +import { buildWindowsStartupTaskCommand } from './lib/cli-startup.js'; async function withTempOpenChamberDataDir(fn) { const previous = process.env.OPENCHAMBER_DATA_DIR; @@ -884,3 +885,37 @@ describe('lifecycle commands with unmanaged explicit ports', () => { }); }); }); + +describe('Windows startup task command builder', () => { + it('default-path length stays under 200 chars', () => { + const cmd = buildWindowsStartupTaskCommand( + 'C:\\Users\\test\\.config\\openchamber\\bin\\OpenChamber.ps1' + ); + expect(cmd).toMatch(/^powershell\.exe -NoProfile -ExecutionPolicy Bypass -File /); + expect(cmd.length).toBeLessThan(200); + }); + + it('worst-case long path stays under 261-char Task Scheduler ceiling', () => { + // Build a wrapper path >= 180 chars (simulates long OPENCHAMBER_DATA_DIR) + // Overhead = 57 chars (prefix + closing quote), so max wrapper for <261 total is 203 + const longPath = + 'C:\\Users\\' + + 'a'.repeat(139) + + '\\.config\\openchamber\\bin\\OpenChamber.ps1'; + expect(longPath.length).toBeGreaterThanOrEqual(180); + + const cmd = buildWindowsStartupTaskCommand(longPath); + expect(cmd.length).toBeLessThan(261); + }); + + it('does NOT inline SetEnvironmentVariable (externalization invariant)', () => { + const cmd = buildWindowsStartupTaskCommand('C:\\wrapper.ps1'); + expect(cmd).not.toContain('SetEnvironmentVariable'); + }); + + it('uses -File form, not -Command', () => { + const cmd = buildWindowsStartupTaskCommand('C:\\wrapper.ps1'); + expect(cmd).toContain('-File '); + expect(cmd).not.toContain('-Command '); + }); +}); diff --git a/packages/web/bin/lib/cli-startup.js b/packages/web/bin/lib/cli-startup.js index 8a2df874..a7a5f929 100644 --- a/packages/web/bin/lib/cli-startup.js +++ b/packages/web/bin/lib/cli-startup.js @@ -74,6 +74,10 @@ function getMacosStartupWrapperPath() { return path.join(getDataDir(), 'bin', 'OpenChamber'); } +function getWindowsStartupWrapperPath() { + return path.join(getDataDir(), 'bin', 'OpenChamber.ps1'); +} + function collectStartupEnv(options = {}) { const env = options.envSnapshot === false ? {} : Object.fromEntries( Object.entries(process.env) @@ -189,6 +193,24 @@ exec ${startupShellQuote(process.execPath)} ${args} return wrapperPath; } +function writeWindowsStartupWrapper(options = {}) { + const wrapperPath = getWindowsStartupWrapperPath(); + const envFilePath = getStartupEnvFilePath(); + const startupArgs = buildStartupArgs(options).map(powershellQuote).join(' '); + const ps1Content = [ + `$envFile=${powershellQuote(envFilePath)}`, + `if (Test-Path $envFile) { Get-Content $envFile | ForEach-Object { if ($_ -match '^([^=]+)=(.*)$') { $v=$matches[2]; if ($v.StartsWith("'") -and $v.EndsWith("'")) { $v=$v.Substring(1,$v.Length-2).Replace("'\\''","'") }; [Environment]::SetEnvironmentVariable($matches[1], $v, 'Process') } } }`, + `& ${powershellQuote(process.execPath)} ${startupArgs}`, + ].join('; '); + fs.mkdirSync(path.dirname(wrapperPath), { recursive: true, mode: 0o700 }); + fs.writeFileSync(wrapperPath, ps1Content, { mode: 0o700 }); + return wrapperPath; +} + +function buildWindowsStartupTaskCommand(wrapperPath) { + return `powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${wrapperPath}"`; +} + function buildMacosLaunchAgent(options = {}) { const wrapperPath = writeMacosStartupWrapper(options); const args = [wrapperPath]; @@ -318,21 +340,16 @@ function enableStartupService(options = {}) { return getStartupStatus(); } - const envFilePath = writeStartupEnvFile(options); - const startupArgs = buildStartupArgs(options).map(powershellQuote).join(', '); - const powerShellCommand = [ - `$envFile=${powershellQuote(envFilePath)}`, - `if (Test-Path $envFile) { Get-Content $envFile | ForEach-Object { if ($_ -match '^([^=]+)=(.*)$') { $v=$matches[2]; if ($v.StartsWith("'") -and $v.EndsWith("'")) { $v=$v.Substring(1,$v.Length-2).Replace("'\\''","'") }; [Environment]::SetEnvironmentVariable($matches[1], $v, 'Process') } } }`, - `& ${powershellQuote(process.execPath)} ${startupArgs}`, - ].join('; '); - const taskArgs = `powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "${powerShellCommand.replace(/"/g, '\\"')}"`; + writeStartupEnvFile(options); + const wrapperPath = writeWindowsStartupWrapper(options); + const taskCommand = buildWindowsStartupTaskCommand(wrapperPath); runStartupCommand('schtasks.exe', [ '/Create', '/TN', STARTUP_SERVICE_ID, '/SC', 'ONLOGON', '/RL', 'LIMITED', '/F', - '/TR', taskArgs, + '/TR', taskCommand, ]); runStartupCommand('schtasks.exe', ['/Run', '/TN', STARTUP_SERVICE_ID], { allowFailure: true }); return getStartupStatus(); @@ -359,6 +376,8 @@ function disableStartupService() { runStartupCommand('schtasks.exe', ['/End', '/TN', STARTUP_SERVICE_ID], { allowFailure: true }); runStartupCommand('schtasks.exe', ['/Delete', '/TN', STARTUP_SERVICE_ID, '/F'], { allowFailure: true }); + try { fs.unlinkSync(getWindowsStartupWrapperPath()); } catch {} + removeStartupEnvFile(); return getStartupStatus(); } @@ -367,4 +386,5 @@ export { getStartupStatus, enableStartupService, disableStartupService, + buildWindowsStartupTaskCommand, }; From 83d4bc7b59c015e672eac63c2e0f3a145cb461c1 Mon Sep 17 00:00:00 2001 From: herjarsa Date: Thu, 9 Jul 2026 21:55:57 +0200 Subject: [PATCH 008/299] fix(chat): save previous-session anchor in microtask with bail check When switching sessions, the previous session's viewport anchor save was deferred via setTimeout(..., 0). This races with the new session's restoreSnapshot effect: the timer can fire after React has flushed the new session's render and before the restore effect runs, leaving the saved anchor and the restored scroll position fighting over the same viewport store entry. The save reads messages (can be expensive) on the same tick as the new session's skeleton render. Replace setTimeout(..., 0) with queueMicrotask() so the save runs immediately after the current synchronous call stack and before the next macrotask / paint. This guarantees the save completes before the new session's restoreSnapshot effect fires. Add a bail check: if the user switched sessions again between the microtask scheduling and execution (rapid switching), the save is now stale. Comparing the captured newId to the current currentSessionId at microtask runtime avoids clobbering the in-flight session's anchor with data from a session that is no longer "previous". This is the queueMicrotask + bail change acknowledged as 'great' in the review of #1675, extracted as a focused single-file PR. --- packages/ui/src/sync/session-ui-store.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index 397fb484..154ae984 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -617,7 +617,16 @@ export const useSessionUIStore = create()((set, get) => ({ // skeleton to render and reads messages which can be expensive. if (previousSessionId && previousSessionId !== id) { const prevId = previousSessionId - setTimeout(() => { + const newId = id + // queueMicrotask runs after the current synchronous call stack (and + // before the next macrotask / setTimeout(0) / paint), so the previous + // session's anchor is saved before the new session's restoreSnapshot + // effect fires. This eliminates the race where save and restore + // interleave against the same viewport store entry. + queueMicrotask(() => { + // Bail if the user already switched again — save is now stale. + const current = get().currentSessionId + if (current !== newId) return const memState = getViewportSessionMemory(prevId) if (!memState?.isStreaming) { const prevMessages = getSyncMessages(prevId) @@ -625,7 +634,7 @@ export const useSessionUIStore = create()((set, get) => ({ useViewportStore.getState().updateViewportAnchor(prevId, prevMessages.length - 1) } } - }, 0) + }); } // Mark session viewed in notification store + update active session ref From 128122fdd92bbd02d4eb26fb07648d1a2caa3365 Mon Sep 17 00:00:00 2001 From: Greg Haynes Date: Sun, 12 Jul 2026 19:54:35 -0700 Subject: [PATCH 009/299] fix(web): shorten PWA install app name --- packages/web/index.html | 2 +- packages/web/public/site.webmanifest | 2 +- packages/web/server/lib/opencode/pwa-manifest-routes.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/web/index.html b/packages/web/index.html index 2434decd..9ee0b466 100644 --- a/packages/web/index.html +++ b/packages/web/index.html @@ -24,7 +24,7 @@