From 1ad470cead5f2bce109c222afecb4249fb8c1594 Mon Sep 17 00:00:00 2001 From: Leonid Skorobogatyy Date: Mon, 22 Jun 2026 02:09:50 +1100 Subject: [PATCH 0001/1073] 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 0002/1073] 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 0003/1073] 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 3d27c0a0cad022657ba3ebf1cf5cf6da75b1895f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 26 Jun 2026 11:26:11 +0000 Subject: [PATCH 0004/1073] perf(build): fix bun .bun chunk parsing and isolate Vite preload helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The manualChunks package-name parser used the first node_modules/ segment, which under bun's isolated install (.bun/@/node_modules/) is always '.bun'. This collapsed nearly every dependency — including lazy-only heavy libs (Shiki langs/themes, transformers, katex, mermaid, elkjs, etc.) — into one 18.5 MB eager 'vendor-.bun' chunk, defeating all code splitting. - Resolve the real package from the LAST node_modules/ segment. - Pin Vite's __vitePreload helper to its own chunk so Rollup stops co-locating it inside vendor-shiki and dragging Shiki core + the 629 KB oniguruma engine into the eager bootstrap. - Drop the now-dead react-syntax-highlighter chunk rule. Eager bootstrap graph drops from ~18.5 MB to ~0.44 MB. Co-authored-by: Serhii Dziupin --- packages/web/vite.config.ts | 17 +++++++++++++++-- vite.config.ts | 17 +++++++++++++++-- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/packages/web/vite.config.ts b/packages/web/vite.config.ts index 553a892b..3fcabff7 100644 --- a/packages/web/vite.config.ts +++ b/packages/web/vite.config.ts @@ -109,9 +109,23 @@ export default defineConfig({ external: ['node:child_process', 'node:fs', 'node:path', 'node:url'], output: { manualChunks(id) { + // Pin Vite's tiny runtime helpers to their own stable chunk. Otherwise + // Rollup co-locates the `__vitePreload` helper into an arbitrary vendor + // chunk (e.g. `shiki`), and since every dynamic import pulls the helper, + // that whole vendor (here Shiki core + the 629KB oniguruma engine) gets + // dragged into the eager bootstrap graph. + if (id.includes('vite/preload-helper') || id.includes('vite/modulepreload-polyfill')) { + return 'vendor-vite-runtime'; + } if (!id.includes('node_modules')) return undefined; - const match = id.split('node_modules/')[1]; + // Resolve the real package from the LAST `node_modules/` segment. + // bun's isolated install nests packages as + // `node_modules/.bun/@/node_modules//...`, so the first + // `node_modules/` segment is `.bun` — using it collapses every dependency + // (incl. lazy-only ones) into a single giant eager `vendor-.bun` chunk. + const lastNodeModules = id.lastIndexOf('node_modules/'); + const match = id.slice(lastNodeModules + 'node_modules/'.length); if (!match) return undefined; const segments = match.split('/'); @@ -123,7 +137,6 @@ export default defineConfig({ if (packageName === '@opencode-ai/sdk') return 'vendor-opencode-sdk'; if (packageName.includes('remark') || packageName.includes('rehype') || packageName === 'react-markdown') return 'vendor-markdown'; if (packageName === '@base-ui/react' || packageName.startsWith('@base-ui')) return 'vendor-base-ui'; - if (packageName.includes('react-syntax-highlighter') || packageName.includes('highlight.js')) return 'vendor-syntax'; const sanitized = packageName.replace(/^@/, '').replace(/\//g, '-'); return `vendor-${sanitized}`; diff --git a/vite.config.ts b/vite.config.ts index 5f6cb03c..ae9721f4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -33,9 +33,23 @@ export default defineConfig({ external: ['node:child_process', 'node:fs', 'node:path', 'node:url'], output: { manualChunks(id) { + // Pin Vite's tiny runtime helpers to their own stable chunk. Otherwise + // Rollup co-locates the `__vitePreload` helper into an arbitrary vendor + // chunk (e.g. `shiki`), and since every dynamic import pulls the helper, + // that whole vendor (here Shiki core + the 629KB oniguruma engine) gets + // dragged into the eager bootstrap graph. + if (id.includes('vite/preload-helper') || id.includes('vite/modulepreload-polyfill')) { + return 'vendor-vite-runtime' + } if (!id.includes('node_modules')) return undefined - const match = id.split('node_modules/')[1] + // Resolve the real package from the LAST `node_modules/` segment. + // bun's isolated install nests packages as + // `node_modules/.bun/@/node_modules//...`, so the first + // `node_modules/` segment is `.bun` — using it collapses every dependency + // (incl. lazy-only ones) into a single giant eager `vendor-.bun` chunk. + const lastNodeModules = id.lastIndexOf('node_modules/') + const match = id.slice(lastNodeModules + 'node_modules/'.length) if (!match) return undefined const segments = match.split('/') @@ -46,7 +60,6 @@ export default defineConfig({ if (packageName === '@opencode-ai/sdk') return 'vendor-opencode-sdk' if (packageName.includes('remark') || packageName.includes('rehype') || packageName === 'react-markdown') return 'vendor-markdown' if (packageName === '@base-ui/react' || packageName.startsWith('@base-ui')) return 'vendor-base-ui' - if (packageName.includes('react-syntax-highlighter') || packageName.includes('highlight.js')) return 'vendor-syntax' const sanitized = packageName.replace(/^@/, '').replace(/\//g, '-') return `vendor-${sanitized}` From 05dd270a9a9f5e53170592ab72f0d24a5765d774 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 26 Jun 2026 11:26:11 +0000 Subject: [PATCH 0005/1073] perf(ui): lazy-load html-to-image and snapDOM Both are only needed on explicit user actions (export message as image, capture preview annotation screenshot). Defer them with dynamic import so ~140 KB leaves the eager app-shell graph. Co-authored-by: Serhii Dziupin --- packages/ui/src/components/chat/message/MessageBody.tsx | 4 +++- packages/ui/src/lib/preview/screenshot-capture.ts | 7 +++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index e2e36000..4bfcf251 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -33,7 +33,6 @@ import { copyTextToClipboard } from '@/lib/clipboard'; import { useChatSurfaceMode } from '@/components/chat/useChatSurfaceMode'; import { isVSCodeRuntime } from '@/lib/desktop'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; -import { toPng } from 'html-to-image'; import { toast } from '@/components/ui'; import { Icon } from "@/components/icon/Icon"; import { formatTimestampForDisplay } from './timeFormat'; @@ -1455,6 +1454,9 @@ const AssistantMessageBody = React.memo(({ wrapper.appendChild(clone); document.body.appendChild(wrapper); + // Lazy-load html-to-image: only needed when the user exports a + // message as an image, so keep it out of the eager app shell. + const { toPng } = await import('html-to-image'); const dataUrl = await toPng(wrapper, { quality: 1, pixelRatio: 2, diff --git a/packages/ui/src/lib/preview/screenshot-capture.ts b/packages/ui/src/lib/preview/screenshot-capture.ts index a587a943..28611fef 100644 --- a/packages/ui/src/lib/preview/screenshot-capture.ts +++ b/packages/ui/src/lib/preview/screenshot-capture.ts @@ -1,5 +1,3 @@ -import { snapdom } from '@zumer/snapdom'; -import { getFontEmbedCSS, toJpeg } from 'html-to-image'; import { invokeDesktop } from '@/lib/desktop'; import { runtimeFetch } from '@/lib/runtime-fetch'; @@ -825,6 +823,9 @@ async function captureIframeSnapdomScreenshot( // Defensive: undo any nested-scroll drift from asset inlining before capture. nestedScroll.reapply(); + // Lazy-load snapDOM: only needed when actually capturing a preview + // annotation screenshot, so keep it out of the eager app shell. + const { snapdom } = await import('@zumer/snapdom'); const snapdomOptions = { backgroundColor: getCaptureBackgroundColor(document), cache: 'disabled' as const, @@ -924,6 +925,8 @@ async function captureIframeDomScreenshot( await document.fonts?.ready.catch(() => undefined); restoreAssets = await inlineIframeCaptureAssets(document, viewportWidth, viewportHeight, { applyLayoutWorkarounds: true }); frameWindow.scrollTo(scrollX, scrollY); + // Lazy-load html-to-image: only the fallback DOM-capture path needs it. + const { getFontEmbedCSS, toJpeg } = await import('html-to-image'); const fontEmbedCSS = await getFontEmbedCSS(root).catch(() => ''); dataUrl = await toJpeg(root, { From f1721199215ad7a3b27e92d8e6efffb9f497a4fc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 26 Jun 2026 11:26:11 +0000 Subject: [PATCH 0006/1073] chore(deps): remove unused react-syntax-highlighter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit react-syntax-highlighter (and its @types) is no longer imported anywhere — code highlighting is handled by the Shiki web worker. Drop the dead deps. Co-authored-by: Serhii Dziupin --- bun.lock | 94 +++++++++------------------------------------------- package.json | 2 -- 2 files changed, 16 insertions(+), 80 deletions(-) diff --git a/bun.lock b/bun.lock index 506dcb99..77837bf1 100644 --- a/bun.lock +++ b/bun.lock @@ -43,7 +43,6 @@ "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-tooltip": "^1.2.8", - "@types/react-syntax-highlighter": "^15.5.13", "@xenova/transformers": "^2.17.2", "@zumer/snapdom": "^2.12.8", "bun-pty": "^0.4.5", @@ -58,7 +57,6 @@ "react": "^19.1.1", "react-dom": "^19.1.1", "react-markdown": "^10.1.0", - "react-syntax-highlighter": "^15.6.6", "remark-gfm": "^4.0.1", "simple-git": "^3.28.0", "sonner": "^2.0.7", @@ -99,7 +97,7 @@ }, "packages/electron": { "name": "@openchamber/electron", - "version": "1.13.2", + "version": "1.13.3", "dependencies": { "@openchamber/web": "workspace:*", "electron-context-menu": "^4.1.2", @@ -114,7 +112,7 @@ }, "packages/ui": { "name": "@openchamber/ui", - "version": "1.13.2", + "version": "1.13.3", "dependencies": { "@base-ui/react": "^1.4.0", "@codemirror/autocomplete": "^6.20.0", @@ -214,7 +212,7 @@ }, "packages/vscode": { "name": "openchamber", - "version": "1.13.2", + "version": "1.13.3", "dependencies": { "@openchamber/ui": "workspace:*", "@opencode-ai/sdk": "^1.17.9", @@ -237,7 +235,7 @@ }, "packages/web": { "name": "@openchamber/web", - "version": "1.13.2", + "version": "1.13.3", "bin": { "openchamber": "./bin/cli.js", }, @@ -1321,8 +1319,6 @@ "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], - "@types/react-syntax-highlighter": ["@types/react-syntax-highlighter@15.5.13", "", { "dependencies": { "@types/react": "*" } }, "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA=="], - "@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="], "@types/responselike": ["@types/responselike@1.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="], @@ -1585,13 +1581,13 @@ "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "character-entities": ["character-entities@1.2.4", "", {}, "sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw=="], + "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], - "character-entities-legacy": ["character-entities-legacy@1.1.4", "", {}, "sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA=="], + "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], - "character-reference-invalid": ["character-reference-invalid@1.1.4", "", {}, "sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg=="], + "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], "cheerio": ["cheerio@1.2.0", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "encoding-sniffer": "^0.2.1", "htmlparser2": "^10.1.0", "parse5": "^7.3.0", "parse5-htmlparser2-tree-adapter": "^7.1.0", "parse5-parser-stream": "^7.1.2", "undici": "^7.19.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg=="], @@ -1925,8 +1921,6 @@ "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], - "fault": ["fault@1.0.4", "", { "dependencies": { "format": "^0.2.0" } }, "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA=="], - "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], @@ -1961,8 +1955,6 @@ "form-data-encoder": ["form-data-encoder@1.7.2", "", {}, "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="], - "format": ["format@0.2.2", "", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="], - "formdata-node": ["formdata-node@4.4.1", "", { "dependencies": { "node-domexception": "1.0.0", "web-streams-polyfill": "4.0.0-beta.3" } }, "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ=="], "formidable": ["formidable@3.5.4", "", { "dependencies": { "@paralleldrive/cuid2": "^2.2.2", "dezalgo": "^1.0.4", "once": "^1.4.0" } }, "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug=="], @@ -2063,7 +2055,7 @@ "hast-util-is-element": ["hast-util-is-element@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g=="], - "hast-util-parse-selector": ["hast-util-parse-selector@2.2.5", "", {}, "sha512-7j6mrk/qqkSehsM92wQjdIgWM2/BW61u/53G6xmC8i1OmEdKLHbk419QKQUjz6LglWsfqoiHmyMRkP1BGjecNQ=="], + "hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="], "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="], @@ -2073,14 +2065,10 @@ "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], - "hastscript": ["hastscript@6.0.0", "", { "dependencies": { "@types/hast": "^2.0.0", "comma-separated-tokens": "^1.0.0", "hast-util-parse-selector": "^2.0.0", "property-information": "^5.0.0", "space-separated-tokens": "^1.0.0" } }, "sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w=="], + "hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], "heic2any": ["heic2any@0.0.4", "", {}, "sha512-3lLnZiDELfabVH87htnRolZ2iehX9zwpRyGNz22GKXIu0fznlblf0/ftppXKNqS26dqFSeqfIBhAmAj/uSp0cA=="], - "highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="], - - "highlightjs-vue": ["highlightjs-vue@1.0.0", "", {}, "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA=="], - "hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="], "html-to-image": ["html-to-image@1.11.13", "", {}, "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg=="], @@ -2147,9 +2135,9 @@ "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], - "is-alphabetical": ["is-alphabetical@1.0.4", "", {}, "sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg=="], + "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], - "is-alphanumerical": ["is-alphanumerical@1.0.4", "", { "dependencies": { "is-alphabetical": "^1.0.0", "is-decimal": "^1.0.0" } }, "sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A=="], + "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], "is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="], @@ -2171,7 +2159,7 @@ "is-date-object": ["is-date-object@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg=="], - "is-decimal": ["is-decimal@1.0.4", "", {}, "sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw=="], + "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], "is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], @@ -2185,7 +2173,7 @@ "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], - "is-hexadecimal": ["is-hexadecimal@1.0.4", "", {}, "sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw=="], + "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], @@ -2369,8 +2357,6 @@ "lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="], - "lowlight": ["lowlight@1.20.0", "", { "dependencies": { "fault": "^1.0.0", "highlight.js": "~10.7.0" } }, "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw=="], - "lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], "lru_map": ["lru_map@0.4.1", "", {}, "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg=="], @@ -2635,7 +2621,7 @@ "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], - "parse-entities": ["parse-entities@2.0.0", "", { "dependencies": { "character-entities": "^1.0.0", "character-entities-legacy": "^1.0.0", "character-reference-invalid": "^1.0.0", "is-alphanumerical": "^1.0.0", "is-decimal": "^1.0.0", "is-hexadecimal": "^1.0.0" } }, "sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ=="], + "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], "parse-json": ["parse-json@8.3.0", "", { "dependencies": { "@babel/code-frame": "^7.26.2", "index-to-position": "^1.1.0", "type-fest": "^4.39.1" } }, "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ=="], @@ -2697,8 +2683,6 @@ "pretty-bytes": ["pretty-bytes@6.1.1", "", {}, "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ=="], - "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], - "proc-log": ["proc-log@2.0.1", "", {}, "sha512-Kcmo2FhfDTXdcbfDH76N7uBYHINxc/8GW7UAVuVP9I+Va3uHSerrnKV6dLooga/gh7GlgzuCCr/eoldnL1muGw=="], "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], @@ -2765,8 +2749,6 @@ "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], - "react-syntax-highlighter": ["react-syntax-highlighter@15.6.6", "", { "dependencies": { "@babel/runtime": "^7.3.1", "highlight.js": "^10.4.1", "highlightjs-vue": "^1.0.0", "lowlight": "^1.17.0", "prismjs": "^1.30.0", "refractor": "^3.6.0" }, "peerDependencies": { "react": ">= 0.14.0" } }, "sha512-DgXrc+AZF47+HvAPEmn7Ua/1p10jNoVZVI/LoPiYdtY+OM+/nG5yefLHKJwdKqY1adMuHFbeyBaG9j64ML7vTw=="], - "read": ["read@1.0.7", "", { "dependencies": { "mute-stream": "~0.0.4" } }, "sha512-rSOKNYUmaxy0om1BNjMN4ezNT6VKK+2xF4GBhc81mkH7L60i6dp8qPYrkndNLT3QPphoII3maL9PVC9XmhHwVQ=="], "read-binary-file-arch": ["read-binary-file-arch@1.0.6", "", { "dependencies": { "debug": "^4.3.4" }, "bin": { "read-binary-file-arch": "cli.js" } }, "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg=="], @@ -2781,8 +2763,6 @@ "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], - "refractor": ["refractor@3.6.0", "", { "dependencies": { "hastscript": "^6.0.0", "parse-entities": "^2.0.0", "prismjs": "~1.27.0" } }, "sha512-MY9W41IOWxxk31o+YvFCNyNzdkc9M20NoZK5vq6jkv4I/uh2zkWcfudj0Q1fovjUQJrNewS9NMzeTtqPf+n5EA=="], - "regenerate": ["regenerate@1.4.2", "", {}, "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A=="], "regenerate-unicode-properties": ["regenerate-unicode-properties@10.2.2", "", { "dependencies": { "regenerate": "^1.4.2" } }, "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g=="], @@ -3305,8 +3285,6 @@ "xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], - "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], - "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], @@ -3487,8 +3465,6 @@ "compression/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], - "decode-named-character-reference/character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], - "dmg-builder/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], @@ -3521,18 +3497,6 @@ "globby/slash": ["slash@5.1.0", "", {}, "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg=="], - "hast-util-from-dom/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], - - "hast-util-from-parse5/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], - - "hastscript/@types/hast": ["@types/hast@2.3.10", "", { "dependencies": { "@types/unist": "^2" } }, "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw=="], - - "hastscript/comma-separated-tokens": ["comma-separated-tokens@1.0.8", "", {}, "sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw=="], - - "hastscript/property-information": ["property-information@5.6.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA=="], - - "hastscript/space-separated-tokens": ["space-separated-tokens@1.1.5", "", {}, "sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA=="], - "iconv-corefoundation/cli-truncate": ["cli-truncate@2.1.0", "", { "dependencies": { "slice-ansi": "^3.0.0", "string-width": "^4.2.0" } }, "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg=="], "iconv-corefoundation/node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="], @@ -3553,8 +3517,6 @@ "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], - "mdast-util-mdx-jsx/parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], - "micromark-extension-math/katex": ["katex@0.16.45", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA=="], "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], @@ -3591,6 +3553,8 @@ "openai/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], + "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + "parse-json/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], "parse-semver/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="], @@ -3611,16 +3575,12 @@ "rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], - "react-syntax-highlighter/@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="], - "read-pkg/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], "read-pkg/unicorn-magic": ["unicorn-magic@0.1.0", "", {}, "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ=="], "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], - "refractor/prismjs": ["prismjs@1.27.0", "", {}, "sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA=="], - "rehype-katex/katex": ["katex@0.16.45", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA=="], "rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], @@ -3643,8 +3603,6 @@ "ssri/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], - "stringify-entities/character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], - "superagent/mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="], "supports-hyperlinks/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], @@ -3743,30 +3701,12 @@ "glob/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="], - "hast-util-from-dom/hastscript/hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="], - - "hast-util-from-parse5/hastscript/hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="], - - "hastscript/@types/hast/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], - "iconv-corefoundation/cli-truncate/slice-ansi": ["slice-ansi@3.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ=="], "make-fetch-happen/http-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], "make-fetch-happen/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], - "mdast-util-mdx-jsx/parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], - - "mdast-util-mdx-jsx/parse-entities/character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], - - "mdast-util-mdx-jsx/parse-entities/character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], - - "mdast-util-mdx-jsx/parse-entities/is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], - - "mdast-util-mdx-jsx/parse-entities/is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], - - "mdast-util-mdx-jsx/parse-entities/is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], - "node-gyp/make-fetch-happen/cacache": ["cacache@19.0.1", "", { "dependencies": { "@npmcli/fs": "^4.0.0", "fs-minipass": "^3.0.0", "glob": "^10.2.2", "lru-cache": "^10.0.1", "minipass": "^7.0.3", "minipass-collect": "^2.0.1", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "p-map": "^7.0.2", "ssri": "^12.0.0", "tar": "^7.4.3", "unique-filename": "^4.0.0" } }, "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ=="], "node-gyp/make-fetch-happen/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], @@ -3939,8 +3879,6 @@ "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - "mdast-util-mdx-jsx/parse-entities/is-alphanumerical/is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], - "node-gyp/make-fetch-happen/cacache/@npmcli/fs": ["@npmcli/fs@4.0.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q=="], "node-gyp/make-fetch-happen/cacache/fs-minipass": ["fs-minipass@3.0.3", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw=="], diff --git a/package.json b/package.json index d2d0aefd..d0d8ffdf 100644 --- a/package.json +++ b/package.json @@ -100,7 +100,6 @@ "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-tooltip": "^1.2.8", - "@types/react-syntax-highlighter": "^15.5.13", "@xenova/transformers": "^2.17.2", "@zumer/snapdom": "^2.12.8", "bun-pty": "^0.4.5", @@ -115,7 +114,6 @@ "react": "^19.1.1", "react-dom": "^19.1.1", "react-markdown": "^10.1.0", - "react-syntax-highlighter": "^15.6.6", "remark-gfm": "^4.0.1", "simple-git": "^3.28.0", "sonner": "^2.0.7", From b384deb8122e545994b3e92d85a89928f6956e99 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sat, 27 Jun 2026 00:06:19 +0300 Subject: [PATCH 0007/1073] fix(chat): stop scroll jiggle and double-scroll on send while pinned Two scroll owners were writing the chat container's scrollTop concurrently during pinned content growth and on send, fighting frame-to-frame and producing the reported flicker/jiggle (after a pause, from the queue, on user interruptions) plus a visible double scroll on a normal user send. Enforce a single-writer invariant in useChatAutoFollow: - The easing follow loop and the instant settle burst now mutually exclude: starting one stops the other, so they can never write scrollTop in the same frame. The isFollowingProgrammatically flag (which suppresses the overlay scrollbar) is owned by whichever loop is active and cleared only when both are idle, including the settle burst's natural 280ms end. Stop the redundant re-pin storm in useChatTimelineController: - While pinned, route goToBottom('instant') only for a prepend (history loaded above), not on every bottom append / streaming part. Normal growth is owned by the follow loop (kicked by the content ResizeObserver and chunk handlers). Remove the double movement on send: - Add scrollToBottomOnSend: when already following, just (re)kick the follow loop for a single smooth movement instead of also firing an instant goToBottom that raced the ResizeObserver-driven loop. When released (scrolled up), keep the instant jump to the just-sent message. --- .../ui/src/components/chat/ChatContainer.tsx | 9 +- .../chat/hooks/useChatTimelineController.ts | 91 ++++++++++--------- packages/ui/src/hooks/useChatAutoFollow.ts | 75 ++++++++++++--- 3 files changed, 115 insertions(+), 60 deletions(-) diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index dcb2465f..b73c52ee 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -568,6 +568,7 @@ export const ChatContainer: React.FC = ({ autoOpenDraft = tr notifyContentChange: handleMessageContentChange, getAnimationHandlers, goToBottom, + scrollToBottomOnSend, releaseAutoFollow, restoreSnapshot, isPinned, @@ -792,7 +793,7 @@ export const ChatContainer: React.FC = ({ autoOpenDraft = tr : 'flex-1 items-center justify-center bg-background px-0 pb-[6vh]' )} > - {promptReadOnly ? : } + {promptReadOnly ? : }
); @@ -852,7 +853,7 @@ export const ChatContainer: React.FC = ({ autoOpenDraft = tr : 'bg-background' )} > - {promptReadOnly ? : } + {promptReadOnly ? : }
); @@ -885,7 +886,7 @@ export const ChatContainer: React.FC = ({ autoOpenDraft = tr : 'bg-background' )} > - {promptReadOnly ? : } + {promptReadOnly ? : }
); @@ -933,7 +934,7 @@ export const ChatContainer: React.FC = ({ autoOpenDraft = tr onClick={navigation.resumeToLatest} /> )} - {promptReadOnly ? : } + {promptReadOnly ? : }
{ prependTrackingRef.current = { - oldestId: renderedMessages[0]?.info?.id ?? null, - newestId: renderedMessages[renderedMessages.length - 1]?.info?.id ?? null, + oldestId: currentOldestId, + newestId: currentNewestId, scrollHeight: container.scrollHeight, }; + }; + + if (isPinnedRef.current) { + // Bottom-pinned. Only content inserted ABOVE (a prepend / history load) + // needs an explicit re-pin: with overflow-anchor:none the browser leaves + // scrollTop unchanged, so the viewport would visibly jump. Route that + // through goToBottom — the single programmatic writer. + // + // A normal bottom APPEND (a sent message, a streaming part) must NOT + // re-pin here. Auto-follow's own follow loop — kicked by the content + // ResizeObserver and the streaming chunk handlers — already eases to the + // new bottom. Calling goToBottom on every append layered its settle burst + // on top of that loop: two writers aiming at different positions, which + // is exactly the up/down jiggle reported on send / from the queue / while + // streaming. So for an append we do nothing and let the follow loop own it. + if (snap || isPrepend) { + prePrependScrollRef.current = null; + goToBottom('instant'); + } + updateTracking(); return; } - const snap = prePrependScrollRef.current; if (snap) { prePrependScrollRef.current = null; // When a viewport anchor is available, delegate to MessageList @@ -382,38 +408,17 @@ export const useChatTimelineController = ({ container.scrollTop = snap.top + delta; } } - } else { - // Auto-detect a prepend: the oldest message changed while the newest - // stayed the same (distinguishes a real prepend from a session - // switch, a bottom append, or a streaming part growing). Compensate - // synchronously by the exact height delta — for a bottom-pinned - // viewport this keeps it pinned, for a released one it preserves the - // read position, with no intermediate frame for auto-follow to fight. - const prev = prependTrackingRef.current; - const currentOldestId = renderedMessages[0]?.info?.id ?? null; - const currentNewestId = renderedMessages[renderedMessages.length - 1]?.info?.id ?? null; - const isPrepend = Boolean( - prev - && prev.oldestId - && currentOldestId - && currentOldestId !== prev.oldestId - && prev.newestId - && currentNewestId - && currentNewestId === prev.newestId, - ); - if (isPrepend && prev) { - const delta = container.scrollHeight - prev.scrollHeight; - if (delta > 0) { - container.scrollTop = container.scrollTop + delta; - } + } else if (isPrepend && prev) { + // Released viewport: preserve the read position by compensating for the + // exact height the prepend added above, with no intermediate frame for + // auto-follow to fight. + const delta = container.scrollHeight - prev.scrollHeight; + if (delta > 0) { + container.scrollTop = container.scrollTop + delta; } } - prependTrackingRef.current = { - oldestId: renderedMessages[0]?.info?.id ?? null, - newestId: renderedMessages[renderedMessages.length - 1]?.info?.id ?? null, - scrollHeight: container.scrollHeight, - }; + updateTracking(); }, [renderedMessages, scrollRef, restoreViewportAnchor, goToBottom]); const revealBufferedTurns = React.useCallback(async (): Promise => false, []); diff --git a/packages/ui/src/hooks/useChatAutoFollow.ts b/packages/ui/src/hooks/useChatAutoFollow.ts index 7ead5106..95c927e5 100644 --- a/packages/ui/src/hooks/useChatAutoFollow.ts +++ b/packages/ui/src/hooks/useChatAutoFollow.ts @@ -36,6 +36,7 @@ export interface UseChatAutoFollowResult { notifyContentChange: (reason?: ContentChangeReason) => void; getAnimationHandlers: (messageId: string) => AnimationHandlers; goToBottom: (mode?: 'instant' | 'smooth') => void; + scrollToBottomOnSend: () => void; releaseAutoFollow: () => void; saveSnapshotNow: () => void; restoreSnapshot: () => Promise; @@ -170,7 +171,21 @@ export const useChatAutoFollow = ({ } followRafRef.current = null; settledFramesRef.current = 0; - setIsFollowingProgrammatically(false); + // Only the active scroll-writer owns the "programmatic follow" flag. If the + // settle burst is still running it remains the owner, so don't clear here. + if (settleBurstRafRef.current === null) { + setIsFollowingProgrammatically(false); + } + }, []); + + const stopSettleBurst = React.useCallback(() => { + if (settleBurstRafRef.current !== null && typeof window !== 'undefined') { + window.cancelAnimationFrame(settleBurstRafRef.current); + } + settleBurstRafRef.current = null; + if (followRafRef.current === null) { + setIsFollowingProgrammatically(false); + } }, []); const tickFollow = React.useCallback(() => { @@ -214,12 +229,18 @@ export const useChatAutoFollow = ({ const startFollowLoop = React.useCallback(() => { if (typeof window === 'undefined') return; - if (followRafRef.current !== null) return; if (stateRef.current !== 'following') return; + // Single-writer invariant: never let the easing follow loop run alongside + // the instant settle burst. They both write scrollTop every frame but aim + // at different positions (the burst snaps to the exact bottom, this loop + // eases toward it), so concurrently they fight frame-to-frame and produce + // the visible up/down jiggle during pinned content growth and sends. + stopSettleBurst(); + if (followRafRef.current !== null) return; settledFramesRef.current = 0; setIsFollowingProgrammatically(true); followRafRef.current = window.requestAnimationFrame(tickFollow); - }, [tickFollow]); + }, [stopSettleBurst, tickFollow]); const writeScrollTopInstant = React.useCallback((target: number) => { const container = scrollRef.current; @@ -231,22 +252,32 @@ export const useChatAutoFollow = ({ lastScrollTopRef.current = container.scrollTop; }, [markProgrammaticWrite]); - const stopSettleBurst = React.useCallback(() => { - if (settleBurstRafRef.current !== null && typeof window !== 'undefined') { - window.cancelAnimationFrame(settleBurstRafRef.current); - } - settleBurstRafRef.current = null; - }, []); - const startSettleBurst = React.useCallback(() => { if (typeof window === 'undefined') return; + // Single-writer invariant (mirror of startFollowLoop): the settle burst is + // taking over scroll ownership, so stop the easing follow loop first. The + // two must never write scrollTop in the same frame. + stopFollowLoop(); stopSettleBurst(); + setIsFollowingProgrammatically(true); const until = (typeof performance !== 'undefined' ? performance.now() : Date.now()) + SETTLE_BURST_DURATION_MS; + const finish = () => { + settleBurstRafRef.current = null; + if (followRafRef.current === null) { + setIsFollowingProgrammatically(false); + } + }; const tick = () => { settleBurstRafRef.current = null; - if (stateRef.current !== 'following') return; + if (stateRef.current !== 'following') { + finish(); + return; + } const c = scrollRef.current; - if (!c) return; + if (!c) { + finish(); + return; + } const target = Math.max(0, c.scrollHeight - c.clientHeight); if (Math.abs(c.scrollTop - target) > SETTLE_EPSILON) { markProgrammaticWrite(); @@ -256,10 +287,12 @@ export const useChatAutoFollow = ({ const now = typeof performance !== 'undefined' ? performance.now() : Date.now(); if (now < until) { settleBurstRafRef.current = window.requestAnimationFrame(tick); + } else { + finish(); } }; settleBurstRafRef.current = window.requestAnimationFrame(tick); - }, [markProgrammaticWrite, stopSettleBurst]); + }, [markProgrammaticWrite, stopFollowLoop, stopSettleBurst]); const releaseAutoFollow = React.useCallback(() => { stopFollowLoop(); @@ -293,6 +326,21 @@ export const useChatAutoFollow = ({ startSettleBurst(); }, [setStateValue, startFollowLoop, startSettleBurst, writeScrollTopInstant]); + const scrollToBottomOnSend = React.useCallback(() => { + // Keep a SINGLE movement to the just-sent message. + // If we're already following the bottom, the optimistic message is eased + // into view by the follow loop (kicked by the content ResizeObserver). Just + // (re)kick that one owner — do NOT also fire an instant goToBottom here, or + // the instant snap races the easing loop and you see a visible double scroll + // (ease, then snap). + if (stateRef.current === 'following') { + startFollowLoop(); + return; + } + // Scrolled up (released): bring the user down to the message they just sent. + goToBottom('instant'); + }, [goToBottom, startFollowLoop]); + const flushSave = React.useCallback(() => { if (saveTimerRef.current !== null) { clearTimeout(saveTimerRef.current); @@ -676,6 +724,7 @@ export const useChatAutoFollow = ({ notifyContentChange, getAnimationHandlers, goToBottom, + scrollToBottomOnSend, releaseAutoFollow, saveSnapshotNow, restoreSnapshot, From eb051f969e37ee9f27e2b4554b80df1a6be693f4 Mon Sep 17 00:00:00 2001 From: yuchi0531 <109888972+yuchi0531@users.noreply.github.com> Date: Sat, 27 Jun 2026 07:00:05 +0900 Subject: [PATCH 0008/1073] Add Japanese locale (ja) support (#1810) * Add Japanese locale (ja) support - New: ja.ts with 2624 UI translation keys - New: ja.settings.ts with 1789 settings translation keys - Registered ja in runtime.ts (Locale type, LOCALES, LOCALE_LABEL_KEYS, normalizeLocale) - Added ja-JP BCP-47 mapping in intl.ts - Added JA_MESSAGES in bootstrap.ts - Added ja to lazy-load dictionary map in store.ts - Updated messages.test.ts with ja dictionary - Added common.language.japanese to all 8 existing locale files * fix(i18n): complete Japanese locale updates * test(i18n): enforce locale key parity * docs: add Japanese documentation --------- Co-authored-by: yuchi0531 Co-authored-by: Bohdan Triapitsyn --- packages/docs/CONTRIBUTING.md | 10 +- .../content/docs/ja/commands-snippets.mdx | 39 + packages/docs/content/docs/ja/context.mdx | 38 + .../docs/content/docs/ja/desktop-browser.mdx | 22 + .../docs/content/docs/ja/desktop-tunnels.mdx | 41 + packages/docs/content/docs/ja/environment.mdx | 138 + .../docs/content/docs/ja/git-identities.mdx | 30 + packages/docs/content/docs/ja/git.mdx | 41 + packages/docs/content/docs/ja/github.mdx | 34 + packages/docs/content/docs/ja/index.mdx | 32 + packages/docs/content/docs/ja/install.mdx | 33 + .../docs/content/docs/ja/magic-prompts.mdx | 26 + packages/docs/content/docs/ja/mcp.mdx | 30 + packages/docs/content/docs/ja/mobile.mdx | 31 + packages/docs/content/docs/ja/multi-run.mdx | 34 + .../content/docs/ja/notes-todos-plans.mdx | 37 + .../docs/content/docs/ja/notifications.mdx | 34 + .../docs/content/docs/ja/opencode-server.mdx | 97 + packages/docs/content/docs/ja/preview.mdx | 32 + .../docs/content/docs/ja/project-actions.mdx | 28 + .../docs/content/docs/ja/project-icons.mdx | 20 + packages/docs/content/docs/ja/projects.mdx | 34 + packages/docs/content/docs/ja/providers.mdx | 49 + packages/docs/content/docs/ja/quickstart.mdx | 26 + .../docs/content/docs/ja/remote-instances.mdx | 42 + .../docs/content/docs/ja/reverse-proxy.mdx | 347 +++ .../docs/content/docs/ja/scheduled-tasks.mdx | 33 + packages/docs/content/docs/ja/security.mdx | 37 + .../docs/content/docs/ja/skills-catalog.mdx | 27 + packages/docs/content/docs/ja/skills.mdx | 30 + .../content/docs/ja/ssh-hosts-proxying.mdx | 48 + packages/docs/content/docs/ja/themes.mdx | 30 + .../docs/content/docs/ja/troubleshooting.mdx | 40 + .../troubleshooting/opencode-connection.mdx | 34 + .../docs/ja/troubleshooting/remote-access.mdx | 38 + .../docs/ja/troubleshooting/worktrees-git.mdx | 35 + packages/docs/content/docs/ja/tunnels.mdx | 117 + packages/docs/content/docs/ja/updates.mdx | 31 + packages/docs/content/docs/ja/usage.mdx | 28 + packages/docs/content/docs/ja/voice.mdx | 36 + packages/docs/content/docs/ja/worktrees.mdx | 36 + packages/docs/sidebar.config.json | 141 +- packages/ui/src/lib/i18n/bootstrap.ts | 25 +- packages/ui/src/lib/i18n/intl.ts | 1 + packages/ui/src/lib/i18n/messages.test.ts | 11 +- packages/ui/src/lib/i18n/messages/en.ts | 1 + packages/ui/src/lib/i18n/messages/es.ts | 1 + packages/ui/src/lib/i18n/messages/fr.ts | 2 + .../ui/src/lib/i18n/messages/ja.settings.ts | 1801 +++++++++++ packages/ui/src/lib/i18n/messages/ja.ts | 2639 +++++++++++++++++ packages/ui/src/lib/i18n/messages/ko.ts | 1 + packages/ui/src/lib/i18n/messages/pl.ts | 1 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 1 + packages/ui/src/lib/i18n/messages/uk.ts | 1 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 1 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 1 + packages/ui/src/lib/i18n/runtime.ts | 10 +- packages/ui/src/lib/i18n/store.ts | 4 +- 58 files changed, 6509 insertions(+), 58 deletions(-) create mode 100644 packages/docs/content/docs/ja/commands-snippets.mdx create mode 100644 packages/docs/content/docs/ja/context.mdx create mode 100644 packages/docs/content/docs/ja/desktop-browser.mdx create mode 100644 packages/docs/content/docs/ja/desktop-tunnels.mdx create mode 100644 packages/docs/content/docs/ja/environment.mdx create mode 100644 packages/docs/content/docs/ja/git-identities.mdx create mode 100644 packages/docs/content/docs/ja/git.mdx create mode 100644 packages/docs/content/docs/ja/github.mdx create mode 100644 packages/docs/content/docs/ja/index.mdx create mode 100644 packages/docs/content/docs/ja/install.mdx create mode 100644 packages/docs/content/docs/ja/magic-prompts.mdx create mode 100644 packages/docs/content/docs/ja/mcp.mdx create mode 100644 packages/docs/content/docs/ja/mobile.mdx create mode 100644 packages/docs/content/docs/ja/multi-run.mdx create mode 100644 packages/docs/content/docs/ja/notes-todos-plans.mdx create mode 100644 packages/docs/content/docs/ja/notifications.mdx create mode 100644 packages/docs/content/docs/ja/opencode-server.mdx create mode 100644 packages/docs/content/docs/ja/preview.mdx create mode 100644 packages/docs/content/docs/ja/project-actions.mdx create mode 100644 packages/docs/content/docs/ja/project-icons.mdx create mode 100644 packages/docs/content/docs/ja/projects.mdx create mode 100644 packages/docs/content/docs/ja/providers.mdx create mode 100644 packages/docs/content/docs/ja/quickstart.mdx create mode 100644 packages/docs/content/docs/ja/remote-instances.mdx create mode 100644 packages/docs/content/docs/ja/reverse-proxy.mdx create mode 100644 packages/docs/content/docs/ja/scheduled-tasks.mdx create mode 100644 packages/docs/content/docs/ja/security.mdx create mode 100644 packages/docs/content/docs/ja/skills-catalog.mdx create mode 100644 packages/docs/content/docs/ja/skills.mdx create mode 100644 packages/docs/content/docs/ja/ssh-hosts-proxying.mdx create mode 100644 packages/docs/content/docs/ja/themes.mdx create mode 100644 packages/docs/content/docs/ja/troubleshooting.mdx create mode 100644 packages/docs/content/docs/ja/troubleshooting/opencode-connection.mdx create mode 100644 packages/docs/content/docs/ja/troubleshooting/remote-access.mdx create mode 100644 packages/docs/content/docs/ja/troubleshooting/worktrees-git.mdx create mode 100644 packages/docs/content/docs/ja/tunnels.mdx create mode 100644 packages/docs/content/docs/ja/updates.mdx create mode 100644 packages/docs/content/docs/ja/usage.mdx create mode 100644 packages/docs/content/docs/ja/voice.mdx create mode 100644 packages/docs/content/docs/ja/worktrees.mdx create mode 100644 packages/ui/src/lib/i18n/messages/ja.settings.ts create mode 100644 packages/ui/src/lib/i18n/messages/ja.ts diff --git a/packages/docs/CONTRIBUTING.md b/packages/docs/CONTRIBUTING.md index 7f555c75..76d6efbf 100644 --- a/packages/docs/CONTRIBUTING.md +++ b/packages/docs/CONTRIBUTING.md @@ -203,12 +203,13 @@ other language mirrors the English files under a locale folder. | Korean | `ko/` | `ko` | | Polish | `pl/` | `pl` | | French | `fr/` | `fr` | +| Japanese | `ja/` | `ja` | > [!IMPORTANT] > The **content folder** uses the lowercase locale key (`zh-cn`, `pt-br`); the > **sidebar `translations`** key uses the BCP-47 language tag (`zh-CN`, `pt-BR`). > They look similar but are not interchangeable — Starlight resolves them with -> different rules. Everything else (`uk`, `es`, `ko`, `pl`, `fr`, `en`) is identical +> different rules. Everything else (`uk`, `es`, `ko`, `pl`, `fr`, `ja`, `en`) is identical > in both columns. This locale set is mirrored in the website at @@ -230,6 +231,7 @@ content/docs/ ko/install.mdx # Korean pl/install.mdx # Polish fr/install.mdx # French + ja/install.mdx # Japanese guides/tunnels.mdx # nested English page uk/guides/tunnels.mdx # its Ukrainian translation @@ -266,7 +268,8 @@ to each section and item in `sidebar.config.json`: "pt-BR": "Comece aqui", "ko": "여기서 시작", "pl": "Zacznij tutaj", - "fr": "Commencer ici" + "fr": "Commencer ici", + "ja": "ここから開始" }, "items": [ { @@ -279,7 +282,8 @@ to each section and item in `sidebar.config.json`: "pt-BR": "Instalação", "ko": "설치", "pl": "Instalacja", - "fr": "Installation" + "fr": "Installation", + "ja": "インストール" } } ] diff --git a/packages/docs/content/docs/ja/commands-snippets.mdx b/packages/docs/content/docs/ja/commands-snippets.mdx new file mode 100644 index 00000000..efb12cc8 --- /dev/null +++ b/packages/docs/content/docs/ja/commands-snippets.mdx @@ -0,0 +1,39 @@ +--- +title: コマンドとスニペット +description: チャットで使い回せるスラッシュコマンドとテキストスニペットを作成します。 +--- + +# コマンドとスニペット + +コマンドとスニペットは、どちらも同じ内容を何度も入力する手間を省きます。コマンドは `/` で呼び出す完全なプロンプトで、スニペットは `#` でメッセージに差し込む短いテキストです。 + +## コマンド + +コマンドは `/review` のようにスラッシュで実行する保存済みプロンプトです。**Settings → Commands** で管理します。 + +1. **Settings → Commands** を開き、コマンドを作成します。 +2. 名前、説明、送信するプロンプト本文を入力します。 +3. 必要なら特定のエージェントやモデルに固定します。 +4. 個人用かプロジェクト用かのスコープを選びます。 + +チャットでは、メッセージの**最初**の文字として `/` を入力するとコマンドが表示されます。そこから選んでください。テキストにはプレースホルダーを使えます。 + +- `$ARGUMENTS` — コマンドの後に入力した内容 +- `@filename` — ファイルの内容を差し込みます +- `` !`command` `` — シェルコマンドの出力を差し込みます + +組み込みの `init` と `review` コマンドはリセットできますが、削除はできません。 + +## スニペット + +スニペットは `#signoff` のようなハッシュタグで本文中から参照する再利用可能なテキストです。**Settings → Snippets** で管理します。 + +1. **Settings → Snippets** を開き、スニペットを作成します。 +2. 名前と、その名前が表すテキストを入力します。複数の呼び出し名が欲しい場合はエイリアスを追加します。 +3. 個人用かプロジェクト用かのスコープを選びます。 + +チャットで `#` を入力してスニペットを選ぶと、OpenChamber が送信前に全文へ置き換えます。 + +## 関連 + +- [スキル](/skills/) — 必要なときに大きな指示セットを読み込む diff --git a/packages/docs/content/docs/ja/context.mdx b/packages/docs/content/docs/ja/context.mdx new file mode 100644 index 00000000..a485f27b --- /dev/null +++ b/packages/docs/content/docs/ja/context.mdx @@ -0,0 +1,38 @@ +--- +title: コンテキスト +description: セッションがモデルの記憶容量をどれだけ使っているかを確認します。 +--- + +# コンテキスト + +どのモデルも、一度に保持できる会話量には上限があります。これがコンテキストです。OpenChamber はその使用量を表示するので、セッションが上限に近づき、返信で古い詳細が抜け始める可能性があるタイミングを把握できます。 + +## クイックインジケーター + +チャット中は、小さなゲージが使用済みコンテキストの割合を表示します。埋まるにつれて色が変わります。 + +- 緑 — まだ十分な余裕があります +- 黄 — かなり埋まっています(約 4 分の 3) +- 赤 — ほぼ満杯です + +ホバーするか、モバイルではタップすると、正確なトークン数を確認できます。 + +## 完全なコンテキストパネル + +右サイドバーの **Context** タブを開くと、現在のセッションをより詳しく確認できます。 + +- 使用中のモデルとセッション開始時刻 +- モデルの上限に対する合計トークン数 +- メッセージ数とコスト合計 +- 直近の返信のトークン内訳 +- 何がコンテキストを占めているかの大まかな内訳(あなたのメッセージ、エージェントのメッセージ、ツール出力) + +この内訳は正確なカウントではなく推定です。課金確認ではなく、何がウィンドウを埋めているかを見るために使ってください。 + +## 満杯になったら + +1 つのセッションを永遠に伸ばすのではなく、新しいタスクには新しいセッションを開始してください。短いコンテキストのほうが速く、モデルの集中も保ちやすくなります。 + +## 関連 + +- [プロジェクト](/projects/) — セッションはプロジェクトごとにまとめられます diff --git a/packages/docs/content/docs/ja/desktop-browser.mdx b/packages/docs/content/docs/ja/desktop-browser.mdx new file mode 100644 index 00000000..d344e505 --- /dev/null +++ b/packages/docs/content/docs/ja/desktop-browser.mdx @@ -0,0 +1,22 @@ +--- +title: デスクトップブラウザ +description: デスクトップアプリ内で任意のページを開き、検査とコンソール取得を使います。 +--- + +# デスクトップブラウザ + +デスクトップアプリには組み込みブラウザがあります。チャットのすぐ横で任意のページを開き、要素を指して質問したり、ページのコンソールを取得したりできます。アプリヘッダーの地球儀ボタンから開きます。 + +> デスクトップブラウザは**デスクトップ専用**機能です。Web では、[プレビュー](/preview/) パネルがローカル開発サーバー向けに同じ検査・コンソールツールを提供します。 + +## 検査して注釈を付ける + +**inspect** をオンにして、ページ上の任意の要素をクリックします。OpenChamber はその要素について、何であるか、スタイル、位置、スクリーンショットを含むメモを取得し、チャットメッセージに添付します。エージェントに「この要素、ここ」と伝える最短の方法です。 + +## コンソール取得 + +ブラウザはページのコンソール出力(エラー、警告、ログ)を集めるので、開発者ツールを開かずにフィルターして読めます。 + +## 関連 + +- [プレビューと開発サーバー](/preview/) — ローカル開発サーバー向けの同じツール diff --git a/packages/docs/content/docs/ja/desktop-tunnels.mdx b/packages/docs/content/docs/ja/desktop-tunnels.mdx new file mode 100644 index 00000000..c7ad8d8a --- /dev/null +++ b/packages/docs/content/docs/ja/desktop-tunnels.mdx @@ -0,0 +1,41 @@ +--- +title: デスクトップトンネル +description: デスクトップアプリから Cloudflare または Ngrok トンネルを作成します。 +--- + +# デスクトップトンネル + +デスクトップアプリでは **Settings → OpenChamber → Tunnel** から公開トンネルを作成できます。この方法では CLI から OpenChamber を起動する必要はありません。 + +## プロバイダーをインストールする + +OpenChamber はあなたのマシン上でプロバイダー CLI を起動します。使いたいプロバイダーを先にインストールしてください。 + +```bash +brew install cloudflared +brew install ngrok +``` + +Cloudflare は `cloudflared` を使います。Ngrok には ngrok アカウントと ngrok ダッシュボードの authtoken が必要です。 + +```bash +ngrok config add-authtoken +``` + +## アプリから開始する + +1. **Settings → OpenChamber → Tunnel** を開きます。 +2. **Cloudflare** または **Ngrok** を選びます。 +3. クイックトンネルを開始します。 +4. 生成された QR コードをスマートフォンでスキャンします。 + +現在、Ngrok はクイックトンネルに対応しています。Cloudflare はクイックトンネルと管理対象 Cloudflare モードに対応しています。 + +## アクセス保護 + +プロバイダー URL 自体が公開されていても、OpenChamber は独自の接続トークンでアクセスを保護します。生成される接続リンクには一度きりのトークンが含まれ、TTL があり、新しいリンクを生成するかトンネルを停止/再起動すると、未使用の古いリンクは無効化されます。 + +## 関連 + +- [トンネル](/tunnels/) — CLI でのトンネル利用と管理対象 Cloudflare モード +- [PWA とモバイル](/mobile/) — スマートフォンから OpenChamber にアクセスする diff --git a/packages/docs/content/docs/ja/environment.mdx b/packages/docs/content/docs/ja/environment.mdx new file mode 100644 index 00000000..af539038 --- /dev/null +++ b/packages/docs/content/docs/ja/environment.mdx @@ -0,0 +1,138 @@ +--- +title: 環境変数 +description: 環境変数で OpenChamber と OpenCode 連携を設定します。 +--- + +# 環境変数 + +OpenChamber は起動時にこれらの環境変数を読み取ります。スタートアップサービスでは、`openchamber startup enable` がデフォルトで現在の環境をスナップショットするため、サービスに使わせたい変数を変更した後は再実行してください。 + +## OpenChamber サーバー + +### `OPENCHAMBER_HOST` + +OpenChamber Web サーバーのバインドアドレスです。他のマシンからアクセスできるようにするには `0.0.0.0` を使います。 + +### `OPENCHAMBER_UI_PASSWORD` + +ブラウザ UI のパスワードです。localhost 以外にバインドする場合、トンネルを使う場合、またはリバースプロキシの背後で実行する場合に使います。 + +### `OPENCHAMBER_API_ONLY` + +`true` または `1` に設定すると、OpenChamber をヘッドレスモードで起動します。デスクトップおよびモバイルクライアント向けの API ルートは利用できますが、ブラウザ UI は配信されません。 + +### `OPENCHAMBER_DATA_DIR` + +OpenChamber のデータディレクトリを上書きします。デフォルトは `~/.config/openchamber` です。 + +### `OPENCHAMBER_COMPRESS_API` + +API レスポンス圧縮を制御します。`true` または `1` で強制的に有効化し、`false` または `0` で強制的に無効化します。 + +### `OPENCHAMBER_SKIP_API_COMPRESSION` + +`true` または `1` に設定すると API レスポンス圧縮を無効化します。これは `OPENCHAMBER_COMPRESS_API` より優先されます。 + +### `OPENCHAMBER_VERBOSE_REQUEST_LOGS` + +`true` または `1` に設定すると詳細な HTTP リクエストログを有効にします。 + +### `OPENCHAMBER_UPDATE_API_URL` + +更新確認 API エンドポイントを上書きします。ほとんどのユーザーは未設定のままでかまいません。 + +### `OPENCHAMBER_PACKAGE_MANAGER` + +自動検出が間違っている場合に、更新操作で使うパッケージマネージャーを強制します。 + +## OpenCode サーバー + +### `OPENCODE_HOST` + +OpenChamber を既存の OpenCode サーバーに接続します。値は明示的なポートを含み、パス、クエリ、ハッシュを含まない `http` または `https` の origin である必要があります。`OPENCODE_HOST` は `OPENCODE_PORT` より優先されます。 + +### `OPENCODE_PORT` + +OpenCode サーバーのポートを設定します。管理対象 OpenCode では管理対象ポートの要求になり、`OPENCODE_SKIP_START=true` ではそのポートの外部サーバーに接続します。 + +### `OPENCODE_SKIP_START` + +`true` に設定すると、OpenChamber が独自の OpenCode サーバーを起動しません。 + +### `OPENCHAMBER_OPENCODE_HOSTNAME` + +OpenChamber が管理する OpenCode サーバーのバインドホスト名です。デフォルトは `127.0.0.1` です。 + +### `OPENCODE_BINARY` + +OpenChamber が実行する `opencode` 実行ファイルへのパスです。 + +### `OPENCODE_CONFIG` + +特定の OpenCode 設定ファイルへのパスです。 + +### `OPENCODE_CONFIG_DIR` + +エージェント、スキル、スニペット、設定検出に使う特定の OpenCode 設定ディレクトリへのパスです。 + +### `OPENCODE_DATA_DIR` + +管理対象 OpenCode サーバーのカスタムデータディレクトリです。 + +### `OPENCODE_WSL_DISTRO` + +Windows 上の OpenCode 連携に使う WSL ディストリビューションを選びます。 + +### `OPENCHAMBER_OPENCODE_WSL_DISTRO` + +WSL ディストリビューション選択用の OpenChamber 固有エイリアスです。両方が設定されている場合は `OPENCODE_WSL_DISTRO` が優先されます。 + +### `OPENCODE_JWT_SECRET` + +UI 認証トークンの署名に使うシークレットです。永続的なサービス配置では長いランダム値を使ってください。 + +## ターミナルと Git + +### `OPENCHAMBER_TERMINAL_SHELL` + +OpenChamber のターミナルセッションで使うシェル実行ファイルです。 + +### `OPENCHAMBER_GIT_BINARY` + +OpenChamber の Git 機能で使う Git 実行ファイルです。 + +### `GIT_BINARY` + +代替の Git 実行ファイル上書きです。OpenChamber 固有の設定には `OPENCHAMBER_GIT_BINARY` を推奨します。 + +### `OPENCHAMBER_GIT_READ_CACHE_TTL_MS` + +Git に基づくファイル読み取りキャッシュの有効期間(ミリ秒)です。デバッグ中にこのキャッシュを無効化するには `0` を設定します。 + +## 音声とトンネル + +### `OPENAI_API_KEY` + +OpenAI 互換サービスを呼び出す OpenChamber の音声機能で使う API キーです。 + +### `OPENCHAMBER_ALLOW_REMOTE_OPENAI_COMPAT_URLS` + +`true` または `1` に設定すると、音声機能でリモートの OpenAI 互換 base URL を許可します。 + +### `NGROK_AUTHTOKEN` + +OpenChamber のトンネルコマンドで使う ngrok 認証トークンです。`ngrok config add-authtoken ` で ngrok 側に設定することもできます。 + +## ランタイムヘルパー + +### `BUN_BINARY` + +デーモンプロセスを起動するときに OpenChamber が使う Bun 実行ファイルです。 + +### `BUN_INSTALL` + +Bun のインストールルートです。OpenChamber はデーモン起動と更新のために `bin/bun` を探す際に使います。 + +### `VITE_OPENCODE_URL` + +Vite でビルドされた Web アプリ向けのビルド時 API base URL です。通常の CLI またはデスクトップ利用では、ほとんどのユーザーは設定する必要がありません。 diff --git a/packages/docs/content/docs/ja/git-identities.mdx b/packages/docs/content/docs/ja/git-identities.mdx new file mode 100644 index 00000000..ba12d12e --- /dev/null +++ b/packages/docs/content/docs/ja/git-identities.mdx @@ -0,0 +1,30 @@ +--- +title: Git ID +description: リポジトリごとに正しい名前とメールでコミットします。 +--- + +# Git ID + +Git ID は、コミットに使われる名前とメールアドレスです。個人用リポジトリと仕事用リポジトリをまたいで作業する場合、1 つのグローバル設定に頼る代わりに ID を保存し、リポジトリごとに正しいものを適用できます。**Settings → Git** で管理します。 + +## ID を追加する + +1. **Settings → Git** を開き、**New** を選びます。 +2. コミットに使う **name** と **email** を入力します。 +3. リモートへの認証方法を選びます。 + - **SSH** — SSH キーを指定します + - **token** — ホスト用に保存された認証情報を使います +4. 必要なら、見分けやすいように色とアイコンを付けます。 + +システムのグローバル ID も読み取り専用で表示されます。 + +## ID をリポジトリに適用する + +ID を適用すると、そのリポジトリの**ローカル** Git 設定に書き込まれます。影響するのはそのリポジトリだけで、グローバル設定には影響しません。SSH ID はそのキーを使う SSH コマンドも設定し、トークン ID はホスト用の認証情報保存を設定します。 + +OpenChamber が既存の Git 認証情報から見つけた ID をインポートし、トークン ID として保存することもできます。 + +## 関連 + +- [Git と GitHub ワークフロー](/git/) — 設定した ID でコミットする +- [GitHub Issues と PR](/github/) — PR 用に GitHub アカウントを接続する diff --git a/packages/docs/content/docs/ja/git.mdx b/packages/docs/content/docs/ja/git.mdx new file mode 100644 index 00000000..4a7292e5 --- /dev/null +++ b/packages/docs/content/docs/ja/git.mdx @@ -0,0 +1,41 @@ +--- +title: Git と GitHub ワークフロー +description: OpenChamber から離れずにステージ、コミット、ブランチ管理を行います。 +--- + +# Git と GitHub ワークフロー + +OpenChamber には組み込みの Git ビューがあり、ターミナルに切り替えずに変更の確認、コミット、ブランチ管理ができます。右サイドバーの **Git** タブから開きます。 + +## 確認してコミットする + +Git ビューは変更を **staged** と **unstaged** に分けて表示します。 + +- ファイルの **+** をクリックしてステージ、**−** をクリックしてアンステージします +- グループ内のすべてをまとめてステージまたはアンステージできます +- ファイルをクリックすると差分を確認できます + +その後、コミットメッセージを書いてコミットします。OpenChamber に staged 変更から**コミットメッセージを生成**させることもできます。現在のセッションのモデルを使うため、セッションを開いておく必要があります。 + +## ブランチと履歴 + +Git ビューは日常的な Git 操作も扱います。 + +- ブランチの作成、切り替え、名前変更、削除 +- push、pull、fetch +- 履歴とコミットごとの差分の閲覧 +- 変更の stash と復元 + +## プルリクエスト + +GitHub を接続すると([GitHub Issues と PR](/github/) を参照)、**PR** タブからプルリクエストを開く、更新する、ready にする、またはマージできます。タイトルと説明もコミットメッセージと同じように生成できます。 + +## コンフリクトを取り込む + +merge、rebase、integrate でコンフリクトが起きると、OpenChamber は詰まっている箇所を表示し、解決できるようにします。エージェントに渡して解決させることもできます。 + +## 関連 + +- [GitHub Issues と PR](/github/) — GitHub を接続し、Issue から作業を始める +- [Worktree セッション](/worktrees/) — ブランチを専用フォルダに分離する +- [Git ID](/git-identities/) — リポジトリごとに正しい人物としてコミットする diff --git a/packages/docs/content/docs/ja/github.mdx b/packages/docs/content/docs/ja/github.mdx new file mode 100644 index 00000000..aaa282f7 --- /dev/null +++ b/packages/docs/content/docs/ja/github.mdx @@ -0,0 +1,34 @@ +--- +title: GitHub Issues と PR +description: GitHub を接続し、Issue やプルリクエストからセッションを開始します。 +--- + +# GitHub Issues と PR + +GitHub アカウントを接続すると、OpenChamber は Issue とプルリクエストを取り込み、そこから直接セッションを開始したり、PR を開いたり更新したりできます。 + +## GitHub を接続する + +1. **Settings → Git** を開きます。 +2. GitHub セクションで **Connect** を選びます。OpenChamber がリンクと短いコードを表示します。 +3. リンクを開き、コードを入力して承認します。 + +接続されると、GitHub セクションにアカウントが表示されます。複数のアカウントを接続して切り替えたり、いつでも切断したりできます。 + +## Issue または PR から作業を始める + +GitHub 接続済みで [worktree セッション](/worktrees/) を作成すると、**Start from GitHub issue/PR** を選べます。 + +- **issue** を選ぶと、OpenChamber はその Issue に基づいてブランチ名を付け、Issue とコメントを最初のメッセージとしてセッションを開きます +- **pull request** を選ぶと、その PR のブランチをチェックアウトします。PR の差分を含めて、エージェントに変更全体を渡すこともできます + +これにより、必要なコンテキストが読み込まれた状態でそのままセッションに入れます。 + +## プルリクエストを開いて管理する + +[Git ビュー](/git/) の **PR** タブから、プルリクエストの作成、更新、draft から ready への変更、マージができます。OpenChamber は変更内容から PR のタイトルと説明を生成できます。 + +## 関連 + +- [Git と GitHub ワークフロー](/git/) — コミットとブランチ管理 +- [Worktree セッション](/worktrees/) — Issue と PR のセッションが始まる場所 diff --git a/packages/docs/content/docs/ja/index.mdx b/packages/docs/content/docs/ja/index.mdx new file mode 100644 index 00000000..7a45c3a7 --- /dev/null +++ b/packages/docs/content/docs/ja/index.mdx @@ -0,0 +1,32 @@ +--- +title: OpenChamber ドキュメント +description: Web、デスクトップ、VS Code で OpenChamber をセットアップして運用するためのガイドです。 +--- + +# OpenChamber ドキュメント + +OpenChamber は、OpenCode(ターミナルで動く AI コーディングエージェント)の周りにあるビジュアルな作業スペースです。コマンドラインだけで作業する代わりに、その作業を見守り、方向付ける画面を提供します。 + +このドキュメントでは次のことができます。 + +- 作業方法に合ったアプリをインストールする +- OpenChamber を安全にリモート利用できるように開く +- 見た目をカスタマイズし、よくある問題を解決する + +## 最初に読むもの + +- [インストール](/install/) +- [クイックスタート](/quickstart/) +- [トンネル](/tunnels/) +- [トラブルシューティング](/troubleshooting/) + +## 探す + +- [プロジェクト](/projects/) と [Worktree セッション](/worktrees/) — 作業を整理し、分離する +- [プロバイダー、モデル、エージェント](/providers/) — OpenCode を接続し、モデルを選ぶ +- [Git と GitHub ワークフロー](/git/) — コミット、レビュー、PR 作成 +- [セキュリティ](/security/) と [トンネル](/tunnels/) — インスタンスを保護し、アクセスする + +## OpenChamber の用途 + +OpenChamber は、AI コーディングのうち、管制室があると便利な部分のためのものです。セッションの分岐、差分レビュー、ターミナル管理、ツール進行状況の監視、プロジェクトアクションの実行、エージェントが作業している間に全体の状況を見える状態に保つことができます。 diff --git a/packages/docs/content/docs/ja/install.mdx b/packages/docs/content/docs/ja/install.mdx new file mode 100644 index 00000000..f48a6a39 --- /dev/null +++ b/packages/docs/content/docs/ja/install.mdx @@ -0,0 +1,33 @@ +--- +title: インストール +description: デスクトップ、Web、VS Code 向けに OpenChamber をインストールします。 +--- + +# インストール + +OpenChamber を実行する方法は 3 つあります。 + +- macOS 用のデスクトップアプリ +- CLI がホストする Web アプリ。スマートフォンアプリのようにインストールできます(PWA) +- VS Code 拡張機能 + +## 前提条件 + +先に [OpenCode](https://opencode.ai) をインストールしてください。OpenChamber はその上で動作します。 + +## Web + PWA + +```bash +curl -fsSL https://raw.githubusercontent.com/openchamber/openchamber/main/scripts/install.sh | bash +openchamber --ui-password be-creative-here +``` + +CLI が表示する URL(通常は `http://localhost:3000`)を開きます。OpenChamber のセッション一覧が表示されるはずです。すぐ使えるようにしておくには、ブラウザのアドレスバーにある「インストール」オプションでアプリとして追加します。 + +## デスクトップ + +GitHub releases ページまたは OpenChamber ダウンロードページから最新のデスクトップビルドをダウンロードします。開いたら、普段の OpenCode ワークフローにサインインします。 + +## VS Code + +VS Code Marketplace からインストールし、普段の OpenCode ワークフローにサインインします。その後、OpenChamber ビューがサイドバーに開きます。 diff --git a/packages/docs/content/docs/ja/magic-prompts.mdx b/packages/docs/content/docs/ja/magic-prompts.mdx new file mode 100644 index 00000000..a31cc297 --- /dev/null +++ b/packages/docs/content/docs/ja/magic-prompts.mdx @@ -0,0 +1,26 @@ +--- +title: マジックプロンプト +description: OpenChamber の自動フローの背後にある組み込みプロンプトをカスタマイズします。 +--- + +# マジックプロンプト + +OpenChamber は、コミットメッセージの作成、PR の下書き、Issue のレビュー、コンフリクト解決、セッション要約などを自動で行うとき、裏側で組み込みプロンプトを使います。Magic Prompts は、それらのプロンプトを読み、書き換える場所です。**Settings → Magic Prompts** から開きます。 + +通常の利用ではこのページは必要ありません。たとえばコミットメッセージを特定のスタイルにしたいなど、フローの動作を変えたいときに使ってください。 + +## プロンプトを編集する + +1. **Settings → Magic Prompts** を開きます。 +2. サイドバーのグループ(Git、GitHub、Planning、Session)からプロンプトを選びます。 +3. テキストを編集して保存します。 + +一部のプロンプトには、表示される部分(あなたが見るメッセージ)と instructions 部分(エージェント向けの非表示ガイド)があります。プロンプトには差分や Issue タイトルなどを OpenChamber が埋める `{{placeholders}}` を含められます。これらは残してください。 + +## リセット + +気が変わりましたか?各プロンプトには **reset to default** があり、すべてを最初からやり直したい場合は **reset all** もあります。 + +## 関連 + +- [Git と GitHub ワークフロー](/git/) — これらのプロンプトの多くが Git フローを支えています diff --git a/packages/docs/content/docs/ja/mcp.mdx b/packages/docs/content/docs/ja/mcp.mdx new file mode 100644 index 00000000..8d639f2c --- /dev/null +++ b/packages/docs/content/docs/ja/mcp.mdx @@ -0,0 +1,30 @@ +--- +title: MCP サーバー +description: MCP サーバーを追加して、エージェントに追加ツールを渡します。 +--- + +# MCP サーバー + +MCP サーバーは、エージェントに追加ツールを与えます。たとえばデータベース検索、API 呼び出し、利用中サービスの読み取りなどです。**Settings → MCP** で追加します。 + +## サーバーを追加する + +1. **Settings → MCP** を開きます。 +2. サーバーを追加し、種類を選びます。 + - **local** — OpenChamber があなたのマシンでコマンドを実行します。実行するコマンドと、必要なら環境変数を指定します。 + - **remote** — OpenChamber が誰かがホストする URL に接続します。URL と必要なヘッダー(たとえば認証トークン)を指定します。 +3. 保存します。サーバーはデフォルトでオンになります。削除せずにオフにできます。 + +## 適用範囲 + +サーバーを追加するときにスコープを選びます。 + +- **personal** — すべてのプロジェクトで利用できます +- **project** — 現在のプロジェクトでのみ利用でき、プロジェクトの他の設定と一緒に保存されます + +サーバー名には小文字、数字、ハイフン、アンダースコアを使います。 + +## 関連 + +- [プロバイダー、モデル、エージェント](/providers/) — 先にモデルを接続する +- [スキル](/skills/) — エージェントができることを広げる別の方法 diff --git a/packages/docs/content/docs/ja/mobile.mdx b/packages/docs/content/docs/ja/mobile.mdx new file mode 100644 index 00000000..90a89613 --- /dev/null +++ b/packages/docs/content/docs/ja/mobile.mdx @@ -0,0 +1,31 @@ +--- +title: PWA とモバイルアクセス +description: OpenChamber をアプリとしてインストールし、スマートフォンから使います。 +--- + +# PWA とモバイルアクセス + +OpenChamber の Web アプリはスマートフォンアプリのようにインストールできます(PWA)。ホーム画面に置いて全画面で使えます。[トンネル](/tunnels/) と組み合わせると、どこからでもセッションを確認できます。 + +## インストールする + +OpenChamber はブラウザ組み込みのインストール機能を使うため、別途ダウンロードは不要です。 + +- **デスクトップブラウザ** — アドレスバーの **Install** オプションを使います +- **iPhone/iPad (Safari)** — 共有 → **ホーム画面に追加** +- **Android (Chrome)** — メニュー → **アプリをインストール** / **ホーム画面に追加** + +インストール後は、ブラウザの枠がない専用ウィンドウで開きます。 + +## スマートフォンからアクセスする + +サーバーがコンピューター上で動いている OpenChamber をスマートフォンで開くには、[トンネル](/tunnels/) を開始し、スマートフォンでリンクを開く(または QR コードをスキャンする)だけです。この操作をするときは必ず強力な [UI パスワード](/security/) を使ってください。 + +## モバイル設定 + +**Settings → OpenChamber** には、モバイルやインストール済みアプリの体験を調整するいくつかのオプションがあります。インストール名、画面の向き、オンスクリーンキーボードの動作などです。 + +## 関連 + +- [トンネル](/tunnels/) — 別ネットワークからインスタンスにアクセスする +- [セキュリティ](/security/) — 公開する前に UI を保護する diff --git a/packages/docs/content/docs/ja/multi-run.mdx b/packages/docs/content/docs/ja/multi-run.mdx new file mode 100644 index 00000000..1e5dac1b --- /dev/null +++ b/packages/docs/content/docs/ja/multi-run.mdx @@ -0,0 +1,34 @@ +--- +title: Multi-run +description: 同じプロンプトを複数のモデルまたはセッションで同時に実行します。 +--- + +# Multi-run + +Multi-run は 1 つのフォームから複数のセッションを起動します。同じタスクを異なるモデルで試し、結果を比較するのに便利です。セッションサイドバー上部のボタンから開きます。 + +## Multi-run を開始する + +1. Multi-run ランチャーを開きます。 +2. プロジェクトを選び、実行グループに名前を付けます。 +3. プロンプトを書き、それを実行するモデルを選びます(1 グループにつき最大 5 つ)。 +4. **isolate runs** するかどうかを選びます。 +5. 起動します。 + +各モデルには独自のセッションが作られ、すべて同じプロンプトから始まります。 + +## 分離実行 + +**isolate runs** をオンにすると、各実行に独自の [worktree](/worktrees/) とブランチが与えられ、同じファイルに触れないようになります。これには Git リポジトリが必要です。Git リポジトリではないフォルダでは自動的にオフになります。実行の開始元ブランチを選んでください。 + +分離をオフにすると、各実行はプロジェクトフォルダ内の通常のセッションになります。 + +## 結果を比較する + +各実行は通常のセッションなので、開いて読み、残すか破棄するか選べます。アプローチを比較するために実行した場合は、横に並べて確認し、最良のものを先へ進めてください。 + +1 つの実行だけ開始に失敗しても、他の実行は起動します。要求した数より少ないセッションが表示されるだけです。 + +## 関連 + +- [Worktree セッション](/worktrees/) — 分離が内部でどう動くか diff --git a/packages/docs/content/docs/ja/notes-todos-plans.mdx b/packages/docs/content/docs/ja/notes-todos-plans.mdx new file mode 100644 index 00000000..d0b29604 --- /dev/null +++ b/packages/docs/content/docs/ja/notes-todos-plans.mdx @@ -0,0 +1,37 @@ +--- +title: プロジェクトのメモ、Todo、計画 +description: プロジェクトごとにメモ、Todo リスト、保存済み計画を保持します。 +--- + +# プロジェクトのメモ、Todo、計画 + +各プロジェクトには、メモ、Todo リスト、保存済み計画のための専用スペースがあります。これらは特定のセッションではなくプロジェクトに属するため、セッションを移動しても残ります。右サイドバーの **Context** タブ(モバイルでは専用タブ)で見つけられます。 + +## メモ + +プロジェクトについて覚えておきたいことを自由に書けるメモ欄です。入力すると自動で保存されます。 + +## Todo + +シンプルなチェックリストです。項目を追加し、チェックを付け、ドラッグで並び替え、完了したものを消去できます。 + +各 Todo には **send** メニューがあり、エージェントに渡せます。 + +- 現在のセッションに送る +- それを使って新しいセッションを開始する +- それを使って新しい [worktree セッション](/worktrees/) を開始する(プロジェクトが Git リポジトリの場合のみ) + +## 計画 + +長めの計画を保存済みファイルとして置いておく場所です。次のことができます。 + +- Markdown またはテキストファイルから計画をインポートする +- 計画を開いてサイドパネルで読む +- 不要になった計画を削除する + +メモが保存され、Todo がチェックされ、または計画が一覧に表示された状態で Context タブに戻れば、操作が反映されたことが分かります。 + +## 関連 + +- [Worktree セッション](/worktrees/) — Todo を専用ブランチで実行する +- [プロジェクト](/projects/) — これらはアクティブなプロジェクトに属します diff --git a/packages/docs/content/docs/ja/notifications.mdx b/packages/docs/content/docs/ja/notifications.mdx new file mode 100644 index 00000000..0c3fc1cd --- /dev/null +++ b/packages/docs/content/docs/ja/notifications.mdx @@ -0,0 +1,34 @@ +--- +title: 通知 +description: セッションがあなたを必要とするとき、または完了したときに知らせます。 +--- + +# 通知 + +通知は、画面を見張っていなくても注意が必要なことを知らせます。セッションの完了、エラー、質問、何かを行うための権限要求などです。**Settings → OpenChamber → Notifications** で設定します。 + +## オンにする + +1. **Settings → OpenChamber → Notifications** を開きます。 +2. ブラウザまたはシステムが求めたら通知を許可します。 +3. 何について通知を受けたいか選びます。 + - セッションが**完了**した + - セッションで**エラー**が起きた + - セッションが**質問**した + - セッションが**権限**を必要としている + - **サブタスク**が完了した + +## 通知の届き方 + +- **デスクトップ**では、ネイティブのシステム通知を受け取ります +- **ブラウザまたはインストール済みアプリ**では、Web Push 通知を受け取るため、タブがバックグラウンドでも届きます + +自動承認に設定されたセッションでは、権限通知で何度も邪魔されることはありません。 + +## 文言をカスタマイズする + +通知の種類ごとに、エージェント名やモデルなどのフィールドを使ったタイトルとメッセージテンプレートを編集できます。直近メッセージをどれだけ含めるかにも上限があり、通知が短く保たれます。 + +## 関連 + +- [音声モード](/voice/) — 代わりに返信を読み上げで聞く diff --git a/packages/docs/content/docs/ja/opencode-server.mdx b/packages/docs/content/docs/ja/opencode-server.mdx new file mode 100644 index 00000000..27535e4a --- /dev/null +++ b/packages/docs/content/docs/ja/opencode-server.mdx @@ -0,0 +1,97 @@ +--- +title: OpenCode サーバー +description: OpenChamber をローカルまたはリモートの OpenCode サーバーに接続します。 +--- + +# OpenCode サーバー + +OpenChamber は OpenCode サーバーの上で動作します。デフォルトでは OpenChamber がサーバーを起動するため、何もする必要はありません。このページが必要なのは、すでに実行しているサーバーに OpenChamber を向けたい場合、または OpenChamber が起動するサーバーを管理したい場合だけです。 + +## OpenChamber がサーバーを見つける順序 + +OpenChamber の起動時、次の順序でサーバーを探します。 + +1. すでに起動済みのサーバーを再利用する +2. 指定されていれば外部サーバーに接続する(下記参照) +3. デフォルトポート(`4096`)のサーバーを自動検出する +4. それ以外の場合は、自分でサーバーを起動して管理する + +何も設定されていなければ、手順 4 が自動的に実行され、そのまま使えるようになります。 + +## すでに実行しているサーバーに接続する + +OpenChamber を起動する前に次を設定します。 + +```bash +OPENCODE_HOST=http://localhost:4096 OPENCODE_SKIP_START=true openchamber +``` + +- `OPENCODE_HOST` — ポートを含む OpenCode サーバーの完全なアドレスです(`http://localhost:4096` のような値)。末尾にパスを付けてはいけません。 +- `OPENCODE_SKIP_START=true` — OpenChamber に自分のサーバーを起動しないよう伝えます。 + +ポートだけを変えたい場合は、`OPENCODE_HOST` ではなく `OPENCODE_PORT` を設定します。 + +`OPENCODE_HOST` にポートがない、またはパスが含まれている場合、OpenChamber はそれを無視し、自分のサーバー起動にフォールバックします。期待した接続が行われなかった場合は、起動ログの `[config]` 警告を確認してください。 + +## CLI からサーバーを管理する + +```bash +openchamber status +openchamber logs +openchamber restart +openchamber stop +``` + +`openchamber` だけを実行すると、サーバーはバックグラウンドで起動します。ターミナルに接続したままにするには `--foreground` を追加します。 + +## ログイン時に OpenChamber を起動する + +`startup enable` を使うと、ネイティブのユーザーサービスをインストールできます。OpenChamber は macOS では `launchd`、Linux では `systemd --user`、Windows では Task Scheduler を使います。 + +```bash +openchamber startup enable +openchamber startup status +openchamber startup disable +``` + +UI を保護するには、サービスを有効化するときにパスワードを設定します。 + +```bash +OPENCHAMBER_UI_PASSWORD='secret' openchamber startup enable +``` + +ログイン時に起動し、デスクトップまたはモバイルクライアント向けに使うヘッドレスサーバーでは、`--api-only` と到達可能な host を含めます。 + +```bash +openchamber startup enable --port 3000 --api-only --host 0.0.0.0 --ui-password secret +``` + +`startup enable` は現在の環境をサービスにスナップショットするため、同じシェルから `openchamber` を起動した場合に近い動作になります。これにより、プロバイダートークン、`PATH`、SSH agent 設定、その他の CLI 認証/設定変数が利用できます。最小限のサービス環境にしたい場合は `--no-env-snapshot` を使ってください。 + +スタートアップサービスは `--port`、`--host`、`--ui-password`、`--api-only` を記憶します。CLI の restart と update restart は保存済み設定を再利用します。 + +別の OpenChamber アプリ向けに接続リンクを作成するには、次を使います。 + +```bash +openchamber connect-url --port 3000 --server http://your-host:3000 --qr +``` + +`openchamber connect-url --help` を実行すると、`--name`、`--lan`、`--server`、`--api-only`、`--ui-password`、`--qr` など、すべてのリンクオプションを確認できます。 + +その実行中サービスに対して、トンネルは独立して管理できます。 + +```bash +openchamber tunnel start --port 3000 +openchamber tunnel stop --port 3000 +``` + +トンネルを停止しても、サービスやアプリは再起動されません。 + +## "OpenCode is restarting" + +サーバーの起動中または再起動中、OpenChamber は "OpenCode is restarting" 状態を表示し、準備が整うまでリクエストを一時停止します。これは起動直後や再起動直後には正常です。いつまでも消えない場合は、[OpenCode 接続](/troubleshooting/opencode-connection/) を参照してください。 + +## 関連 + +- [プロバイダー、モデル、エージェント](/providers/) — サーバーの接続先を設定する +- [OpenCode 接続](/troubleshooting/opencode-connection/) — 接続できない場合 diff --git a/packages/docs/content/docs/ja/preview.mdx b/packages/docs/content/docs/ja/preview.mdx new file mode 100644 index 00000000..6b3e22fa --- /dev/null +++ b/packages/docs/content/docs/ja/preview.mdx @@ -0,0 +1,32 @@ +--- +title: プレビューと開発サーバー +description: 実行中の開発サーバーを OpenChamber 内で開きます。 +--- + +# プレビューと開発サーバー + +開発サーバーを起動すると、OpenChamber は別のブラウザタブではなくアプリ内で直接開けます。サイトをチャットの横で見ながら、コンソールを取得し、要素を指して質問できます。 + +## プレビューを開く + +OpenChamber はターミナル出力からローカルアドレスを監視します(Vite、Next.js、Astro などが表示する `Local:` 行)。見つけると次のことができます。 + +- ターミナルに **Open preview** ボタンが表示されます +- 自動オープンを有効にした [プロジェクトアクション](/project-actions/) が開きます +- チャットメッセージ内のローカルリンクからも開けます + +サイトはサイドパネルに読み込まれます。プレビューできるのはローカルアドレス(あなたのマシン上)のみです。 + +## コンソールと検査 + +プレビューパネルでは次のことができます。 + +- ページの **console** を見る — エラー、警告、ログを好きなようにフィルターできます +- **inspect** をオンにし、任意の要素をクリックして、そのメモ(セレクター、スタイル、位置、スクリーンショット)をそのままチャットへ送る + +これは「このボタン、ここ」とエージェントに伝える最速の方法です。 + +## 関連 + +- [プロジェクトアクション](/project-actions/) — サーバー起動時に自動で開く +- [デスクトップブラウザ](/desktop-browser/) — デスクトップで任意のページに同じツールを使う diff --git a/packages/docs/content/docs/ja/project-actions.mdx b/packages/docs/content/docs/ja/project-actions.mdx new file mode 100644 index 00000000..9d10579f --- /dev/null +++ b/packages/docs/content/docs/ja/project-actions.mdx @@ -0,0 +1,28 @@ +--- +title: プロジェクトアクション +description: よく実行するコマンドを保存し、ワンクリックで起動します。 +--- + +# プロジェクトアクション + +プロジェクトアクションは、一度保存してクリックで実行できるシェルコマンドです。開発サーバー、ビルド、テスト実行などに使えます。各プロジェクトが独自の一覧を持ちます。**Settings → Projects → Project Actions** で設定します。 + +## アクションを追加する + +1. **Settings → Projects** を開き、**Project Actions** セクションを見つけます。 +2. アクションを追加し、名前を付け、アイコンを選び、実行するコマンドを入力します。 +3. 保存します。 + +コマンドが特定の OS でしか意味を持たない場合は、アクションをその OS に限定できます。 + +## アクションを実行する + +アクションはアプリヘッダーのメニューにあります。クリックすると、OpenChamber はプロジェクトフォルダ内のターミナルで実行し、出力を見られるようにターミナルビューへ切り替えます。同じメニューから停止できます。 + +## 開発サーバーを自動で開く + +サーバーを起動するアクションでは **auto-open URL** をオンにします。OpenChamber は出力からローカルアドレスを監視し、開く候補として表示します。[プレビューと開発サーバー](/preview/) を参照してください。デスクトップでは SSH ポートフォワード経由にすることもできます。 + +## 関連 + +- [プレビューと開発サーバー](/preview/) — 実行中の開発サーバーを OpenChamber 内で開く diff --git a/packages/docs/content/docs/ja/project-icons.mdx b/packages/docs/content/docs/ja/project-icons.mdx new file mode 100644 index 00000000..f231193b --- /dev/null +++ b/packages/docs/content/docs/ja/project-icons.mdx @@ -0,0 +1,20 @@ +--- +title: プロジェクトアイコン +description: 各プロジェクトに見分けやすいアイコンを付けます。 +--- + +# プロジェクトアイコン + +プロジェクトアイコンがあると、プロジェクトをひと目で見分けやすくなります。OpenChamber は自動で見つけようとしますが、自分で設定することもできます。**Settings → Projects** で管理します。 + +## 自動検出 + +プロジェクトを追加すると、OpenChamber はその中の `favicon` ファイルを探し、プロジェクトアイコンとして使います。リポジトリにすでに favicon が含まれていれば、多くの場合アイコンは自動で表示されます。何もする必要はありません。 + +## 自分で設定する + +**Settings → Projects** を開き、画像をアップロードします(PNG、JPEG、SVG、最大 5 MB)。カスタム画像は自動検出されたものより優先されます。代わりに色を選ぶことも、画像を削除して自動検出に戻すこともできます。 + +## 関連 + +- [プロジェクト](/projects/) — プロジェクトの名前、色、整理 diff --git a/packages/docs/content/docs/ja/projects.mdx b/packages/docs/content/docs/ja/projects.mdx new file mode 100644 index 00000000..2c91f48d --- /dev/null +++ b/packages/docs/content/docs/ja/projects.mdx @@ -0,0 +1,34 @@ +--- +title: プロジェクト +description: 作業をプロジェクトに整理し、切り替えます。 +--- + +# プロジェクト + +プロジェクトは、OpenChamber が追跡するあなたのコンピューター上のフォルダです。通常は 1 つのコードベースです。プロジェクトを切り替えると、エージェントが作業するフォルダ、そのプロジェクトのセッション、設定も切り替わります。 + +## プロジェクトを追加する + +プロジェクトは複数の場所から追加できます。 + +- コマンドパレットの **Add project** 項目 +- セッションサイドバー上部の **+** ボタン +- ディレクトリを選ぶときのフォルダブラウザ + +フォルダを指定すると、OpenChamber が記憶します。名前はフォルダ名から取られますが、後で変更できます。 + +## プロジェクトを切り替える + +サイドバーからプロジェクトを選ぶとアクティブになります。セッション、Git、メモなどすべてが、開いているプロジェクトに追従します。 + +## プロジェクトを見分けやすくする + +**Settings → Projects** を開くと、プロジェクトにカスタム名、色、アイコンを設定できます。OpenChamber はアイコンを自動で探します。[プロジェクトアイコン](/project-icons/) を参照してください。 + +> VS Code では、OpenChamber は開いているフォルダを常に唯一のプロジェクトとして使います。そのため追加や切り替えはありません。Projects 設定ページも表示されません。 + +## 関連 + +- [プロジェクトのメモ、Todo、計画](/notes-todos-plans/) — プロジェクトごとに作業メモを保持する +- [プロジェクトアクション](/project-actions/) — よく実行するコマンドを保存する +- [コンテキスト](/context/) — セッションがモデルの記憶容量をどれだけ使っているかを見る diff --git a/packages/docs/content/docs/ja/providers.mdx b/packages/docs/content/docs/ja/providers.mdx new file mode 100644 index 00000000..56b1616b --- /dev/null +++ b/packages/docs/content/docs/ja/providers.mdx @@ -0,0 +1,49 @@ +--- +title: プロバイダー、モデル、エージェント +description: AI プロバイダーを接続し、モデルを選び、エージェントを設定します。 +--- + +# プロバイダー、モデル、エージェント + +OpenChamber が何かを行うには、少なくとも 1 つの AI プロバイダーが接続されている必要があります。このページでは、プロバイダーの接続、モデルの選択、エージェントの調整について説明します。 + +## プロバイダーを接続する + +1. **Settings → Providers** を開きます。 +2. **Add provider** メニューを開き、まだ接続されていないプロバイダーを選びます。 +3. プロバイダーに応じて、次のどちらかの方法でサインインします。 + - **API key** — キーを貼り付けて保存します。 + - **Sign-in (device flow)** — OpenChamber がリンクと短いコードを表示します。リンクを開き、コードを入力して承認します。OpenChamber が自動で接続を完了します。 + +プロバイダーが接続済みとして表示されると、そのモデルがチャットで使えるようになります。 + +切断するには、プロバイダーを開き、サインインを削除する操作を選びます。 + +## モデルを選ぶ + +作業している場所でモデルを選びます。 + +- チャットでは、メッセージバーのモデルピッカーを使って、そのセッションのプロバイダーとモデルを設定します +- エージェントごとに、デフォルトモデルを設定します(下記) + +## エージェントを設定する + +エージェントは、名前付きの設定です。モデル、人格、許可された操作をまとめます。 + +1. **Settings → Agents** を開きます。 +2. エージェントを選ぶか、新しく作成します。 +3. 次の項目を編集できます。 + - **description** — エージェントの用途 + - **model** — デフォルトモデル + - **temperature** — 回答の創造性 + - **prompt** — 常に従う固定指示 + - **tool rules** — 使用してよいツール + +## サインイン情報の保存場所 + +プロバイダーのサインイン情報は OpenChamber ではなく OpenCode に保存されるため、OpenCode CLI と共有されます。同じプロバイダーを複数の場所で設定した場合、最も具体的な設定が優先されます。プロジェクトごとの設定は個人設定を上書きします。 + +## 関連 + +- [MCP サーバー](/mcp/) — エージェントに追加ツールを加える +- [使用量とクォータ](/usage/) — 使った量を追跡する diff --git a/packages/docs/content/docs/ja/quickstart.mdx b/packages/docs/content/docs/ja/quickstart.mdx new file mode 100644 index 00000000..9b009d4c --- /dev/null +++ b/packages/docs/content/docs/ja/quickstart.mdx @@ -0,0 +1,26 @@ +--- +title: クイックスタート +description: OpenChamber をすばやく始め、タスクに合ったアプリを選びます。 +--- + +# クイックスタート + +## 最短手順 + +1. [OpenCode](https://opencode.ai) をインストールします。 +2. OpenChamber CLI をインストールします(1 行コマンドは [インストール](/install/) を参照)。 +3. `openchamber --ui-password be-creative-here` を実行します。 +4. CLI が表示する URL(通常は `http://localhost:3000`)を開きます。 +5. スマートフォンから使うには、[トンネル](/tunnels/) を開始して QR コードをスキャンします。 + +ブラウザに OpenChamber のセッション一覧が表示されるはずです。読み込まれれば、起動できています。 + +特にインスタンスをインターネットに公開する予定がある場合は、強力な UI パスワードを使ってください。 + +ページが読み込まれない場合は、[トラブルシューティング](/troubleshooting/) を確認してください。 + +## どのアプリを使うべきですか? + +- macOS での日常作業には **desktop** を使います +- リモートアクセスやスマートフォンからの確認には **web** を使います +- コードのすぐ横でセッションを使うには **VS Code** を使います diff --git a/packages/docs/content/docs/ja/remote-instances.mdx b/packages/docs/content/docs/ja/remote-instances.mdx new file mode 100644 index 00000000..44267ac0 --- /dev/null +++ b/packages/docs/content/docs/ja/remote-instances.mdx @@ -0,0 +1,42 @@ +--- +title: リモートインスタンス +description: デスクトップアプリを SSH 経由で別マシン上の OpenChamber に接続します。 +--- + +# リモートインスタンス + +デスクトップアプリは、作業サーバー、クラウドマシン、ホームラボなど、別マシン上で動く OpenChamber に SSH 経由で接続し、その UI をローカルのように画面へ表示できます。**Settings → Remote Instances** で設定します。 + +> リモートインスタンスは**デスクトップ専用**機能です。Web または VS Code では、代わりに [OpenCode サーバー](/opencode-server/) の環境変数を使ってリモートサーバーに接続します。 + +## リモートインスタンスを追加する + +1. **Settings → Remote Instances** を開き、追加します。 +2. そのマシンに接続するために普段使う SSH コマンドと、ニックネームを入力します。 +3. そこで OpenChamber をどう実行するか選びます。 + - **managed** — OpenChamber がリモートマシンに自分自身をインストールして起動します + - **external** — すでに実行中の OpenChamber に接続します +4. 接続します。 + +OpenChamber は接続確認、リモートのセットアップ、サーバー起動、ポート転送の各ステップを進め、現在の段階を表示します。**ready** に到達すると、リモート UI がローカルに読み込まれます。 + +## 認証情報 + +SSH と UI のパスワードを保存するか、毎回入力するかを選べます。接続が落ちた場合、OpenChamber はどのステップで失敗したかを表示するため、修正できます。[リモートアクセス](/troubleshooting/remote-access/) を参照してください。 + +## 直接接続リンク + +リモートマシンですでに OpenChamber が実行されている場合は、そこで接続リンクを作成し、**Settings → Remote Instances → Server links** でインポートします。 + +```bash +openchamber connect-url --port 3000 --server http://your-host:3000 --qr +``` + +`connect-url` は、そのポートで何も実行されていなければ先にサーバーを起動します。ヘッドレスサーバーには `--api-only`、起動時に LAN にバインドするには `--lan`、ブラウザアクセスを保護するには `--ui-password`、保存接続にラベルを付けるには `--name` を追加します。 + +生成されたリンクには OpenChamber アプリ用のクライアントトークンが含まれます。このトークンはブラウザ UI パスワードとは別で、取り消すか削除するまでサーバー再起動後も残ります。 + +## 関連 + +- [OpenCode サーバー](/opencode-server/) — Web または VS Code でリモートサーバーに接続する +- [リモートアクセス](/troubleshooting/remote-access/) — 接続が完了しない場合 diff --git a/packages/docs/content/docs/ja/reverse-proxy.mdx b/packages/docs/content/docs/ja/reverse-proxy.mdx new file mode 100644 index 00000000..161b70a8 --- /dev/null +++ b/packages/docs/content/docs/ja/reverse-proxy.mdx @@ -0,0 +1,347 @@ +--- +title: リバースプロキシ +description: Nginx、Nginx Proxy Manager、その他のリバースプロキシの背後で OpenChamber を正しく設定します。 +--- + +# リバースプロキシ + +Nginx、Nginx Proxy Manager、Caddy、Cloudflare、その他のリバースプロキシの背後で OpenChamber を実行する場合は、このページを使ってください。 + +## プロキシする前に + +1. まず OpenChamber が直接動くことを確認します。 +2. 同じネットワークから `http://:3000` またはカスタムポートを開きます。 +3. 直接接続が動いてから、リバースプロキシを追加します。 + +## プロキシが対応すべきもの + +- ライブメッセージ転送用の WebSocket: + - `/api/event/ws` + - `/api/global/event/ws` + - `/api/terminal/ws` +- バッファリングなしの SSE: + - `/api/event` + - `/api/global/event` + - `/api/notifications/stream` + - `/api/openchamber/events` + - `/api/terminal/:sessionId/stream` +- 添付ファイルとファイル操作向けの大きなリクエストボディ +- ライブストリームとターミナルセッション向けの長い read timeout + +## 重要なルール + +- WebSocket プロキシを有効にします。 +- SSE ルートではバッファリングを無効にします。 +- OpenChamber がすでにレスポンスを圧縮している場合、プロキシ側の gzip を無効にします。 +- 圧縮を有効にする層は 1 つだけにします。 +- `Host`、`X-Forwarded-For`、`X-Forwarded-Proto` など通常のプロキシヘッダーを転送します。 +- ユーザーがファイルをアップロードする場合は body size limit を増やします。 + +## クイックチェックリスト + +- OpenChamber に LAN から直接到達できる +- プロキシで WebSocket が有効 +- SSE ルートのバッファリングがオフ +- プロキシホストで `gzip off`、または別の方法でプロキシ圧縮が無効 +- `client_max_body_size` が添付ファイルに十分な大きさ +- `proxy_read_timeout` がストリームに十分長い + +## 例: Nginx + +
+設定例を表示 + +```nginx +client_max_body_size 50M; +client_body_buffer_size 50M; +proxy_request_buffering off; + +proxy_http_version 1.1; +proxy_set_header Connection ""; +proxy_set_header Host $host; +proxy_set_header X-Real-IP $remote_addr; +proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +proxy_set_header X-Forwarded-Proto $scheme; +proxy_set_header X-Forwarded-Host $host; + +gzip off; + +location = /api/terminal/ws { + proxy_pass http://127.0.0.1:3000; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; +} + +location = /api/global/event/ws { + proxy_pass http://127.0.0.1:3000; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; +} + +location = /api/event/ws { + proxy_pass http://127.0.0.1:3000; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; +} + +location ~ ^/api/(event|global/event|notifications/stream|openchamber/events)$ { + proxy_pass http://127.0.0.1:3000; + proxy_set_header Accept "text/event-stream"; + proxy_set_header Cache-Control "no-cache"; + proxy_buffering off; + proxy_cache off; + gzip off; + add_header X-Accel-Buffering "no" always; + add_header Cache-Control "no-cache, no-transform" always; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; +} + +location ~ ^/api/terminal/.+/stream$ { + proxy_pass http://127.0.0.1:3000; + proxy_set_header Accept "text/event-stream"; + proxy_set_header Cache-Control "no-cache"; + proxy_buffering off; + proxy_cache off; + gzip off; + add_header X-Accel-Buffering "no" always; + add_header Cache-Control "no-cache, no-transform" always; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; +} + +location /api { + proxy_pass http://127.0.0.1:3000; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; +} + +location / { + proxy_pass http://127.0.0.1:3000; +} +``` + +
+ +## 例: Nginx Proxy Manager + +
+Advanced タブの例を表示 + +```nginx +client_max_body_size 50M; +client_body_buffer_size 50M; +proxy_request_buffering off; + +proxy_http_version 1.1; +proxy_set_header Connection ""; +proxy_set_header Host $host; +proxy_set_header X-Real-IP $remote_addr; +proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +proxy_set_header X-Forwarded-Proto $scheme; +proxy_set_header X-Forwarded-Host $host; + +gzip off; + +location = /api/terminal/ws { + proxy_pass http://127.0.0.1:3000; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_connect_timeout 30s; +} + +location = /api/global/event/ws { + proxy_pass http://127.0.0.1:3000; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_connect_timeout 30s; +} + +location = /api/event/ws { + proxy_pass http://127.0.0.1:3000; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_connect_timeout 30s; +} + +location = /api/event { + proxy_pass http://127.0.0.1:3000; + proxy_set_header Accept "text/event-stream"; + proxy_set_header Cache-Control "no-cache"; + proxy_buffering off; + proxy_cache off; + gzip off; + add_header X-Accel-Buffering "no" always; + add_header Cache-Control "no-cache, no-transform" always; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_connect_timeout 30s; +} + +location = /api/global/event { + proxy_pass http://127.0.0.1:3000; + proxy_set_header Accept "text/event-stream"; + proxy_set_header Cache-Control "no-cache"; + proxy_buffering off; + proxy_cache off; + gzip off; + add_header X-Accel-Buffering "no" always; + add_header Cache-Control "no-cache, no-transform" always; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_connect_timeout 30s; +} + +location = /api/notifications/stream { + proxy_pass http://127.0.0.1:3000; + proxy_set_header Accept "text/event-stream"; + proxy_set_header Cache-Control "no-cache"; + proxy_buffering off; + proxy_cache off; + gzip off; + add_header X-Accel-Buffering "no" always; + add_header Cache-Control "no-cache, no-transform" always; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_connect_timeout 30s; +} + +location = /api/openchamber/events { + proxy_pass http://127.0.0.1:3000; + proxy_set_header Accept "text/event-stream"; + proxy_set_header Cache-Control "no-cache"; + proxy_buffering off; + proxy_cache off; + gzip off; + add_header X-Accel-Buffering "no" always; + add_header Cache-Control "no-cache, no-transform" always; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_connect_timeout 30s; +} + +location ~ ^/api/terminal/.+/stream$ { + proxy_pass http://127.0.0.1:3000; + proxy_set_header Accept "text/event-stream"; + proxy_set_header Cache-Control "no-cache"; + proxy_buffering off; + proxy_cache off; + gzip off; + add_header X-Accel-Buffering "no" always; + add_header Cache-Control "no-cache, no-transform" always; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_connect_timeout 30s; +} + +location /api { + proxy_pass http://127.0.0.1:3000; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_connect_timeout 30s; +} + +location / { + proxy_pass http://127.0.0.1:3000; +} +``` + +
+ +このホストでは Nginx Proxy Manager の `Websockets Support` も有効にしてください。 + +## よくある失敗の兆候 + +### ページは読み込まれるが、メッセージ送信に失敗する + +- プロキシで WebSocket が有効になっていません +- `/api/event/ws` または `/api/global/event/ws` が正しく通っていません + +### 通知またはライブ状態が更新されない + +- SSE ルートのどれかがバッファリングまたはキャッシュされています +- `X-Accel-Buffering "no"` がありません + +### ファイルアップロードに失敗する + +- `client_max_body_size` が小さすぎます + +### ローカルではすべて動くが、プロキシ背後でだけ壊れる + +- プロキシがライブ通信を圧縮またはバッファリングしています +- プロキシに WebSocket 対応がありません + +## 例: Caddy + +
+設定例を表示 + +```caddy +reverse_proxy 127.0.0.1:3000 { + # WebSocket support is automatic in Caddy + + # Flush SSE responses immediately + flush_interval -1 + + # Pass through Host and proxy headers + header_up Host {host} + header_up X-Real-IP {remote_host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + + # Increase timeouts for long-lived streams + transport http { + read_timeout 3600s + write_timeout 3600s + } +} +``` + +
+ +Caddy は WebSocket upgrade を自動で処理します。追加設定は不要です。`flush_interval -1` ディレクティブにより、SSE チャンクがバッファリングされずすぐ転送されます。 + +## CDN と二重圧縮の警告 + +リバースプロキシの前に Cloudflare などの CDN を置く場合、二重圧縮に注意してください。 + +- OpenChamber は HTTP レスポンスを gzip 圧縮します(しきい値 1 KB)。 +- Cloudflare や他の CDN もデフォルトでレスポンスを圧縮します。 +- これにより、二重圧縮されたレスポンスや誤った `Content-Encoding` ヘッダーが発生することがあります。 + +これを避けるには、**どちらか一方**の層で圧縮を無効にします。 + +- **Cloudflare:** Rules → Compression → disable(または "Passthrough" mode を使用)。 +- **Nginx:** `gzip off`(上の例に含まれています)。 +- **Caddy:** upstream がすでに圧縮済みコンテンツを送る場合、Caddy はデフォルトでは再圧縮しません。 + +SSE ストリーミングルートは OpenChamber 側で圧縮対象外ですが、CDN がまだバッファリングする場合があります。SSE パスでバッファリングを無効にする方法は、CDN のドキュメントを確認してください。 + +## 関連 + +- [トンネル](/tunnels/) +- [トラブルシューティング](/troubleshooting/) diff --git a/packages/docs/content/docs/ja/scheduled-tasks.mdx b/packages/docs/content/docs/ja/scheduled-tasks.mdx new file mode 100644 index 00000000..a9b5301c --- /dev/null +++ b/packages/docs/content/docs/ja/scheduled-tasks.mdx @@ -0,0 +1,33 @@ +--- +title: スケジュールタスク +description: プロンプトをスケジュールに従って自動実行します。 +--- + +# スケジュールタスク + +スケジュールタスクは、たとえば毎日の「昨日の変更を要約」や毎週の整理のように、指定したスケジュールでプロンプトを実行します。実行時には OpenChamber が新しいセッションを開始し、自動でプロンプトを送信します。セッションサイドバー上部のボタンからスケジューラーを開きます。 + +## タスクを作成する + +1. セッションサイドバーからスケジュールタスクダイアログを開きます。 +2. タスクを追加し、名前を付けます。 +3. 実行タイミングを選びます。 + - **daily** — 毎日 1 つ以上の時刻 + - **weekly** — 選んだ曜日と時刻 + - **once** — 1 回だけの日時 +4. 実行内容を設定します。送信するプロンプト、使用するプロバイダー、モデル、エージェントです。プロンプトには `/review` のようなスラッシュコマンドも使えます。 +5. 保存し、タスクが有効になっていることを確認します。 + +任意のタスクは **run now** ですぐ実行でき、期待通り動くか確認できます。 + +## 成功時の見え方 + +実行後、タスクには最後に実行された時刻、成功したかどうか、作成されたセッションへのリンクが表示されます。実行に失敗した場合は、エラーもそこに表示されます。 + +## 注意点 + +タスクは OpenChamber サーバーが実行中の間だけ発火します。閉じると、サーバーが戻るまでスケジュール実行は一時停止します。 + +## 関連 + +- [コマンドとスニペット](/commands-snippets/) — スラッシュコマンドをプロンプトとして再利用する diff --git a/packages/docs/content/docs/ja/security.mdx b/packages/docs/content/docs/ja/security.mdx new file mode 100644 index 00000000..9beda111 --- /dev/null +++ b/packages/docs/content/docs/ja/security.mdx @@ -0,0 +1,37 @@ +--- +title: セキュリティ +description: 公開する前に、パスワードとパスキーで UI を保護します。 +--- + +# セキュリティ + +OpenChamber はあなたのマシンとコードへのアクセスを提供するため、あなた以外が到達できるようにする前に必ず保護してください。このページでは UI パスワード、パスキー、ネットワークに OpenChamber を公開する前に知っておくべきことを説明します。 + +## UI パスワードを設定する + +パスワード付きで OpenChamber を起動すると、ブラウザ UI がそれを要求します。 + +```bash +openchamber --ui-password be-creative-here +``` + +コマンドラインに書く代わりに、`OPENCHAMBER_UI_PASSWORD` 環境変数で設定することもできます。サインイン後、OpenChamber はしばらくそのデバイスを記憶するため、毎回求められることはありません。 + +インスタンスが自分以外から到達可能な場合は、必ずパスワードを設定してください。特に [トンネル](/tunnels/) や public internet 経由の場合は重要です。 + +## パスキー + +パスワードを設定すると、より速くサインインするためにパスキー(Face ID、Touch ID、セキュリティキー)を追加できます。**Settings → OpenChamber → Passkeys** で追加します。 + +パスキーは現在のパスワードに紐づきます。パスワードを変更または削除すると、保存済みパスキーは消去され、再追加が必要になります。 + +## 公開する前に + +- デフォルトでは、OpenChamber はあなたのマシン上(`127.0.0.1`)でのみ待ち受けます。より広く待ち受けるには明示的な変更が必要で、その前にパスワードを設定するべきです。 +- インターネットにポートを開けるより、[トンネル](/tunnels/) または VPN のようなプライベートネットワークを推奨します。 +- OpenChamber を自分の HTTPS サーバーの背後に置く場合は、[リバースプロキシ](/reverse-proxy/) を参照してください。 + +## 関連 + +- [トンネル](/tunnels/) — インスタンスへリモートアクセスする推奨方法 +- [リバースプロキシ](/reverse-proxy/) — OpenChamber を自分のサーバー背後で実行する diff --git a/packages/docs/content/docs/ja/skills-catalog.mdx b/packages/docs/content/docs/ja/skills-catalog.mdx new file mode 100644 index 00000000..c45fa791 --- /dev/null +++ b/packages/docs/content/docs/ja/skills-catalog.mdx @@ -0,0 +1,27 @@ +--- +title: スキルカタログ +description: 既製のスキルを探してインストールします。 +--- + +# スキルカタログ + +Skills Catalog では、自分で書く代わりに、他の人が公開したスキルをインストールできます。**Settings → Skills → Catalog** から開きます。 + +自分でスキルを書く場合は、[スキル](/skills/) を参照してください。 + +## スキルをインストールする + +1. カタログを開きます。 +2. 組み込みソース(Anthropic skills repo と ClawdHub community registry)を閲覧するか、検索します。 +3. スキルを選び、インストールします。 +4. インストール先を選びます。すべての作業で使うか、現在のプロジェクトだけで使うかです。 + +同じ名前のスキルがすでにある場合、OpenChamber はどうするかを確認します。スキップ、上書き、またはスキルごとに判断できます。 + +## 独自ソースを追加する + +任意の Git リポジトリを、`owner/repo` 名または完全な Git URL でソースとして追加できます。プライベートリポジトリには、あなたのマシン上でアクセス設定(SSH キーまたは保存済み認証情報)が必要です。ソースが認証できない場合、カタログは黙って失敗せず、その旨を表示します。 + +## 関連 + +- [スキル](/skills/) — インストール済みスキルを作成・管理する diff --git a/packages/docs/content/docs/ja/skills.mdx b/packages/docs/content/docs/ja/skills.mdx new file mode 100644 index 00000000..cde47de4 --- /dev/null +++ b/packages/docs/content/docs/ja/skills.mdx @@ -0,0 +1,30 @@ +--- +title: スキル +description: エージェントが必要に応じて読み込む再利用可能な指示を作成します。 +--- + +# スキル + +スキルは、関連があるときにエージェントが取り込める再利用可能な指示セットです。たとえば「コミットメッセージの書き方」や「自社 API の規約」などです。**Settings → Skills** で管理します。 + +自分で書く代わりに既製のスキルをインストールする場合は、[スキルカタログ](/skills-catalog/) を参照してください。 + +## スキルを作成する + +1. **Settings → Skills** を開きます。 +2. スキルを作成し、名前と短い説明を付けます。説明はエージェントがそのスキルをいつ適用するか判断する材料なので、具体的に書いてください。 +3. 指示を書きます。必要なら補助ファイルを追加します。 +4. 保存場所を選びます。 + - **personal** — すべてのプロジェクトで利用できます + - **project** — 現在のプロジェクトでのみ利用できます + +## チャットでスキルを使う + +メッセージの途中で `/` を入力するとスキルピッカーが表示されます。そこから選ぶと、エージェントはそのスキルの指示を返信に読み込みます。 + +メッセージの先頭にある `/` は、代わりに [コマンド](/commands-snippets/) を開きます。 + +## 関連 + +- [スキルカタログ](/skills-catalog/) — 他の人が公開したスキルをインストールする +- [コマンドとスニペット](/commands-snippets/) — チャットでテキストを再利用する別の方法 diff --git a/packages/docs/content/docs/ja/ssh-hosts-proxying.mdx b/packages/docs/content/docs/ja/ssh-hosts-proxying.mdx new file mode 100644 index 00000000..d7b5ac46 --- /dev/null +++ b/packages/docs/content/docs/ja/ssh-hosts-proxying.mdx @@ -0,0 +1,48 @@ +--- +title: SSH ホストとプロキシ +description: 保存済み SSH ホストをインポートし、デスクトップアプリで追加の SSH ポートフォワードを設定します。 +--- + +# SSH ホストとプロキシ + +デスクトップアプリの **Settings → Remote Instances** を使うと、保存済み SSH ホストのインポート、リモートマシンへの接続、同じ SSH 接続を通じた追加ポートの利用ができます。 + +> SSH ホストと SSH プロキシは**デスクトップ専用**機能です。あなたのコンピューター上の SSH クライアントを使います。 + +## SSH ホストをインポートする + +OpenChamber は、`ssh work-server` のようなコマンドが使うのと同じローカル SSH config からホストを読み取れます。 + +1. **Settings → Remote Instances** を開きます。 +2. **Saved SSH hosts** からホストを選びます。 +3. ホストがパターンの場合は、`deploy@app.example.com` のような実際の宛先を入力します。 +4. 保存して接続します。 + +OpenChamber はその SSH コマンドを使ってリモートインスタンスを作成します。**ready** に到達すると、リモート OpenChamber UI がデスクトップアプリ内で開きます。 + +## 追加のポートフォワードを加える + +各リモートインスタンスには **Port Forwards** セクションがあります。SSH 接続の片側にあるものが、もう片側のポートへ到達する必要があるときに使います。 + +OpenChamber は 3 種類のフォワードに対応しています。 + +- **Local (-L)** — あなたのコンピューター上にポートを開き、リモートマシン上の何かへ接続します。 +- **Remote (-R)** — リモートマシン上にポートを開き、あなたのコンピューターへ戻って接続します。 +- **Dynamic (-D)** — SSH 接続を通じてローカル SOCKS プロキシを開きます。 + +リモートマシン上で動く多くのアプリプレビューやダッシュボードには、**Local (-L)** を使います。 + +## SOCKS プロキシを使う + +あなたのコンピューター上の他のツールから、リモートマシン経由でブラウズしたい場合は **Dynamic (-D)** を選びます。OpenChamber はその SSH 接続用のローカル SOCKS プロキシポートを開きます。 + +接続が ready になったら、フォワード行からローカルプロキシアドレスをコピーまたは使用します。ブラウザやツールで SOCKS5 プロキシとして設定してください。 + +## 非公開に保つ + +転送ポートにネットワーク上の他のデバイスから意図的にアクセスさせたい場合を除き、ローカル bind host には `127.0.0.1` または `localhost` を使ってください。 + +## 関連 + +- [リモートインスタンス](/remote-instances/) — デスクトップアプリを別マシン上の OpenChamber に接続する +- [リモートアクセス](/troubleshooting/remote-access/) — SSH またはリモートアクセスが接続できない場合 diff --git a/packages/docs/content/docs/ja/themes.mdx b/packages/docs/content/docs/ja/themes.mdx new file mode 100644 index 00000000..4b125972 --- /dev/null +++ b/packages/docs/content/docs/ja/themes.mdx @@ -0,0 +1,30 @@ +--- +title: テーマ +description: 組み込みテーマとユーザー定義テーマで OpenChamber をカスタマイズします。 +--- + +# テーマ + +OpenChamber は組み込みテーマとカスタムテーマ JSON ファイルに対応しています。 + +## カスタムテーマを追加する + +1. テーマディレクトリを作成します。 + +```bash +mkdir -p ~/.config/openchamber/themes +``` + +2. そのディレクトリに JSON ファイルを追加します(例: `my-theme.json`)。 +3. OpenChamber を開き、**Settings -> Theme -> Reload themes** に移動します。 +4. ドロップダウンからテーマを選びます。すぐに適用されます。 + +## テーマの場所 + +- macOS/Linux: `~/.config/openchamber/themes/` + +## 完全な JSON 形式リファレンス + +メインリポジトリのドキュメントにある完全な形式ガイドを使ってください。 + +- [`docs/CUSTOM_THEMES.md`](https://github.com/openchamber/openchamber/blob/main/docs/CUSTOM_THEMES.md) diff --git a/packages/docs/content/docs/ja/troubleshooting.mdx b/packages/docs/content/docs/ja/troubleshooting.mdx new file mode 100644 index 00000000..559d08bf --- /dev/null +++ b/packages/docs/content/docs/ja/troubleshooting.mdx @@ -0,0 +1,40 @@ +--- +title: トラブルシューティング +description: セットアップや実行時によくある問題と、すぐ試せる修正です。 +--- + +# トラブルシューティング + +問題に当たりましたか?下の症状を探し、修正を試してください。 + +## OpenChamber コマンドが終了する、または起動に失敗する + +- Node.js が `>=22` であることを確認します +- `openchamber --version` を実行します +- 必要なら最新の CLI を再インストールします + +## Web UI に到達できない + +- `openchamber logs` でサーバーログを確認します +- アクティブなポートを確認します(デフォルトは `3000`) +- トンネルリンクを試す前に、まず `http://localhost:3000` を直接開きます + +## リモート/トンネルリンクが動かない + +- `openchamber tunnel status --all` を実行します +- 同じインスタンス/ポートからトンネルを再起動します +- 以前のトークンがすでに使用済みなら、接続リンクを再生成します + +完全なセットアップは [トンネル](/tunnels/) を参照してください。リバースプロキシを使っている場合は [リバースプロキシ](/reverse-proxy/) も参照してください。 + +## VS Code 拡張機能が接続しない + +- OpenChamber サーバーが実行中であることを確認します +- 拡張機能が更新済みであることを確認します +- VS Code ウィンドウを再読み込みし、接続を再試行します + +## 関連 + +- [クイックスタート](/quickstart/) +- [トンネル](/tunnels/) +- [リバースプロキシ](/reverse-proxy/) diff --git a/packages/docs/content/docs/ja/troubleshooting/opencode-connection.mdx b/packages/docs/content/docs/ja/troubleshooting/opencode-connection.mdx new file mode 100644 index 00000000..f3b4cfe4 --- /dev/null +++ b/packages/docs/content/docs/ja/troubleshooting/opencode-connection.mdx @@ -0,0 +1,34 @@ +--- +title: OpenCode 接続 +description: OpenChamber が OpenCode サーバーへ接続できない問題を修正します。 +--- + +# OpenCode 接続 + +OpenChamber は読み込まれるのに "OpenCode is restarting" から進まない、またはチャットが応答しない場合、接続先サーバーに到達できていません。次を順に確認してください。 + +## "OpenCode is restarting" のまま止まっている + +- 起動直後は少し待ってください。サーバー起動中はこの状態が正常です +- `openchamber status` でサーバーが生きているか確認します +- `openchamber restart` で再起動します +- `openchamber logs` で起動詳細を確認します + +## 自分のサーバーへ接続している + +既存サーバーを使うよう OpenChamber を設定した場合は、[OpenCode サーバー](/opencode-server/) の設定を再確認してください。 + +- `OPENCODE_HOST` はポートを含み、パスを含まない必要があります。例: `http://localhost:4096` +- `OPENCODE_SKIP_START=true` を設定し、OpenChamber が自分のサーバーも起動しないようにします +- アドレスが無効な場合、OpenChamber はそれを無視して自分のサーバーを起動します。ログの `[config]` 警告を探してください + +## まだ失敗する場合 + +- Node.js がバージョン `20` 以上であることを確認します +- 最新の CLI を再インストールします +- トンネルやリモートリンクを試す前に、`http://localhost:3000` を直接開きます + +## 関連 + +- [OpenCode サーバー](/opencode-server/) — OpenChamber がサーバーを見つけて管理する方法 +- [トラブルシューティング](/troubleshooting/) — その他のよくある問題 diff --git a/packages/docs/content/docs/ja/troubleshooting/remote-access.mdx b/packages/docs/content/docs/ja/troubleshooting/remote-access.mdx new file mode 100644 index 00000000..84138c5e --- /dev/null +++ b/packages/docs/content/docs/ja/troubleshooting/remote-access.mdx @@ -0,0 +1,38 @@ +--- +title: リモートアクセス +description: トンネル、リモートインスタンス、別デバイスからの OpenChamber アクセスを修正します。 +--- + +# リモートアクセス + +スマートフォンや別マシンから OpenChamber に到達できない場合、修正方法は接続方法によって変わります。 + +## まず基本を確認する + +- 同じコンピューターで先に `http://localhost:3000` を開きます。失敗する場合はリモートの問題ではありません。[OpenCode 接続](/troubleshooting/opencode-connection/) を参照してください +- `openchamber status` でサーバーが実行中であることを確認します + +## トンネルリンクが動かない + +- `openchamber tunnel status --all` を実行します +- 同じインスタンスとポートからトンネルを再起動します +- 前のリンクがすでに使用済みなら、接続リンクを再生成します + +完全なセットアップは [トンネル](/tunnels/) を参照してください。 + +## リモートインスタンスが接続しない(デスクトップ) + +[リモートインスタンス](/remote-instances/) が止まった場合、OpenChamber は失敗したステップ名を表示します。 + +- **auth** — SSH または UI パスワードが拒否されました。再入力してください +- **install / start** — OpenChamber がリモートマシン上でサーバーをセットアップまたは起動できませんでした。そのマシンの要件を確認してください +- **forwarding** — 接続はできていますが、ポートが届いていません。別のローカルポートを試してください + +## 自分のサーバーの背後にある場合 + +OpenChamber をリバースプロキシの背後に置いていて、表示がおかしい、または接続できない場合は [リバースプロキシ](/reverse-proxy/) を参照してください。 + +## 関連 + +- [トンネル](/tunnels/) · [リモートインスタンス](/remote-instances/) · [リバースプロキシ](/reverse-proxy/) +- [セキュリティ](/security/) — 公開する前に UI を保護する diff --git a/packages/docs/content/docs/ja/troubleshooting/worktrees-git.mdx b/packages/docs/content/docs/ja/troubleshooting/worktrees-git.mdx new file mode 100644 index 00000000..099f2aca --- /dev/null +++ b/packages/docs/content/docs/ja/troubleshooting/worktrees-git.mdx @@ -0,0 +1,35 @@ +--- +title: Worktrees と Git +description: よくある worktree と Git の問題を修正します。 +--- + +# Worktrees と Git + +[worktree セッション](/worktrees/) と [Git ビュー](/git/) で起きる問題と、その解消方法です。 + +## worktree に注意が必要 + +OpenChamber は、worktree に異常があるとフラグを立てます。 + +- **folder missing** — worktree のフォルダが削除された、または OpenChamber 外で移動されました。セッションを削除し、新しい worktree を作成してください +- **detached or unborn branch** — worktree が通常のブランチ上にありません。ブランチをチェックアウトしてください +- **merge, rebase, or cherry-pick in progress** — 操作が途中で残っています。Git ビューから完了または中止してください + +## worktree を作成できない + +- **branch already exists** — 別のブランチ名を選ぶか、既存ブランチのオプションを使います +- **name already in use** — 別の worktree 名を選びます + +## コミットまたは PR 生成に失敗する + +コミットメッセージや PR 説明の生成はアクティブなセッションで実行されるため、動作するモデルが選択されたセッションを開いておく必要があります。セッションを開くか選び直して、もう一度試してください。 + +## SSH または Windows パスの問題 + +- リポジトリが使う SSH キーが [Git ID](/git-identities/) に設定したものか確認します +- Windows では Git が Unix 形式のパス(`/c/Users/...` など)を使います。OpenChamber はこれを扱えますが、カスタム SSH キーパスも同じ形式にしてください + +## 関連 + +- [Worktree セッション](/worktrees/) — worktree の作成と削除の仕組み +- [Git ID](/git-identities/) — リポジトリごとに正しいキーと ID を設定する diff --git a/packages/docs/content/docs/ja/tunnels.mdx b/packages/docs/content/docs/ja/tunnels.mdx new file mode 100644 index 00000000..7eaf6c30 --- /dev/null +++ b/packages/docs/content/docs/ja/tunnels.mdx @@ -0,0 +1,117 @@ +--- +title: トンネル +description: リモートおよびモバイルアクセス向けに OpenChamber を安全に公開します。 +--- + +# トンネル + +トンネルは OpenChamber への公開リンクです。スマートフォンや別ネットワークからアクセスできます。実行中のインスタンスに対して作成するには `openchamber tunnel` を使います。 + +## 前提条件 + +OpenChamber はあなたのマシン上でトンネルプロバイダー CLI を起動します。使いたいプロバイダーを先にインストールしてください。 + +```bash +brew install cloudflared +brew install ngrok +``` + +Cloudflare quick tunnel は `cloudflared` で実行できます。Ngrok には ngrok アカウントと ngrok ダッシュボードの authtoken が必要です。 + +```bash +ngrok config add-authtoken +``` + +## クイックスタート + +1. OpenChamber を起動します。 + +```bash +openchamber +``` + +この手順を省いた場合、`openchamber tunnel start` は CLI サーバーを自動起動できます。自動起動時には `--port`、`--host`、`--lan`、`--ui-password`、`--api-only` などのサーバーオプションを渡せます。 + +2. Cloudflare トンネルを開始します。 + +```bash +openchamber tunnel start --provider cloudflare --mode quick +``` + +または Ngrok トンネルを開始します。 + +```bash +openchamber tunnel start --provider ngrok --mode quick +``` + +3. 状態を確認します。 + +```bash +openchamber tunnel status +``` + +トンネルが起動すると、`status` に公開 URL が表示されます。それを開くか QR コードをスキャンすると、どこからでも OpenChamber にアクセスできます。 + +デフォルトでは、OpenChamber は対話型 TTY セッションで QR コードを表示します。QR 出力を強制するには `--qr`、無効にするには `--no-qr` を使います。 + +## プロバイダー + +- `cloudflare`: quick、managed remote、managed local モード +- `ngrok`: quick モード + +## 管理対象モード + +### Managed remote + +Cloudflare が管理する token + hostname を使います。 + +```bash +openchamber tunnel start --provider cloudflare --mode managed-remote --token-file ~/.secrets/cf-token --hostname app.example.com +``` + +### Managed local + +ローカルの `cloudflared` 設定を使います。 + +```bash +openchamber tunnel start --provider cloudflare --mode managed-local --config ~/.cloudflared/config.yml +``` + +## プロファイル(managed-remote) + +再利用可能なプロファイルを保存します。 + +```bash +openchamber tunnel profile add --provider cloudflare --mode managed-remote --name prod-main --hostname app.example.com --token-file ~/.secrets/cf-token +``` + +保存したプロファイルで起動します。 + +```bash +openchamber tunnel start --profile prod-main +``` + +## 便利なコマンド + +```bash +openchamber tunnel providers +openchamber tunnel ready --provider cloudflare +openchamber tunnel ready --provider ngrok +openchamber tunnel doctor --provider cloudflare +openchamber tunnel doctor --provider ngrok +openchamber tunnel stop --port 3000 +``` + +## 動作メモ + +- OpenChamber インスタンス(ポート)ごとにアクティブなトンネルは 1 つです +- 同じインスタンスで新しい mode/provider を開始すると、前のトンネルは置き換えられます +- 新しい接続リンクを生成すると、以前の未使用リンクは無効化されます +- トンネルの自動起動は、restart/update フローで使うインスタンス設定に `--ui-password` や `--api-only` などのサーバーフラグを保持します + +## 関連 + +- [セキュリティ](/security/) — 公開する前に UI を保護する +- [デスクトップトンネル](/desktop-tunnels/) — CLI 起動なしでデスクトップアプリからトンネルを設定する +- [PWA とモバイル](/mobile/) — スマートフォンから OpenChamber にアクセスする +- [トラブルシューティング](/troubleshooting/) — トンネルリンクが動かない場合 diff --git a/packages/docs/content/docs/ja/updates.mdx b/packages/docs/content/docs/ja/updates.mdx new file mode 100644 index 00000000..66d1ed23 --- /dev/null +++ b/packages/docs/content/docs/ja/updates.mdx @@ -0,0 +1,31 @@ +--- +title: 更新 +description: デスクトップ、Web、VS Code の OpenChamber を最新に保ちます。 +--- + +# 更新 + +OpenChamber の更新方法は、インストール方法によって異なります。どの場合でも、現在のバージョンは **Settings → OpenChamber → About** で確認できます。 + +## デスクトップアプリ + +デスクトップアプリは GitHub releases を確認して更新を探します。更新がある場合、OpenChamber が知らせ、あなたが選んだときにダウンロードし、次回再起動時にインストールします。常にあなたが制御します。勝手にインストールされることはありません。 + +## Web / CLI + +CLI をインストールした場合は、**About** の確認・更新ボタンから、またはターミナルから更新できます。 + +```bash +openchamber update +``` + +OpenChamber はインストール方法(npm、pnpm、yarn、bun)を検出し、適切な更新を実行します。 + +## OpenCode サーバー + +OpenChamber と OpenCode は別々に更新されます。新しい OpenCode バージョンが利用可能な場合、OpenChamber は更新を提案し、その後サーバーを再起動します。そのサーバーの管理方法は [OpenCode サーバー](/opencode-server/) を参照してください。 + +## 関連 + +- [インストール](/install/) — 各アプリのインストール方法 +- [OpenCode サーバー](/opencode-server/) — 下層サーバーの更新 diff --git a/packages/docs/content/docs/ja/usage.mdx b/packages/docs/content/docs/ja/usage.mdx new file mode 100644 index 00000000..c05a2f9d --- /dev/null +++ b/packages/docs/content/docs/ja/usage.mdx @@ -0,0 +1,28 @@ +--- +title: 使用量とクォータ +description: プロバイダープランの使用量を追跡します。 +--- + +# 使用量とクォータ + +Usage ページでは、各プロバイダープランをどれだけ使ったかを確認でき、上限にどれだけ近いかが分かります。**Settings → Usage** から開きます。 + +## 表示されるもの + +接続済みプロバイダーごとに、OpenChamber は次を表示します。 + +- 現在のウィンドウでどれだけ使ったかをバーで表示 +- モデル別の内訳 +- 使い切りそうなペースか分かる pace インジケーター + +表示するプロバイダーは選べます。同じサマリーはアプリヘッダーのドロップダウンからも確認できます。 + +## 対応プロバイダー + +Usage は、Claude、Codex、GitHub Copilot、Google、OpenRouter、Kimi、NanoGPT、z.ai、Zhipu、MiniMax、Ollama Cloud、Wafer など、クォータを公開しているプロバイダーで動作します。 + +プロバイダーの使用量は、[プロバイダー](/providers/) ページでサインインした後に表示されます。一部のプロバイダーには追加手順が必要です。たとえば Ollama Cloud は別途設定したセッションファイルを読み取ります。プロバイダーにデータが表示されない場合、多くはその追加認証情報が不足しています。 + +## 関連 + +- [プロバイダー、モデル、エージェント](/providers/) — 使用量を表示する前にサインインする diff --git a/packages/docs/content/docs/ja/voice.mdx b/packages/docs/content/docs/ja/voice.mdx new file mode 100644 index 00000000..62ac5103 --- /dev/null +++ b/packages/docs/content/docs/ja/voice.mdx @@ -0,0 +1,36 @@ +--- +title: 音声モード +description: OpenChamber に話しかけ、返信を読み上げで聞きます。 +--- + +# 音声モード + +音声モードでは、メッセージを音声入力し、返信を読み上げで聞けます。**Settings → OpenChamber → Voice** でオンにします。 + +## 返信の読み上げ(text-to-speech) + +返信の読み上げ方法を選びます。 + +- **browser** — ブラウザ組み込みの音声。設定不要です +- **OpenAI** — OpenAI の音声。API キーを貼り付けて声を選びます +- **OpenAI-compatible** — OpenAI 形式で音声を返す任意のサービス。URL と、必要なら API キーを入力します +- **macOS say** — Mac にある組み込みの `say` コマンド + +オンにすると、メッセージに再生ボタンが表示され、読み上げを聞けます。 + +## メッセージの音声入力(speech-to-text) + +音声をどう文字起こしするか選びます。 + +- **browser** — ブラウザ組み込みの認識。設定不要です +- **server** — OpenAI 互換の文字起こしサービス。URL と、必要なら API キーを入力します +- **on-device** — ブラウザ内で動く音声モデル。初回利用時にダウンロードされます + +## スマートフォン向けの注意 + +スマートフォンで返信を読み上げる場合、OpenAI または OpenAI-compatible の選択肢が最も安定します。モバイルブラウザは組み込み音声に制限があります。 + +## 関連 + +- [通知](/notifications/) — 聞き続ける代わりに通知を受け取る +- [プロバイダー、モデル、エージェント](/providers/) — OpenAI キーがすでにあるかもしれない場所 diff --git a/packages/docs/content/docs/ja/worktrees.mdx b/packages/docs/content/docs/ja/worktrees.mdx new file mode 100644 index 00000000..d3ec4558 --- /dev/null +++ b/packages/docs/content/docs/ja/worktrees.mdx @@ -0,0 +1,36 @@ +--- +title: Worktree セッション +description: セッションに専用のブランチとフォルダを与え、作業を分離します。 +--- + +# Worktree セッション + +Worktree セッションは、リポジトリをチェックアウトした独自のコピーと独自ブランチ(git worktree)で実行されます。これにより、並行セッションが互いのファイルを踏み合うことを防げます。片方がリファクタリングし、もう片方がバグ修正をしても、行き来する必要はありません。 + +## 作成する + +1. セッションサイドバー上部のボタンから new-worktree ダイアログを開きます。 +2. 開始点を選びます。 + - **new branch** — ブランチ名を付け、どのブランチから始めるか選びます + - **existing branch** — すでにあるブランチをチェックアウトします +3. worktree フォルダを確認します(OpenChamber はブランチ名から候補を出します)。 +4. 作成します。 + +OpenChamber はブランチを作り、フォルダをセットアップし、その中でセッションを開始します。[Todo](/notes-todos-plans/) や [GitHub Issue または PR](/github/) から直接開始することもできます。 + +## 作業を戻す + +作業が良い状態になったら、Git ビューの **Integrate** を使って、worktree のコミットを別ブランチ(`main` など)へ取り込みます。変更がコンフリクトした場合は、エージェントに解決を任せることもできます。 + +## 片付ける + +セッションを削除またはアーカイブすると、worktree を削除できます。ブランチも削除するかどうかを選びます。ローカルと、存在する場合はリモートも対象です。あなたが指示しない限り、何も削除されません。 + +## 何かおかしい場合 + +worktree は、フォルダがなくなった、ブランチが detached 状態になった、merge または rebase が途中で止まった、などの場合に注意が必要になります。OpenChamber はこれらにフラグを付け、修正できるようにします。[Worktrees と Git](/troubleshooting/worktrees-git/) を参照してください。 + +## 関連 + +- [Multi-run](/multi-run/) — 多数の worktree セッションを一度に起動する +- [Git と GitHub ワークフロー](/git/) — OpenChamber 内でコミットして統合する diff --git a/packages/docs/sidebar.config.json b/packages/docs/sidebar.config.json index f2409b3e..c6f409d3 100644 --- a/packages/docs/sidebar.config.json +++ b/packages/docs/sidebar.config.json @@ -9,7 +9,8 @@ "pt-BR": "Comece aqui", "ko": "여기서 시작", "pl": "Zacznij tutaj", - "fr": "Commencer ici" + "fr": "Commencer ici", + "ja": "ここから開始" }, "items": [ { @@ -22,7 +23,8 @@ "pt-BR": "Visão geral", "ko": "개요", "pl": "Przegląd", - "fr": "Vue d’ensemble" + "fr": "Vue d’ensemble", + "ja": "概要" } }, { @@ -35,7 +37,8 @@ "pt-BR": "Instalação", "ko": "설치", "pl": "Instalacja", - "fr": "Installation" + "fr": "Installation", + "ja": "インストール" } }, { @@ -48,7 +51,8 @@ "pt-BR": "Início rápido", "ko": "빠른 시작", "pl": "Szybki start", - "fr": "Démarrage rapide" + "fr": "Démarrage rapide", + "ja": "クイックスタート" } }, { @@ -61,7 +65,8 @@ "pt-BR": "Servidor OpenCode", "ko": "OpenCode 서버", "pl": "Serwer OpenCode", - "fr": "Serveur OpenCode" + "fr": "Serveur OpenCode", + "ja": "OpenCode サーバー" } }, { @@ -74,7 +79,8 @@ "pt-BR": "Variáveis de ambiente", "ko": "환경 변수", "pl": "Zmienne środowiskowe", - "fr": "Variables d’environnement" + "fr": "Variables d’environnement", + "ja": "環境変数" } } ] @@ -88,7 +94,8 @@ "pt-BR": "Fluxos de trabalho", "ko": "워크플로", "pl": "Przepływy pracy", - "fr": "Workflows" + "fr": "Workflows", + "ja": "ワークフロー" }, "items": [ { @@ -101,7 +108,8 @@ "pt-BR": "Projetos", "ko": "프로젝트", "pl": "Projekty", - "fr": "Projets" + "fr": "Projets", + "ja": "プロジェクト" } }, { @@ -114,7 +122,8 @@ "pt-BR": "Contexto", "ko": "컨텍스트", "pl": "Kontekst", - "fr": "Contexte" + "fr": "Contexte", + "ja": "コンテキスト" } }, { @@ -127,7 +136,8 @@ "pt-BR": "Notas, tarefas e planos", "ko": "메모, 할 일, 계획", "pl": "Notatki, zadania i plany", - "fr": "Notes, todos et plans" + "fr": "Notes, todos et plans", + "ja": "メモ、Todo、計画" } }, { @@ -140,7 +150,8 @@ "pt-BR": "Tarefas agendadas", "ko": "예약 작업", "pl": "Zaplanowane zadania", - "fr": "Tâches planifiées" + "fr": "Tâches planifiées", + "ja": "スケジュールタスク" } }, { @@ -153,7 +164,8 @@ "pt-BR": "Ações do projeto", "ko": "프로젝트 작업", "pl": "Akcje projektu", - "fr": "Actions de projet" + "fr": "Actions de projet", + "ja": "プロジェクトアクション" } }, { @@ -166,7 +178,8 @@ "pt-BR": "Pré-visualização e servidores de desenvolvimento", "ko": "미리보기 및 개발 서버", "pl": "Podgląd i serwery deweloperskie", - "fr": "Aperçu et serveurs de dev" + "fr": "Aperçu et serveurs de dev", + "ja": "プレビューと開発サーバー" } }, { @@ -179,21 +192,24 @@ "pt-BR": "Sessões de worktree", "ko": "Worktree 세션", "pl": "Sesje worktree", - "fr": "Sessions worktree" + "fr": "Sessions worktree", + "ja": "Worktree セッション" } }, { "label": "Multi-run", "link": "/multi-run/", "translations": { - "fr": "Multi-run" + "fr": "Multi-run", + "ja": "Multi-run" } }, { "label": "Git & GitHub", "link": "/git/", "translations": { - "fr": "Git et GitHub" + "fr": "Git et GitHub", + "ja": "Git と GitHub" } }, { @@ -206,7 +222,8 @@ "pt-BR": "Issues e PRs do GitHub", "ko": "GitHub 이슈 및 PR", "pl": "Zgłoszenia i PR-y GitHub", - "fr": "Issues et PR GitHub" + "fr": "Issues et PR GitHub", + "ja": "GitHub Issues と PR" } }, { @@ -219,7 +236,8 @@ "pt-BR": "Prompts mágicos", "ko": "매직 프롬프트", "pl": "Magiczne prompty", - "fr": "Magic Prompts" + "fr": "Magic Prompts", + "ja": "マジックプロンプト" } }, { @@ -232,7 +250,8 @@ "pt-BR": "Identidades do Git", "ko": "Git 아이덴티티", "pl": "Tożsamości Git", - "fr": "Identités Git" + "fr": "Identités Git", + "ja": "Git ID" } } ] @@ -246,7 +265,8 @@ "pt-BR": "Configuração do OpenCode", "ko": "OpenCode 설정", "pl": "Konfiguracja OpenCode", - "fr": "Configuration OpenCode" + "fr": "Configuration OpenCode", + "ja": "OpenCode 設定" }, "items": [ { @@ -259,7 +279,8 @@ "pt-BR": "Provedores, modelos e agentes", "ko": "공급자, 모델, 에이전트", "pl": "Dostawcy, modele i agenci", - "fr": "Fournisseurs, modèles et agents" + "fr": "Fournisseurs, modèles et agents", + "ja": "プロバイダー、モデル、エージェント" } }, { @@ -272,7 +293,8 @@ "pt-BR": "Servidores MCP", "ko": "MCP 서버", "pl": "Serwery MCP", - "fr": "Serveurs MCP" + "fr": "Serveurs MCP", + "ja": "MCP サーバー" } }, { @@ -285,7 +307,8 @@ "pt-BR": "Habilidades", "ko": "스킬", "pl": "Umiejętności", - "fr": "Skills" + "fr": "Skills", + "ja": "スキル" } }, { @@ -298,7 +321,8 @@ "pt-BR": "Catálogo de habilidades", "ko": "스킬 카탈로그", "pl": "Katalog umiejętności", - "fr": "Catalogue de skills" + "fr": "Catalogue de skills", + "ja": "スキルカタログ" } }, { @@ -311,7 +335,8 @@ "pt-BR": "Comandos e trechos", "ko": "명령 및 스니펫", "pl": "Polecenia i fragmenty", - "fr": "Commandes et snippets" + "fr": "Commandes et snippets", + "ja": "コマンドとスニペット" } }, { @@ -324,7 +349,8 @@ "pt-BR": "Uso e cotas", "ko": "사용량 및 할당량", "pl": "Zużycie i limity", - "fr": "Utilisation et quotas" + "fr": "Utilisation et quotas", + "ja": "使用量とクォータ" } } ] @@ -338,7 +364,8 @@ "pt-BR": "Acesso remoto", "ko": "원격 접속", "pl": "Dostęp zdalny", - "fr": "Accès distant" + "fr": "Accès distant", + "ja": "リモートアクセス" }, "items": [ { @@ -351,7 +378,8 @@ "pt-BR": "Túneis", "ko": "터널", "pl": "Tunele", - "fr": "Tunnels" + "fr": "Tunnels", + "ja": "トンネル" } }, { @@ -364,7 +392,8 @@ "pt-BR": "Proxy reverso", "ko": "리버스 프록시", "pl": "Reverse proxy", - "fr": "Reverse proxy" + "fr": "Reverse proxy", + "ja": "リバースプロキシ" } }, { @@ -377,7 +406,8 @@ "pt-BR": "PWA e celular", "ko": "PWA 및 모바일", "pl": "PWA i urządzenia mobilne", - "fr": "PWA et mobile" + "fr": "PWA et mobile", + "ja": "PWA とモバイル" } }, { @@ -390,7 +420,8 @@ "pt-BR": "Segurança", "ko": "보안", "pl": "Bezpieczeństwo", - "fr": "Sécurité" + "fr": "Sécurité", + "ja": "セキュリティ" } } ] @@ -404,7 +435,8 @@ "pt-BR": "Personalizar", "ko": "맞춤 설정", "pl": "Dostosuj", - "fr": "Personnaliser" + "fr": "Personnaliser", + "ja": "カスタマイズ" }, "items": [ { @@ -417,7 +449,8 @@ "pt-BR": "Temas", "ko": "테마", "pl": "Motywy", - "fr": "Thèmes" + "fr": "Thèmes", + "ja": "テーマ" } }, { @@ -430,7 +463,8 @@ "pt-BR": "Notificações", "ko": "알림", "pl": "Powiadomienia", - "fr": "Notifications" + "fr": "Notifications", + "ja": "通知" } }, { @@ -443,7 +477,8 @@ "pt-BR": "Modo de voz", "ko": "음성 모드", "pl": "Tryb głosowy", - "fr": "Mode vocal" + "fr": "Mode vocal", + "ja": "音声モード" } }, { @@ -456,7 +491,8 @@ "pt-BR": "Ícones de projeto", "ko": "프로젝트 아이콘", "pl": "Ikony projektów", - "fr": "Icônes de projet" + "fr": "Icônes de projet", + "ja": "プロジェクトアイコン" } } ] @@ -470,7 +506,8 @@ "pt-BR": "Desktop", "ko": "데스크톱", "pl": "Pulpit", - "fr": "Desktop" + "fr": "Desktop", + "ja": "デスクトップ" }, "items": [ { @@ -483,7 +520,8 @@ "pt-BR": "Instâncias remotas", "ko": "원격 인스턴스", "pl": "Zdalne instancje", - "fr": "Instances distantes" + "fr": "Instances distantes", + "ja": "リモートインスタンス" } }, { @@ -496,7 +534,8 @@ "pt-BR": "Navegador desktop", "ko": "데스크톱 브라우저", "pl": "Przeglądarka na pulpicie", - "fr": "Navigateur desktop" + "fr": "Navigateur desktop", + "ja": "デスクトップブラウザ" } }, { @@ -509,7 +548,8 @@ "pt-BR": "Túneis no desktop", "ko": "데스크톱 터널", "pl": "Tunele w aplikacji desktopowej", - "fr": "Tunnels desktop" + "fr": "Tunnels desktop", + "ja": "デスクトップトンネル" } }, { @@ -522,7 +562,8 @@ "pt-BR": "Hosts SSH e proxy", "ko": "SSH 호스트 및 프록시", "pl": "Hosty SSH i proxy", - "fr": "Hosts SSH et proxy" + "fr": "Hosts SSH et proxy", + "ja": "SSH ホストとプロキシ" } }, { @@ -535,7 +576,8 @@ "pt-BR": "Atualizações", "ko": "업데이트", "pl": "Aktualizacje", - "fr": "Mises à jour" + "fr": "Mises à jour", + "ja": "更新" } } ] @@ -549,7 +591,8 @@ "pt-BR": "Ajuda", "ko": "도움말", "pl": "Pomoc", - "fr": "Aide" + "fr": "Aide", + "ja": "ヘルプ" }, "items": [ { @@ -562,7 +605,8 @@ "pt-BR": "Solução de problemas", "ko": "문제 해결", "pl": "Rozwiązywanie problemów", - "fr": "Dépannage" + "fr": "Dépannage", + "ja": "トラブルシューティング" } }, { @@ -575,7 +619,8 @@ "pt-BR": "Conexão com o OpenCode", "ko": "OpenCode 연결", "pl": "Połączenie z OpenCode", - "fr": "Connexion à OpenCode" + "fr": "Connexion à OpenCode", + "ja": "OpenCode 接続" } }, { @@ -588,7 +633,8 @@ "pt-BR": "Worktrees e Git", "ko": "Worktree 및 Git", "pl": "Worktree i Git", - "fr": "Worktrees et Git" + "fr": "Worktrees et Git", + "ja": "Worktrees と Git" } }, { @@ -601,7 +647,8 @@ "pt-BR": "Acesso remoto", "ko": "원격 접속", "pl": "Dostęp zdalny", - "fr": "Accès distant" + "fr": "Accès distant", + "ja": "リモートアクセス" } } ] diff --git a/packages/ui/src/lib/i18n/bootstrap.ts b/packages/ui/src/lib/i18n/bootstrap.ts index a300a162..1c6ac1bb 100644 --- a/packages/ui/src/lib/i18n/bootstrap.ts +++ b/packages/ui/src/lib/i18n/bootstrap.ts @@ -57,7 +57,30 @@ const FR_MESSAGES: BootstrapMessages = { loadingData: (providersText, agentsText) => `Chargement des données (${providersText}, ${agentsText})…`, }; -export const getBootstrapMessages = (locale: Locale): BootstrapMessages => (locale === 'fr' ? FR_MESSAGES : EN_MESSAGES); +const JA_MESSAGES: BootstrapMessages = { + startingApi: 'OpenCode API を起動中…', + initializing: '初期化中…', + connecting: '接続中…', + connected: '接続完了!', + connectionError: '接続エラー', + disconnected: '切断されました', + reconnecting: '再接続中…', + initialDataLoadFailed: 'OpenCode に接続しましたが、初期データの読み込みに失敗しました。', + cliNotFound: 'OpenCode CLI が見つかりません。先にインストールしてください。', + providersReady: '✓ プロバイダ', + providersLoading: '… プロバイダ', + agentsReady: '✓ エージェント', + agentsLoading: '… エージェント', + startingDevServer: (hostLabel) => `Webview 開発サーバーを起動中 (${hostLabel})...`, + waitingDevServer: (hostLabel, attempt) => `Webview 開発サーバーを待機中 (${hostLabel})... 試行 ${attempt}`, + loadingData: (providersText, agentsText) => `データを読み込み中 (${providersText}, ${agentsText})…`, +}; + +export const getBootstrapMessages = (locale: Locale): BootstrapMessages => { + if (locale === 'fr') return FR_MESSAGES; + if (locale === 'ja') return JA_MESSAGES; + return EN_MESSAGES; +}; export const readStoredLocaleForBootstrap = (): Locale => { if (typeof window === 'undefined') { diff --git a/packages/ui/src/lib/i18n/intl.ts b/packages/ui/src/lib/i18n/intl.ts index 00080675..b5d7f80b 100644 --- a/packages/ui/src/lib/i18n/intl.ts +++ b/packages/ui/src/lib/i18n/intl.ts @@ -11,6 +11,7 @@ const INTL_LOCALE_BY_LOCALE: Record = { 'pt-BR': 'pt-BR', ko: 'ko-KR', pl: 'pl-PL', + ja: 'ja-JP', }; const getIntlLocale = (locale: Locale): string => INTL_LOCALE_BY_LOCALE[locale] ?? 'en-US'; diff --git a/packages/ui/src/lib/i18n/messages.test.ts b/packages/ui/src/lib/i18n/messages.test.ts index ad9979bb..bb4c2b3e 100644 --- a/packages/ui/src/lib/i18n/messages.test.ts +++ b/packages/ui/src/lib/i18n/messages.test.ts @@ -3,6 +3,7 @@ import { describe, expect, test } from 'bun:test'; import { dict as enDict } from './messages/en'; import { dict as esDict } from './messages/es'; import { dict as frDict } from './messages/fr'; +import { dict as jaDict } from './messages/ja'; import { dict as koDict } from './messages/ko'; import { dict as plDict } from './messages/pl'; import { dict as ptBrDict } from './messages/pt-BR'; @@ -14,6 +15,7 @@ const localeDictionaries = { en: enDict, fr: frDict, es: esDict, + ja: jaDict, 'pt-BR': ptBrDict, uk: ukDict, ko: koDict, @@ -23,15 +25,18 @@ const localeDictionaries = { } as const; describe('i18n dictionaries', () => { - test('french stays in key parity with english', () => { + test('all locales stay in key parity with english', () => { const englishKeys = Object.keys(enDict).sort(); - expect(Object.keys(frDict).sort()).toEqual(englishKeys); + for (const dictionary of Object.values(localeDictionaries)) { + expect(Object.keys(dictionary).sort()).toEqual(englishKeys); + } }); - test('all locales expose the french language label key', () => { + test('all locales expose language label keys', () => { for (const [, dictionary] of Object.entries(localeDictionaries)) { expect(dictionary['common.language.french']).toBeTruthy(); + expect(dictionary['common.language.japanese']).toBeTruthy(); } }); }); diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 54f09542..f5dcb6d5 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -13,6 +13,7 @@ export const dict = { 'common.language.brazilianPortuguese': 'Brazilian Portuguese', 'common.language.korean': 'Korean', 'common.language.polish': 'Polish', + 'common.language.japanese': 'Japanese', 'common.revealPath.finder': 'Reveal in Finder', 'common.revealPath.fileExplorer': 'Open in File Explorer', 'common.revealPath.fileManager': 'Open in File Manager', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index d1c0a41d..7d4bba4f 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -14,6 +14,7 @@ export const dict: Record = { "common.language.brazilianPortuguese": "Portugués brasileño", "common.language.korean": "Coreano", "common.language.polish": "Polaco", + "common.language.japanese": "Japonés", "common.revealPath.finder": "Mostrar en Finder", "common.revealPath.fileExplorer": "Abrir en File Explorer", "common.revealPath.fileManager": "Abrir en gestor de archivos", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 81f823ac..335d431f 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -13,6 +13,7 @@ export const dict = { 'common.language.brazilianPortuguese': 'Portugais brésilien', 'common.language.korean': 'Coréen', 'common.language.polish': 'Polonais', + 'common.language.japanese': 'Japonais', 'common.revealPath.finder': 'Révéler dans le Finder', 'common.revealPath.fileExplorer': 'Ouvrir dans l\'explorateur de fichiers', 'common.revealPath.fileManager': 'Ouvrir dans le gestionnaire de fichiers', @@ -103,6 +104,7 @@ export const dict = { 'sessions.scheduledTasks.editor.scheduleType.daily': 'Tous les jours', 'sessions.scheduledTasks.editor.scheduleType.weekly': 'Hebdomadaire', 'sessions.scheduledTasks.editor.scheduleType.once': 'Une fois', + 'sessions.scheduledTasks.editor.scheduleType.cron': 'Cron', 'sessions.scheduledTasks.editor.date.label': 'Date', 'sessions.scheduledTasks.editor.date.placeholder': 'Sélectionnez une date', 'sessions.scheduledTasks.editor.date.previousMonth': 'Mois précédent', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts new file mode 100644 index 00000000..6437c188 --- /dev/null +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -0,0 +1,1801 @@ +export const settingsDict = { + 'settings.appearance.language.label': '言語', + 'settings.appearance.language.description': 'インターフェース言語を選択します。', + 'settings.appearance.language.select': '言語を選択', + 'settings.window.description': 'OpenChamber 設定ウィンドウ。', + 'settings.view.home.title': '設定', + 'settings.view.home.description': 'よく使うページにジャンプします。', + 'settings.view.home.cards.providers.title': 'プロバイダー', + 'settings.view.home.cards.providers.description': 'モデル + 認証情報を接続', + 'settings.view.home.cards.agents.title': 'エージェント', + 'settings.view.home.cards.agents.description': 'プロンプト、ツール、権限', + 'settings.view.home.cards.skillsCatalog.title': 'スキルカタログ', + 'settings.view.home.cards.skillsCatalog.description': 'カタログからスキルをインストール', + 'settings.view.home.cards.mcp.title': 'MCP', + 'settings.view.home.cards.mcp.description': 'MCP サーバーと接続を設定', + 'settings.view.home.cards.plugins.title': 'プラグイン', + 'settings.view.home.cards.plugins.description': 'opencode のプラグインを管理', + 'settings.view.home.cards.usage.title': '使用量', + 'settings.view.home.cards.usage.description': '割り当てと支出の表示', + 'settings.view.unavailable.title': '利用できません', + 'settings.view.unavailable.description': 'このランタイムではこの設定ページは利用できません。', + 'settings.view.badge.beta': 'ベータ', + 'settings.view.actions.reloadOpenCode': 'OpenCode を再読み込み', + 'settings.view.actions.reloadOpenCodeTooltip': 'OpenCode を再起動して設定を再読み込みします。', + 'settings.view.actions.backToSettings': '設定に戻る', + 'settings.view.actions.closeSettings': '設定を閉じる', + 'settings.view.actions.openSectionList': 'セクション一覧を開く', + 'settings.view.actions.closeSettingsWithShortcut': '設定を閉じる ({shortcut}+,)', + 'settings.view.actions.back': '戻る', + 'settings.view.actions.resizeNavigation': '設定ナビゲーションのサイズ変更', + 'settings.view.search.placeholder': '設定を検索', + 'settings.view.search.aria': '設定を検索', + 'settings.view.search.clear': '設定検索をクリア', + 'settings.view.search.noResults': '該当する設定がありません', + 'settings.page.projects.title': 'プロジェクト', + 'settings.page.remoteInstances.title': 'リモートインスタンス', + 'settings.page.providers.title': 'プロバイダー', + 'settings.page.usage.title': '使用量', + 'settings.page.agents.title': 'エージェント', + 'settings.page.commands.title': 'コマンド', + 'settings.page.mcp.title': 'MCP', + 'settings.page.plugins.title': 'プラグイン', + 'settings.page.skills.title': 'スキル', + 'settings.page.skillsCatalog.title': 'スキルカタログ', + 'settings.page.git.title': 'Git', + 'settings.page.appearance.title': '外観', + 'settings.page.chat.title': 'チャット', + 'settings.page.shortcuts.title': 'ショートカット', + 'settings.page.sessions.title': 'セッション', + 'settings.page.magicPrompts.title': 'マジックプロンプト', + 'settings.page.notifications.title': '通知', + 'settings.page.voice.title': '音声', + 'settings.page.tunnel.title': 'リモートトンネル', + 'settings.page.about.title': '概要', + 'settings.page.snippets.title': 'スニペット', + 'settings.snippets.sidebar.title': 'スニペット', + 'settings.snippets.sidebar.total': '合計: {count}', + 'settings.snippets.sidebar.actions.create': 'Snippet を作成', + 'settings.snippets.sidebar.actions.more': '{name} のその他の操作', + 'settings.snippets.sidebar.toast.deleted': 'Snippet を削除しました', + 'settings.snippets.sidebar.toast.deleteFailed': 'Snippet の削除に失敗しました', + 'settings.snippets.sidebar.dialog.deleteTitle': 'Snippet を削除しますか?', + 'settings.snippets.sidebar.dialog.deleteDescription': '#{name} は完全に削除されます。', + 'settings.snippets.page.empty.title': 'Snippet を選択', + 'settings.snippets.page.empty.description': 'サイドバーから編集する Snippet を選択してください。', + 'settings.snippets.page.title.new': '新しい Snippet', + 'settings.snippets.page.field.namePlaceholder': 'snippet-name', + 'settings.snippets.page.field.descriptionPlaceholder': 'この Snippet の説明', + 'settings.snippets.page.field.aliases': 'エイリアス', + 'settings.snippets.page.field.aliasesPlaceholder': 'safe, careful', + 'settings.snippets.page.field.content': '内容', + 'settings.snippets.page.field.contentPlaceholder': 'Snippet の Markdown...', + 'settings.snippets.page.hint': 'プロンプトで #name を使用します。Aliases と opencode-snippets 互換の prepend/append ブロックをサポートします。', + 'settings.snippets.page.toast.nameRequired': 'Snippet 名が必要です', + 'settings.snippets.page.toast.contentRequired': 'Snippet の内容が必要です', + 'settings.snippets.page.toast.saveFailed': 'Snippet の保存に失敗しました', + 'settings.snippets.page.toast.saved': 'Snippet を保存しました', + 'settings.page.promptTemplates.title': 'プロンプトテンプレート', + 'settings.promptTemplates.sidebar.title': 'プロンプトテンプレート', + 'settings.promptTemplates.sidebar.total': '合計: {count}', + 'settings.promptTemplates.sidebar.empty.title': 'Prompt Template がありません', + 'settings.promptTemplates.sidebar.empty.description': 'Prompt Template を作成して始めましょう。', + 'settings.promptTemplates.sidebar.toast.deleted': 'Template "{name}" を削除しました', + 'settings.promptTemplates.sidebar.toast.deleteFailed': 'Template の削除に失敗しました', + 'settings.promptTemplates.sidebar.toast.nameRequired': 'Template 名が必要です', + 'settings.promptTemplates.sidebar.toast.renamed': 'Template の名前を変更しました', + 'settings.promptTemplates.sidebar.toast.renameFailed': 'Template の名前変更に失敗しました', + 'settings.promptTemplates.sidebar.dialog.deleteTitle': 'Template を削除しますか?', + 'settings.promptTemplates.sidebar.dialog.deleteDescription': '"{name}" は完全に削除されます。この操作は元に戻せません。', + 'settings.promptTemplates.sidebar.renameDialog.title': 'Template の名前を変更', + 'settings.promptTemplates.sidebar.renameDialog.description': '"{name}" の新しい名前を入力してください。', + 'settings.promptTemplates.sidebar.renameDialog.placeholder': 'Template 名', + 'settings.promptTemplates.sidebar.badge.default': 'デフォルト', + 'settings.promptTemplates.page.empty.title': 'Template を選択', + 'settings.promptTemplates.page.empty.description': 'サイドバーから編集する Template を選択してください。', + 'settings.promptTemplates.page.title.new': '新しい Template', + 'settings.promptTemplates.page.subtitle.edit': 'Template を編集', + 'settings.promptTemplates.page.subtitle.new': '新しい Prompt Template を作成', + 'settings.promptTemplates.page.section.identity': '識別情報', + 'settings.promptTemplates.page.field.name': '名前', + 'settings.promptTemplates.page.field.namePlaceholder': 'Template 名', + 'settings.promptTemplates.page.section.template': 'テンプレート', + 'settings.promptTemplates.page.field.templatePlaceholder': 'Prompt Template のテキストを入力...', + 'settings.promptTemplates.page.templateHint': 'このテキストはマルチラングループで選択されたときにユーザーのプロンプトの前に追加されます。', + 'settings.promptTemplates.page.toast.nameRequired': 'Template 名が必要です', + 'settings.promptTemplates.page.toast.updated': 'Template を更新しました', + 'settings.promptTemplates.page.toast.updateFailed': 'Template の更新に失敗しました', + 'settings.promptTemplates.page.toast.created': 'Template を作成しました', + 'settings.promptTemplates.page.toast.createFailed': 'Template の作成に失敗しました', + 'settings.promptTemplates.page.toast.saveUnexpectedError': '保存中に予期しないエラーが発生しました', + 'settings.openchamber.tunnel.title': 'リモートトンネル', + 'settings.openchamber.tunnel.description': 'クイックリンクまたは自身で管理するリモート Cloudflare Tunnel を使用して安全なリモートアクセスを設定します。', + 'settings.openchamber.tunnel.note.serverSideEnforced': '安全な Tunnel アクセスはサーバー側で強制されます。', + 'settings.openchamber.tunnel.note.connectLinksOneTime': '接続リンクは一度きりで、Tunnel が停止するか接続リンクの TTL が期限切れになると無効になります。', + 'settings.openchamber.tunnel.note.cloudflareConnectorTarget': 'Cloudflare コネクタターゲット:', + 'settings.openchamber.tunnel.note.cloudflareConnectorTargetUse': 'Cloudflare コネクタターゲットには以下を使用:', + 'settings.openchamber.tunnel.note.tokensSavedPerTunnel': 'Token は Tunnel ごとに保存され、ディスクから再利用されます。', + 'settings.openchamber.tunnel.note.customConfigUsed': 'Tunnel 起動時にカスタム設定ファイルが使用されます。', + 'settings.openchamber.tunnel.note.defaultConfigUsed': '空の場合、cloudflared はデフォルト設定 (~/.cloudflared/config.yml) を使用します。', + 'settings.openchamber.tunnel.note.managedRemoteRequiresDomain': 'Managed Remote Tunnel には Cloudflare アカウントで購入したドメインが必要です。', + 'settings.openchamber.tunnel.note.managedLocalUsesConfig': 'Managed Local Tunnel はローカルの cloudflared 設定ファイルを使用します。', + 'settings.openchamber.tunnel.note.startModeAndGenerateLink': '{mode} Tunnel を起動し、一度きりの接続リンクを生成します。この Tunnel 使用中はアプリを閉じないでください。', + 'settings.openchamber.tunnel.note.scanQrToConnect': 'スマートフォンでスキャンして接続します。', + 'settings.openchamber.tunnel.section.redeemedAccessLinks': '使用済みアクセスリンク', + 'settings.openchamber.tunnel.section.savedManagedRemoteTunnels': '保存済み Managed Remote Tunnel', + 'settings.openchamber.tunnel.notAvailable.cloudflaredNotFound': 'cloudflared が見つかりません', + 'settings.openchamber.tunnel.notAvailable.dependencyNotFound': '{dependency} が見つかりませんでした。', + 'settings.openchamber.tunnel.notAvailable.installHint': 'Remote Tunnel アクセスを有効にするにはインストールしてください:', + 'settings.openchamber.tunnel.field.provider': 'プロバイダー', + 'settings.openchamber.tunnel.field.providerPlaceholder': 'プロバイダーを選択', + 'settings.openchamber.tunnel.field.tunnelType': 'Tunnel タイプ', + 'settings.openchamber.tunnel.field.connectLinkTtl': '接続リンク TTL', + 'settings.openchamber.tunnel.field.tunnelSessionTtl': 'Tunnel Session TTL', + 'settings.openchamber.tunnel.field.hostnameLabel': 'ホスト名:', + 'settings.openchamber.tunnel.field.savedTokenAvailablePlaceholder': '保存済み Token があります(置き換える場合は任意)', + 'settings.openchamber.tunnel.field.pasteTokenPlaceholder': 'この Tunnel の Token を貼り付け', + 'settings.openchamber.tunnel.field.newPresetNamePlaceholder': 'Tunnel 名(例: 本番)', + 'settings.openchamber.tunnel.field.newPresetHostnamePlaceholder': 'ホスト名(例: oc.example.com)', + 'settings.openchamber.tunnel.field.newPresetTokenPlaceholder': 'Token', + 'settings.openchamber.tunnel.field.managedRemoteTokenInfoAria': 'Managed Remote Tunnel Token 情報', + 'settings.openchamber.tunnel.field.configurationFile': '設定ファイル', + 'settings.openchamber.tunnel.field.configurationFilePlaceholder': 'デフォルトの cloudflared 設定を使用', + 'settings.openchamber.tunnel.field.managedRemoteTunnelToConnect': '接続する Managed Remote Tunnel', + 'settings.openchamber.tunnel.field.selectSavedTunnelPlaceholder': '保存済み Tunnel を選択', + 'settings.openchamber.tunnel.field.publicUrlHint': '公開 URL(Token なしではアクセス不可)', + 'settings.openchamber.tunnel.field.connectLink': '接続リンク', + 'settings.openchamber.tunnel.field.expires': '有効期限', + 'settings.openchamber.tunnel.field.connectQrAlt': 'Tunnel 接続 QR コード', + 'settings.openchamber.tunnel.actions.removePresetAria': '{name} を削除', + 'settings.openchamber.tunnel.actions.saveToken': 'Token を保存', + 'settings.openchamber.tunnel.actions.browseConfigFileAria': '設定ファイルを参照', + 'settings.openchamber.tunnel.actions.clearConfigFileAria': '設定ファイルをクリア', + 'settings.openchamber.tunnel.actions.openManagedRemoteDocs': 'Managed Remote Tunnel の設定方法のドキュメントを確認', + 'settings.openchamber.tunnel.actions.openManagedLocalDocs': 'Managed Local Tunnel 設定のドキュメントを確認', + 'settings.openchamber.tunnel.actions.startTunnel': 'Tunnel を起動', + 'settings.openchamber.tunnel.actions.startingTunnel': 'Tunnel を起動中...', + 'settings.openchamber.tunnel.actions.stopTunnel': 'Tunnel を停止', + 'settings.openchamber.tunnel.actions.stopping': '停止中...', + 'settings.openchamber.tunnel.actions.newConnectLink': '新しい接続リンク', + 'settings.openchamber.tunnel.actions.copied': 'コピーしました', + 'settings.openchamber.tunnel.actions.retry': '再試行', + 'settings.openchamber.tunnel.option.moreProvidersSoon': '近日中にさらに Provider が追加予定', + 'settings.openchamber.tunnel.option.mode.quick.label': 'クイック', + 'settings.openchamber.tunnel.option.mode.quick.tooltip': 'Quick Tunnel はベストエフォートであり、稼働時間は保証されません。', + 'settings.openchamber.tunnel.option.mode.managedRemote.label': '管理対象リモート', + 'settings.openchamber.tunnel.option.mode.managedRemote.tooltip': 'Managed Remote は Cloudflare アカウントとホスト名を使用して長期的なアクセスを提供します。', + 'settings.openchamber.tunnel.option.mode.managedLocal.label': '管理対象ローカル', + 'settings.openchamber.tunnel.option.mode.managedLocal.tooltip': 'Managed Local はローカルの cloudflared 設定ファイルを使用します。', + 'settings.openchamber.tunnel.warning.quickModeReliability': 'より信頼性の高い長期的なアクセスには、Managed Remote または Managed Local Tunnel モードに切り替えてください。', + 'settings.openchamber.tunnel.warning.replacesActiveTunnel': 'この Tunnel を起動すると、アクティブな Tunnel が置き換えられ、既存の接続リンクとリモート Session が無効になります。', + 'settings.openchamber.tunnel.badge.quick': 'QUICK', + 'settings.openchamber.tunnel.badge.remote': 'REMOTE', + 'settings.openchamber.tunnel.badge.local': 'LOCAL', + 'settings.openchamber.tunnel.state.loading': '読み込み中', + 'settings.openchamber.tunnel.state.expired': '期限切れ', + 'settings.openchamber.tunnel.state.revoked': '無効化済み', + 'settings.openchamber.tunnel.state.inactive': '非アクティブ', + 'settings.openchamber.tunnel.state.noExpiry': '期限なし', + 'settings.openchamber.tunnel.state.never': 'なし', + 'settings.openchamber.tunnel.state.tunnelReady': 'Tunnel 準備完了', + 'settings.openchamber.tunnel.session.redeemedAt': '{time} に使用', + 'settings.openchamber.tunnel.session.expiresIn': 'あと {remaining} で期限切れ', + 'settings.openchamber.tunnel.session.inactiveWithReason': '非アクティブ ({reason})', + 'settings.openchamber.tunnel.error.invalidConfigExtension': '設定ファイルは .yml、.yaml、または .json 拡張子を使用する必要があります。', + 'settings.openchamber.tunnel.tooltip.tokensSavedPath': 'Token は ~/.config/openchamber/cloudflare-managed-remote-tunnels.json に保存されます。', + 'settings.openchamber.tunnel.empty.noManagedRemoteTunnels': '保存された Managed Remote Tunnel はまだありません。', + 'settings.openchamber.tunnel.toast.checkAvailabilityFailed': 'Tunnel の利用可能性の確認に失敗しました', + 'settings.openchamber.tunnel.toast.saveSettingsFailed': 'Tunnel 設定の保存に失敗しました', + 'settings.openchamber.tunnel.toast.saveTtlFailed': 'Tunnel TTL 設定の保存に失敗しました', + 'settings.openchamber.tunnel.toast.saveTokenFailed': 'Managed Remote Tunnel Token の保存に失敗しました', + 'settings.openchamber.tunnel.toast.selectOrAddManagedRemoteFirst': '最初に Managed Remote Tunnel を選択または追加してください', + 'settings.openchamber.tunnel.toast.managedRemoteTokenRequiredBeforeStarting': '起動するには Managed Remote Tunnel Token が必要です', + 'settings.openchamber.tunnel.toast.addManagedRemoteTokenBeforeStarting': '起動前に Managed Remote Tunnel Token を追加してください', + 'settings.openchamber.tunnel.toast.startFailed': 'Tunnel の起動に失敗しました', + 'settings.openchamber.tunnel.toast.startedButNoPublicUrl': 'Tunnel は起動しましたが、公開 URL が返されませんでした', + 'settings.openchamber.tunnel.toast.replacedTunnelSingleSingle': '前の Tunnel を置き換えました: 1 つのリンクを無効化、1 つの Session を無効化。', + 'settings.openchamber.tunnel.toast.replacedTunnelSingleManySessions': '前の Tunnel を置き換えました: 1 つのリンクを無効化、{invalidatedSessionCount} の Session を無効化。', + 'settings.openchamber.tunnel.toast.replacedTunnelManyLinksSingleSession': '前の Tunnel を置き換えました: {revokedBootstrapCount} のリンクを無効化、1 つの Session を無効化。', + 'settings.openchamber.tunnel.toast.replacedTunnelManyMany': '前の Tunnel を置き換えました: {revokedBootstrapCount} のリンクを無効化、{invalidatedSessionCount} の Session を無効化。', + 'settings.openchamber.tunnel.toast.linkReady': 'Tunnel リンク準備完了', + 'settings.openchamber.tunnel.toast.stopped': 'Tunnel を停止しました', + 'settings.openchamber.tunnel.toast.stopFailed': 'Tunnel の停止に失敗しました', + 'settings.openchamber.tunnel.toast.connectLinkCopied': '接続リンクをコピーしました', + 'settings.openchamber.tunnel.toast.copyUrlFailed': 'URL のコピーに失敗しました', + 'settings.openchamber.tunnel.toast.saveSelectedManagedRemoteFailed': '選択した Managed Remote Tunnel の保存に失敗しました', + 'settings.openchamber.tunnel.toast.tunnelNameRequired': 'Tunnel 名が必要です', + 'settings.openchamber.tunnel.toast.managedRemoteHostnameRequired': 'Managed Remote Tunnel のホスト名が必要です', + 'settings.openchamber.tunnel.toast.managedRemoteTokenRequired': 'Managed Remote Tunnel Token が必要です', + 'settings.openchamber.tunnel.toast.hostnameAlreadyExists': 'このホスト名は既に存在します', + 'settings.openchamber.tunnel.toast.managedRemoteSaved': 'Managed Remote Tunnel を保存しました', + 'settings.openchamber.tunnel.toast.managedRemoteRemoved': 'Managed Remote Tunnel を削除しました', + 'settings.magicPrompts.sidebar.title': 'マジックプロンプト', + 'settings.magicPrompts.sidebar.description': '編集する Prompt Template を選択してください。', + 'settings.magicPrompts.sidebar.group.git': 'Git', + 'settings.magicPrompts.sidebar.group.github': 'GitHub', + 'settings.magicPrompts.sidebar.group.planning': '計画', + 'settings.magicPrompts.sidebar.group.session': 'セッション', + 'settings.magicPrompts.sidebar.item.gitCommitGenerate': 'Commit 生成', + 'settings.magicPrompts.sidebar.item.gitPrGenerate': 'PR 生成', + 'settings.magicPrompts.sidebar.item.gitConflictResolve': 'Merge/Rebase 競合解決', + 'settings.magicPrompts.sidebar.item.gitCherrypickConflictResolve': 'Cherry-pick 競合解決', + 'settings.magicPrompts.sidebar.item.githubPrReview': 'PR レビュー', + 'settings.magicPrompts.sidebar.item.githubIssueReview': 'Issue レビュー', + 'settings.magicPrompts.sidebar.item.githubPrFailedChecksReview': 'PR 失敗チェックレビュー', + 'settings.magicPrompts.sidebar.item.githubPrCommentsReview': 'PR コメントレビュー', + 'settings.magicPrompts.sidebar.item.githubSinglePrCommentReview': '単一 PR コメントレビュー', + 'settings.magicPrompts.sidebar.item.planTodo': 'Todo 計画', + 'settings.magicPrompts.sidebar.item.planImprove': '計画の改善', + 'settings.magicPrompts.sidebar.item.planImplement': '計画の実装', + 'settings.magicPrompts.sidebar.item.sessionSummary': 'Session サマリー', + 'settings.magicPrompts.sidebar.item.sessionWorkspaceReview': 'Workspace レビュー', + 'settings.magicPrompts.sidebar.item.sessionExplore': 'コードベースツアー', + 'settings.magicPrompts.sidebar.item.sessionFeaturePlan': '機能計画', + 'settings.magicPrompts.sidebar.item.sessionCatchUp': 'キャッチアップ', + 'settings.magicPrompts.sidebar.item.sessionDebug': 'デバッグ', + 'settings.magicPrompts.sidebar.item.sessionWeigh': '選択肢の比較', + 'settings.magicPrompts.sidebar.item.sessionFusion': '統合', + 'settings.remoteInstances.sidebar.title': 'リモートインスタンス', + 'settings.remoteInstances.sidebar.total': '合計 {count}', + 'settings.remoteInstances.sidebar.newSshInstanceName': '新しい SSH 接続', + 'settings.remoteInstances.sidebar.actions.addSshInstance': 'SSH 接続を追加', + 'settings.remoteInstances.sidebar.actions.connect': '接続', + 'settings.remoteInstances.sidebar.actions.disconnect': '切断', + 'settings.remoteInstances.sidebar.actions.retry': '再試行', + 'settings.remoteInstances.sidebar.actions.remove': '削除', + 'settings.remoteInstances.sidebar.confirm.localPortInUseRetry': 'ローカルポートは既に使用中です。ランダムな空きローカルポートを選んで再試行しますか?', + 'settings.remoteInstances.sidebar.toast.createFailed': 'SSH 接続の作成に失敗しました', + 'settings.remoteInstances.sidebar.toast.retriedWithRandomPort': 'ランダムなポートで再試行しました', + 'settings.remoteInstances.sidebar.toast.connectFailed': 'インスタンスの接続に失敗しました', + 'settings.remoteInstances.sidebar.toast.disconnectFailed': 'インスタンスの切断に失敗しました', + 'settings.remoteInstances.sidebar.toast.retryFailed': '接続の再試行に失敗しました', + 'settings.remoteInstances.sidebar.toast.removeFailed': 'インスタンスの削除に失敗しました', + 'settings.remoteInstances.direct.sidebarTitle': 'サーバーリンク', + 'settings.remoteInstances.direct.sidebarDescription': 'リンクまたは Token で接続', + 'settings.remoteInstances.direct.title': 'その他の OpenChamber サーバー', + 'settings.remoteInstances.direct.description': 'URL で別の OpenChamber サーバーを追加します。サーバーが既に実行中で接続 Token がある場合に使用します。', + 'settings.remoteInstances.direct.field.labelPlaceholder': 'ラベル(任意)', + 'settings.remoteInstances.direct.field.urlPlaceholder': 'https://host:port', + 'settings.remoteInstances.direct.field.tokenPlaceholder': '接続 Token(信頼できるローカルサーバーでは任意)', + 'settings.remoteInstances.direct.note': '接続 Token はこのデバイスに保存され、このアプリがそのサーバーに接続するときにのみ使用されます。', + 'settings.remoteInstances.direct.actions.add': 'サーバーを追加', + 'settings.remoteInstances.direct.import.description': '別の OpenChamber サーバーからの接続リンクを貼り付けてください。', + 'settings.remoteInstances.direct.import.placeholder': 'openchamber://connect?...', + 'settings.remoteInstances.direct.import.action': 'リンクをインポート', + 'settings.remoteInstances.direct.error.invalidConnectLink': '無効な OpenChamber 接続リンクです。', + 'settings.remoteInstances.direct.state.loading': 'インスタンスを読み込み中...', + 'settings.remoteInstances.direct.state.empty': 'まだ他のサーバーが追加されていません。', + 'settings.remoteInstances.clientAuth.title': 'このサーバーに接続', + 'settings.remoteInstances.clientAuth.description': 'OpenChamber Desktop がこのサーバーに接続できるように、安全なリンクまたは Token を作成します。', + 'settings.remoteInstances.clientAuth.field.labelPlaceholder': 'デバイス名(任意)', + 'settings.remoteInstances.clientAuth.actions.create': 'Token を作成', + 'settings.remoteInstances.clientAuth.actions.pair': 'リンクを作成', + 'settings.remoteInstances.clientAuth.actions.revoke': '無効化', + 'settings.remoteInstances.clientAuth.actions.clearRevoked': '無効化済みをクリア', + 'settings.remoteInstances.clientAuth.qrAlt': 'OpenChamber 接続 QR コード', + 'settings.remoteInstances.clientAuth.pairingUrl': '接続リンク', + 'settings.remoteInstances.clientAuth.createdToken': 'この Token を今すぐコピーしてください。セキュリティのため、再表示されません。', + 'settings.remoteInstances.clientAuth.state.loading': 'Token を読み込み中...', + 'settings.remoteInstances.clientAuth.state.empty': 'まだデバイスが接続されていません。', + 'settings.remoteInstances.clientAuth.state.revoked': '無効化済み', + 'settings.remoteInstances.clientAuth.state.thisDevice': 'このデバイス', + 'settings.remoteInstances.clientAuth.lastUsed': '最終使用 {date}', + 'settings.remoteInstances.clientAuth.neverUsed': '未使用', + 'settings.remoteInstances.sidebar.phase.ready': '準備完了', + 'settings.remoteInstances.sidebar.phase.error': 'エラー', + 'settings.remoteInstances.sidebar.phase.reconnect': '再接続', + 'settings.remoteInstances.sidebar.phase.installing': 'インストール中', + 'settings.remoteInstances.sidebar.phase.updating': '更新中', + 'settings.remoteInstances.sidebar.phase.forwarding': '転送中', + 'settings.remoteInstances.sidebar.phase.starting': '起動中', + 'settings.remoteInstances.sidebar.phase.connecting': '接続中', + 'settings.remoteInstances.sidebar.phase.idle': 'アイドル', + 'settings.remoteInstances.page.section.instance': 'インスタンス', + 'settings.remoteInstances.page.section.instanceDescription': 'この接続の SSH コマンドと表示名を選択します。', + 'settings.remoteInstances.page.field.mode': 'モード', + 'settings.remoteInstances.page.field.modeHint': 'OpenChamber にサーバーを起動させるか、既に実行中のサーバーに接続するかを選択します。', + 'settings.remoteInstances.page.field.modePlaceholder': 'モードを選択', + 'settings.remoteInstances.page.field.modeManaged': '自動起動', + 'settings.remoteInstances.page.field.modeExternal': '既に実行中', + 'settings.remoteInstances.page.field.preferredRemotePort': '優先リモートポート', + 'settings.remoteInstances.page.field.preferredRemotePortHint': 'リモートマシンで使用するポート。空の場合は自動的に選択されます。', + 'settings.remoteInstances.page.field.keepServerRunning': 'サーバーを実行したままにする', + 'settings.remoteInstances.page.field.keepServerRunningHint': '切断後もリモートマシンで OpenChamber を実行し続けます。', + 'settings.remoteInstances.page.field.bindHost': 'バインドホスト', + 'settings.remoteInstances.page.field.bindHostHint': 'ローカル接続の待受先。LAN アクセスが必要でない限り、127.0.0.1 または localhost を使用してください。', + 'settings.remoteInstances.page.field.preferredLocalPort': '優先ローカルポート', + 'settings.remoteInstances.page.field.preferredLocalPortHint': 'この接続に使用するローカルポート。空の場合は自動的に選択されます。', + 'settings.remoteInstances.page.field.forwardType': '転送タイプ', + 'settings.remoteInstances.page.field.localHostPlaceholder': '127.0.0.1', + 'settings.remoteInstances.page.field.remoteHostPlaceholder': '127.0.0.1', + 'settings.remoteInstances.page.field.auto': '自動', + 'settings.remoteInstances.page.confirm.bindAllInterfaces': '0.0.0.0 にバインドすると、転送されたポートがローカルネットワークに公開されます。続行しますか?', + 'settings.remoteInstances.page.preview.localSocks5': '(ローカル SOCKS5)', + 'settings.remoteInstances.page.preview.local': '(ローカル)', + 'settings.remoteInstances.page.preview.remote': '(リモート)', + 'settings.remoteInstances.page.toast.openLocalEndpointFailed': 'ローカルアドレスのオープンに失敗しました', + 'settings.remoteInstances.page.toast.localUrlCopied': 'ローカル URL をコピーしました', + 'settings.remoteInstances.page.actions.copyLocalUrl': 'ローカル URL をコピー', + 'settings.remoteInstances.page.actions.open': '開く', + 'settings.remoteInstances.page.logsDialog.selectedInstanceFallback': '選択したインスタンス', + 'settings.remoteInstances.page.patternDialog.descriptionWithHost': '{host} には具体的な接続先が必要です。', + 'settings.remoteInstances.page.patternDialog.description': '接続先を入力してください。', + 'settings.common.actions.cancel': 'キャンセル', + 'settings.common.actions.create': '作成', + 'settings.common.actions.delete': '削除', + 'settings.common.actions.reset': 'リセット', + 'settings.common.actions.rename': '名前変更', + 'settings.common.actions.duplicate': '複製', + 'settings.common.actions.import': 'インポート', + 'settings.common.actions.copyAll': 'すべてコピー', + 'settings.common.actions.clear': 'クリア', + 'settings.common.actions.saving': '保存中...', + 'settings.common.actions.saveChanges': '変更を保存', + 'settings.common.scope.global': 'グローバル', + 'settings.common.scope.project': 'プロジェクト', + 'settings.common.field.description': '説明', + 'settings.common.badge.new': '新規', + 'settings.common.badge.modified': '変更済み', + 'settings.common.permission.allow': '許可', + 'settings.common.permission.ask': '確認', + 'settings.common.permission.deny': '拒否', + 'settings.common.state.comingSoon': '近日公開...', + 'settings.projects.actions.title': 'アクション', + 'settings.projects.actions.description': 'ヘッダーのプロジェクト名の横に表示されるプロジェクトごとのコマンド。', + 'settings.projects.actions.validation.fillNameAndCommand': '保存する前にアクション名とコマンドを入力してください。', + 'settings.projects.actions.state.loading': '読み込み中...', + 'settings.projects.actions.state.empty': 'まだアクションが設定されていません。', + 'settings.projects.actions.state.untitled': '名前なしアクション', + 'settings.projects.actions.state.noDesktopSshForwards': '有効なローカル SSH 転送がありません。', + 'settings.projects.actions.field.selectIconAria': 'アイコンを選択', + 'settings.projects.actions.field.iconAria': 'アイコン {icon}', + 'settings.projects.actions.field.actionNamePlaceholder': 'アクション名', + 'settings.projects.actions.field.command': 'コマンド', + 'settings.projects.actions.field.commandPlaceholder': '例: bun run lint', + 'settings.projects.actions.field.autoOpenUrl': 'URL を自動開く', + 'settings.projects.actions.field.autoOpenUrlForAria': '{title} の URL を自動開く', + 'settings.projects.actions.field.autoOpenUrlDescription': '出力または以下のカスタム URL から URL を開く', + 'settings.projects.actions.field.overrideUrlPlaceholder': '上書き URL(任意)', + 'settings.projects.actions.field.overrideUrlTooltip': 'このフィールドが入力されている場合、カスタム URL が使用されます。空の場合、アプリは出力から最適な URL を開きます。', + 'settings.projects.actions.field.desktopSshForward': 'Desktop SSH 転送', + 'settings.projects.actions.field.useOutputManualUrl': '出力/手動 URL を使用', + 'settings.projects.actions.actions.add': 'アクションを追加', + 'settings.projects.actions.actions.save': 'アクションを保存', + 'settings.projects.actions.toast.saveFailed': 'アクションの保存に失敗しました', + 'settings.projects.actions.toast.saved': 'プロジェクトアクションを保存しました', + 'settings.openchamber.about.title': 'OpenChamber について', + 'settings.openchamber.about.field.version': 'バージョン', + 'settings.openchamber.about.field.openCodeVersion': 'OpenCode バージョン', + 'settings.openchamber.about.state.checking': '確認中...', + 'settings.openchamber.about.state.upToDate': '最新です', + 'settings.openchamber.about.state.unknown': '不明', + 'settings.openchamber.about.actions.checkUpdates': 'アップデートを確認', + 'settings.openchamber.about.actions.update': 'アップデート', + 'settings.openchamber.about.actions.updateToVersion': '{version} にアップデート', + 'settings.openchamber.about.actions.checkForUpdates': 'アップデートを確認', + 'settings.openchamber.about.toast.latestVersion': '最新バージョンです', + 'settings.agents.sidebar.title': 'エージェント', + 'settings.agents.sidebar.total': '合計 {count}', + 'settings.agents.sidebar.empty.title': 'Agent が設定されていません', + 'settings.agents.sidebar.empty.description': '上の + ボタンを使用して作成してください', + 'settings.agents.sidebar.section.builtIn': 'ビルトイン Agent', + 'settings.agents.sidebar.section.custom': 'カスタム Agent', + 'settings.agents.sidebar.badge.system': 'システム', + 'settings.agents.sidebar.dialog.deleteTitle': 'Agent を削除', + 'settings.agents.sidebar.dialog.resetTitle': 'Agent をリセット', + 'settings.agents.sidebar.dialog.deleteDescription': 'Agent "{name}" を削除してもよろしいですか?', + 'settings.agents.sidebar.dialog.resetDescription': 'Agent "{name}" をデフォルト設定にリセットしてもよろしいですか?', + 'settings.agents.sidebar.renameDialog.title': 'Agent の名前変更', + 'settings.agents.sidebar.renameDialog.description': 'Agent "@{name}" の新しい名前を入力してください', + 'settings.agents.sidebar.renameDialog.placeholder': '新しい Agent 名...', + 'settings.agents.sidebar.toast.builtInCannotDelete': 'ビルトイン Agent は削除できません', + 'settings.agents.sidebar.toast.agentDeleted': 'Agent "{name}" を削除しました', + 'settings.agents.sidebar.toast.agentReset': 'Agent "{name}" をデフォルトにリセットしました', + 'settings.agents.sidebar.toast.deleteFailed': 'Agent の削除に失敗しました', + 'settings.agents.sidebar.toast.definitionNotFound': 'Agent 定義が見つかりませんでした。変更は行われませんでした。', + 'settings.agents.sidebar.toast.resetFailed': 'Agent のリセットに失敗しました', + 'settings.agents.sidebar.toast.agentNameRequired': 'Agent 名が必要です', + 'settings.agents.sidebar.toast.agentExists': '同じ名前の Agent が既に存在します', + 'settings.agents.sidebar.toast.removeOldAfterRenameFailed': '名前変更後に古い Agent の削除に失敗しました', + 'settings.agents.sidebar.toast.renameFailed': 'Agent の名前変更に失敗しました', + 'settings.agents.sidebar.toast.agentRenamed': 'Agent の名前を「{name}」に変更しました', + 'settings.agents.page.toast.permissionNameRequired': '権限名が必要です', + 'settings.agents.page.toast.created': 'Agent を作成しました', + 'settings.agents.page.toast.updated': 'Agent を更新しました', + 'settings.agents.page.toast.createFailed': 'Agent の作成に失敗しました', + 'settings.agents.page.toast.updateFailed': 'Agent の更新に失敗しました', + 'settings.agents.page.toast.saveUnexpectedError': '保存中にエラーが発生しました', + 'settings.agents.page.toast.savedManualRestart': 'ディスクに保存しました。変更を適用するには接続中の OpenCode サーバーを再起動してください。', + 'settings.agents.page.empty.title': 'サイドバーから Agent を選択してください', + 'settings.agents.page.empty.description': 'または新しいものを作成', + 'settings.agents.page.title.new': '新しい Agent', + 'settings.agents.page.subtitle.new': '新しいアシスタントペルソナを設定', + 'settings.agents.page.subtitle.edit': 'Agent 設定を編集', + 'settings.agents.page.section.identityRole': '識別情報と役割', + 'settings.agents.page.section.modelParameters': 'モデルとパラメーター', + 'settings.agents.page.section.systemPrompt': 'システムプロンプト', + 'settings.agents.page.section.toolPermissions': 'ツール権限', + 'settings.agents.page.field.agentName': 'Agent 名', + 'settings.agents.page.field.agentNamePlaceholder': 'agent-name', + 'settings.agents.page.field.scopePlaceholder': 'スコープ', + 'settings.agents.page.field.descriptionPlaceholder': 'この Agent は何をしますか?', + 'settings.agents.page.field.mode': 'モード', + 'settings.agents.page.field.modeTooltip': 'プライマリと Subagent の表示設定', + 'settings.agents.page.field.overrideModel': 'モデルを上書き', + 'settings.agents.page.field.temperature': '温度', + 'settings.agents.page.field.temperatureTooltip': 'ランダム性を制御します。高い = 創造的、低い = 集中的。', + 'settings.agents.page.field.temperatureRange': '0.0 ~ 2.0', + 'settings.agents.page.field.clearTemperatureAria': 'Temperature の上書きをクリア', + 'settings.agents.page.field.topP': 'Top P', + 'settings.agents.page.field.topPTooltip': 'Nucleus サンプリングの多様性。低い = 確率の高い Token のみ。', + 'settings.agents.page.field.topPRange': '0.0 ~ 1.0', + 'settings.agents.page.field.clearTopPAria': 'Top P の上書きをクリア', + 'settings.agents.page.field.variant': '思考バリアント', + 'settings.agents.page.field.variantTooltip': 'この Agent の思考/推論の深さを制御します。プロバイダー固有のパラメーター(例: Anthropic variant、OpenAI reasoning effort)に対応します。', + 'settings.agents.page.field.variantHint': '例: high、max、low', + 'settings.agents.page.field.variantPlaceholder': 'デフォルト', + 'settings.agents.page.field.systemPromptPlaceholder': 'あなたは熟練したコーディングアシスタントです...', + 'settings.agents.page.mode.primary': 'プライマリ', + 'settings.agents.page.mode.subagent': 'サブエージェント', + 'settings.agents.page.mode.all': 'すべて', + 'settings.agents.page.permissions.defaultLabel': 'デフォルト', + 'settings.agents.page.permissions.hideEditor': 'エディターを非表示', + 'settings.agents.page.permissions.advancedEditor': '高度なエディター', + 'settings.agents.page.permissions.globalSummary': 'グローバル: {summary}', + 'settings.agents.page.permissions.rulesSummary': 'ルール: {summary}', + 'settings.agents.page.permissions.globalDefault': 'グローバルデフォルト', + 'settings.agents.page.permissions.pattern': 'パターン', + 'settings.agents.page.permissions.addCustomRule': 'カスタムルールを追加', + 'settings.agents.page.permissions.permissionPlaceholder': '権限...', + 'settings.agents.page.permissions.patternPlaceholder': 'パターン(例: *)', + 'settings.page.behavior.title': '動作', + 'settings.behavior.page.title': '動作', + 'settings.behavior.page.warning.title': 'グローバルルールはプロジェクトルールと組み合わされます', + 'settings.behavior.page.warning.description': 'ここでの変更は {path} を更新します。OpenCode はプロジェクトレベルの AGENTS.md ルールも存在する場合は含めます。', + 'settings.behavior.page.section.systemPrompt': 'グローバル AGENTS.md', + 'settings.behavior.page.field.systemPromptPlaceholder': 'あなたは役立つ AI アシスタントです...\n\nこのスペースを使用して、すべての Session と Provider にわたって AI の動作方法に関する絶対的なルールを定義してください。', + 'settings.behavior.page.section.responseStyle': '応答スタイル', + 'settings.behavior.page.responseStyle.tooltip': '有効にすると、これらの指示は新しい会話ごとにアシスタントの応答方法をガイドします。最初のメッセージとともに送信され、グローバル AGENTS.md ルールは変更しません。', + 'settings.behavior.page.responseStyle.enableAria': '応答スタイルの指示を有効化', + 'settings.behavior.page.responseStyle.enable': '新しい会話に応答スタイルの指示を追加', + 'settings.behavior.page.responseStyle.preset': 'プリセット', + 'settings.behavior.page.responseStyle.option.concise': '簡潔', + 'settings.behavior.page.responseStyle.option.detailed': '詳細', + 'settings.behavior.page.responseStyle.option.mentor': 'メンター', + 'settings.behavior.page.responseStyle.option.pushback': '反論', + 'settings.behavior.page.responseStyle.option.noFiller': '無駄なし', + 'settings.behavior.page.responseStyle.option.matchEnergy': 'テンションに合わせる', + 'settings.behavior.page.responseStyle.option.warmPeer': '親しみやすい同僚', + 'settings.behavior.page.responseStyle.option.custom': 'カスタム', + 'settings.behavior.page.responseStyle.customPlaceholder': '最初のメッセージに追加する指示を記述...', + 'settings.behavior.page.toast.saved': '動作設定を保存しました', + 'settings.behavior.page.toast.saveFailed': '動作設定の保存に失敗しました', + 'settings.commands.sidebar.title': 'コマンド', + 'settings.commands.sidebar.total': '合計 {count}', + 'settings.commands.sidebar.empty.title': 'Command が設定されていません', + 'settings.commands.sidebar.empty.description': '上の + ボタンを使用して作成してください', + 'settings.commands.sidebar.section.builtIn': 'ビルトイン Command', + 'settings.commands.sidebar.section.custom': 'カスタム Command', + 'settings.commands.sidebar.dialog.deleteTitle': 'Command を削除', + 'settings.commands.sidebar.dialog.resetTitle': 'Command をリセット', + 'settings.commands.sidebar.dialog.deleteDescription': 'Command "{name}" を削除してもよろしいですか?', + 'settings.commands.sidebar.dialog.resetDescription': 'Command "{name}" をデフォルト設定にリセットしてもよろしいですか?', + 'settings.commands.sidebar.renameDialog.title': 'Command の名前変更', + 'settings.commands.sidebar.renameDialog.description': 'Command "/{name}" の新しい名前を入力してください', + 'settings.commands.sidebar.renameDialog.placeholder': '新しい Command 名...', + 'settings.commands.sidebar.toast.builtInCannotDelete': 'ビルトイン Command は削除できません', + 'settings.commands.sidebar.toast.commandDeleted': 'Command "{name}" を削除しました', + 'settings.commands.sidebar.toast.commandReset': 'Command "{name}" をデフォルトにリセットしました', + 'settings.commands.sidebar.toast.deleteFailed': 'Command の削除に失敗しました', + 'settings.commands.sidebar.toast.resetFailed': 'Command のリセットに失敗しました', + 'settings.commands.sidebar.toast.commandNameRequired': 'Command 名が必要です', + 'settings.commands.sidebar.toast.commandExists': '同じ名前の Command が既に存在します', + 'settings.commands.sidebar.toast.removeOldAfterRenameFailed': '名前変更後に古い Command の削除に失敗しました', + 'settings.commands.sidebar.toast.renameFailed': 'Command の名前変更に失敗しました', + 'settings.commands.page.toast.templateRequired': 'Command Template が必要です', + 'settings.commands.page.toast.created': 'Command を作成しました', + 'settings.commands.page.toast.updated': 'Command を更新しました', + 'settings.commands.page.toast.createFailed': 'Command の作成に失敗しました', + 'settings.commands.page.toast.updateFailed': 'Command の更新に失敗しました', + 'settings.commands.page.toast.saveUnexpectedError': '保存中にエラーが発生しました', + 'settings.commands.page.empty.title': 'サイドバーから Command を選択してください', + 'settings.commands.page.empty.description': 'または新しいものを作成', + 'settings.commands.page.title.new': '新しい Command', + 'settings.commands.page.subtitle.new': '新しいスラッシュ Command を設定', + 'settings.commands.page.subtitle.edit': 'Command 設定を編集', + 'settings.commands.page.section.identity': '識別情報', + 'settings.commands.page.section.executionContext': '実行コンテキスト', + 'settings.commands.page.section.template': 'コマンドテンプレート', + 'settings.commands.page.field.commandName': 'Command 名', + 'settings.commands.page.field.commandNamePlaceholder': 'command-name', + 'settings.commands.page.field.descriptionPlaceholder': 'この Command は何をしますか?', + 'settings.commands.page.field.overrideAgent': 'Agent を上書き', + 'settings.commands.page.field.templatePlaceholder': 'ここに Command Template を記述...\n\nユーザー入力を参照するには $ARGUMENTS を使用します。\nシェル出力を挿入するには !`shell command` を使用します。\nファイル内容を含めるには @filename を使用します。', + 'settings.commands.page.templateHint.userInput': 'ユーザー入力', + 'settings.commands.page.templateHint.shellOutput': 'シェル出力', + 'settings.commands.page.templateHint.fileContents': 'ファイル内容', + 'settings.commands.agentSelector.title': 'Agent を選択', + 'settings.commands.agentSelector.notSelected': '選択されていません', + 'settings.commands.agentSelector.selectAgentPlaceholder': 'Agent を選択...', + 'settings.gitIdentities.page.section.title': 'ID', + 'settings.gitIdentities.page.empty.title': 'Identity が設定されていません', + 'settings.gitIdentities.page.empty.description': 'プロジェクトごとの Git 作成者設定を管理するために作成してください', + 'settings.gitIdentities.page.discoveredCredentials.title': '~/.git-credentials で見つかりました', + 'settings.gitIdentities.page.badge.default': 'デフォルト', + 'settings.gitIdentities.page.actions.setAsDefault': 'デフォルトに設定', + 'settings.gitIdentities.page.actions.unsetDefault': 'デフォルトを解除', + 'settings.gitIdentities.page.actions.import': 'インポート', + 'settings.gitIdentities.page.toast.updateDefaultFailed': 'デフォルト Identity の更新に失敗しました', + 'settings.gitIdentities.page.toast.defaultUpdated': 'デフォルト Identity を更新しました', + 'settings.gitIdentities.page.toast.defaultUnset': 'デフォルト Identity を解除しました', + 'settings.gitIdentities.page.toast.profileDeleted': 'プロファイル "{name}" を削除しました', + 'settings.gitIdentities.page.toast.deleteProfileFailed': 'プロファイルの削除に失敗しました', + 'settings.gitIdentities.page.deleteDialog.title': 'プロファイルを削除', + 'settings.gitIdentities.page.deleteDialog.description': '"{name}" を削除してもよろしいですか?', + 'settings.gitIdentities.editor.title.importCredential': '認証情報をインポート', + 'settings.gitIdentities.editor.title.newIdentity': '新しい Identity', + 'settings.gitIdentities.editor.title.globalIdentity': 'グローバル Identity', + 'settings.gitIdentities.editor.title.editIdentity': 'Identity を編集', + 'settings.gitIdentities.editor.description.globalReadOnly': 'システム全体の Git Identity(読み取り専用)', + 'settings.gitIdentities.editor.description.newProfile': '新しい Git Identity プロファイルを作成', + 'settings.gitIdentities.editor.description.editProfile': 'Identity プロファイル設定を編集', + 'settings.gitIdentities.editor.section.commitSigning': 'Commit 署名', + 'settings.gitIdentities.editor.field.profileName': 'プロファイル名', + 'settings.gitIdentities.editor.field.profileNamePlaceholder': '仕事用、個人用など', + 'settings.gitIdentities.editor.field.color': '色', + 'settings.gitIdentities.editor.field.icon': 'アイコン', + 'settings.gitIdentities.editor.field.userName': 'ユーザー名', + 'settings.gitIdentities.editor.field.userNameTooltip': 'Git Commit メッセージに表示される名前です。', + 'settings.gitIdentities.editor.field.userNamePlaceholder': '山田 太郎', + 'settings.gitIdentities.editor.field.emailAddress': 'メールアドレス', + 'settings.gitIdentities.editor.field.emailAddressTooltip': 'GitHub/GitLab でのメールアドレスと一致させることで適切な帰属が行われます。', + 'settings.gitIdentities.editor.field.emailAddressPlaceholder': 'yamada@example.com', + 'settings.gitIdentities.editor.field.authMethod': '認証方法', + 'settings.gitIdentities.editor.field.authToken': 'Token', + 'settings.gitIdentities.editor.field.sshKeyPath': 'SSH 鍵パス', + 'settings.gitIdentities.editor.field.sshKeyPathTooltip': '秘密鍵へのパス(任意)。例: ~/.ssh/id_ed25519', + 'settings.gitIdentities.editor.field.sshKeyPathPlaceholder': '~/.ssh/id_ed25519', + 'settings.gitIdentities.editor.field.signCommits': 'この Identity で Commit に署名', + 'settings.gitIdentities.editor.field.signingKey': '署名鍵', + 'settings.gitIdentities.editor.field.signingKeyPlaceholder': '~/.ssh/id_ed25519.pub', + 'settings.gitIdentities.editor.field.host': 'ホスト', + 'settings.gitIdentities.editor.field.hostTooltip': 'このホストの Token が ~/.git-credentials から読み取られます。', + 'settings.gitIdentities.editor.field.hostPlaceholder': 'github.com', + 'settings.gitIdentities.editor.actions.close': '閉じる', + 'settings.gitIdentities.editor.actions.create': '作成', + 'settings.gitIdentities.editor.actions.save': '保存', + 'settings.gitIdentities.editor.toast.userNameEmailRequired': 'ユーザー名とメールアドレスが必要です', + 'settings.gitIdentities.editor.toast.hostRequiredForToken': 'Token ベースの認証にはホストが必要です', + 'settings.gitIdentities.editor.toast.signingKeyRequired': 'Commit 署名が有効な場合は署名鍵が必要です', + 'settings.gitIdentities.editor.toast.profileCreated': 'プロファイルを作成しました', + 'settings.gitIdentities.editor.toast.profileUpdated': 'プロファイルを更新しました', + 'settings.gitIdentities.editor.toast.createProfileFailed': 'プロファイルの作成に失敗しました', + 'settings.gitIdentities.editor.toast.updateProfileFailed': 'プロファイルの更新に失敗しました', + 'settings.gitIdentities.editor.toast.saveUnexpectedError': '保存中にエラーが発生しました', + 'settings.gitIdentities.editor.toast.profileDeleted': 'プロファイルを削除しました', + 'settings.gitIdentities.editor.toast.deleteProfileFailed': 'プロファイルの削除に失敗しました', + 'settings.gitIdentities.editor.toast.deleteUnexpectedError': '削除中にエラーが発生しました', + 'settings.skills.sidebar.title': 'スキル', + 'settings.skills.sidebar.total': '合計 {count}', + 'settings.skills.sidebar.section.project': 'プロジェクト Skill', + 'settings.skills.sidebar.section.user': 'ユーザー Skill', + 'settings.skills.sidebar.empty.title': 'Skill が設定されていません', + 'settings.skills.sidebar.empty.description': '上の + ボタンを使用して作成してください', + 'settings.skills.sidebar.badge.claude': 'claude', + 'settings.skills.sidebar.badge.agents': 'agents', + 'settings.skills.sidebar.badge.opencode': 'opencode', + 'settings.skills.sidebar.toast.skillDeleted': 'Skill "{name}" を削除しました', + 'settings.skills.sidebar.toast.deleteSkillFailed': 'Skill の削除に失敗しました', + 'settings.skills.sidebar.toast.duplicateLoadFailed': '複製用の Skill 詳細の読み込みに失敗しました', + 'settings.skills.sidebar.toast.renameLoadFailed': 'Skill 詳細の読み込みに失敗しました', + 'settings.skills.sidebar.toast.removeOldAfterRenameFailed': '名前変更後に古い Skill の削除に失敗しました', + 'settings.skills.sidebar.toast.renameFailed': 'Skill の名前変更に失敗しました', + 'settings.skills.sidebar.deleteDialog.title': 'Skill を削除', + 'settings.skills.sidebar.deleteDialog.description': 'Skill "{name}" を削除してもよろしいですか?', + 'settings.skills.sidebar.renameDialog.title': 'Skill の名前変更', + 'settings.skills.sidebar.renameDialog.description': 'Skill "{name}" の新しい名前を入力してください', + 'settings.skills.sidebar.renameDialog.placeholder': '新しい Skill 名...', + 'settings.skills.page.title.newSkill': '新しい Skill', + 'settings.skills.page.subtitle.newSkill': '新しい Skill を設定', + 'settings.skills.page.subtitle.skillLocation': '{location} Skill', + 'settings.skills.page.section.basicInformation': '基本情報', + 'settings.skills.page.section.instructions': '指示', + 'settings.skills.page.section.supportingFiles': 'サポートファイル', + 'settings.skills.page.field.skillNameLocation': 'Skill 名と場所', + 'settings.skills.page.field.skillNameHint': '小文字、数字、ハイフン', + 'settings.skills.page.field.skillNamePlaceholder': 'skill-name', + 'settings.skills.page.field.descriptionHint': 'Agent はこれを使用して Skill を読み込むタイミングを判断します', + 'settings.skills.page.field.descriptionPlaceholder': 'この Skill の簡単な説明...', + 'settings.skills.page.field.instructionsPlaceholder': 'ステップごとの指示、ガイドライン、参考内容...', + 'settings.skills.page.badge.claudeCompatible': 'Claude 互換', + 'settings.skills.page.badge.pending': '保留中', + 'settings.skills.page.actions.addFile': 'ファイルを追加', + 'settings.skills.page.actions.createSkill': 'Skill を作成', + 'settings.skills.page.actions.createFile': 'ファイルを作成', + 'settings.skills.page.supportingFiles.empty': 'サポートファイルがありません。「ファイルを追加」を使用して参考資料を含めてください。', + 'settings.skills.page.loading.details': 'Skill 詳細を読み込み中...', + 'settings.skills.page.loading.fileContent': 'ファイル内容を読み込み中...', + 'settings.skills.page.empty.title': 'サイドバーから Skill を選択してください', + 'settings.skills.page.empty.description': 'または新しいものを作成', + 'settings.skills.page.deleteFileDialog.title': 'サポートファイルを削除', + 'settings.skills.page.deleteFileDialog.description': '"{path}" を削除してもよろしいですか?', + 'settings.skills.page.fileDialog.titleEdit': 'サポートファイルを編集', + 'settings.skills.page.fileDialog.titleAdd': 'サポートファイルを追加', + 'settings.skills.page.fileDialog.descriptionEdit': 'ファイル内容を変更', + 'settings.skills.page.fileDialog.descriptionAdd': 'Skill ディレクトリに新しいファイルを作成', + 'settings.skills.page.fileDialog.field.filePath': 'ファイルパス', + 'settings.skills.page.fileDialog.field.filePathPlaceholder': 'example.md または docs/reference.txt', + 'settings.skills.page.fileDialog.field.filePathHint': 'Skill ディレクトリ内の相対パス。サブディレクトリは自動的に作成されます。', + 'settings.skills.page.fileDialog.field.content': '内容', + 'settings.skills.page.fileDialog.field.contentPlaceholder': 'ファイル内容...', + 'settings.skills.page.toast.skillNameRequired': 'Skill 名が必要です', + 'settings.skills.page.toast.invalidSkillName': 'Skill 名は1~64文字の小文字英数字とハイフンで、ハイフンで開始または終了できません', + 'settings.skills.page.toast.descriptionRequired': '説明が必要です', + 'settings.skills.page.toast.skillExists': '同じ名前の Skill が既に存在します', + 'settings.skills.page.toast.skillCreated': 'Skill を作成しました', + 'settings.skills.page.toast.skillUpdated': 'Skill を更新しました', + 'settings.skills.page.toast.createSkillFailed': 'Skill の作成に失敗しました', + 'settings.skills.page.toast.updateSkillFailed': 'Skill の更新に失敗しました', + 'settings.skills.page.toast.saveUnexpectedError': '保存中にエラーが発生しました', + 'settings.skills.page.toast.loadFileContentFailed': 'ファイル内容の読み込みに失敗しました', + 'settings.skills.page.toast.fileNameRequired': 'ファイル名が必要です', + 'settings.skills.page.toast.fileExists': '同じ名前のファイルが既に存在します', + 'settings.skills.page.toast.noSkillSelected': 'Skill が選択されていません', + 'settings.skills.page.toast.fileAdded': 'ファイル "{path}" を追加しました', + 'settings.skills.page.toast.fileCreated': 'ファイル "{path}" を作成しました', + 'settings.skills.page.toast.fileUpdated': 'ファイル "{path}" を更新しました', + 'settings.skills.page.toast.fileRemoved': 'ファイル "{path}" を削除しました', + 'settings.skills.page.toast.fileDeleted': 'ファイル "{path}" を削除しました', + 'settings.skills.page.toast.createFileFailed': 'ファイルの作成に失敗しました', + 'settings.skills.page.toast.updateFileFailed': 'ファイルの更新に失敗しました', + 'settings.skills.page.toast.deleteFileFailed': 'ファイルの削除に失敗しました', + 'settings.skills.location.option.userOpencode.label': 'ユーザー / OpenCode', + 'settings.skills.location.option.userOpencode.description': 'グローバル OpenCode 設定場所', + 'settings.skills.location.option.projectOpencode.label': 'プロジェクト / OpenCode', + 'settings.skills.location.option.projectOpencode.description': '現在のプロジェクト .opencode の場所', + 'settings.skills.location.option.userClaude.label': 'ユーザー / Claude', + 'settings.skills.location.option.userClaude.description': 'グローバル Claude Skill の場所', + 'settings.skills.location.option.projectClaude.label': 'プロジェクト / Claude', + 'settings.skills.location.option.projectClaude.description': '現在のプロジェクト .claude の場所', + 'settings.skills.location.option.userAgents.label': 'ユーザー / Agents', + 'settings.skills.location.option.userAgents.description': 'グローバル .agents 互換性の場所', + 'settings.skills.location.option.projectAgents.label': 'プロジェクト / Agents', + 'settings.skills.location.option.projectAgents.description': '現在のプロジェクト .agents 互換性の場所', + 'settings.skills.catalog.shared.field.repository': 'リポジトリ', + 'settings.skills.catalog.shared.field.repositoryPlaceholder': 'owner/repo または git@github.com:owner/repo.git', + 'settings.skills.catalog.shared.field.optionalSubpath': 'オプションのサブパス', + 'settings.skills.catalog.shared.field.subpathPlaceholder': '例: skills', + 'settings.skills.catalog.shared.field.targetLocation': '対象の場所', + 'settings.skills.catalog.shared.field.project': 'プロジェクト', + 'settings.skills.catalog.shared.field.noProjects': '利用可能なプロジェクトがありません', + 'settings.skills.catalog.shared.field.chooseProjectPlaceholder': 'プロジェクトを選択', + 'settings.skills.catalog.shared.field.searchSkillsPlaceholder': 'Skill を検索...', + 'settings.skills.catalog.shared.toast.repositoryRequired': 'リポジトリソースが必要です', + 'settings.skills.catalog.shared.toast.privateRepoNotSupportedVsCode': 'プライベートリポジトリは VS Code ではまだサポートされていません', + 'settings.skills.catalog.shared.toast.foundSkills': '{count} 個の Skill が見つかりました', + 'settings.skills.catalog.shared.noDescription': '説明がありません', + 'settings.skills.catalog.shared.actions.scan': 'スキャン', + 'settings.skills.catalog.shared.actions.scanning': 'スキャン中...', + 'settings.skills.catalog.shared.actions.install': 'インストール', + 'settings.skills.catalog.shared.actions.installing': 'インストール中...', + 'settings.skills.catalog.shared.auth.title': '認証が必要です', + 'settings.skills.catalog.shared.auth.description': 'Git Identity(SSH 鍵)を選択してください', + 'settings.skills.catalog.shared.auth.chooseIdentity': 'Identity を選択', + 'settings.skills.catalog.shared.auth.footerHint': 'Identity は設定 - Git Identities で設定できます。', + 'settings.skills.catalog.shared.auth.footerHintArrow': 'Identity は設定 → Git Identities で設定できます。', + 'settings.skills.catalog.conflicts.title': 'Skill が既に存在します', + 'settings.skills.catalog.conflicts.description': '選択した Skill の一部はこのスコープに既にインストールされています。スキップするか上書きするかを選択してください。', + 'settings.skills.catalog.conflicts.count': '{count} 件の競合', + 'settings.skills.catalog.conflicts.installedIn': '{scope} / {source} にインストール済み', + 'settings.skills.catalog.conflicts.scope.user': 'ユーザー', + 'settings.skills.catalog.conflicts.scope.project': 'プロジェクト', + 'settings.skills.catalog.conflicts.source.opencode': 'opencode', + 'settings.skills.catalog.conflicts.source.agents': 'agents', + 'settings.skills.catalog.conflicts.decision.skip': 'スキップ', + 'settings.skills.catalog.conflicts.decision.overwrite': '上書き', + 'settings.skills.catalog.conflicts.actions.skipAll': 'すべてスキップ', + 'settings.skills.catalog.conflicts.actions.overwriteAll': 'すべて上書き', + 'settings.skills.catalog.conflicts.actions.continue': '続行', + 'settings.skills.catalog.installSkill.title': 'Skill をインストール', + 'settings.skills.catalog.installSkill.descriptionPrefix': 'インストール', + 'settings.skills.catalog.installSkill.descriptionSuffix': 'を4つの対象場所のいずれかにインストールします。', + 'settings.skills.catalog.installSkill.field.destination': 'インストール先', + 'settings.skills.catalog.installSkill.field.project': 'プロジェクト', + 'settings.skills.catalog.installSkill.field.noProjects': '利用可能なプロジェクトがありません', + 'settings.skills.catalog.installSkill.field.chooseProjectPlaceholder': 'プロジェクトを選択', + 'settings.skills.catalog.installSkill.actions.install': 'インストール', + 'settings.skills.catalog.installSkill.actions.installing': 'インストール中...', + 'settings.skills.catalog.installSkill.toast.installed': 'Skill をインストールしました', + 'settings.skills.catalog.installSkill.toast.authRequired': '認証が必要です', + 'settings.skills.catalog.installSkill.toast.installFailed': 'Skill のインストールに失敗しました', + 'settings.skills.catalog.add.title': 'Skills Catalog を追加', + 'settings.skills.catalog.add.descriptionPrefix': 'Git リポジトリを新しいカタログソースとして追加します。OpenChamber が以下のファイルを含むフォルダーをスキャンします:', + 'settings.skills.catalog.add.descriptionSuffix': '。', + 'settings.skills.catalog.add.field.catalogName': 'カタログ名', + 'settings.skills.catalog.add.field.catalogNamePlaceholder': '例: チーム Skill', + 'settings.skills.catalog.add.field.repository': 'リポジトリ', + 'settings.skills.catalog.add.field.optionalSubpath': 'オプションのサブパス', + 'settings.skills.catalog.add.field.repositoryHint': '公開リポジトリはどこでも動作します。プライベートリポジトリは SSH Identity が必要です(Desktop/Web のみ)。', + 'settings.skills.catalog.add.scanResult': 'スキャン結果: {count} 個の Skill が見つかりました', + 'settings.skills.catalog.add.duplicateMessage': 'このカタログは既に追加されています。', + 'settings.skills.catalog.add.actions.addCatalog': 'カタログを追加', + 'settings.skills.catalog.add.toast.repositoryRequired': 'リポジトリソースが必要です', + 'settings.skills.catalog.add.toast.authenticationRequiredScan': '認証が必要です。Git Identity を選択して再度スキャンしてください。', + 'settings.skills.catalog.add.toast.scanFailed': 'リポジトリのスキャンに失敗しました', + 'settings.skills.catalog.add.toast.noSkillsFound': 'このリポジトリに Skill が見つかりませんでした', + 'settings.skills.catalog.add.toast.catalogNameRequired': 'カタログ名が必要です', + 'settings.skills.catalog.add.toast.scanBeforeAdd': 'このカタログを追加する前にリポジトリをスキャンしてください', + 'settings.skills.catalog.add.toast.catalogAlreadyExists': 'このカタログは既に存在します', + 'settings.skills.catalog.add.toast.catalogAdded': 'カタログを追加しました', + 'settings.skills.catalog.add.toast.saveFailed': 'カタログの保存に失敗しました', + 'settings.skills.catalog.installFromRepo.title': 'Git リポジトリからインストール', + 'settings.skills.catalog.installFromRepo.descriptionPrefix': '以下のファイルを含むフォルダーをリポジトリからスキャンし', + 'settings.skills.catalog.installFromRepo.descriptionSuffix': '、選択した Skill をインストールします。', + 'settings.skills.catalog.installFromRepo.repositoryHintPrefix': 'GitHub のショートハンドを使用する場合、次のようにサブパスを追加できます:', + 'settings.skills.catalog.installFromRepo.authDescription': 'このリポジトリにアクセスできる Git Identity(SSH 鍵)を選択してください。', + 'settings.skills.catalog.installFromRepo.empty.noScanResultsTitle': 'まだスキャン結果がありません', + 'settings.skills.catalog.installFromRepo.empty.noScanResultsDescription': 'リポジトリをスキャンして Skill を発見', + 'settings.skills.catalog.installFromRepo.actions.selectAll': 'すべて選択', + 'settings.skills.catalog.installFromRepo.actions.selectNone': 'すべて解除', + 'settings.skills.catalog.installFromRepo.actions.installSelected': '選択したものをインストール', + 'settings.skills.catalog.installFromRepo.badge.installed': 'インストール済み ({scope}/{source})', + 'settings.skills.catalog.installFromRepo.selectedCount': '選択: {selected} / {total}', + 'settings.skills.catalog.installFromRepo.toast.authenticationRequiredScan': '認証が必要です。Git Identity を選択して再度スキャンしてください。', + 'settings.skills.catalog.installFromRepo.toast.authenticationRequiredInstall': '認証が必要です。Git Identity を選択して再度インストールしてください。', + 'settings.skills.catalog.installFromRepo.toast.scanFailed': 'リポジトリのスキャンに失敗しました', + 'settings.skills.catalog.installFromRepo.toast.selectAtLeastOne': '少なくとも1つの Skill を選択してください', + 'settings.skills.catalog.installFromRepo.toast.installedCount': '{count} 個の Skill をインストールしました', + 'settings.skills.catalog.installFromRepo.toast.installCompleted': 'インストールが完了しました', + 'settings.skills.catalog.installFromRepo.toast.installFailed': 'Skill のインストールに失敗しました', + 'settings.skills.catalog.page.mode.manual': '手動', + 'settings.skills.catalog.page.mode.external': '外部', + 'settings.skills.catalog.page.title': 'スキルカタログ', + 'settings.skills.catalog.page.section.sourceRepository': 'ソースリポジトリ', + 'settings.skills.catalog.page.field.selectSourcePlaceholder': 'ソースを選択', + 'settings.skills.catalog.page.actions.refreshTitle': '更新', + 'settings.skills.catalog.page.actions.removeCatalogTitle': 'カタログを削除', + 'settings.skills.catalog.page.actions.addCatalog': 'カタログを追加', + 'settings.skills.catalog.page.actions.removeCatalog': 'カタログを削除', + 'settings.skills.catalog.page.actions.loadMoreSkills': 'さらに Skill を読み込む', + 'settings.skills.catalog.page.loading.catalog': '読み込み中...', + 'settings.skills.catalog.page.loading.skills': 'Skill を読み込み中...', + 'settings.skills.catalog.page.loading.more': '読み込み中...', + 'settings.skills.catalog.page.foundCount': '{count} 個の Skill が見つかりました', + 'settings.skills.catalog.page.error.catalogTitle': 'カタログエラー', + 'settings.skills.catalog.page.empty.noSkillsTitle': 'Skill が見つかりません', + 'settings.skills.catalog.page.empty.noSkillsDescription': '別の検索語を試すか、カタログを更新してください', + 'settings.skills.catalog.page.badge.installed': 'インストール済み ({scope})', + 'settings.skills.catalog.page.badge.notInstallable': 'インストール不可', + 'settings.skills.catalog.page.badge.unknown': '不明', + 'settings.skills.catalog.page.byOwnerPrefix': '提供', + 'settings.skills.catalog.page.removeDialog.title': 'カタログを削除', + 'settings.skills.catalog.page.removeDialog.description': 'このカタログを削除してもよろしいですか?', + 'settings.openchamber.passkeys.title': 'パスキー', + 'settings.openchamber.passkeys.field.currentDevice': '現在のデバイス', + 'settings.openchamber.passkeys.actions.add': 'パスキーを追加', + 'settings.openchamber.passkeys.actions.cancelSetup': 'パスキー設定をキャンセル', + 'settings.openchamber.passkeys.actions.signOutEverywhere': 'すべての端末からサインアウト', + 'settings.openchamber.passkeys.actions.signingOut': 'サインアウト中...', + 'settings.openchamber.passkeys.actions.removing': '削除中...', + 'settings.openchamber.passkeys.state.uiPasswordRequired': 'パスキーは UI パスワードロックが有効な場合のみ利用できます。', + 'settings.openchamber.passkeys.state.loading': 'パスキーを読み込み中...', + 'settings.openchamber.passkeys.state.noneSaved': 'このホストに保存されたパスキーはまだありません。', + 'settings.openchamber.passkeys.item.lastUsed': '最終使用 {time}', + 'settings.openchamber.passkeys.item.added': '追加日 {time}', + 'settings.openchamber.passkeys.time.neverUsed': '未使用', + 'settings.openchamber.passkeys.toast.loadFailed': 'パスキーを読み込めませんでした。', + 'settings.openchamber.passkeys.toast.enableUiPasswordFirst': 'パスキーを追加する前に UI パスワードロックを有効にしてください。', + 'settings.openchamber.passkeys.toast.added': 'パスキーを追加しました', + 'settings.openchamber.passkeys.toast.setupCanceled': 'パスキー設定をキャンセルしました', + 'settings.openchamber.passkeys.toast.addFailed': 'パスキーを追加できませんでした。', + 'settings.openchamber.passkeys.toast.removed': 'パスキーを削除しました', + 'settings.openchamber.passkeys.toast.removeFailed': 'パスキーを削除できませんでした。', + 'settings.openchamber.passkeys.toast.clearAuthFailed': '保存された認証情報をクリアできませんでした。', + 'settings.openchamber.sessionRetention.title': 'Session 保持', + 'settings.openchamber.sessionRetention.tooltip': '最終アクティビティに基づいて非アクティブな Session を自動的にアーカイブまたは削除します。最新5件の Session は保持されます。', + 'settings.openchamber.sessionRetention.field.enableAutoCleanupAria': '自動クリーンアップを有効化', + 'settings.openchamber.sessionRetention.field.enableAutoCleanup': '自動クリーンアップを有効化', + 'settings.openchamber.sessionRetention.field.retentionPeriod': '保持期間', + 'settings.openchamber.sessionRetention.field.retentionPeriodAria': '保持期間(日数)', + 'settings.openchamber.sessionRetention.field.days': '日', + 'settings.openchamber.sessionRetention.field.whenSessionsExpire': 'Session の期限が切れたとき', + 'settings.openchamber.sessionRetention.actions.resetRetentionAria': '保持期間をリセット', + 'settings.openchamber.sessionRetention.actions.runCleanupNow': '今すぐクリーンアップを実行', + 'settings.openchamber.sessionRetention.actions.cleaningUp': 'クリーンアップ中...', + 'settings.openchamber.sessionRetention.action.archive': 'アーカイブ', + 'settings.openchamber.sessionRetention.action.delete': '削除', + 'settings.openchamber.sessionRetention.manualCleanup.title': '手動クリーンアップ', + 'settings.openchamber.sessionRetention.manualCleanup.eligibleArchiveNow': '現在アーカイブ可能: {count}', + 'settings.openchamber.sessionRetention.manualCleanup.eligibleDeleteNow': '現在削除可能: {count}', + 'settings.openchamber.sessionRetention.toast.noneEligibleArchive': 'アーカイブ可能な Session はありません', + 'settings.openchamber.sessionRetention.toast.noneEligibleDelete': '削除可能な Session はありません', + 'settings.openchamber.sessionRetention.toast.archivedCount': '{count} 個の Session をアーカイブしました', + 'settings.openchamber.sessionRetention.toast.deletedCount': '{count} 個の Session を削除しました', + 'settings.openchamber.sessionRetention.toast.failedArchiveCount': '{count} 個の Session のアーカイブに失敗しました', + 'settings.openchamber.sessionRetention.toast.failedDeleteCount': '{count} 個の Session の削除に失敗しました', + 'settings.openchamber.desktopNetwork.title': 'Desktop ネットワークアクセス', + 'settings.openchamber.desktopNetwork.field.launchAtLoginAria': 'ログイン時に OpenChamber を起動', + 'settings.openchamber.desktopNetwork.field.launchAtLogin': 'ログイン時に OpenChamber を起動', + 'settings.openchamber.desktopNetwork.field.launchAtLoginDescription': 'ウィンドウを開かずにバックグラウンドでアプリを起動します。Dock アイコンをクリックして開きます。', + 'settings.openchamber.desktopNetwork.field.allowLanAccessAria': 'Desktop サイドカーへの LAN アクセスを許可', + 'settings.openchamber.desktopNetwork.field.allowLanAccess': 'ローカルネットワーク上の他のデバイスがこのアプリを開けるようにする', + 'settings.openchamber.desktopNetwork.field.allowLanAccessDescription': 'アプリを再起動して、Wi-Fi 上の他のデバイスから開けるようにします。', + 'settings.openchamber.desktopNetwork.field.warning': '警告: 有効にすると、アプリは同じローカルネットワーク上の誰からもアクセス可能になります。', + 'settings.openchamber.desktopNetwork.field.passwordRequiredWarning': 'LAN アクセスには Desktop UI パスワードが必要です。設定されるまで、Desktop アプリはローカルのみで起動します。', + 'settings.openchamber.desktopPassword.field.password': 'Desktop UI パスワード', + 'settings.openchamber.desktopPassword.field.passwordPlaceholder': 'パスワード不要', + 'settings.openchamber.desktopPassword.field.passwordDescription': 'OpenChamber は再起動後、およびログイン Session の有効期限後(12時間、または「このデバイスを信頼」の場合は7日)に確認を求めます。空のままにするとログインが無効になります。', + 'settings.openchamber.desktopNetwork.hint.openAfterRestart': '再起動後、別のデバイスから開く: ', + 'settings.openchamber.desktopNetwork.hint.openNow': '別のデバイスから開く: ', + 'settings.openchamber.desktopNetwork.actions.saveAndRestart': '保存して再起動', + 'settings.openchamber.desktopNetwork.error.loadFailed': 'Desktop 設定の読み込みに失敗しました', + 'settings.openchamber.desktopNetwork.error.saveFailed': 'Desktop 設定の保存に失敗しました', + 'settings.openchamber.desktopNetwork.error.savedRestartFailed': '保存しましたが、アプリの再起動に失敗しました', + 'settings.openchamber.desktopNetwork.error.launchAtLoginUnsupported': 'このシステムではログイン時の起動はサポートされていません', + 'settings.openchamber.desktopNetwork.error.launchAtLoginSaveFailed': 'ログイン時起動設定の更新に失敗しました', + 'settings.openchamber.opencodeCli.title': 'OpenCode CLI', + 'settings.openchamber.opencodeCli.tooltipPrefix': '以下への絶対パス(任意):', + 'settings.openchamber.opencodeCli.tooltipSuffix': 'バイナリ。', + 'settings.openchamber.opencodeCli.field.binaryPath': 'OpenCode バイナリパス', + 'settings.openchamber.opencodeCli.field.binaryPathPlaceholder': '/Users/you/.bun/bin/opencode', + 'settings.openchamber.opencodeCli.field.showUpdateNotifications': 'OpenCode のアップデート通知を表示', + 'settings.openchamber.opencodeCli.field.showUpdateNotificationsAria': 'OpenCode のアップデート通知を表示', + 'settings.openchamber.opencodeCli.actions.browseAria': 'OpenCode バイナリパスを参照', + 'settings.openchamber.opencodeCli.actions.browse': '参照', + 'settings.openchamber.opencodeCli.actions.saveAndReload': '保存して再読み込み', + 'settings.openchamber.opencodeCli.actions.restartingOpenCode': 'OpenCode を再起動中...', + 'settings.openchamber.opencodeCli.dialog.selectBinaryTitle': 'opencode バイナリを選択', + 'settings.openchamber.opencodeCli.tipPrefix': 'ヒント: 環境変数', + 'settings.openchamber.opencodeCli.tipMiddle': 'も使用できますが、この設定は以下に保存されます:', + 'settings.mcp.sidebar.title': 'MCP サーバー', + 'settings.mcp.sidebar.total': '合計 {count}', + 'settings.mcp.sidebar.actions.refreshStatusAria': 'MCP ステータスを更新', + 'settings.mcp.sidebar.actions.refreshStatusTitle': 'MCP ステータスを更新', + 'settings.mcp.sidebar.actions.addServerTitle': 'MCP サーバーを追加', + 'settings.mcp.sidebar.actions.deleting': '削除中...', + 'settings.mcp.sidebar.empty.title': 'MCP サーバーが設定されていません', + 'settings.mcp.sidebar.empty.description': '上の + ボタンを使用して追加してください', + 'settings.mcp.sidebar.group.projectServers': 'プロジェクトサーバー', + 'settings.mcp.sidebar.group.userServers': 'ユーザーサーバー', + 'settings.mcp.sidebar.serverType.localTitle': 'ローカルサーバー', + 'settings.mcp.sidebar.serverType.remoteTitle': 'リモートサーバー', + 'settings.mcp.sidebar.deleteDialog.title': 'MCP サーバーを削除', + 'settings.mcp.sidebar.deleteDialog.descriptionPrefix': '"{name}" を削除してもよろしいですか?以下から削除されます:', + 'settings.mcp.sidebar.toast.deleteFailed': 'MCP サーバーの削除に失敗しました', + 'settings.mcp.sidebar.toast.serverDeleted': 'MCP サーバー "{name}" を削除しました', + 'settings.mcp.sidebar.toast.refreshListIfStale': 'UI が古く見える場合は MCP リストを更新してください。', + 'settings.plugins.sidebar.title': 'プラグイン', + 'settings.plugins.sidebar.total': '合計 {count}', + 'settings.plugins.sidebar.actions.addTitle': 'Plugin を追加', + 'settings.plugins.sidebar.actions.deleting': '削除中...', + 'settings.plugins.sidebar.empty.title': 'Plugin が設定されていません', + 'settings.plugins.sidebar.empty.description': '上の + ボタンを使用して追加してください', + 'settings.plugins.sidebar.group.userEntries': 'ユーザー設定', + 'settings.plugins.sidebar.group.userFiles': 'ユーザー Plugin ファイル', + 'settings.plugins.sidebar.group.projectEntries': 'プロジェクト設定', + 'settings.plugins.sidebar.group.projectFiles': 'プロジェクト Plugin ファイル', + 'settings.plugins.sidebar.kind.npm': 'npm パッケージ', + 'settings.plugins.sidebar.kind.path': 'ローカルパス', + 'settings.plugins.sidebar.kind.file': 'Plugin ファイル', + 'settings.plugins.sidebar.deleteDialog.title': 'Plugin を削除', + 'settings.plugins.sidebar.deleteDialog.description': '"{name}" を削除してもよろしいですか?', + 'settings.plugins.sidebar.toast.deleted': 'Plugin "{name}" を削除しました', + 'settings.plugins.sidebar.toast.deleteFailed': 'Plugin の削除に失敗しました', + 'settings.plugins.page.empty.select': '表示または編集する Plugin を選択', + 'settings.plugins.page.empty.add': 'または + をクリックして新しい Plugin を追加', + 'settings.plugins.page.header.entry': 'インストール済み Plugin', + 'settings.plugins.page.header.file': 'Plugin ファイル', + 'settings.plugins.page.field.spec': '仕様', + 'settings.plugins.page.field.spec.placeholder': 'npm-package@version または /absolute/path', + 'settings.plugins.page.field.options': 'オプション(JSON)', + 'settings.plugins.page.field.options.invalidJson': '無効な JSON', + 'settings.plugins.page.field.fileName': 'ファイル名', + 'settings.plugins.page.field.content': '内容', + 'settings.plugins.page.field.scope': 'スコープ', + 'settings.plugins.scope.user': 'ユーザー', + 'settings.plugins.scope.project': 'プロジェクト', + 'settings.plugins.page.action.save': '保存', + 'settings.plugins.page.action.discard': '破棄', + 'settings.plugins.dialog.add.title': 'Plugin を追加', + 'settings.plugins.dialog.add.tab.npm': 'npm から', + 'settings.plugins.dialog.add.tab.path': 'ローカルパスから', + 'settings.plugins.dialog.add.tab.file': '新しいファイル', + 'settings.plugins.dialog.add.action.submit': '追加', + 'settings.plugins.dialog.add.action.cancel': 'キャンセル', + 'settings.plugins.toast.created': 'Plugin を追加しました', + 'settings.plugins.toast.updated': 'Plugin を更新しました', + 'settings.plugins.toast.reloadFailed': 'opencode の再読み込みに失敗しました - 再起動が必要です', + 'settings.plugins.validation.fileName': 'ファイル名は小文字で、.js / .ts / .mjs / .cjs で終わる必要があります', + 'settings.plugins.validation.specRequired': 'Spec が必要です', + 'settings.plugins.registry.badge.update.label': '↑ {version}', + 'settings.plugins.registry.badge.update.tooltip': 'アップデート可能: {current} → {latest}', + 'settings.plugins.registry.badge.malformed.tooltip': 'Spec の形式が不正です', + 'settings.plugins.registry.badge.missingPackage.tooltip': 'パッケージ {name} が npm に見つかりません', + 'settings.plugins.registry.badge.missingVersion.tooltip': 'バージョン {version} の {name} は公開されていません', + 'settings.plugins.registry.badge.pathMissing.tooltip': 'ファイルが見つかりません: {path}', + 'settings.plugins.registry.badge.pathUnreadable.tooltip': 'ファイルを読み取れません: {path}', + 'settings.plugins.registry.badge.network.tooltip': 'npm レジストリに接続できませんでした', + 'settings.plugins.registry.banner.updateAvailable.title': 'アップデート可能', + 'settings.plugins.registry.banner.updateAvailable.description': '{current} → {latest}', + 'settings.plugins.registry.banner.updateAvailable.action': '{latest} にアップデート', + 'settings.plugins.registry.banner.invalid.title': '無効な Plugin', + 'settings.plugins.registry.banner.invalid.malformed': 'Spec の構文が不正です', + 'settings.plugins.registry.banner.invalid.missingPackage': 'npm にパッケージが見つかりません', + 'settings.plugins.registry.banner.invalid.missingVersion': 'バージョンが公開されていません', + 'settings.plugins.registry.banner.invalid.pathMissing': 'ファイルが存在しません', + 'settings.plugins.registry.banner.invalid.pathUnreadable': 'ファイルが読み取り不可能です', + 'settings.plugins.sidebar.actions.updateToLatest': '最新にアップデート', + 'settings.plugins.sidebar.actions.refresh': 'アップデートを確認', + 'settings.plugins.sidebar.group.updatesAvailable_one': '{count} 件のアップデート可能', + 'settings.plugins.sidebar.group.updatesAvailable_other': '{count} 件のアップデート可能', + 'settings.plugins.toast.updatedToLatest': 'Plugin を {version} にアップデートしました', + 'settings.plugins.toast.refreshing': 'npm でアップデートを確認中...', + 'settings.plugins.toast.refreshFailed': 'npm レジストリを確認できませんでした', + 'settings.openchamber.keyboardShortcuts.title': 'キーボードショートカット', + 'settings.openchamber.keyboardShortcuts.actions.resetAll': 'すべてリセット', + 'settings.openchamber.keyboardShortcuts.actions.overwrite': '上書き', + 'settings.openchamber.keyboardShortcuts.tooltip': '新しいキーコンボを設定して保存すると、バインディングが即座に更新されます。', + 'settings.openchamber.keyboardShortcuts.overwritePrompt': 'このキーコンボは別のショートカットで既に使用されています。上書きしてそのマッピングをクリアしますか?', + 'settings.openchamber.keyboardShortcuts.field.pressKeys': 'キーを押してください...', + 'settings.openchamber.keyboardShortcuts.error.captureFirst': '最初にショートカットを設定してください。', + 'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': 'このショートカットはブラウザのデフォルトと競合する可能性があります。それでも保存されます。', + 'settings.openchamber.keyboardShortcuts.action.open_go_to_line.label': '指定行に移動(ファイルエディター)', + 'settings.openchamber.keyboardShortcuts.action.open_command_palette.label': 'コマンドパレットを開く', + 'settings.openchamber.keyboardShortcuts.action.focus_input.label': '入力をフォーカス', + 'settings.openchamber.keyboardShortcuts.action.open_settings.label': '設定を開く', + 'settings.openchamber.keyboardShortcuts.action.toggle_terminal.label': 'ターミナルドックの切替', + 'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label': 'ターミナル拡大の切替', + 'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label': 'サイドバーの切替', + 'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': '右サイドバーの切替', + 'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': '右サイドバー Git タブを開く', + 'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': '右サイドバー ファイルタブを開く', + 'settings.openchamber.keyboardShortcuts.action.cycle_right_sidebar_tab.label': '右サイドバータブを順に切替', + 'settings.openchamber.keyboardShortcuts.action.new_chat.label': '新しい Session', + 'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': '新しい Worktree 下書き', + 'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': '新しいミニチャットウィンドウ', + 'settings.openchamber.keyboardShortcuts.action.open_help.label': 'キーボードショートカットを開く', + 'settings.openchamber.keyboardShortcuts.action.toggle_context_plan.label': '計画コンテキストパネルの切替', + 'settings.openchamber.keyboardShortcuts.action.toggle_services_menu.label': 'サービスメニューの切替', + 'settings.openchamber.keyboardShortcuts.action.cycle_services_tab.label': 'サービスタブを順に切替', + 'settings.openchamber.keyboardShortcuts.action.cycle_theme.label': 'テーマを順に切替', + 'settings.openchamber.keyboardShortcuts.action.cycle_agent.label': 'Agent を順に切替', + 'settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_forward.label': 'お気に入りモデルを次へ', + 'settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_backward.label': 'お気に入りモデルを前へ', + 'settings.openchamber.keyboardShortcuts.action.expand_input.label': '入力を拡大', + 'settings.openchamber.keyboardShortcuts.action.open_model_selector.label': 'モデルセレクターを開く', + 'settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label': '会話タイムラインを開く', + 'settings.projects.sidebar.total': '合計 {count}', + 'settings.projects.sidebar.actions.addProject': 'プロジェクトを追加', + 'settings.projects.page.empty.noProjects': '利用可能なプロジェクトがありません。', + 'settings.projects.page.title.default': 'プロジェクト設定', + 'settings.projects.page.section.worktree': 'ワークツリー', + 'settings.projects.page.field.projectName': 'プロジェクト名', + 'settings.projects.page.field.projectNamePlaceholder': 'プロジェクト名', + 'settings.projects.page.field.accentColor': 'アクセントカラー', + 'settings.projects.page.field.projectIcon': 'プロジェクトアイコン', + 'settings.projects.page.field.projectIconBackgroundAria': 'プロジェクトアイコンの背景色', + 'settings.projects.page.field.clearIconBackgroundAria': 'アイコン背景をクリア', + 'settings.projects.page.field.clearBackground': '背景をクリア', + 'settings.projects.page.field.none': 'なし', + 'settings.projects.page.field.preview': 'プレビュー', + 'settings.projects.page.actions.uploadIcon': 'アイコンをアップロード', + 'settings.projects.page.actions.uploading': 'アップロード中...', + 'settings.projects.page.actions.discoverFavicon': 'Favicon を検出', + 'settings.projects.page.actions.discovering': '検出中...', + 'settings.projects.page.actions.removeProjectIcon': 'プロジェクトアイコンを削除', + 'settings.projects.page.actions.removing': '削除中...', + 'settings.projects.page.actions.undoRemove': '削除を元に戻す', + 'settings.projects.page.toast.uploadIconFailed': 'プロジェクトアイコンのアップロードに失敗しました', + 'settings.projects.page.toast.iconUpdated': 'プロジェクトアイコンを更新しました', + 'settings.projects.page.toast.removeIconFailed': 'プロジェクトアイコンの削除に失敗しました', + 'settings.projects.page.toast.iconRemoved': 'プロジェクトアイコンを削除しました', + 'settings.projects.page.toast.discoverIconFailed': 'プロジェクトアイコンの検出に失敗しました', + 'settings.projects.page.toast.customIconAlreadySet': 'このプロジェクトには既にカスタムアイコンが設定されています', + 'settings.projects.page.toast.iconDiscovered': 'プロジェクトアイコンを検出しました', + 'settings.usage.sidebar.title': '使用量', + 'settings.usage.sidebar.total': '合計 {count}', + 'settings.usage.sidebar.actions.toggleAutoRefreshAria': '自動更新の切替', + 'settings.usage.sidebar.actions.refreshAria': '使用量を更新', + 'settings.usage.sidebar.actions.refreshTitle': '使用量を更新', + 'settings.usage.sidebar.tooltip.autoRefresh': '設定された間隔で使用量データを自動更新', + 'settings.usage.sidebar.field.intervalPlaceholder': '間隔', + 'settings.usage.sidebar.field.display': '表示', + 'settings.usage.sidebar.field.displayModePlaceholder': '表示モード', + 'settings.usage.sidebar.field.displayModeUsage': '使用量', + 'settings.usage.sidebar.field.displayModeRemaining': '残り割り当て', + 'settings.usage.sidebar.field.showPredictions': '予測を表示', + 'settings.usage.sidebar.status.notSet': '未設定', + 'settings.usage.page.empty.selectProvider': '使用量の詳細を表示する Provider を選択してください。', + 'settings.usage.page.header.providerUsage': '{provider} の使用量', + 'settings.usage.page.header.refreshing': '使用量を更新中...', + 'settings.usage.page.header.lastUpdated': '最終更新: {time}', + 'settings.usage.page.options.showInHeaderAria': 'ヘッダーメニューに表示', + 'settings.usage.page.options.showInHeader': 'ヘッダーメニューに表示', + 'settings.usage.page.options.showInHeaderTooltip': '有効にすると、アプリヘッダーのクイックアクセスドロップダウンメニューにこの Provider の使用量が表示されます。', + 'settings.usage.page.state.noData': 'まだ使用量データがありません。', + 'settings.usage.page.state.refreshFailedTitle': '使用量データの更新に失敗しました', + 'settings.usage.page.state.providerNotConfiguredTitle': 'Provider が設定されていません', + 'settings.usage.page.state.providerNotConfiguredDescription': 'Providers タブで認証情報を追加して使用量追跡を有効にしてください。', + 'settings.usage.page.section.modelQuotas': 'モデル割り当て', + 'settings.usage.page.section.otherModels': 'その他のモデル', + 'settings.usage.page.state.noQuotaWindowsTitle': '報告された割り当てウィンドウはありません', + 'settings.usage.page.state.noQuotaWindowsDescription': 'この Provider は現在レート制限や使用量割り当てを報告していません。', + 'settings.usage.pace.status.onTrack': '順調', + 'settings.usage.pace.status.slightlyFast': 'やや速い', + 'settings.usage.pace.status.tooFast': '速すぎる', + 'settings.usage.pace.status.usedUp': '使い切り', + 'settings.usage.pace.predictionTooltip': '現在のペースに基づくウィンドウ終了時の予測使用量: {prediction}', + 'settings.usage.pace.wait': '{duration} 待機', + 'settings.usage.pace.prediction': '予測: {prediction}', + 'settings.usage.pace.rate': 'ペース: {rate}', + 'settings.usage.pace.waitSeparator': ' · 待機 ', + 'settings.usage.pace.predictionLabel': '予測: ', + 'settings.remoteInstances.page.title': 'リモートインスタンス', + 'settings.remoteInstances.page.description': 'SSH 経由で別のマシンに接続し、そこで OpenChamber を開きます。', + 'settings.remoteInstances.page.empty.selectInstance': 'インスタンスを選択して設定を表示・編集します。', + 'settings.remoteInstances.page.empty.noExtraForwards': '追加のポート転送は設定されていません。', + 'settings.remoteInstances.page.section.actions': 'アクション', + 'settings.remoteInstances.page.section.actionsDescription': '接続、再接続、ログ表示、またはこの接続の削除。', + 'settings.remoteInstances.page.section.remoteServer': 'リモートマシン上の OpenChamber', + 'settings.remoteInstances.page.section.remoteServerDescription': 'SSH 接続後に OpenChamber をどのように実行するか選択します。', + 'settings.remoteInstances.page.section.mainTunnel': 'ローカルアクセス', + 'settings.remoteInstances.page.section.mainTunnelDescription': 'このリモート OpenChamber サーバーを開くために使用するローカルアドレスを選択します。', + 'settings.remoteInstances.page.section.authentication': '認証', + 'settings.remoteInstances.page.section.authenticationDescription': 'SSH およびリモート OpenChamber UI のオプションの認証情報。', + 'settings.remoteInstances.page.section.portForwards': 'ポート転送', + 'settings.remoteInstances.page.section.portForwardsDescription': 'この SSH 接続を通じて利用可能にする追加のオプションポート。', + 'settings.remoteInstances.page.field.sshCommand': 'SSH コマンド', + 'settings.remoteInstances.page.field.sshCommandPlaceholder': 'ssh user@host', + 'settings.remoteInstances.page.field.nickname': 'ニックネーム', + 'settings.remoteInstances.page.field.nicknamePlaceholder': '仕事用ノートPC', + 'settings.remoteInstances.page.field.connectionTimeoutSeconds': '接続タイムアウト(秒)', + 'settings.remoteInstances.page.field.installMethod': 'インストール方法', + 'settings.remoteInstances.page.field.installMethodHint': 'このアプリがリモートマシンで OpenChamber を起動する際の配置方法。', + 'settings.remoteInstances.page.field.selectInstallMethodPlaceholder': 'インストール方法を選択', + 'settings.remoteInstances.page.field.installMethodDownloadRelease': 'リリースをダウンロード', + 'settings.remoteInstances.page.field.installMethodUploadBundle': 'バンドルをアップロード', + 'settings.remoteInstances.page.field.selectBindHostPlaceholder': 'バインドホストを選択', + 'settings.remoteInstances.page.field.sshPasswordOptional': 'SSH パスワード(任意)', + 'settings.remoteInstances.page.field.sshPasswordPlaceholder': 'SSH パスワードを入力', + 'settings.remoteInstances.page.field.uiPasswordOptional': 'UI パスワード(任意)', + 'settings.remoteInstances.page.field.uiPasswordPlaceholder': 'UI パスワードを入力', + 'settings.remoteInstances.page.field.forwardTypeHint': 'この SSH 接続が提供するポートアクセスの種類を選択します。', + 'settings.remoteInstances.page.field.typePlaceholder': 'タイプ', + 'settings.remoteInstances.page.forwardType.local': 'ローカル (-L)', + 'settings.remoteInstances.page.forwardType.remote': 'リモート (-R)', + 'settings.remoteInstances.page.forwardType.dynamic': '動的 (-D)', + 'settings.remoteInstances.page.forwardTypeDescription.local': 'リモートマシン上の何かに接続するローカルポートを開きます。', + 'settings.remoteInstances.page.forwardTypeDescription.remote': '自分のコンピューターに接続するリモートポートを開きます。', + 'settings.remoteInstances.page.forwardTypeDescription.dynamic': 'SSH 接続を通じてローカル SOCKS プロキシを開きます。', + 'settings.remoteInstances.page.actions.create': '作成', + 'settings.remoteInstances.page.actions.cancel': 'キャンセル', + 'settings.remoteInstances.page.actions.connecting': '接続中...', + 'settings.remoteInstances.page.actions.reconnecting': '再接続中...', + 'settings.remoteInstances.page.actions.reconnectNow': '今すぐ再接続', + 'settings.remoteInstances.page.actions.logs': 'SSH ログ', + 'settings.remoteInstances.page.actions.pickRandomPort': 'ランダムポートを選択', + 'settings.remoteInstances.page.actions.enableForwardAria': '転送を有効化', + 'settings.remoteInstances.page.actions.openLocal': 'ローカルを開く', + 'settings.remoteInstances.page.actions.addForward': '転送を追加', + 'settings.remoteInstances.page.import.sectionTitle': '保存された SSH ホスト', + 'settings.remoteInstances.page.import.loading': 'SSH ホストを読み込み中...', + 'settings.remoteInstances.page.import.noneFound': 'SSH ホストが見つかりません。', + 'settings.remoteInstances.page.import.noneAvailable': 'インポート可能な SSH ホストがありません。', + 'settings.remoteInstances.page.import.patternSuffix': '(パターン)', + 'settings.remoteInstances.page.status.currentLocalUrl': '現在のローカル URL:', + 'settings.remoteInstances.page.status.reconnectStale': '再接続が停止しているようです。今すぐ再試行できます。', + 'settings.remoteInstances.page.logsDialog.title': 'SSH ログ', + 'settings.remoteInstances.page.logsDialog.loading': 'ログを読み込み中...', + 'settings.remoteInstances.page.logsDialog.empty': 'まだ SSH ログがありません。', + 'settings.remoteInstances.page.patternDialog.title': 'SSH 接続先を選択', + 'settings.remoteInstances.page.patternDialog.destinationPlaceholder': 'user@host', + 'settings.remoteInstances.page.phase.resolvingConfiguration': '設定を解決中', + 'settings.remoteInstances.page.phase.checkingAuth': '認証を確認中', + 'settings.remoteInstances.page.phase.establishingSsh': 'SSH 接続を確立中', + 'settings.remoteInstances.page.phase.probingRemote': 'リモートマシンを確認中', + 'settings.remoteInstances.page.phase.installingOpenChamber': 'OpenChamber をインストール中', + 'settings.remoteInstances.page.phase.updatingOpenChamber': 'OpenChamber を更新中', + 'settings.remoteInstances.page.phase.detectingServer': 'サーバーを検出中', + 'settings.remoteInstances.page.phase.startingServer': 'サーバーを起動中', + 'settings.remoteInstances.page.phase.forwardingPorts': 'ポートを転送中', + 'settings.remoteInstances.page.phase.reconnecting': '再接続中', + 'settings.remoteInstances.page.confirm.storeSshPasswordPlaintext': 'SSH パスワードをディスクに平文で保存しますか?', + 'settings.remoteInstances.page.confirm.storeUiPasswordPlaintext': 'UI パスワードをディスクに平文で保存しますか?', + 'settings.remoteInstances.page.confirm.removeInstance': 'このリモートインスタンスを削除しますか?', + 'settings.remoteInstances.page.toast.sshCommandRequired': 'SSH コマンドが必要です', + 'settings.remoteInstances.page.toast.saveFailed': 'インスタンスの保存に失敗しました', + 'settings.remoteInstances.page.toast.instanceSaved': 'インスタンスを保存しました', + 'settings.remoteInstances.page.toast.instanceCreated': 'インスタンスを作成しました', + 'settings.remoteInstances.page.toast.destinationRequired': '接続先が必要です', + 'settings.remoteInstances.page.toast.noLogsToCopy': 'コピーするログがありません', + 'settings.remoteInstances.page.toast.logsCopied': 'ログをコピーしました', + 'settings.remoteInstances.page.toast.logsCleared': 'ログをクリアしました', + 'settings.remoteInstances.page.toast.clearLogsFailed': 'ログのクリアに失敗しました', + 'settings.remoteInstances.page.toast.instanceUrlUnavailable': 'インスタンス URL が利用できません', + 'settings.remoteInstances.page.toast.connectFailed': 'インスタンスの接続に失敗しました', + 'settings.remoteInstances.page.toast.cancelConnectionFailed': '接続のキャンセルに失敗しました', + 'settings.remoteInstances.page.toast.disconnectFailed': 'インスタンスの切断に失敗しました', + 'settings.remoteInstances.page.toast.retryFailed': '接続の再試行に失敗しました', + 'settings.remoteInstances.page.toast.instanceRemoved': 'インスタンスを削除しました', + 'settings.remoteInstances.page.toast.removeInstanceFailed': 'インスタンスの削除に失敗しました', + 'settings.openchamber.worktrees.state.selectProject': 'Worktree を管理するプロジェクトを選択してください。', + 'settings.openchamber.worktrees.state.gitOnly': 'Worktree 設定は Git リポジトリでのみ利用可能です。', + 'settings.openchamber.worktrees.setup.title': 'セットアップコマンド', + 'settings.openchamber.worktrees.setup.tooltipPrefix': 'Worktree が作成されたときに新しい Worktree ディレクトリ内で自動的に実行されます。以下を使用します:', + 'settings.openchamber.worktrees.setup.tooltipSuffix': 'をプロジェクトルートに。', + 'settings.openchamber.worktrees.setup.loading': '読み込み中...', + 'settings.openchamber.worktrees.setup.commandPlaceholder': '例: bun install', + 'settings.openchamber.worktrees.setup.removeCommandAria': 'コマンドを削除', + 'settings.openchamber.worktrees.setup.addCommand': 'コマンドを追加', + 'settings.openchamber.worktrees.setup.waitForCommands': 'セットアップコマンドの完了を待ってからセッションを作成または送信する', + 'settings.openchamber.worktrees.setup.waitForCommandsAria': 'Worktree セットアップコマンドの完了を待ってからセッションを作成または送信する', + 'settings.openchamber.worktrees.list.title': '既存の Worktree', + 'settings.openchamber.worktrees.list.tooltip': 'Worktree はリポジトリの外にあり(OpenCode 管理)、削除するとリンクされた Session も削除されます。', + 'settings.openchamber.worktrees.list.loading': 'Worktree を読み込み中...', + 'settings.openchamber.worktrees.list.empty': 'このプロジェクトに Worktree は見つかりません', + 'settings.openchamber.worktrees.list.detachedHead': '分離 HEAD', + 'settings.openchamber.worktrees.list.deleteWorktreeAria': 'Worktree {name} を削除', + 'settings.agents.modelSelector.title': 'モデルを選択', + 'settings.agents.modelSelector.searchPlaceholder': 'モデルを検索', + 'settings.agents.modelSelector.selectPlaceholder': 'モデルを選択...', + 'settings.agents.modelSelector.notSelected': '選択されていません', + 'settings.agents.modelSelector.noModelOptional': 'モデルなし(任意)', + 'settings.agents.modelSelector.section.favorites': 'お気に入り', + 'settings.agents.modelSelector.section.recents': '最近', + 'settings.agents.modelSelector.section.recent': '最近', + 'settings.agents.modelSelector.badge.current': '現在', + 'settings.agents.modelSelector.actions.favorite': 'お気に入り', + 'settings.agents.modelSelector.actions.unfavorite': 'お気に入り解除', + 'settings.agents.modelSelector.actions.addToFavorites': 'お気に入りに追加', + 'settings.agents.modelSelector.actions.removeFromFavorites': 'お気に入りから削除', + 'settings.agents.modelSelector.state.noModelsFound': 'モデルが見つかりません', + 'settings.agents.modelSelector.keyboardHints': '↑↓ で移動 • Enter で選択 • Esc で閉じる', + 'settings.providers.sidebar.title': 'プロバイダー', + 'settings.providers.sidebar.total': '合計 {count}', + 'settings.providers.sidebar.actions.connectProviderAria': 'Provider に接続', + 'settings.providers.sidebar.actions.connectProviderTitle': 'Provider に接続', + 'settings.providers.sidebar.empty.title': 'Provider が見つかりません', + 'settings.providers.sidebar.empty.description': 'OpenCode の設定を確認してください', + 'settings.providers.sidebar.section.userProviders': 'ユーザー Provider', + 'settings.providers.sidebar.section.projectProviders': 'プロジェクト Provider', + 'settings.providers.page.state.loading': '読み込み中...', + 'settings.providers.page.state.unableToLoadProviderList': 'Provider 一覧を読み込めません', + 'settings.providers.page.empty.noProvidersDetected': 'Provider が検出されませんでした', + 'settings.providers.page.empty.checkOpenCodeConfiguration': 'OpenCode の設定を確認してください', + 'settings.providers.page.empty.selectProviderFromSidebar': 'サイドバーから Provider を選択', + 'settings.providers.page.empty.reviewDetailsAndConfigureAuth': '詳細を確認し認証を設定', + 'settings.providers.page.connect.title': 'Provider に接続', + 'settings.providers.page.connect.selectProviderTitle': 'Provider を選択', + 'settings.providers.page.connect.providerField': 'プロバイダー', + 'settings.providers.page.connect.selectProviderPlaceholder': 'Provider を選択', + 'settings.providers.page.connect.searchProvidersPlaceholder': '検索...', + 'settings.providers.page.connect.noProvidersFound': 'Provider が見つかりません', + 'settings.providers.page.connect.allProvidersConnected': 'すべての Provider が接続されています。', + 'settings.providers.page.auth.title': '認証', + 'settings.providers.page.auth.loadingMethods': '認証方法を読み込み中...', + 'settings.providers.page.auth.apiKeyLabel': 'API キー', + 'settings.providers.page.auth.apiKeyTooltip': 'キーは OpenCode に直接送信され、OpenChamber が保存することはありません。', + 'settings.providers.page.auth.apiKeyPlaceholder': 'sk-...', + 'settings.providers.page.auth.oauthMethodFallback': 'OAuth 方法 {index}', + 'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': '認証コードを貼り付け', + 'settings.providers.page.auth.connected': '接続済み', + 'settings.providers.page.auth.useReconnectHint': '· 認証情報を更新するには再接続を使用', + 'settings.providers.page.connectionDetails.title': '接続詳細', + 'settings.providers.page.connectionDetails.configuredIn': '設定場所:', + 'settings.providers.page.connectionDetails.noActiveSource': 'アクティブな設定ソースがありません', + 'settings.providers.page.connectionDetails.source.authCredentials': '認証情報', + 'settings.providers.page.connectionDetails.source.userConfig': 'ユーザー設定', + 'settings.providers.page.connectionDetails.source.projectConfig': 'プロジェクト設定', + 'settings.providers.page.connectionDetails.source.customConfig': 'カスタム設定', + 'settings.providers.page.models.title': '利用可能なモデル', + 'settings.providers.page.models.filterPlaceholder': 'モデルをフィルター...', + 'settings.providers.page.models.noModelsMatchFilter': 'このフィルターに一致するモデルはありません。', + 'settings.providers.page.models.capability.toolCalling': 'ツール呼び出し', + 'settings.providers.page.models.capability.reasoning': '推論', + 'settings.providers.page.models.capability.imageInput': '画像入力', + 'settings.providers.page.models.tokenBadge.context': 'ctx', + 'settings.providers.page.models.tokenBadge.output': 'out', + 'settings.providers.page.models.actions.hideModelFromSelectors': 'セレクターからモデルを非表示', + 'settings.providers.page.models.actions.showModelInSelectors': 'セレクターにモデルを表示', + 'settings.providers.page.models.actions.hideModel': 'モデルを非表示', + 'settings.providers.page.models.actions.showModel': 'モデルを表示', + 'settings.providers.page.actions.saving': '保存中...', + 'settings.providers.page.actions.saveKey': 'キーを保存', + 'settings.providers.page.actions.connect': '接続', + 'settings.providers.page.actions.copyCode': 'コードをコピー', + 'settings.providers.page.actions.open': '開く', + 'settings.providers.page.actions.copy': 'コピー', + 'settings.providers.page.actions.complete': '完了', + 'settings.providers.page.actions.hide': '非表示', + 'settings.providers.page.actions.reconnect': '再接続', + 'settings.providers.page.actions.disconnecting': '切断中...', + 'settings.providers.page.actions.disconnect': '切断', + 'settings.providers.page.actions.hideAll': 'すべて非表示', + 'settings.providers.page.actions.showAll': 'すべて表示', + 'settings.providers.page.toast.authMethodsLoadFailed': 'Provider 認証方法の読み込みに失敗しました', + 'settings.providers.page.toast.providerSourcesLoadFailed': 'Provider ソースの読み込みに失敗しました', + 'settings.providers.page.toast.apiKeyRequired': 'API キーが必要です', + 'settings.providers.page.toast.apiKeySaveFailed': 'API キーの保存に失敗しました', + 'settings.providers.page.toast.apiKeySaved': 'API キーを保存しました', + 'settings.providers.page.toast.oauthStartFailed': 'OAuth フローの開始に失敗しました', + 'settings.providers.page.toast.oauthDetailsMissing': 'OAuth の詳細が返されませんでした', + 'settings.providers.page.toast.completeOAuthInBrowser': 'ブラウザで OAuth フローを完了してください', + 'settings.providers.page.toast.oauthCompleteFailed': 'OAuth フローの完了に失敗しました', + 'settings.providers.page.toast.oauthCompleted': 'OAuth 接続が完了しました', + 'settings.providers.page.toast.oauthLinkCopied': 'OAuth リンクをコピーしました', + 'settings.providers.page.toast.oauthLinkCopyFailed': 'OAuth リンクのコピーに失敗しました', + 'settings.providers.page.toast.deviceCodeCopied': 'デバイスコードをコピーしました', + 'settings.providers.page.toast.deviceCodeCopyFailed': 'デバイスコードのコピーに失敗しました', + 'settings.providers.page.toast.providerDisconnected': 'Provider を切断しました', + 'settings.providers.page.toast.providerDisconnectFailed': 'Provider の切断に失敗しました', + 'settings.mcp.page.empty.selectServer': 'サイドバーから MCP サーバーを選択してください', + 'settings.mcp.page.empty.addNewOne': 'または新しいものを追加', + 'settings.mcp.page.header.newServer': '新しい MCP サーバー', + 'settings.mcp.page.header.configureNewServer': '新しい MCP サーバーを設定', + 'settings.mcp.page.header.transport': '{type} トランスポート', + 'settings.mcp.page.transport.local': 'ローカル · stdio', + 'settings.mcp.page.transport.remote': 'リモート · SSE', + 'settings.mcp.page.status.label.connected': '接続済み', + 'settings.mcp.page.status.label.failed': '失敗', + 'settings.mcp.page.status.label.needsAuth': '認証が必要', + 'settings.mcp.page.status.label.needsRegistration': '登録が必要', + 'settings.mcp.page.status.description.connected': '接続済み。OpenCode がツールとリソースを検出できます。', + 'settings.mcp.page.status.description.failedDefault': 'OpenCode がこの MCP サーバーに到達できませんでした。', + 'settings.mcp.page.status.description.needsAuth': 'このリモート MCP サーバーは接続する前に認証が必要です。', + 'settings.mcp.page.status.description.needsClientRegistrationDefault': 'このリモート MCP サーバーは認証を完了する前にクライアント登録が必要です。', + 'settings.mcp.page.status.description.disabled': 'この MCP サーバーは設定で無効になっています。', + 'settings.mcp.page.status.description.default': '更新するか接続をテストして、ライブランタイムステータスを読み込んでください。', + 'settings.mcp.page.status.runtimeStatus': 'ランタイムステータス', + 'settings.mcp.page.status.projectScopedTo': 'プロジェクトスコープ: {directory}', + 'settings.mcp.page.status.userScoped': 'ユーザースコープ設定', + 'settings.mcp.page.status.activeProject': 'アクティブなプロジェクト', + 'settings.mcp.page.actions.connect': '接続', + 'settings.mcp.page.actions.disconnect': '切断', + 'settings.mcp.page.actions.authorize': '認可', + 'settings.mcp.page.actions.reauthorize': '再認可', + 'settings.mcp.page.actions.clearAuth': '認証をクリア', + 'settings.mcp.page.actions.test': 'テスト', + 'settings.mcp.page.actions.testConnection': '接続をテスト', + 'settings.mcp.page.actions.working': '処理中...', + 'settings.mcp.page.actions.starting': '起動中...', + 'settings.mcp.page.actions.clearing': 'クリア中...', + 'settings.mcp.page.actions.testing': 'テスト中...', + 'settings.mcp.page.actions.completing': '完了中...', + 'settings.mcp.page.actions.completeAuthorization': '認可を完了', + 'settings.mcp.page.actions.openInBrowser': 'ブラウザで開く', + 'settings.mcp.page.actions.copyLink': 'リンクをコピー', + 'settings.mcp.page.actions.deleting': '削除中...', + 'settings.mcp.page.auth.authorizationUrl': '認可 URL', + 'settings.mcp.page.auth.manualFallbackTitle': '手動認可フォールバック', + 'settings.mcp.page.auth.manualFallbackDescription': 'ブラウザが別のマシンに戻った場合や、コード付きのコールバック URL が表示された場合は、その完全な URL または生のコードをここに貼り付けてください。', + 'settings.mcp.page.auth.callbackInputPlaceholder': 'コールバック URL または認可コードを貼り付け', + 'settings.mcp.page.auth.waitingForOpenCode': 'OpenCode がブラウザの認可フロー完了を検出するのを待っています...', + 'settings.mcp.page.server.title': 'サーバー', + 'settings.mcp.page.server.name': 'サーバー名', + 'settings.mcp.page.server.namePlaceholder': 'my-mcp-server', + 'settings.mcp.page.server.importJson': 'JSON Snippet からインポート', + 'settings.mcp.page.server.importJsonTitle': 'JSON Snippet から MCP サーバー設定全体をインポート', + 'settings.mcp.page.server.enable': 'サーバーを有効化', + 'settings.mcp.page.server.enableAria': 'サーバーを有効化', + 'settings.mcp.page.server.transportMode': 'トランスポートモード', + 'settings.mcp.page.connection.command': 'コマンド', + 'settings.mcp.page.connection.serverUrl': 'サーバー URL', + 'settings.mcp.page.connection.serverUrlPlaceholder': 'https://mcp.example.com/mcp', + 'settings.mcp.page.connection.pasteCommand': 'コマンドを貼り付け', + 'settings.mcp.page.connection.pasteCommandTitle': 'クリップボードからローカルコマンドを貼り付けて自動分割', + 'settings.mcp.page.connection.previewArgs': 'プレビュー({count} 引数)', + 'settings.mcp.page.advanced.title': '高度なリモートオプション', + 'settings.mcp.page.advanced.configure': '高度なオプションを設定', + 'settings.mcp.page.advanced.autoDetect': '自動検出', + 'settings.mcp.page.advanced.custom': 'カスタム', + 'settings.mcp.page.advanced.headers': 'ヘッダー', + 'settings.mcp.page.advanced.timeoutMs': 'タイムアウト(ms)', + 'settings.mcp.page.advanced.timeoutHint': '空欄の場合は OpenCode のデフォルト MCP タイムアウトが使用されます。', + 'settings.mcp.page.advanced.requestHeaders': 'リクエストヘッダー', + 'settings.mcp.page.advanced.headerNamePlaceholder': 'Header-Name', + 'settings.mcp.page.advanced.pasteHeaders': 'ヘッダーを貼り付け', + 'settings.mcp.page.advanced.pasteHeadersTitle': 'クリップボードから KEY=VALUE ヘッダー行を貼り付け', + 'settings.mcp.page.advanced.oauthAutoDetection': 'OAuth 自動検出を有効化', + 'settings.mcp.page.advanced.oauthAutoDetectionAria': 'OAuth 自動検出を有効化', + 'settings.mcp.page.advanced.oauthClientIdPlaceholder': 'OAuth クライアント ID', + 'settings.mcp.page.advanced.oauthClientSecretPlaceholder': 'OAuth クライアントシークレット', + 'settings.mcp.page.advanced.oauthScopesPlaceholder': 'スコープ(スペース区切り)', + 'settings.mcp.page.advanced.oauthRedirectUriPlaceholder': 'リダイレクト URI', + 'settings.mcp.page.advanced.oauthHint': 'これらのフィールドを空欄にすると、OpenCode が MCP サーバーから OAuth 設定を推測します。', + 'settings.mcp.page.advanced.oauthCallbackHint': 'ブラウザベースの MCP 認可は、リダイレクト URI が空の場合にこのコールバック URL を使用します:', + 'settings.mcp.page.env.title': '環境変数', + 'settings.mcp.page.env.key': 'キー', + 'settings.mcp.page.env.value': '値', + 'settings.mcp.page.env.valuePlaceholder': '値', + 'settings.mcp.page.env.hide': '非表示', + 'settings.mcp.page.env.show': '表示', + 'settings.mcp.page.env.addVariable': '変数を追加', + 'settings.mcp.page.env.addEnvironmentVariable': '環境変数を追加', + 'settings.mcp.page.env.keyPlaceholder': 'API_KEY', + 'settings.mcp.page.env.pasteEnv': '.env を貼り付け', + 'settings.mcp.page.env.pasteEnvTitle': 'クリップボードから KEY=VALUE 行を貼り付け', + 'settings.mcp.page.env.plainTextWarning': '⚠ 値は opencode.json に平文で保存されます', + 'settings.mcp.page.env.removeVariableAria': '変数を削除', + 'settings.mcp.page.importDialog.title': 'JSON Snippet をインポート', + 'settings.mcp.page.importDialog.description': 'ドキュメントや他の設定ファイルから MCP JSON Snippet 全体を貼り付けてください。解析された値が保存前に確認用にフォームに表示されます。', + 'settings.mcp.page.importDialog.pasteFromClipboard': 'クリップボードから JSON を貼り付け', + 'settings.mcp.page.deleteDialog.title': '"{name}" を削除しますか?', + 'settings.mcp.page.deleteDialog.descriptionPrefix': 'これによりサーバーが以下から削除されます:', + 'settings.mcp.page.deleteDialog.descriptionSuffix': 'OpenCode の再読み込みが必要になります。', + 'settings.mcp.page.toast.clipboardReadFailed': 'クリップボードを読み取れません', + 'settings.mcp.page.toast.configImported': 'MCP 設定をインポートしました', + 'settings.mcp.page.toast.nameRequired': '名前が必要です', + 'settings.mcp.page.toast.serverNameExists': '同じ名前のサーバーが既に存在します', + 'settings.mcp.page.toast.localCommandRequired': 'ローカルサーバーではコマンドは空にできません', + 'settings.mcp.page.toast.remoteUrlRequired': 'リモートサーバーでは URL は空にできません', + 'settings.mcp.page.toast.serverCreatedReloadFailed': 'MCP サーバーを作成しましたが、OpenCode の再読み込みに失敗しました。', + 'settings.mcp.page.toast.savedReloadFailed': '保存しましたが、OpenCode の再読み込みに失敗しました。', + 'settings.mcp.page.toast.retryRefreshHint': 'このサーバーを認可する前に、更新を再試行するか設定を開き直してください。', + 'settings.mcp.page.toast.serverCreatedReloading': 'MCP サーバーを作成しました。OpenCode を再読み込み中...', + 'settings.mcp.page.toast.savedReloading': '保存しました。OpenCode を再読み込み中...', + 'settings.mcp.page.toast.saveFailed': '保存に失敗しました', + 'settings.mcp.page.toast.unexpectedError': 'エラーが発生しました', + 'settings.mcp.page.toast.serverDeletedReloadFailed': '"{name}" を削除しましたが、OpenCode の再読み込みに失敗しました', + 'settings.mcp.page.toast.refreshListIfStale': 'UI が古く見える場合は MCP リストを更新してください。', + 'settings.mcp.page.toast.serverDeleted': '"{name}" を削除しました', + 'settings.mcp.page.toast.deleteFailed': '削除に失敗しました', + 'settings.mcp.page.toast.disconnected': '切断しました', + 'settings.mcp.page.toast.connected': '接続しました', + 'settings.mcp.page.toast.connectionNeedsAuthorization': '接続には認可が必要です', + 'settings.mcp.page.toast.connectionNeedsClientRegistration': '接続にはクライアント登録が必要です', + 'settings.mcp.page.toast.connectionFailed': '接続に失敗しました', + 'settings.mcp.page.toast.connectionAttemptFinished': '接続試行が完了しました。詳細はステータスを更新してください。', + 'settings.mcp.page.toast.createServerBeforeLiveActions': 'ライブアクションを実行する前にサーバーを作成してください', + 'settings.mcp.page.toast.saveBeforeLiveActions': 'ライブアクションを実行する前に変更を保存してください', + 'settings.mcp.page.toast.refreshStatusFailed': 'MCP ステータスの更新に失敗しました', + 'settings.mcp.page.toast.oauthRedirectUrlBuildFailed': 'MCP OAuth リダイレクト URL を構築できませんでした', + 'settings.mcp.page.toast.oauthBrowserCallbackSaveFailed': 'MCP 認可のブラウザコールバック URL の保存に失敗しました', + 'settings.mcp.page.toast.openCodeReloadFailedAfterCallbackSave': 'ブラウザコールバック URL 保存後、OpenCode の再読み込みに失敗しました', + 'settings.mcp.page.toast.completeAuthorizationInBrowserWithPaste': 'ブラウザで MCP 認可フローを完了し、返されたコードまたはコールバック URL をここに貼り付けてください', + 'settings.mcp.page.toast.completeAuthorizationInBrowser': 'ブラウザで MCP 認可フローを完了してください', + 'settings.mcp.page.toast.openAuthorizationUrlFailed': '認可 URL を自動的に開けませんでした', + 'settings.mcp.page.toast.authorizationStartFailed': '認可の開始に失敗しました', + 'settings.mcp.page.toast.authSessionExpired': '認可 Session が期限切れになるか、再読み込み中にクリアされました。「認可」を再度クリックしてください。', + 'settings.mcp.page.toast.savedAuthorizationRemoved': '保存された MCP 認可が削除されました', + 'settings.mcp.page.toast.clearAuthorizationFailed': '認可のクリアに失敗しました', + 'settings.mcp.page.toast.authorizationUrlCopied': '認可 URL をコピーしました', + 'settings.mcp.page.toast.authorizationUrlCopyFailed': '認可 URL のコピーに失敗しました', + 'settings.mcp.page.toast.pasteCallbackOrCodeFirst': '最初にコールバック URL または認可コードを貼り付けてください', + 'settings.mcp.page.toast.missingServerDetails': 'MCP サーバーの詳細が不足しています。サーバーを再選択するか、完全なコールバック URL を貼り付けてください。', + 'settings.mcp.page.toast.authorizationCompleted': 'MCP 認可が完了しました', + 'settings.mcp.page.toast.authorizationCompletedFor': '{name} の MCP 認可が完了しました', + 'settings.mcp.page.toast.authorizationCompleteFailed': 'MCP 認可の完了に失敗しました', + 'settings.mcp.page.toast.enableServerBeforeTest': '接続をテストする前にこのサーバーを有効にしてください', + 'settings.mcp.page.toast.connectionTestSucceeded': '接続テストに成功しました', + 'settings.mcp.page.toast.connectionTestFailed': '接続テストに失敗しました', + 'settings.mcp.page.toast.connectionTestFinished': '接続テストが完了しました。詳細はステータスを更新してください。', + 'settings.mcp.page.toast.authorizationFailed': '認可に失敗しました', + 'settings.mcp.page.toast.authorizationStillInProgress': 'ブラウザで認可がまだ進行中です。必要に応じてコールバック URL またはコードを貼り付けてください。', + 'settings.mcp.page.toast.noKeyValuePairsFound': 'クリップボードに KEY=VALUE ペアが見つかりません', + 'settings.mcp.page.toast.importedVariablesCount': 'インポートした変数: {count}', + 'settings.mcp.page.toast.pastedArgumentsCount': '貼り付けた引数: {count}', + 'settings.shared.projectSelector.fallbackProject': 'プロジェクト', + 'settings.shared.projectSelector.switchProjectAria': 'プロジェクトを切替', + 'settings.shared.projectSelector.switchProjectTitle': 'プロジェクトを切替', + 'settings.openchamber.defaults.title': 'Session デフォルト', + 'settings.openchamber.defaults.summaryPrefix': '新しい Session は以下で開始されます:', + 'settings.openchamber.defaults.summaryOpenCodeDefault': 'OpenCode Agent デフォルト', + 'settings.openchamber.defaults.field.defaultModel': 'デフォルトモデル', + 'settings.openchamber.defaults.field.defaultThinking': 'デフォルト思考', + 'settings.openchamber.defaults.field.thinkingPlaceholder': '思考', + 'settings.openchamber.defaults.field.defaultAgent': 'デフォルト Agent', + 'settings.openchamber.defaults.field.showDeletionDialogAria': '削除ダイアログを表示', + 'settings.openchamber.defaults.field.showDeletionDialog': '削除ダイアログを表示', + 'settings.openchamber.defaults.field.openFilesPreviewAria': 'ファイルをプレビューモードで開く', + 'settings.openchamber.defaults.field.openFilesPreview': 'ファイルをプレビューモードで開く', + 'settings.openchamber.defaults.option.default': 'デフォルト', + 'settings.openchamber.defaults.option.defaultLowercase': 'デフォルト', + 'settings.openchamber.git.title': 'Git 設定', + 'settings.openchamber.git.changesViewTitle': '変更ビュー', + 'settings.openchamber.git.changesViewAria': 'Git 変更ビューモード', + 'settings.openchamber.git.option.flatList': 'フラットリスト', + 'settings.openchamber.git.option.treeView': 'ツリービュー', + 'settings.openchamber.git.optionAria': 'Git 変更ビューモード: {option}', + 'settings.openchamber.git.enableGitmojiAria': 'Gitmoji ピッカーを有効化', + 'settings.openchamber.git.enableGitmoji': 'Gitmoji ピッカーを有効化', + 'settings.openchamber.git.showGitignoredAria': '.gitignore ファイルを表示', + 'settings.openchamber.git.showGitignored': '.gitignore ファイルを表示', + 'settings.github.page.tooltip.connectAccount': 'アプリ内 PR および Issue ワークフロー用に GitHub アカウントを接続します。', + 'settings.github.page.avatarAlt.withLogin': '{login} のアバター', + 'settings.github.page.avatarAlt.fallback': 'GitHub アバター', + 'settings.github.page.label.unknownUser': '不明', + 'settings.github.page.label.scopes': 'スコープ: {value}', + 'settings.github.page.label.otherAccounts': 'その他のアカウント', + 'settings.github.page.accountSource.oauth': 'OAuth', + 'settings.github.page.accountSource.cli': 'CLI', + 'settings.github.page.actions.disconnect': '切断', + 'settings.github.page.actions.connect': 'GitHub に接続', + 'settings.github.page.actions.switchTo': '切替', + 'settings.github.page.actions.addAccount': 'アカウントを追加', + 'settings.github.page.actions.openGithub': 'GitHub を開く', + 'settings.github.page.status.notConnected': '未接続', + 'settings.github.page.status.active': 'アクティブ', + 'settings.github.page.flow.title': 'OpenChamber を認可', + 'settings.github.page.flow.description': 'GitHub で以下のコードを入力してこのデバイスを認可してください:', + 'settings.github.page.flow.waiting': '承認待機中...(自動更新)', + 'settings.github.page.toast.startConnectFailed': 'GitHub 接続の開始に失敗しました', + 'settings.github.page.toast.connected': 'GitHub に接続しました', + 'settings.github.page.toast.authorizationFailed': 'GitHub の認可に失敗しました', + 'settings.github.page.toast.disconnected': 'GitHub を切断しました', + 'settings.github.page.toast.disconnectFailed': 'GitHub の切断に失敗しました', + 'settings.github.page.toast.accountSwitched': 'GitHub アカウントを切替しました', + 'settings.github.page.toast.accountSwitchFailed': 'GitHub アカウントの切替に失敗しました', + 'settings.github.page.oauth.title': 'GitHub OAuth Token', + 'settings.github.page.ghCli.title': 'GitHub CLI Token', + 'settings.github.page.ghCli.activeDescription': 'gh CLI 経由で認証済み', + 'settings.github.page.ghCli.fallbackDescription': 'OpenChamber 認証が削除された場合のフォールバックとして利用可能', + 'settings.github.page.ghCli.disabledDescription': 'インストールされているがフォールバックとして未使用', + 'settings.github.page.ghCli.actions.disable': '無効化', + 'settings.github.page.ghCli.actions.enable': '有効化', + 'settings.github.page.toast.ghCliEnabled': 'gh CLI フォールバックを有効化しました', + 'settings.github.page.toast.ghCliDisabled': 'gh CLI フォールバックを無効化しました', + 'settings.github.page.toast.ghCliUpdateFailed': 'gh CLI 設定の更新に失敗しました', + 'settings.notifications.page.delivery.title': '通知配信', + 'settings.notifications.page.delivery.enableAria': '通知を有効化', + 'settings.notifications.page.delivery.enableLabel': '通知を有効化', + 'settings.notifications.page.delivery.focusedAria': 'アプリ起動中も通知', + 'settings.notifications.page.delivery.focusedLabel': 'アプリ起動中も通知', + 'settings.notifications.page.delivery.testAction': 'テスト通知を送信', + 'settings.notifications.page.delivery.browserPermissionHint': '初回はブラウザが許可を求める場合があります。', + 'settings.notifications.page.delivery.permissionDenied': '通知の許可が拒否されました。ブラウザ設定で有効にしてください。', + 'settings.notifications.page.delivery.permissionGrantedButDisabled': '許可は付与されていますが、通知が無効になっています。', + 'settings.notifications.page.delivery.vscodeHint': '有効にすると、通知は VS Code のネイティブ通知を通じて配信されます。', + 'settings.notifications.page.events.title': '通知イベント', + 'settings.notifications.page.events.completionAria': 'Agent 完了', + 'settings.notifications.page.events.completionLabel': 'Agent 完了', + 'settings.notifications.page.events.subtaskAria': 'Subagent 完了', + 'settings.notifications.page.events.subtaskLabel': 'Subagent 完了', + 'settings.notifications.page.events.errorAria': 'Agent エラー', + 'settings.notifications.page.events.errorLabel': 'Agent エラー', + 'settings.notifications.page.events.questionAria': 'Agent 質問', + 'settings.notifications.page.events.questionLabel': 'Agent 質問', + 'settings.notifications.page.template.title': '通知 Template', + 'settings.notifications.page.template.variablesLabel': '変数:', + 'settings.notifications.page.template.event.completion': 'completion', + 'settings.notifications.page.template.event.subtask': 'Subagent 完了', + 'settings.notifications.page.template.event.error': 'error', + 'settings.notifications.page.template.event.question': 'question', + 'settings.notifications.page.template.field.title': 'タイトル', + 'settings.notifications.page.template.field.message': 'メッセージ', + 'settings.notifications.page.template.defaults.completion.title': '{agent_name} の準備ができました', + 'settings.notifications.page.template.defaults.completion.message': '{model_name} がタスクを完了しました', + 'settings.notifications.page.template.defaults.error.title': 'ツールエラー', + 'settings.notifications.page.template.defaults.error.message': '{last_message}', + 'settings.notifications.page.template.defaults.question.title': '入力が必要です', + 'settings.notifications.page.template.defaults.question.message': '{last_message}', + 'settings.notifications.page.template.defaults.subtask.title': '{agent_name} の準備ができました', + 'settings.notifications.page.template.defaults.subtask.message': '{model_name} がタスクを完了しました', + 'settings.notifications.page.summary.title': 'AI 要約', + 'settings.notifications.page.summary.toggleAria': '最後のメッセージを要約', + 'settings.notifications.page.summary.toggleLabel': '最後のメッセージを要約', + 'settings.notifications.page.summary.requiresTemplateVariable': '必要', + 'settings.notifications.page.summary.modelLabel': '要約モデル', + 'settings.notifications.page.summary.modelTooltip': '通知と音声要約に使用されます。', + 'settings.notifications.page.summary.notSelected': '選択されていません', + 'settings.notifications.page.summary.thresholdLabel': 'しきい値', + 'settings.notifications.page.summary.thresholdHint': 'これより長いメッセージは要約されます', + 'settings.notifications.page.summary.resetThresholdAria': 'しきい値をリセット', + 'settings.notifications.page.summary.lengthLabel': '長さ', + 'settings.notifications.page.summary.lengthHint': '要約の目標文字数', + 'settings.notifications.page.summary.resetLengthAria': '要約の長さをリセット', + 'settings.notifications.page.summary.maxLengthLabel': '最大長', + 'settings.notifications.page.summary.maxLengthHint': '{last_message} をこの長さに切り詰めます', + 'settings.notifications.page.summary.resetMaxLengthAria': 'メッセージ最大長をリセット', + 'settings.notifications.page.push.title': 'バックグラウンドプッシュ通知', + 'settings.notifications.page.push.enableAria': 'プッシュ通知を有効化', + 'settings.notifications.page.push.enableLabel': 'プッシュ通知を有効化', + 'settings.notifications.page.push.unsupportedHint': 'プッシュ非対応。Desktop Chrome/Edge と Android はプッシュをサポート。iOS にはインストール済み PWA が必要です。', + 'settings.notifications.page.push.supportedHint': 'OS のバックグラウンドサービス経由でアラートを受信', + 'settings.notifications.page.push.loadingAria': '読み込み中', + 'settings.notifications.page.toast.permissionDenied.title': '通知の許可が拒否されました', + 'settings.notifications.page.toast.permissionDenied.description': 'ブラウザ設定で通知を有効にしてください。', + 'settings.notifications.page.toast.permissionDenied.enableInBrowser': 'ブラウザ設定で通知を有効にしてください。', + 'settings.notifications.page.toast.requestPermissionFailed': '通知の許可リクエストに失敗しました', + 'settings.notifications.page.toast.notificationsApiUnavailable': 'Notifications API が利用できません', + 'settings.notifications.page.toast.testNotificationSent': 'テスト通知を送信しました', + 'settings.notifications.page.toast.testNotificationFailed': 'テスト通知の送信に失敗しました', + 'settings.notifications.page.toast.pushUnsupported': 'プッシュ通知はサポートされていません', + 'settings.notifications.page.toast.pushApiUnavailable': 'Push API が利用できません', + 'settings.notifications.page.toast.pushKeyLoadFailed': 'プッシュキーの読み込みに失敗しました', + 'settings.notifications.page.toast.enableBackgroundFailed': 'バックグラウンド通知の有効化に失敗しました', + 'settings.notifications.page.toast.backgroundEnabled': 'バックグラウンド通知を有効化しました', + 'settings.notifications.page.toast.backgroundDisabled': 'バックグラウンド通知を無効化しました', + 'settings.notifications.page.testNotification.title': 'テスト通知', + 'settings.notifications.page.testNotification.body': 'これは OpenChamber からのテスト通知です。', + 'settings.voice.page.section.voiceSetup': '音声設定', + 'settings.voice.page.section.speechRecognition': '音声認識', + 'settings.voice.page.section.playbackAndSummary': '再生', + 'settings.voice.page.field.enableVoiceModeAria': '音声モードを有効化', + 'settings.voice.page.field.enableVoiceMode': '音声モードを有効化', + 'settings.voice.page.field.provider': 'プロバイダー', + 'settings.voice.page.provider.browser': 'ブラウザ', + 'settings.voice.page.provider.custom': 'カスタム', + 'settings.voice.page.provider.say': 'Say', + 'settings.voice.page.provider.server': 'サーバー', + 'settings.voice.page.provider.wasm': 'ローカル', + 'settings.voice.page.stt.wasmModel': 'Whisper モデル', + 'settings.voice.page.stt.wasmLoaded': 'モデル読み込み完了', + 'settings.voice.page.stt.wasmDownloading': 'モデルをダウンロード中...', + 'settings.voice.page.stt.wasmLoading': 'モデルを読み込み中...', + 'settings.voice.page.stt.wasmNotLoaded': 'モデルは初回音声使用時に読み込まれます', + 'settings.voice.page.stt.wasmDownload': '読み込む', + 'settings.voice.page.stt.wasmRetry': '再試行', + 'settings.voice.page.tooltip.browser': '無料、オフライン、モバイルサポートは制限あり。', + 'settings.voice.page.tooltip.openai': '高品質、モバイル対応、API キーが必要。', + 'settings.voice.page.tooltip.custom': 'OpenAI 互換サーバー(例: Kokoro)。', + 'settings.voice.page.tooltip.say': 'macOS ネイティブ。高速、無料、オフライン。', + 'settings.voice.page.tooltip.sttBrowser': 'Web Speech API(Chrome/Edge)。無料、設定不要。', + 'settings.voice.page.tooltip.sttServer': 'OpenAI 互換 Whisper サーバー。高精度、多言語対応。', + 'settings.voice.page.field.apiKey': 'API キー', + 'settings.voice.page.field.apiKeyHintUsingConfig': '設定のキーを使用', + 'settings.voice.page.field.apiKeyHintRequired': 'OpenAI TTS には API キーが必要です', + 'settings.voice.page.field.apiKeyHintProvide': 'OpenAI キーを入力', + 'settings.voice.page.field.serverUrl': 'サーバー URL', + 'settings.voice.page.field.serverUrlHint': 'OpenAI 互換 TTS サーバーのベース URL', + 'settings.voice.page.field.model': 'モデル', + 'settings.voice.page.field.voice': '音声', + 'settings.voice.page.field.voiceIdentifierHint': 'サーバーがサポートする音声識別子', + 'settings.voice.page.field.configuredAbove': '上記で設定', + 'settings.voice.page.actions.preview': 'プレビュー', + 'settings.voice.page.field.selectVoicePlaceholder': '音声を選択', + 'settings.voice.page.field.auto': '自動', + 'settings.voice.page.field.speechRate': '話速', + 'settings.voice.page.field.speechPitch': '音程', + 'settings.voice.page.field.speechVolume': '音量', + 'settings.voice.page.field.language': '言語', + 'settings.voice.page.field.selectLanguagePlaceholder': '言語を選択', + 'settings.voice.page.field.sttBrowserSupportError': 'このブラウザでは MediaRecorder または AudioContext が利用できません。サーバー STT が動作しない可能性があります。', + 'settings.voice.page.field.sttServerUrlHint': 'Whisper 互換サーバーのベース URL', + 'settings.voice.page.field.sttLanguageHint': 'BCP-47 コード(例: en, fr)。自動検出の場合は空欄。', + 'settings.voice.page.field.silenceThreshold': '無音しきい値', + 'settings.voice.page.field.silenceHold': '無音保持', + 'settings.voice.page.field.transcribeOnStopAria': '録音停止時に書き起こし', + 'settings.voice.page.field.transcribeOnStop': '停止時に書き起こし', + 'settings.voice.page.field.millisecondsUnit': 'ms', + 'settings.voice.page.field.messageReadAloudButtonAria': 'メッセージ読み上げボタン', + 'settings.voice.page.field.messageReadAloudButton': 'メッセージ読み上げボタン', + 'settings.voice.page.field.summarizeBeforePlaybackAria': '再生前に要約', + 'settings.voice.page.field.summarizeBeforePlayback': '再生前に要約', + 'settings.voice.page.field.summarizeVoiceModeResponsesAria': '音声モードの応答を要約', + 'settings.voice.page.field.summarizeVoiceModeResponses': '音声モードの応答を要約', + 'settings.voice.page.field.summarizationThreshold': '要約しきい値', + 'settings.voice.page.field.summaryMaxLength': '要約最大長', + 'settings.voice.page.hint.shiftClickPrefix': '押す', + 'settings.voice.page.hint.shiftClickSuffix': 'でマイクボタンを連続モードに切替', + 'settings.voice.page.preview.browserVoiceFallback': 'ブラウザの音声', + 'settings.voice.page.preview.voiceLine': 'こんにちは!{voiceName} です。これが私の声です。', + 'settings.voice.page.preview.customServerLine': 'こんにちは!これはカスタム TTS サーバーのプレビューです。', + 'settings.voice.page.field.ttsInputMode': 'TTS 入力モード', + 'settings.voice.page.field.ttsInputModeSanitized': 'サニタイズ', + 'settings.voice.page.field.ttsInputModeRaw': '生 Markdown', + 'settings.openchamber.visual.section.colorMode': 'カラーモード', + 'settings.openchamber.visual.section.mobileLayout': 'モバイルレイアウト', + 'settings.openchamber.visual.option.mobileLayout.default': '旧', + 'settings.openchamber.visual.option.mobileLayout.new': '新', + 'settings.openchamber.visual.section.localization': 'ローカライゼーション', + 'settings.openchamber.visual.section.spacingAndLayout': '間隔とレイアウト', + 'settings.openchamber.visual.section.navigation': 'ナビゲーション', + 'settings.openchamber.visual.section.chatRenderMode': 'Chat 表示モード', + 'settings.openchamber.visual.section.chatRenderModeAria': 'Chat 表示モード', + 'settings.openchamber.visual.section.messageStreamTransport': 'メッセージストリーム転送', + 'settings.openchamber.visual.section.activityDefault': 'アクティビティデフォルト', + 'settings.openchamber.visual.section.activityDefaultAria': 'アクティビティデフォルトモード', + 'settings.openchamber.visual.section.showToolsOpenedByDefault': 'デフォルトで開くツールを表示:', + 'settings.openchamber.visual.section.userMessageRendering': 'ユーザーメッセージ表示', + 'settings.openchamber.visual.section.userMessageRenderingAria': 'ユーザーメッセージ表示モード', + 'settings.openchamber.visual.section.mermaidRendering': 'Mermaid 表示', + 'settings.openchamber.visual.section.mermaidRenderingAria': 'Mermaid 表示モード', + 'settings.openchamber.visual.section.diffLayout': 'Diff レイアウト', + 'settings.openchamber.visual.section.diffLayoutAria': 'Diff レイアウト', + 'settings.openchamber.visual.section.privacy': 'プライバシー', + 'settings.openchamber.visual.field.lightTheme': 'ライトテーマ', + 'settings.openchamber.visual.field.darkTheme': 'ダークテーマ', + 'settings.openchamber.visual.field.selectLightThemeAria': 'ライトテーマを選択', + 'settings.openchamber.visual.field.selectDarkThemeAria': 'ダークテーマを選択', + 'settings.openchamber.visual.field.selectThemePlaceholder': 'テーマを選択', + 'settings.openchamber.visual.field.timeFormat': '時刻形式', + 'settings.openchamber.visual.field.weekStartsOn': '週の開始日', + 'settings.openchamber.visual.field.selectTimeFormatAria': '時刻形式を選択', + 'settings.openchamber.visual.field.selectWeekStartAria': '週の開始日を選択', + 'settings.openchamber.visual.actions.reloadThemes': 'テーマを再読み込み', + 'settings.openchamber.visual.actions.reloadingThemes': 'テーマを再読み込み中...', + 'settings.openchamber.visual.field.macVibrancy': 'ウィンドウ透過', + 'settings.openchamber.visual.field.macVibrancyHint': 'サイドバーの背後に macOS ネイティブのぼかし(Vibrancy)を使用します。完全に不透明なサーフェスにする場合はオフにします。', + 'settings.openchamber.visual.actions.saveAndRestart': '保存して再起動', + 'settings.openchamber.visual.actions.restarting': '再起動中...', + 'settings.openchamber.visual.field.themeImportInfoAria': 'テーマインポート情報', + 'settings.openchamber.visual.field.themeImportInfoTooltip': '~/.config/openchamber/themes/ からカスタムテーマをインポート', + 'settings.openchamber.visual.field.installAppName': 'インストールアプリ名', + 'settings.openchamber.visual.field.installAppNameHint': 'PWA インストールプロセスで使用されます。', + 'settings.openchamber.visual.field.pwaInstallAppNameAria': 'PWA インストールアプリ名', + 'settings.openchamber.visual.actions.resetInstallAppNameAria': 'インストールアプリ名をリセット', + 'settings.openchamber.visual.field.installOrientation': 'インストール方向', + 'settings.openchamber.visual.field.installOrientationHint': 'インストールされたウェブアプリで使用されます。変更後は PWA を再インストールしてください。', + 'settings.openchamber.visual.field.pwaInstallOrientationAria': 'PWA インストール方向', + 'settings.openchamber.visual.field.selectOrientationPlaceholder': '方向を選択', + 'settings.openchamber.visual.actions.resetInstallOrientationAria': 'インストール方向をリセット', + 'settings.openchamber.visual.field.mobileKeyboardMode': 'モバイルキーボード動作', + 'settings.openchamber.visual.field.mobileKeyboardModeHint': 'デフォルトのブラウザ動作が最も安全です。コンテンツリサイズは、対応ブラウザに画面キーボード表示時にアプリを縮小するよう要求します。', + 'settings.openchamber.visual.field.mobileKeyboardModeAria': 'モバイルキーボード動作', + 'settings.openchamber.visual.field.selectMobileKeyboardModePlaceholder': 'キーボード動作を選択', + 'settings.openchamber.visual.actions.resetMobileKeyboardModeAria': 'モバイルキーボード動作をリセット', + 'settings.openchamber.visual.field.interfaceFontSize': 'インターフェースフォントサイズ', + 'settings.openchamber.visual.field.interfaceFont': 'インターフェースフォント', + 'settings.openchamber.visual.field.selectInterfaceFontAria': 'インターフェースフォントを選択', + 'settings.openchamber.visual.actions.resetInterfaceFontAria': 'インターフェースフォントをリセット', + 'settings.openchamber.visual.field.fontSizePercentageAria': 'フォントサイズの割合', + 'settings.openchamber.visual.actions.resetFontSizeAria': 'フォントサイズをリセット', + 'settings.openchamber.visual.field.terminalFontSize': 'ターミナルフォントサイズ', + 'settings.openchamber.visual.field.codeFont': 'コードフォント', + 'settings.openchamber.visual.field.selectCodeFontAria': 'コードフォントを選択', + 'settings.openchamber.visual.actions.resetCodeFontAria': 'コードフォントをリセット', + 'settings.openchamber.visual.actions.resetTerminalFontSizeAria': 'ターミナルフォントサイズをリセット', + 'settings.openchamber.visual.field.spacingDensity': '間隔の密度', + 'settings.openchamber.visual.actions.resetSpacingAria': '間隔をリセット', + 'settings.openchamber.visual.field.inputBarOffset': '入力バーオフセット', + 'settings.openchamber.visual.field.inputBarOffsetTooltip': 'ホームバーなどの OS レベルの画面障害物を避けるため入力バーを上げます。', + 'settings.openchamber.visual.actions.resetInputBarOffsetAria': '入力バーオフセットをリセット', + 'settings.openchamber.visual.field.terminalQuickKeysAria': 'ターミナルクイックキー', + 'settings.openchamber.visual.field.terminalQuickKeys': 'ターミナルクイックキー', + 'settings.openchamber.visual.field.terminalQuickKeysTooltip': 'ターミナルビューに Esc、Ctrl、矢印を表示', + 'settings.openchamber.visual.field.fileEditorKeymap': 'ファイルエディターキーマップ', + 'settings.openchamber.visual.option.fileEditorKeymap.default': 'デフォルト', + 'settings.openchamber.visual.option.fileEditorKeymap.vim': 'Vim', + 'settings.openchamber.visual.field.activityDefaultModeAria': 'アクティビティデフォルトモード: {option}', + 'settings.openchamber.visual.field.showExpandedBashToolsAria': '展開された Bash ツールを表示', + 'settings.openchamber.visual.field.showExpandedEditToolsAria': '展開された編集ツールを表示', + 'settings.openchamber.visual.field.bash': 'Bash', + 'settings.openchamber.visual.field.editTools': '編集ツール', + 'settings.openchamber.visual.field.userMessageRenderingAria': 'ユーザーメッセージ表示: {option}', + 'settings.openchamber.visual.field.mermaidRenderingAria': 'Mermaid 表示: {option}', + 'settings.openchamber.visual.field.diffLayoutAria': 'Diff レイアウト: {option}', + 'settings.openchamber.visual.field.showReasoningTracesAria': '推論トレースを表示', + 'settings.openchamber.visual.field.showReasoningTraces': '推論トレースを表示', + 'settings.openchamber.visual.field.collapsibleThinkingBlocksAria': '折りたたみ可能な推論ブロックを有効化', + 'settings.openchamber.visual.field.collapsibleThinkingBlocks': '折りたたみ可能な推論ブロックを有効化', + 'settings.openchamber.visual.field.collapsibleUserMessagesAria': '長いユーザーメッセージを折りたたむ', + 'settings.openchamber.visual.field.collapsibleUserMessages': '長いユーザーメッセージを折りたたむ', + 'settings.openchamber.visual.field.stickyUserHeaderAria': 'ユーザーヘッダー固定', + 'settings.openchamber.visual.field.stickyUserHeader': 'ユーザーヘッダー固定', + 'settings.openchamber.visual.field.expandedEditorToolbarAria': 'エディターツールバーを常に表示', + 'settings.openchamber.visual.field.expandedEditorToolbar': 'エディターツールバーを常に表示(ファイルタブの下にドッキング)', + 'settings.openchamber.visual.field.wideChatLayoutAria': 'ワイドチャットレイアウト', + 'settings.openchamber.visual.field.wideChatLayout': 'ワイドチャットレイアウト', + 'settings.openchamber.visual.field.showSplitAssistantMessageActionsAria': 'インラインアシスタントアクション', + 'settings.openchamber.visual.field.showSplitAssistantMessageActions': 'インラインアシスタントアクション', + 'settings.openchamber.visual.field.showSplitAssistantMessageActionsTooltip': '同じ応答内の後のツール呼び出しの前に表示されるアシスタントテキストブロックに、回答コピー、画像保存、読み上げを表示します。', + 'settings.openchamber.visual.field.showToolFileIconsAria': 'ツールファイルアイコンを表示', + 'settings.openchamber.visual.field.showToolFileIcons': 'ツールファイルアイコンを表示', + 'settings.openchamber.visual.field.showTurnChangedFilesAria': '完了したターンの変更ファイルを表示', + 'settings.openchamber.visual.field.showTurnChangedFiles': '完了したターンの変更ファイルを表示', + 'settings.openchamber.visual.field.showDotfilesAria': 'ドットファイルを表示', + 'settings.openchamber.visual.field.showDotfiles': 'ドットファイルを表示', + 'settings.openchamber.visual.field.queueMessagesByDefaultAria': 'デフォルトでメッセージをキューイング', + 'settings.openchamber.visual.field.queueMessagesByDefault': 'デフォルトでメッセージをキューイング', + 'settings.openchamber.visual.field.queueMessagesByDefaultTooltip': '有効にすると Enter でメッセージをキューイングします。{modifier}+Enter で送信します。', + 'settings.openchamber.visual.field.persistDraftMessagesAria': '下書きメッセージを保持', + 'settings.openchamber.visual.field.persistDraftMessages': '下書きメッセージを保持', + 'settings.openchamber.visual.field.enableSpellcheckInTextInputsAria': 'テキスト入力のスペルチェックを有効化', + 'settings.openchamber.visual.field.enableSpellcheckInTextInputs': 'テキスト入力のスペルチェックを有効化', + 'settings.openchamber.visual.field.sendAnonymousUsageReportsAria': '匿名使用状況レポートを送信', + 'settings.openchamber.visual.field.sendAnonymousUsageReports': '匿名使用状況レポートを送信', + 'settings.openchamber.visual.field.sendAnonymousUsageReportsHint': 'どのアプリバージョンがアクティブに使用されているかを把握し、改善の優先順位を決めるのに役立ちます。収集されるのはアプリバージョン、プラットフォーム、ランタイムのみで、個人データやコードは収集されません。', + 'settings.openchamber.visual.option.themeMode.system': 'システム', + 'settings.openchamber.visual.option.themeMode.light': 'ライト', + 'settings.openchamber.visual.option.themeMode.dark': 'ダーク', + 'settings.openchamber.visual.option.diffLayout.dynamic.label': '動的', + 'settings.openchamber.visual.option.diffLayout.dynamic.description': '新規はインライン、変更は並列表示。', + 'settings.openchamber.visual.option.diffLayout.inline.label': '常にインライン', + 'settings.openchamber.visual.option.diffLayout.inline.description': '単一の統合ビューとして表示。', + 'settings.openchamber.visual.option.diffLayout.sideBySide.label': '常に並列', + 'settings.openchamber.visual.option.diffLayout.sideBySide.description': '元のファイルと変更後のファイルを比較。', + 'settings.openchamber.visual.option.mermaidRendering.svg.label': 'SVG', + 'settings.openchamber.visual.option.mermaidRendering.svg.description': '図をスケーラブルグラフィックとして表示。', + 'settings.openchamber.visual.option.mermaidRendering.ascii.label': 'ASCII', + 'settings.openchamber.visual.option.mermaidRendering.ascii.description': '図をテキストブロックとして表示。', + 'settings.openchamber.visual.option.pwaOrientation.system.label': 'システムに従う', + 'settings.openchamber.visual.option.pwaOrientation.system.description': 'デバイスの回転設定に従います。', + 'settings.openchamber.visual.option.pwaOrientation.portrait.label': '縦向き固定', + 'settings.openchamber.visual.option.pwaOrientation.portrait.description': 'アプリを縦向き固定でインストール。', + 'settings.openchamber.visual.option.pwaOrientation.landscape.label': '横向き固定', + 'settings.openchamber.visual.option.pwaOrientation.landscape.description': 'アプリを横向き固定でインストール。', + 'settings.openchamber.visual.option.mobileKeyboardMode.native.label': 'ブラウザに従う', + 'settings.openchamber.visual.option.mobileKeyboardMode.native.description': 'キーボード表示時のパンとビューポート変更にブラウザデフォルトを使用。', + 'settings.openchamber.visual.option.mobileKeyboardMode.resizeContent.label': 'コンテンツをリサイズ', + 'settings.openchamber.visual.option.mobileKeyboardMode.resizeContent.description': 'パンのみに依存せず、対応ブラウザにアプリレイアウトを縮小するよう要求。', + 'settings.openchamber.visual.option.userMessageRendering.markdown.label': 'Markdown', + 'settings.openchamber.visual.option.userMessageRendering.markdown.description': 'ユーザーテキストを Markdown 書式で表示。', + 'settings.openchamber.visual.option.userMessageRendering.plain.label': 'プレインテキスト', + 'settings.openchamber.visual.option.userMessageRendering.plain.description': 'ユーザーテキストを空白とリンクを保持して表示。', + 'chat.message.userText.collapseAria': 'ユーザーメッセージを折りたたむ', + 'settings.openchamber.visual.option.chatRenderMode.sorted.label': 'ソート', + 'settings.openchamber.visual.option.chatRenderMode.sorted.description': '完了したアシスタントメッセージをライブストリーミングなしで表示。', + 'settings.openchamber.visual.option.chatRenderMode.live.label': 'ライブ', + 'settings.openchamber.visual.option.chatRenderMode.live.description': 'アシスタントのテキストとツールを到着次第ストリーミング。', + 'settings.openchamber.visual.option.messageTransport.auto.label': '自動', + 'settings.openchamber.visual.option.messageTransport.auto.description': 'WebSocket を優先し、必要に応じて SSE にフォールバック。', + 'settings.openchamber.visual.option.messageTransport.ws.label': 'WebSocket', + 'settings.openchamber.visual.option.messageTransport.ws.description': 'メッセージストリーミングに WebSocket を使用。', + 'settings.openchamber.visual.option.messageTransport.sse.label': 'SSE', + 'settings.openchamber.visual.option.messageTransport.sse.description': 'メッセージストリーミングに SSE を使用。', + 'settings.openchamber.visual.option.activityRenderMode.collapsed.label': '折りたたみ', + 'settings.openchamber.visual.option.activityRenderMode.collapsed.description': 'アクティビティをデフォルトで折りたたみ。', + 'settings.openchamber.visual.option.activityRenderMode.summary.label': '展開', + 'settings.openchamber.visual.option.activityRenderMode.summary.description': 'アクティビティをデフォルトで展開。', + 'settings.openchamber.visual.option.timeFormat.auto.label': '自動', + 'settings.openchamber.visual.option.timeFormat.auto.description': 'システムロケール設定に従います。', + 'settings.openchamber.visual.option.timeFormat.24h.label': '24時間', + 'settings.openchamber.visual.option.timeFormat.24h.description': '時刻を 14:15 と表示。', + 'settings.openchamber.visual.option.timeFormat.12h.label': '12時間', + 'settings.openchamber.visual.option.timeFormat.12h.description': '時刻を 02:15 PM と表示。', + 'settings.openchamber.visual.option.weekStart.auto.label': '自動', + 'settings.openchamber.visual.option.weekStart.auto.description': 'ロケールの週開始日に従います。', + 'settings.openchamber.visual.option.weekStart.monday.label': '月曜日', + 'settings.openchamber.visual.option.weekStart.sunday.label': '日曜日', + 'settings.magicPrompts.page.loading.aria': '読み込み中', + 'settings.magicPrompts.page.loading.text': 'Magic Prompts を読み込み中...', + 'settings.magicPrompts.page.block.visiblePrompt': '表示プロンプト', + 'settings.magicPrompts.page.block.instructions': '指示', + 'settings.magicPrompts.page.group.gitCommitGenerate.title': 'Commit 生成', + 'settings.magicPrompts.page.group.gitCommitGenerate.description': 'Commit メッセージ生成に使用するプロンプト: 表示ユーザーメッセージ + 非表示指示。', + 'settings.magicPrompts.page.group.gitPrGenerate.title': 'PR 生成', + 'settings.magicPrompts.page.group.gitPrGenerate.description': 'PR タイトル/本文生成に使用するプロンプト: 表示ユーザーメッセージ + 非表示指示。', + 'settings.magicPrompts.page.group.githubPrReview.title': 'PR レビュー', + 'settings.magicPrompts.page.group.githubPrReview.description': 'PR レビューフローに使用するプロンプト: 表示ユーザーメッセージ + 非表示指示ペイロード。', + 'settings.magicPrompts.page.group.githubIssueReview.title': 'Issue レビュー', + 'settings.magicPrompts.page.group.githubIssueReview.description': 'Issue レビューフローに使用するプロンプト: 表示ユーザーメッセージ + 非表示指示ペイロード。', + 'settings.magicPrompts.page.group.githubPrFailedChecksReview.title': 'PR 失敗チェックレビュー', + 'settings.magicPrompts.page.group.githubPrFailedChecksReview.description': 'PR 失敗チェック分析に使用するプロンプト。', + 'settings.magicPrompts.page.group.githubPrCommentsReview.title': 'PR コメントレビュー', + 'settings.magicPrompts.page.group.githubPrCommentsReview.description': 'PR コメント分析に使用するプロンプト。', + 'settings.magicPrompts.page.group.githubSinglePrCommentReview.title': '単一 PR コメントレビュー', + 'settings.magicPrompts.page.group.githubSinglePrCommentReview.description': '単一 PR コメント分析に使用するプロンプト。', + 'settings.magicPrompts.page.group.gitConflictResolve.title': 'Merge/Rebase 競合解決', + 'settings.magicPrompts.page.group.gitConflictResolve.description': 'AI で Merge/Rebase 競合を解決する際に使用するプロンプト。', + 'settings.magicPrompts.page.group.gitCherrypickConflictResolve.title': 'Cherry-pick 競合解決', + 'settings.magicPrompts.page.group.gitCherrypickConflictResolve.description': '統合フローで Cherry-pick 競合を解決する際に使用するプロンプト。', + 'settings.magicPrompts.page.group.planImprove.title': '計画の改善', + 'settings.magicPrompts.page.group.planImprove.description': '保存された計画を改善フローに送る際に使用する非表示プロンプト。', + 'settings.magicPrompts.page.group.planTodo.title': 'Todo 計画', + 'settings.magicPrompts.page.group.planTodo.description': 'Todo を新しい計画 Session に送る際に使用する非表示プロンプト。', + 'settings.magicPrompts.page.group.planImplement.title': '計画の実装', + 'settings.magicPrompts.page.group.planImplement.description': '保存された計画を実装フローに送る際に使用する非表示プロンプト。', + 'settings.magicPrompts.page.group.sessionSummary.title': 'Session サマリー', + 'settings.magicPrompts.page.group.sessionSummary.description': '/summary スラッシュコマンドで使用するプロンプト: 表示ユーザーメッセージ + 非表示指示。非破壊的 - Session 履歴を圧縮しません。', + 'settings.magicPrompts.page.group.sessionWorkspaceReview.title': 'Workspace レビュー', + 'settings.magicPrompts.page.group.sessionExplore.title': 'コードベースツアー', + 'settings.magicPrompts.page.group.sessionExplore.description': '/explore スラッシュコマンドで使用するプロンプト: 表示ユーザーメッセージ + 非表示指示。ファイルごとのダンプではなく、リポジトリを調査し、全体像、主要モジュール、それらの接続方法、開始場所を構造化された形で提供します。', + 'settings.magicPrompts.page.group.sessionWorkspaceReview.description': '/workspace-review スラッシュコマンドで使用するプロンプト: 表示ユーザーメッセージ + 非表示指示。Workspace の Diff が意図を達成しているか、正確で適切かをレビューし、重大度ごとに所見を分類します。', + 'settings.magicPrompts.page.group.sessionFeaturePlan.title': '機能計画', + 'settings.magicPrompts.page.group.sessionFeaturePlan.description': '/plan-feature スラッシュコマンドで使用するプロンプト: 表示ユーザーメッセージ + 非表示指示。コードを調査し、実装計画を作成する前に少人数のバッチで明確化の質問を行うガイド付き対話を実行します。', + 'settings.magicPrompts.page.group.sessionCatchUp.title': 'キャッチアップ', + 'settings.magicPrompts.page.group.sessionCatchUp.description': '/catch-up スラッシュコマンドで使用するプロンプト: 表示ユーザーメッセージ + 非表示指示。ブランチの Commit、PR、未コミットの作業をまとめてブランチ認識コンテキストを構築し、何をしていたかを理解した上で、わかりやすいサマリーとプロダクトに焦点を当てた次のステップを提供します。', + 'settings.magicPrompts.page.group.sessionDebug.title': 'デバッグ', + 'settings.magicPrompts.page.group.sessionDebug.description': '/debug スラッシュコマンドで使用するプロンプト: 表示ユーザーメッセージ + 非表示指示。症状を捉え、仮説を立て、コードで検証し、修正を提案する前に原因を確認するガイド付き根本原因調査を実行します。', + 'settings.magicPrompts.page.group.sessionWeigh.title': '選択肢の比較', + 'settings.magicPrompts.page.group.sessionWeigh.description': '/weigh スラッシュコマンドで使用するプロンプト: 表示ユーザーメッセージ + 非表示指示。コードを調査し、計画やコードを書かずに2〜3の異なるアプローチとトレードオフ、推奨事項を提示します。', + 'settings.magicPrompts.page.group.sessionFusion.title': '統合', + 'settings.magicPrompts.page.group.sessionFusion.description': 'マルチランの出力を1つの最終回答に結合する際に使用するプロンプト: ソース結果の前に表示ユーザーメッセージ + 非表示指示。', + 'settings.magicPrompts.page.actions.resetting': 'リセット中...', + 'settings.magicPrompts.page.actions.resetAllOverrides': 'すべての上書きをリセット', + 'settings.magicPrompts.page.actions.resetToDefault': 'デフォルトにリセット', + 'settings.magicPrompts.page.actions.save': '保存', + 'settings.magicPrompts.page.placeholdersLabel': 'プレースホルダー:', + 'settings.magicPrompts.page.validation.visiblePromptRequired': '表示プロンプトは空にできません。', + 'settings.magicPrompts.page.status.unsavedChanges': '未保存の変更', + 'settings.magicPrompts.page.status.usingSavedOverride': '保存済みの上書きを使用', + 'settings.magicPrompts.page.status.usingBuiltinDefault': 'ビルトインデフォルトを使用', + 'settings.magicPrompts.page.toast.loadFailed': 'Magic Prompts の読み込みに失敗しました', + 'settings.magicPrompts.page.toast.visiblePromptRequired': '表示プロンプトは空にできません', + 'settings.magicPrompts.page.toast.saved': 'Magic Prompt を保存しました', + 'settings.magicPrompts.page.toast.saveFailed': 'Magic Prompt の保存に失敗しました', + 'settings.magicPrompts.page.toast.resetSuccess': 'プロンプトをデフォルトにリセットしました', + 'settings.magicPrompts.page.toast.resetFailed': 'プロンプトのリセットに失敗しました', + 'settings.magicPrompts.page.toast.resetAllSuccess': 'すべてのプロンプト上書きをリセットしました', + 'settings.magicPrompts.page.toast.resetAllFailed': 'すべてのプロンプトのリセットに失敗しました', +} as const; diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts new file mode 100644 index 00000000..97e58f37 --- /dev/null +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -0,0 +1,2639 @@ +import type { I18nKey } from './en'; +import { settingsDict } from './ja.settings'; + +export const dict: Record = { + ...settingsDict, + 'common.loading': '読み込み中...', + 'common.unavailable': '利用できません', + 'common.language.english': '英語', + 'common.language.french': 'フランス語', + 'common.language.simplifiedChinese': '中国語(簡体字)', + 'common.language.traditionalChinese': '中国語(繁体字)', + 'common.language.ukrainian': 'ウクライナ語', + 'common.language.spanish': 'スペイン語', + 'common.language.brazilianPortuguese': 'ブラジルポルトガル語', + 'common.language.korean': '韓国語', + 'common.language.polish': 'ポーランド語', + 'common.language.japanese': '日本語', + 'common.revealPath.finder': 'Finderで表示', + 'common.revealPath.fileExplorer': 'エクスプローラーで開く', + 'common.revealPath.fileManager': 'ファイルマネージャーで開く', + 'pwa.installPrompt.description': 'OpenChamberをインストールしてすぐにアクセス', + 'pwa.installPrompt.action': 'インストール', + 'pwa.installPrompt.dismiss': '閉じる', + 'pwa.installPrompt.started': 'インストールを開始しました', + 'pwa.installPrompt.installed': 'OpenChamberをインストールしました', + 'layout.mainTab.chat': 'チャット', + 'layout.mainTab.plan': '計画', + 'layout.mainTab.diff': '差分', + 'layout.mainTab.diagram': 'ダイアグラム', + 'layout.mainTab.files': 'ファイル', + 'layout.mainTab.terminal': 'ターミナル', + 'layout.mainTab.context': 'コンテキスト', + 'mobile.nav.aria': 'モバイルナビゲーション', + 'mobile.nav.changes': '変更', + 'mobile.nav.settings': '設定', + 'mobile.surface.closeAria': '閉じる', + 'mobile.header.openMenuAria': 'メニューを開く', + 'mobile.header.openMetadataAria': 'セッションメタデータを開く', + 'mobile.header.metadata.context': 'コンテキスト', + 'mobile.header.metadata.branch': 'ブランチ', + 'mobile.header.metadata.usage': '使用量', + 'mobile.menu.titleAria': 'ワークスペースツール', + 'mobile.menu.files': 'ファイル', + 'mobile.menu.changes': '変更', + 'mobile.menu.mcp': 'MCP', + 'mobile.menu.update': '更新', + 'mobile.menu.settings': '設定', + 'mobile.sessions.newChatCta': '{project}で新しいチャット', + 'mobile.sessions.dateGroup.today': '今日', + 'mobile.sessions.dateGroup.yesterday': '昨日', + 'mobile.sessions.dateGroup.thisWeek': '今週', + 'mobile.sessions.dateGroup.older': '以前', + 'mobile.sessions.section.worktrees': 'ワークツリー', + 'mobile.sessions.section.otherProjects': 'プロジェクトを切り替え', + 'mobile.sessions.section.projects': 'プロジェクト', + 'mobile.sessions.empty.noProjectsTitle': 'まだプロジェクトがありません', + 'mobile.sessions.empty.noProjectsDescription': 'プロジェクトを追加してコードとチャットを始めましょう。', + 'mobile.sessions.empty.noSessionsTitle': 'まだセッションがありません', + 'mobile.sessions.empty.noSessionsDescription': '最初のチャットを開始してここに表示しましょう。', + 'mobile.sessions.empty.searchTitle': '一致しません', + 'mobile.sessions.empty.searchDescription': '別の検索語をお試しください。', + 'mobile.sessions.showArchived': 'アーカイブを表示({count})', + 'mobile.sessions.hideArchived': 'アーカイブを非表示', + 'mobile.sessions.activeWorktreeAria': 'アクティブなワークツリー', + 'mobile.sessions.activeProjectAria': 'アクティブなプロジェクト', + 'mobile.sessions.startNewChat': '新しいチャットを開始', + 'mobile.sessions.newChat': '新しいチャット', + 'mobile.sessions.editOrder': 'プロジェクトの並び替え', + 'mobile.sessions.doneEditing': '完了', + 'mobile.sessions.editOrderHint': 'ハンドルをドラッグしてプロジェクトを並び替えます。チェックをタップして完了します。', + 'mobile.sessions.editProjectAria': '{label}を編集', + 'mobile.sessions.dragHandleAria': '{label}をドラッグして並び替え', + 'mobile.sessions.moveUpAria': '{label}を上に移動', + 'mobile.sessions.moveDownAria': '{label}を下に移動', + 'mobile.sessions.removeProjectAria': '{label}を削除', + 'mobile.sessions.cancelRemoveProjectAria': '{label}の削除をキャンセル', + 'mobile.sessions.confirmRemoveProject': '閉じる', + 'mobile.sessions.confirmRemoveProjectAria': '{label}の削除を確認', + 'mobile.sessions.toast.projectRemoved': '{label}を削除しました', + 'mobile.sessions.archiveSessionAria': '{title}をアーカイブ', + 'mobile.sessions.cancelArchiveAria': '{title}のアーカイブをキャンセル', + 'mobile.projectEdit.worktreesTitle': 'ワークツリー', + 'mobile.projectEdit.worktreesEmpty': 'このプロジェクトにはまだワークツリーがありません。', + 'mobile.projectEdit.reorderHint': 'ドラッグしてワークツリーを並び替え。', + 'mobile.projectEdit.dragWorktreeAria': '{label}をドラッグして並び替え', + 'mobile.projectEdit.deleteWorktreeAria': 'ワークツリー{label}を削除', + 'mobile.projectEdit.deleteWorktreeTitle': 'ワークツリーを削除', + 'mobile.projectEdit.deleteWorktreeConfirmButton': '削除', + 'mobile.projectEdit.deleteWorktreeConfirm': 'ワークツリー「{name}」を削除しますか?この操作は元に戻せません。', + 'mobile.projectEdit.deleteWorktreeDirty': 'このワークツリーには失われる未コミットの変更があります。', + 'mobile.projectEdit.deleteWorktreeArchiveNote': '{count}件の添付セッションがアーカイブされます。', + 'mobile.projectEdit.deleteLocalBranch': 'ローカルブランチも削除', + 'mobile.projectEdit.deleteRemoteBranch': 'リモートブランチも削除', + 'mobile.sessions.showMore': 'さらに{count}件表示', + 'mobile.sessions.search.section.sessions': 'セッション', + 'mobile.sessions.search.section.archived': 'アーカイブ済み', + 'mobile.sessions.search.section.projects': 'プロジェクト', + 'mobile.sessions.clearSearchAria': '検索をクリア', + 'mobile.header.noProject': 'プロジェクトを選択', + 'mobile.header.activeSession': 'アクティブなセッション', + 'mobile.header.noSession': 'アクティブなセッションがありません', + 'mobile.sessions.openSheetAria': 'セッションとプロジェクトを開く', + 'mobile.sessions.closeSheetAria': 'セッションとプロジェクトを閉じる', + 'mobile.sessions.sheet.title': 'セッション', + 'mobile.sessions.sheet.description': 'プロジェクトの切り替え、セッションの開始、新しいチャットを始められます。', + 'mobile.sessions.search.placeholder': 'セッションを検索', + 'mobile.sessions.empty': 'セッションが見つかりません。', + 'mobile.sessions.unassignedProject': 'その他のセッション', + 'mobile.sessions.newSessionAria': 'このプロジェクトで新しいセッションを開始', + 'mobile.sessions.untitled': '無題のセッション', + 'mobile.sessions.project.sessionsSingle': '1セッション', + 'mobile.sessions.project.sessionsPlural': '{count}セッション', + 'mobile.files.refreshAria': 'ファイルを更新', + 'mobile.files.backToParentAria': '{name}に戻る', + 'mobile.files.rootDirectory': 'プロジェクトファイル', + 'mobile.files.search.placeholder': 'ファイルを検索', + 'mobile.files.search.empty': 'ファイルが見つかりません。', + 'mobile.files.parentDirectory': '親ディレクトリ', + 'mobile.files.empty.noDirectory': 'プロジェクトを選択してファイルを参照します。', + 'mobile.files.empty.directory': 'このディレクトリは空です。', + 'mobile.files.error.listFailed': 'ファイルの読み込みに失敗しました', + 'mobile.files.error.readUnavailable': 'このランタイムではファイルプレビューは利用できません。', + 'mobile.files.file.truncated': 'モバイル向けにファイルプレビューを切り詰めています。', + 'mobile.files.copyPathAria': 'ファイルパスをコピー', + 'mobile.files.copyContent': '内容をコピー', + 'mobile.files.copyContentAria': 'ファイル内容をコピー', + 'mobile.files.toast.pathCopied': 'パスをコピーしました', + 'mobile.files.toast.contentCopied': '内容をコピーしました', + 'mobile.files.toast.copyFailed': 'コピーに失敗しました', + 'mobile.changes.placeholder.title': '変更', + 'mobile.changes.placeholder.description': 'ワーキングツリーの確認、同期、コミット操作はここに表示されます。', + 'mobile.changes.branchLabel': 'ブランチ: {branch}', + 'mobile.changes.noRemote': 'リモートが利用できません', + 'mobile.changes.cleanDescription': 'このワークスペースに変更されたファイルはありません。', + 'mobile.changes.diffDetail.subtitle': '読み取り専用の差分', + 'mobile.changes.diffDetail.loadFailed': '差分の読み込みに失敗しました', + 'mobile.changes.diffDetail.missingTitle': 'ファイルは変更されていません', + 'mobile.changes.diffDetail.missingDescription': '変更画面に戻ってリストを更新してください。', + 'mobile.changes.diffDetail.imageUnavailable': 'モバイルの変更画面では画像差分はまだ利用できません。', + 'mobile.settings.placeholder.title': '設定', + 'mobile.settings.placeholder.description': 'モバイル接続とアプリの設定はここに表示されます。', + 'layout.rightSidebar.git': 'Git', + 'layout.rightSidebar.files': 'ファイル', + 'layout.rightSidebar.context': 'コンテキスト', + 'layout.services.instance': 'インスタンス', + 'layout.services.usage': '使用量', + 'sessions.sidebar.header.actions.closeSessions': 'セッションを閉じる', + 'sessions.sidebar.header.actions.addProject': 'プロジェクトを追加', + 'sessions.sidebar.header.actions.newSession': '新しいセッション', + 'sessions.sidebar.header.actions.newMultiRun': '新規マルチラン', + 'sessions.sidebar.header.actions.scheduledTasks': 'スケジュールタスク', + 'sessions.scheduledTasks.dialog.title': 'スケジュールタスク', + 'sessions.scheduledTasks.dialog.description': '新しいセッションを作成し設定済みプロンプトを送信するサーバーサイドタスク。', + 'sessions.scheduledTasks.dialog.project.label': 'プロジェクト', + 'sessions.scheduledTasks.dialog.project.placeholder': 'プロジェクトを選択', + 'sessions.scheduledTasks.dialog.project.empty': 'プロジェクトがありません', + 'sessions.scheduledTasks.dialog.actions.newTask': '新しいタスク', + 'sessions.scheduledTasks.dialog.actions.runNow': '今すぐ実行', + 'sessions.scheduledTasks.dialog.actions.edit': '編集', + 'sessions.scheduledTasks.dialog.actions.editAria': '{taskName}を編集', + 'sessions.scheduledTasks.dialog.actions.deleteAria': '{taskName}を削除', + 'sessions.scheduledTasks.dialog.loading': 'タスクを読み込み中...', + 'sessions.scheduledTasks.dialog.empty.noTasks': 'まだスケジュールタスクがありません。', + 'sessions.scheduledTasks.dialog.empty.selectProject': 'プロジェクトを選択してスケジュールタスクを管理します。', + 'sessions.scheduledTasks.dialog.error.chooseProjectFirst': '先にプロジェクトを選択してください', + 'sessions.scheduledTasks.dialog.toast.loadFailed': 'スケジュールタスクの読み込みに失敗しました', + 'sessions.scheduledTasks.dialog.toast.saved': 'スケジュールタスクを保存しました', + 'sessions.scheduledTasks.dialog.toast.updateFailed': 'タスクの更新に失敗しました', + 'sessions.scheduledTasks.dialog.toast.deleted': 'スケジュールタスクを削除しました', + 'sessions.scheduledTasks.dialog.toast.deleteFailed': 'タスクの削除に失敗しました', + 'sessions.scheduledTasks.dialog.toast.started': 'タスクを開始しました', + 'sessions.scheduledTasks.dialog.toast.runFailed': 'タスクの実行に失敗しました', + 'sessions.scheduledTasks.dialog.confirm.deleteTask': 'スケジュールタスク「{taskName}」を削除しますか?', + 'sessions.scheduledTasks.dialog.schedule.daily': '毎日 {time}', + 'sessions.scheduledTasks.dialog.schedule.dailyWithTimezone': '毎日 {time}({timezone})', + 'sessions.scheduledTasks.dialog.schedule.weekly': '毎週 {days} {time}', + 'sessions.scheduledTasks.dialog.schedule.weeklyWithTimezone': '毎週 {days} {time}({timezone})', + 'sessions.scheduledTasks.dialog.schedule.once': '1回限り {date} {time}', + 'sessions.scheduledTasks.dialog.schedule.onceWithTimezone': '1回限り {date} {time}({timezone})', + 'sessions.scheduledTasks.dialog.schedule.cron': 'Cron: {cron}', + 'sessions.scheduledTasks.dialog.schedule.cronWithTimezone': 'Cron: {cron}({timezone})', + 'sessions.scheduledTasks.dialog.schedule.unknownDate': '不明な日付', + 'sessions.scheduledTasks.dialog.schedule.weekdayShort.sun': '日', + 'sessions.scheduledTasks.dialog.schedule.weekdayShort.mon': '月', + 'sessions.scheduledTasks.dialog.schedule.weekdayShort.tue': '火', + 'sessions.scheduledTasks.dialog.schedule.weekdayShort.wed': '水', + 'sessions.scheduledTasks.dialog.schedule.weekdayShort.thu': '木', + 'sessions.scheduledTasks.dialog.schedule.weekdayShort.fri': '金', + 'sessions.scheduledTasks.dialog.schedule.weekdayShort.sat': '土', + 'sessions.scheduledTasks.dialog.schedule.weekdayShort.unknown': '?', + 'sessions.scheduledTasks.dialog.relativeTime.inLessThanOneMinute': '1分未満', + 'sessions.scheduledTasks.dialog.relativeTime.justNow': 'たった今', + 'sessions.scheduledTasks.dialog.relativeTime.inMinutes': '{count}分後', + 'sessions.scheduledTasks.dialog.relativeTime.minutesAgo': '{count}分前', + 'sessions.scheduledTasks.dialog.relativeTime.inDuration': '{duration}後', + 'sessions.scheduledTasks.dialog.relativeTime.durationAgo': '{duration}前', + 'sessions.scheduledTasks.dialog.status.success': '成功', + 'sessions.scheduledTasks.dialog.status.error': 'エラー', + 'sessions.scheduledTasks.dialog.status.running': '実行中', + 'sessions.scheduledTasks.dialog.status.idle': '待機中', + 'sessions.scheduledTasks.dialog.nextRun.label': '次回', + 'sessions.scheduledTasks.dialog.lastRun.label': '最終実行', + 'sessions.scheduledTasks.dialog.lastRun.runningNow': '実行中', + 'sessions.scheduledTasks.dialog.lastRun.never': 'なし', + 'sessions.scheduledTasks.dialog.taskToggle.enableAria': '{taskName}を有効にする', + 'sessions.scheduledTasks.dialog.taskToggle.pauseAria': '{taskName}を一時停止', + 'sessions.scheduledTasks.dialog.taskToggle.enabled': '有効', + 'sessions.scheduledTasks.dialog.taskToggle.paused': '一時停止中', + 'sessions.scheduledTasks.editor.title.edit': 'スケジュールタスクを編集', + 'sessions.scheduledTasks.editor.title.new': '新しいスケジュールタスク', + 'sessions.scheduledTasks.editor.description': '新しいセッションを作成しプロンプトを送信するサーバーサイドタスクを設定します。', + 'sessions.scheduledTasks.editor.taskName.label': 'タスク名', + 'sessions.scheduledTasks.editor.taskName.placeholder': '日次同期', + 'sessions.scheduledTasks.editor.scheduleType.label': 'スケジュール種類', + 'sessions.scheduledTasks.editor.scheduleType.daily': '毎日', + 'sessions.scheduledTasks.editor.scheduleType.weekly': '毎週', + 'sessions.scheduledTasks.editor.scheduleType.once': '1回限り', + 'sessions.scheduledTasks.editor.scheduleType.cron': 'Cron', + 'sessions.scheduledTasks.editor.date.label': '日付', + 'sessions.scheduledTasks.editor.date.placeholder': '日付を選択', + 'sessions.scheduledTasks.editor.date.previousMonth': '前月', + 'sessions.scheduledTasks.editor.date.nextMonth': '翌月', + 'sessions.scheduledTasks.editor.date.jumpToToday': '今日にジャンプ', + 'sessions.scheduledTasks.editor.date.today': '今日', + 'sessions.scheduledTasks.editor.time.label': '時間', + 'sessions.scheduledTasks.editor.time.hourAria': '時', + 'sessions.scheduledTasks.editor.time.minuteAria': '分', + 'sessions.scheduledTasks.editor.time.periodAria': '午前/午後', + 'sessions.scheduledTasks.editor.time.period.am': '午前', + 'sessions.scheduledTasks.editor.time.period.pm': '午後', + 'sessions.scheduledTasks.editor.times.label': '時刻', + 'sessions.scheduledTasks.editor.times.add': '時刻を追加', + 'sessions.scheduledTasks.editor.times.removeAria': '時刻を削除', + 'sessions.scheduledTasks.editor.timezone.label': 'タイムゾーン', + 'sessions.scheduledTasks.editor.weekdays.label': '曜日', + 'sessions.scheduledTasks.editor.model.label': 'モデル', + 'sessions.scheduledTasks.editor.thinkingLevel.label': '思考レベル', + 'sessions.scheduledTasks.editor.thinkingLevel.default': 'デフォルト', + 'sessions.scheduledTasks.editor.agent.label': 'エージェント', + 'sessions.scheduledTasks.editor.prompt.label': 'プロンプト', + 'sessions.scheduledTasks.editor.prompt.placeholder': '未完了のタスクを要約し次のアクションを提案', + 'sessions.scheduledTasks.editor.enabled.aria': 'タスクを有効にする', + 'sessions.scheduledTasks.editor.enabled.label': '有効', + 'sessions.scheduledTasks.editor.actions.closeAria': '閉じる', + 'sessions.scheduledTasks.editor.actions.cancel': 'キャンセル', + 'sessions.scheduledTasks.editor.actions.save': '保存', + 'sessions.scheduledTasks.editor.actions.saving': '保存中...', + 'sessions.scheduledTasks.editor.toast.saveFailed': 'タスクの保存に失敗しました', + 'sessions.scheduledTasks.editor.validation.taskNameRequired': 'タスク名が必要です', + 'sessions.scheduledTasks.editor.validation.promptRequired': 'プロンプトが必要です', + 'sessions.scheduledTasks.editor.validation.modelRequired': 'モデルが必要です', + 'sessions.scheduledTasks.editor.validation.dateFormat': '日付はYYYY-MM-DD形式で入力してください', + 'sessions.scheduledTasks.editor.validation.timeFormat': '時刻はHH:mm形式で入力してください', + 'sessions.scheduledTasks.editor.validation.atLeastOneTime': '有効な時刻を1つ以上追加してください', + 'sessions.scheduledTasks.editor.validation.atLeastOneWeekday': '曜日を1つ以上選択してください', + 'sessions.scheduledTasks.editor.validation.timezoneRequired': 'タイムゾーンが必要です', + 'sessions.scheduledTasks.editor.validation.cronRequired': 'Cron式が必要です', + 'sessions.scheduledTasks.editor.validation.cronInvalid': '無効なCron式です', + 'sessions.scheduledTasks.editor.cronExpression.label': 'Cron式', + 'sessions.scheduledTasks.editor.cronExpression.placeholder': '*/5 * * * *', + 'sessions.scheduledTasks.editor.cronExpression.nextRuns': '次の実行', + 'sessions.scheduledTasks.editor.cronExpression.examples': '例', + 'sessions.scheduledTasks.editor.cronExpression.examples.every5min': '5分ごと', + 'sessions.scheduledTasks.editor.cronExpression.examples.everyHour': '毎時', + 'sessions.scheduledTasks.editor.cronExpression.examples.monday9am': '毎週月曜日9時', + 'sessions.scheduledTasks.editor.cronExpression.examples.9am5pm': '毎日9時と17時', + 'sessions.scheduledTasks.editor.cronExpression.examples.firstOfMonth': '毎月1日', + 'multirun.launcher.title': '新規マルチラン', + 'multirun.launcher.actions.closeEsc': '閉じる(Esc)', + 'multirun.launcher.actions.cancel': 'キャンセル', + 'multirun.launcher.actions.creating': '作成中...', + 'multirun.launcher.actions.startWithModelCount': '開始({count}モデル)', + 'multirun.launcher.project.label': 'プロジェクト', + 'multirun.launcher.project.placeholder': 'プロジェクトを選択', + 'multirun.launcher.project.empty': '先にプロジェクトを追加してください。', + 'multirun.launcher.project.gitRequired': 'ワークツリー分離なし: 実行は同じディレクトリを使用します。', + 'multirun.launcher.groupName.label': 'グループ名', + 'multirun.launcher.groupName.info': 'ワークツリーのディレクトリ名とブランチ名に使用されます', + 'multirun.launcher.groupName.placeholder': 'feature-auth, bugfix-login', + 'multirun.launcher.baseBranch.label': 'ベースブランチ', + 'multirun.launcher.baseBranch.info': 'このベースからモデルごとに新しいブランチを作成', + 'multirun.launcher.isolateRuns.label': '実行を分離', + 'multirun.launcher.agent.label': 'エージェント', + 'multirun.launcher.agent.info': 'すべての実行で使用するエージェント。設定済みエージェントがデフォルトです。', + 'multirun.launcher.setupCommands.label': 'セットアップコマンド', + 'multirun.launcher.setupCommands.loading': '読み込み中...', + 'multirun.launcher.setupCommands.commandPlaceholder': 'bun install', + 'multirun.launcher.setupCommands.removeCommandAria': 'コマンドを削除', + 'multirun.launcher.setupCommands.addCommand': 'コマンドを追加', + 'multirun.launcher.prompt.label': 'プロンプト', + 'multirun.launcher.prompt.placeholder': 'すべてのモデルに送信するプロンプトを入力...', + 'multirun.launcher.attachments.attach': '添付', + 'multirun.launcher.attachments.tooltip': '同じファイルをすべての実行に送信', + 'multirun.launcher.models.label': 'モデル', + 'multirun.launcher.models.info': '2~{max}モデルを選択。同じモデルを複数回追加できます。', + 'multirun.launcher.toast.fileTooLarge': 'ファイル「{fileName}」が大きすぎます(最大10MB)', + 'multirun.launcher.toast.attachFailed': '「{fileName}」の添付に失敗しました', + 'multirun.launcher.toast.attachedSingle': '{count}ファイルを添付しました', + 'multirun.launcher.toast.attachedPlural': '{count}ファイルを添付しました', + 'multirun.modelMultiSelect.actions.addModel': 'モデルを追加', + 'multirun.modelMultiSelect.search.placeholder': 'モデルを検索', + 'multirun.modelMultiSelect.search.noResults': 'モデルが見つかりません', + 'multirun.modelMultiSelect.sections.favorites': 'お気に入り', + 'multirun.modelMultiSelect.sections.recent': '最近', + 'multirun.modelMultiSelect.variant.placeholder': '思考', + 'multirun.modelMultiSelect.variant.default': 'デフォルト', + 'multirun.modelMultiSelect.keyboard.hint': '↑↓ 移動 • Enter 選択 • Esc 閉じる', + 'multirun.modelMultiSelect.validation.minOnly': '少なくとも{min}モデルを選択してください。', + 'multirun.modelMultiSelect.validation.minToMax': '{min}から{max}モデルを選択してください。', + 'multirun.agentSelector.placeholder': 'エージェントを選択', + 'multirun.fusion.title': '実行フュージョン', + 'multirun.fusion.description': '選択したマルチランの出力を1つの強力な回答に結合します。', + 'multirun.fusion.provider.placeholder': 'プロバイダー', + 'multirun.fusion.model.placeholder': 'モデル', + 'multirun.fusion.sources.label': 'ソース({count})', + 'multirun.fusion.actions.cancel': 'キャンセル', + 'multirun.fusion.actions.start': 'フュージョンを開始', + 'multirun.fusion.actions.starting': '開始中...', + 'multirun.fusion.toast.noOutputs': 'フュージョンするアシスタント出力が見つかりません。', + 'multirun.fusion.toast.failed': 'フュージョンの開始に失敗しました。', + 'multirun.launcher.attachments.label': 'ファイル', + 'multirun.launcher.actions.startWithRunCount': '開始({count}実行)', + 'multirun.launcher.groups.addGroup': '実行グループを追加', + 'multirun.launcher.groups.removeGroup': 'グループを削除', + 'multirun.launcher.groups.groupLabel': '実行グループ{index}', + 'multirun.launcher.groups.template.label': 'テンプレート', + 'multirun.launcher.groups.template.placeholder': 'テンプレートを選択...', + 'multirun.launcher.groups.template.custom': 'カスタムプロンプト', + 'multirun.launcher.groups.prompt.label': 'プロンプト', + 'multirun.launcher.groups.prompt.placeholder': 'このグループのプロンプトを入力...', + 'sessions.sidebar.header.actions.searchSessions': 'セッションを検索', + 'sessions.sidebar.header.actions.exitSelection': '選択を終了', + 'sessions.sidebar.header.actions.selectSessions': 'セッションを選択', + 'sessions.sidebar.header.actions.sessionDisplayMode': 'セッション表示モード', + 'sessions.sidebar.header.displayMode.label': '表示モード', + 'sessions.sidebar.header.displayMode.default': 'デフォルト', + 'sessions.sidebar.header.displayMode.minimal': 'ミニマル', + 'sessions.sidebar.header.displayMode.showRecent': '最近のセクションを表示', + 'sessions.sidebar.header.displayMode.showArchived': 'アーカイブ済みセッションを表示', + 'sessions.sidebar.header.displayMode.collapseAll': 'すべて折りたたむ', + 'sessions.sidebar.header.displayMode.expandAll': 'すべて展開', + 'sessions.sidebar.header.search.matchCountSingle': '{count}件一致', + 'sessions.sidebar.header.search.matchCountPlural': '{count}件一致', + 'sessions.sidebar.header.search.escapeHint': 'Escでクリア', + 'sessions.sidebar.header.search.placeholder': 'セッションを検索...', + 'sessions.sidebar.header.search.clear': '検索をクリア', + 'sessions.sidebar.footer.actions.settings': '設定', + 'sessions.sidebar.footer.actions.shortcuts': 'ショートカット', + 'sessions.sidebar.footer.actions.aboutOpenChamber': 'OpenChamberについて', + 'sessions.sidebar.footer.actions.update': '更新', + 'sessions.sidebar.empty.noSessions.title': 'まだセッションがありません', + 'sessions.sidebar.empty.noSessions.description': '最初のセッションを作成してコーディングを始めましょう。', + 'sessions.sidebar.empty.noMatches.title': '一致するセッションがありません', + 'sessions.sidebar.empty.noMatches.description': '別のタイトル、ブランチ、フォルダ、パスをお試しください。', + 'sessions.sidebar.activity.recentTitle': '最近', + 'sessions.switcher.openAria': 'セッションスイッチャーを開く', + 'sessions.switcher.empty': '最近のセッションはありません', + 'sessions.switcher.draftTitle': '新しいセッション', + 'sessions.sidebar.updateCheck.errorTitle': '更新の確認に失敗しました', + 'sessions.sidebar.updateCheck.latestVersion': '最新バージョンです', + 'sessions.sidebar.directory.errorAddProjectTitle': 'プロジェクトの追加に失敗しました', + 'sessions.sidebar.directory.errorAddProjectDescription': '有効なディレクトリを選択してください。', + 'sessions.sidebar.directory.errorSelectDirectoryTitle': 'ディレクトリの選択に失敗しました', + 'sessions.sidebar.folder.newFolderName': '新しいフォルダ', + 'sessions.sidebar.bulkActions.delete': '削除', + 'sessions.sidebar.bulkActions.archive': 'アーカイブ', + 'sessions.sidebar.bulkActions.selectedCount': '{count}件選択', + 'sessions.sidebar.bulkActions.moveToFolder': 'フォルダに移動', + 'sessions.sidebar.bulkActions.deletedSingle': '{count}セッションを削除しました', + 'sessions.sidebar.bulkActions.deletedPlural': '{count}セッションを削除しました', + 'sessions.sidebar.bulkActions.failedDeleteSingle': '{count}セッションの削除に失敗しました', + 'sessions.sidebar.bulkActions.failedDeletePlural': '{count}セッションの削除に失敗しました', + 'sessions.sidebar.bulkActions.archivedSingle': '{count}セッションをアーカイブしました', + 'sessions.sidebar.bulkActions.archivedPlural': '{count}セッションをアーカイブしました', + 'sessions.sidebar.bulkActions.failedArchiveSingle': '{count}セッションのアーカイブに失敗しました', + 'sessions.sidebar.bulkActions.failedArchivePlural': '{count}セッションのアーカイブに失敗しました', + 'sessions.sidebar.folders.none': 'まだフォルダがありません', + 'sessions.sidebar.folders.newFolderEllipsis': '新しいフォルダ...', + 'sessions.sidebar.folders.removeFromFolder': 'フォルダから削除', + 'sessions.sidebar.folders.moveToFolder': 'フォルダに移動', + 'sessions.sidebar.project.actions.newWorktree': '新しいワークツリー', + 'sessions.sidebar.project.actions.newWorktreeEllipsis': '新しいワークツリー...', + 'sessions.sidebar.project.actions.projectMenu': 'プロジェクトメニュー', + 'sessions.sidebar.project.actions.newSession': '新しいセッション', + 'sessions.sidebar.project.actions.closeProject': 'プロジェクトを閉じる', + 'sessions.sidebar.project.actions.newDraftSession': '新しい下書きセッション', + 'sessions.sidebar.session.menu.rename': '名前の変更', + 'sessions.sidebar.session.rename.save': 'セッション名を保存', + 'sessions.sidebar.session.rename.cancel': 'セッション名の変更をキャンセル', + 'sessions.sidebar.session.menu.unpin': 'セッションのピン留めを解除', + 'sessions.sidebar.session.menu.pin': 'セッションをピン留め', + 'sessions.sidebar.session.menu.share': '共有', + 'sessions.sidebar.session.menu.copied': 'コピーしました', + 'sessions.sidebar.session.menu.copyLink': 'リンクをコピー', + 'sessions.sidebar.session.menu.unshare': '共有解除', + 'sessions.sidebar.session.menu.exportMarkdown': 'Markdownでエクスポート', + 'sessions.sidebar.session.menu.runFusion': 'フュージョンを実行', + 'sessions.sidebar.session.menu.openInSidePanel': 'サイドパネルで開く', + 'sessions.sidebar.session.actions.openInEditor': 'エディターで開く', + 'sessions.sidebar.session.menu.betaBadge': 'ベータ', + 'sessions.sidebar.session.menu.label': 'セッションメニュー', + 'sessions.sidebar.session.untitled': '無題のセッション', + 'sessions.sidebar.session.export.nothingToExport': 'エクスポートするものがありません', + 'sessions.sidebar.session.export.success': 'セッションをエクスポートしました', + 'sessions.sidebar.session.export.failedRevealPath': 'パスの表示に失敗しました', + 'sessions.sidebar.session.export.untitledSubagent': '無題のサブエージェント', + 'sessions.sidebar.session.export.skippedSubtaskSingle': 'セッションをエクスポートしましたが、読み込めなかった{count}件のサブエージェントタスクをスキップしました。', + 'sessions.sidebar.session.export.skippedSubtaskMany': 'セッションをエクスポートしましたが、読み込めなかった{count}件のサブエージェントタスクをスキップしました。', + 'sessions.sidebar.session.export.dialog.title': 'Markdownでエクスポート', + 'sessions.sidebar.session.export.dialog.descriptionSingle': 'このセッションには{count}件のサブエージェントタスクがあります。エクスポートに含めますか?', + 'sessions.sidebar.session.export.dialog.descriptionMany': 'このセッションには{count}件のサブエージェントタスクがあります。エクスポートに含めますか?', + 'sessions.sidebar.session.export.dialog.includeSubtasks': 'サブエージェントタスクを含める', + 'sessions.sidebar.session.export.dialog.confirm': 'エクスポート', + 'sessions.sidebar.session.status.active': 'セッションアクティブ', + 'sessions.sidebar.session.status.unread': '未読の更新', + 'sessions.sidebar.session.status.pinned': 'ピン留めされたセッション', + 'sessions.sidebar.session.status.permissionRequired': '権限が必要です', + 'sessions.sidebar.session.subsessions.collapse': 'サブセッションを折りたたむ', + 'sessions.sidebar.session.subsessions.expand': 'サブセッションを展開', + 'sessions.sidebar.dialogs.deleteSession.title': 'セッションを削除しますか?', + 'sessions.sidebar.dialogs.archiveSession.title': 'セッションをアーカイブしますか?', + 'sessions.sidebar.dialogs.deleteSession.withOneSubtask': '「{sessionTitle}」と{count}件のサブタスクは完全に削除されます。', + 'sessions.sidebar.dialogs.deleteSession.withManySubtasks': '「{sessionTitle}」と{count}件のサブタスクは完全に削除されます。', + 'sessions.sidebar.dialogs.archiveSession.withOneSubtask': '「{sessionTitle}」と{count}件のサブタスクはアーカイブされます。', + 'sessions.sidebar.dialogs.archiveSession.withManySubtasks': '「{sessionTitle}」と{count}件のサブタスクはアーカイブされます。', + 'sessions.sidebar.dialogs.deleteSession.single': '「{sessionTitle}」は完全に削除されます。', + 'sessions.sidebar.dialogs.archiveSession.single': '「{sessionTitle}」はアーカイブされます。', + 'sessions.sidebar.dialogs.neverAsk': '今後確認しない', + 'sessions.sidebar.dialogs.cancel': 'キャンセル', + 'sessions.sidebar.dialogs.deleteSession.titleAction': 'セッションを削除', + 'sessions.sidebar.dialogs.deleteSessions.titleAction': 'セッションを削除', + 'sessions.sidebar.dialogs.deleteSessions.title': 'セッションを削除しますか?', + 'sessions.sidebar.dialogs.archiveSessions.title': 'セッションをアーカイブしますか?', + 'sessions.sidebar.dialogs.deleteSessions.singleDescription': '{count}セッションが完全に削除されます。', + 'sessions.sidebar.dialogs.deleteSessions.pluralDescription': '{count}セッションが完全に削除されます。', + 'sessions.sidebar.dialogs.archiveSessions.singleDescription': '{count}セッションがアーカイブされます。', + 'sessions.sidebar.dialogs.archiveSessions.pluralDescription': '{count}セッションがアーカイブされます。', + 'sessions.sidebar.dialogs.deleteFolder.title': 'フォルダを削除しますか?', + 'sessions.sidebar.dialogs.deleteFolder.withOneSubfolder': '「{folderName}」は{count}件のサブフォルダとともに削除されます。中のセッションは削除されません。', + 'sessions.sidebar.dialogs.deleteFolder.withManySubfolders': '「{folderName}」は{count}件のサブフォルダとともに削除されます。中のセッションは削除されません。', + 'sessions.sidebar.dialogs.deleteFolder.withContentsNoSubfolders': '「{folderName}」は削除されます。中のセッションは削除されません。', + 'sessions.sidebar.dialogs.deleteFolder.single': '「{folderName}」は完全に削除されます。', + 'sessions.sidebar.dialogs.deleteResult.singleFailedToDelete': '{count}セッションを削除できませんでした。', + 'sessions.sidebar.dialogs.deleteResult.manyFailedToDelete': '{count}セッションを削除できませんでした。', + 'sessions.sidebar.dialogs.deleteResult.singleFailedToArchive': '{count}セッションをアーカイブできませんでした。', + 'sessions.sidebar.dialogs.deleteResult.manyFailedToArchive': '{count}セッションをアーカイブできませんでした。', + 'sessions.sidebar.dialogs.deleteResult.removedFromDate': '{dateLabel}からすべてのセッションを削除しました。', + 'sessions.sidebar.dialogs.deleteResult.tryAgain': 'しばらくしてからもう一度お試しください。', + 'sessions.sidebar.dialogs.worktreeDelete.descriptionNoLinked': '選択したワークツリーを削除します。', + 'sessions.sidebar.dialogs.worktreeDelete.descriptionOneLinked': '選択したワークツリーを削除し、{count}件のリンクされたセッションをアーカイブします。', + 'sessions.sidebar.dialogs.worktreeDelete.descriptionManyLinked': '選択したワークツリーを削除し、{count}件のリンクされたセッションをアーカイブします。', + 'sessions.sidebar.dialogs.sessionDelete.descriptionOne': 'この操作は1セッションを完全に削除します。', + 'sessions.sidebar.dialogs.sessionDelete.descriptionOneWithDate': 'この操作は{dateLabel}の1セッションを完全に削除します。', + 'sessions.sidebar.dialogs.sessionDelete.descriptionMany': 'この操作は{count}セッションを完全に削除します。', + 'sessions.sidebar.dialogs.sessionDelete.descriptionManyWithDate': 'この操作は{dateLabel}の{count}セッションを完全に削除します。', + 'sessions.sidebar.dialogs.sessionList.more': 'さらに{count}件', + 'sessions.sidebar.session.share.successTitle': 'セッションを共有しました', + 'sessions.sidebar.session.share.successDescription': 'メニューからリンクをコピーできます。', + 'sessions.sidebar.session.share.error': 'セッションを共有できません', + 'sessions.sidebar.session.share.copyUrlError': 'URLのコピーに失敗しました', + 'sessions.sidebar.session.unshare.success': 'セッションの共有を解除しました', + 'sessions.sidebar.session.unshare.error': 'セッションの共有を解除できません', + 'sessions.sidebar.session.delete.success': 'セッションを削除しました', + 'sessions.sidebar.session.delete.error': 'セッションの削除に失敗しました', + 'sessions.sidebar.session.archive.success': 'セッションをアーカイブしました', + 'sessions.sidebar.session.archive.error': 'セッションのアーカイブに失敗しました', + 'sessions.sidebar.group.pr.checksPassed': '{success}/{total}のチェックに合格', + 'sessions.sidebar.group.pr.failingCount': '{count}件失敗', + 'sessions.sidebar.group.pr.pendingCount': '{count}件保留中', + 'sessions.sidebar.group.pr.conflictsOrBlocked': '競合またはブロック中', + 'sessions.sidebar.group.pr.mergeable': 'マージ可能', + 'sessions.sidebar.group.pr.mergeState': 'マージ状態: {state}', + 'sessions.sidebar.group.pr.status.merged': 'マージ済み', + 'sessions.sidebar.group.pr.status.readyToMerge': 'マージ準備完了', + 'sessions.sidebar.group.pr.status.open': 'PR公開中', + 'sessions.sidebar.group.pr.status.mergeConflicts': 'マージ競合', + 'sessions.sidebar.group.pr.status.mergeBlocked': 'マージブロック中', + 'sessions.sidebar.group.pr.status.draft': '下書きPR', + 'sessions.sidebar.group.pr.status.closed': 'クローズ', + 'sessions.sidebar.group.empty.noArchivedSessions': 'まだアーカイブ済みセッションはありません。', + 'sessions.sidebar.group.empty.noSessionsInWorkspace': 'このワークスペースにはまだセッションがありません。', + 'sessions.sidebar.group.showMore': 'さらにセッションを表示', + 'sessions.sidebar.group.showMoreSingle': 'さらに{count}セッションを表示', + 'sessions.sidebar.group.showMorePlural': 'さらに{count}セッションを表示', + 'sessions.sidebar.group.showFewer': 'セッションを少なく表示', + 'sessions.sidebar.group.expandAria': '{label}を展開', + 'sessions.sidebar.group.collapseAria': '{label}を折りたたむ', + 'sessions.sidebar.group.actions.deleteArchivedInGroupAria': '{label}のアーカイブ済みセッションを削除', + 'sessions.sidebar.group.actions.deleteArchivedSessions': 'アーカイブ済みセッションを削除', + 'sessions.sidebar.group.actions.deleteGroupAria': '{label}を削除', + 'sessions.sidebar.group.actions.deleteWorktree': 'ワークツリーを削除', + 'sessions.sidebar.group.actions.newDraftInGroupAria': '{label}で新しい下書きセッション', + 'sessions.sidebar.grouping.projectRoot': 'プロジェクトルート', + 'sessions.sidebar.grouping.projectRootWithBranch': 'プロジェクトルート: {branch}', + 'sessions.sidebar.grouping.archived': 'アーカイブ済み', + 'sessions.sidebar.grouping.archivedDescription': 'アーカイブ済みおよび未割り当てのセッション', + 'sessions.sidebar.folderItem.expandAria': 'フォルダ{folderName}を展開', + 'sessions.sidebar.folderItem.collapseAria': 'フォルダ{folderName}を折りたたむ', + 'sessions.sidebar.folderItem.namePlaceholder': 'フォルダ名', + 'sessions.sidebar.folderItem.newSessionAria': '{folderName}で新しいセッション', + 'sessions.sidebar.folderItem.newSubfolderAria': '{folderName}に新しいサブフォルダ', + 'sessions.sidebar.folderItem.newSubfolder': '新しいサブフォルダ', + 'sessions.sidebar.folderItem.renameAria': 'フォルダ{folderName}の名前を変更', + 'sessions.sidebar.folderItem.deleteArchivedInFolderAria': 'フォルダ{folderName}のアーカイブ済みセッションを削除', + 'sessions.sidebar.folderItem.deleteFolderAria': 'フォルダ{folderName}を削除', + 'sessions.sidebar.folderItem.emptyFolder': '空のフォルダ', + 'sessions.sidebar.sessionDialogs.ok': 'OK', + 'sessions.sidebar.sessionDialogs.linkedSessionSingle': 'リンクされたセッション', + 'sessions.sidebar.sessionDialogs.linkedSessionPlural': 'リンクされたセッション', + 'sessions.sidebar.sessionDialogs.delete.note': 'ワークツリーディレクトリはそのまま残ります。選択したセッションにリンクされたサブセッションも削除されます。', + 'sessions.sidebar.sessionDialogs.directory.errorSelectTitle': 'ディレクトリの選択に失敗しました', + 'sessions.sidebar.sessionDialogs.directory.errorOpenTitle': 'ディレクトリを開けませんでした', + 'sessions.sidebar.sessionDialogs.directory.errorOpenDescription': 'デスクトップがファイルアクセスを許可しませんでした。', + 'sessions.sidebar.sessionDialogs.directory.errorAddProjectTitle': 'プロジェクトの追加に失敗しました', + 'sessions.sidebar.sessionDialogs.directory.errorAddProjectDescription': '有効なディレクトリパスを選択してください。', + 'sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle': 'ワークツリーの削除に失敗しました', + 'sessions.sidebar.sessionDialogs.worktree.removedTitle': 'ワークツリーを削除しました', + 'sessions.sidebar.sessionDialogs.worktree.removed': 'ワークツリーを削除しました。', + 'sessions.sidebar.sessionDialogs.worktree.removedWithRemote': 'ワークツリーとリモートブランチを削除しました。', + 'sessions.sidebar.sessionDialogs.worktree.attachedArchived': '添付のワークツリーをアーカイブしました。', + 'sessions.sidebar.sessionDialogs.worktree.attachedArchivedPlural': '添付のワークツリーをアーカイブしました。', + 'sessions.sidebar.sessionDialogs.worktree.archivedAndRemoteRemoved': 'ワークツリーをアーカイブし、リモートブランチを削除しました。', + 'sessions.sidebar.sessionDialogs.worktree.label': 'ワークツリー', + 'sessions.sidebar.sessionDialogs.worktree.pathUnavailable': 'ワークツリーパスは利用できません。', + 'sessions.sidebar.sessionDialogs.worktree.uncommittedWarning': '未コミットの変更は破棄されます。', + 'sessions.sidebar.sessionDialogs.actions.deleteRemoteBranch': 'リモートブランチを削除', + 'sessions.sidebar.sessionDialogs.actions.remoteBranchInfoUnavailable': 'リモートブランチ情報は利用できません', + 'sessions.sidebar.sessionDialogs.actions.deleteLocalBranch': 'ローカルブランチを削除', + 'sessions.sidebar.sessionDialogs.actions.deleting': '削除中…', + 'sessions.sidebar.sessionDialogs.actions.deleteWorktree': 'ワークツリーを削除', + 'gitView.branch.branchToMergeInto': '{branch}にマージするブランチ', + 'gitView.branch.branchToRebaseOnto': 'リベース先のブランチ', + 'gitView.branch.create': '新しいブランチを作成...', + 'gitView.branch.currentBadge': '現在', + 'gitView.branch.currentBranchFallback': '現在のブランチ', + 'gitView.branch.currentBranchTooltip': '現在のブランチ', + 'gitView.branch.detachedHead': '分離HEAD', + 'gitView.branch.dialogDescriptionPrefix': '別のブランチを取り込む方法を選択', + 'gitView.branch.empty': 'ブランチが見つかりません。', + 'gitView.branch.localBranches': 'ローカルブランチ', + 'gitView.branch.mergeDescription': 'マージコミットを作成しブランチ履歴を保持します。', + 'gitView.branch.mergeRebase': 'マージ/リベース', + 'gitView.branch.mergeRebaseTooltip': '別のブランチからこのブランチを更新します。', + 'gitView.branch.mergingInProgress': 'マージ進行中', + 'gitView.branch.namePlaceholder': 'ブランチ名', + 'gitView.branch.newBranchPlaceholder': '新しいブランチ名', + 'gitView.branch.noLocalBranches': 'ローカルブランチがありません', + 'gitView.branch.noRemoteBranches': 'リモートブランチがありません', + 'gitView.branch.operation': '操作', + 'gitView.branch.operationCompleted': '操作が完了しました', + 'gitView.branch.operationFailed': '操作に失敗しました', + 'gitView.branch.pushToPrefix': 'プッシュ先プレフィックス', + 'gitView.branch.pushToSuffix': 'プッシュ先サフィックス', + 'gitView.branch.rebaseDescription': '選択したブランチの上にコミットをリプレイします。', + 'gitView.branch.rebasingInProgress': 'リベース進行中', + 'gitView.branch.remoteBranches': 'リモートブランチ', + 'gitView.branch.renameTitle': 'ブランチ名を変更', + 'gitView.branch.renameSave': 'ブランチ名を保存', + 'gitView.branch.renameSaving': 'ブランチ名を保存中', + 'gitView.branch.renameCancel': 'ブランチ名の変更をキャンセル', + 'gitView.branch.searchPlaceholder': 'ブランチを検索...', + 'gitView.branch.selectBranch': 'ブランチを選択', + 'gitView.branch.summaryMergeInfix': 'に', + 'gitView.branch.summaryMergePrefix': 'これはマージします', + 'gitView.branch.summaryRebaseInfix': 'に', + 'gitView.branch.summaryRebasePrefix': 'これはリベースします', + 'gitView.branch.updateDescriptionPrefix': '最新の変更を取り込む', + 'gitView.branch.updateTitle': 'ブランチを更新', + 'gitView.changes.changedFilesAria': '変更されたファイル', + 'gitView.changes.clearSelectionAria': 'ファイル選択をクリア', + 'gitView.changes.collapseDirectoryAria': 'ディレクトリ{path}を折りたたむ', + 'gitView.changes.expandDirectoryAria': 'ディレクトリ{path}を展開', + 'gitView.changes.revertAll': 'すべて元に戻す', + 'gitView.changes.revertAllDescriptionPlural': '{count}件の変更ファイルを元に戻しますか?この操作は元に戻せません。', + 'gitView.changes.revertAllDescriptionSingle': '{count}件の変更ファイルを元に戻しますか?この操作は元に戻せません。', + 'gitView.changes.revertAllDialogTitle': 'すべての変更を元に戻しますか?', + 'gitView.changes.revertDirectoryAria': '{path}の変更を元に戻す', + 'gitView.changes.revertDirectory': 'フォルダを元に戻す', + 'gitView.changes.revertDirectoryDescriptionPlural': '{path}以下の{count}ファイルのローカル変更が破棄されます。', + 'gitView.changes.revertDirectoryDescriptionSingle': '{path}以下の{count}ファイルのローカル変更が破棄されます。', + 'gitView.changes.revertDirectoryDialogTitle': 'フォルダの変更を元に戻しますか?', + 'gitView.changes.revertDirectoryTooltip': 'フォルダの変更を元に戻す', + 'gitView.changes.revertFileAria': '{path}の変更を元に戻す', + 'gitView.changes.revertFileTooltip': '変更を元に戻す', + 'gitView.changes.reverting': '元に戻しています...', + 'gitView.changes.selectAllAria': 'すべてのファイルを選択', + 'gitView.changes.selectFileAria': '{path}を選択', + 'gitView.changes.stagedTitle': 'ステージ済み', + 'gitView.changes.resizeSplitAria': 'ステージ済みと未ステージの変更のサイズを調整', + 'gitView.changes.stageAllAria': 'すべての変更をステージ', + 'gitView.changes.stageDirectoryAria': '{path}のすべての変更をステージ', + 'gitView.changes.stageFileAria': '{path}をステージ', + 'gitView.changes.title': '変更', + 'gitView.changes.toggleDirectorySelectionAria': '{path}の選択を切り替え', + 'gitView.changes.unstageAllAria': 'すべての変更をアンステージ', + 'gitView.changes.unstageDirectoryAria': '{path}のすべての変更をアンステージ', + 'gitView.changes.unstageFileAria': '{path}をアンステージ', + 'gitView.commit.addGitmoji': 'Gitmojiを追加', + 'gitView.commit.aiHighlights.title': 'ハイライト', + 'gitView.commit.commit': 'コミット', + 'gitView.commit.committing': 'コミット中...', + 'gitView.commit.generate': '生成', + 'gitView.commit.generateAria': 'コミットメッセージを生成', + 'gitView.commit.messagePlaceholder': 'コミットメッセージ', + 'gitView.commit.push': 'コミットして同期', + 'gitView.commit.pushAria': 'コミットして同期', + 'gitView.commit.pushing': '同期中...', + 'gitView.commit.selectFilesHint': '変更画面でファイルを選択するとコミットが有効になります。', + 'gitView.commit.stageFilesHint': 'ファイルをステージするとコミットが有効になります。', + 'gitView.commit.title': 'コミット', + 'gitView.common.cancel': 'キャンセル', + 'gitView.common.close': '閉じる', + 'gitView.common.done': '完了', + 'gitView.common.processing': '処理中...', + 'gitView.common.reset': 'リセット', + 'gitView.conflict.abortOperation': '{operation}を中止', + 'gitView.conflict.conflictedFiles': '競合ファイル:', + 'gitView.conflict.continueLater': '後で続ける', + 'gitView.conflict.detectedDescription': '{operation}の競合を解決して続行してください。', + 'gitView.conflict.detectedTitle': '{operation}の競合が検出されました', + 'gitView.conflict.errorLoadingDetails': '競合の詳細の読み込みに失敗しました: {message}', + 'gitView.conflict.headInfo': 'HEAD情報:', + 'gitView.conflict.loadFailed': '競合の詳細の読み込みに失敗しました', + 'gitView.conflict.loading': '競合の詳細を読み込み中...', + 'gitView.conflict.noActiveSession': 'アクティブなセッションがありません', + 'gitView.conflict.noActiveSessionDescription': 'セッションを開くか作成して競合を解決してください。', + 'gitView.conflict.resolveCurrentSession': '現在のセッションで解決', + 'gitView.conflict.resolveNewSession': '新しいセッションで解決', + 'gitView.empty.cleanDescription': 'すべての変更がコミットされました', + 'gitView.empty.cleanTitle': 'ワーキングツリーはクリーンです', + 'gitView.empty.pullBehindPlural': '{count}コミットをプル', + 'gitView.empty.pullBehindSingle': '{count}コミットをプル', + 'gitView.header.identityTooltip': 'Git ID', + 'gitView.header.noIdentity': 'IDがありません', + 'gitView.header.noProfiles': '適用できるプロファイルがありません。', + 'gitView.header.repositoryViews': 'リポジトリビュー', + 'gitView.header.removeRemoteAria': 'リモート{name}を削除', + 'gitView.header.removeRemoteTitle': 'リモート{name}を削除', + 'gitView.header.upstreamSynced': '同期済み', + 'gitView.header.upstreamTooltip': '{target}と比較中。', + 'gitView.header.upstreamTooltipTracking': '{target}と比較中。プライマリ同期バッジは{tracking}を反映しています。', + 'gitView.history.actions.cancelButton': 'キャンセル', + 'gitView.history.actions.checkout': 'チェックアウト', + 'gitView.history.actions.checkoutConfirm': 'このコミットを分離HEADとしてチェックアウトしますか?', + 'gitView.history.actions.cherryPick': 'チェリーピック', + 'gitView.history.actions.cherryPickConfirm': 'このコミットを現在のブランチにチェリーピックしますか?', + 'gitView.history.actions.conflictToastDescription': '競合ファイル: {files}。手動で解決してコミットするか、git cherry-pick/revert --abortで中断してください。', + 'gitView.history.actions.conflictToastTitle': '競合', + 'gitView.history.actions.confirmButton': '確認', + 'gitView.history.actions.createBranch': 'ここにブランチを作成', + 'gitView.history.actions.createBranchConfirm': '作成', + 'gitView.history.actions.createBranchPlaceholder': 'ブランチ名', + 'gitView.history.actions.detachedHead': 'チェックアウトしました(分離HEAD)', + 'gitView.history.actions.merge': '現在にマージ', + 'gitView.history.actions.mergeConfirm': 'このコミットを現在のブランチにマージしますか?', + 'gitView.history.actions.rebase': 'これにリベース', + 'gitView.history.actions.rebaseConfirm': '現在のブランチをこのコミットにリベースしますか?', + 'gitView.history.actions.reset': 'リセット...', + 'gitView.history.actions.resetHard': 'ハード — すべての変更を破棄', + 'gitView.history.actions.resetHardConfirm': 'ハードリセット — HEADが移動し、未コミットの変更はすべて永久に破棄されます。', + 'gitView.history.actions.resetHardConfirmButton': '変更を破棄', + 'gitView.history.actions.resetMixed': 'ミックス — 変更をアンステージ', + 'gitView.history.actions.resetMixedConfirm': 'ミックスリセット — HEADが移動し、変更はアンステージされます。', + 'gitView.history.actions.resetSoft': 'ソフト — ステージ済みを保持', + 'gitView.history.actions.resetSoftConfirm': 'ソフトリセット — HEADが移動し、ステージ済みの変更は保持されます。', + 'gitView.history.actions.revert': '取り消し', + 'gitView.history.actions.revertConfirm': 'このコミットの取り消しをステージしますか?', + 'gitView.history.binary': 'バイナリ', + 'gitView.history.binaryNoDiff': 'バイナリファイル — 差分は利用できません', + 'gitView.history.commitsPlaceholder': 'コミット', + 'gitView.history.copySha': 'SHAをコピー', + 'gitView.history.diffError': '差分の読み込みに失敗しました。クリックして再試行。', + 'gitView.history.largeDiffDescription': 'レンダリングが遅くなる可能性があります。以下をクリックして差分を表示できます。', + 'gitView.history.largeDiffTitle': '大きな差分({count}行変更)', + 'gitView.history.loadingDiff': '差分を読み込み中...', + 'gitView.history.loadingFiles': 'ファイルを読み込み中...', + 'gitView.history.loadMore': 'さらに読み込む', + 'gitView.history.loadingMore': '読み込み中...', + 'gitView.history.logSize100': '100コミット', + 'gitView.history.logSize25': '25コミット', + 'gitView.history.logSize50': '50コミット', + 'gitView.history.noCommits': 'コミットが見つかりません', + 'gitView.history.noFiles': 'ファイルがありません', + 'gitView.history.renamedNoDiff': '名前変更されたファイル — 差分はサポートされていません', + 'gitView.history.renderDiffAnyway': 'とにかくレンダリング', + 'gitView.history.title': '履歴', + 'gitView.graph.title': 'グラフ', + 'gitView.integrate.checking': '確認中…', + 'gitView.integrate.cherryPickAbortedToast': 'チェリーピックを中止しました', + 'gitView.integrate.cherryPickConflictDescription': 'チェリーピックの競合を解決して続行してください。', + 'gitView.integrate.cherryPickConflictToast': 'チェリーピックの競合が検出されました', + 'gitView.integrate.cherryPickContinueFailedToast': 'チェリーピックの続行に失敗しました', + 'gitView.integrate.cherryPickFinishedToast': 'チェリーピックが完了しました', + 'gitView.integrate.commitsMovedDescriptionPlural': '{count}コミットを{branch}に移動しました。', + 'gitView.integrate.commitsMovedDescriptionSingle': '{count}コミットを{branch}に移動しました。', + 'gitView.integrate.commitsMovedToast': 'コミットを移動しました', + 'gitView.integrate.commitsToMove': '移動するコミット', + 'gitView.integrate.conflictsInFiles': 'ファイルの競合', + 'gitView.integrate.currentCommit': '現在のコミット', + 'gitView.integrate.currentSession': '現在のセッション', + 'gitView.integrate.failedToMoveToast': 'コミットの移動に失敗しました', + 'gitView.integrate.moreFiles': 'その他のファイル', + 'gitView.integrate.move': '移動', + 'gitView.integrate.moveCommits': 'コミットを移動', + 'gitView.integrate.moving': '移動中…', + 'gitView.integrate.newSession': '新しいセッション', + 'gitView.integrate.noActiveSession': 'アクティブなセッションがありません', + 'gitView.integrate.noActiveSessionDescription': 'セッションを開くか作成して続行してください。', + 'gitView.integrate.noCommitsToMove': '移動するコミットがありません。', + 'gitView.integrate.noCommitsToMoveToast': '移動するコミットがありません', + 'gitView.integrate.previewUnavailable': 'プレビューは利用できません', + 'gitView.integrate.showAll': 'すべて表示', + 'gitView.integrate.showLess': '表示を減らす', + 'gitView.integrate.showingFirstCommits': '最初のコミットを表示中', + 'gitView.integrate.target': 'ターゲット', + 'gitView.integrate.title': 'コミットの再統合', + 'gitView.integrate.toMoveCount': '移動数', + 'gitView.operation.abort': '中止', + 'gitView.operation.continue': '続行', + 'gitView.operation.inProgressTitle': '{operation}進行中', + 'gitView.operation.merge': 'マージ', + 'gitView.operation.mergeInProgressWithHead': '{head}をマージ中', + 'gitView.operation.mergingMessage': 'マージ中: {message}', + 'gitView.operation.rebase': 'リベース', + 'gitView.operation.rebaseInProgress': 'リベース進行中', + 'gitView.operation.rebasingOnto': '{headName}を{onto}にリベース中', + 'gitView.operation.resolveConflictsHint': '競合を解決してから操作を続行してください。', + 'gitView.operation.resolveWithAi': 'AIで解決', + 'gitView.stash.confirmButton': '{operation}', + 'gitView.stash.description': '{operation}を開始する前にローカル変更をスタッシュします。', + 'gitView.stash.mergeWith': 'に', + 'gitView.stash.rebaseOnto': 'に', + 'gitView.stash.restoreAfterOperation': '{operation}後にスタッシュした変更を復元', + 'gitView.stash.restoreAria': '操作後にスタッシュした変更を復元', + 'gitView.stash.stepRestore': 'スタッシュした変更を復元', + 'gitView.stash.stepStash': '未コミットの変更をスタッシュ', + 'gitView.stash.thisWill': '以下の操作を行います:', + 'gitView.stash.title': '未コミットの変更', + 'gitView.stashes.actions.apply': '適用', + 'gitView.stashes.actions.drop': '削除', + 'gitView.stashes.actions.pop': '取り出し', + 'gitView.stashes.actions.stashCurrent': '現在の変更をスタッシュ', + 'gitView.stashes.actions.stashCurrentWithCount': '{count}ファイルをスタッシュ', + 'gitView.stashes.confirm.drop': '{ref}を削除しますか?', + 'gitView.stashes.description': 'このリポジトリのGitスタッシュを保存、復元、クリーンアップします。', + 'gitView.stashes.empty.list': 'まだスタッシュがありません。', + 'gitView.stashes.empty.search': '一致するスタッシュがありません。', + 'gitView.stashes.includeUntrackedHint': '追跡されていないファイルは自動的に含まれます。', + 'gitView.stashes.fileCount': '{count}ファイル', + 'gitView.stashes.fileCountLoading': 'ファイル数を計算中...', + 'gitView.stashes.itemNumber': '#{number}', + 'gitView.stashes.latestLabel': '最新', + 'gitView.stashes.messagePlaceholder': 'スタッシュ名', + 'gitView.stashes.searchPlaceholder': 'スタッシュを検索', + 'gitView.stashes.title': 'スタッシュ', + 'gitView.stashes.toast.applyFailed': 'スタッシュの適用に失敗しました', + 'gitView.stashes.toast.applySuccess': 'スタッシュを適用しました', + 'gitView.stashes.toast.createFailed': '変更のスタッシュに失敗しました', + 'gitView.stashes.toast.created': '変更をスタッシュしました', + 'gitView.stashes.toast.dropFailed': 'スタッシュの削除に失敗しました', + 'gitView.stashes.toast.dropSuccess': 'スタッシュを削除しました', + 'gitView.stashes.toast.loadFailed': 'スタッシュの読み込みに失敗しました', + 'gitView.stashes.toast.noChanges': 'スタッシュするローカル変更がありません', + 'gitView.stashes.toast.popFailed': 'スタッシュの取り出しに失敗しました', + 'gitView.stashes.toast.popSuccess': 'スタッシュを取り出しました', + 'gitView.stashes.untitled': '無題のスタッシュ', + 'gitView.sync.fetch': 'フェッチ', + 'gitView.sync.fetchFromRemote': '{name}からフェッチ', + 'gitView.sync.fetchTooltip': 'リモートからフェッチ', + 'gitView.sync.commitOrStashTooltip': '同期する前に変更をコミットまたはスタッシュしてください', + 'gitView.sync.moreActionsAria': 'その他の同期アクション', + 'gitView.sync.noRemoteTooltip': 'リモートが設定されていません', + 'gitView.sync.pull': 'プル', + 'gitView.sync.pullTooltip': '変更をプル', + 'gitView.sync.pullTooltipBehind': '変更をプル({count}遅延)', + 'gitView.sync.push': 'プッシュ', + 'gitView.sync.pushTooltip': '変更をプッシュ', + 'gitView.sync.pushTooltipAhead': '変更をプッシュ({count}先行)', + 'gitView.sync.sync': '同期', + 'gitView.sync.syncChanges': '変更を同期', + 'gitView.sync.syncChangesTooltip': '変更を同期({behind}↓ {ahead}↑)', + 'gitView.sync.syncChangesWithCounts': '変更を同期 {behind}↓ {ahead}↑', + 'gitView.sync.syncCounts': '{behind}↓ {ahead}↑', + 'gitView.branch.actionsUnavailable': 'このリポジトリ状態ではブランチ操作は利用できません', + 'gitView.conflict.noDetailsAvailable': '競合の詳細はありません', + 'gitView.empty.notGitRepository': 'このディレクトリはGitリポジトリではありません', + 'gitView.empty.notGitRepositoryDescription': 'このディレクトリでGitを初期化するか、リポジトリを開いてください。', + 'gitView.empty.selectSessionOrDirectory': 'セッションまたはディレクトリを選択してGitステータスを表示', + 'gitView.empty.worktreeFeaturesUnavailable': 'このワークスペースモードではワークツリー機能は利用できません。', + 'gitView.empty.worktreeSetupDescription': 'ワークツリーのセットアップを完了し、リポジトリ状態を準備中。', + 'gitView.empty.worktreeSetupInProgress': 'ワークツリーのセットアップ進行中', + 'worktree.bootstrap.toast.failed': 'ワークツリーのセットアップに失敗しました', + 'worktree.bootstrap.toast.failedDescription': 'ワークツリーは作成されましたが、バックグラウンドセットアップが完了しませんでした。', + 'worktree.bootstrap.toast.timeoutDescription': 'ワークツリーは作成されましたが、バックグラウンドセットアップがタイムアウトしました。', + 'gitView.gitmoji.empty': 'Gitmojiが見つかりません', + 'gitView.gitmoji.searchPlaceholder': 'Gitmojiを検索...', + 'gitView.gitmoji.title': 'Gitmojiを挿入', + 'gitView.history.dialogDescription': '最近のコミットを閲覧し、変更されたファイルを確認します。', + 'gitView.loading.checkingRepository': 'リポジトリを確認中...', + 'gitView.loading.loading': '読み込み中...', + 'gitView.pr.actions.add': '追加', + 'gitView.pr.actions.cancelEditing': '編集をキャンセル', + 'gitView.pr.actions.cancelEditingAria': 'PR編集をキャンセル', + 'gitView.pr.actions.createPr': 'PRを作成', + 'gitView.pr.actions.edit': '編集', + 'gitView.pr.actions.editPr': 'PRを編集', + 'gitView.pr.actions.editPrAria': 'プルリクエストを編集', + 'gitView.pr.actions.hide': '非表示', + 'gitView.pr.actions.markReady': '準備完了にする', + 'gitView.pr.actions.markReadyAria': 'プルリクエストをレビュー準備完了にする', + 'gitView.pr.actions.mergePr': 'PRをマージ', + 'gitView.pr.actions.mergePrAria': 'プルリクエストをマージ', + 'gitView.pr.actions.openChecks': 'チェックを開く', + 'gitView.pr.actions.openChecksAria': '失敗したチェックを開く', + 'gitView.pr.actions.openComments': 'コメントを開く', + 'gitView.pr.actions.openCommentsAria': 'プルリクエストのコメントを開く', + 'gitView.pr.actions.openOnGitHub': 'GitHubで開く', + 'gitView.pr.actions.openOnGitHubAria': 'プルリクエストをGitHubで開く', + 'gitView.pr.actions.openSettings': '設定を開く', + 'gitView.pr.actions.repo': 'リポジトリ', + 'gitView.pr.actions.resolveFailedChecks': '失敗したチェックを解決', + 'gitView.pr.actions.resolveFailedChecksAria': '失敗したチェックをエージェントに送信', + 'gitView.pr.actions.savePr': 'PRを保存', + 'gitView.pr.actions.savePrAria': 'プルリクエストの変更を保存', + 'gitView.pr.actions.sendCommentToAgent': 'コメントをエージェントに送信', + 'gitView.pr.actions.sendCommentToAgentAria': 'このコメントをエージェントに送信', + 'gitView.pr.actions.sendToAgent': 'エージェントに送信', + 'gitView.pr.actions.shareComments': 'コメントを共有', + 'gitView.pr.actions.shareCommentsAria': 'プルリクエストのコメントをエージェントに送信', + 'gitView.pr.actions.toggleDraftAria': '下書き状態を切り替え', + 'gitView.pr.actions.refresh': 'PRステータスを更新', + 'gitView.pr.actions.refreshAria': 'プルリクエストのステータスを更新', + 'gitView.pr.additionalContext.added': '追加済み', + 'gitView.pr.additionalContext.hint': 'より良いレビューのために追加のコンテキストを提供します。', + 'gitView.pr.additionalContext.optional': '任意', + 'gitView.pr.additionalContext.title': '追加コンテキスト', + 'gitView.pr.checkDetails.empty': 'チェックの詳細はありません', + 'gitView.pr.checkDetails.title': '失敗したチェックの詳細', + 'gitView.pr.checkingStatus': 'プルリクエストのステータスを確認中...', + 'gitView.pr.checks.label': 'チェック', + 'gitView.pr.checks.completedLabel': '完了', + 'gitView.pr.checks.conclusionLabel': '結論', + 'gitView.pr.checks.startedLabel': '開始', + 'gitView.pr.checks.statusLabel': 'ステータス', + 'gitView.pr.checks.stepLabel': 'ステップ', + 'gitView.pr.checks.steps': 'ステップ', + 'gitView.pr.comments.empty': 'コメントが見つかりません', + 'gitView.pr.comments.generalContext': '一般コメント', + 'gitView.pr.comments.reviewContext': 'レビューコメント', + 'gitView.pr.comments.title': 'コメント', + 'gitView.pr.comments.unknownAuthor': '不明な作成者', + 'gitView.pr.createTitle': 'プルリクエストを作成', + 'gitView.pr.draftMustBeReady': 'マージ前に下書きPRを準備完了にする必要があります。', + 'gitView.pr.field.baseBranch': 'ベースブランチ', + 'gitView.pr.field.description': '説明', + 'gitView.pr.field.draft': '下書き', + 'gitView.pr.field.title': 'タイトル', + 'gitView.pr.githubNotConnected': 'GitHubが接続されていません', + 'gitView.pr.loadingDescription': '説明を読み込み中...', + 'gitView.pr.mergeMethod.merge': 'マージコミットを作成', + 'gitView.pr.mergeMethod.rebase': 'リベースしてマージ', + 'gitView.pr.mergeMethod.squash': 'スカッシュしてマージ', + 'gitView.pr.noDescription': '説明なし', + 'gitView.pr.noMergePermission': 'このプルリクエストをマージする権限がありません。', + 'gitView.pr.notMergeable': 'マージ不可', + 'gitView.pr.numberLabel': 'PR #{number}', + 'gitView.pr.placeholder.additionalContext': '制約、ロールアウトノート、レビューアーコンテキスト...', + 'gitView.pr.placeholder.description': '変更内容を説明...', + 'gitView.pr.placeholder.main': 'プルリクエストを要約', + 'gitView.pr.placeholder.selectBaseBranch': 'ベースブランチを選択', + 'gitView.pr.placeholder.title': 'プルリクエストのタイトル', + 'gitView.pr.placeholder.whatChanged': '何が変わったのか、なぜか?', + 'gitView.pr.statusUnavailable': 'プルリクエストのステータスは利用できません', + 'gitView.pr.toast.baseBranchRequired': 'ベースブランチを選択してください', + 'gitView.pr.toast.baseMustDifferFromHead': 'ベースブランチは現在のブランチと異なる必要があります', + 'gitView.pr.toast.createPrFailed': 'プルリクエストの作成に失敗しました', + 'gitView.pr.toast.generateDescriptionFailed': 'プルリクエストの説明の生成に失敗しました', + 'gitView.pr.toast.githubApiUnavailable': 'GitHub APIは利用できません', + 'gitView.pr.toast.loadCheckDetailsFailed': 'チェックの詳細の読み込みに失敗しました', + 'gitView.pr.toast.loadChecksFailed': 'プルリクエストのチェックの読み込みに失敗しました', + 'gitView.pr.toast.loadCommentsFailed': 'コメントの読み込みに失敗しました', + 'gitView.pr.toast.loadPrCommentsFailed': 'プルリクエストのコメントの読み込みに失敗しました', + 'gitView.pr.toast.markReadyFailed': 'プルリクエストの準備完了のマークに失敗しました', + 'gitView.pr.toast.markedReady': 'プルリクエストはレビュー準備完了とマークされました', + 'gitView.pr.toast.mergeFailed': 'プルリクエストのマージに失敗しました', + 'gitView.pr.toast.noActiveSession': 'アクティブなチャットセッションがありません', + 'gitView.pr.toast.noActiveSessionDescription': 'チャットセッションを開くか作成してエージェントにコンテキストを送信してください。', + 'gitView.pr.toast.noFailedChecks': '失敗したチェックはありません', + 'gitView.pr.toast.noModelSelected': '先にモデルを選択してください', + 'gitView.pr.toast.noPrComments': 'プルリクエストのコメントが見つかりません', + 'gitView.pr.toast.prCreated': 'プルリクエストを作成しました', + 'gitView.pr.toast.prMerged': 'プルリクエストをマージしました', + 'gitView.pr.toast.prNotMerged': 'プルリクエストはマージされませんでした', + 'gitView.pr.toast.prUpdated': 'プルリクエストを更新しました', + 'gitView.pr.toast.sendMessageFailed': 'エージェントへのメッセージ送信に失敗しました', + 'gitView.pr.toast.titleRequired': 'タイトルが必要です', + 'gitView.pr.toast.updatePrFailed': 'プルリクエストの更新に失敗しました', + 'gitView.pullRequest.createHint': 'このブランチからプルリクエストを作成・管理します。', + 'gitView.pullRequest.availableOnFeatureBranches': '現在のブランチがプルリクエストを開ける場合に利用可能です。', + 'gitView.pullRequest.title': 'プルリクエスト', + 'gitView.tabs.commit': 'コミット', + 'gitView.tabs.pr': 'PR', + 'gitView.tabs.update': '更新', + 'gitView.tabs.worktree': 'ワークツリー', + 'gitView.toast.abortOperationFailed': '操作の中止に失敗しました', + 'gitView.toast.appliedIdentity': 'IDを適用しました: {name}', + 'gitView.toast.applyIdentityFailed': 'IDの適用に失敗しました', + 'gitView.toast.branchCreatedLocally': 'ブランチをローカルに作成しましたが、アップストリームの設定に失敗しました。', + 'gitView.toast.cannotCheckout': 'ブランチをチェックアウトできません: {reason}', + 'gitView.toast.cannotCreateBranch': 'ブランチを作成できません: {reason}', + 'gitView.toast.cannotRemoveOriginRemote': 'originリモートは削除できません', + 'gitView.toast.cannotRenameBranch': 'ブランチ名を変更できません: {reason}', + 'gitView.toast.checkedOut': '{name}をチェックアウトしました', + 'gitView.toast.checkoutFailed': '{name}のチェックアウトに失敗しました', + 'gitView.toast.commitCreated': 'コミットを作成しました', + 'gitView.toast.commitHashCopied': 'コミットハッシュをコピーしました', + 'gitView.toast.continueOperationFailed': '操作の続行に失敗しました', + 'gitView.toast.copyFailed': 'コピーに失敗しました', + 'gitView.toast.createBranchFailed': 'ブランチの作成に失敗しました', + 'gitView.toast.createCommitFailed': 'コミットの作成に失敗しました', + 'gitView.toast.createdBranch': 'ブランチ{name}を作成しました', + 'gitView.toast.enterCommitMessage': 'コミットメッセージを入力してください', + 'gitView.toast.fetchedFromRemote': '{name}からフェッチしました', + 'gitView.toast.generateCommitMessageFailed': 'コミットメッセージの生成に失敗しました', + 'gitView.toast.mergeAborted': 'マージを中止しました', + 'gitView.toast.mergeCompleted': 'マージが完了しました', + 'gitView.toast.mergeConflictsDetected': 'マージの競合が検出されました', + 'gitView.toast.mergedIntoBranch': '{branch}を{currentBranch}にマージしました', + 'gitView.toast.pulledFilesPlural': '{count}ファイルを{name}からプルしました', + 'gitView.toast.pulledFilesSingle': '{count}ファイルを{name}からプルしました', + 'gitView.toast.pushedToUpstream': '{name}にプッシュしました', + 'gitView.toast.commitOrStashBeforeSync': '同期する前に変更をコミットまたはスタッシュしてください', + 'gitView.toast.alreadyUpToDate': 'すでに最新です', + 'gitView.toast.syncedPulledPluralAndPushed': '{count}ファイルを{name}からプルしてアップストリームにプッシュしました', + 'gitView.toast.syncedPulledSingleAndPushed': '{count}ファイルを{name}からプルしてアップストリームにプッシュしました', + 'gitView.toast.syncedChanges': '変更を同期しました', + 'gitView.toast.rebaseAborted': 'リベースを中止しました', + 'gitView.toast.rebaseConflictsDetected': 'リベースの競合が検出されました', + 'gitView.toast.rebaseStepCompleted': 'リベースのステップが完了しました', + 'gitView.toast.rebasedOntoBranch': '{currentBranch}を{branch}にリベースしました', + 'gitView.toast.refreshRepositoryFailed': 'リポジトリの更新に失敗しました', + 'gitView.toast.remoteNameRequired': 'リモート名が必要です', + 'gitView.toast.removedRemote': 'リモート{name}を削除しました', + 'gitView.toast.renameBranchFailed': '{oldName}から{newName}への名前変更に失敗しました', + 'gitView.toast.renamedBranch': '{oldName}を{newName}に名前変更しました', + 'gitView.toast.restoreStashFailed': 'スタッシュの復元に失敗しました', + 'gitView.toast.restoreStashManually': 'スタッシュの手動復元が必要です', + 'gitView.toast.revertFailed': '変更の取り消しに失敗しました', + 'gitView.toast.revertedFile': '{path}を元に戻しました', + 'gitView.toast.revertedFilesPlural': '{count}ファイルを元に戻しました', + 'gitView.toast.revertedFilesSingle': '{count}ファイルを元に戻しました', + 'gitView.toast.revertedSomePlural': '{success}ファイルを元に戻しました、{failed}は失敗', + 'gitView.toast.revertedSomeSingle': '{success}ファイルを元に戻しました、{failed}は失敗', + 'gitView.toast.stageFileFailed': '変更のステージに失敗しました', + 'gitView.toast.stageFileToCommit': 'コミットするには少なくとも1ファイルをステージしてください', + 'gitView.toast.stageFileToDescribe': '説明するには少なくとも1ファイルをステージしてください', + 'gitView.toast.selectFileToCommit': 'コミットするには少なくとも1ファイルを選択してください', + 'gitView.toast.selectFileToDescribe': '説明するには少なくとも1ファイルを選択してください', + 'gitView.toast.stashedRestored': 'スタッシュした変更を復元しました', + 'gitView.toast.syncActionFailed': '{action}に失敗しました', + 'gitView.toast.unstageFileFailed': '変更のアンステージに失敗しました', + 'gitView.toast.upstreamSet': '{branch}のアップストリームを{remote}に設定しました', + 'gitView.worktree.availableInWorktreeMode': 'ワークツリーモードでのみ利用可能', + 'contextPanel.mode.chat': 'チャット', + 'contextPanel.mode.files': 'ファイル', + 'contextPanel.mode.diff': '変更', + 'contextPanel.mode.stagedDiff': 'ステージ済み差分', + 'contextPanel.mode.workingDiff': '作業中差分', + 'contextPanel.mode.plan': '計画', + 'contextPanel.mode.context': 'コンテキスト', + 'contextPanel.mode.preview': 'プレビュー', + 'contextPanel.mode.browser': 'ブラウザ', + 'contextPanel.browser.open': 'ブラウザパネルを開く', + 'contextPanel.browser.addressAria': 'ブラウザアドレス', + 'contextPanel.browser.empty': 'ウェブブラウザ', + 'contextPanel.browser.emptyHint': '上のアドレスバーにURLを入力してウェブを閲覧', + 'contextPanel.browser.inspectUnavailable': 'このページはブラウザパネルから検査できません。', + 'contextPanel.browser.trustNotice': 'ここで開かれたページはOpenChamberへの完全なアクセス権を持ちます — 検査とスクリーンショットに必要です。信頼できるサイトのみを開いてください: 悪意のあるページがデータを読み取ったりあなたの代わりに行動したりする可能性があります。', + 'contextPanel.tab.closeTabAria': '{label}タブを閉じる', + 'contextPanel.actions.collapsePanel': 'パネルを折りたたむ', + 'contextPanel.actions.expandPanel': 'パネルを展開', + 'contextPanel.actions.closePanel': 'パネルを閉じる', + 'contextPanel.actions.resizePanelAria': 'コンテキストパネルのサイズを変更', + 'contextPanel.iframe.sessionChatTitle': 'セッションチャット {sessionID}', + 'contextPanel.preview.actions.reload': 'プレビューを再読み込み', + 'contextPanel.preview.actions.openExternal': 'ブラウザで開く', + 'contextPanel.preview.actions.retry': '再試行', + 'contextPanel.preview.iframeTitle': 'プレビュー', + 'contextPanel.preview.invalidUrl': 'プレビューには有効なhttp(s) URLが必要です。', + 'contextPanel.preview.empty': 'プレビューURLがありません', + 'contextPanel.preview.loading': 'プレビュープロキシに接続中...', + 'contextPanel.preview.proxyError': 'プレビュープロキシを開始できませんでした。', + 'contextPanel.preview.upstreamUnreachable': '開発サーバーが応答していません。', + 'contextPanel.preview.upstreamUnreachableHint': '開発サーバーがまだ実行中であることを確認してから再試行してください。', + 'contextPanel.preview.startingServer': '開発サーバーを起動中...', + 'contextPanel.preview.startingServerHint': 'サーバーが接続を受け付けるのを待っています。', + 'contextPanel.preview.title': 'プレビュー', + 'contextPanel.preview.description': 'プロジェクトアクションまたはターミナルのプレビューボタンを使用してプレビューを開きます。', + 'contextPanel.preview.startPreview': 'プレビューを開始', + 'contextPanel.preview.starting': '開始中...', + 'contextPanel.preview.noDevServer': '開発サーバーコマンドが見つかりません。プロジェクトアクションを設定するか、package.jsonに「dev」スクリプトを追加してください。', + 'contextPanel.preview.startFailed': 'プレビューサーバーの起動に失敗しました。', + 'contextPanel.preview.serverExited': '開発サーバーが予期せず終了しました。', + 'contextPanel.preview.noUrlDetected': '開発サーバーは起動しましたが、出力からURLが検出されませんでした。プレビューターミナルタブを開いてログを確認してください。', + 'contextPanel.preview.serverExitedWithLog': '開発サーバーが予期せず終了しました。最終出力:\n\n{log}', + 'contextPanel.preview.noUrlDetectedWithLog': '開発サーバーに到達できませんでした。最終出力:\n\n{log}', + 'contextPanel.preview.console.open': 'プレビューコンソールを開く', + 'contextPanel.preview.console.waiting': 'プレビューコンソールを待機中', + 'contextPanel.preview.console.title': 'プレビューコンソール', + 'contextPanel.preview.console.attach': '添付', + 'contextPanel.preview.console.copy': 'コピー', + 'contextPanel.preview.console.clear': 'クリア', + 'contextPanel.preview.console.empty': 'まだプレビューコンソールイベントはありません。', + 'contextPanel.preview.console.noFilteredEvents': 'このフィルターに一致するイベントはありません。', + 'contextPanel.preview.console.runtimeError': 'ランタイムエラー', + 'contextPanel.preview.console.copied': 'プレビューコンソールをコピーしました', + 'contextPanel.preview.console.copyFailed': 'プレビューコンソールのコピーに失敗しました', + 'contextPanel.preview.console.attached': 'プレビューコンソールをチャットに添付しました', + 'contextPanel.preview.console.attachNoSession': 'プレビューログを添付する前にチャットセッションを開いてください', + 'contextPanel.preview.console.attachAnnotation': 'これらはこのプロジェクトの開発サーバーからのブラウザコンソールログです。', + 'contextPanel.preview.inspect.toggle': 'プレビュー要素を検査', + 'contextPanel.preview.inspect.attached': 'プレビュー注釈をチャットに添付しました', + 'contextPanel.preview.inspect.attachNoSession': 'プレビュー注釈を添付する前にチャットセッションを開いてください', + 'contextPanel.preview.inspect.attachAnnotation': 'これはアプリ内プレビューから選択したDOM要素です。', + 'contextPanel.preview.inspect.attachAnnotationWithScreenshot': 'これはアプリ内プレビューから選択したDOM要素です。選択した要素をハイライトしたプレビュー領域のスクリーンショットが添付されています。', + 'contextPanel.preview.console.filter.all': 'すべて', + 'contextPanel.preview.console.filter.errors': 'エラー', + 'contextPanel.preview.console.filter.warnings': '警告', + 'contextPanel.preview.console.filter.logs': 'ログ', + 'terminalView.preview.open': 'プレビュー', + 'terminalView.preview.openTitle': 'プレビューペインを開く', + 'sidebarFilesTree.menu.rename': '名前の変更', + 'sidebarFilesTree.menu.copyPath': 'パスをコピー', + 'sidebarFilesTree.menu.save': '保存', + 'sidebarFilesTree.menu.newFile': '新しいファイル', + 'sidebarFilesTree.menu.newFolder': '新しいフォルダ', + 'sidebarFilesTree.menu.delete': '削除', + 'sidebarFilesTree.toast.pathCopied': 'パスをコピーしました', + 'sidebarFilesTree.toast.copyFailed': 'コピーに失敗しました', + 'sidebarFilesTree.toast.revealFailed': 'パスの表示に失敗しました', + 'sidebarFilesTree.toast.filenameRequired': 'ファイル名が必要です', + 'sidebarFilesTree.toast.writeNotSupported': '書き込みはサポートされていません', + 'sidebarFilesTree.toast.fileCreated': 'ファイルを作成しました', + 'sidebarFilesTree.toast.operationFailed': '操作に失敗しました', + 'sidebarFilesTree.toast.folderNameRequired': 'フォルダ名が必要です', + 'sidebarFilesTree.toast.folderCreated': 'フォルダを作成しました', + 'sidebarFilesTree.toast.nameRequired': '名前が必要です', + 'sidebarFilesTree.toast.renameNotSupported': '名前の変更はサポートされていません', + 'sidebarFilesTree.toast.renamedSuccessfully': '名前の変更に成功しました', + 'sidebarFilesTree.toast.deleteNotSupported': '削除はサポートされていません', + 'sidebarFilesTree.toast.deletedSuccessfully': '削除に成功しました', + 'sidebarFilesTree.search.placeholder': 'ファイルを検索...', + 'sidebarFilesTree.search.clearAria': '検索をクリア', + 'sidebarFilesTree.actions.newFileTitle': '新しいファイル', + 'sidebarFilesTree.actions.newFolderTitle': '新しいフォルダ', + 'sidebarFilesTree.actions.refreshTitle': '更新', + 'sidebarFilesTree.actions.fileMenuTitle': 'ファイルメニュー', + 'sidebarFilesTree.state.searching': '検索中...', + 'sidebarFilesTree.state.loading': '読み込み中...', + 'sidebarFilesTree.dialog.createFile.title': 'ファイルを作成', + 'sidebarFilesTree.dialog.createFolder.title': 'フォルダを作成', + 'sidebarFilesTree.dialog.rename.title': '名前の変更', + 'sidebarFilesTree.dialog.delete.title': '削除', + 'sidebarFilesTree.dialog.createFile.description': '{path}に新しいファイルを作成', + 'sidebarFilesTree.dialog.createFolder.description': '{path}に新しいフォルダを作成', + 'sidebarFilesTree.dialog.rename.description': '{name}の名前を変更', + 'sidebarFilesTree.dialog.delete.description': '{name}を削除してもよろしいですか?この操作は元に戻せません。', + 'sidebarFilesTree.dialog.rootFallback': 'ルート', + 'sidebarFilesTree.dialog.rename.placeholder': '新しい名前', + 'sidebarFilesTree.dialog.namePlaceholder': '名前', + 'sidebarFilesTree.dialog.cancel': 'キャンセル', + 'sidebarFilesTree.dialog.confirm': '確認', + 'sidebarFilesTree.dialog.delete.confirm': '削除', + 'filesView.dialog.createFile.title': 'ファイルを作成', + 'filesView.dialog.createFolder.title': 'フォルダを作成', + 'filesView.dialog.rename.title': '名前の変更', + 'filesView.dialog.delete.title': '削除', + 'filesView.dialog.createFile.description': '{path}に新しいファイルを作成', + 'filesView.dialog.createFolder.description': '{path}に新しいフォルダを作成', + 'filesView.dialog.rename.description': '{name}の名前を変更', + 'filesView.dialog.delete.description': '{name}を削除してもよろしいですか?この操作は元に戻せません。', + 'filesView.dialog.rootFallback': 'ルート', + 'filesView.dialog.rename.placeholder': '新しい名前', + 'filesView.dialog.namePlaceholder': '名前', + 'filesView.dialog.cancel': 'キャンセル', + 'filesView.dialog.confirm': '確認', + 'filesView.dialog.delete.confirm': '削除', + 'filesView.editor.saving': '保存中...', + 'filesView.editor.saved': '保存しました', + 'filesView.editor.autoSaveOn': '自動保存オン', + 'filesView.editor.manualSave': '手動保存', + 'filesView.editor.saveNowTitle': '今すぐ保存({shortcut})- 1.5秒後に自動保存', + 'filesView.editor.saveNowManualTitle': '今すぐ保存({shortcut})', + 'filesView.editor.saveAria': '保存({shortcut})', + 'filesView.editor.openInDesktopApp': 'デスクトップアプリで開く', + 'filesView.editor.refreshApps': 'アプリを更新', + 'filesView.editor.disableLineWrap': '行の折り返しを無効にする', + 'filesView.editor.enableLineWrap': '行の折り返しを有効にする', + 'filesView.editor.findInFile': 'ファイル内を検索', + 'filesView.editor.goToLine': '指定行に移動', + 'filesView.editor.switchToTextView': 'テキストビューに切り替え', + 'filesView.editor.switchToTreeView': 'ツリービューに切り替え', + 'filesView.toast.copyFailed': 'コピーに失敗しました', + 'filesView.toast.openInAppFailed': '{app}で開くのに失敗しました', + 'filesView.toast.savingNotSupported': '保存はサポートされていません', + 'filesView.toast.writeFileFailed': 'ファイルの書き込みに失敗しました', + 'filesView.toast.saveFailed': '保存に失敗しました', + 'filesView.editor.copyFileContents': 'ファイル内容をコピー', + 'filesView.editor.copyFilePathTitle': 'ファイルパスをコピー({path})', + 'filesView.editor.saveFile': 'ファイルを保存', + 'filesView.editor.exitFullscreen': '全画面を終了', + 'filesView.editor.fullscreen': '全画面', + 'filesView.unsaved.title': '未保存の変更', + 'filesView.unsaved.description': '続行する前に編集内容を保存しますか?', + 'filesView.unsaved.saveChanges': '変更を保存', + 'filesView.unsaved.discard': '破棄', + 'filesView.editor.back': '戻る', + 'filesView.editor.openFilesAria': '開いているファイル', + 'filesView.editor.closeFileAria': '{name}を閉じる', + 'filesView.editor.selectFile': 'ファイルを選択', + 'filesView.editor.showControlsAria': 'エディターコントロールを表示', + 'filesView.editor.controlsTitle': 'エディターコントロール', + 'filesView.editor.pickFileFromTree': 'ツリーからファイルを選択してください。', + 'filesView.state.loading': '読み込み中...', + 'filesView.state.openingFileAtChange': '変更箇所のファイルを開いています...', + 'filesView.tree.search.placeholder': 'ファイルを検索...', + 'filesView.tree.search.clearAria': '検索をクリア', + 'filesView.tree.search.searching': '検索中...', + 'filesView.tree.actions.newFileTitle': '新しいファイル', + 'filesView.tree.actions.newFolderTitle': '新しいフォルダ', + 'filesView.tree.actions.refreshTitle': '更新', + 'filesView.editor.imageAltFallback': '画像', + 'filesView.error.jsonViewerUnavailable': 'JSONビューアーは利用できません', + 'filesView.error.switchToTextMode': 'テキストモードに切り替えて生の内容を表示してください。', + 'filesView.warning.largeFilePreviewLimited': 'このファイルは大きいです({sizeKb}KB)。プレビューが制限される場合があります。', + 'filesView.error.previewUnavailable': 'プレビューは利用できません', + 'filesView.error.switchToEditMode': '編集モードに切り替えて問題を修正してください。', + 'filesView.error.readFileFailed': 'ファイルの読み込みに失敗しました', + 'filesView.editor.htmlPreviewTitle': 'HTMLプレビュー', + 'filesView.diagram.closeDiagramView': 'ダイアグラムビューを閉じる', + 'filesView.diagram.saveDiagram': 'ダイアグラムを保存', + 'contextUsage.aria.label': 'コンテキスト使用量', + 'contextUsage.mobile.title': 'コンテキスト使用量', + 'contextUsage.mobile.usedTokens': '使用トークン', + 'contextUsage.mobile.contextLimit': 'コンテキスト制限', + 'contextUsage.mobile.outputLimit': '出力制限', + 'contextUsage.mobile.usage': '使用量', + 'contextUsage.tooltip.usedTokens': '使用トークン: {tokens}', + 'contextUsage.tooltip.contextLimit': 'コンテキスト制限: {tokens}', + 'contextUsage.tooltip.outputLimit': '出力制限: {tokens}', + 'contextSidebar.session.untitled': '無題のセッション', + 'contextSidebar.empty.openSession': 'セッションを開いてコンテキストを確認します。', + 'contextSidebar.section.context': 'コンテキスト', + 'contextSidebar.section.lastAssistantMessage': '最後のアシスタントメッセージ', + 'contextSidebar.section.rawMessages': '生メッセージ', + 'contextSidebar.context.percentUsed': '{percent}%使用', + 'contextSidebar.breakdown.user': 'ユーザー', + 'contextSidebar.breakdown.assistant': 'アシスタント', + 'contextSidebar.breakdown.toolCalls': 'ツール呼び出し', + 'contextSidebar.breakdown.other': 'その他', + 'contextSidebar.stats.messages': 'メッセージ', + 'contextSidebar.stats.user': 'ユーザー', + 'contextSidebar.stats.assistant': 'アシスタント', + 'contextSidebar.stats.cost': 'コスト', + 'contextSidebar.tokens.input': '入力', + 'contextSidebar.tokens.output': '出力', + 'contextSidebar.tokens.reasoning': '推論', + 'contextSidebar.tokens.cacheRead': 'キャッシュ読み取り', + 'contextSidebar.tokens.cacheWrite': 'キャッシュ書き込み', + 'contextSidebar.tokens.cacheHit': 'キャッシュヒット', + 'contextSidebar.actions.copyJson': 'JSONをコピー', + 'contextSidebar.actions.copy': 'コピー', + 'contextSidebar.actions.copied': 'コピーしました', + 'planView.file.defaultName': '計画', + 'planView.title.default': '計画', + 'planView.error.saveFailed': '保存に失敗しました', + 'planView.error.previewUnavailable': 'プレビューは利用できません', + 'planView.error.switchToEditMode': '編集モードに切り替えて問題を修正してください。', + 'planView.error.writeFailed': '書き込みに失敗しました', + 'planView.error.writePlanFileFailed': '計画ファイルの書き込みに失敗しました({status})', + 'planView.actions.improvePlanAria': '計画を改善', + 'planView.actions.improve': '改善', + 'planView.actions.implementPlanAria': '計画を実装', + 'planView.actions.implement': '実装', + 'planView.actions.sendToNewSession': '新しいセッションに送信', + 'planView.actions.sendToNewWorktreeSession': '新しいワークツリーセッションに送信', + 'planView.actions.copyPlanContents': '計画内容をコピー', + 'planView.state.loading': '読み込み中...', + 'diffView.binary.unavailable': 'このファイルの内容は表示できません。', + 'diffView.change.untracked': '追跡されていないファイル', + 'diffView.change.new': '新しいファイル', + 'diffView.change.deleted': '削除されたファイル', + 'diffView.change.renamed': '名前変更されたファイル', + 'diffView.change.copied': 'コピーされたファイル', + 'diffView.change.modified': '変更されたファイル', + 'diffView.selector.selectFile': 'ファイルを選択', + 'diffView.image.original': '元の画像', + 'diffView.image.modified': '変更後', + 'diffView.image.new': '新しい画像', + 'diffView.image.originalAlt': '元の画像: {path}', + 'diffView.image.modifiedAlt': '変更後: {path}', + 'diffView.section.files': 'ファイル', + 'diffView.state.selectSessionDirectory': 'セッションディレクトリを選択して差分を表示', + 'diffView.state.loadingRepositoryStatus': 'リポジトリステータスを読み込み中...', + 'diffView.state.notGitRepository': 'Gitリポジトリではありません。Gitタブを使用して初期化するかディレクトリを変更してください。', + 'diffView.state.cleanWorkingTree': 'ワーキングツリーはクリーンで、表示する変更はありません', + 'diffView.state.failedToLoadDiff': '差分の読み込みに失敗しました', + 'diffView.state.loadingDiff': '差分を読み込み中...', + 'diffView.state.loadingChanges': '変更を読み込み中...', + 'diffView.state.largeDiff': '大きな差分({count}行変更)', + 'diffView.state.largeDiffDescription': 'レンダリングが遅くなる可能性があります。以下をクリックして差分を表示できます。', + 'diffView.summary.changedFilesSingle': '{count}ファイルが変更されました', + 'diffView.summary.changedFilesPlural': '{count}ファイルが変更されました', + 'diffView.scope.changed': '変更済み', + 'diffView.scope.staged': 'ステージ済み', + 'diffView.scope.selectorAria': '変更モードを選択', + 'diffView.actions.retry': '再試行', + 'diffView.actions.renderAnyway': 'とにかくレンダリング', + 'diffView.actions.expandAll': 'すべて展開', + 'diffView.actions.collapseAll': 'すべて折りたたむ', + 'diffView.actions.loadFullFiles': '全ファイルを読み込む', + 'diffView.actions.disableFullFiles': '全ファイルの読み込みを解除', + 'diffView.actions.disableLineWrap': '行の折り返しを無効にする', + 'diffView.actions.enableLineWrap': '行の折り返しを有効にする', + 'diffView.actions.openFileInEditorAtChange': '変更箇所でファイルをエディターで開く', + 'diffView.actions.openFileAtFirstChangedLine': '最初の変更行でファイルを開く', + 'diffView.actions.review': 'レビュー', + 'diffView.actions.reviewAria': '変更をレビュー', + 'diffView.reviewDialog.title': '変更をレビュー', + 'diffView.reviewDialog.description': '現在の変更に対して別のレビューセッションを開始します。', + 'diffView.reviewDialog.generateHandoff': 'ハンドオフを生成', + 'diffView.reviewDialog.info': 'このフローは変更が実装されたセッションから開始するのが最適です。', + 'diffView.reviewDialog.actions.cancel': 'キャンセル', + 'diffView.reviewDialog.actions.start': 'レビュー', + 'diffView.reviewDialog.actions.starting': '開始中...', + 'diffView.reviewDialog.autoReview': '自動レビューループを実行', + 'diffView.reviewDialog.toast.noSessionDirectory': 'セッションディレクトリは利用できません', + 'diffView.reviewDialog.toast.startFailed': 'レビューフローの開始に失敗しました', + 'diffView.hunk.label': 'ハンク', + 'diffView.hunk.stage': 'ステージ', + 'diffView.hunk.unstage': 'アンステージ', + 'diffView.hunk.discard': '破棄', + 'diffView.hunk.stageTitle': 'ハンク{index}をステージ', + 'diffView.hunk.unstageTitle': 'ハンク{index}をアンステージ', + 'diffView.hunk.discardTitle': 'ハンク{index}を破棄', + 'diffView.hunk.unavailable': 'このハンクはもう利用できません。差分を更新してからもう一度お試しください。', + 'diffView.hunk.unsupported': '個別のハンクのステージングはこのランタイムではサポートされていません。', + 'chat.autoReview.title': 'コードレビューループが実行中です', + 'chat.autoReview.status.waitingForReviewer': 'レビュアーを待機中', + 'chat.autoReview.status.waitingForImplementer': '実装者を待機中', + 'chat.autoReview.reviewSessionLabel': 'レビューセッション', + 'chat.autoReview.actions.open': '開く', + 'chat.autoReview.actions.stop': '停止', + 'rightSidebar.contextNotesTodo.plan.defaultTitle': '計画', + 'rightSidebar.contextNotesTodo.empty.selectProject': 'プロジェクトを選択してメモとTODOを追加します。', + 'rightSidebar.contextNotesTodo.notes.title': 'クイックメモ - {project}', + 'rightSidebar.contextNotesTodo.notes.placeholder': 'コンテキスト、リマインダー、リンクを記録', + 'rightSidebar.contextNotesTodo.todo.title': 'TODO', + 'rightSidebar.contextNotesTodo.todo.itemsSingle': '{count}項目', + 'rightSidebar.contextNotesTodo.todo.itemsPlural': '{count}項目', + 'rightSidebar.contextNotesTodo.todo.clearCompleted': '完了をクリア', + 'rightSidebar.contextNotesTodo.todo.inputPlaceholder': 'TODOを追加', + 'rightSidebar.contextNotesTodo.todo.addAria': 'TODOを追加', + 'rightSidebar.contextNotesTodo.todo.empty': 'まだTODOはありません。このプロジェクトのチェックリストを追加してください。', + 'rightSidebar.contextNotesTodo.todo.actions.markComplete': '「{text}」を完了にする', + 'rightSidebar.contextNotesTodo.todo.actions.collapse': 'TODO「{text}」を折りたたむ', + 'rightSidebar.contextNotesTodo.todo.actions.expand': 'TODO「{text}」を展開', + 'rightSidebar.contextNotesTodo.todo.actions.delete': '「{text}」を削除', + 'rightSidebar.contextNotesTodo.todo.actions.send': '「{text}」を送信', + 'rightSidebar.contextNotesTodo.todo.actions.reorder': '「{text}」を並び替え', + 'rightSidebar.contextNotesTodo.todo.resizeAria': 'TODOリストのサイズを変更', + 'rightSidebar.contextNotesTodo.todo.sendMenu.currentSession': '現在のセッションに送信', + 'rightSidebar.contextNotesTodo.todo.sendMenu.newSession': '新しいセッションに送信', + 'rightSidebar.contextNotesTodo.todo.sendMenu.newWorktreeSession': '新しいワークツリーセッションに送信', + 'rightSidebar.contextNotesTodo.plans.title': '計画', + 'rightSidebar.contextNotesTodo.plans.filesSingle': '{count}ファイル', + 'rightSidebar.contextNotesTodo.plans.filesPlural': '{count}ファイル', + 'rightSidebar.contextNotesTodo.plans.importFromFile': 'ファイルから計画をインポート', + 'rightSidebar.contextNotesTodo.plans.empty': 'まだ保存された計画はありません。', + 'rightSidebar.contextNotesTodo.plans.deletePlan': '計画を削除', + 'rightSidebar.contextNotesTodo.plans.deletePlanWithTitle': '計画「{title}」を削除', + 'rightSidebar.contextNotesTodo.sendDialog.title.newSession': '新しいセッションに送信', + 'rightSidebar.contextNotesTodo.sendDialog.title.newWorktree': '新しいワークツリーに送信', + 'rightSidebar.contextNotesTodo.sendDialog.variant.default': 'デフォルト', + 'rightSidebar.contextNotesTodo.sendDialog.actions.cancel': 'キャンセル', + 'rightSidebar.contextNotesTodo.sendDialog.actions.send': '送信', + 'rightSidebar.contextNotesTodo.sendDialog.actions.sending': '送信中', + 'rightSidebar.contextNotesTodo.toast.saveNotesFailed': 'プロジェクトメモの保存に失敗しました', + 'rightSidebar.contextNotesTodo.toast.loadNotesFailed': 'プロジェクトメモの読み込みに失敗しました', + 'rightSidebar.contextNotesTodo.toast.noActiveSession': 'アクティブなセッションが選択されていません', + 'rightSidebar.contextNotesTodo.toast.sentToCurrentSession': 'TODOを現在のセッションに送信しました', + 'rightSidebar.contextNotesTodo.toast.worktreeRequiresGitRepo': 'ワークツリー操作はGitリポジトリでのみ利用可能です', + 'rightSidebar.contextNotesTodo.toast.createSessionFailed': 'セッションの作成に失敗しました', + 'rightSidebar.contextNotesTodo.toast.sentToNewSession': 'TODOを新しいセッションに送信しました', + 'rightSidebar.contextNotesTodo.toast.sentToNewWorktreeSession': 'TODOを新しいワークツリーセッションに送信しました', + 'rightSidebar.contextNotesTodo.toast.sendTodoFailed': 'TODOの送信に失敗しました', + 'rightSidebar.contextNotesTodo.toast.deletePlanFailed': '計画の削除に失敗しました', + 'rightSidebar.contextNotesTodo.toast.planFileEmpty': '計画ファイルが空です', + 'rightSidebar.contextNotesTodo.toast.importPlanFailed': '計画のインポートに失敗しました', + 'rightSidebar.contextNotesTodo.toast.planImported': '計画をインポートしました', + 'rightSidebar.contextNotesTodo.toast.readPlanFileFailed': '計画ファイルの読み込みに失敗しました', + 'inlineComment.range.lines': '{start}行目~{end}行目', + 'inlineComment.input.placeholder': 'コメントを追加...(Cmd+Enterで保存)', + 'inlineComment.input.placeholderShort': 'コメントを追加...', + 'inlineComment.actions.cancel': 'キャンセル', + 'inlineComment.actions.save': '保存', + 'inlineComment.actions.comment': 'コメント', + 'inlineComment.actions.showLess': '表示を減らす', + 'inlineComment.actions.showMore': 'さらに表示', + 'inlineComment.actions.editComment': 'コメントを編集', + 'inlineComment.actions.deleteComment': 'コメントを削除', + 'inlineComment.toast.selectSessionToSave': 'コメントを保存するセッションを選択してください', + 'header.github.connectedWithLogin': 'GitHub: {login}', + 'header.github.connected': 'GitHubに接続済み', + 'header.github.avatarWithLogin': '{login}のアバター', + 'header.github.avatar': 'GitHubアバター', + 'header.github.accountsTitle': 'GitHubアカウント', + 'header.github.accountSource.oauth': 'OAuth', + 'header.github.accountSource.cli': 'CLI', + 'header.services.openWithCurrent': 'インスタンス、使用量、MCPを開く(現在: {current})', + 'header.services.open': 'サービス、使用量、MCPを開く', + 'header.services.tooltip.currentInstanceWithShortcuts': '現在のインスタンス: {current}({toggle}、次のタブ {nextTab})', + 'header.services.tooltip.servicesWithShortcuts': 'サービス({toggle}、次のタブ {nextTab})', + 'header.services.title': 'サービス', + 'header.services.viewAria': 'サービスを表示', + 'header.services.closeAria': 'サービスを閉じる', + 'header.services.rateLimits': 'レート制限', + 'header.services.refreshRateLimitsAria': 'レート制限を更新', + 'header.services.noRateLimits': 'レート制限は利用できません。', + 'header.services.noRateLimitsReported': 'レート制限は報告されていません。', + 'header.services.remoteUpdate.title': 'リモートインスタンスの更新', + 'header.services.remoteUpdate.checking': '更新を確認中...', + 'header.services.remoteUpdate.upToDate': 'このインスタンスは最新です。', + 'header.services.remoteUpdate.available': 'バージョン{version}がこのインスタンスで利用可能です。', + 'header.services.remoteUpdate.error': 'リモートインスタンスの更新確認に失敗しました', + 'header.services.remoteUpdate.actions.open': '更新', + 'header.services.used': '使用済み', + 'header.services.remaining': '残り', + 'header.services.modelFamily.other': 'その他', + 'header.services.shutdownDev': 'OpenChamberを停止', + 'header.actions.openPlanAria': '計画を開く', + 'header.actions.toggleChangesPanel': '変更パネル', + 'header.actions.toggleChangesPanelAria': '変更パネルの切り替え', + 'header.actions.planWithShortcut': '計画({shortcut})', + 'header.actions.terminalPanelWithShortcut': 'ターミナルパネル({shortcut})', + 'header.actions.toggleTerminalPanelAria': 'ターミナルパネルの切り替え', + 'terminalView.stream.processExitedMessage': '\r\n[プロセスが終了しました{exitCodeSegment}{signalSegment}]\r\n', + 'terminalView.stream.processExitedWithCode': ' コード {exitCode}', + 'terminalView.stream.processExitedWithSignal': '(シグナル {signal})', + 'terminalView.error.sessionEnded': 'ターミナルセッションが終了しました', + 'terminalView.error.connectionFailed': '接続に失敗しました: {message}', + 'terminalView.error.startSessionFailed': 'ターミナルセッションの開始に失敗しました', + 'terminalView.error.restartFailed': 'ターミナルの再起動に失敗しました', + 'terminalView.error.sendInputFailed': '入力の送信に失敗しました', + 'terminalView.empty.noWorkingDirectory': 'ターミナルに利用可能な作業ディレクトリがありません。', + 'terminalView.empty.selectSession': 'セッションを選択してターミナルを開きます。', + 'terminalView.empty.noWorkingDirectoryForSession': 'このセッションに利用可能な作業ディレクトリがありません。', + 'terminalView.actions.retry': '再試行', + 'terminalView.actions.hardRestart': 'ハード再起動', + 'terminalView.actions.hardRestartTitle': '強制終了して新しいセッションを作成', + 'terminalView.quickKeys.escape': 'Esc', + 'terminalView.quickKeys.tabAria': 'Tab', + 'terminalView.quickKeys.controlLabel': 'Ctrl', + 'terminalView.quickKeys.controlModifierAria': 'Control修飾キー', + 'terminalView.quickKeys.commandModifierAria': 'Command修飾キー', + 'terminalView.quickKeys.arrowUpAria': '上矢印', + 'terminalView.quickKeys.arrowLeftAria': '左矢印', + 'terminalView.quickKeys.arrowDownAria': '下矢印', + 'terminalView.quickKeys.arrowRightAria': '右矢印', + 'terminalView.quickKeys.enterAria': 'Enter', + 'terminalView.tabs.closeTabTitle': 'タブを閉じる', + 'terminalView.tabs.newTabTitle': '新しいタブ', + 'terminalView.bottomDock.resizeAria': 'ターミナルパネルのサイズを変更', + 'terminalView.bottomDock.restoreTitle': 'ターミナルパネルの高さを復元', + 'terminalView.bottomDock.restoreAria': 'ターミナルパネルの高さを復元', + 'terminalView.bottomDock.expandTitle': 'ターミナルパネルを展開', + 'terminalView.bottomDock.expandAria': 'ターミナルパネルを展開', + 'terminalView.bottomDock.closeTitle': 'ターミナルパネルを閉じる', + 'terminalView.bottomDock.closeAria': 'ターミナルパネルを閉じる', + 'terminalView.viewport.inputAria': 'ターミナル入力', + 'directoryExplorerDialog.title': 'プロジェクトディレクトリを追加', + 'directoryExplorerDialog.description': 'プロジェクトとして追加するフォルダを選択してください。', + 'directoryExplorerDialog.toggle.showHidden': '隠しファイルを表示', + 'directoryExplorerDialog.pathInput.placeholder': 'パスを入力するかツリーから選択...', + 'directoryExplorerDialog.actions.openingFinder': '開いています...', + 'directoryExplorerDialog.actions.openInFinder': 'Finderで開く', + 'directoryExplorerDialog.actions.adding': '追加中...', + 'directoryExplorerDialog.actions.addProject': 'プロジェクトを追加', + 'directoryExplorerDialog.actions.addLocalProject': 'ローカルプロジェクトを追加', + 'directoryExplorerDialog.actions.cloneRepository': 'リポジトリをクローン', + 'directoryExplorerDialog.actions.cloneAndAdd': 'クローンして追加', + 'directoryExplorerDialog.actions.cloning': 'クローン中...', + 'directoryExplorerDialog.actions.createAndAdd': '作成して追加', + 'directoryExplorerDialog.actions.alreadyAdded': 'すでに追加済み', + 'directoryExplorerDialog.clone.remoteUrlPlaceholder': 'リポジトリURL(HTTPSまたはSSH)', + 'directoryExplorerDialog.browse.directories': 'ディレクトリ', + 'directoryExplorerDialog.browse.loading': 'ディレクトリを読み込み中...', + 'directoryExplorerDialog.browse.empty': '一致するディレクトリがありません。', + 'directoryExplorerDialog.browse.parentDirectory': '親ディレクトリ', + 'directoryExplorerDialog.browse.addedBadge': '追加済み', + 'directoryExplorerDialog.browse.quickAdd': '追加', + 'directoryExplorerDialog.footer.navigate': '移動', + 'directoryExplorerDialog.footer.select': '選択', + 'directoryExplorerDialog.footer.add': '追加', + 'directoryExplorerDialog.shortcut.enter': 'Enter', + 'directoryExplorerDialog.toast.unableToAccessDirectory': 'ディレクトリにアクセスできません', + 'directoryExplorerDialog.toast.desktopDeniedAccess': 'デスクトップがディレクトリアクセスを拒否しました。', + 'directoryExplorerDialog.toast.failedToOpenDirectory': 'ディレクトリを開けませんでした', + 'directoryExplorerDialog.toast.desktopCouldNotGrantAccess': 'デスクトップがファイルアクセスを許可できませんでした。', + 'directoryExplorerDialog.toast.failedToAddProject': 'プロジェクトの追加に失敗しました', + 'directoryExplorerDialog.toast.cloneUrlRequired': 'クローンする前にリポジトリURLを入力してください。', + 'directoryExplorerDialog.toast.selectValidDirectoryPath': '有効なディレクトリパスを選択してください。', + 'directoryExplorerDialog.toast.failedToSelectDirectory': 'ディレクトリの選択に失敗しました', + 'directoryExplorerDialog.toast.unknownError': '不明なエラーが発生しました。', + 'directoryTree.actions.createNewDirectory': '新しいディレクトリを作成', + 'directoryTree.actions.pinDirectory': 'ディレクトリをピン留め', + 'directoryTree.actions.unpinDirectory': 'ディレクトリのピン留めを解除', + 'directoryTree.actions.createDirectory': 'ディレクトリを作成', + 'directoryTree.actions.cancel': 'キャンセル', + 'directoryTree.actions.selectWorkingDirectoryAria': '作業ディレクトリを選択', + 'directoryTree.state.locatingHomeDirectory': 'ホームディレクトリを検索中...', + 'directoryTree.state.loading': '読み込み中...', + 'directoryTree.state.noDirectoriesFound': 'ディレクトリが見つかりません', + 'directoryTree.section.pinned': 'ピン留め', + 'directoryTree.section.browse': '参照', + 'aboutDialog.versionLabel': 'バージョン {version}', + 'aboutDialog.openChamberVersionLabel': 'OpenChamber バージョン {version}', + 'aboutDialog.openCodeVersionLabel': 'OpenCode バージョン {version}', + 'aboutDialog.actions.copyDiagnostics': '診断情報をコピー', + 'aboutDialog.actions.preparingDiagnostics': '診断情報を準備中...', + 'aboutDialog.actions.diagnosticsCopied': '診断情報をコピーしました', + 'aboutDialog.diagnosticsDescription': 'OpenChamberの状態、OpenCodeの状態、ディレクトリ、プロジェクトが含まれます。', + 'aboutDialog.footerNote': 'コミュニティへの愛情を込めて', + 'aboutDialog.toast.copyFailed': 'コピーに失敗しました', + 'aboutDialog.toast.diagnosticsNotReady': '診断情報の準備ができていません。しばらく待ってから再試行してください。', + 'aboutDialog.toast.diagnosticsCopied': '診断情報をコピーしました', + 'helpDialog.title': 'キーボードショートカット', + 'helpDialog.description': 'これらのキーボードショートカットを使ってOpenChamberを効率的に操作できます', + 'helpDialog.section.navigationCommands': 'ナビゲーションとコマンド', + 'helpDialog.section.sessionManagement': 'セッション管理', + 'helpDialog.section.panels': 'パネル', + 'helpDialog.section.interface': 'インターフェース', + 'helpDialog.item.openCommandPalette': 'コマンドパレットを開く', + 'helpDialog.item.showKeyboardShortcuts': 'キーボードショートカットを表示(このダイアログ)', + 'helpDialog.item.toggleSessionSidebar': 'セッションサイドバーの切り替え', + 'helpDialog.item.cycleAgent': 'エージェント切り替え(チャット入力)', + 'helpDialog.item.openModelSelector': 'モデルセレクターを開く', + 'helpDialog.item.navigateModels': 'モデルを移動(ピッカー内)', + 'helpDialog.item.adjustThinkingMode': '思考モード調整(ピッカー内、対応時)', + 'helpDialog.item.cycleThinkingVariant': '思考バリアント切り替え(グローバルショートカット)', + 'helpDialog.item.newWindow': '新しいウィンドウ(デスクトップのみ)', + 'helpDialog.item.createNewSession': '新しいセッションを作成', + 'helpDialog.item.createNewWorktreeDraft': '新しいワークツリー下書きを作成', + 'helpDialog.item.focusChatInput': 'チャット入力にフォーカス', + 'helpDialog.item.abortActiveRun': 'アクティブな実行を中止(ダブルプレス)', + 'helpDialog.item.toggleRightSidebar': '右サイドバーの切り替え', + 'helpDialog.item.openRightSidebarGitTab': '右サイドバーのGitタブを開く', + 'helpDialog.item.openRightSidebarFilesTab': '右サイドバーのファイルタブを開く', + 'helpDialog.item.cycleRightSidebarTab': '右サイドバータブの切り替え', + 'helpDialog.item.toggleTerminalDock': 'ターミナルドックの切り替え', + 'helpDialog.item.toggleTerminalExpanded': 'ターミナル展開の切り替え', + 'helpDialog.item.togglePlanContextPanel': '計画コンテキストパネルの切り替え', + 'helpDialog.item.cycleTheme': 'テーマ切り替え(ライト→ダーク→システム)', + 'helpDialog.item.switchProject': 'プロジェクトを切り替え', + 'helpDialog.item.toggleServicesMenu': 'サービスの切り替え', + 'helpDialog.item.cycleServicesTab': 'サービス変数の切り替え', + 'helpDialog.item.openSettings': '設定を開く', + 'helpDialog.keyCombiner.or': 'または', + 'helpDialog.proTips.title': 'プロのヒント:', + 'helpDialog.proTips.commandPalette': 'コマンドパレット({shortcut})を使うとすべての操作にすばやくアクセスできます', + 'helpDialog.proTips.recentSessions': '最近の5つのセッションがコマンドパレットに表示されます', + 'helpDialog.proTips.themeCycling': 'テーマの切り替えはセッション間で設定が記憶されます', + 'header.actions.rightSidebarWithShortcut': '右サイドバー({shortcut})', + 'header.actions.toggleRightSidebarAria': '右サイドバーの切り替え', + 'header.actions.openAppMenu': 'OpenChamberメニュー', + 'header.actions.openAppMenuAria': 'OpenChamberメニューを開く', + 'header.actions.openSessionsWithShortcut': 'セッションを開く({shortcut})', + 'header.actions.openSessionsAria': 'セッションを開く', + 'header.actions.closeSessionsAria': 'セッションを閉じる', + 'header.actions.newSessionAria': '新しいセッション', + 'header.actions.newSessionWithShortcut': '新しいセッション({shortcut})', + 'header.actions.backAria': '戻る', + 'header.navigation.mainAria': 'メインナビゲーション', + 'header.sessions.title': 'セッション', + 'header.changes.availableAria': '変更が利用可能', + 'session.githubIssuePicker.error.noActiveProject': 'アクティブなプロジェクトがありません', + 'session.githubIssuePicker.error.runtimeUnavailable': 'GitHubランタイムAPIは利用できません', + 'session.githubIssuePicker.error.notConnected': 'GitHubに接続されていません', + 'session.githubIssuePicker.error.repoNotResolvable': 'リポジトリを解決できません', + 'session.githubIssuePicker.error.repoMustBeGithub': 'originリモートはGitHub URLである必要があります', + 'session.githubIssuePicker.error.issueNotFound': 'Issueが見つかりません', + 'session.githubIssuePicker.error.noModelSelected': 'モデルが選択されていません', + 'session.githubIssuePicker.toast.loadMoreFailed': 'さらにIssueを読み込めませんでした', + 'session.githubIssuePicker.toast.loadIssueDetailsFailed': 'Issueの詳細の読み込みに失敗しました', + 'session.githubIssuePicker.toast.sendContextFailed': 'Issueコンテキストの送信に失敗しました', + 'session.githubIssuePicker.toast.sessionCreated': 'Issueからセッションを作成しました', + 'session.githubIssuePicker.toast.startSessionFailed': 'セッションの開始に失敗しました', + 'session.githubIssuePicker.title.select': 'GitHub Issueをリンク', + 'session.githubIssuePicker.title.createSession': 'GitHub Issueから新しいセッション', + 'session.githubIssuePicker.description.select': 'このセッションにリンクするIssueを選択してください。', + 'session.githubIssuePicker.description.createSession': '非表示のIssueコンテキスト(タイトル/本文/ラベル/コメント)を含む新しいセッションを作成します。', + 'session.githubIssuePicker.searchPlaceholder': 'GitHubコード検索構文で検索', + 'session.githubIssuePicker.empty.noActiveProject': 'アクティブなプロジェクトが選択されていません。', + 'session.githubIssuePicker.empty.runtimeUnavailable': 'GitHubランタイムAPIは利用できません。', + 'session.githubIssuePicker.empty.notConnected': 'GitHubに接続されていません。設定でGitHubアカウントを接続してください。', + 'session.githubIssuePicker.empty.noIssuesFound': 'Issueが見つかりません', + 'session.githubIssuePicker.empty.noOpenIssuesFound': 'オープンなIssueが見つかりません', + 'session.githubIssuePicker.loading.issues': 'Issueを読み込み中...', + 'session.githubIssuePicker.loading.more': '読み込み中...', + 'session.githubIssuePicker.actions.openSettings': '設定を開く', + 'session.githubIssuePicker.actions.useIssue': 'Issue #{number}を使用', + 'session.githubIssuePicker.actions.openInGitHubAria': 'GitHubで開く', + 'session.githubIssuePicker.actions.loadMore': 'さらに読み込む', + 'session.githubIssuePicker.actions.sectionTitle': 'アクション', + 'session.githubIssuePicker.actions.toggleWorktreeAria': 'ワークツリーの切り替え', + 'session.githubIssuePicker.actions.createInWorktree': 'ワークツリーで作成', + 'session.githubIssuePicker.actions.openRepo': 'リポジトリを開く', + 'session.githubIssuePicker.actions.refresh': '更新', + 'session.githubPrPicker.error.noActiveProject': 'アクティブなプロジェクトがありません', + 'session.githubPrPicker.error.runtimeUnavailable': 'GitHubランタイムAPIは利用できません', + 'session.githubPrPicker.error.notConnected': 'GitHubに接続されていません', + 'session.githubPrPicker.error.prNotFound': 'プルリクエストが見つかりません', + 'session.githubPrPicker.error.repoNotResolvable': 'リポジトリを解決できません', + 'session.githubPrPicker.error.repoMustBeGithub': 'originリモートはGitHub URLである必要があります', + 'session.githubPrPicker.toast.loadMoreFailed': 'さらにプルリクエストを読み込めませんでした', + 'session.githubPrPicker.toast.loadDetailsFailed': 'プルリクエストの詳細の読み込みに失敗しました', + 'session.githubPrPicker.title': 'GitHubプルリクエストをリンク', + 'session.githubPrPicker.description': 'このメッセージにレビューコンテキストを添付するプルリクエストを選択してください。', + 'session.githubPrPicker.searchPlaceholder': 'GitHubコード検索構文で検索', + 'session.githubPrPicker.includeDiffAria': '添付コンテキストにPR差分を含める', + 'session.githubPrPicker.includeDiff': 'PR差分を含める', + 'session.githubPrPicker.empty.noActiveProject': 'アクティブなプロジェクトが選択されていません。', + 'session.githubPrPicker.empty.runtimeUnavailable': 'GitHubランタイムAPIは利用できません。', + 'session.githubPrPicker.empty.notConnected': 'GitHubに接続されていません。設定でGitHubアカウントを接続してください。', + 'session.githubPrPicker.empty.noPullRequestsFound': 'プルリクエストが見つかりません', + 'session.githubPrPicker.empty.noOpenPullRequestsFound': 'オープンなプルリクエストが見つかりません', + 'session.githubPrPicker.loading.pullRequests': 'プルリクエストを読み込み中...', + 'session.githubPrPicker.loading.more': '読み込み中...', + 'session.githubPrPicker.actions.openSettings': '設定を開く', + 'session.githubPrPicker.actions.usePullRequest': 'プルリクエスト #{number}を使用', + 'session.githubPrPicker.actions.openInGitHubAria': 'GitHubで開く', + 'session.githubPrPicker.actions.loadMore': 'さらに読み込む', + 'session.newWorktree.title': '新しいワークツリー', + 'session.newWorktree.mode.newBranch': '新しいブランチ', + 'session.newWorktree.mode.existingBranch': '既存のブランチ', + 'session.newWorktree.selectBranch': 'ブランチを選択', + 'session.newWorktree.chooseBranch': 'ブランチを選択...', + 'session.newWorktree.fetchBranches': 'ブランチをフェッチ', + 'session.newWorktree.searchBranches': 'ブランチを検索...', + 'session.newWorktree.loadingBranches': 'ブランチを読み込み中...', + 'session.newWorktree.noBranchesFound': 'ブランチが見つかりません', + 'session.newWorktree.matchingBranches': '一致するブランチ', + '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': '変更', + 'session.newWorktree.actions.startFromGitHubIssuePr': 'GitHub Issue/PRから開始', + 'session.newWorktree.usingPrBranch': 'PRブランチを使用: {branch}', + 'session.newWorktree.fromIssue': 'Issue #{number}: {title}から', + 'session.newWorktree.worktreeDirectory': 'ワークツリーディレクトリ', + 'session.newWorktree.worktreeDirectoryPlaceholder': 'my-worktree-directory', + 'session.newWorktree.resetToMatchBranchName': 'ブランチ名に合わせてリセット', + 'session.newWorktree.sourceBranch': 'ソースブランチ', + 'session.newWorktree.selectSourceBranch': 'ソースブランチを選択', + 'session.newWorktree.selectSourceBranchPlaceholder': 'ソースブランチを選択...', + 'session.newWorktree.newBranchFromSource': '{source}から新しいブランチが作成されます', + 'session.newWorktree.issueNumber': 'Issue #{number}', + 'session.newWorktree.prNumber': 'PR #{number}', + 'session.newWorktree.includeDiffBadge': '+diff', + 'session.newWorktree.newSessionTitle': '新しいセッション', + 'session.newWorktree.fromSource': 'from {source}', + 'session.newWorktree.actions.cancel': 'キャンセル', + 'session.newWorktree.actions.creating': '作成中...', + 'session.newWorktree.actions.createWorktree': 'ワークツリーを作成', + 'session.newWorktree.actions.reset': 'リセット', + 'session.newWorktree.error.noModelSelected': 'モデルが選択されていません', + 'session.newWorktree.error.noActiveProject': 'アクティブなプロジェクトがありません', + 'session.newWorktree.error.branchNameRequired': 'ブランチ名が必要です', + 'session.newWorktree.error.worktreeDirectoryRequired': 'ワークツリーディレクトリが必要です', + 'session.newWorktree.error.sendGitHubContextFailed': 'GitHubコンテキストの送信に失敗しました', + 'session.newWorktree.error.createWorktreeFailed': 'ワークツリーの作成に失敗しました', + 'session.newWorktree.toast.sessionFromIssue': 'Issueからセッションを作成しました', + 'session.newWorktree.toast.sessionFromPr': 'PRからセッションを作成しました', + 'session.newWorktree.toast.worktreeCreated': 'ワークツリーを作成しました', + 'session.newWorktree.toast.worktreeCreatedDescription': '{target} - バックグラウンドでブートストラップ中', + 'session.githubIntegration.title': 'GitHubから選択', + 'session.githubIntegration.tabs.issues': 'Issue', + 'session.githubIntegration.tabs.pullRequests': 'プルリクエスト', + 'session.githubIntegration.connect.title': 'GitHubに接続', + 'session.githubIntegration.connect.description': 'Issueまたはプルリクエストをリンクしてワークツリー詳細を自動入力', + 'session.githubIntegration.connect.action': 'GitHubに接続', + 'session.githubIntegration.search.issuesPlaceholder': 'GitHubコード検索構文で検索', + 'session.githubIntegration.search.prsPlaceholder': 'GitHubコード検索構文で検索', + 'session.githubIntegration.empty.noIssuesFound': 'Issueが見つかりません', + 'session.githubIntegration.empty.noPullRequestsFound': 'プルリクエストが見つかりません', + 'session.githubIntegration.actions.loadMore': 'さらに読み込む', + 'session.githubIntegration.actions.cancel': 'キャンセル', + 'session.githubIntegration.actions.select': '選択', + 'session.githubIntegration.selected.issueNumber': 'Issue #{number}', + 'session.githubIntegration.selected.prNumber': 'PR #{number}', + 'session.githubIntegration.includeDiffAria': 'セッションコンテキストにPR差分を含める', + 'session.githubIntegration.includeDiff': 'PR差分を含める', + 'session.githubIntegration.error.notConnected': 'GitHubに接続されていません', + 'session.githubIntegration.error.loadDataFailed': 'データの読み込みに失敗しました', + 'session.githubIntegration.validation.branchAlreadyCheckedOut': 'ブランチはすでにワークツリーでチェックアウトされています', + 'session.githubIntegration.validation.branchAlreadyExists': 'ブランチはすでにローカルに存在します', + 'session.githubIntegration.validation.failed': '検証に失敗しました', + 'chat.fileAttachment.toast.attachFailed': 'ファイルの添付に失敗しました', + 'chat.fileAttachment.toast.someFilesSkipped': '一部のファイルがスキップされました:\n{summary}', + 'chat.fileAttachment.toast.vscodePickFailed': 'VS Codeでのファイル選択に失敗しました', + 'chat.fileAttachment.fileFallback': 'ファイル', + 'chat.fileAttachment.skippedFallback': 'スキップ', + 'chat.fileAttachment.actions.attachAria': 'ファイルを添付', + 'chat.fileAttachment.actions.attach': 'ファイルを添付', + 'chat.fileAttachment.actions.removeNamed': '{name}を削除', + 'chat.fileAttachment.actions.removeImage': '画像を削除', + 'chat.fileAttachment.activeEditor.addFile': 'ファイル {name}をコンテキストに追加', + 'chat.fileAttachment.activeEditor.pinSelection': '選択範囲をコンテキストに固定', + 'chat.fileAttachment.activeEditor.remove': 'コンテキストから削除', + 'chat.fileAttachment.openInDiagram': 'ダイアグラムビューで開く', + 'chat.pendingChanges.fileCountSingle': '{count}ファイル', + 'chat.pendingChanges.fileCountPlural': '{count}ファイル', + 'chat.pendingChanges.changedInWorkspace': 'ワークスペースで変更', + 'chat.changedFiles.title': '変更されたファイル', + 'chat.changedFiles.actions.openFileTitle': '{path}を開く', + 'chat.emptyState.opencodeUnreachable': 'OpenCodeに接続できません', + 'chat.emptyState.startNewChat': '新しいチャットを開始', + 'chat.emptyState.draftTitle': '何に取り組みますか?', + 'chat.emptyState.draftTitleWithProject': '{project}で何に取り組みますか?', + 'chat.draftPresets.explore.label': 'コードベースを探索', + 'chat.draftPresets.catchup.label': '状況を把握', + 'chat.draftPresets.weigh.label': '選択肢を比較検討', + 'chat.draftPresets.plan.label': '機能の計画を開始', + 'chat.draftPresets.debug.label': '問題をデバッグ', + 'chat.draftPresets.review.label': '変更をレビュー', + 'chat.draftStarters.add': 'スターターを追加', + 'chat.draftStarters.searchPlaceholder': 'コマンドとスキルを検索…', + 'chat.draftStarters.empty': '追加するものがありません', + 'chat.draftStarters.sectionBuiltIn': 'ビルトイン', + 'chat.draftStarters.sectionCommands': 'コマンド', + 'chat.draftStarters.sectionSkills': 'スキル', + 'chat.draftStarters.remove': '削除', + 'chat.scrollToBottom.aria': '一番下にスクロール', + 'chat.timeline.relative.justNow': 'たった今', + 'chat.timeline.relative.minutesAgo': '{count}分前', + 'chat.timeline.relative.hoursAgo': '{count}時間前', + 'chat.timeline.relative.daysAgo': '{count}日前', + 'chat.timeline.title': '会話タイムライン', + 'chat.timeline.description': '会話の任意の時点に移動するか、新しいセッションをフォーク', + 'chat.timeline.searchPlaceholder': 'メッセージを検索...', + 'chat.timeline.empty.search': 'メッセージが見つかりません', + 'chat.timeline.empty.session': 'このセッションにはまだメッセージがありません', + 'chat.timeline.noTextContent': '[テキストなし]', + 'chat.timeline.actions.title': 'アクション', + 'chat.timeline.actions.revertFromHere': 'ここから元に戻す', + 'chat.timeline.actions.forkFromHere': 'ここからフォーク', + 'chat.timeline.actions.previousTurn': '前のターン', + 'chat.timeline.actions.latest': '最新', + 'chat.timeline.help.clickMessage': 'メッセージをクリックして会話内のその位置にスクロール', + 'chat.timeline.help.undoToPoint': 'この時点まで元に戻す(メッセージテキストが入力欄に反映されます)', + 'chat.timeline.help.createSessionFromHere': 'ここから新しいセッションを作成', + 'chat.statusRow.todo.status.inProgress': '進行中', + 'chat.statusRow.todo.status.pending': '保留中', + 'chat.statusRow.todo.status.completed': '完了', + 'chat.statusRow.todo.status.cancelled': 'キャンセル', + 'chat.statusRow.todo.priority.high': '高優先度', + 'chat.statusRow.todo.priority.medium': '中優先度', + 'chat.statusRow.todo.priority.low': '低優先度', + 'chat.statusRow.actions.stopGeneratingAria': '生成を停止', + 'chat.statusRow.tasksTitle': 'タスク', + 'chat.statusRow.summary.activeLeft': '{active}アクティブ · {left}残り', + 'chat.statusRow.aborted': '中止されました', + 'chat.revertIndicator.redo': 'やり直し', + 'chat.revertIndicator.redoAria': 'やり直し — 元に戻したメッセージを復元', + 'chat.revertPopover.title': '元に戻しました', + 'chat.revertPopover.noTextContent': 'テキスト内容なし', + 'chat.revertPopover.forkFromHere': 'ここからフォーク', + 'chat.revertPopover.forkFromMessage': 'このメッセージからフォーク', + 'chat.revertPopover.restoreAll': 'すべて復元', + 'chat.revertPopover.restore': '復元', + 'chat.revertPopover.revert': '元に戻す', + 'chat.revertPopover.fork': 'フォーク', + 'chat.revert.toast.undo': '{preview}に元に戻しました', + 'chat.revert.toast.redo': 'やり直しました', + 'chat.revert.toast.restored': 'すべてのメッセージを復元しました', + 'chat.errorBoundary.title': 'チャットエラー', + 'chat.errorBoundary.description': 'チャットインターフェースでエラーが発生しました。一時的なネットワーク問題または破損したメッセージデータが原因の可能性があります。', + 'chat.errorBoundary.sessionLabel': 'セッション', + 'chat.errorBoundary.detailsSummary': 'エラー詳細', + 'chat.errorBoundary.resetAction': 'チャットをリセット', + 'chat.errorBoundary.persistentHint': '問題が解決しない場合は、ページを更新してみてください。', + 'chat.autocomplete.tabs.commands': 'コマンド', + 'chat.autocomplete.tabs.agents': 'エージェント', + 'chat.autocomplete.tabs.files': 'ファイル', + 'chat.autocomplete.keyboardHint': '↑↓ 移動 • Enter 選択 • Esc 閉じる', + 'chat.commandAutocomplete.command.initDescription': 'AGENTS.mdファイルを作成/更新', + 'chat.commandAutocomplete.command.undoDescription': '最後のメッセージを元に戻す', + 'chat.commandAutocomplete.command.redoDescription': '元に戻したメッセージをやり直す', + 'chat.commandAutocomplete.command.timelineDescription': '会話タイムラインを開く', + 'chat.commandAutocomplete.command.compactDescription': 'AIを使用してセッション履歴を圧縮しコンテキストサイズを削減', + 'chat.commandAutocomplete.command.summaryDescription': '非破壊的なセッション要約。コマンドの後にオプションのトピックヒント。', + 'chat.commandAutocomplete.command.workspaceReviewDescription': 'ワークスペースの差分を意図、正確性、妥当性について重大度で評価。', + 'chat.commandAutocomplete.command.handoffReviewDescription': '生成されたハンドオフから別のレビューセッションを作成または再利用。', + 'chat.commandAutocomplete.command.featurePlanDescription': '新機能のガイド付き対話型計画セッションを開始。', + 'chat.commandAutocomplete.command.catchUpDescription': 'コンテキストを再確立: 何をしていたか、どこから再開するか。', + 'chat.commandAutocomplete.command.debugDescription': '修正を提案する前に、バグのガイド付き根本原因調査。', + 'chat.commandAutocomplete.command.weighDescription': 'トレードオフと推奨事項を含む2~3のアプローチを比較検討してからコミット。', + 'chat.commandAutocomplete.command.exploreDescription': 'このコードベースに慣れる: アーキテクチャと主要部分の概要ツアー。', + 'chat.commandAutocomplete.badge.skill': 'スキル', + 'chat.commandAutocomplete.badge.command': 'コマンド', + 'chat.commandAutocomplete.badge.system': 'システム', + 'chat.commandAutocomplete.empty': 'コマンドが見つかりません', + 'chat.agentMentionAutocomplete.badge.system': 'システム', + 'chat.agentMentionAutocomplete.empty': 'エージェントが見つかりません', + 'chat.fileMentionAutocomplete.searchMoreAgents': 'さらにエージェントを検索するには入力', + 'chat.fileMentionAutocomplete.empty': '一致が見つかりません', + 'chat.queuedMessage.attachments': '+{count}ファイル', + 'chat.queuedMessage.empty': '(空)', + 'chat.queuedMessage.title': 'キューされたメッセージ', + 'chat.queuedMessage.edit': '編集', + 'chat.queuedMessage.send': '送信', + 'chat.queuedMessage.removeAria': 'キューから削除', + 'chat.queuedMessage.reorderAria': 'ドラッグして並び替え', + 'chat.container.returnToParent.aria': '親セッションに戻る', + 'chat.container.returnToParent.titleNamed': '戻る: {title}', + 'chat.container.returnToParent.title': '親セッションに戻る', + 'chat.container.returnToParent.label': '親', + 'chat.container.readOnlySubagentPromptBanner': 'サブエージェントセッションはプロンプトを受け付けません。', + 'chat.unifiedControls.title': 'コントロール', + 'chat.unifiedControls.model.title': 'モデル', + 'chat.unifiedControls.model.noRecent': '最近のモデルはありません', + 'chat.unifiedControls.model.moreAria': 'その他のモデル', + 'chat.unifiedControls.effort.title': '努力', + 'chat.unifiedControls.effort.moreAria': 'その他の努力オプション', + 'chat.questionCard.summaryTab': '要約', + 'chat.questionCard.noAnswer': '(回答なし)', + 'chat.questionCard.inputNeeded': '入力が必要', + 'chat.questionCard.fromSubagent': 'サブエージェントから', + 'chat.questionCard.questionFallback': '質問{index}', + 'chat.questionCard.selectMultiple': '複数選択', + 'chat.questionCard.recommended': '推奨', + 'chat.questionCard.other': 'その他…', + 'chat.questionCard.yourAnswer': 'あなたの回答', + 'chat.questionCard.submit': '送信', + 'chat.questionCard.next': '次へ', + 'chat.questionCard.dismiss': '閉じる', + 'chat.questionCard.copyMarkdown': 'Markdownとしてコピー', + 'chat.questionCard.copyJson': 'JSONとしてコピー', + 'chat.questionCard.copiedMarkdown': '質問をMarkdownとしてコピーしました', + 'chat.questionCard.copiedJson': '質問をJSONとしてコピーしました', + 'chat.questionCard.copyFailed': '質問のコピーに失敗しました', + 'chat.questionCard.submitFailed': '応答の送信に失敗しました', + 'chat.questionCard.dismissFailed': '質問の閉じるのに失敗しました', + 'chat.questionCard.noLongerPending': 'この質問は応答待ちではなくなりました。', + 'chat.questionCard.tryAgain': 'しばらくしてからもう一度お試しください。', + 'chat.textSelection.toast.noProject': 'このセッションのプロジェクトが見つかりません', + 'chat.textSelection.toast.addToNotesFailed': 'メモへの追加に失敗しました', + 'chat.textSelection.toast.addToNotesSuccess': '選択テキストをメモに追加しました', + 'chat.textSelection.toast.addToNotesSummaryFailed': '選択範囲を要約できませんでした。選択テキストをメモに追加しました。', + 'chat.textSelection.actions.addToChat': 'チャットに追加', + 'chat.textSelection.actions.newSession': '新しいセッション', + 'chat.textSelection.actions.copy': 'コピー', + 'chat.textSelection.actions.addToNotes': 'メモに追加', + 'chat.textSelection.title.addToCurrentChat': '現在のチャットに追加', + 'chat.textSelection.title.newSessionWithSelection': '選択範囲で新しいセッションを作成', + 'chat.textSelection.title.saveInsightToNotes': '選択テキストをメモに保存', + 'chat.messageBody.actions.revertAria': 'このメッセージに戻す', + 'chat.messageBody.actions.revert': 'ここから元に戻す', + 'chat.messageBody.actions.forkAria': 'このメッセージからフォーク', + 'chat.messageBody.actions.fork': 'ここからフォーク', + 'chat.messageBody.actions.copyMessageAria': 'メッセージテキストをコピー', + 'chat.messageBody.actions.copyMessage': 'メッセージをコピー', + 'chat.messageBody.actions.openPreviewAria': 'プレビューを開く', + 'chat.messageBody.actions.openPreview': 'プレビューを開く', + 'chat.messageBody.actions.copyAnswer': '回答をコピー', + 'chat.messageBody.actions.savingImage': '画像を保存中...', + 'chat.messageBody.actions.saveAsImage': '画像として保存', + 'chat.messageBody.actions.saveAsPlan': '計画として保存', + 'chat.messageBody.actions.startNewSession': 'この回答から新しいセッションを開始', + 'chat.messageBody.actions.startNewMultiRun': 'この回答から新しいマルチランを開始', + 'chat.messageBody.forkDialog.instructions.label': '指示', + 'chat.messageBody.forkDialog.instructions.placeholder': '新しいセッションの指示を追加…', + 'chat.messageBody.forkDialog.createWorktree': 'ワークツリーを作成', + 'chat.generatedResult.actions.copy': 'コピー', + 'chat.generatedResult.actions.copied': 'コピーしました', + 'chat.generatedResult.commit.title': '生成されたコミットメッセージ', + 'chat.generatedResult.commit.highlights': 'ハイライト', + 'chat.generatedResult.pullRequest.title': '生成されたプルリクエスト', + 'chat.generatedResult.pullRequest.titleLabel': 'タイトル', + 'chat.generatedResult.pullRequest.bodyLabel': '本文', + 'chat.messageBody.tts.stopSpeaking': '読み上げを停止', + 'chat.messageBody.tts.readAloud': '読み上げ', + 'chat.messageBody.tts.readAloudWithProvider': '読み上げ({provider}音声)', + 'planView.tts.readAloud': '計画を読み上げ', + 'planView.tts.stopSpeaking': '読み上げを停止', + 'filesView.tts.readAloud': 'ファイルを読み上げ', + 'filesView.tts.stopSpeaking': '読み上げを停止', + 'chat.messageBody.toast.noProject': 'このセッションのプロジェクトが見つかりません', + 'chat.messageBody.toast.savePlanFailed': '計画の保存に失敗しました', + 'chat.messageBody.toast.planSaved': '計画を保存しました', + 'chat.messageBody.toast.imageSaved': '画像を保存しました', + 'chat.messageBody.toast.generateImageFailed': '画像の生成に失敗しました', + 'chat.chatInput.actions.commands': 'コマンド', + 'chat.chatInput.actions.attachFiles': 'ファイルを添付', + 'chat.chatInput.actions.addAttachment': '添付ファイルを追加', + 'chat.chatInput.actions.linkGithubIssue': 'GitHub Issueをリンク', + 'chat.chatInput.actions.linkGithubPr': 'GitHub PRをリンク', + 'chat.chatInput.actions.modelAgentSettings': 'モデルとエージェント設定', + 'chat.chatInput.actions.sendMessageAria': 'メッセージを送信', + 'chat.chatInput.actions.queueMessageAria': 'メッセージをキュー', + 'chat.chatInput.actions.stopGeneratingAria': '生成を停止', + 'chat.chatInput.focusMode.toggleAria': 'フォーカスモードの切り替え', + 'chat.chatInput.focusMode.label': 'フォーカスモード', + 'chat.chatInput.permissionAutoAccept.disable': '権限の自動承認を無効にする', + 'chat.chatInput.permissionAutoAccept.enable': '権限の自動承認を有効にする', + 'chat.chatInput.permissionAutoAccept.on': '権限の自動承認: オン', + 'chat.chatInput.permissionAutoAccept.off': '権限の自動承認: オフ', + 'chat.chatInput.linked.byAuthor': '{author}による', + 'chat.chatInput.linked.issue.openInBrowserAria': 'ブラウザでIssueを開く', + 'chat.chatInput.linked.issue.removeAria': 'リンクされたIssueを削除', + 'chat.chatInput.linked.pr.number': 'PR #{number}', + 'chat.chatInput.linked.pr.openInBrowserAria': 'ブラウザでプルリクエストを開く', + 'chat.chatInput.linked.pr.removeAria': 'リンクされたプルリクエストを削除', + 'chat.chatInput.placeholder.shell': 'シェルコマンドを入力...', + 'chat.chatInput.placeholder.chat': '@でファイル/エージェント、/でコマンド/スキル、!でシェル、#でスニペット', + 'chat.chatInput.placeholder.chatCompact': '@ / ! # でヘルパーを使用', + 'chat.chatInput.placeholder.selectSession': 'セッションを選択または作成してチャットを開始', + 'chat.snippetAutocomplete.action.addNew': '+ 新しいスニペットを追加', + 'chat.snippetAutocomplete.empty': 'スニペットが見つかりません', + 'chat.snippetAutocomplete.footer': '↑↓ 移動 • Enter 選択 • Esc 閉じる', + 'snippets.source.global': 'グローバル', + 'snippets.source.project': 'プロジェクト', + 'chat.chatInput.toast.compactFailed': 'セッションの圧縮に失敗しました', + 'chat.chatInput.toast.summaryFailed': '要約の生成に失敗しました', + 'chat.messageBody.actions.sendReviewFeedback': 'レビューフィードバックを実装エージェントに送信', + 'chat.messageBody.actions.sendImplementationResponse': '実装応答をレビューエージェントに送信', + 'chat.chatInput.toast.reviewFailed': '変更のレビューに失敗しました', + 'chat.chatInput.toast.planFeatureFailed': '機能計画の開始に失敗しました', + 'chat.chatInput.toast.catchUpFailed': 'キャッチアップに失敗しました', + 'chat.chatInput.toast.debugFailed': 'デバッグの開始に失敗しました', + 'chat.chatInput.toast.weighFailed': '比較検討に失敗しました', + 'chat.chatInput.toast.exploreFailed': 'ツアーの開始に失敗しました', + 'chat.chatInput.toast.attachmentsTooLarge': '添付ファイルが大きすぎて送信できません。画像の数またはサイズを減らしてください。', + 'chat.chatInput.toast.sendAttachmentsFailed': '添付ファイルの送信に失敗しました。ファイルを減らすかサイズを小さくしてください。', + 'chat.chatInput.toast.messageSendFailed': 'メッセージの送信に失敗しました。添付ファイルは復元されました。', + 'chat.chatInput.toast.clipboardAttachFailed': 'クリップボードからの画像添付に失敗しました', + 'chat.chatInput.toast.addedFileMentions': '{count}件のファイルメンションを追加しました', + 'chat.chatInput.toast.attachFileFailed': 'ファイルの添付に失敗しました', + 'gitView.commit.aiHighlights.insertAria': '挿入のariaラベル', + 'gitView.commit.aiHighlights.insertTooltip': '挿入のツールチップ', + 'gitView.commit.commitAria': 'コミットのariaラベル', + 'chat.chatInput.toast.attachNamedFailed': '{name}の添付に失敗しました', + 'chat.chatInput.toast.someFilesSkipped': '一部のファイルがスキップされました:\n{summary}', + 'chat.chatInput.toast.vscodePickFailed': 'VS Codeでのファイル選択に失敗しました', + 'chat.chatInput.toast.openSessionFirst': '先にセッションを開いてください', + 'chat.chatInput.toast.togglePermissionAutoAcceptFailed': '権限の自動承認の切り替えに失敗しました', + 'chat.chatInput.reviewComments': 'レビューコメント:', + 'chat.chatInput.reviewCommentsRemove': 'レビューコメントを削除', + 'chat.chatInput.devServerLogs': '開発サーバーログ:', + 'chat.chatInput.devServerLogsRemove': '開発サーバーログを削除', + 'chat.chatInput.previewAnnotations': 'プレビュー注釈:', + 'chat.chatInput.previewContext': 'プレビューコンテキスト:', + 'chat.chatInput.previewContextRemove': 'プレビューコンテキストを削除', + 'chat.chatInput.projectRoot': 'プロジェクトルート', + 'chat.chatInput.branch': 'ブランチ', + 'chat.chatInput.worktrees': 'ワークツリー', + 'chat.chatInput.worktreeNew': '+ 新規', + 'chat.chatInput.drop.insertMention': 'ドロップしてメンションとして挿入', + 'chat.chatInput.drop.attachFiles': 'ここにファイルをドロップして添付', + 'chat.chatInput.fileFallback': 'ファイル', + 'chat.mobileStatus.editProjects.title': 'プロジェクトを編集', + 'chat.mobileStatus.editProjects.footer': '項目をドラッグして並び替え、または矢印で移動。編集をタップして詳細を変更します。', + 'chat.mobileStatus.editProjects.empty': '編集するプロジェクトがありません', + 'chat.mobileStatus.projects.empty': 'プロジェクトがありません', + 'chat.mobileStatus.projects.addAria': 'プロジェクトを追加', + 'chat.mobileStatus.projects.removeTitle': 'プロジェクトを削除', + 'chat.mobileStatus.projects.removeDescriptionPrefix': '本当に削除しますか?', + 'chat.mobileStatus.projects.cancel': 'キャンセル', + 'chat.mobileStatus.projects.remove': '削除', + 'chat.mobileStatus.new': '新規', + 'chat.mobileStatus.noSessionsInProject': 'このプロジェクトにセッションはありません', + 'chat.mobileStatus.swipeHint': '← ここをスワイプでサイドバーを開く →', + 'chat.mobileStatus.toast.addProjectFailed': 'プロジェクトの追加に失敗しました', + 'chat.mobileStatus.toast.selectValidDirectory': '有効なディレクトリを選択してください。', + 'chat.mobileStatus.toast.selectDirectoryFailed': 'ディレクトリの選択に失敗しました', + 'chat.toolOutputDialog.image.previousAria': '前の画像', + 'chat.toolOutputDialog.image.nextAria': '次の画像', + 'chat.toolOutputDialog.image.closeAria': '画像プレビューを閉じる', + 'chat.toolOutputDialog.mermaid.missingSource': 'MermaidソースURLがありません。', + 'chat.toolOutputDialog.mermaid.loadFailed': 'Mermaidダイアグラムを読み込めません。', + 'chat.toolOutputDialog.mermaid.closeAria': 'ダイアグラムプレビューを閉じる', + 'chat.toolOutputDialog.mermaid.loading': 'ダイアグラムを読み込み中...', + 'chat.toolOutputDialog.mermaid.renderFailed': 'Mermaidダイアグラムをレンダリングできません。', + 'chat.toolOutputDialog.mermaid.retry': '再試行', + 'chat.toolOutputDialog.commandCompleted': 'コマンドが正常に完了しました', + 'chat.toolOutputDialog.noOutputProduced': '出力は生成されませんでした', + 'chat.toolPart.lspErrors': 'LSPエラー', + 'chat.toolPart.moreErrors': '+{count}件のエラー', + 'chat.toolPart.error': 'エラー:', + 'chat.toolPart.awaitingResponse': '応答を待機中...', + 'chat.toolPart.noOutputProduced': '出力は生成されませんでした', + 'chat.toolPart.output': '出力', + 'chat.toolPart.showRawJson': '生JSONを表示', + 'chat.toolPart.showFormattedJson': '整形JSONを表示', + 'chat.toolPart.copyOutput': '出力をコピー', + 'chat.toolPart.copiedOutput': '出力をコピーしました', + 'chat.toolPart.copyOutputFailed': '出力のコピーに失敗しました', + 'chat.toolPart.openSubtask': '{type}サブタスクを開く', + 'chat.todo.total': '合計', + 'chat.todo.inProgress': '進行中', + 'chat.todo.pending': '保留中', + 'chat.todo.completed': '完了', + 'chat.todo.cancelled': 'キャンセル', + 'chat.permissionCard.workingDirectory': '作業ディレクトリ:', + 'chat.permissionCard.timeout': 'タイムアウト:', + 'chat.permissionCard.request': 'リクエスト:', + 'chat.permissionCard.headers': 'ヘッダー:', + 'chat.permissionCard.body': '本文:', + 'chat.permissionCard.action': 'アクション:', + 'chat.permissionCard.details': '詳細:', + 'chat.permissionCard.patterns': 'パターン:', + 'chat.permissionToast.sessionFallback': 'セッション', + 'chat.permissionToast.permissionFallback': '権限の詳細は利用できません', + 'chat.permissionToast.labels.session': 'セッション:', + 'chat.permissionToast.labels.permission': '権限:', + 'chat.permissionToast.actions.once': '1回のみ', + 'chat.permissionToast.actions.always': '常に', + 'chat.permissionToast.actions.deny': '拒否', + 'chat.permissionToast.actions.approveOnceAria': '1回のみ承認', + 'chat.permissionToast.actions.approveOnceAriaWithSession': '{session}で1回のみ承認', + 'chat.permissionToast.actions.approveAlwaysAria': '常に承認', + 'chat.permissionToast.actions.approveAlwaysAriaWithSession': '{session}で常に承認', + 'chat.permissionToast.actions.denyAria': '権限を拒否', + 'chat.permissionToast.actions.denyAriaWithSession': '{session}の権限を拒否', + 'chat.permissionRequest.required': '権限が必要:', + 'chat.permissionRequest.actions.once': '1回のみ', + 'chat.permissionRequest.actions.always': '常に', + 'chat.permissionRequest.actions.reject': '拒否', + 'chat.modelControls.provider': 'プロバイダー', + 'chat.modelControls.capability.toolCalling': 'ツール呼び出し', + 'chat.modelControls.capability.reasoning': '推論', + 'chat.modelControls.modality.text': 'テキスト', + 'chat.modelControls.modality.image': '画像', + 'chat.modelControls.modality.video': '動画', + 'chat.modelControls.modality.audio': '音声', + 'chat.modelControls.modality.pdf': 'PDF', + 'chat.modelControls.capabilities': '機能', + 'chat.modelControls.modalities': 'モダリティ', + 'chat.modelControls.input': '入力', + 'chat.modelControls.output': '出力', + 'chat.modelControls.limits': '制限', + 'chat.modelControls.context': 'コンテキスト', + 'chat.modelControls.metadata': 'メタデータ', + 'chat.modelControls.knowledge': '知識', + 'chat.modelControls.release': 'リリース', + 'chat.modelControls.mode': 'モード', + 'chat.modelControls.model': 'モデル', + 'chat.modelControls.temperature': '温度', + 'chat.modelControls.topP': 'Top P', + 'chat.modelControls.permissions': '権限', + 'chat.modelControls.edit': '編集', + 'chat.modelControls.bash': 'Bash', + 'chat.modelControls.webFetch': 'WebFetch', + 'chat.modelControls.customPrompt': 'カスタムプロンプト', + 'chat.modelControls.selectModel': 'モデルを選択', + 'chat.modelControls.searchProvidersOrModels': 'プロバイダーまたはモデルを検索', + 'chat.modelControls.clearSearch': '検索をクリア', + 'chat.modelControls.current': '現在', + 'chat.modelControls.thinking': '思考', + 'chat.modelControls.default': 'デフォルト', + 'chat.modelControls.selectAgent': 'エージェントを選択', + 'chat.modelControls.costPerMillion': 'コスト($/100万トークン)', + 'chat.modelControls.metadataUnavailable': 'モデルのメタデータは利用できません。', + 'chat.modelControls.addNewProvider': '新しいプロバイダーを追加', + 'chat.modelControls.noAgentSelected': 'エージェントが選択されていません。', + 'chat.modelControls.resetToDefault': 'デフォルトにリセット', + 'chat.modelControls.searchModels': 'モデルを検索', + 'chat.modelControls.searchAgents': 'エージェントを検索', + 'chat.modelControls.noModelsFound': 'モデルが見つかりません', + 'chat.modelControls.favorites': 'お気に入り', + 'chat.modelControls.recent': '最近', + 'chat.modelControls.addToFavorites': 'お気に入りに追加', + 'chat.modelControls.removeFromFavorites': 'お気に入りから削除', + 'chat.modelControls.favoriteAria': 'お気に入り', + 'chat.modelControls.unfavoriteAria': 'お気に入り解除', + 'chat.modelControls.collapseProvider': 'プロバイダーを折りたたむ', + 'chat.modelControls.expandProvider': 'プロバイダーを展開', + 'chat.modelControls.keyboardHint': '↑↓ 移動{thinking} • Enter 選択 • Esc 閉じる', + 'chat.modelControls.keyboardHintNavigate': '↑↓ 移動', + 'chat.modelControls.keyboardHintSwitchAgent': '{shortcut} エージェント切り替え', + 'chat.modelControls.keyboardHintThinking': '←→ 思考', + 'chat.modelControls.showThinkingModes': '思考モードを表示', + 'chat.modelControls.hideThinkingModes': '思考モードを非表示', + 'chat.modelControls.moreThinkingModes': 'その他の思考モード', + 'chat.modelControls.noProvidersOrModelsFound': '検索に一致するプロバイダーまたはモデルがありません。', + 'chat.modelControls.reorderFavoriteAria': 'お気に入りを並び替え', + 'chat.modelControls.reorderFavoriteTitle': 'ドラッグしてお気に入りを並び替え', + 'chat.modelControls.reorderProviderTitle': 'ドラッグしてプロバイダーを並び替え', + 'chat.modelControls.permissionLabel.custom': 'カスタム', + 'chat.modelControls.permissionLabel.allow': '許可', + 'chat.modelControls.permissionLabel.deny': '拒否', + 'chat.modelControls.permissionLabel.ask': '確認', + 'chat.modelControls.modeValue.primary': 'プライマリ', + 'chat.modelControls.modeValue.subagent': 'サブエージェント', + 'chat.modelControls.modeValue.all': 'すべて', + 'chat.modelControls.modeValue.none': '—', + 'chat.reasoningTrace.thinking': '思考', + 'chat.reasoningTrace.justification': '正当化', + 'chat.reasoningTrace.expandAria': '推論トレースを展開', + 'chat.reasoningTrace.collapseAria': '推論トレースを折りたたむ', + 'chat.reasoningTrace.thought': '思考', + 'chat.messageBody.subtask.title': '委任タスク', + 'chat.messageBody.subtask.hidePrompt': 'プロンプトを非表示', + 'chat.messageBody.subtask.showPrompt': 'プロンプトを表示', + 'chat.messageBody.subtask.openSession': 'サブタスクセッションを開く', + 'chat.messageBody.shellCommand.title': 'シェルコマンド', + 'chat.messageBody.shellCommand.hideOutput': '出力を非表示', + 'chat.messageBody.shellCommand.showOutput': '出力を表示', + 'chat.messageBody.shellCommand.copied': 'コピーしました', + 'chat.messageBody.shellCommand.copyOutput': '出力をコピー', + 'commandPalette.title': 'コマンドパレット', + 'commandPalette.description': 'ファイル、セッション、コマンドを検索します。', + 'commandPalette.input.placeholder': 'ファイル、セッション、コマンドを検索...', + 'commandPalette.empty.noResults': '結果が見つかりません。', + 'commandPalette.empty.searchingFiles': 'ファイルを検索中...', + 'commandPalette.item.newSession': '新しいセッション', + 'commandPalette.item.newMiniChat': '新しいMini Chatウィンドウ', + 'commandPalette.item.newWorktreeDraft': '新しいワークツリー下書き', + 'commandPalette.item.addProject': 'プロジェクトを追加', + 'commandPalette.item.showSessionSwitcher': 'セッションスイッチャーを表示', + 'commandPalette.item.toggleSidebar': 'サイドバーの切り替え', + 'commandPalette.item.toggleRightSidebar': '右サイドバーの切り替え', + 'commandPalette.item.showContextUsage': 'コンテキスト使用量を表示', + 'commandPalette.item.toggleTerminal': 'ターミナルの切り替え', + 'commandPalette.item.openSettings': '設定を開く...', + 'commandPalette.session.untitled': '無題のセッション', + 'openCodeStatusDialog.title': 'OpenCodeステータス', + 'openCodeStatusDialog.description': '現在のOpenCodeステータスと診断情報を確認します。', + 'openCodeStatusDialog.actions.copy': 'コピー', + 'openCodeStatusDialog.empty.noData': 'データがありません。', + 'openCodeStatusDialog.toast.copiedTitle': 'コピーしました', + 'openCodeStatusDialog.toast.copiedDescription': 'ステータスをクリップボードにコピーしました', + 'openCodeStatusDialog.toast.copyFailed': 'ステータスのコピーに失敗しました', + 'saveProjectPlanDialog.title': 'プロジェクト計画を保存', + 'saveProjectPlanDialog.description': 'この計画をMarkdownファイルとしてプロジェクトに保存します。', + 'saveProjectPlanDialog.field.title': 'タイトル', + 'saveProjectPlanDialog.field.titlePlaceholder': '計画タイトル', + 'saveProjectPlanDialog.field.contentPreview': '内容プレビュー', + 'saveProjectPlanDialog.actions.cancel': 'キャンセル', + 'saveProjectPlanDialog.actions.save': '保存', + 'saveProjectPlanDialog.actions.saving': '保存中...', + 'branchPickerDialog.title': 'ブランチピッカー', + 'branchPickerDialog.description.localBranchesForProject': '{project}のローカルブランチ', + 'branchPickerDialog.description.selectProject': 'プロジェクトを選択してブランチを表示', + 'branchPickerDialog.search.placeholder': 'ブランチを検索...', + 'branchPickerDialog.search.renameBranchPlaceholder': 'ブランチ名を変更', + 'branchPickerDialog.state.noProjectSelected': 'プロジェクトが選択されていません', + 'branchPickerDialog.state.loadingBranches': 'ブランチを読み込み中...', + 'branchPickerDialog.state.noMatchingBranches': '一致するブランチがありません', + 'branchPickerDialog.state.noBranchesFound': 'ブランチが見つかりません', + 'branchPickerDialog.badge.head': 'HEAD', + 'branchPickerDialog.badge.worktree': 'ワークツリー', + 'branchPickerDialog.actions.createWorktreeAria': 'ブランチからワークツリーを作成', + 'branchPickerDialog.actions.renameAria': 'ブランチ名を変更', + 'branchPickerDialog.actions.deleteAria': 'ブランチを削除', + 'branchPickerDialog.actions.deleteWorktreeAria': 'ワークツリーを削除', + 'branchPickerDialog.actions.confirmRenameAria': '名前変更を確認', + 'branchPickerDialog.actions.cancelRenameAria': '名前変更をキャンセル', + 'branchPickerDialog.actions.confirmDeleteAria': 'ブランチ削除を確認', + 'branchPickerDialog.actions.cancelDeleteAria': 'ブランチ削除をキャンセル', + 'branchPickerDialog.actions.deletePrompt': '削除', + 'branchPickerDialog.actions.forceDeletePrompt': '強制削除', + 'branchPickerDialog.tooltip.createWorktree': 'ワークツリーを作成', + 'branchPickerDialog.tooltip.worktreeAlreadyExists': 'ワークツリーはすでに存在します', + 'branchPickerDialog.tooltip.rename': 'ブランチ名を変更', + 'branchPickerDialog.tooltip.renameDisabledForRoot': 'ルートブランチの名前は変更できません', + 'branchPickerDialog.tooltip.delete': 'ブランチを削除', + 'branchPickerDialog.tooltip.deleteCurrentBranch': '現在のブランチは削除できません', + 'branchPickerDialog.tooltip.deleteDisabledForRoot': 'ルートブランチは削除できません', + 'branchPickerDialog.tooltip.deleteWorktree': 'ワークツリーを削除', + 'branchPickerDialog.tooltip.deleteWorktreeRootProtected': 'ルートワークツリーは削除できません', + 'branchPickerDialog.toast.branchRenamed': 'ブランチ名を変更しました', + 'branchPickerDialog.toast.failedToRenameBranch': 'ブランチ名の変更に失敗しました', + 'branchPickerDialog.toast.branchDeleted': 'ブランチを削除しました', + 'branchPickerDialog.toast.branchNotMerged': 'ブランチが完全にマージされていません', + 'branchPickerDialog.toast.confirmAgainToForceDelete': 'もう一度確認して強制削除', + 'branchPickerDialog.toast.failedToDeleteBranch': 'ブランチの削除に失敗しました', + 'branchPickerDialog.toast.worktreeCreated': 'ワークツリーを作成しました', + 'branchPickerDialog.toast.failedToCreateWorktree': 'ワークツリーの作成に失敗しました', + 'branchPickerDialog.error.failedToLoad': 'ブランチの読み込みに失敗しました', + 'branchPickerDialog.error.renameRejected': '名前変更が拒否されました', + 'branchPickerDialog.error.renameFailed': 'ブランチ名の変更に失敗しました', + 'branchPickerDialog.error.deleteRejected': '削除が拒否されました', + 'branchPickerDialog.error.deleteFailed': 'ブランチの削除に失敗しました', + 'branchPickerDialog.error.createWorktreeFailed': 'ワークツリーの作成に失敗しました', + 'projectEditDialog.title': 'プロジェクトを編集', + 'projectEditDialog.field.name': '名前', + 'projectEditDialog.field.namePlaceholder': 'プロジェクト名', + 'projectEditDialog.field.color': '色', + 'projectEditDialog.field.icon': 'アイコン', + 'projectEditDialog.field.preview': 'プレビュー', + 'projectEditDialog.field.iconBackground': 'アイコン背景', + 'projectEditDialog.field.iconBackgroundAria': 'プロジェクトアイコンの背景色', + 'projectEditDialog.option.none': 'なし', + 'projectEditDialog.actions.uploadIcon': 'アイコンをアップロード', + 'projectEditDialog.actions.uploading': 'アップロード中...', + 'projectEditDialog.actions.discoverFavicon': 'Faviconを検出', + 'projectEditDialog.actions.discovering': '検出中...', + 'projectEditDialog.actions.removeProjectIcon': 'プロジェクトアイコンを削除', + 'projectEditDialog.actions.removing': '削除中...', + 'projectEditDialog.actions.undoRemove': '削除を元に戻す', + 'projectEditDialog.actions.clear': 'クリア', + 'projectEditDialog.actions.cancel': 'キャンセル', + 'projectEditDialog.actions.save': '保存', + 'projectEditDialog.toast.failedToUploadIcon': 'プロジェクトアイコンのアップロードに失敗しました', + 'projectEditDialog.toast.iconUpdated': 'プロジェクトアイコンを更新しました', + 'projectEditDialog.toast.failedToRemoveIcon': 'プロジェクトアイコンの削除に失敗しました', + 'projectEditDialog.toast.iconRemoved': 'プロジェクトアイコンを削除しました', + 'projectEditDialog.toast.failedToDiscoverIcon': 'プロジェクトアイコンの検出に失敗しました', + 'projectEditDialog.toast.customIconAlreadySet': 'このプロジェクトにはカスタムアイコンがすでに設定されています', + 'projectEditDialog.toast.iconDiscovered': 'プロジェクトアイコンを検出しました', + 'agentManager.sidebar.search.placeholder': 'エージェントグループを検索...', + 'agentManager.sidebar.actions.newAgentGroup': '新規エージェントグループ', + 'agentManager.sidebar.actions.more': '... もっと見る({count})', + 'agentManager.sidebar.actions.showLess': '表示を減らす', + 'agentManager.sidebar.section.agentGroups': 'エージェントグループ', + 'agentManager.sidebar.state.loading': '読み込み中...', + 'agentManager.sidebar.state.noGroupsFound': 'グループが見つかりません', + 'agentManager.sidebar.state.noGroupsYet': 'まだエージェントグループがありません', + 'agentManager.sidebar.state.createToGetStarted': '新しいエージェントグループを作成して開始', + 'agentManager.sidebar.item.modelCountSingle': '{count}モデル', + 'agentManager.sidebar.item.modelCountPlural': '{count}モデル', + 'agentManager.sidebar.item.groupMenuAria': 'グループメニュー', + 'agentManager.sidebar.item.delete': '削除', + 'agentManager.sidebar.relativeTime.now': '今', + 'agentManager.sidebar.relativeTime.minutes': '{count}分', + 'agentManager.sidebar.relativeTime.hours': '{count}時間', + 'agentManager.sidebar.relativeTime.days': '{count}日', + 'agentManager.sidebar.dialog.deleteGroupTitle': 'エージェントグループを削除', + 'agentManager.sidebar.dialog.deleteGroupDescription': '「{group}」を削除しますか?このグループ内のすべてのワークツリーとセッションが削除されます。', + 'agentManager.sidebar.dialog.cancel': 'キャンセル', + 'agentManager.sidebar.dialog.delete': '削除', + 'agentManager.sidebar.dialog.deleting': '削除中...', + 'agentManager.sidebar.toast.deletingGroup': '「{group}」を削除中...', + 'agentManager.sidebar.toast.deletedGroup': '「{group}」を削除しました', + 'agentManager.sidebar.toast.failedToDeleteGroup': '「{group}」の完全削除に失敗しました', + 'agentManager.detail.header.modelCountSingle': '{count}モデル', + 'agentManager.detail.header.modelCountPlural': '{count}モデル', + 'agentManager.detail.header.noBranch': 'ブランチなし', + 'agentManager.detail.actions.worktreeActionsAria': 'ワークツリーアクション', + 'agentManager.detail.actions.removeThisWorktree': 'このワークツリーを削除', + 'agentManager.detail.actions.keepThisRemoveOthers': 'これを残して他を削除', + 'agentManager.detail.actions.copyWorktreePath': 'ワークツリーパスをコピー', + 'agentManager.detail.dialog.removeWorktreeTitle': 'ワークツリーを削除', + 'agentManager.detail.dialog.removeOtherWorktreesTitle': '他のワークツリーを削除', + 'agentManager.detail.dialog.removeWorktreeDescription': '「{label}」を削除しますか?そのワークツリー内の全セッションが削除され、ワークツリー自体も削除されます。', + 'agentManager.detail.dialog.removeOtherWorktreesDescription': '「{label}」を保持し、「{group}」内の他のワークツリーを削除します。', + 'agentManager.detail.dialog.cancel': 'キャンセル', + 'agentManager.detail.dialog.remove': '削除', + 'agentManager.detail.dialog.removeOthers': '他を削除', + 'agentManager.detail.dialog.working': '処理中...', + 'agentManager.detail.state.loadingSessionFor': '{label}のセッションを読み込み中', + 'agentManager.detail.state.sessionId': 'セッションID: {id}', + 'agentManager.detail.state.noSessionsInGroup': 'このグループにセッションはありません', + 'agentManager.detail.toast.noWorktreePath': 'ワークツリーパスは利用できません', + 'agentManager.detail.toast.worktreePathCopied': 'ワークツリーパスをコピーしました', + 'agentManager.detail.toast.failedToCopyPath': 'パスのコピーに失敗しました', + 'agentManager.detail.toast.removingWorktree': 'ワークツリーを削除中...', + 'agentManager.detail.toast.removingOtherWorktrees': '他のワークツリーを削除中...', + 'agentManager.detail.toast.failedToFullyRemoveWorktree': 'ワークツリーの完全削除に失敗しました', + 'agentManager.detail.toast.worktreeRemoved': 'ワークツリーを削除しました', + 'agentManager.detail.toast.otherWorktreesRemoved': '他のワークツリーを削除しました', + 'agentManager.empty.groupName.label': 'グループ名', + 'agentManager.empty.groupName.placeholder': '例: feature-auth, bugfix-login', + 'agentManager.empty.groupName.description': 'ワークツリーディレクトリとブランチ名に使用されます', + 'agentManager.empty.baseBranch.label': 'ベースブランチ', + 'agentManager.empty.baseBranch.description': '{branch}から新しいブランチを作成', + 'agentManager.empty.setupCommands.label': 'セットアップコマンド', + 'agentManager.empty.setupCommands.configured': '{count}設定済み', + 'agentManager.empty.setupCommands.description': '各新しいワークツリーで実行するコマンド。$ROOT_PROJECT_PATHでプロジェクトルートを指定。', + 'agentManager.empty.setupCommands.loading': '読み込み中...', + 'agentManager.empty.setupCommands.commandPlaceholder': '例: bun install', + 'agentManager.empty.setupCommands.removeCommandAria': 'コマンドを削除', + 'agentManager.empty.setupCommands.addCommand': 'コマンドを追加', + 'agentManager.empty.agent.label': 'エージェント', + 'agentManager.empty.agent.description': 'デフォルトの設定済みエージェントを使用', + 'agentManager.empty.models.label': 'モデル', + 'agentManager.empty.models.addModel': 'モデルを追加', + 'agentManager.empty.models.selectedSingle': '{count}モデル選択中', + 'agentManager.empty.models.selectedPlural': '{count}モデル選択中', + 'agentManager.empty.prompt.label': 'プロンプト', + 'agentManager.empty.prompt.placeholder': '何か質問...', + 'agentManager.empty.prompt.addAttachmentAria': '添付ファイルを追加', + 'agentManager.empty.actions.startAgentGroupAria': 'エージェントグループを開始', + 'agentManager.empty.toast.fileTooLarge': 'ファイル「{fileName}」が大きすぎます(最大10MB)', + 'agentManager.empty.toast.failedToAttach': '「{fileName}」の添付に失敗しました', + 'agentManager.empty.toast.attachedSingle': '{count}ファイルを添付しました', + 'agentManager.empty.toast.attachedPlural': '{count}ファイルを添付しました', + 'agentManager.empty.toast.failedToCreateGroup': 'エージェントグループの作成に失敗しました', + 'openInApp.actions.open': '開く', + 'openInApp.actions.openInAria': '{app}で開く', + 'openInApp.actions.chooseAppAria': '開くアプリを選択', + 'openInApp.actions.copyPath': 'パスをコピー', + 'openInApp.actions.refreshApps': 'アプリを更新', + 'openInApp.toast.pathCopied': 'パスをクリップボードにコピーしました', + 'projectActions.actions.addActionAria': 'アクションを追加', + 'projectActions.actions.addAction': 'アクションを追加', + 'projectActions.actions.addNewAction': '新しいアクションを追加', + 'projectActions.actions.autoDiscover': '自動検出', + 'projectActions.actions.chooseActionAria': 'プロジェクトアクションを選択', + 'projectActions.actions.openPreview': 'プレビューを開く', + 'projectActions.actions.runNamedAria': '{name}を実行', + 'projectActions.actions.stopNamedAria': '{name}を停止', + 'projectActions.label.fallbackAction': 'アクション', + 'projectActions.toast.openedUrlFromOutput': 'アクション出力からURLを開きました', + 'projectActions.toast.openedForwardedUrl': '転送URLを開きました', + 'projectActions.toast.openedActionUrl': 'アクションURLを開きました', + 'projectActions.error.noActiveDirectory': 'アクティブなディレクトリがありません', + 'projectActions.error.noActiveDirectoryForAction': 'アクションのアクティブなディレクトリがありません', + 'projectActions.error.failedToCreateTerminalSession': 'ターミナルセッションの作成に失敗しました', + 'projectActions.error.invalidCustomUrlFormat': '無効なカスタムURL形式です', + 'projectActions.error.selectedDesktopSshForwardUnavailable': '選択したデスクトップSSH転送は利用できません', + 'projectActions.error.failedToRunAction': 'アクションの実行に失敗しました', + 'mcpDropdown.title': 'MCPサーバー', + 'mcpDropdown.actions.refreshAria': '更新', + 'mcpDropdown.actions.openAria': 'MCPサーバー', + 'mcpDropdown.statusAria': 'MCPステータス', + 'mcpDropdown.status.unknown': '不明', + 'mcpDropdown.status.connected': '接続済み', + 'mcpDropdown.status.failed': '失敗: {error}', + 'mcpDropdown.status.unknownError': '不明なエラー', + 'mcpDropdown.status.needsAuth': '認証が必要です', + 'mcpDropdown.status.needsRegistration': '登録が必要: {error}', + 'mcpDropdown.empty.configureInConfig': '設定でMCPサーバーを構成してください。', + 'sessionAuth.error.rateLimitTitle': '試行回数が多すぎます', + 'sessionAuth.error.networkTitle': 'サーバーに到達できません', + 'sessionAuth.error.rateLimitDescriptionSingle': '再試行まで{minutes}分お待ちください。', + 'sessionAuth.error.rateLimitDescriptionPlural': '再試行まで{minutes}分お待ちください。', + 'sessionAuth.error.networkDescription': 'UIセッションを確認できませんでした。サービスが実行中であることを確認して再試行してください。', + 'sessionAuth.error.retry': '再試行', + 'sessionAuth.error.passkeySetupFailed': 'パスキーの設定に失敗しました。', + 'sessionAuth.error.incorrectPassword': 'パスワードが間違っています。もう一度お試しください。', + 'sessionAuth.error.unexpectedResponse': 'サーバーから予期しない応答がありました。', + 'sessionAuth.error.networkRetry': 'ネットワークエラー。接続を確認して再試行してください。', + 'sessionAuth.error.passkeySignInCanceled': 'パスキーサインインがキャンセルされました。', + 'sessionAuth.error.enterPasswordForPasskey': 'パスキーを追加するためにパスワードを入力してください。', + 'sessionAuth.locked.tunnelTitle': 'トンネルアクセスが必要', + 'sessionAuth.locked.unlockTitle': 'OpenChamberのロックを解除', + 'sessionAuth.locked.tunnelDescription': 'デスクトップアプリのワンタイム接続リンクを使用してこのトンネルを開きます。', + 'sessionAuth.locked.passwordDescription': 'このセッションはパスワードで保護されています。', + 'sessionAuth.locked.hostSwitcherHint': 'リモートに到達できない場合はローカルを使用してください。', + 'sessionAuth.actions.cancelPasskey': 'パスキーをキャンセル', + 'sessionAuth.actions.usePasskey': 'パスキーを使用', + 'sessionAuth.actions.unlockingAria': 'ロック解除中', + 'sessionAuth.actions.unlockAria': 'ロック解除', + 'sessionAuth.actions.trustDevice': 'このデバイスを信頼', + 'sessionAuth.actions.trustDeviceAria': 'このデバイスを信頼', + 'sessionAuth.actions.cancelPasskeySetup': 'パスキー設定をキャンセル', + 'sessionAuth.actions.addPasskey': 'パスキーを追加', + 'sessionAuth.password.placeholder': 'パスワードを入力', + 'sessionAuth.toast.passkeyAdded': 'パスキーを追加しました', + 'sessionAuth.toast.passkeySetupCanceled': 'パスキー設定をキャンセルしました', + 'desktopHostSwitcher.title': 'インスタンス', + 'desktopHostSwitcher.description': 'ローカルとリモートのOpenChamberサーバーを切り替えます', + 'desktopHostSwitcher.header.current': '現在', + 'desktopHostSwitcher.header.default': 'デフォルト', + 'desktopHostSwitcher.header.currentColon': '現在:', + 'desktopHostSwitcher.header.currentDefaultColon': '現在のデフォルト:', + 'desktopHostSwitcher.status.connected': '接続済み', + 'desktopHostSwitcher.status.authRequired': '認証が必要', + 'desktopHostSwitcher.status.checking': '確認中', + 'desktopHostSwitcher.status.updateRecommended': '更新推奨', + 'desktopHostSwitcher.status.incompatible': '互換性なし', + 'desktopHostSwitcher.status.wrongService': 'サービスが違います', + 'desktopHostSwitcher.status.unreachable': '到達不能', + 'desktopHostSwitcher.status.unknown': '不明', + 'desktopHostSwitcher.status.ping': ' · {ms}ms ping', + 'desktopHostSwitcher.statusAria': 'インスタンスステータス', + 'desktopHostSwitcher.sshPhase.ready': '準備完了', + 'desktopHostSwitcher.sshPhase.error': 'エラー', + 'desktopHostSwitcher.sshPhase.reconnecting': '再接続中', + 'desktopHostSwitcher.sshPhase.resolvingConfig': '設定を解決中', + 'desktopHostSwitcher.sshPhase.checkingAuth': '認証を確認中', + 'desktopHostSwitcher.sshPhase.connectingSsh': 'SSH接続中', + 'desktopHostSwitcher.sshPhase.probingRemote': 'リモートを調査中', + 'desktopHostSwitcher.sshPhase.installing': 'インストール中', + 'desktopHostSwitcher.sshPhase.updating': '更新中', + 'desktopHostSwitcher.sshPhase.detectingServer': 'サーバーを検出中', + 'desktopHostSwitcher.sshPhase.startingServer': 'サーバーを起動中', + 'desktopHostSwitcher.sshPhase.forwardingPorts': 'ポート転送中', + 'desktopHostSwitcher.sshPhase.idle': '待機中', + 'desktopHostSwitcher.ssh.needInstancesHint': 'SSHインスタンスが必要ですか?設定で管理してください。', + 'desktopHostSwitcher.ssh.instanceFallback': 'SSHインスタンス', + 'desktopHostSwitcher.ssh.connectingTo': '{host}に接続中', + 'desktopHostSwitcher.actions.refresh': '更新', + 'desktopHostSwitcher.actions.refreshInstancesAria': 'インスタンスを更新', + 'desktopHostSwitcher.actions.remoteSsh': 'リモートSSH', + 'desktopHostSwitcher.actions.instanceActionsAria': 'インスタンスアクション', + 'desktopHostSwitcher.actions.edit': '編集', + 'desktopHostSwitcher.actions.delete': '削除', + 'desktopHostSwitcher.actions.connect': '接続', + 'desktopHostSwitcher.actions.defaultInstanceAria': 'デフォルトインスタンス', + 'desktopHostSwitcher.actions.setAsDefaultAria': 'デフォルトに設定', + 'desktopHostSwitcher.actions.setAsDefault': 'デフォルトに設定', + 'desktopHostSwitcher.actions.openInNewWindowAria': '新しいウィンドウで開く', + 'desktopHostSwitcher.actions.openInNewWindow': '新しいウィンドウで開く', + 'desktopHostSwitcher.actions.addInstance': 'インスタンスを追加', + 'desktopHostSwitcher.actions.cancel': 'キャンセル', + 'desktopHostSwitcher.actions.save': '保存', + 'desktopHostSwitcher.actions.add': '追加', + 'desktopHostSwitcher.actions.switchToLocal': 'ローカルに切り替え', + 'desktopHostSwitcher.actions.retry': '再試行', + 'desktopHostSwitcher.actions.switchInstanceAria': 'インスタンスを切り替え', + 'desktopHostSwitcher.actions.switchInstance': 'インスタンスを切り替え', + 'desktopHostSwitcher.actions.switchToAria': '{instance}に切り替え', + 'desktopHostSwitcher.field.labelPlaceholder': 'ラベル', + 'desktopHostSwitcher.field.labelOptionalPlaceholder': 'ラベル(任意)', + 'desktopHostSwitcher.field.urlPlaceholder': 'https://host:port', + 'desktopHostSwitcher.state.limitedOnPage': 'インスタンススイッチャーはこのページでは制限されています。ローカルを使用して復旧してください。', + 'desktopHostSwitcher.state.loading': '読み込み中...', + 'desktopHostSwitcher.state.instanceUnreachable': 'インスタンスに到達できません', + 'desktopHostSwitcher.edit.title': 'インスタンスを編集', + 'desktopHostSwitcher.add.title': 'インスタンスを追加', + 'desktopHostSwitcher.error.failedToSave': '保存に失敗しました', + 'desktopHostSwitcher.error.failedToLoad': '読み込みに失敗しました', + 'desktopHostSwitcher.error.invalidUrl': '無効なURL(http/httpsである必要があります)', + 'desktopHostSwitcher.error.failedToOpenNewWindow': '新しいウィンドウを開けませんでした', + 'desktopHostSwitcher.instance.local': 'ローカル', + 'desktopHostSwitcher.instance.fallback': 'インスタンス', + 'desktopHostSwitcher.startup.title': 'デフォルトSSHインスタンスは利用できません', + 'desktopHostSwitcher.startup.connectingTo': '{host}に接続中...', + 'desktopHostSwitcher.startup.failed': 'デフォルトSSHインスタンスへの接続に失敗しました。', + 'desktopHostSwitcher.toast.sshConnected': 'SSHインスタンス「{host}」に接続しました', + 'desktopHostSwitcher.toast.sshFailedToConnect': 'SSHインスタンス「{host}」への接続に失敗しました', + 'desktopHostSwitcher.toast.instanceUnreachable': 'インスタンス「{host}」に到達できません', + 'miniChat.header.newSession': '新しいセッション', + 'miniChat.header.session': 'セッション', + 'miniChat.header.defaultAgent': 'デフォルトエージェント', + 'miniChat.header.noModel': 'モデルなし', + 'miniChat.status.busy': '実行中', + 'miniChat.status.retry': '再試行中', + 'miniChat.status.idle': '待機中', + 'miniChat.actions.pin': '他のウィンドウの上に固定', + 'miniChat.actions.unpin': 'ウィンドウの固定を解除', + 'miniChat.actions.pinAria': 'Mini Chatウィンドウを固定', + 'miniChat.actions.unpinAria': 'Mini Chatウィンドウの固定を解除', + 'miniChat.actions.openMain': 'メインウィンドウで開く', + 'miniChat.actions.openMainAria': 'メインウィンドウでセッションを開く', + 'miniChat.unavailable.title': 'セッションは利用できません', + 'miniChat.unavailable.description': 'このセッションを読み込めませんでした。削除されたか、アーカイブされたか、別のプロジェクトコンテキストから開かれた可能性があります。', + 'sessions.sidebar.session.menu.openMiniChatWindow': 'Mini Chatウィンドウで開く', + 'header.actions.newMiniChat': '新しいMini Chatウィンドウ', + 'header.actions.newMiniChatAria': '新しいMini Chatウィンドウを開く', + 'header.actions.openSessionMiniChat': 'Mini Chatでセッションを開く', + 'header.actions.openSessionMiniChatAria': '現在のセッションをMini Chatで開く', + 'header.windowControls.groupAria': 'ウィンドウコントロール', + 'header.windowControls.minimize': 'ウィンドウを最小化', + 'header.windowControls.maximize': 'ウィンドウを最大化', + 'header.windowControls.restore': 'ウィンドウを復元', + 'header.windowControls.close': 'ウィンドウを閉じる', + 'errorBoundary.title': '問題が発生しました', + 'errorBoundary.description': 'アプリケーションで予期しないエラーが発生しました。デバッグ用に記録されています。', + 'errorBoundary.state.unknownError': '不明なエラー', + 'errorBoundary.state.componentStackLabel': 'コンポーネントスタック:', + 'errorBoundary.actions.errorDetails': 'エラー詳細', + 'errorBoundary.actions.tryAgain': '再試行', + 'errorBoundary.actions.copy': 'コピー', + 'errorBoundary.actions.copied': 'コピーしました', + 'jsonTreeView.error.emptyJson': '空のJSON内容', + 'jsonTreeView.error.invalidJson': '無効なJSON', + 'jsonTreeView.error.invalidJsonTitle': '無効なJSON', + 'jsonTreeView.actions.expandAll': 'すべて展開', + 'jsonTreeView.actions.collapseAll': 'すべて折りたたむ', + 'numberInput.actions.decreaseAria': '値を減らす', + 'numberInput.actions.increaseAria': '値を増やす', + 'goToLineDialog.field.linePlaceholderShort': '行', + 'goToLineDialog.field.linePlaceholder': '行番号', + 'goToLineDialog.actions.go': '移動', + 'goToLineDialog.helper.editorUnavailable': 'エディターは利用できません。', + 'goToLineDialog.helper.currentLineRange': '現在の行: {current}。1から{max}の間の行番号を入力して移動します。', + 'goToLineDialog.helper.goToLine': '{line}行目に移動', + 'dialog.common.actions.close': '閉じる', + 'filesView.toast.relativePathCopied': '相対パスをコピーしました', + 'filesView.tree.menu.copyRelativePath': '相対パスをコピー', + 'multiRun.branchSelector.placeholder.selectSourceBranch': 'ソースブランチを選択...', + 'multiRun.branchSelector.status.loadingBranches': 'ブランチを読み込み中...', + 'multiRun.branchSelector.status.noBranchesFound': 'ブランチが見つかりません', + 'multiRun.branchSelector.status.notInGitRepository': 'Gitリポジトリ内ではありません。', + 'multiRun.branchSelector.groups.localBranches': 'ローカルブランチ', + 'multiRun.branchSelector.groups.remoteBranches': 'リモートブランチ', + 'multiRun.window.actions.closeAria': 'マルチランを閉じる', + 'multiRun.window.description': 'OpenChamberマルチランウィンドウ。', + 'voice.status.idle': '音声準備完了', + 'voice.status.listening': '聴取中...', + 'voice.status.processing': '処理中...', + 'voice.status.speaking': '発話中...', + 'voice.status.error': '音声エラー', + 'voice.status.conversationModeActiveAria': '会話モードアクティブ', + 'voice.action.finishAndTranscribe': '音声入力を終了して文字起こし', + 'onboarding.common.actions.back': '戻る', + 'onboarding.common.copyToClipboard': 'クリップボードにコピー', + 'onboarding.common.status.copiedToClipboard': 'クリップボードにコピーしました', + 'onboarding.chooser.title': 'OpenChamberへようこそ', + 'onboarding.chooser.description': '開始する接続方法を選択してください。', + 'onboarding.chooser.tabs.localInstall': 'ローカルインストール', + 'onboarding.chooser.tabs.connectRemote': 'リモートに接続', + 'onboarding.localSetup.title': 'OpenCodeのセットアップ', + 'onboarding.localSetup.description': 'OpenCode CLIをインストールして続行します。', + 'onboarding.localSetup.dialog.selectOpencodeBinary': 'opencodeバイナリを選択', + 'onboarding.localSetup.errors.cliNotReady': 'OpenCode CLIの準備ができていません。インストールが完了していることを確認してもう一度お試しください。', + 'onboarding.localSetup.errors.detectionFailed': '検出に失敗しました', + 'onboarding.localSetup.windows.title': 'Windowsセットアップ', + 'onboarding.localSetup.windows.stepInstallWsl': '以下のコマンドでOpenCodeをインストールします。', + 'onboarding.localSetup.windows.stepInstallWslSuffix': '', + 'onboarding.localSetup.windows.stepRunInstallInWsl': 'Windowsターミナルで以下のインストールコマンドを実行します。', + 'onboarding.localSetup.windows.stepSetBinaryPath': 'OpenChamberがOpenCodeを自動検出しない場合、以下のパスを手動で設定します。', + 'onboarding.localSetup.intro': 'OpenCodeはOpenChamberの中核です — インストールして開始しましょう。', + 'onboarding.localSetup.docs.windows': 'OpenCodeドキュメント', + 'onboarding.localSetup.docs.default': 'OpenCodeドキュメント', + 'onboarding.localSetup.actions.checking': '確認中…', + 'onboarding.localSetup.actions.checkNow': '今すぐ確認', + 'onboarding.localSetup.helper.checkAndContinue': 'クリックしてOpenCode CLIが利用可能か確認します。成功すると自動的にメイン画面に進みます。', + 'onboarding.localSetup.status.watching': 'OpenCodeを待機中', + 'onboarding.localSetup.field.alreadyInstalled': 'すでにインストール済みですか?OpenCode CLIのパスを設定:', + 'onboarding.localSetup.advanced.title': 'カスタムバイナリパスを設定', + 'onboarding.localSetup.troubleshoot.title': '問題が発生しましたか?', + 'onboarding.localSetup.actions.browse': '参照', + 'onboarding.localSetup.actions.apply': '適用', + 'onboarding.localSetup.helper.saveAndReload': 'OpenChamber設定に保存し、OpenCode設定を再読み込みします。', + 'onboarding.localSetup.remotePreference': 'リモートサーバーを使用しますか?', + 'onboarding.localSetup.actions.connectRemoteServer': 'リモートサーバーに接続 →', + 'onboarding.localSetup.windows.hintInstallInWsl': 'Windowsでは、OpenCodeをネイティブでインストールして実行します。', + 'onboarding.localSetup.windows.hintDetectionFailed': '検出に失敗した場合、opencode.cmdやopencode.exeなどのネイティブパスを設定してください。', + 'onboarding.localSetup.hint.ensurePath': 'すでにインストール済みですか?opencodeがPATHにあることを確認してください。', + 'onboarding.localSetup.hint.setEnv': 'またはOPENCODE_BINARY環境変数を設定してください。', + 'onboarding.localSetup.hint.missingRuntime': '"env: node: No such file or directory"または"env: bun: No such file or directory"が表示される場合、そのランタイムをインストールするかPATHに追加してください。', + 'onboarding.remoteConnection.title': 'リモートサーバーに接続', + 'onboarding.remoteConnection.titleRecovery': '別のサーバーに接続', + 'onboarding.remoteConnection.description': '接続するOpenChamberサーバーのアドレスを入力します。', + 'onboarding.remoteConnection.field.serverAddress': 'サーバーアドレス', + 'onboarding.remoteConnection.field.serverAddressPlaceholder': 'https://your-server.example.com:4096', + 'onboarding.remoteConnection.field.nameOptional': '名前(任意)', + 'onboarding.remoteConnection.field.namePlaceholder': 'マイリモートサーバー', + 'onboarding.remoteConnection.errors.connectionTestFailed': '接続テストに失敗しました', + 'onboarding.remoteConnection.errors.failedToSaveConnection': '接続の保存に失敗しました', + 'onboarding.remoteConnection.status.connectedSuccessfully': '接続に成功しました({latencyMs}ms)', + 'onboarding.remoteConnection.status.authWarning': 'サーバーは認証を必要とします。接続は可能です。', + 'onboarding.remoteConnection.status.connectionFailed': '接続に失敗しました', + 'onboarding.remoteConnection.status.suggestionsUnreachable': 'ヒント: サーバーアドレスを確認し、サーバーが実行中であることを確認し、ネットワーク接続を確認してください。', + 'onboarding.remoteConnection.status.suggestionsWrongService': 'ヒント: URLがOpenChamberサーバーを指しているか確認するか、サーバー管理者に問い合わせてください。', + 'onboarding.remoteConnection.actions.testing': 'テスト中…', + 'onboarding.remoteConnection.actions.testConnection': '接続テスト', + 'onboarding.remoteConnection.actions.connectAndRestart': '接続して再起動', + 'onboarding.remoteConnection.actions.whatToDo': 'どうしますか?', + 'onboarding.remoteConnection.actions.chooseDifferentServer': '別のサーバーを選択', + 'onboarding.remoteConnection.actions.useLocalInstead': '代わりにローカルを使用', + 'onboarding.remoteConnection.probe.authMessage': 'サーバーは認証を必要とします。接続は可能ですが、認証情報の提供が必要な場合があります。', + 'onboarding.remoteConnection.probe.updateRecommendedMessage': 'このインスタンスは異なるバージョンのOpenChamberを実行しています。接続は可能ですが、正常に動作しない場合は両方のアプリを更新してください。', + 'onboarding.remoteConnection.probe.incompatibleMessage': 'サーバーはOpenChamberを実行していますが、このアプリバージョンと互換性がありません。サーバーのOpenChamberを更新してから再試行してください。', + 'onboarding.remoteConnection.probe.wrongServiceMessage': 'サーバーは応答しましたが、OpenChamberを実行していません。アドレスがOpenChamberサーバーを指していることを確認してください。', + 'onboarding.remoteConnection.probe.unreachableMessage': 'サーバーに到達できません。ネットワーク接続を確認し、サーバーアドレスを確認してください。', + 'onboarding.desktopRecovery.localUnavailable.title': 'ローカルOpenCodeが利用できません', + 'onboarding.desktopRecovery.localUnavailable.description': 'OpenCode CLIを起動できないか、インストールされていません。OpenCodeをインストールするか、代わりにリモートサーバーに接続してください。', + 'onboarding.desktopRecovery.localUnavailable.retry': 'ローカルを再試行', + 'onboarding.desktopRecovery.localUnavailable.useLocal': 'ローカルをセットアップ', + 'onboarding.desktopRecovery.noDefaultConnection.title': 'デフォルト接続がありません', + 'onboarding.desktopRecovery.noDefaultConnection.description': '保存されたデフォルト接続が見つかりませんでした。接続方法を選択してください。', + 'onboarding.desktopRecovery.remoteUnreachable.title': 'リモートサーバーに到達できません', + 'onboarding.desktopRecovery.remoteUnreachable.description': '「{host}」に接続できませんでした。ネットワーク接続を確認し、サーバーアドレスを確認してください。', + 'onboarding.desktopRecovery.remoteUnreachable.retry': '接続を再試行', + 'onboarding.desktopRecovery.incompatibleServer.title': '互換性のないサーバー', + 'onboarding.desktopRecovery.incompatibleServer.description': '「{host}」のサーバーはOpenChamberを実行していません。アドレスがOpenChamberサーバーを指していることを確認してください。', + 'onboarding.desktopRecovery.remoteIncompatible.title': 'サーバーの更新が必要', + 'onboarding.desktopRecovery.remoteIncompatible.description': '「{host}」のOpenChamberサーバーはこのアプリバージョンと互換性がありません。サーバーのOpenChamberを更新してから再試行してください。', + 'onboarding.desktopRecovery.common.useLocal': 'ローカルを使用', + 'onboarding.desktopRecovery.common.useRemote': 'リモートを使用', + 'onboarding.desktopRecovery.actions.retrying': '再試行中…', + 'onboarding.desktopRecovery.actions.retryConnection': '接続を再試行', + 'startup.initRecovery.title': '起動に失敗しました', + 'startup.initRecovery.description': 'OpenChamberが初期化を完了できませんでした。サーバーが実行中であることを確認してから再試行してください。', + 'startup.initRecovery.retry': '再試行', + 'startup.initRecovery.retrying': '再試行中…', + 'onboarding.desktopRecovery.placeholders.remoteServer': 'リモートサーバー', + 'onboarding.desktopRecovery.placeholders.unknownServer': '不明', + 'vscodeLayout.title.chat': 'チャット', + 'vscodeLayout.title.newSession': '新しいセッション', + 'vscodeLayout.title.sessions': 'セッション', + 'vscodeLayout.title.sessionFallback': 'セッション', + 'vscodeLayout.actions.backToSessionsAria': 'セッションに戻る', + 'vscodeLayout.actions.archiveAllAria': 'すべてのセッションをアーカイブ', + 'vscodeLayout.actions.archiveAllConfirm': 'すべてアーカイブ', + 'vscodeLayout.actions.archiveAllSuccess': '{count}セッションをアーカイブしました', + 'vscodeLayout.actions.archiveAllError': '{count}セッションのアーカイブに失敗しました', + 'vscodeLayout.actions.cancel': 'キャンセル', + 'vscodeLayout.actions.newSessionAria': '新しいセッション', + 'vscodeLayout.actions.openAgentManagerAria': 'エージェントマネージャーを開く', + 'vscodeLayout.actions.resizeSessionsSidebarAria': 'セッションサイドバーのサイズを調整', + 'vscodeLayout.actions.settingsAria': '設定', + 'vscodeLayout.quota.title': 'レート制限', + 'vscodeLayout.quota.mode.used': '使用済み', + 'vscodeLayout.quota.mode.remaining': '残り', + 'vscodeLayout.quota.lastUpdated': '最終更新 {time}', + 'vscodeLayout.quota.noRateLimitsAvailable': 'レート制限は利用できません。', + 'vscodeLayout.quota.noRateLimitsReported': 'レート制限は報告されていません。', + 'vscodeLayout.quota.actions.rateLimitsAria': 'レート制限', + 'vscodeLayout.quota.actions.showUsedAria': '使用済みクォータを表示', + 'vscodeLayout.quota.actions.showRemainingAria': '残りクォータを表示', + 'vscodeLayout.quota.actions.refreshAria': 'レート制限を更新', + 'updateDialog.header.updateAvailable': '更新があります', + 'updateDialog.header.updating': 'OpenChamberを更新中...', + 'updateDialog.status.installingUpdate': '更新をインストール中...', + 'updateDialog.status.serverRestarting': 'サーバーを再起動中...', + 'updateDialog.status.waitingForServer': 'サーバーを待機中...', + 'updateDialog.status.autoReloadHint': '更新が完了すると自動的にページが再読み込みされます。', + 'updateDialog.fallback.updateViaTerminal': 'またはターミナルで更新:', + 'updateDialog.actions.copyCommand': 'コマンドをコピー', + 'updateDialog.actions.copied': 'コピーしました!', + 'updateDialog.status.downloadingPayload': '更新ペイロードをダウンロード中...', + 'updateDialog.actions.downloadUpdate': '更新をダウンロード', + 'updateDialog.status.downloading': 'ダウンロード中...', + 'updateDialog.actions.restartToUpdate': '再起動して更新', + 'updateDialog.actions.updateNow': '今すぐ更新', + 'updateDialog.status.updating': '更新中...', + 'updateDialog.error.updateFailed': '更新に失敗しました', + 'updateDialog.error.takingLonger': '更新に予想以上に時間がかかっています。しばらく待ってから更新するか、次を実行: openchamber update', + 'opencodeUpdate.toast.available.title': 'OpenCode更新', + 'opencodeUpdate.toast.available.description': 'バージョン{version}が利用可能です。', + 'opencodeUpdate.toast.actions.update': '更新', + 'opencodeUpdate.toast.actions.dismiss': '閉じる', + 'opencodeUpdate.toast.actions.reload': 'OpenCodeを再読み込み', + 'opencodeUpdate.toast.upgrading.title': 'OpenCodeを更新中...', + 'opencodeUpdate.toast.upgrading.description': 'OpenChamberを開いたままにしてください。', + 'opencodeUpdate.toast.updated.title': 'OpenCodeを更新しました', + 'opencodeUpdate.toast.updated.description': '更新をインストールしました。', + 'opencodeUpdate.toast.updated.descriptionWithVersion': 'バージョン{version}をインストールしました。', + 'opencodeUpdate.toast.failed.title': 'OpenCodeを更新できませんでした', + 'opencodeUpdate.toast.failed.description': 'OpenCodeのアップグレードに失敗しました。', + 'opencodeUpdate.toast.reload.message': 'OpenCodeを再起動中...', + 'memoryDebugPanel.title': 'デバッグパネル', + 'memoryDebugPanel.tabs.memory': 'メモリ', + 'memoryDebugPanel.tabs.streaming': 'ストリーミング', + 'memoryDebugPanel.section.sessionsInMemory': 'メモリ内のセッション', + 'memoryDebugPanel.section.uiStreamingMetrics': 'UIストリーミングメトリクス', + 'memoryDebugPanel.section.vscodeBridgeMetrics': 'VS Codeブリッジメトリクス', + 'memoryDebugPanel.section.noUiSamples': 'まだUIストリーミングサンプルがありません。ストリームを開始してこのパネルを開いたままにしてください。', + 'memoryDebugPanel.section.noVscodeSamples': 'まだVS Codeブリッジサンプルがありません。', + 'memoryDebugPanel.metric.totalMessages': '総メッセージ数', + 'memoryDebugPanel.metric.cachedSessions': 'キャッシュされたセッション', + 'memoryDebugPanel.metric.userMessages': 'ユーザーメッセージ', + 'memoryDebugPanel.metric.assistantMessages': 'アシスタントメッセージ', + 'memoryDebugPanel.metric.viewportWindow': 'ビューポートウィンドウ', + 'memoryDebugPanel.metric.zombieTimeout': 'ゾンビタイムアウト', + 'memoryDebugPanel.metric.githubTotalRequests': 'GitHub総リクエスト数', + 'memoryDebugPanel.metric.metrics': 'メトリクス', + 'memoryDebugPanel.metric.samples': 'サンプル', + 'memoryDebugPanel.metric.lastUpdate': '最終更新', + 'memoryDebugPanel.metric.uiMetrics': 'UIメトリクス', + 'memoryDebugPanel.metric.vscodeMetrics': 'VS Codeメトリクス', + 'memoryDebugPanel.metric.messageListRenders': 'MsgListレンダリング', + 'memoryDebugPanel.metric.messageListStreamRenders': 'MsgListストリームレンダリング', + 'memoryDebugPanel.metric.chatMessageRenders': 'ChatMessageレンダリング', + 'memoryDebugPanel.metric.chatMessageStreamRenders': 'ChatMessageストリームレンダリング', + 'memoryDebugPanel.metric.chatMessageStaticDuringStream': 'ストリーム中の静止ChatMessage', + 'memoryDebugPanel.metric.chatMessageStaticOutsideActiveTurn': 'アクティブターン外の静止ChatMessage', + 'memoryDebugPanel.metric.messagesValue': '{count}メッセージ', + 'memoryDebugPanel.metric.minutesValue': '{count}分', + 'memoryDebugPanel.metric.msgsValue': '{count}メッセージ', + 'memoryDebugPanel.metric.roleMsgsValue': 'U {user} / A {assistant}', + 'memoryDebugPanel.metric.countValue': 'カウント {value}', + 'memoryDebugPanel.metric.avgValue': '平均 {value}', + 'memoryDebugPanel.metric.maxValue': '最大 {value}', + 'memoryDebugPanel.metric.totalValue': '合計 {value}', + 'memoryDebugPanel.actions.logState': '状態をログ', + 'memoryDebugPanel.actions.copyJson': 'JSONをコピー', + 'memoryDebugPanel.tooltip.logCurrentState': '現在のメモリ状態をブラウザコンソールに出力', + 'memoryDebugPanel.streaming.copy.copied': 'ストリーミングデバッグJSONをコピーしました', + 'memoryDebugPanel.streaming.copy.failed': 'JSONのコピーに失敗しました', + 'memoryDebugPanel.streaming.copy.hint': 'UIとVS Codeの両方のストリーミングメトリクスをJSONとしてエクスポートします', + 'memoryDebugPanel.common.idle': '待機中', + 'memoryDebugPanel.common.live': 'ライブ', + 'memoryDebugPanel.common.notAvailable': 'N/A', + 'memoryDebugPanel.common.untitled': '無題', + 'directoryTree.field.newDirectoryPlaceholder': 'new_directory', + 'textarea.resizeHandleAria': 'テキストエリアのサイズを変更', + 'sidebar.resize.leftPanelAria': '左パネルのサイズを変更', + 'sidebar.resize.rightPanelAria': '右パネルのサイズを変更', + 'mainLayout.mobile.closeDrawerAria': 'ドロワーを閉じる', + 'sortableTabsStrip.aria.tabs': 'タブ', + 'openChamberLogo.aria.logo': 'OpenChamberロゴ', + 'markdownRenderer.table.actions.copyTitle': 'テーブルをコピー', + 'markdownRenderer.table.actions.downloadTitle': 'テーブルをダウンロード', + 'markdownRenderer.table.toast.downloadedAsFormat': 'テーブルを{format}としてダウンロードしました', + 'markdownRenderer.mermaid.actions.copyTitle': 'コピー', + 'markdownRenderer.mermaid.actions.copySourceTitle': 'ソースをコピー', + 'markdownRenderer.mermaid.actions.downloadSvgTitle': 'SVGをダウンロード', + 'markdownRenderer.mermaid.toast.downloadFailed': 'ダイアグラムのダウンロードに失敗しました', + 'common.date.today': '今日', + 'common.date.yesterday': '昨日', + 'common.date.yesterdayWithTime': '昨日{time}', + 'common.relative.justNow': 'たった今', + 'common.relative.minutesAgoShort': '{count}分前', + 'common.relative.hoursAgoShort': '{count}時間前', + 'common.relative.daysAgoShort': '{count}日前', + 'common.relative.weeksAgoShort': '{count}週前', + 'common.relative.yearsAgoShort': '{count}年前', + 'common.relative.daysAgoCompact': '{count}日前', + 'common.relative.weeksAgoCompact': '{count}週前', + 'common.relative.yearsAgoCompact': '{count}年前', + 'contextFileOpen.failure.tooLarge': 'ファイルが大きすぎて開けません(>{count}行)', + 'contextFileOpen.failure.missing': 'ファイルが見つかりません', + 'contextFileOpen.failure.unreadable': 'ファイルを開けませんでした', + 'quota.window.5h': '5時間', + 'quota.window.7d': '7日間制限', + 'quota.window.7dSonnet': '7日間Sonnet制限', + 'quota.window.7dOpus': '7日間Opus制限', + 'quota.window.weekly': '週間制限', + 'quota.window.daily': '日次', + 'quota.window.monthly': '月間制限', + 'quota.window.credits': 'クレジット', + 'quota.window.creditsBalance': 'クレジット残高', + 'quota.window.billingCycle': '請求サイクル', + 'quota.window.auto': '自動', + 'quota.window.api': 'API', + 'quota.window.planLimit': 'プラン制限', + 'quota.window.onDemand': '従量課金', + 'quota.window.session': 'セッション', + 'quota.window.premium': 'プレミアムインタラクション', + 'quota.window.chat': 'チャットリクエスト', + 'quota.window.completions': '完了', + 'onboarding.localSetup.actions.checkAndContinue': 'インストール完了、確認して続行', + 'onboarding.localSetup.status.autoContinue': '検出され次第自動的に続行します。', + 'updateDialog.changelog.title': '新機能', + 'quota.window.premiumInteractions': 'プレミアムインタラクション', + +}; diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 536dae55..0e590934 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -14,6 +14,7 @@ export const dict: Record = { 'common.language.brazilianPortuguese': '브라질 포르투갈어', 'common.language.korean': '한국어', 'common.language.polish': '폴란드어', + 'common.language.japanese': '일본어', 'common.revealPath.finder': 'Finder에서 보기', 'common.revealPath.fileExplorer': 'File Explorer에서 열기', 'common.revealPath.fileManager': '파일 관리자에서 열기', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 8429129a..f4c2f728 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -15,6 +15,7 @@ export const dict: Record = { 'common.language.brazilianPortuguese': 'Portugalski (Brazylia)', 'common.language.korean': 'Koreański', 'common.language.polish': 'Polski', + 'common.language.japanese': 'Japoński', 'common.revealPath.finder': 'Pokaż w Finderze', 'common.revealPath.fileExplorer': 'Otwórz w Eksploratorze plików', 'common.revealPath.fileManager': 'Otwórz w Menedżerze plików', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index c60672b8..5063bb75 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -14,6 +14,7 @@ export const dict: Record = { "common.language.brazilianPortuguese": "Português brasileiro", "common.language.korean": "Coreano", "common.language.polish": "Polonês", + "common.language.japanese": "Japonês", "common.revealPath.finder": "Mostrar no Finder", "common.revealPath.fileExplorer": "Abrir no File Explorer", "common.revealPath.fileManager": "Abrir no gerenciador de arquivos", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index add85902..5fca9424 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -14,6 +14,7 @@ export const dict: Record = { "common.language.brazilianPortuguese": "Бразильська португальська", "common.language.korean": "Корейська", "common.language.polish": "Польська", + "common.language.japanese": "Японська", "common.revealPath.finder": "Показати у Finder", "common.revealPath.fileExplorer": "Відкрити у File Explorer", "common.revealPath.fileManager": "Відкрити у файловому менеджері", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 73899a31..82b4dced 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -14,6 +14,7 @@ export const dict: Record = { 'common.language.brazilianPortuguese': '巴西葡萄牙语', 'common.language.korean': '韩语', 'common.language.polish': '波兰语', + 'common.language.japanese': '日语', 'common.revealPath.finder': '在 Finder 中显示', 'common.revealPath.fileExplorer': '在文件资源管理器中打开', 'common.revealPath.fileManager': '在文件管理器中打开', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index ac74fd07..5b1d666c 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -14,6 +14,7 @@ export const dict: Record = { 'common.language.brazilianPortuguese': '巴西葡萄牙語', 'common.language.korean': '韓語', 'common.language.polish': '波蘭語', + 'common.language.japanese': '日語', 'common.revealPath.finder': '在 Finder 中顯示', 'common.revealPath.fileExplorer': '在檔案總管中開啟', 'common.revealPath.fileManager': '在檔案管理員中開啟', diff --git a/packages/ui/src/lib/i18n/runtime.ts b/packages/ui/src/lib/i18n/runtime.ts index c2a1c4c0..a80ad5a0 100644 --- a/packages/ui/src/lib/i18n/runtime.ts +++ b/packages/ui/src/lib/i18n/runtime.ts @@ -1,10 +1,10 @@ -export type Locale = 'en' | 'fr' | 'zh-CN' | 'zh-TW' | 'uk' | 'es' | 'pt-BR' | 'ko' | 'pl'; +export type Locale = 'en' | 'fr' | 'zh-CN' | 'zh-TW' | 'uk' | 'es' | 'pt-BR' | 'ko' | 'pl' | 'ja'; -export const LOCALES = ['en', 'fr', 'zh-CN', 'zh-TW', 'uk', 'es', 'pt-BR', 'ko', 'pl'] as const satisfies readonly Locale[]; +export const LOCALES = ['en', 'fr', 'zh-CN', 'zh-TW', 'uk', 'es', 'pt-BR', 'ko', 'pl', 'ja'] as const satisfies readonly Locale[]; export const DEFAULT_LOCALE: Locale = 'en'; -export const LOCALE_LABEL_KEYS: Record = { +export const LOCALE_LABEL_KEYS: Record = { en: 'common.language.english', fr: 'common.language.french', 'zh-CN': 'common.language.simplifiedChinese', @@ -14,6 +14,7 @@ export const LOCALE_LABEL_KEYS: Record { ? await import('./messages/ko') as { dict: I18nDictionary } : locale === 'pl' ? await import('./messages/pl') as { dict: I18nDictionary } - : { dict: enDict }; + : locale === 'ja' + ? await import('./messages/ja') as { dict: I18nDictionary } + : { dict: enDict }; dictionaries.set(locale, mod.dict); return mod.dict; } From 9547e3a0d2d112a5a33ec68fc17d2c90051ded69 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sat, 27 Jun 2026 01:03:32 +0300 Subject: [PATCH 0009/1073] fix(i18n): localize bootstrap messages --- packages/ui/src/lib/i18n/bootstrap.ts | 150 +++++++++++++++++++++++++- 1 file changed, 147 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/lib/i18n/bootstrap.ts b/packages/ui/src/lib/i18n/bootstrap.ts index 1c6ac1bb..dc712f63 100644 --- a/packages/ui/src/lib/i18n/bootstrap.ts +++ b/packages/ui/src/lib/i18n/bootstrap.ts @@ -57,6 +57,139 @@ const FR_MESSAGES: BootstrapMessages = { loadingData: (providersText, agentsText) => `Chargement des données (${providersText}, ${agentsText})…`, }; +const ZH_CN_MESSAGES: BootstrapMessages = { + startingApi: '正在启动 OpenCode API…', + initializing: '正在初始化…', + connecting: '正在连接…', + connected: '已连接!', + connectionError: '连接错误', + disconnected: '已断开连接', + reconnecting: '正在重新连接…', + initialDataLoadFailed: 'OpenCode 已连接,但初始数据加载失败。', + cliNotFound: '未找到 OpenCode CLI。请先安装它。', + providersReady: '✓ 提供商', + providersLoading: '… 提供商', + agentsReady: '✓ 智能体', + agentsLoading: '… 智能体', + startingDevServer: (hostLabel) => `正在启动 webview 开发服务器 (${hostLabel})...`, + waitingDevServer: (hostLabel, attempt) => `正在等待 webview 开发服务器 (${hostLabel})... 第 ${attempt} 次尝试`, + loadingData: (providersText, agentsText) => `正在加载数据 (${providersText}, ${agentsText})…`, +}; + +const ZH_TW_MESSAGES: BootstrapMessages = { + startingApi: '正在啟動 OpenCode API…', + initializing: '正在初始化…', + connecting: '正在連線…', + connected: '已連線!', + connectionError: '連線錯誤', + disconnected: '已中斷連線', + reconnecting: '正在重新連線…', + initialDataLoadFailed: 'OpenCode 已連線,但初始資料載入失敗。', + cliNotFound: '找不到 OpenCode CLI。請先安裝。', + providersReady: '✓ 提供者', + providersLoading: '… 提供者', + agentsReady: '✓ 代理', + agentsLoading: '… 代理', + startingDevServer: (hostLabel) => `正在啟動 webview 開發伺服器 (${hostLabel})...`, + waitingDevServer: (hostLabel, attempt) => `正在等待 webview 開發伺服器 (${hostLabel})... 第 ${attempt} 次嘗試`, + loadingData: (providersText, agentsText) => `正在載入資料 (${providersText}, ${agentsText})…`, +}; + +const UK_MESSAGES: BootstrapMessages = { + startingApi: 'Запуск OpenCode API…', + initializing: 'Ініціалізація…', + connecting: 'Підключення…', + connected: 'Підключено!', + connectionError: 'Помилка підключення', + disconnected: 'Відключено', + reconnecting: 'Повторне підключення…', + initialDataLoadFailed: 'OpenCode підключено, але початкове завантаження даних не вдалося.', + cliNotFound: 'OpenCode CLI не знайдено. Спершу встановіть його.', + providersReady: '✓ Провайдери', + providersLoading: '… Провайдери', + agentsReady: '✓ Агенти', + agentsLoading: '… Агенти', + startingDevServer: (hostLabel) => `Запуск dev-сервера webview (${hostLabel})...`, + waitingDevServer: (hostLabel, attempt) => `Очікування dev-сервера webview (${hostLabel})... спроба ${attempt}`, + loadingData: (providersText, agentsText) => `Завантаження даних (${providersText}, ${agentsText})…`, +}; + +const ES_MESSAGES: BootstrapMessages = { + startingApi: 'Iniciando la API de OpenCode…', + initializing: 'Inicializando…', + connecting: 'Conectando…', + connected: '¡Conectado!', + connectionError: 'Error de conexión', + disconnected: 'Desconectado', + reconnecting: 'Reconectando…', + initialDataLoadFailed: 'OpenCode se conectó, pero falló la carga inicial de datos.', + cliNotFound: 'No se encontró OpenCode CLI. Instálalo primero.', + providersReady: '✓ Proveedores', + providersLoading: '… Proveedores', + agentsReady: '✓ Agentes', + agentsLoading: '… Agentes', + startingDevServer: (hostLabel) => `Iniciando el servidor de desarrollo de webview (${hostLabel})...`, + waitingDevServer: (hostLabel, attempt) => `Esperando el servidor de desarrollo de webview (${hostLabel})... intento ${attempt}`, + loadingData: (providersText, agentsText) => `Cargando datos (${providersText}, ${agentsText})…`, +}; + +const PT_BR_MESSAGES: BootstrapMessages = { + startingApi: 'Iniciando a API do OpenCode…', + initializing: 'Inicializando…', + connecting: 'Conectando…', + connected: 'Conectado!', + connectionError: 'Erro de conexão', + disconnected: 'Desconectado', + reconnecting: 'Reconectando…', + initialDataLoadFailed: 'OpenCode conectado, mas o carregamento inicial dos dados falhou.', + cliNotFound: 'OpenCode CLI não encontrado. Instale-o primeiro.', + providersReady: '✓ Provedores', + providersLoading: '… Provedores', + agentsReady: '✓ Agentes', + agentsLoading: '… Agentes', + startingDevServer: (hostLabel) => `Iniciando o servidor de desenvolvimento da webview (${hostLabel})...`, + waitingDevServer: (hostLabel, attempt) => `Aguardando o servidor de desenvolvimento da webview (${hostLabel})... tentativa ${attempt}`, + loadingData: (providersText, agentsText) => `Carregando dados (${providersText}, ${agentsText})…`, +}; + +const KO_MESSAGES: BootstrapMessages = { + startingApi: 'OpenCode API 시작 중…', + initializing: '초기화 중…', + connecting: '연결 중…', + connected: '연결됨!', + connectionError: '연결 오류', + disconnected: '연결 끊김', + reconnecting: '다시 연결 중…', + initialDataLoadFailed: 'OpenCode에 연결되었지만 초기 데이터 로드에 실패했습니다.', + cliNotFound: 'OpenCode CLI를 찾을 수 없습니다. 먼저 설치하세요.', + providersReady: '✓ 공급자', + providersLoading: '… 공급자', + agentsReady: '✓ 에이전트', + agentsLoading: '… 에이전트', + startingDevServer: (hostLabel) => `webview 개발 서버 시작 중 (${hostLabel})...`, + waitingDevServer: (hostLabel, attempt) => `webview 개발 서버 대기 중 (${hostLabel})... ${attempt}번째 시도`, + loadingData: (providersText, agentsText) => `데이터 로드 중 (${providersText}, ${agentsText})…`, +}; + +const PL_MESSAGES: BootstrapMessages = { + startingApi: 'Uruchamianie API OpenCode…', + initializing: 'Inicjalizacja…', + connecting: 'Łączenie…', + connected: 'Połączono!', + connectionError: 'Błąd połączenia', + disconnected: 'Rozłączono', + reconnecting: 'Ponowne łączenie…', + initialDataLoadFailed: 'OpenCode połączony, ale początkowe ładowanie danych nie powiodło się.', + cliNotFound: 'Nie znaleziono OpenCode CLI. Najpierw go zainstaluj.', + providersReady: '✓ Dostawcy', + providersLoading: '… Dostawcy', + agentsReady: '✓ Agenci', + agentsLoading: '… Agenci', + startingDevServer: (hostLabel) => `Uruchamianie serwera deweloperskiego webview (${hostLabel})...`, + waitingDevServer: (hostLabel, attempt) => `Oczekiwanie na serwer deweloperski webview (${hostLabel})... próba ${attempt}`, + loadingData: (providersText, agentsText) => `Ładowanie danych (${providersText}, ${agentsText})…`, +}; + const JA_MESSAGES: BootstrapMessages = { startingApi: 'OpenCode API を起動中…', initializing: '初期化中…', @@ -77,9 +210,20 @@ const JA_MESSAGES: BootstrapMessages = { }; export const getBootstrapMessages = (locale: Locale): BootstrapMessages => { - if (locale === 'fr') return FR_MESSAGES; - if (locale === 'ja') return JA_MESSAGES; - return EN_MESSAGES; + return BOOTSTRAP_MESSAGES[locale]; +}; + +const BOOTSTRAP_MESSAGES: Record = { + en: EN_MESSAGES, + fr: FR_MESSAGES, + 'zh-CN': ZH_CN_MESSAGES, + 'zh-TW': ZH_TW_MESSAGES, + uk: UK_MESSAGES, + es: ES_MESSAGES, + 'pt-BR': PT_BR_MESSAGES, + ko: KO_MESSAGES, + pl: PL_MESSAGES, + ja: JA_MESSAGES, }; export const readStoredLocaleForBootstrap = (): Locale => { From 3c3abd2afe8bb7c29fb5d19c66d7aec45cce91af Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sat, 27 Jun 2026 01:34:27 +0300 Subject: [PATCH 0010/1073] fix(chat): keep historical session entry an instant snap to bottom, not a smooth scroll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Entering a historical session sometimes showed a smooth scroll from a mid position instead of landing instantly at the bottom. The single-writer change had startFollowLoop stop the settle burst, so a content-measurement ResizeObserver tick during restore would downgrade the authoritative instant pin into an easing follow loop that scrolled in from the partially-measured position. - startFollowLoop now yields to an active settle burst instead of stopping it. The asymmetry is intentional: the settle burst is the authoritative instant pin (session restore / goToBottom 'instant') and must not be preempted by the easing loop. startSettleBurst still stops the follow loop, so the two never write scrollTop in the same frame. - tickFollow snaps deltas larger than a full viewport (discrete jumps: late history measurement, session entry, a big block in one commit) instead of easing them; only small streaming-sized deltas ease. - restoreSnapshot mirrors goToBottom('instant') — instant write + settle burst, no startFollowLoop. --- packages/ui/src/hooks/useChatAutoFollow.ts | 39 +++++++++++++++++----- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/packages/ui/src/hooks/useChatAutoFollow.ts b/packages/ui/src/hooks/useChatAutoFollow.ts index 95c927e5..2abf35a8 100644 --- a/packages/ui/src/hooks/useChatAutoFollow.ts +++ b/packages/ui/src/hooks/useChatAutoFollow.ts @@ -204,6 +204,21 @@ export const useChatAutoFollow = ({ const current = container.scrollTop; const delta = target - current; + // A delta larger than a full viewport is a DISCRETE jump (late history + // measurement, session entry, a big block rendering in one commit) — not + // incremental streaming growth. Easing it produces a visible smooth scroll + // from a mid position to the bottom, which felt inconsistent when entering + // historical sessions. Snap such jumps; only ease small, streaming-sized + // deltas below. + if (Math.abs(delta) > container.clientHeight) { + markProgrammaticWrite(); + container.scrollTop = target; + lastScrollTopRef.current = target; + settledFramesRef.current = 0; + followRafRef.current = window.requestAnimationFrame(tickFollow); + return; + } + if (Math.abs(delta) <= SETTLE_EPSILON) { if (current !== target) { markProgrammaticWrite(); @@ -230,17 +245,20 @@ export const useChatAutoFollow = ({ const startFollowLoop = React.useCallback(() => { if (typeof window === 'undefined') return; if (stateRef.current !== 'following') return; - // Single-writer invariant: never let the easing follow loop run alongside - // the instant settle burst. They both write scrollTop every frame but aim - // at different positions (the burst snaps to the exact bottom, this loop - // eases toward it), so concurrently they fight frame-to-frame and produce - // the visible up/down jiggle during pinned content growth and sends. - stopSettleBurst(); + // Single-writer invariant, asymmetric on purpose: the settle burst is the + // AUTHORITATIVE instant pin (session restore / goToBottom 'instant'). While + // it is snapping to the bottom, YIELD — never preempt it with the easing + // follow loop. Preempting it let a content-measurement ResizeObserver tick + // downgrade an instant restore into a visible smooth scroll from a mid + // position when entering a historical session. When the burst ends, the + // next content kick starts the follow loop. (startSettleBurst still stops + // this loop, so the two never write scrollTop in the same frame.) + if (settleBurstRafRef.current !== null) return; if (followRafRef.current !== null) return; settledFramesRef.current = 0; setIsFollowingProgrammatically(true); followRafRef.current = window.requestAnimationFrame(tickFollow); - }, [stopSettleBurst, tickFollow]); + }, [tickFollow]); const writeScrollTopInstant = React.useCallback((target: number) => { const container = scrollRef.current; @@ -405,11 +423,14 @@ export const useChatAutoFollow = ({ setStateValue('following'); lastUserReleaseAtRef.current = 0; const target = Math.max(0, container.scrollHeight - container.clientHeight); + // Mirror goToBottom('instant'): jump to the bottom now, then hold it with the + // settle burst while late history content measures in. Do NOT also start the + // easing follow loop here — that is what produced the smooth scroll-from-mid + // position on session entry. writeScrollTopInstant(target); - startFollowLoop(); startSettleBurst(); return false; - }, [setStateValue, startFollowLoop, startSettleBurst, writeScrollTopInstant]); + }, [setStateValue, startSettleBurst, writeScrollTopInstant]); React.useEffect(() => { if (!currentSessionId || currentSessionId === lastSessionIdRef.current) { From 1cec2f3b1ab9ef71ef9b458558cc69a76b986cd4 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sat, 27 Jun 2026 01:50:10 +0300 Subject: [PATCH 0011/1073] release v1.13.4 --- CHANGELOG.md | 15 +++++++++++++++ package.json | 2 +- packages/electron/package.json | 2 +- packages/ui/package.json | 2 +- packages/vscode/CHANGELOG.md | 10 ++++++++++ packages/vscode/package.json | 2 +- packages/web/package.json | 2 +- 7 files changed, 30 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ae9d7a0..b44d9b73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [1.13.4] - 2026-06-27 + +- UI/Localization: added Japanese interface translations and Japanese documentation (thanks to @yuchi0531). +- Chat: queued messages can now be reordered by dragging them in the queue (thanks to @makeittech). +- Chat: sending a message now closes an open question prompt instead of leaving stale question UI in the composer (thanks to @tomzx). +- Chat: conversations pinned to the bottom no longer jiggle or double-scroll after sending, and revisiting older sessions snaps to the latest message without a smooth-scroll delay. +- Reviews: the Review changes dialog can now run an automatic review loop, with a chat banner for opening or stopping the linked review sessions. +- Models: the model picker now remembers provider group expansion and custom ordering, and Shift+Delete removes a recent model from recents (thanks to @makeittech). +- Shortcuts: the model-selector shortcut can now be customized (thanks to @makeittech). +- Agents: agent edits against an external OpenCode server no longer show a saved-state update when the save did not succeed (thanks to @makeittech). +- Providers: the add-provider form no longer loses the selected provider during background provider refreshes (thanks to @IbrahimKhan12). +- Worktrees: messages sent to new worktree sessions now wait until the worktree session is ready instead of racing ahead (thanks to @bashrusakh). +- Git: commit and pull-request generation from a draft session now starts from the created chat session instead of a temporary draft (thanks to @bashrusakh). +- CLI: startup and status commands now check the live server port before treating an existing process as the active OpenChamber server. + ## [1.13.3] - 2026-06-24 - Chat: selecting a user-installed skill from the slash command menu now invokes the skill instead of inserting the skill name as plain text (thanks to @IbrahimKhan12). diff --git a/package.json b/package.json index 197d82f7..f8c6ebfc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openchamber-monorepo", - "version": "1.13.3", + "version": "1.13.4", "description": "OpenChamber monorepo workspace for web, ui, and desktop runtimes", "private": true, "type": "module", diff --git a/packages/electron/package.json b/packages/electron/package.json index 6a23ff1b..2aaa3d7f 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -1,6 +1,6 @@ { "name": "@openchamber/electron", - "version": "1.13.3", + "version": "1.13.4", "private": true, "description": "Electron desktop runtime for OpenChamber", "author": "OpenChamber", diff --git a/packages/ui/package.json b/packages/ui/package.json index 0bb71dc0..755c40f4 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@openchamber/ui", - "version": "1.13.3", + "version": "1.13.4", "private": true, "type": "module", "main": "src/main.tsx", diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index c0687c6e..cec79fd2 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -1,3 +1,13 @@ +## [1.13.4] - 2026-06-27 + +- UI/Localization: added Japanese interface translations (thanks to @yuchi0531). +- Chat: queued messages can now be reordered by dragging them in the queue (thanks to @makeittech). +- Chat: sending a message now closes an open question prompt instead of leaving stale question UI in the composer (thanks to @tomzx). +- Chat: conversations pinned to the bottom no longer jiggle or double-scroll after sending, and revisiting older sessions snaps to the latest message without a smooth-scroll delay. +- Models: the model picker now remembers provider group expansion and custom ordering, and Shift+Delete removes a recent model from recents (thanks to @makeittech). +- Agents: agent edits against an external OpenCode server no longer show a saved-state update when the save did not succeed (thanks to @makeittech). +- Providers: the add-provider form no longer loses the selected provider during background provider refreshes (thanks to @IbrahimKhan12). + ## [1.13.3] - 2026-06-24 - Chat: selecting a user-installed skill from the slash command menu now invokes the skill instead of inserting the skill name as plain text (thanks to @IbrahimKhan12). diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 96a198e2..f2747d6f 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -2,7 +2,7 @@ "name": "openchamber", "displayName": "OpenChamber", "description": "%extension.description%", - "version": "1.13.3", + "version": "1.13.4", "publisher": "fedaykindev", "private": true, "repository": { diff --git a/packages/web/package.json b/packages/web/package.json index 6b9bab68..c03954c6 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@openchamber/web", - "version": "1.13.3", + "version": "1.13.4", "private": false, "type": "module", "main": "./server/index.js", From 38c9ff77c9bb966ffcfa274377709961ab50933e Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sat, 27 Jun 2026 09:19:24 +0300 Subject: [PATCH 0012/1073] fix: restore CLI and quota provider startup (#1857) Export ngrok tunnel capabilities for CLI startup Import Clack spinner/progress helpers used in interactive CLI paths Restore Google quota provider registry exports --- packages/web/bin/cli-output.js | 2 ++ packages/web/bin/cli-output.test.js | 36 +++++++++++++++++++ packages/web/bin/cli.test.js | 12 +++++++ .../lib/quota/providers/google/index.js | 20 +++++++---- .../web/server/lib/quota/providers/index.js | 6 ++-- .../server/lib/quota/providers/index.test.js | 17 +++++++++ .../web/server/lib/tunnels/providers/ngrok.js | 2 +- 7 files changed, 85 insertions(+), 10 deletions(-) create mode 100644 packages/web/bin/cli-output.test.js create mode 100644 packages/web/server/lib/quota/providers/index.test.js diff --git a/packages/web/bin/cli-output.js b/packages/web/bin/cli-output.js index 92a9e845..14b9c7cc 100644 --- a/packages/web/bin/cli-output.js +++ b/packages/web/bin/cli-output.js @@ -15,6 +15,8 @@ import { select, text, password, + spinner, + progress, cancel, isCancel, } from '@clack/prompts'; diff --git a/packages/web/bin/cli-output.test.js b/packages/web/bin/cli-output.test.js new file mode 100644 index 00000000..3c2b42e7 --- /dev/null +++ b/packages/web/bin/cli-output.test.js @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; + +async function withInteractiveTty(fn) { + const stdoutDescriptor = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY'); + const stdinDescriptor = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY'); + + Object.defineProperty(process.stdout, 'isTTY', { configurable: true, value: true }); + Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: true }); + + try { + return await fn(); + } finally { + if (stdoutDescriptor) { + Object.defineProperty(process.stdout, 'isTTY', stdoutDescriptor); + } else { + delete process.stdout.isTTY; + } + + if (stdinDescriptor) { + Object.defineProperty(process.stdin, 'isTTY', stdinDescriptor); + } else { + delete process.stdin.isTTY; + } + } +} + +describe('cli output', () => { + it('creates interactive clack spinner and progress helpers', async () => { + await withInteractiveTty(async () => { + const output = await import('./cli-output.js?interactive-test'); + + expect(output.createSpinner({})).toBeTruthy(); + await expect(output.createProgress({}, { max: 2 })).resolves.toBeTruthy(); + }); + }); +}); diff --git a/packages/web/bin/cli.test.js b/packages/web/bin/cli.test.js index bafef8ae..9faa2100 100644 --- a/packages/web/bin/cli.test.js +++ b/packages/web/bin/cli.test.js @@ -10,6 +10,11 @@ import { pathToFileURL } from 'url'; import { isModuleCliExecution, normalizeCliEntryPath } from './cli-entry.js'; import { requestJson } from './lib/cli-http.js'; import { inspectTunnelAttachability } from './lib/cli-lifecycle.js'; +import { DEFAULT_TUNNEL_PROVIDER_CAPABILITIES } from './lib/cli-tunnel-capabilities.js'; +import { + TUNNEL_PROVIDER_CLOUDFLARE, + TUNNEL_PROVIDER_NGROK, +} from '../server/lib/tunnels/types.js'; import { assertAuthenticatedNetworkExposure, commands, @@ -173,6 +178,13 @@ function spawnOpenChamberLikeHungServer(port) { } describe('cli args', () => { + it('loads fallback tunnel provider capabilities for CLI startup', () => { + expect(DEFAULT_TUNNEL_PROVIDER_CAPABILITIES.map((provider) => provider.provider)).toEqual([ + TUNNEL_PROVIDER_CLOUDFLARE, + TUNNEL_PROVIDER_NGROK, + ]); + }); + it('accepts legacy daemon flags as no-ops', () => { expect(parseArgs(['serve', '--daemon']).removedFlagErrors).toEqual([]); expect(parseArgs(['serve', '-d']).removedFlagErrors).toEqual([]); diff --git a/packages/web/server/lib/quota/providers/google/index.js b/packages/web/server/lib/quota/providers/google/index.js index e24b3ef7..c74497cf 100644 --- a/packages/web/server/lib/quota/providers/google/index.js +++ b/packages/web/server/lib/quota/providers/google/index.js @@ -11,12 +11,20 @@ import { fetchGoogleModels } from './api.js'; +export { resolveGoogleAuthSources } from './auth.js'; + +export const providerId = 'google'; +export const providerName = 'Google'; +export const aliases = ['google', 'google.oauth']; + +export const isConfigured = () => resolveGoogleAuthSources().length > 0; + export const fetchGoogleQuota = async () => { const authSources = resolveGoogleAuthSources(); if (!authSources.length) { return buildResult({ - providerId: 'google', - providerName: 'Google', + providerId, + providerName, ok: false, configured: false, error: 'Not configured' @@ -76,8 +84,8 @@ export const fetchGoogleQuota = async () => { if (!Object.keys(models).length) { return buildResult({ - providerId: 'google', - providerName: 'Google', + providerId, + providerName, ok: false, configured: true, error: sourceErrors[0] ?? 'Failed to fetch models' @@ -85,8 +93,8 @@ export const fetchGoogleQuota = async () => { } return buildResult({ - providerId: 'google', - providerName: 'Google', + providerId, + providerName, ok: true, configured: true, usage: { diff --git a/packages/web/server/lib/quota/providers/index.js b/packages/web/server/lib/quota/providers/index.js index 0b212d2e..c27cd46f 100644 --- a/packages/web/server/lib/quota/providers/index.js +++ b/packages/web/server/lib/quota/providers/index.js @@ -43,9 +43,9 @@ const registry = { fetchQuota: cursor.fetchQuota }, google: { - providerId: 'google', - providerName: 'Google', - isConfigured: () => google.resolveGoogleAuthSources().length > 0, + providerId: google.providerId, + providerName: google.providerName, + isConfigured: google.isConfigured, fetchQuota: google.fetchGoogleQuota }, 'zai-coding-plan': { diff --git a/packages/web/server/lib/quota/providers/index.test.js b/packages/web/server/lib/quota/providers/index.test.js new file mode 100644 index 00000000..b95cfb8a --- /dev/null +++ b/packages/web/server/lib/quota/providers/index.test.js @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; + +import * as google from './google/index.js'; +import { listConfiguredQuotaProviders } from './index.js'; + +describe('quota provider registry', () => { + it('exposes google provider configuration helpers through the provider module', () => { + expect(google.providerId).toBe('google'); + expect(google.providerName).toBe('Google'); + expect(typeof google.isConfigured).toBe('function'); + expect(typeof google.resolveGoogleAuthSources).toBe('function'); + }); + + it('can list configured providers without missing provider exports', () => { + expect(() => listConfiguredQuotaProviders()).not.toThrow(); + }); +}); diff --git a/packages/web/server/lib/tunnels/providers/ngrok.js b/packages/web/server/lib/tunnels/providers/ngrok.js index a567a36b..76d94399 100644 --- a/packages/web/server/lib/tunnels/providers/ngrok.js +++ b/packages/web/server/lib/tunnels/providers/ngrok.js @@ -13,7 +13,7 @@ import { } from '../types.js'; import { getTunnelDependencyInstallInfo } from '../install-help.js'; -const ngrokTunnelProviderCapabilities = { +export const ngrokTunnelProviderCapabilities = { provider: TUNNEL_PROVIDER_NGROK, defaults: { mode: TUNNEL_MODE_QUICK, From 5cc37d0c339c15f949350c59dacca6ea666213cd Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sat, 27 Jun 2026 09:45:34 +0300 Subject: [PATCH 0013/1073] fix: restore CLI validation and test baseline (#1857) Fixed lazy CLI helper imports for tunnel flows Restored update command version detection Made web test and dead-code commands run reliably --- package.json | 2 +- packages/web/bin/lib/cli-http.js | 1 + packages/web/bin/lib/cli-network.js | 1 + packages/web/bin/lib/cli-paths.js | 2 ++ packages/web/bin/lib/commands-tunnel.js | 8 +++-- packages/web/server/lib/git/routes.test.js | 8 ++--- packages/web/server/lib/github/routes.js | 1 + packages/web/server/lib/package-manager.js | 2 +- .../web/server/lib/package-manager.test.js | 9 +++++- packages/web/src/api/files.test.ts | 6 ++-- packages/web/test/bun-test-shim.ts | 31 +++++++++++++++++++ packages/web/vitest.config.ts | 10 ++++++ 12 files changed, 70 insertions(+), 11 deletions(-) create mode 100644 packages/web/test/bun-test-shim.ts create mode 100644 packages/web/vitest.config.ts diff --git a/package.json b/package.json index f8c6ebfc..fce229c3 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "vscode:package": "bun run --cwd packages/vscode package", "vscode:type-check": "bun run --cwd packages/vscode type-check", "docs:validate": "node scripts/docs/validate-docs.mjs", - "dead-code": "bunx knip --no-exit-code --include files,exports,nsExports,types,nsTypes,enumMembers,namespaceMembers,duplicates", + "dead-code": "bunx knip@5.80.0 --no-exit-code --include files,exports,nsExports,types,nsTypes,enumMembers,duplicates", "doctor": "node scripts/react-doctor.mjs", "icons:sprite": "node scripts/generate-file-type-sprite.mjs", "icons:generate": "bun run scripts/generate-icon-sprite.mjs", diff --git a/packages/web/bin/lib/cli-http.js b/packages/web/bin/lib/cli-http.js index b4ffb9a7..16bb1d23 100644 --- a/packages/web/bin/lib/cli-http.js +++ b/packages/web/bin/lib/cli-http.js @@ -210,6 +210,7 @@ export { requestServerShutdown, requestJson, isServerHealthReady, + waitForServerHealth, fetchTunnelProvidersFromPort, fetchSystemInfoFromPort, }; diff --git a/packages/web/bin/lib/cli-network.js b/packages/web/bin/lib/cli-network.js index 042716a5..7e0e666a 100644 --- a/packages/web/bin/lib/cli-network.js +++ b/packages/web/bin/lib/cli-network.js @@ -145,6 +145,7 @@ export { resolveServeHost, resolveApiHost, formatHostForUrl, + isUnsafeBrowserPort, buildLocalUrl, detectLanIPv4Address, assertSafeBrowserPort, diff --git a/packages/web/bin/lib/cli-paths.js b/packages/web/bin/lib/cli-paths.js index 67479fae..9932b6fa 100644 --- a/packages/web/bin/lib/cli-paths.js +++ b/packages/web/bin/lib/cli-paths.js @@ -107,5 +107,7 @@ export { getLogFilePath, getTunnelProfilesFilePath, getLegacyCloudflareManagedRemoteFilePath, + readLastManagedLocalConfigPath, + writeLastManagedLocalConfigPath, getRunDir, }; diff --git a/packages/web/bin/lib/commands-tunnel.js b/packages/web/bin/lib/commands-tunnel.js index 74ead243..9f64059c 100644 --- a/packages/web/bin/lib/commands-tunnel.js +++ b/packages/web/bin/lib/commands-tunnel.js @@ -4,7 +4,7 @@ import path from 'path'; import crypto from 'crypto'; import { EXIT_CODE, TunnelCliError } from './cli-errors.js'; import { DEFAULT_PORT, findClosestMatch, generateCompletionScript, showTunnelHelp } from './cli-args.js'; -import { requestJson, fetchSystemInfoFromPort } from './cli-http.js'; +import { requestJson, fetchSystemInfoFromPort, waitForServerHealth } from './cli-http.js'; import { discoverRunningInstances, getLatestInstance, @@ -34,7 +34,11 @@ import { resolveTunnelTtlOverrides, } from './cli-tunnel-utils.js'; import { DEFAULT_TUNNEL_PROVIDER_CAPABILITIES } from './cli-tunnel-capabilities.js'; -import { assertSafeBrowserPort, buildLocalUrl } from './cli-network.js'; +import { assertSafeBrowserPort, buildLocalUrl, isUnsafeBrowserPort } from './cli-network.js'; +import { + readLastManagedLocalConfigPath, + writeLastManagedLocalConfigPath, +} from './cli-paths.js'; import { intro as clackIntro, outro as clackOutro, diff --git a/packages/web/server/lib/git/routes.test.js b/packages/web/server/lib/git/routes.test.js index ec028d48..0c0597c8 100644 --- a/packages/web/server/lib/git/routes.test.js +++ b/packages/web/server/lib/git/routes.test.js @@ -1,11 +1,11 @@ -import { beforeEach, describe, expect, it, mock } from 'bun:test'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; const gitLibraries = { - stageFiles: mock(), - unstageFiles: mock(), + stageFiles: vi.fn(), + unstageFiles: vi.fn(), }; -mock.module('./index.js', () => ({ +vi.mock('./index.js', () => ({ stageFiles: gitLibraries.stageFiles, unstageFiles: gitLibraries.unstageFiles, })); diff --git a/packages/web/server/lib/github/routes.js b/packages/web/server/lib/github/routes.js index 5496f2ba..ea9b975f 100644 --- a/packages/web/server/lib/github/routes.js +++ b/packages/web/server/lib/github/routes.js @@ -982,6 +982,7 @@ export function registerGitHubRoutes(app) { if (upstream) { try { const { getRemotes } = await import('../git/index.js'); + const { resolveGitHubRepoFromDirectory } = await import('./index.js'); const remotes = await getRemotes(directory); for (const r of remotes) { if (r?.name) { diff --git a/packages/web/server/lib/package-manager.js b/packages/web/server/lib/package-manager.js index 40684bec..fc92af10 100644 --- a/packages/web/server/lib/package-manager.js +++ b/packages/web/server/lib/package-manager.js @@ -621,7 +621,7 @@ export function getUpdateCommand(pm = detectPackageManager()) { /** * Get current installed version from package.json */ -function getCurrentVersion() { +export function getCurrentVersion() { try { const pkgPath = path.resolve(__dirname, '..', '..', 'package.json'); const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); diff --git a/packages/web/server/lib/package-manager.test.js b/packages/web/server/lib/package-manager.test.js index 7edab852..d64bf541 100644 --- a/packages/web/server/lib/package-manager.test.js +++ b/packages/web/server/lib/package-manager.test.js @@ -6,7 +6,7 @@ vi.mock('node:child_process', () => ({ spawnSync: vi.fn(() => ({ status: 0, stdout: '/usr/local/bin', stderr: '' })), })); -const { checkForUpdates } = await import('./package-manager.js'); +const { checkForUpdates, getCurrentVersion } = await import('./package-manager.js'); /** Helper: create a fetch mock that routes by URL pattern */ function createFetchMock() { @@ -244,3 +244,10 @@ describe('checkForUpdates', () => { expect(result.available).toBe(false); }); }); + +describe('getCurrentVersion', () => { + it('is exported for the CLI update command', () => { + expect(typeof getCurrentVersion).toBe('function'); + expect(getCurrentVersion()).toMatch(/^\d+\.\d+\.\d+|unknown$/); + }); +}); diff --git a/packages/web/src/api/files.test.ts b/packages/web/src/api/files.test.ts index f1b963c2..3587da99 100644 --- a/packages/web/src/api/files.test.ts +++ b/packages/web/src/api/files.test.ts @@ -32,14 +32,16 @@ describe('createWebFilesAPI', () => { runtimeFetchMock.mockResolvedValueOnce(Response.json({ path: '/worktree-b/file.txt', isFile: true, size: 12 })); await api.statFile?.('/worktree-b/file.txt', { directory: '/worktree-a' }); - expect(runtimeFetchMock).toHaveBeenLastCalledWith('/api/fs/stat?path=%2Fworktree-b%2Ffile.txt', { + expect(runtimeFetchMock).toHaveBeenLastCalledWith('/api/fs/stat', { + query: new URLSearchParams({ path: '/worktree-b/file.txt' }), headers: { 'x-opencode-directory': '/worktree-a' }, }); runtimeFetchMock.mockResolvedValueOnce(new Response('content')); await api.readFile?.('/worktree-b/file.txt', { directory: '/worktree-a' }); - expect(runtimeFetchMock).toHaveBeenLastCalledWith('/api/fs/read?path=%2Fworktree-b%2Ffile.txt', { + expect(runtimeFetchMock).toHaveBeenLastCalledWith('/api/fs/read', { + query: new URLSearchParams({ path: '/worktree-b/file.txt' }), cache: 'default', headers: { 'x-opencode-directory': '/worktree-a' }, }); diff --git a/packages/web/test/bun-test-shim.ts b/packages/web/test/bun-test-shim.ts new file mode 100644 index 00000000..3e50f1e1 --- /dev/null +++ b/packages/web/test/bun-test-shim.ts @@ -0,0 +1,31 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + test, + vi, +} from 'vitest'; + +const mock = Object.assign( + unknown>(implementation?: T) => vi.fn(implementation), + { + module: vi.mock, + }, +); + +export { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + mock, + test, + vi, +}; diff --git a/packages/web/vitest.config.ts b/packages/web/vitest.config.ts new file mode 100644 index 00000000..275cd22f --- /dev/null +++ b/packages/web/vitest.config.ts @@ -0,0 +1,10 @@ +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + resolve: { + alias: { + 'bun:test': fileURLToPath(new URL('./test/bun-test-shim.ts', import.meta.url)), + }, + }, +}); From 7f45963050d6001cfddb4f8be9c9e33802f96f3a Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sat, 27 Jun 2026 09:49:38 +0300 Subject: [PATCH 0014/1073] release v1.13.5 --- CHANGELOG.md | 6 ++++++ package.json | 2 +- packages/electron/package.json | 2 +- packages/ui/package.json | 2 +- packages/vscode/CHANGELOG.md | 4 ++++ packages/vscode/package.json | 2 +- packages/web/package.json | 2 +- 7 files changed, 15 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b44d9b73..86ff11f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [1.13.5] - 2026-06-27 + +- CLI: global web installs no longer crash on startup when tunnel commands load ngrok capabilities. +- CLI: `openchamber update` works again, and tunnel start paths no longer fail when using managed-local config prompts, multi-instance port selection, or auto-started servers. +- GitHub/Usage: fork upstream detection and Google quota checks no longer fail because of missing server helpers. + ## [1.13.4] - 2026-06-27 - UI/Localization: added Japanese interface translations and Japanese documentation (thanks to @yuchi0531). diff --git a/package.json b/package.json index fce229c3..5dbca49e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openchamber-monorepo", - "version": "1.13.4", + "version": "1.13.5", "description": "OpenChamber monorepo workspace for web, ui, and desktop runtimes", "private": true, "type": "module", diff --git a/packages/electron/package.json b/packages/electron/package.json index 2aaa3d7f..be12e257 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -1,6 +1,6 @@ { "name": "@openchamber/electron", - "version": "1.13.4", + "version": "1.13.5", "private": true, "description": "Electron desktop runtime for OpenChamber", "author": "OpenChamber", diff --git a/packages/ui/package.json b/packages/ui/package.json index 755c40f4..8a2a5d03 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@openchamber/ui", - "version": "1.13.4", + "version": "1.13.5", "private": true, "type": "module", "main": "src/main.tsx", diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index cec79fd2..5f30afa4 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -1,3 +1,7 @@ +## [1.13.5] - 2026-06-27 + +- No notable changes. + ## [1.13.4] - 2026-06-27 - UI/Localization: added Japanese interface translations (thanks to @yuchi0531). diff --git a/packages/vscode/package.json b/packages/vscode/package.json index f2747d6f..500a1c94 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -2,7 +2,7 @@ "name": "openchamber", "displayName": "OpenChamber", "description": "%extension.description%", - "version": "1.13.4", + "version": "1.13.5", "publisher": "fedaykindev", "private": true, "repository": { diff --git a/packages/web/package.json b/packages/web/package.json index c03954c6..3abfbd9b 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@openchamber/web", - "version": "1.13.4", + "version": "1.13.5", "private": false, "type": "module", "main": "./server/index.js", From 757c7049567a814430c73d1ff7b07ae89301fa2d Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sat, 27 Jun 2026 23:45:34 +0300 Subject: [PATCH 0015/1073] fix(chat): rebuild auto-follow on a single instant writer to kill scroll twitch Replace the RAF easing follow loop + settle burst with an always-on instant-follow model: while pinned, the content ResizeObserver re-pins to the bottom synchronously (scrollTop = scrollHeight, before paint) and is the only writer of scrollTop. A position+TTL auto marker distinguishes our own programmatic writes from genuine user scrolling, so a scroll event that lands at the just-written bottom never trips a false release. This removes the feedback loop where the easing animation, growing content, and the user's own scroll all fought for scrollTop in the same frame -- the infinite twitch when scrolling down during streaming, and the jiggle on send / from the queue. The public hook interface is unchanged; all consumers keep working untouched. --- .../chat/hooks/useChatTimelineController.ts | 13 +- packages/ui/src/hooks/useChatAutoFollow.ts | 467 ++++++++---------- 2 files changed, 202 insertions(+), 278 deletions(-) diff --git a/packages/ui/src/components/chat/hooks/useChatTimelineController.ts b/packages/ui/src/components/chat/hooks/useChatTimelineController.ts index 2e5365aa..a8e72106 100644 --- a/packages/ui/src/components/chat/hooks/useChatTimelineController.ts +++ b/packages/ui/src/components/chat/hooks/useChatTimelineController.ts @@ -382,12 +382,13 @@ export const useChatTimelineController = ({ // through goToBottom — the single programmatic writer. // // A normal bottom APPEND (a sent message, a streaming part) must NOT - // re-pin here. Auto-follow's own follow loop — kicked by the content - // ResizeObserver and the streaming chunk handlers — already eases to the - // new bottom. Calling goToBottom on every append layered its settle burst - // on top of that loop: two writers aiming at different positions, which - // is exactly the up/down jiggle reported on send / from the queue / while - // streaming. So for an append we do nothing and let the follow loop own it. + // re-pin here. Auto-follow already owns the bottom: its content + // ResizeObserver re-pins instantly (scrollTop = scrollHeight, before + // paint) on every append. Re-pinning again from here would just be a + // second writer chasing the same target a frame later — redundant at + // best, and the source of the old up/down jiggle on send / from the + // queue / while streaming. So for an append we do nothing and let + // auto-follow own it. if (snap || isPrepend) { prePrependScrollRef.current = null; goToBottom('instant'); diff --git a/packages/ui/src/hooks/useChatAutoFollow.ts b/packages/ui/src/hooks/useChatAutoFollow.ts index 2abf35a8..395bb2f3 100644 --- a/packages/ui/src/hooks/useChatAutoFollow.ts +++ b/packages/ui/src/hooks/useChatAutoFollow.ts @@ -42,16 +42,37 @@ export interface UseChatAutoFollowResult { restoreSnapshot: () => Promise; } +// ────────────────────────────────────────────────────────────────────────── +// This is a direct port of opencode's `createAutoScroll` (SolidJS) to a React +// hook. The model is deliberately simple, which is what makes it flicker-free: +// +// • Auto-follow is ALWAYS on unless the user scrolled up (`released`). +// • Following the bottom is INSTANT — `scrollTop = scrollHeight` inside the +// content ResizeObserver, which fires after layout and before paint. There +// is NO easing loop and NO settle burst, so there are never two writers +// racing for `scrollTop` (the root cause of the old jiggle/double-scroll). +// • A short-lived "auto" marker (position + 1500ms) lets the scroll handler +// distinguish our own programmatic writes from genuine user scrolling, so +// a scroll event that lands at our just-written bottom never trips a false +// release. +// +// The public interface below is unchanged from the old implementation so every +// consumer (ChatContainer, message parts, the timeline controller) keeps +// working without edits. +// ────────────────────────────────────────────────────────────────────────── + const BOTTOM_SPACER_DESKTOP_VH = 0.10; const BOTTOM_SPACER_MOBILE_PX = 40; -const PROGRAMMATIC_WRITE_WINDOW_MS = 200; const SAVE_DEBOUNCE_MS = 150; -const LERP = 0.18; -const SETTLE_EPSILON = 0.5; -const SETTLE_FRAMES = 4; const TOUCH_FINGER_DOWN_THRESHOLD = 2; -const SETTLE_BURST_DURATION_MS = 280; -const REPIN_GRACE_AFTER_RELEASE_MS = 1200; +// How long an "auto" (programmatic) scroll position stays trusted. Browsers can +// dispatch the `scroll` event for our write asynchronously, after newer content +// has already changed the geometry; the window keeps us from reading that lag as +// a user scroll. Mirrors opencode's 1500ms. +const AUTO_MARK_TTL_MS = 1500; +const AUTO_MATCH_TOLERANCE_PX = 2; + +const now = (): number => (typeof performance !== 'undefined' ? performance.now() : Date.now()); // The bottom of the chat has an empty spacer (10vh on desktop, 40px on mobile) // — its height is exactly how far above scrollHeight the user can be while still @@ -68,6 +89,10 @@ const distanceFromBottom = (el: HTMLElement): number => { return el.scrollHeight - el.scrollTop - el.clientHeight; }; +const canScroll = (el: HTMLElement): boolean => { + return el.scrollHeight - el.clientHeight > 1; +}; + const isNearBottom = (el: HTMLElement, isMobile: boolean): boolean => { return distanceFromBottom(el) <= computeBottomZoneThreshold(isMobile, el); }; @@ -115,21 +140,27 @@ export const useChatAutoFollow = ({ const [showScrollButton, setShowScrollButton] = React.useState(false); const [isFollowingProgrammatically, setIsFollowingProgrammatically] = React.useState(false); + // `stateRef` is the single source of truth for follow vs released; the React + // state above is a mirror for rendering. `released === userScrolled` in + // opencode terms. const stateRef = React.useRef('following'); + const isMobileRef = React.useRef(isMobile); + isMobileRef.current = isMobile; const sessionMessageCountRef = React.useRef(sessionMessageCount); sessionMessageCountRef.current = sessionMessageCount; const currentSessionIdRef = React.useRef(currentSessionId); currentSessionIdRef.current = currentSessionId; const lastSessionIdRef = React.useRef(null); - const programmaticWriteUntilRef = React.useRef(0); - const followRafRef = React.useRef(null); - const settledFramesRef = React.useRef(0); - const lastScrollTopRef = React.useRef(0); + + // Programmatic-scroll marker (opencode's `auto`): the bottom position we last + // wrote and when. A scroll event whose scrollTop matches `top` within a few + // px while still inside the TTL is OUR write, not the user's. + const autoRef = React.useRef<{ top: number; time: number } | null>(null); + const autoTimerRef = React.useRef | null>(null); + const saveTimerRef = React.useRef | null>(null); const pendingSaveRef = React.useRef<{ sessionId: string; anchor: number } | null>(null); - const settleBurstRafRef = React.useRef(null); - const lastUserReleaseAtRef = React.useRef(0); // When restoreSnapshot is invoked while ChatViewport is still hydrating // (skeleton rendered, no scroll container yet), we record the session here // so a follow-up effect can replay the restore once the container mounts. @@ -155,210 +186,117 @@ export const useChatAutoFollow = ({ setState(next); }, []); - const markProgrammaticWrite = React.useCallback(() => { - const now = typeof performance !== 'undefined' ? performance.now() : Date.now(); - programmaticWriteUntilRef.current = now + PROGRAMMATIC_WRITE_WINDOW_MS; + // ── auto marker ──────────────────────────────────────────────────────── + const markAuto = React.useCallback((el: HTMLElement) => { + autoRef.current = { + top: Math.max(0, el.scrollHeight - el.clientHeight), + time: now(), + }; + if (autoTimerRef.current) clearTimeout(autoTimerRef.current); + autoTimerRef.current = setTimeout(() => { + autoRef.current = null; + autoTimerRef.current = null; + }, AUTO_MARK_TTL_MS); }, []); - const isInProgrammaticWindow = React.useCallback(() => { - const now = typeof performance !== 'undefined' ? performance.now() : Date.now(); - return now < programmaticWriteUntilRef.current; + const isAuto = React.useCallback((el: HTMLElement): boolean => { + const a = autoRef.current; + if (!a) return false; + if (now() - a.time > AUTO_MARK_TTL_MS) { + autoRef.current = null; + return false; + } + return Math.abs(el.scrollTop - a.top) < AUTO_MATCH_TOLERANCE_PX; }, []); - const stopFollowLoop = React.useCallback(() => { - if (followRafRef.current !== null && typeof window !== 'undefined') { - window.cancelAnimationFrame(followRafRef.current); - } - followRafRef.current = null; - settledFramesRef.current = 0; - // Only the active scroll-writer owns the "programmatic follow" flag. If the - // settle burst is still running it remains the owner, so don't clear here. - if (settleBurstRafRef.current === null) { - setIsFollowingProgrammatically(false); - } - }, []); - - const stopSettleBurst = React.useCallback(() => { - if (settleBurstRafRef.current !== null && typeof window !== 'undefined') { - window.cancelAnimationFrame(settleBurstRafRef.current); - } - settleBurstRafRef.current = null; - if (followRafRef.current === null) { - setIsFollowingProgrammatically(false); - } - }, []); - - const tickFollow = React.useCallback(() => { - followRafRef.current = null; + // ── overflow / scroll-to-bottom button ────────────────────────────────── + const updateOverflowAndButton = React.useCallback(() => { const container = scrollRef.current; if (!container) { - stopFollowLoop(); + setIsOverflowing(false); + setShowScrollButton(false); return; } - if (stateRef.current !== 'following') { - stopFollowLoop(); + const overflowing = canScroll(container); + setIsOverflowing(overflowing); + if (!overflowing) { + setShowScrollButton(false); return; } + const showButton = stateRef.current === 'released' && !isNearBottom(container, isMobileRef.current); + setShowScrollButton(showButton); + }, []); - const target = Math.max(0, container.scrollHeight - container.clientHeight); - const current = container.scrollTop; - const delta = target - current; - - // A delta larger than a full viewport is a DISCRETE jump (late history - // measurement, session entry, a big block rendering in one commit) — not - // incremental streaming growth. Easing it produces a visible smooth scroll - // from a mid position to the bottom, which felt inconsistent when entering - // historical sessions. Snap such jumps; only ease small, streaming-sized - // deltas below. - if (Math.abs(delta) > container.clientHeight) { - markProgrammaticWrite(); - container.scrollTop = target; - lastScrollTopRef.current = target; - settledFramesRef.current = 0; - followRafRef.current = window.requestAnimationFrame(tickFollow); + // ── core scroll primitives (ported from opencode) ──────────────────────── + const scrollToBottomNow = React.useCallback((behavior: ScrollBehavior) => { + const el = scrollRef.current; + if (!el) return; + markAuto(el); + if (behavior === 'smooth') { + el.scrollTo({ top: el.scrollHeight, behavior }); return; } + // Direct `scrollTop` assignment bypasses any CSS `scroll-behavior: smooth` + // and lands in the same frame — no visible catch-up animation. + el.scrollTop = el.scrollHeight; + }, [markAuto]); - if (Math.abs(delta) <= SETTLE_EPSILON) { - if (current !== target) { - markProgrammaticWrite(); - container.scrollTop = target; - lastScrollTopRef.current = target; - } - settledFramesRef.current += 1; - if (settledFramesRef.current >= SETTLE_FRAMES) { - stopFollowLoop(); - return; - } - followRafRef.current = window.requestAnimationFrame(tickFollow); + // `force` true = user-intent jump (clears released and always scrolls). + // `force` false = passive follow (only while still following). + const scrollToBottom = React.useCallback((force: boolean, behavior: ScrollBehavior = 'auto') => { + const el = scrollRef.current; + + if (force && stateRef.current !== 'following') { + setStateValue('following'); + } + if (!el) return; + if (!force && stateRef.current !== 'following') return; + + const distance = distanceFromBottom(el); + if (distance < AUTO_MATCH_TOLERANCE_PX) { + // Already at the bottom; just refresh the auto marker so the next + // scroll event is recognised as ours. + markAuto(el); return; } + scrollToBottomNow(force ? behavior : 'auto'); + }, [markAuto, scrollToBottomNow, setStateValue]); - settledFramesRef.current = 0; - const next = current + delta * LERP; - markProgrammaticWrite(); - container.scrollTop = next; - lastScrollTopRef.current = container.scrollTop; - followRafRef.current = window.requestAnimationFrame(tickFollow); - }, [markProgrammaticWrite, stopFollowLoop]); - - const startFollowLoop = React.useCallback(() => { - if (typeof window === 'undefined') return; - if (stateRef.current !== 'following') return; - // Single-writer invariant, asymmetric on purpose: the settle burst is the - // AUTHORITATIVE instant pin (session restore / goToBottom 'instant'). While - // it is snapping to the bottom, YIELD — never preempt it with the easing - // follow loop. Preempting it let a content-measurement ResizeObserver tick - // downgrade an instant restore into a visible smooth scroll from a mid - // position when entering a historical session. When the burst ends, the - // next content kick starts the follow loop. (startSettleBurst still stops - // this loop, so the two never write scrollTop in the same frame.) - if (settleBurstRafRef.current !== null) return; - if (followRafRef.current !== null) return; - settledFramesRef.current = 0; - setIsFollowingProgrammatically(true); - followRafRef.current = window.requestAnimationFrame(tickFollow); - }, [tickFollow]); - - const writeScrollTopInstant = React.useCallback((target: number) => { - const container = scrollRef.current; - if (!container) return; - const max = Math.max(0, container.scrollHeight - container.clientHeight); - const clamped = Math.max(0, Math.min(target, max)); - markProgrammaticWrite(); - container.scrollTop = clamped; - lastScrollTopRef.current = container.scrollTop; - }, [markProgrammaticWrite]); - - const startSettleBurst = React.useCallback(() => { - if (typeof window === 'undefined') return; - // Single-writer invariant (mirror of startFollowLoop): the settle burst is - // taking over scroll ownership, so stop the easing follow loop first. The - // two must never write scrollTop in the same frame. - stopFollowLoop(); - stopSettleBurst(); - setIsFollowingProgrammatically(true); - const until = (typeof performance !== 'undefined' ? performance.now() : Date.now()) + SETTLE_BURST_DURATION_MS; - const finish = () => { - settleBurstRafRef.current = null; - if (followRafRef.current === null) { - setIsFollowingProgrammatically(false); - } - }; - const tick = () => { - settleBurstRafRef.current = null; - if (stateRef.current !== 'following') { - finish(); - return; - } - const c = scrollRef.current; - if (!c) { - finish(); - return; - } - const target = Math.max(0, c.scrollHeight - c.clientHeight); - if (Math.abs(c.scrollTop - target) > SETTLE_EPSILON) { - markProgrammaticWrite(); - c.scrollTop = target; - lastScrollTopRef.current = target; - } - const now = typeof performance !== 'undefined' ? performance.now() : Date.now(); - if (now < until) { - settleBurstRafRef.current = window.requestAnimationFrame(tick); - } else { - finish(); - } - }; - settleBurstRafRef.current = window.requestAnimationFrame(tick); - }, [markProgrammaticWrite, stopFollowLoop, stopSettleBurst]); - - const releaseAutoFollow = React.useCallback(() => { - stopFollowLoop(); - stopSettleBurst(); - lastUserReleaseAtRef.current = typeof performance !== 'undefined' ? performance.now() : Date.now(); + // User left the bottom — release auto-follow. + const stop = React.useCallback(() => { + const el = scrollRef.current; + if (!el) return; + if (!canScroll(el)) { + setStateValue('following'); + return; + } + if (stateRef.current === 'released') return; setStateValue('released'); - }, [setStateValue, stopFollowLoop, stopSettleBurst]); - - const releaseFromUserIntent = React.useCallback(() => { - if (stateRef.current === 'following') { - stopFollowLoop(); - stopSettleBurst(); - lastUserReleaseAtRef.current = typeof performance !== 'undefined' ? performance.now() : Date.now(); - setStateValue('released'); - } else { - lastUserReleaseAtRef.current = typeof performance !== 'undefined' ? performance.now() : Date.now(); - } - }, [setStateValue, stopFollowLoop, stopSettleBurst]); + updateOverflowAndButton(); + }, [setStateValue, updateOverflowAndButton]); + // ── public scroll API (mapped onto the primitives) ─────────────────────── const goToBottom = React.useCallback((mode: 'instant' | 'smooth' = 'instant') => { - const container = scrollRef.current; - setStateValue('following'); - lastUserReleaseAtRef.current = 0; - if (!container) return; - if (mode === 'smooth') { - startFollowLoop(); - return; - } - const target = Math.max(0, container.scrollHeight - container.clientHeight); - writeScrollTopInstant(target); - startSettleBurst(); - }, [setStateValue, startFollowLoop, startSettleBurst, writeScrollTopInstant]); + scrollToBottom(true, mode === 'smooth' ? 'smooth' : 'auto'); + }, [scrollToBottom]); const scrollToBottomOnSend = React.useCallback(() => { - // Keep a SINGLE movement to the just-sent message. - // If we're already following the bottom, the optimistic message is eased - // into view by the follow loop (kicked by the content ResizeObserver). Just - // (re)kick that one owner — do NOT also fire an instant goToBottom here, or - // the instant snap races the easing loop and you see a visible double scroll - // (ease, then snap). - if (stateRef.current === 'following') { - startFollowLoop(); - return; - } - // Scrolled up (released): bring the user down to the message they just sent. - goToBottom('instant'); - }, [goToBottom, startFollowLoop]); + // Single movement to the just-sent message. Force re-pins to the bottom + // whether we were following or scrolled up; the content ResizeObserver + // keeps us pinned as the optimistic message and its reply stream in. + scrollToBottom(true); + }, [scrollToBottom]); + const releaseAutoFollow = React.useCallback(() => { + setStateValue('released'); + updateOverflowAndButton(); + }, [setStateValue, updateOverflowAndButton]); + + const releaseFromUserIntent = React.useCallback(() => { + stop(); + }, [stop]); + + // ── per-session snapshot persistence (kept; restore still goes to bottom) ─ const flushSave = React.useCallback(() => { if (saveTimerRef.current !== null) { clearTimeout(saveTimerRef.current); @@ -417,21 +355,16 @@ export const useChatAutoFollow = ({ } pendingInitialRestoreRef.current = null; - // Always return to the bottom on session switch. The previous saved-ratio - // restore had a low success rate and, by landing 'released' partway up, - // produced the visible backward jump as content finished loading. + // Always return to the bottom on session switch (opencode resumes on the + // same edge). The content ResizeObserver re-pins instantly as late + // history measures in, so there is no smooth scroll-from-mid artifact. setStateValue('following'); - lastUserReleaseAtRef.current = 0; - const target = Math.max(0, container.scrollHeight - container.clientHeight); - // Mirror goToBottom('instant'): jump to the bottom now, then hold it with the - // settle burst while late history content measures in. Do NOT also start the - // easing follow loop here — that is what produced the smooth scroll-from-mid - // position on session entry. - writeScrollTopInstant(target); - startSettleBurst(); + scrollToBottom(true); + updateOverflowAndButton(); return false; - }, [setStateValue, startSettleBurst, writeScrollTopInstant]); + }, [scrollToBottom, setStateValue, updateOverflowAndButton]); + // ── session change ─────────────────────────────────────────────────────── React.useEffect(() => { if (!currentSessionId || currentSessionId === lastSessionIdRef.current) { return; @@ -439,20 +372,28 @@ export const useChatAutoFollow = ({ lastSessionIdRef.current = currentSessionId; MessageFreshnessDetector.getInstance().recordSessionStart(currentSessionId); flushSave(); - stopFollowLoop(); - stopSettleBurst(); - markProgrammaticWrite(); + autoRef.current = null; // Drop any pending restore request inherited from a different session. if (pendingInitialRestoreRef.current && pendingInitialRestoreRef.current !== currentSessionId) { pendingInitialRestoreRef.current = null; } - }, [currentSessionId, flushSave, markProgrammaticWrite, stopFollowLoop, stopSettleBurst]); + }, [currentSessionId, flushSave]); + // When work begins (a reply starts streaming) and we are still following, + // make sure we are pinned to the bottom. Mirrors opencode's `working` effect. React.useEffect(() => { if (sessionIsWorking && stateRef.current === 'following') { - startFollowLoop(); + scrollToBottom(false); } - }, [sessionIsWorking, startFollowLoop]); + }, [sessionIsWorking, scrollToBottom]); + + // Suppress the overlay scrollbar thumb only while we are actively following a + // live stream (the thumb would otherwise jump on every instant re-pin). When + // idle or released the scrollbar behaves normally. Stable: changes only when + // follow-state or working-state flips, not on every frame. + React.useEffect(() => { + setIsFollowingProgrammatically(state === 'following' && sessionIsWorking); + }, [state, sessionIsWorking]); // Replay a deferred restoreSnapshot once ChatViewport mounts. // useLayoutEffect ensures scroll position is set before the browser paints, @@ -464,67 +405,38 @@ export const useChatAutoFollow = ({ } }, [containerEl, currentSessionId, restoreSnapshot]); - const updateOverflowAndButton = React.useCallback(() => { - const container = scrollRef.current; - if (!container) { - setIsOverflowing(false); - setShowScrollButton(false); - return; - } - const overflowing = container.scrollHeight > container.clientHeight + 1; - setIsOverflowing(overflowing); - if (!overflowing) { - setShowScrollButton(false); - return; - } - const showButton = stateRef.current === 'released' && !isNearBottom(container, isMobile); - setShowScrollButton(showButton); - }, [isMobile]); - + // ── scroll event handling (ported from opencode handleScroll) ──────────── const handleScrollEvent = React.useCallback(() => { - const container = scrollRef.current; - if (!container) return; - - const programmatic = isInProgrammaticWindow(); - const currentTop = container.scrollTop; - lastScrollTopRef.current = currentTop; + const el = scrollRef.current; + if (!el) return; updateOverflowAndButton(); - if (programmatic) { + if (!canScroll(el)) { + setStateValue('following'); return; } - // Release auto-follow only when the user has actually left the near-bottom - // zone — not on the small scrollTop clamp the browser applies when the - // composer grows and shrinks the viewport (which keeps you at the bottom). - // Position-based, mirroring the re-pin check below; this removes the false - // release that produced the visible backward jump on session switch. - if (stateRef.current === 'following' && !isNearBottom(container, isMobile)) { - stopFollowLoop(); - stopSettleBurst(); - lastUserReleaseAtRef.current = typeof performance !== 'undefined' ? performance.now() : Date.now(); - setStateValue('released'); - } - - const now = typeof performance !== 'undefined' ? performance.now() : Date.now(); - const inGrace = (now - lastUserReleaseAtRef.current) < REPIN_GRACE_AFTER_RELEASE_MS; - if (stateRef.current === 'released' && isNearBottom(container, isMobile) && !inGrace) { + // Within the bottom zone → (re-)pin to following. This is how scrolling + // back down to the bottom resumes auto-follow. + if (isNearBottom(el, isMobileRef.current)) { setStateValue('following'); - startFollowLoop(); + queueSave(); + return; } + // Our own programmatic write that landed at the bottom but where content + // grew between the write and this event — keep following, don't release. + if (stateRef.current === 'following' && isAuto(el)) { + scrollToBottom(false); + queueSave(); + return; + } + + // Genuine user scroll away from the bottom. + stop(); queueSave(); - }, [ - isInProgrammaticWindow, - isMobile, - queueSave, - setStateValue, - startFollowLoop, - stopFollowLoop, - stopSettleBurst, - updateOverflowAndButton, - ]); + }, [isAuto, queueSave, scrollToBottom, setStateValue, stop, updateOverflowAndButton]); React.useEffect(() => { const container = containerEl; @@ -596,15 +508,24 @@ export const useChatAutoFollow = ({ }; }, [containerEl, handleScrollEvent, releaseFromUserIntent]); + // The heart of the follow behaviour: the content ResizeObserver fires after + // layout and before paint, so re-pinning to the bottom here is invisible — + // there is no "jump up then catch up". Observe both the container (composer + // growth shrinks the viewport) and the inner content (streaming growth). React.useEffect(() => { const container = containerEl; if (!container || typeof ResizeObserver === 'undefined') return; const observer = new ResizeObserver(() => { - updateOverflowAndButton(); - if (stateRef.current === 'following') { - startFollowLoop(); + const el = scrollRef.current; + if (el && !canScroll(el)) { + setStateValue('following'); + updateOverflowAndButton(); + return; } + updateOverflowAndButton(); + if (stateRef.current !== 'following') return; + scrollToBottom(false); }); observer.observe(container); const inner = container.firstElementChild; @@ -612,7 +533,7 @@ export const useChatAutoFollow = ({ observer.observe(inner); } return () => observer.disconnect(); - }, [containerEl, startFollowLoop, updateOverflowAndButton]); + }, [containerEl, scrollToBottom, setStateValue, updateOverflowAndButton]); React.useEffect(() => { updateOverflowAndButton(); @@ -622,9 +543,9 @@ export const useChatAutoFollow = ({ void _reason; updateOverflowAndButton(); if (stateRef.current === 'following') { - startFollowLoop(); + scrollToBottom(false); } - }, [startFollowLoop, updateOverflowAndButton]); + }, [scrollToBottom, updateOverflowAndButton]); const animationHandlersRef = React.useRef>(new Map()); @@ -634,7 +555,7 @@ export const useChatAutoFollow = ({ const kick = () => { if (stateRef.current === 'following') { - startFollowLoop(); + scrollToBottom(false); } }; @@ -651,19 +572,21 @@ export const useChatAutoFollow = ({ }; animationHandlersRef.current.set(messageId, handlers); return handlers; - }, [startFollowLoop, updateOverflowAndButton]); + }, [scrollToBottom, updateOverflowAndButton]); React.useEffect(() => { return () => { - stopFollowLoop(); - stopSettleBurst(); + if (autoTimerRef.current) { + clearTimeout(autoTimerRef.current); + autoTimerRef.current = null; + } flushSave(); if (saveTimerRef.current !== null) { clearTimeout(saveTimerRef.current); saveTimerRef.current = null; } }; - }, [flushSave, stopFollowLoop, stopSettleBurst]); + }, [flushSave]); React.useEffect(() => { if (!onActiveTurnChange) return; From 1b67aa3c217114c0aec7e1285580eef97dc95542 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 28 Jun 2026 00:10:35 +0300 Subject: [PATCH 0016/1073] fix: prevent duplicate preview URL auth tokens Stops embedded browser URLs from accumulating repeated oc_url_token params Strips preview-only query params before persisting browser navigation Adds regression coverage for replacing URL auth tokens --- .../ui/src/components/layout/ContextPanel.tsx | 23 +++++++++++++++++-- packages/ui/src/lib/runtime-url.test.ts | 13 +++++++++++ packages/ui/src/lib/runtime-url.ts | 14 +++++------ 3 files changed, 40 insertions(+), 10 deletions(-) diff --git a/packages/ui/src/components/layout/ContextPanel.tsx b/packages/ui/src/components/layout/ContextPanel.tsx index ca1767cb..d45f993d 100644 --- a/packages/ui/src/components/layout/ContextPanel.tsx +++ b/packages/ui/src/components/layout/ContextPanel.tsx @@ -447,6 +447,21 @@ const stripPreviewTokenFromUrl = (value: string): string => { return value; } }; + +const stripPreviewQueryParams = (value: string): string => { + if (!value) return value; + try { + const parsed = new URL(value); + parsed.searchParams.delete('ocPreview'); + parsed.searchParams.delete('oc_preview_token'); + parsed.searchParams.delete('oc_client_token'); + parsed.searchParams.delete('oc_url_token'); + return parsed.toString(); + } catch { + return value; + } +}; + const PreviewPane: React.FC = ({ rawUrl, onNavigate }) => { const { t } = useI18n(); const { currentTheme } = useThemeSystem(); @@ -593,6 +608,8 @@ const PreviewPane: React.FC = ({ rawUrl, onNavigate }) => { ? (() => { const path = normalizedUrl.pathname || '/'; const searchParams = new URLSearchParams(normalizedUrl.search); + searchParams.delete('oc_url_token'); + searchParams.delete('oc_client_token'); searchParams.set('ocPreview', String(reloadNonce)); searchParams.set('oc_preview_token', proxyState.previewToken || ''); const search = searchParams.toString(); @@ -1394,6 +1411,8 @@ const IframeBrowserPane: React.FC = ({ initialUrl, dire const parsed = new URL(currentUrl); const path = parsed.pathname || '/'; const searchParams = new URLSearchParams(parsed.search); + searchParams.delete('oc_url_token'); + searchParams.delete('oc_client_token'); searchParams.set('ocPreview', String(reloadNonce)); searchParams.set('oc_preview_token', proxyState.previewToken || ''); const search = searchParams.toString(); @@ -1418,7 +1437,7 @@ const IframeBrowserPane: React.FC = ({ initialUrl, dire const rest = parsedFrameUrl.pathname.slice(proxyBasePath.length) || '/'; const upstreamOrigin = new URL(currentUrl).origin; - return new URL(`${rest}${parsedFrameUrl.search}${parsedFrameUrl.hash}`, upstreamOrigin).toString(); + return stripPreviewQueryParams(new URL(`${rest}${parsedFrameUrl.search}${parsedFrameUrl.hash}`, upstreamOrigin).toString()); } catch { return ''; } @@ -1440,7 +1459,7 @@ const IframeBrowserPane: React.FC = ({ initialUrl, dire return ''; } - return new URL(`${parsedFrameUrl.pathname}${parsedFrameUrl.search}${parsedFrameUrl.hash}`, upstreamOrigin).toString(); + return stripPreviewQueryParams(new URL(`${parsedFrameUrl.pathname}${parsedFrameUrl.search}${parsedFrameUrl.hash}`, upstreamOrigin).toString()); } catch { return ''; } diff --git a/packages/ui/src/lib/runtime-url.test.ts b/packages/ui/src/lib/runtime-url.test.ts index 631076cf..2b30836c 100644 --- a/packages/ui/src/lib/runtime-url.test.ts +++ b/packages/ui/src/lib/runtime-url.test.ts @@ -120,6 +120,19 @@ describe('createRuntimeUrlResolver', () => { } }); + test('replaces existing short-lived URL auth query on relative authenticated URLs', () => { + setRuntimeUrlAuthToken('oc_url_secret', Date.now() + 60_000); + try { + const urls = createRuntimeUrlResolver(); + + expect(urls.authenticatedAsset('/api/preview/proxy/abc/?oc_url_token=stale&x=1#top')).toBe( + '/api/preview/proxy/abc/?oc_url_token=oc_url_secret&x=1#top', + ); + } finally { + setRuntimeUrlAuthToken(null, null); + } + }); + test('does not put the long-lived client token in URLs', () => { setRuntimeBearerToken('oc_client_secret'); try { diff --git a/packages/ui/src/lib/runtime-url.ts b/packages/ui/src/lib/runtime-url.ts index d1bc46ab..ba3797c0 100644 --- a/packages/ui/src/lib/runtime-url.ts +++ b/packages/ui/src/lib/runtime-url.ts @@ -91,14 +91,12 @@ const withUrlAuth = (urlValue: string): string => { const token = getRuntimeUrlAuthTokenSync(); if (!token) return urlValue; - if (ABSOLUTE_URL_PATTERN.test(urlValue)) { - const url = new URL(urlValue); - url.searchParams.set('oc_url_token', token); - return url.toString(); - } - - const separator = urlValue.includes('?') ? '&' : '?'; - return `${urlValue}${separator}oc_url_token=${encodeURIComponent(token)}`; + const url = ABSOLUTE_URL_PATTERN.test(urlValue) + ? new URL(urlValue) + : new URL(urlValue, 'http://openchamber.local'); + url.searchParams.set('oc_url_token', token); + if (ABSOLUTE_URL_PATTERN.test(urlValue)) return url.toString(); + return `${url.pathname}${url.search}${url.hash}`; }; const toWebSocketUrl = (candidate: string, config: RuntimeUrlConfig): string => { From 00efd8cc4b6ba9ea543324e78a1d24f52d26b279 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 28 Jun 2026 01:05:44 +0300 Subject: [PATCH 0017/1073] fix: mark context panel chat sessions as seen Clears unread state when a chat session is active in the context panel Keeps embedded chat presence in sync while the window is focused --- .../ui/src/components/layout/ContextPanel.tsx | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/packages/ui/src/components/layout/ContextPanel.tsx b/packages/ui/src/components/layout/ContextPanel.tsx index d45f993d..7e9bbee8 100644 --- a/packages/ui/src/components/layout/ContextPanel.tsx +++ b/packages/ui/src/components/layout/ContextPanel.tsx @@ -17,6 +17,8 @@ import { useUIStore, type ContextPanelMode } from '@/stores/useUIStore'; import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useInputStore } from '@/sync/input-store'; +import { markSessionViewed } from '@/sync/notification-store'; +import { setExternallyViewedSession } from '@/sync/sync-context'; import { ContextPanelContent } from './ContextSidebarTab'; import { toast } from '@/components/ui'; import { runtimeFetch } from '@/lib/runtime-fetch'; @@ -2176,6 +2178,35 @@ export const ContextPanel: React.FC = () => { }, [activeTab, directoryKey, setSelectedFilePath]); const activeChatTabID = activeTab?.mode === 'chat' ? activeTab.id : null; + const activeChatSessionID = activeTab?.mode === 'chat' ? getSessionIDFromDedupeKey(activeTab.dedupeKey) : null; + + React.useEffect(() => { + if (!isOpen || !directoryKey || !activeChatSessionID || typeof window === 'undefined') { + return; + } + + const markActiveChatViewed = () => { + if (document.visibilityState === 'hidden' || !document.hasFocus()) { + setExternallyViewedSession(directoryKey, activeChatSessionID, false); + return; + } + + markSessionViewed(activeChatSessionID); + setExternallyViewedSession(directoryKey, activeChatSessionID, true); + }; + + markActiveChatViewed(); + const interval = window.setInterval(markActiveChatViewed, 10_000); + window.addEventListener('focus', markActiveChatViewed); + document.addEventListener('visibilitychange', markActiveChatViewed); + + return () => { + window.clearInterval(interval); + window.removeEventListener('focus', markActiveChatViewed); + document.removeEventListener('visibilitychange', markActiveChatViewed); + setExternallyViewedSession(directoryKey, activeChatSessionID, false); + }; + }, [activeChatSessionID, directoryKey, isOpen]); const getEmbeddedChatSrc = React.useCallback((tabID: string, sessionID: string, readOnly: boolean): string => { return getOrCreateEmbeddedSessionChatURL(chatFrameSrcByTabIDRef.current, tabID, sessionID, directoryKey || null, readOnly, { From 84d7303346538260c065ed53bc32f3cd71420e44 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 28 Jun 2026 01:07:52 +0300 Subject: [PATCH 0018/1073] feat(desktop): add macOS dock badge for chats with unseen activity Show a count of chats (root sessions) with unseen activity on the macOS dock icon. The count is computed in the existing tray snapshot (full cross-project list, not the capped tray view; a subtask's unseen rolls up to its root only when subtask notifications are enabled) and pushed to the main process over the existing desktop_tray_update IPC, which calls app.setBadgeCount (0 clears it). The badge clears as sessions are marked seen on window focus. Add a Dock badge toggle in Appearance settings (default on, persisted, darwin desktop only), localized across all dictionaries, with a matching settings-search entry whose availability mirrors the render guard exactly. --- packages/electron/main.mjs | 11 +++++ .../openchamber/OpenChamberVisualSettings.tsx | 43 +++++++++++++++++++ .../ui/src/components/views/SettingsView.tsx | 8 +++- packages/ui/src/hooks/useTraySync.ts | 29 ++++++++++++- .../ui/src/lib/i18n/messages/en.settings.ts | 2 + .../ui/src/lib/i18n/messages/es.settings.ts | 2 + .../ui/src/lib/i18n/messages/fr.settings.ts | 2 + .../ui/src/lib/i18n/messages/ja.settings.ts | 2 + .../ui/src/lib/i18n/messages/ko.settings.ts | 2 + .../ui/src/lib/i18n/messages/pl.settings.ts | 2 + .../src/lib/i18n/messages/pt-BR.settings.ts | 2 + .../ui/src/lib/i18n/messages/uk.settings.ts | 2 + .../src/lib/i18n/messages/zh-CN.settings.ts | 2 + .../src/lib/i18n/messages/zh-TW.settings.ts | 2 + packages/ui/src/lib/settings/search.ts | 12 ++++++ packages/ui/src/stores/useUIStore.ts | 9 ++++ 16 files changed, 129 insertions(+), 3 deletions(-) diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index b706fcbc..27312ef4 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -3265,6 +3265,17 @@ const handleInvoke = async (browserWindow, command, args = {}) => { log.warn('[electron] tray update failed', error); } } + // Dock badge: count of chats with unseen activity (0 = cleared, also when + // the user disabled the badge). setBadgeCount drives the macOS dock badge. + try { + const rawCount = args && typeof args.dockBadgeCount === 'number' ? args.dockBadgeCount : 0; + const badgeCount = Number.isFinite(rawCount) ? Math.max(0, Math.floor(rawCount)) : 0; + if (typeof app.setBadgeCount === 'function') { + app.setBadgeCount(badgeCount); + } + } catch (error) { + log.warn('[electron] dock badge update failed', error); + } return null; case 'desktop_clear_cache': diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx index aea0f322..c1efef3d 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx @@ -336,6 +336,17 @@ export const OpenChamberVisualSettings: React.FC const macVibrancyEnabled = typeof window !== 'undefined' && window.__OPENCHAMBER_ELECTRON__?.macVibrancy === true; const [vibrancyChecked, setVibrancyChecked] = React.useState(macVibrancyEnabled); const [vibrancyRestarting, setVibrancyRestarting] = React.useState(false); + + // macOS-desktop-only dock badge that counts chats with unseen activity. + // The tray sync (mac-only) pumps the count to the main process, so the + // toggle is offered only where it actually has an effect. No relaunch needed. + const dockBadgeSupported = React.useMemo( + () => isDesktopShell() && typeof window !== 'undefined' + && (window as unknown as { __OPENCHAMBER_PLATFORM__?: string }).__OPENCHAMBER_PLATFORM__ === 'darwin', + [], + ); + const dockBadgeEnabled = useUIStore(state => state.dockBadgeEnabled); + const setDockBadgeEnabled = useUIStore(state => state.setDockBadgeEnabled); const [chatRenderPreviewTick, setChatRenderPreviewTick] = React.useState(0); const reportUsage = useUIStore(state => state.reportUsage); const setReportUsage = useUIStore(state => state.setReportUsage); @@ -854,6 +865,38 @@ export const OpenChamberVisualSettings: React.FC )}
)} + + {dockBadgeSupported && ( +
+
setDockBadgeEnabled(!dockBadgeEnabled)} + onKeyDown={(event) => { + if (event.key === ' ' || event.key === 'Enter') { + event.preventDefault(); + setDockBadgeEnabled(!dockBadgeEnabled); + } + }} + > + +
+ + {t('settings.openchamber.visual.field.dockBadge')} + + + {t('settings.openchamber.visual.field.dockBadgeHint')} + +
+
+
+ )} )} diff --git a/packages/ui/src/components/views/SettingsView.tsx b/packages/ui/src/components/views/SettingsView.tsx index 25fee971..ed89324f 100644 --- a/packages/ui/src/components/views/SettingsView.tsx +++ b/packages/ui/src/components/views/SettingsView.tsx @@ -330,6 +330,10 @@ export const SettingsView: React.FC = ({ onClose, forceMobile const isDesktopLocalOrigin = React.useMemo(() => { return isDesktopShell() && isDesktopLocalOriginActive(); }, []); + const isMac = React.useMemo(() => { + return isDesktopShell() && typeof window !== 'undefined' + && (window as unknown as { __OPENCHAMBER_PLATFORM__?: string }).__OPENCHAMBER_PLATFORM__ === 'darwin'; + }, []); // keep platform check available for future window chrome tweaks @@ -532,12 +536,12 @@ export const SettingsView: React.FC = ({ onClose, forceMobile const settingsSearchResults = React.useMemo(() => { return buildSettingsSearchResults({ query: settingsSearchQuery, - runtimeCtx: { ...runtimeCtx, isDesktopLocalOrigin }, + runtimeCtx: { ...runtimeCtx, isDesktopLocalOrigin, isMac }, visiblePageSlugs, t, getPageTitle, }); - }, [getPageTitle, isDesktopLocalOrigin, runtimeCtx, settingsSearchQuery, t, visiblePageSlugs]); + }, [getPageTitle, isDesktopLocalOrigin, isMac, runtimeCtx, settingsSearchQuery, t, visiblePageSlugs]); const prepareSettingsSearchTarget = React.useCallback((result: SettingsSearchResult): string => { if (result.id.startsWith('agents.')) { diff --git a/packages/ui/src/hooks/useTraySync.ts b/packages/ui/src/hooks/useTraySync.ts index 3f860119..0a10a32e 100644 --- a/packages/ui/src/hooks/useTraySync.ts +++ b/packages/ui/src/hooks/useTraySync.ts @@ -19,6 +19,7 @@ import { QUOTA_PROVIDERS, formatWindowLabel, formatQuotaValueLabel } from '@/lib import { useProjectsStore } from '@/stores/useProjectsStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useGitStore } from '@/stores/useGitStore'; +import { useUIStore } from '@/stores/useUIStore'; import { resolveProjectForSessionDirectory, normalizeProjectPath } from '@/lib/projectResolution'; import type { ProjectEntry } from '@/lib/api/types'; import type { WorktreeMetadata } from '@/types/worktree'; @@ -80,6 +81,9 @@ type TraySnapshot = { // dropdown (same "configured to show" rule as the header/mobile). Empty // groups → the tray omits the Usage submenu entirely. usage: TrayUsage; + // Number of chats (root sessions) with unseen activity, for the macOS dock + // badge. 0 when the user disabled the badge — the main process clears it. + dockBadgeCount: number; }; // focus-session / new-session are routed natively by the main process through @@ -389,7 +393,26 @@ const buildSnapshot = (instanceName: string): TraySnapshot => { const approvals = live.approvals.map((a) => ({ ...a, sessionTitle: titleById.get(a.sessionId) || '' })); - return { sessions, approvals, instanceName, usage: buildUsage() }; + // Dock badge: count chats (root sessions) with unseen activity over the FULL + // cross-project list — not the MAX_SESSIONS-capped `sessions` above — so the + // number is accurate even with many projects. A subtask's unseen rolls up to + // its root only when the user opted into subtask notifications, matching the + // sidebar's needs-attention rule. + const ui = useUIStore.getState(); + let dockBadgeCount = 0; + if (ui.dockBadgeEnabled) { + for (const session of allSessions) { + if (!session?.id || session.parentID) continue; // roots only + let familyUnseen = notif.unseenCount[session.id] ?? 0; + if (familyUnseen === 0 && ui.notifyOnSubtasks) { + familyUnseen = collectDescendants(session.id) + .reduce((sum, id) => sum + (notif.unseenCount[id] ?? 0), 0); + } + if (familyUnseen > 0) dockBadgeCount += 1; + } + } + + return { sessions, approvals, instanceName, usage: buildUsage(), dockBadgeCount }; }; export const useTraySync = (): void => { @@ -491,6 +514,9 @@ export const useTraySync = (): void => { const unsubscribeProjects = useProjectsStore.subscribe(() => scheduleFlush()); const unsubscribeWorktrees = useSessionUIStore.subscribe(() => scheduleFlush()); const unsubscribeGit = useGitStore.subscribe(() => scheduleFlush()); + // The dock-badge toggle and subtask-notification preference live here; a + // change must re-push the snapshot so the badge appears/clears immediately. + const unsubscribeUI = useUIStore.subscribe(() => scheduleFlush()); // Cross-project status map: fed live by the sync dispatcher from the global // event stream, and seeded/reconciled by the poll below. const unsubscribeGlobalStatus = useGlobalSessionStatusStore.subscribe(() => scheduleFlush()); @@ -541,6 +567,7 @@ export const useTraySync = (): void => { unsubscribeProjects(); unsubscribeWorktrees(); unsubscribeGit(); + unsubscribeUI(); unsubscribeGlobalStatus(); unsubscribeQuota(); unsubscribeRegistry?.(); diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 9012bd25..c425b72e 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -1606,6 +1606,8 @@ export const settingsDict = { 'settings.openchamber.visual.actions.reloadingThemes': 'Reloading themes...', 'settings.openchamber.visual.field.macVibrancy': 'Window transparency', 'settings.openchamber.visual.field.macVibrancyHint': 'Use the native macOS blur (vibrancy) behind the sidebar. Turn off for fully solid, opaque surfaces.', + 'settings.openchamber.visual.field.dockBadge': 'Dock badge', + 'settings.openchamber.visual.field.dockBadgeHint': 'Show a count of chats with unseen activity on the macOS dock icon.', 'settings.openchamber.visual.actions.saveAndRestart': 'Save & restart', 'settings.openchamber.visual.actions.restarting': 'Restarting…', 'settings.openchamber.visual.field.themeImportInfoAria': 'Theme import info', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index 9a2c92f3..06851680 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -1572,6 +1572,8 @@ export const settingsDict = { "settings.openchamber.visual.actions.reloadThemes": "Recargar temas", "settings.openchamber.visual.field.macVibrancy": "Transparencia de la ventana", "settings.openchamber.visual.field.macVibrancyHint": "Usa el desenfoque nativo de macOS (vibrancy) detrás de la barra lateral. Desactívalo para superficies totalmente sólidas y opacas.", + "settings.openchamber.visual.field.dockBadge": "Insignia en el Dock", + "settings.openchamber.visual.field.dockBadgeHint": "Muestra en el icono del Dock de macOS el número de chats con actividad sin ver.", "settings.openchamber.visual.actions.saveAndRestart": "Guardar y reiniciar", "settings.openchamber.visual.actions.restarting": "Reiniciando…", "settings.openchamber.visual.actions.reloadingThemes": "Recargando temas...", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index a8bd66e9..8d1daedd 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -1762,6 +1762,8 @@ export const settingsDict = { 'settings.openchamber.visual.option.mobileLayout.new': 'Nouvelle', 'settings.openchamber.visual.field.macVibrancy': 'Transparence de la fenêtre', 'settings.openchamber.visual.field.macVibrancyHint': 'Utiliser le flou macOS natif (vibrancy) derrière la barre latérale. Désactivez cette option pour des surfaces entièrement opaques.', + 'settings.openchamber.visual.field.dockBadge': 'Badge du Dock', + 'settings.openchamber.visual.field.dockBadgeHint': 'Afficher sur l’icône du Dock de macOS le nombre de discussions avec une activité non vue.', 'settings.openchamber.visual.actions.saveAndRestart': 'Enregistrer et redémarrer', 'settings.openchamber.visual.actions.restarting': 'Redémarrage…', 'settings.openchamber.visual.field.fileEditorKeymap': 'Keymap de l’éditeur de fichiers', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index 6437c188..160bcd77 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -1606,6 +1606,8 @@ export const settingsDict = { 'settings.openchamber.visual.actions.reloadingThemes': 'テーマを再読み込み中...', 'settings.openchamber.visual.field.macVibrancy': 'ウィンドウ透過', 'settings.openchamber.visual.field.macVibrancyHint': 'サイドバーの背後に macOS ネイティブのぼかし(Vibrancy)を使用します。完全に不透明なサーフェスにする場合はオフにします。', + 'settings.openchamber.visual.field.dockBadge': 'Dock バッジ', + 'settings.openchamber.visual.field.dockBadgeHint': '未読のアクティビティがあるチャットの数を macOS の Dock アイコンに表示します。', 'settings.openchamber.visual.actions.saveAndRestart': '保存して再起動', 'settings.openchamber.visual.actions.restarting': '再起動中...', 'settings.openchamber.visual.field.themeImportInfoAria': 'テーマインポート情報', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index d898681b..7aac2181 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -1572,6 +1572,8 @@ export const settingsDict = { 'settings.openchamber.visual.actions.reloadThemes': '테마 다시 로드', 'settings.openchamber.visual.field.macVibrancy': '창 투명도', 'settings.openchamber.visual.field.macVibrancyHint': '사이드바 뒤에 macOS 기본 블러(vibrancy)를 사용합니다. 완전히 불투명한 표면을 원하면 끄세요.', + 'settings.openchamber.visual.field.dockBadge': 'Dock 배지', + 'settings.openchamber.visual.field.dockBadgeHint': '확인하지 않은 활동이 있는 채팅 수를 macOS Dock 아이콘에 표시합니다.', 'settings.openchamber.visual.actions.saveAndRestart': '저장 후 재시작', 'settings.openchamber.visual.actions.restarting': '재시작 중…', 'settings.openchamber.visual.actions.reloadingThemes': '테마 다시 로드 중...', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index da16e9c5..cd62d24d 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -907,6 +907,8 @@ export const settingsDict = { 'settings.openchamber.visual.actions.reloadThemes': 'Przeładuj motywy', 'settings.openchamber.visual.field.macVibrancy': 'Przezroczystość okna', 'settings.openchamber.visual.field.macVibrancyHint': 'Używaj natywnego rozmycia macOS (vibrancy) za panelem bocznym. Wyłącz, aby uzyskać w pełni nieprzezroczyste powierzchnie.', + 'settings.openchamber.visual.field.dockBadge': 'Plakietka w Docku', + 'settings.openchamber.visual.field.dockBadgeHint': 'Pokazuj na ikonie w Docku macOS liczbę czatów z nieprzeczytaną aktywnością.', 'settings.openchamber.visual.actions.saveAndRestart': 'Zapisz i uruchom ponownie', 'settings.openchamber.visual.actions.restarting': 'Ponowne uruchamianie…', 'settings.openchamber.visual.actions.reloadingThemes': 'Przeładowywanie motywów...', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index 4aab66bc..3c38e551 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -1572,6 +1572,8 @@ export const settingsDict = { "settings.openchamber.visual.actions.reloadThemes": "Recarregar temas", "settings.openchamber.visual.field.macVibrancy": "Transparência da janela", "settings.openchamber.visual.field.macVibrancyHint": "Usa o desfoque nativo do macOS (vibrancy) atrás da barra lateral. Desative para superfícies totalmente sólidas e opacas.", + "settings.openchamber.visual.field.dockBadge": "Selo no Dock", + "settings.openchamber.visual.field.dockBadgeHint": "Mostra no ícone do Dock do macOS o número de conversas com atividade não vista.", "settings.openchamber.visual.actions.saveAndRestart": "Salvar e reiniciar", "settings.openchamber.visual.actions.restarting": "Reiniciando…", "settings.openchamber.visual.actions.reloadingThemes": "Recarregando temas...", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index e62eda5e..8d4f408d 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -1572,6 +1572,8 @@ export const settingsDict = { "settings.openchamber.visual.actions.reloadThemes": "Перезавантажити теми", "settings.openchamber.visual.field.macVibrancy": "Прозорість вікна", "settings.openchamber.visual.field.macVibrancyHint": "Використовувати нативне розмиття macOS (vibrancy) під сайдбаром. Вимкніть для повністю непрозорих поверхонь.", + "settings.openchamber.visual.field.dockBadge": "Лічильник у доку", + "settings.openchamber.visual.field.dockBadgeHint": "Показувати на іконці в доку кількість чатів із непрочитаною активністю.", "settings.openchamber.visual.actions.saveAndRestart": "Зберегти та перезапустити", "settings.openchamber.visual.actions.restarting": "Перезапуск…", "settings.openchamber.visual.actions.reloadingThemes": "Перезавантаження тем...", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index 6d9ad39b..49a1db3e 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -1572,6 +1572,8 @@ export const settingsDict = { 'settings.openchamber.visual.actions.reloadThemes': '重新加载主题', 'settings.openchamber.visual.field.macVibrancy': '窗口透明度', 'settings.openchamber.visual.field.macVibrancyHint': '在侧边栏后使用 macOS 原生模糊(vibrancy)。关闭以获得完全不透明的界面。', + 'settings.openchamber.visual.field.dockBadge': 'Dock 角标', + 'settings.openchamber.visual.field.dockBadgeHint': '在 macOS Dock 图标上显示有未读动态的会话数量。', 'settings.openchamber.visual.actions.saveAndRestart': '保存并重启', 'settings.openchamber.visual.actions.restarting': '正在重启…', 'settings.openchamber.visual.actions.reloadingThemes': '正在重新加载主题...', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index 953c7174..24af70a1 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -1491,6 +1491,8 @@ 'settings.openchamber.visual.actions.reloadThemes': '重新載入主題', 'settings.openchamber.visual.field.macVibrancy': '視窗透明度', 'settings.openchamber.visual.field.macVibrancyHint': '在側邊欄後使用 macOS 原生模糊(vibrancy)。關閉以獲得完全不透明的介面。', + 'settings.openchamber.visual.field.dockBadge': 'Dock 標記', + 'settings.openchamber.visual.field.dockBadgeHint': '在 macOS Dock 圖示上顯示有未讀動態的對話數量。', 'settings.openchamber.visual.actions.saveAndRestart': '儲存並重新啟動', 'settings.openchamber.visual.actions.restarting': '正在重新啟動…', 'settings.openchamber.visual.actions.reloadingThemes': '正在重新載入主題...', diff --git a/packages/ui/src/lib/settings/search.ts b/packages/ui/src/lib/settings/search.ts index 3d24931b..3a74ce8e 100644 --- a/packages/ui/src/lib/settings/search.ts +++ b/packages/ui/src/lib/settings/search.ts @@ -20,6 +20,8 @@ export interface SettingsSearchResult extends SettingsSearchItem { interface SettingsSearchAvailabilityContext extends SettingsRuntimeContext { isMobile: boolean; isDesktopLocalOrigin: boolean; + // macOS desktop shell — for controls that only render on darwin (e.g. dock badge). + isMac: boolean; } const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ @@ -65,6 +67,16 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ keywords: ['transparent', 'transparency', 'vibrancy', 'blur', 'macos', 'opaque'], isAvailable: (ctx) => ctx.isDesktopLocalOrigin, }, + { + id: 'appearance.dock-badge', + page: 'appearance', + titleKey: 'settings.openchamber.visual.field.dockBadge', + descriptionKey: 'settings.openchamber.visual.field.dockBadgeHint', + keywords: ['dock', 'badge', 'unread', 'unseen', 'counter', 'count', 'notification', 'macos'], + // Exactly matches the render guard in OpenChamberVisualSettings: any darwin + // Electron shell (isMac already implies isDesktopShell), local or remote host. + isAvailable: (ctx) => ctx.isMac, + }, { id: 'appearance.pwa-install-name', page: 'appearance', diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 450ee6e4..6033f607 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -592,6 +592,8 @@ interface UIStore { nativeNotificationsEnabled: boolean; notificationMode: 'always' | 'hidden-only'; notifyOnSubtasks: boolean; + // Desktop dock badge showing the count of sessions with unseen activity (macOS). + dockBadgeEnabled: boolean; // Event toggles (which events trigger notifications) notifyOnCompletion: boolean; @@ -750,6 +752,7 @@ interface UIStore { setNotificationMode: (mode: 'always' | 'hidden-only') => void; setShowTerminalQuickKeysOnDesktop: (value: boolean) => void; setNotifyOnSubtasks: (value: boolean) => void; + setDockBadgeEnabled: (value: boolean) => void; setNotifyOnCompletion: (value: boolean) => void; setNotifyOnError: (value: boolean) => void; setNotifyOnQuestion: (value: boolean) => void; @@ -877,6 +880,7 @@ export const useUIStore = create()( nativeNotificationsEnabled: false, notificationMode: 'hidden-only', notifyOnSubtasks: true, + dockBadgeEnabled: true, // Event toggles (which events trigger notifications) notifyOnCompletion: true, @@ -1975,6 +1979,10 @@ export const useUIStore = create()( set({ notifyOnSubtasks: value }); }, + setDockBadgeEnabled: (value) => { + set({ dockBadgeEnabled: value }); + }, + setNotifyOnCompletion: (value) => { set({ notifyOnCompletion: value }); }, setNotifyOnError: (value) => { set({ notifyOnError: value }); }, setNotifyOnQuestion: (value) => { set({ notifyOnQuestion: value }); }, @@ -2243,6 +2251,7 @@ export const useUIStore = create()( notificationMode: state.notificationMode, showTerminalQuickKeysOnDesktop: state.showTerminalQuickKeysOnDesktop, notifyOnSubtasks: state.notifyOnSubtasks, + dockBadgeEnabled: state.dockBadgeEnabled, notifyOnCompletion: state.notifyOnCompletion, notifyOnError: state.notifyOnError, notifyOnQuestion: state.notifyOnQuestion, From 4d4b3aa9d999bdeedfe85386e7fb08cc1be168e8 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 28 Jun 2026 01:39:15 +0300 Subject: [PATCH 0019/1073] fix: improve context panel session visibility Marks active embedded chat sessions as seen only while focused Shows real session titles for context panel chat tabs Names review sessions after the implementation session --- .../ui/src/components/layout/ContextPanel.tsx | 80 ++++++++++++++++++- .../session/sidebar/SessionNodeItem.tsx | 3 +- packages/ui/src/lib/reviewFlow.ts | 9 ++- packages/ui/src/stores/useUIStore.ts | 6 ++ 4 files changed, 91 insertions(+), 7 deletions(-) diff --git a/packages/ui/src/components/layout/ContextPanel.tsx b/packages/ui/src/components/layout/ContextPanel.tsx index 7e9bbee8..28644e82 100644 --- a/packages/ui/src/components/layout/ContextPanel.tsx +++ b/packages/ui/src/components/layout/ContextPanel.tsx @@ -18,7 +18,7 @@ import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore' import { useSessionUIStore } from '@/sync/session-ui-store'; import { useInputStore } from '@/sync/input-store'; import { markSessionViewed } from '@/sync/notification-store'; -import { setExternallyViewedSession } from '@/sync/sync-context'; +import { setExternallyViewedSession, useDirectoryStore } from '@/sync/sync-context'; import { ContextPanelContent } from './ContextSidebarTab'; import { toast } from '@/components/ui'; import { runtimeFetch } from '@/lib/runtime-fetch'; @@ -45,6 +45,7 @@ const CONTEXT_PANEL_MAX_WIDTH = 1400; const CONTEXT_PANEL_DEFAULT_WIDTH = 600; const CONTEXT_TAB_LABEL_MAX_CHARS = 24; type TranslateFn = ReturnType['t']; +const EMPTY_SESSION_TITLE_MAP = new Map(); type PreviewConsoleEvent = { id: number; @@ -177,9 +178,27 @@ const getFileNameFromPath = (path: string | null): string | null => { }; const getTabLabel = ( - tab: { mode: ContextPanelMode; label: string | null; targetPath: string | null; stagedDiff?: boolean }, + tab: { mode: ContextPanelMode; label: string | null; targetPath: string | null; dedupeKey?: string; sessionTitleFallback?: string | null; stagedDiff?: boolean }, + sessionTitleById: ReadonlyMap, t: TranslateFn ): string => { + if (tab.mode === 'chat') { + const sessionID = getSessionIDFromDedupeKey(tab.dedupeKey); + if (sessionID) { + const sessionTitle = sessionTitleById.get(sessionID)?.trim(); + if (sessionTitle) { + return sessionTitle; + } + } + + const sessionTitleFallback = tab.sessionTitleFallback?.trim(); + if (sessionTitleFallback) { + return sessionTitleFallback; + } + + return t('contextPanel.mode.chat'); + } + if (tab.label) { return tab.label; } @@ -251,6 +270,47 @@ const getSessionIDFromDedupeKey = (dedupeKey: string | undefined): string | null return sessionID || null; }; +const areTitleMapsEqual = (a: ReadonlyMap, b: ReadonlyMap): boolean => { + if (a.size !== b.size) return false; + for (const [key, value] of a) { + if (b.get(key) !== value) return false; + } + return true; +}; + +const buildSessionTitleMap = (sessions: Array<{ id: string; title?: string | null }>, sessionIDs: readonly string[]): Map => { + if (sessionIDs.length === 0) return EMPTY_SESSION_TITLE_MAP; + const wanted = new Set(sessionIDs); + const next = new Map(); + for (const session of sessions) { + if (!wanted.has(session.id)) continue; + const title = session.title?.trim(); + if (title) next.set(session.id, title); + } + return next.size === 0 ? EMPTY_SESSION_TITLE_MAP : next; +}; + +const useSessionTitleMap = (directory: string | undefined, sessionIDs: readonly string[]): ReadonlyMap => { + const store = useDirectoryStore(directory); + const snapshotRef = React.useRef>(EMPTY_SESSION_TITLE_MAP); + const sessionIDsRef = React.useRef(sessionIDs); + + sessionIDsRef.current = sessionIDs; + + return React.useSyncExternalStore( + store.subscribe, + React.useCallback(() => { + const next = buildSessionTitleMap(store.getState().session, sessionIDsRef.current); + if (areTitleMapsEqual(snapshotRef.current, next)) { + return snapshotRef.current; + } + snapshotRef.current = next; + return next; + }, [store]), + () => EMPTY_SESSION_TITLE_MAP, + ); +}; + const DESKTOP_BROWSER_INSPECT_SCRIPT = `new Promise((resolve) => { const existing = document.getElementById('__openchamber_desktop_browser_overlay'); if (existing) existing.remove(); @@ -2010,6 +2070,16 @@ export const ContextPanel: React.FC = () => { const isOpen = Boolean(panelState?.isOpen && activeTab); const isExpanded = Boolean(isOpen && panelState?.expanded); const width = clampWidth(panelState?.width ?? CONTEXT_PANEL_DEFAULT_WIDTH); + const chatSessionIDs = React.useMemo(() => { + const ids: string[] = []; + for (const tab of tabs) { + if (tab.mode !== 'chat') continue; + const sessionID = getSessionIDFromDedupeKey(tab.dedupeKey); + if (sessionID && !ids.includes(sessionID)) ids.push(sessionID); + } + return ids; + }, [tabs]); + const sessionTitleById = useSessionTitleMap(directoryKey || undefined, chatSessionIDs); const [isResizing, setIsResizing] = React.useState(false); const [suppressWidthTransition, setSuppressWidthTransition] = React.useState(false); @@ -2198,11 +2268,13 @@ export const ContextPanel: React.FC = () => { markActiveChatViewed(); const interval = window.setInterval(markActiveChatViewed, 10_000); window.addEventListener('focus', markActiveChatViewed); + window.addEventListener('blur', markActiveChatViewed); document.addEventListener('visibilitychange', markActiveChatViewed); return () => { window.clearInterval(interval); window.removeEventListener('focus', markActiveChatViewed); + window.removeEventListener('blur', markActiveChatViewed); document.removeEventListener('visibilitychange', markActiveChatViewed); setExternallyViewedSession(directoryKey, activeChatSessionID, false); }; @@ -2356,7 +2428,7 @@ export const ContextPanel: React.FC = () => { }, [darkThemeId, lightThemeId, postEmbeddedVisibilityToChats, postThemeSyncToEmbeddedChat, tabs, themeMode]); const tabItems = React.useMemo(() => tabs.map((tab) => { - const rawLabel = getTabLabel(tab, t); + const rawLabel = getTabLabel(tab, sessionTitleById, t); const label = truncateTabLabel(rawLabel, CONTEXT_TAB_LABEL_MAX_CHARS); const tabPathLabel = getRelativePathLabel(tab.targetPath, effectiveDirectory); return { @@ -2366,7 +2438,7 @@ export const ContextPanel: React.FC = () => { title: tabPathLabel ? `${rawLabel}: ${tabPathLabel}` : rawLabel, closeLabel: t('contextPanel.tab.closeTabAria', { label }), }; - }), [effectiveDirectory, t, tabs]); + }), [effectiveDirectory, sessionTitleById, t, tabs]); const activeNonChatContent = activeTab?.mode === 'context' ? diff --git a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx index a20e8feb..89dc1620 100644 --- a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx +++ b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx @@ -82,7 +82,7 @@ type Props = { removeSessionFromFolder: (scopeKey: string, sessionId: string) => void; addSessionToFolder: (scopeKey: string, folderId: string, sessionId: string) => void; createFolderAndStartRename: (scopeKey: string, parentId?: string | null) => { id: string } | null; - openContextPanelTab: (directory: string, options: { mode: 'chat'; dedupeKey: string; label: string; readOnly?: boolean }) => void; + openContextPanelTab: (directory: string, options: { mode: 'chat'; dedupeKey: string; label: string; sessionTitleFallback?: string; readOnly?: boolean }) => void; handleDeleteSession: (session: Session, source?: { archivedBucket?: boolean; hardDelete?: boolean; skipConfirm?: boolean }) => void; mobileVariant: boolean; alwaysShowActions: boolean; @@ -867,6 +867,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { mode: 'chat', dedupeKey: `session:${session.id}`, label: sessionTitle, + sessionTitleFallback: sessionTitle, }); }} className="[&>svg]:mr-1" diff --git a/packages/ui/src/lib/reviewFlow.ts b/packages/ui/src/lib/reviewFlow.ts index 432ff43d..26915e57 100644 --- a/packages/ui/src/lib/reviewFlow.ts +++ b/packages/ui/src/lib/reviewFlow.ts @@ -27,7 +27,6 @@ const AUTO_REVIEW_POLL_MS = 300; const AUTO_REVIEW_MAX_ITERATIONS = 15; const AUTO_REVIEW_FINAL_MARKER = 'FINAL_REVIEW_STATUS: no_remaining_findings'; const AUTO_REVIEW_FINAL_MARKER_NORMALIZED = AUTO_REVIEW_FINAL_MARKER.toLowerCase(); -const REVIEW_SESSION_TITLE = 'Review of workspace changes'; const activeAutoReviewLoops = new Set(); const activeAutoReviewForwardKeys = new Set(); @@ -417,6 +416,7 @@ const openReviewSessionPanel = (directory: string, session: Session): void => { mode: 'chat', dedupeKey: `session:${session.id}`, label: session.title ?? null, + sessionTitleFallback: session.title ?? null, }); }; @@ -428,6 +428,11 @@ const getSessionOrNull = async (sessionID: string, directory: string): Promise { + const implementationTitle = original.title?.trim() || original.id; + return `Review: ${implementationTitle}`; +}; + const createOrReuseReviewSession = async (originalSessionID: string, directory: string, expectedRuntimeKey?: string): Promise => { assertAutoReviewRuntimeStillCurrent(expectedRuntimeKey); const original = await opencodeClient.getSession(originalSessionID, directory); @@ -451,7 +456,7 @@ const createOrReuseReviewSession = async (originalSessionID: string, directory: assertAutoReviewRuntimeStillCurrent(expectedRuntimeKey); const review = await opencodeClient.createSession({ - title: REVIEW_SESSION_TITLE, + title: getReviewSessionTitle(original), metadata: withReviewSessionMarker({}, originalSessionID), }, directory); assertAutoReviewRuntimeStillCurrent(expectedRuntimeKey); diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 6033f607..e4db27f1 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -31,6 +31,7 @@ type ContextPanelTab = { targetPath: string | null; dedupeKey: string; label: string | null; + sessionTitleFallback: string | null; readOnly: boolean; stagedDiff: boolean; touchedAt: number; @@ -41,6 +42,7 @@ type ContextPanelTabDescriptor = { targetPath?: string | null; dedupeKey?: string | null; label?: string | null; + sessionTitleFallback?: string | null; readOnly?: boolean; stagedDiff?: boolean; }; @@ -223,6 +225,7 @@ const createContextPanelTab = (descriptor: ContextPanelTabDescriptor): ContextPa targetPath: normalizedTargetPath, dedupeKey, label: normalizeContextTabLabel(descriptor.label), + sessionTitleFallback: normalizeContextTabLabel(descriptor.sessionTitleFallback), readOnly: descriptor.readOnly === true, stagedDiff: descriptor.stagedDiff === true, touchedAt: Date.now(), @@ -263,6 +266,7 @@ const sanitizeContextPanelTabs = (tabs: unknown): ContextPanelTab[] => { targetPath?: unknown; dedupeKey?: unknown; label?: unknown; + sessionTitleFallback?: unknown; readOnly?: unknown; stagedDiff?: unknown; touchedAt?: unknown; @@ -290,6 +294,7 @@ const sanitizeContextPanelTabs = (tabs: unknown): ContextPanelTab[] => { targetPath, dedupeKey, label: normalizeContextTabLabel(typeof candidate.label === 'string' ? candidate.label : null), + sessionTitleFallback: normalizeContextTabLabel(typeof candidate.sessionTitleFallback === 'string' ? candidate.sessionTitleFallback : null), readOnly: candidate.readOnly === true, stagedDiff: candidate.stagedDiff === true, touchedAt: typeof candidate.touchedAt === 'number' && Number.isFinite(candidate.touchedAt) @@ -350,6 +355,7 @@ const upsertContextPanelTab = ( targetPath: nextTab.targetPath || tab.targetPath, dedupeKey: nextTab.dedupeKey, label: nextTab.label, + sessionTitleFallback: nextTab.sessionTitleFallback || tab.sessionTitleFallback, stagedDiff: nextTab.stagedDiff, readOnly: nextTab.readOnly, touchedAt: Date.now(), From 1ac8fbad701e05ec710419f8fbda00bdc273578a Mon Sep 17 00:00:00 2001 From: FoundrySeven <119387799+foundryseven@users.noreply.github.com> Date: Sun, 28 Jun 2026 08:39:44 +1000 Subject: [PATCH 0020/1073] fix(ui): actually shrink typography classes on mobile viewports (#1791) The mobile stylesheet at packages/ui/src/styles/mobile.css defines a @media (max-width: 1024px) block that overrides the --text-ui-header, --text-ui-label, --text-meta, and --text-micro custom properties to shrink labels on phones. The values it was setting were equal to (or only 1px smaller than) the desktop defaults, so the model selector, agent selector, and any other element using these classes rendered at the same size on a 360px-wide phone as on a 1920px desktop. Fix: tighten the mobile values to 0.875rem (ui-header), 0.75rem (ui-label, meta), and 0.6875rem (micro), so that mobile is genuinely smaller than desktop. The same change is applied to the iOS PWA standalone block lower in the file so both code paths produce the same size. Fixes #504 Signed-off-by: Bohdan Triapitsyn Co-authored-by: James Pinnell Co-authored-by: Bohdan Triapitsyn --- CHANGELOG.md | 2 ++ packages/ui/src/styles/mobile.css | 24 ++++++++++++------------ 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86ff11f4..830b5cd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- Chat: selecting a user-installed skill from the slash command menu now invokes the skill and injects its content, instead of inserting the skill name as plain text. +- UI: typography classes (ui-header, ui-label, meta, micro) now actually shrink on mobile viewports — they previously rendered at the same size as desktop despite the mobile clamp rules. ## [1.13.5] - 2026-06-27 - CLI: global web installs no longer crash on startup when tunnel commands load ngrok capabilities. diff --git a/packages/ui/src/styles/mobile.css b/packages/ui/src/styles/mobile.css index 20cb5b9c..d03f4873 100644 --- a/packages/ui/src/styles/mobile.css +++ b/packages/ui/src/styles/mobile.css @@ -44,20 +44,20 @@ :root.mobile-pointer:not(.desktop-runtime) { --text-markdown: 1rem; --text-code: 0.875rem; - --text-ui-header: 0.9375rem; - --text-ui-label: 0.875rem; - --text-meta: 0.875rem; - --text-micro: 0.8125rem; + --text-ui-header: 0.875rem; + --text-ui-label: 0.75rem; + --text-meta: 0.75rem; + --text-micro: 0.6875rem; } /* Force override with higher specificity */ :root.mobile-pointer:not(.desktop-runtime) * { --text-markdown: 1rem !important; --text-code: 0.875rem !important; - --text-ui-header: 0.9375rem !important; - --text-ui-label: 0.875rem !important; - --text-meta: 0.875rem !important; - --text-micro: 0.8125rem !important; + --text-ui-header: 0.875rem !important; + --text-ui-label: 0.75rem !important; + --text-meta: 0.75rem !important; + --text-micro: 0.6875rem !important; } /* Additional override for elements using typography classes */ @@ -325,19 +325,19 @@ } .typography-ui-header { - font-size: 0.9375rem !important; + font-size: 0.875rem !important; } .typography-ui-label { - font-size: 0.875rem !important; + font-size: 0.75rem !important; } .typography-meta { - font-size: 0.875rem !important; + font-size: 0.75rem !important; } .typography-micro { - font-size: 0.8125rem !important; + font-size: 0.6875rem !important; } /* Fix font size for tool displays */ From 5034ad27a832ea48b11976355885ff9f64f3445d Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 28 Jun 2026 02:00:17 +0300 Subject: [PATCH 0021/1073] release v1.13.6 --- CHANGELOG.md | 9 ++++++++- package.json | 2 +- packages/electron/package.json | 2 +- packages/ui/package.json | 2 +- packages/vscode/CHANGELOG.md | 4 ++++ packages/vscode/package.json | 2 +- packages/web/package.json | 2 +- 7 files changed, 17 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 830b5cd9..76781dd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,15 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [1.13.6] - 2026-06-28 + +- Chat: scrolling in conversations now stays steady while sending, queueing, streaming, switching sessions, and loading older messages. - Chat: selecting a user-installed skill from the slash command menu now invokes the skill and injects its content, instead of inserting the skill name as plain text. -- UI: typography classes (ui-header, ui-label, meta, micro) now actually shrink on mobile viewports — they previously rendered at the same size as desktop despite the mobile clamp rules. +- Context Panel: chat tabs now use the session title and mark the open chat as seen while you are viewing it. +- Desktop/macOS: the Dock icon can now show a badge count for chats with unseen activity, with a new Appearance setting to turn it off. +- Context Panel: Browser and Preview tabs no longer accumulate duplicate auth tokens in their URLs after reloads or navigation. +- UI: typography classes (ui-header, ui-label, meta, micro) now actually shrink on mobile viewports — they previously rendered at the same size as desktop despite the mobile clamp rules (thanks to @foundryseven). + ## [1.13.5] - 2026-06-27 - CLI: global web installs no longer crash on startup when tunnel commands load ngrok capabilities. diff --git a/package.json b/package.json index 5dbca49e..ab6bc430 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openchamber-monorepo", - "version": "1.13.5", + "version": "1.13.6", "description": "OpenChamber monorepo workspace for web, ui, and desktop runtimes", "private": true, "type": "module", diff --git a/packages/electron/package.json b/packages/electron/package.json index be12e257..78654969 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -1,6 +1,6 @@ { "name": "@openchamber/electron", - "version": "1.13.5", + "version": "1.13.6", "private": true, "description": "Electron desktop runtime for OpenChamber", "author": "OpenChamber", diff --git a/packages/ui/package.json b/packages/ui/package.json index 8a2a5d03..f1c5b219 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@openchamber/ui", - "version": "1.13.5", + "version": "1.13.6", "private": true, "type": "module", "main": "src/main.tsx", diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index 5f30afa4..905b8351 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -1,3 +1,7 @@ +## [1.13.6] - 2026-06-28 + +- Chat: scrolling in conversations now stays steady while sending, queueing, streaming, switching sessions, and loading older messages. + ## [1.13.5] - 2026-06-27 - No notable changes. diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 500a1c94..a441351d 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -2,7 +2,7 @@ "name": "openchamber", "displayName": "OpenChamber", "description": "%extension.description%", - "version": "1.13.5", + "version": "1.13.6", "publisher": "fedaykindev", "private": true, "repository": { diff --git a/packages/web/package.json b/packages/web/package.json index 3abfbd9b..9f410745 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@openchamber/web", - "version": "1.13.5", + "version": "1.13.6", "private": false, "type": "module", "main": "./server/index.js", From b72230bc243451639ac7feca178ea71b6536fd22 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 28 Jun 2026 09:58:45 +0300 Subject: [PATCH 0022/1073] fix: restore update command helpers (#1857) Exported package-manager helpers used by openchamber update Added regression coverage for the update-available path --- packages/web/bin/lib/commands-update.test.js | 50 +++++++++++++++++++ packages/web/server/lib/package-manager.js | 4 +- .../web/server/lib/package-manager.test.js | 14 +++++- 3 files changed, 65 insertions(+), 3 deletions(-) create mode 100644 packages/web/bin/lib/commands-update.test.js diff --git a/packages/web/bin/lib/commands-update.test.js b/packages/web/bin/lib/commands-update.test.js new file mode 100644 index 00000000..f4e11492 --- /dev/null +++ b/packages/web/bin/lib/commands-update.test.js @@ -0,0 +1,50 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { describe, expect, it, vi } from 'vitest'; + +import { createUpdateCommand } from './commands-update.js'; + +async function withTempOpenChamberDataDir(fn) { + const previous = process.env.OPENCHAMBER_DATA_DIR; + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-update-test-')); + process.env.OPENCHAMBER_DATA_DIR = dir; + try { + return await fn(dir); + } finally { + if (typeof previous === 'string') { + process.env.OPENCHAMBER_DATA_DIR = previous; + } else { + delete process.env.OPENCHAMBER_DATA_DIR; + } + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +describe('update command', () => { + it('uses the package-manager helpers on the update-available path', async () => { + await withTempOpenChamberDataDir(async () => { + const originalWrite = process.stdout.write; + process.stdout.write = vi.fn(() => true); + const executeUpdate = vi.fn(() => ({ success: true, exitCode: 0 })); + const updateCommand = createUpdateCommand({ + packageManagerPath: '/fake/package-manager.js', + serveCommand: vi.fn(), + importFromFilePath: vi.fn(async () => ({ + checkForUpdates: vi.fn(async () => ({ available: true, version: '9.9.9' })), + detectPackageManager: vi.fn(() => 'npm'), + executeUpdate, + getCurrentVersion: vi.fn(() => '1.0.0'), + })), + }); + + try { + await updateCommand({ json: true }); + + expect(executeUpdate).toHaveBeenCalledWith('npm', { silent: true }); + } finally { + process.stdout.write = originalWrite; + } + }); + }); +}); diff --git a/packages/web/server/lib/package-manager.js b/packages/web/server/lib/package-manager.js index fc92af10..ec482a8e 100644 --- a/packages/web/server/lib/package-manager.js +++ b/packages/web/server/lib/package-manager.js @@ -483,7 +483,7 @@ export function detectPackageManagerDetails() { }; } -function detectPackageManager() { +export function detectPackageManager() { return detectPackageManagerDetails().packageManager; } @@ -769,7 +769,7 @@ export async function checkForUpdates(options = {}) { /** * Execute the update (used by CLI) */ -function executeUpdate(pm = detectPackageManager(), options = {}) { +export function executeUpdate(pm = detectPackageManager(), options = {}) { const command = getUpdateCommand(pm); if (!options?.silent) { console.log(`Updating ${PACKAGE_NAME} using ${pm}...`); diff --git a/packages/web/server/lib/package-manager.test.js b/packages/web/server/lib/package-manager.test.js index d64bf541..a8b962e6 100644 --- a/packages/web/server/lib/package-manager.test.js +++ b/packages/web/server/lib/package-manager.test.js @@ -6,7 +6,12 @@ vi.mock('node:child_process', () => ({ spawnSync: vi.fn(() => ({ status: 0, stdout: '/usr/local/bin', stderr: '' })), })); -const { checkForUpdates, getCurrentVersion } = await import('./package-manager.js'); +const { + checkForUpdates, + detectPackageManager, + executeUpdate, + getCurrentVersion, +} = await import('./package-manager.js'); /** Helper: create a fetch mock that routes by URL pattern */ function createFetchMock() { @@ -251,3 +256,10 @@ describe('getCurrentVersion', () => { expect(getCurrentVersion()).toMatch(/^\d+\.\d+\.\d+|unknown$/); }); }); + +describe('CLI update exports', () => { + it('exports package-manager helpers used by the update command', () => { + expect(typeof detectPackageManager).toBe('function'); + expect(typeof executeUpdate).toBe('function'); + }); +}); From b2598fa45ecd9f67be0939602ec71a8b9b233103 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 28 Jun 2026 12:01:26 +0300 Subject: [PATCH 0023/1073] fix(chat): stop scroll twitch and message skipping with expanded tools Gate passive auto-follow on active (working/settling) state so idle layout churn from virtualizer re-measurement no longer re-pins the viewport to the bottom. Render default-open tool bodies synchronously on mount so the virtualizer measures the real row height up front instead of growing a frame later and lurching scroll past several messages. --- .../chat/message/parts/ToolPart.tsx | 18 +++- packages/ui/src/hooks/useChatAutoFollow.ts | 89 +++++++++++++++---- 2 files changed, 89 insertions(+), 18 deletions(-) diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index d1126352..b67a1b0f 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -226,14 +226,30 @@ const scheduleDeferredToolBodyMount = (fn: () => void) => { }; const useDeferredExpandedContent = (isExpanded: boolean) => { - const [shouldRender, setShouldRender] = React.useState(false); + // If the tool is expanded when the row first mounts (e.g. "show tools open + // by default", or scrolling a default-open tool back into a virtualized + // view), render the body SYNCHRONOUSLY so the virtualizer measures the real + // height immediately. Deferring it would let the row mount short and grow a + // frame later, which makes the virtualizer compensate scroll and lurch the + // viewport past several messages on slow scroll. Only defer LATER + // user-initiated expansions, where instant single-item feedback isn't worth + // blocking the click on a heavy body render. + const [shouldRender, setShouldRender] = React.useState(isExpanded); + const mountedRef = React.useRef(false); React.useEffect(() => { if (!isExpanded) { + mountedRef.current = true; setShouldRender(false); return; } + if (!mountedRef.current) { + mountedRef.current = true; + setShouldRender(true); + return; + } + return scheduleDeferredToolBodyMount(() => { setShouldRender(true); }); diff --git a/packages/ui/src/hooks/useChatAutoFollow.ts b/packages/ui/src/hooks/useChatAutoFollow.ts index 395bb2f3..f8b543b6 100644 --- a/packages/ui/src/hooks/useChatAutoFollow.ts +++ b/packages/ui/src/hooks/useChatAutoFollow.ts @@ -43,10 +43,15 @@ export interface UseChatAutoFollowResult { } // ────────────────────────────────────────────────────────────────────────── -// This is a direct port of opencode's `createAutoScroll` (SolidJS) to a React -// hook. The model is deliberately simple, which is what makes it flicker-free: +// Chat auto-follow. The model is deliberately simple, which is what makes it +// flicker-free: // -// • Auto-follow is ALWAYS on unless the user scrolled up (`released`). +// • Auto-follow is on unless the user scrolled up (`released`), AND passive +// following only acts while the session is active (working, plus a short +// settle window). When idle, content-size changes are layout churn +// (virtualizer re-measurement, async tool/code rendering) rather than live +// growth, so the hook leaves scroll alone — re-pinning then would fight the +// virtualizer and twitch the viewport. // • Following the bottom is INSTANT — `scrollTop = scrollHeight` inside the // content ResizeObserver, which fires after layout and before paint. There // is NO easing loop and NO settle burst, so there are never two writers @@ -68,9 +73,12 @@ const TOUCH_FINGER_DOWN_THRESHOLD = 2; // How long an "auto" (programmatic) scroll position stays trusted. Browsers can // dispatch the `scroll` event for our write asynchronously, after newer content // has already changed the geometry; the window keeps us from reading that lag as -// a user scroll. Mirrors opencode's 1500ms. +// a user scroll. const AUTO_MARK_TTL_MS = 1500; const AUTO_MATCH_TOLERANCE_PX = 2; +// After streaming stops, keep following the bottom for a short window so the +// final content can settle into place. +const SETTLE_MS = 300; const now = (): number => (typeof performance !== 'undefined' ? performance.now() : Date.now()); @@ -141,11 +149,17 @@ export const useChatAutoFollow = ({ const [isFollowingProgrammatically, setIsFollowingProgrammatically] = React.useState(false); // `stateRef` is the single source of truth for follow vs released; the React - // state above is a mirror for rendering. `released === userScrolled` in - // opencode terms. + // state above is a mirror for rendering. `released` means the user scrolled + // up and away from the bottom. const stateRef = React.useRef('following'); const isMobileRef = React.useRef(isMobile); isMobileRef.current = isMobile; + const sessionIsWorkingRef = React.useRef(sessionIsWorking); + sessionIsWorkingRef.current = sessionIsWorking; + // `settling` keeps passive follow alive for a short window after work stops + // so the final content can land at the bottom. + const settlingRef = React.useRef(false); + const settleTimerRef = React.useRef | null>(null); const sessionMessageCountRef = React.useRef(sessionMessageCount); sessionMessageCountRef.current = sessionMessageCount; const currentSessionIdRef = React.useRef(currentSessionId); @@ -153,7 +167,7 @@ export const useChatAutoFollow = ({ const lastSessionIdRef = React.useRef(null); - // Programmatic-scroll marker (opencode's `auto`): the bottom position we last + // Programmatic-scroll marker: the bottom position we last // wrote and when. A scroll event whose scrollTop matches `top` within a few // px while still inside the TTL is OUR write, not the user's. const autoRef = React.useRef<{ top: number; time: number } | null>(null); @@ -180,6 +194,17 @@ export const useChatAutoFollow = ({ } }); + // `active` is `working || settling`. Passive auto-follow + // (the ResizeObserver re-pin and any non-forced scrollToBottom) only runs + // while active. When the session is idle, content-size changes are layout + // churn — virtualizer re-measurement, async tool/code rendering — NOT live + // growth, so we must NOT yank the user to the bottom. Forcing this gate is + // what stops the twitch when tall items (expanded tools) re-measure as the + // user scrolls. + const isActive = React.useCallback((): boolean => { + return sessionIsWorkingRef.current || settlingRef.current; + }, []); + const setStateValue = React.useCallback((next: AutoFollowState) => { if (stateRef.current === next) return; stateRef.current = next; @@ -227,7 +252,7 @@ export const useChatAutoFollow = ({ setShowScrollButton(showButton); }, []); - // ── core scroll primitives (ported from opencode) ──────────────────────── + // ── core scroll primitives ─────────────────────────────────────────────── const scrollToBottomNow = React.useCallback((behavior: ScrollBehavior) => { const el = scrollRef.current; if (!el) return; @@ -246,6 +271,10 @@ export const useChatAutoFollow = ({ const scrollToBottom = React.useCallback((force: boolean, behavior: ScrollBehavior = 'auto') => { const el = scrollRef.current; + // Passive follow only while active (working/settling). Forced jumps + // (send, go-to-bottom, session restore) always proceed. + if (!force && !isActive()) return; + if (force && stateRef.current !== 'following') { setStateValue('following'); } @@ -260,7 +289,7 @@ export const useChatAutoFollow = ({ return; } scrollToBottomNow(force ? behavior : 'auto'); - }, [markAuto, scrollToBottomNow, setStateValue]); + }, [isActive, markAuto, scrollToBottomNow, setStateValue]); // User left the bottom — release auto-follow. const stop = React.useCallback(() => { @@ -355,8 +384,8 @@ export const useChatAutoFollow = ({ } pendingInitialRestoreRef.current = null; - // Always return to the bottom on session switch (opencode resumes on the - // same edge). The content ResizeObserver re-pins instantly as late + // Always return to the bottom on session switch. The content + // ResizeObserver re-pins instantly as late // history measures in, so there is no smooth scroll-from-mid artifact. setStateValue('following'); scrollToBottom(true); @@ -379,12 +408,29 @@ export const useChatAutoFollow = ({ } }, [currentSessionId, flushSave]); - // When work begins (a reply starts streaming) and we are still following, - // make sure we are pinned to the bottom. Mirrors opencode's `working` effect. + // When work begins and we are still + // following, pin to the bottom. When work stops, keep following alive for a + // short settle window so the final content lands at the bottom, then go + // idle (after which passive follow is disabled — see `isActive`). React.useEffect(() => { - if (sessionIsWorking && stateRef.current === 'following') { - scrollToBottom(false); + settlingRef.current = false; + if (settleTimerRef.current) { + clearTimeout(settleTimerRef.current); + settleTimerRef.current = null; } + + if (sessionIsWorking) { + if (stateRef.current === 'following') { + scrollToBottom(true); + } + return; + } + + settlingRef.current = true; + settleTimerRef.current = setTimeout(() => { + settlingRef.current = false; + settleTimerRef.current = null; + }, SETTLE_MS); }, [sessionIsWorking, scrollToBottom]); // Suppress the overlay scrollbar thumb only while we are actively following a @@ -405,7 +451,7 @@ export const useChatAutoFollow = ({ } }, [containerEl, currentSessionId, restoreSnapshot]); - // ── scroll event handling (ported from opencode handleScroll) ──────────── + // ── scroll event handling ──────────────────────────────────────────────── const handleScrollEvent = React.useCallback(() => { const el = scrollRef.current; if (!el) return; @@ -524,6 +570,11 @@ export const useChatAutoFollow = ({ return; } updateOverflowAndButton(); + // Idle resize = layout churn (virtualizer re-measurement, async + // tool/code rendering), NOT live growth. Never re-pin when idle, or + // tall items re-measuring as the user scrolls cause an endless + // scroll-to-bottom/re-measure twitch. + if (!isActive()) return; if (stateRef.current !== 'following') return; scrollToBottom(false); }); @@ -533,7 +584,7 @@ export const useChatAutoFollow = ({ observer.observe(inner); } return () => observer.disconnect(); - }, [containerEl, scrollToBottom, setStateValue, updateOverflowAndButton]); + }, [containerEl, isActive, scrollToBottom, setStateValue, updateOverflowAndButton]); React.useEffect(() => { updateOverflowAndButton(); @@ -580,6 +631,10 @@ export const useChatAutoFollow = ({ clearTimeout(autoTimerRef.current); autoTimerRef.current = null; } + if (settleTimerRef.current) { + clearTimeout(settleTimerRef.current); + settleTimerRef.current = null; + } flushSave(); if (saveTimerRef.current !== null) { clearTimeout(saveTimerRef.current); From 8b5acbf415f8cdce3e321d261904efa1f669d2e9 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 28 Jun 2026 12:27:38 +0300 Subject: [PATCH 0024/1073] fix(chat): improve mobile history loading and virtualization fidelity Give touch surfaces a larger, viewport-relative head start for loading older history so an in-flight fetch completes before the finger reaches the top. Raise the mobile virtualizer overscan so fast flings stay populated instead of leaving blank gaps, and drop the fixed itemSize hint so virtua auto-estimates row heights from measured sizes instead of a flat constant. --- .../ui/src/components/chat/MessageList.tsx | 24 ++++++++----------- .../chat/hooks/useChatTimelineController.ts | 20 +++++++++++++++- 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx index 8957f216..653f370d 100644 --- a/packages/ui/src/components/chat/MessageList.tsx +++ b/packages/ui/src/components/chat/MessageList.tsx @@ -19,25 +19,22 @@ import type { StreamPhase } from './message/types'; import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; import { useSessionParts } from '@/sync/sync-context'; import type { ReviewTransferDirection } from '@/lib/reviewFlow'; +import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface'; const MESSAGE_LIST_VIRTUALIZE_THRESHOLD = 5; const EMPTY_STATIC_ENTRY_MESSAGES: ChatMessageEntry[] = []; const EMPTY_UNGROUPED_MESSAGE_IDS = new Set(); const MESSAGE_LIST_BUFFER_SIZE = 900; +// Touch surfaces fling-scroll natively and dispatch scroll events less often +// than the virtualizer can repaint, so a desktop-sized buffer leaves blank gaps +// during momentum that only fill once measurement catches up. A larger overscan +// keeps more rows mounted around the viewport so fast flings stay populated. +const MOBILE_MESSAGE_LIST_BUFFER_SIZE = 2400; +const resolveMessageListBufferSize = (): number => ( + isMobileSurfaceRuntime() ? MOBILE_MESSAGE_LIST_BUFFER_SIZE : MESSAGE_LIST_BUFFER_SIZE +); const TIMELINE_CACHE_LIMIT = 16; -const estimateHistoryEntryHeight = (entry: RenderEntry | undefined): number => { - if (!entry) { - return 160; - } - - if (entry.kind === 'turn') { - return 180 + Math.min(entry.turn.assistantMessages.length, 4) * 100; - } - - return 140; -}; - const sameKeys = (a: readonly string[] | undefined, b: readonly string[] | undefined): boolean => { if (a === b) return true; if (!a || !b) return false; @@ -982,8 +979,7 @@ const StaticHistoryList = React.memo(({ entries, shouldVirtualize, contentRef, s ref={virtualizerRef} data={entries} cache={virtualCache} - itemSize={virtualCache ? undefined : estimateHistoryEntryHeight(undefined)} - bufferSize={MESSAGE_LIST_BUFFER_SIZE} + bufferSize={resolveMessageListBufferSize()} shift={shift} scrollRef={scrollRef} > diff --git a/packages/ui/src/components/chat/hooks/useChatTimelineController.ts b/packages/ui/src/components/chat/hooks/useChatTimelineController.ts index a8e72106..13a0793c 100644 --- a/packages/ui/src/components/chat/hooks/useChatTimelineController.ts +++ b/packages/ui/src/components/chat/hooks/useChatTimelineController.ts @@ -60,6 +60,24 @@ export interface UseChatTimelineControllerResult { const TURN_MODEL_CACHE_MAX = 30 const HISTORY_SCROLL_THRESHOLD = 200 +// On touch surfaces the user can drag continuously toward the top, and +// loadEarlier is an async (network) fetch. A 200px lead is enough on desktop +// (wheel + fast render) but the finger can outrun an in-flight fetch on mobile +// and hit the very top before history lands. Give touch a much larger, +// viewport-relative head start so the fetch completes before the top is +// reached, regardless of how fast the user drags. +const MOBILE_HISTORY_SCROLL_THRESHOLD_MIN = 1200 +const MOBILE_HISTORY_SCROLL_VIEWPORT_FACTOR = 2 + +const resolveHistoryScrollThreshold = (clientHeight: number): number => { + if (!isMobileSurfaceRuntime()) { + return HISTORY_SCROLL_THRESHOLD + } + return Math.max( + MOBILE_HISTORY_SCROLL_THRESHOLD_MIN, + clientHeight * MOBILE_HISTORY_SCROLL_VIEWPORT_FACTOR, + ) +} const VSCODE_TURN_MODEL_CACHE_MAX = 4 const VSCODE_TURN_MODEL_CACHE_MAX_MESSAGES = 30 const MOBILE_TURN_MODEL_CACHE_MAX = 4 @@ -521,7 +539,7 @@ export const useChatTimelineController = ({ const container = scrollRef.current; if (!container) return; if (isPinnedRef.current) return; - if (container.scrollTop >= HISTORY_SCROLL_THRESHOLD) return; + if (container.scrollTop >= resolveHistoryScrollThreshold(container.clientHeight)) return; if (!historySignalsRef.current.canLoadEarlier) return; if (isLoadingOlderRef.current || pendingRevealWorkRef.current) return; From c5eab5ac1e3d2a30fcd7552c79d64faeb25f971d Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 28 Jun 2026 12:33:10 +0300 Subject: [PATCH 0025/1073] Revert "fix(ui): actually shrink typography classes on mobile viewports (#1791)" This reverts commit 1ac8fbad701e05ec710419f8fbda00bdc273578a. --- CHANGELOG.md | 1 - packages/ui/src/styles/mobile.css | 24 ++++++++++++------------ 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76781dd0..aed2f0b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,6 @@ All notable changes to this project will be documented in this file. - Context Panel: chat tabs now use the session title and mark the open chat as seen while you are viewing it. - Desktop/macOS: the Dock icon can now show a badge count for chats with unseen activity, with a new Appearance setting to turn it off. - Context Panel: Browser and Preview tabs no longer accumulate duplicate auth tokens in their URLs after reloads or navigation. -- UI: typography classes (ui-header, ui-label, meta, micro) now actually shrink on mobile viewports — they previously rendered at the same size as desktop despite the mobile clamp rules (thanks to @foundryseven). ## [1.13.5] - 2026-06-27 diff --git a/packages/ui/src/styles/mobile.css b/packages/ui/src/styles/mobile.css index d03f4873..20cb5b9c 100644 --- a/packages/ui/src/styles/mobile.css +++ b/packages/ui/src/styles/mobile.css @@ -44,20 +44,20 @@ :root.mobile-pointer:not(.desktop-runtime) { --text-markdown: 1rem; --text-code: 0.875rem; - --text-ui-header: 0.875rem; - --text-ui-label: 0.75rem; - --text-meta: 0.75rem; - --text-micro: 0.6875rem; + --text-ui-header: 0.9375rem; + --text-ui-label: 0.875rem; + --text-meta: 0.875rem; + --text-micro: 0.8125rem; } /* Force override with higher specificity */ :root.mobile-pointer:not(.desktop-runtime) * { --text-markdown: 1rem !important; --text-code: 0.875rem !important; - --text-ui-header: 0.875rem !important; - --text-ui-label: 0.75rem !important; - --text-meta: 0.75rem !important; - --text-micro: 0.6875rem !important; + --text-ui-header: 0.9375rem !important; + --text-ui-label: 0.875rem !important; + --text-meta: 0.875rem !important; + --text-micro: 0.8125rem !important; } /* Additional override for elements using typography classes */ @@ -325,19 +325,19 @@ } .typography-ui-header { - font-size: 0.875rem !important; + font-size: 0.9375rem !important; } .typography-ui-label { - font-size: 0.75rem !important; + font-size: 0.875rem !important; } .typography-meta { - font-size: 0.75rem !important; + font-size: 0.875rem !important; } .typography-micro { - font-size: 0.6875rem !important; + font-size: 0.8125rem !important; } /* Fix font size for tool displays */ From 1178c4e4f584c8de2a41f387d135586879d35975 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 28 Jun 2026 13:05:00 +0300 Subject: [PATCH 0026/1073] fix(providers): keep add-provider flow selected during sync --- packages/ui/src/stores/useConfigStore.test.ts | 66 +++++++++++++++++++ packages/ui/src/stores/useConfigStore.ts | 38 ++++++++--- 2 files changed, 94 insertions(+), 10 deletions(-) diff --git a/packages/ui/src/stores/useConfigStore.test.ts b/packages/ui/src/stores/useConfigStore.test.ts index e587c703..ca4562c0 100644 --- a/packages/ui/src/stores/useConfigStore.test.ts +++ b/packages/ui/src/stores/useConfigStore.test.ts @@ -408,6 +408,31 @@ describe('useConfigStore provider persistence', () => { expect(useConfigStore.getState().selectedProviderId).toBe('__add_provider__'); }); + test('add-provider sentinel is not persisted as a stable provider selection', async () => { + useConfigStore.setState({ + activeDirectoryKey: DIRECTORY, + currentProviderId: 'live', + currentModelId: 'live-model', + selectedProviderId: '__add_provider__', + directoryScoped: { + [DIRECTORY]: { + providers: [provider('live')], + agents: [], + currentProviderId: 'live', + currentModelId: 'live-model', + currentAgentName: undefined, + selectedProviderId: '__add_provider__', + agentModelSelections: {}, + defaultProviders: { default: 'live' }, + }, + }, + }); + + const persisted = JSON.parse(storage.get(STORAGE_KEY) ?? '{}'); + expect(persisted.state.selectedProviderId).toBe(''); + expect(persisted.state.directoryScoped[DIRECTORY].selectedProviderId).toBe(''); + }); + test('setAgent applies settings default variant for an agent configured model', () => { useSessionUIStore.setState({ currentSessionId: 'ses_agent_default_variant' }); useConfigStore.setState({ @@ -603,6 +628,47 @@ describe('useConfigStore provider persistence', () => { expect(state.currentAgentName).toBe('review'); }); + test('sync config defaults do not close the add-provider settings flow', () => { + useConfigStore.setState({ + activeDirectoryKey: DIRECTORY, + providers: [provider('openai', 'gpt-5.5'), provider('anthropic', 'claude')], + agents: [ + testAgent('build', { model: { providerID: 'anthropic', modelID: 'claude' } }), + testAgent('review', { model: { providerID: 'openai', modelID: 'gpt-5.5' } }), + ], + currentProviderId: 'anthropic', + currentModelId: 'claude', + currentAgentName: 'build', + selectedProviderId: '__add_provider__', + selectionSource: 'auto', + directoryScoped: { + [DIRECTORY]: { + providers: [provider('openai', 'gpt-5.5'), provider('anthropic', 'claude')], + agents: [ + testAgent('build', { model: { providerID: 'anthropic', modelID: 'claude' } }), + testAgent('review', { model: { providerID: 'openai', modelID: 'gpt-5.5' } }), + ], + currentProviderId: 'anthropic', + currentModelId: 'claude', + currentAgentName: 'build', + selectedProviderId: '__add_provider__', + agentModelSelections: {}, + defaultProviders: {}, + selectionSource: 'auto', + }, + }, + }); + + emitSyncConfigChanged(DIRECTORY, { default_agent: 'review', model: 'openai/gpt-5.5' }); + + const state = useConfigStore.getState(); + expect(state.currentAgentName).toBe('review'); + expect(state.currentProviderId).toBe('openai'); + expect(state.currentModelId).toBe('gpt-5.5'); + expect(state.selectedProviderId).toBe('__add_provider__'); + expect(state.directoryScoped[DIRECTORY]?.selectedProviderId).toBe('__add_provider__'); + }); + test('duplicate sync config event is a no-op when defaults and selection are unchanged', () => { useConfigStore.setState({ activeDirectoryKey: DIRECTORY, diff --git a/packages/ui/src/stores/useConfigStore.ts b/packages/ui/src/stores/useConfigStore.ts index 2563b092..ae0f3131 100644 --- a/packages/ui/src/stores/useConfigStore.ts +++ b/packages/ui/src/stores/useConfigStore.ts @@ -31,7 +31,8 @@ const STT_SILENCE_HOLD_MS_MAX = 10000; const FALLBACK_PROVIDER_ID = "opencode"; const FALLBACK_MODEL_ID = "big-pickle"; // Sentinel selectedProviderId used by the providers UI while the "Add provider" -// form is open. It is intentionally not a real provider id. +// form is open. It is intentionally not a real provider id and must not be +// persisted as a stable provider selection. const ADD_PROVIDER_SENTINEL = "__add_provider__"; const GIT_UTILITY_PROVIDER_ID = "zen"; const GIT_UTILITY_PREFERRED_MODEL_ID = "big-pickle"; @@ -189,6 +190,14 @@ type ProviderWithModelList = Omit & { models: ProviderModel[ type GitModelSelection = { providerId: string; modelId: string }; type ProviderModelSelection = { providerId: string; modelId: string; variant?: string } | null; +const sanitizePersistedSelectedProviderId = (providerId: string | undefined): string => ( + providerId === ADD_PROVIDER_SENTINEL ? "" : (providerId ?? "") +); + +const preserveAddProviderSelection = (currentSelectedProviderId: string | undefined, nextProviderId: string): string => ( + currentSelectedProviderId === ADD_PROVIDER_SENTINEL ? ADD_PROVIDER_SENTINEL : nextProviderId +); + const normalizeOptionalString = (value: unknown): string | undefined => { if (typeof value !== "string") { return undefined; @@ -2424,7 +2433,7 @@ export const useConfigStore = create()( currentProviderId: providerId, currentModelId: modelId, currentVariant: variant, - selectedProviderId: providerId, + selectedProviderId: preserveAddProviderSelection(state.selectedProviderId, providerId), selectionSource: "manual", }; @@ -2432,7 +2441,7 @@ export const useConfigStore = create()( currentProviderId: providerId, currentModelId: modelId, currentVariant: variant, - selectedProviderId: providerId, + selectedProviderId: preserveAddProviderSelection(state.selectedProviderId, providerId), selectionSource: "manual", directoryScoped: { ...state.directoryScoped, @@ -2577,7 +2586,7 @@ export const useConfigStore = create()( currentProviderId: resolvedProviderId, currentModelId: resolvedModelId, currentVariant: resolvedVariant, - selectedProviderId: resolvedProviderId, + selectedProviderId: preserveAddProviderSelection(state.selectedProviderId, resolvedProviderId), } : {}), selectionSource: "auto", @@ -2596,7 +2605,7 @@ export const useConfigStore = create()( nextState.currentProviderId = resolvedProviderId; nextState.currentModelId = resolvedModelId; nextState.currentVariant = resolvedVariant; - nextState.selectedProviderId = resolvedProviderId; + nextState.selectedProviderId = preserveAddProviderSelection(state.selectedProviderId, resolvedProviderId); } return nextState; @@ -2678,6 +2687,7 @@ export const useConfigStore = create()( const currentProviderId = isActive ? state.currentProviderId : baseSnapshot.currentProviderId; const currentModelId = isActive ? state.currentModelId : baseSnapshot.currentModelId; const currentVariant = isActive ? state.currentVariant : baseSnapshot.currentVariant; + const currentSelectedProviderId = isActive ? state.selectedProviderId : baseSnapshot.selectedProviderId; const nextSelection = resolveSelectionWithManualGuard({ agents, providers, @@ -2702,7 +2712,7 @@ export const useConfigStore = create()( currentProviderId: nextSelection.providerId, currentModelId: nextSelection.modelId, currentVariant: nextSelection.variant, - selectedProviderId: nextSelection.providerId, + selectedProviderId: preserveAddProviderSelection(currentSelectedProviderId, nextSelection.providerId), } : {}), selectionSource: nextSelection.selectionSource, @@ -2721,7 +2731,7 @@ export const useConfigStore = create()( state.currentProviderId !== nextSelection.providerId || state.currentModelId !== nextSelection.modelId || state.currentVariant !== nextSelection.variant - || state.selectedProviderId !== nextSelection.providerId + || state.selectedProviderId !== preserveAddProviderSelection(currentSelectedProviderId, nextSelection.providerId) )) )); @@ -2741,7 +2751,7 @@ export const useConfigStore = create()( nextState.currentProviderId = nextSelection.providerId; nextState.currentModelId = nextSelection.modelId; nextState.currentVariant = nextSelection.variant; - nextState.selectedProviderId = nextSelection.providerId; + nextState.selectedProviderId = preserveAddProviderSelection(currentSelectedProviderId, nextSelection.providerId); } } @@ -3288,14 +3298,22 @@ export const useConfigStore = create()( // success) and by the provider/agent config-change subscriptions. partialize: (state) => ({ activeDirectoryKey: state.activeDirectoryKey, - directoryScoped: state.directoryScoped, + directoryScoped: Object.fromEntries( + Object.entries(state.directoryScoped).map(([directoryKey, snapshot]) => [ + directoryKey, + { + ...snapshot, + selectedProviderId: sanitizePersistedSelectedProviderId(snapshot.selectedProviderId), + }, + ]), + ), providers: state.providers, agents: state.agents, currentProviderId: state.currentProviderId, currentModelId: state.currentModelId, currentVariant: state.currentVariant, currentAgentName: state.currentAgentName, - selectedProviderId: state.selectedProviderId, + selectedProviderId: sanitizePersistedSelectedProviderId(state.selectedProviderId), agentModelSelections: state.agentModelSelections, defaultProviders: state.defaultProviders, settingsDefaultModel: state.settingsDefaultModel, From bf1d0d3a7580d9bb15b6b99566c0ae3ec5902052 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 28 Jun 2026 13:33:55 +0300 Subject: [PATCH 0027/1073] feat(mobile): polish composer model/agent controls and selection overlay Redesign the mobile composer model and agent buttons as borderless, full-bleed labels that hug their content, truncate with an ellipsis when space is tight, and show the provider logo inline before the model name. Tighten the footer action buttons (sessions / attach / auto-accept) so they sit close together, with a small left inset on the group. In the mobile model selection overlay, make the thinking-variant control text-only with a chevron, vertically center the variant and favorite controls in each row, and place the provider logo inline with the model name. --- packages/ui/src/components/chat/ChatInput.tsx | 4 ++-- .../ui/src/components/chat/MobileAgentButton.tsx | 8 +++++--- .../ui/src/components/chat/MobileModelButton.tsx | 13 +++++++++---- .../ui/src/components/chat/ModelControls.tsx | 16 ++++++++-------- packages/ui/src/styles/mobile.css | 10 ++++++++++ 5 files changed, 34 insertions(+), 17 deletions(-) diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index abf4bd4c..91e972c6 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -4514,7 +4514,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo {isMobile ? ( <>
-
+
= ({ onOpenSettings, scrollTo />
-
+
handleOpenMobilePanel('model')} className="min-w-0 flex-shrink" /> = ({ onCycleAge onPointerLeave={handlePointerLeave} onContextMenu={(e) => e.preventDefault()} className={cn( - 'inline-flex min-w-0 items-center select-none', - 'rounded-lg border border-border/50 px-1.5', + 'inline-flex min-w-0 items-stretch select-none', + 'rounded-lg', 'typography-micro font-medium', 'focus:outline-none hover:bg-[var(--interactive-hover)]', 'touch-none', @@ -88,7 +88,9 @@ export const MobileAgentButton: React.FC = ({ onCycleAge }} title={agentLabel} > - {agentLabel} + + {agentLabel} + ); }; diff --git a/packages/ui/src/components/chat/MobileModelButton.tsx b/packages/ui/src/components/chat/MobileModelButton.tsx index b34d45ed..10941836 100644 --- a/packages/ui/src/components/chat/MobileModelButton.tsx +++ b/packages/ui/src/components/chat/MobileModelButton.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { cn } from '@/lib/utils'; import { useConfigStore } from '@/stores/useConfigStore'; import { getModelDisplayName } from './mobileControlsUtils'; +import { ProviderLogo } from '@/components/ui/ProviderLogo'; import { useI18n } from '@/lib/i18n'; interface MobileModelButtonProps { @@ -12,6 +13,7 @@ interface MobileModelButtonProps { export const MobileModelButton: React.FC = ({ onOpenModel, className }) => { const { t } = useI18n(); const currentModelId = useConfigStore((state) => state.currentModelId); + const currentProviderId = useConfigStore((state) => state.currentProviderId); const getCurrentProvider = useConfigStore((state) => state.getCurrentProvider); const currentProvider = getCurrentProvider(); const modelLabel = getModelDisplayName(currentProvider, currentModelId, t('chat.modelControls.selectModel')); @@ -21,8 +23,8 @@ export const MobileModelButton: React.FC = ({ onOpenMode type="button" onClick={onOpenModel} className={cn( - 'inline-flex min-w-0 items-center justify-center', - 'rounded-lg border border-border/50 px-1.5', + 'inline-flex min-w-0 items-stretch', + 'rounded-lg', 'typography-micro font-medium text-foreground/80', 'focus:outline-none hover:bg-[var(--interactive-hover)]', className @@ -30,8 +32,11 @@ export const MobileModelButton: React.FC = ({ onOpenMode style={{ height: '26px', maxHeight: '26px', minHeight: '26px' }} title={modelLabel} > - - {modelLabel} + + {currentProviderId ? ( + + ) : null} + {modelLabel} ); diff --git a/packages/ui/src/components/chat/ModelControls.tsx b/packages/ui/src/components/chat/ModelControls.tsx index 3e254195..cca3e5ea 100644 --- a/packages/ui/src/components/chat/ModelControls.tsx +++ b/packages/ui/src/components/chat/ModelControls.tsx @@ -1671,7 +1671,7 @@ export const ModelControls: React.FC = ({ isSelected && 'bg-interactive-selection/15 text-interactive-selection-foreground' )} > -
+
) : null} -
+
)} - {(shouldShow('collapsibleUserMessages') || shouldShow('stickyUserHeader') || shouldShow('wideChatLayout') || shouldShow('splitAssistantMessageActions') || shouldShow('dotfiles') || shouldShow('fileViewerPreview') || shouldShow('queueMode') || shouldShow('persistDraft') || shouldShow('showToolFileIcons') || shouldShow('showTurnChangedFiles') || (!isMobile && shouldShow('inputSpellcheck')) || shouldShow('reasoning')) && ( + {(shouldShow('collapsibleUserMessages') || shouldShow('stickyUserHeader') || shouldShow('wideChatLayout') || shouldShow('splitAssistantMessageActions') || shouldShow('dotfiles') || shouldShow('fileViewerPreview') || shouldShow('followUpBehavior') || shouldShow('persistDraft') || shouldShow('showToolFileIcons') || shouldShow('showTurnChangedFiles') || (!isMobile && shouldShow('inputSpellcheck')) || shouldShow('reasoning')) && (
{shouldShow('reasoning') && (
)} - {shouldShow('queueMode') && ( -
setQueueMode(!queueModeEnabled)} - onKeyDown={(event) => { - if (event.key === ' ' || event.key === 'Enter') { - event.preventDefault(); - setQueueMode(!queueModeEnabled); - } - }} - > - -
- {t('settings.openchamber.visual.field.queueMessagesByDefault')} - - - - - - {t('settings.openchamber.visual.field.queueMessagesByDefaultTooltip', { modifier: getModifierLabel() })} - - + {shouldShow('followUpBehavior') && ( +
+

{t('settings.openchamber.visual.section.followUpBehavior')}

+
+ {FOLLOW_UP_BEHAVIOR_OPTIONS.map((option) => { + const selected = followUpBehavior === option.id; + return ( +
setFollowUpBehavior(option.id)} + onKeyDown={(event) => { + if (event.key === ' ' || event.key === 'Enter') { + event.preventDefault(); + setFollowUpBehavior(option.id); + } + }} + className="flex w-full items-center gap-2 py-0 text-left" + > + setFollowUpBehavior(option.id)} + ariaLabel={t('settings.openchamber.visual.field.followUpBehaviorAria', { option: tUnsafe(option.labelKey) })} + /> + + {tUnsafe(option.labelKey)} + +
+ ); + })}
-
+
)} {shouldShow('persistDraft') && ( diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index c8bfd37e..d3539c29 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -638,6 +638,7 @@ export interface SettingsPayload { autoDeleteEnabled?: boolean; autoDeleteAfterDays?: number; sessionRetentionAction?: 'archive' | 'delete'; + followUpBehavior?: 'steer' | 'queue'; queueModeEnabled?: boolean; gitmojiEnabled?: boolean; inputSpellcheckEnabled?: boolean; diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index 9bdcdcba..867a2929 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -115,6 +115,7 @@ export type DesktopSettings = { defaultGitIdentityId?: string; // ''/undefined = unset, 'global' or profile id openInAppId?: string; autoCreateWorktree?: boolean; + followUpBehavior?: 'steer' | 'queue'; queueModeEnabled?: boolean; gitmojiEnabled?: boolean; defaultFileViewerPreview?: boolean; diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index c425b72e..81ad24fb 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -1800,4 +1800,10 @@ export const settingsDict = { 'settings.magicPrompts.page.toast.resetFailed': 'Failed to reset prompt', 'settings.magicPrompts.page.toast.resetAllSuccess': 'All prompt overrides reset', 'settings.magicPrompts.page.toast.resetAllFailed': 'Failed to reset all prompts', + 'settings.openchamber.visual.section.followUpBehavior': 'Follow-up behavior', + 'settings.openchamber.visual.section.followUpBehaviorAria': 'Follow-up behavior', + 'settings.openchamber.visual.field.followUpBehaviorAria': 'Follow-up behavior: {option}', + 'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.', + 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer (insert into the running turn)', + 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue (deliver after the current turn)', } as const; diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index 06851680..b97d5417 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -1800,4 +1800,10 @@ export const settingsDict = { "settings.promptTemplates.page.toast.created": "Plantilla creada", "settings.promptTemplates.page.toast.createFailed": "Error al crear la plantilla", "settings.promptTemplates.page.toast.saveUnexpectedError": "Ocurrió un error inesperado al guardar", + "settings.openchamber.visual.section.followUpBehavior": "Follow-up behavior", + "settings.openchamber.visual.section.followUpBehaviorAria": "Follow-up behavior", + "settings.openchamber.visual.field.followUpBehaviorAria": "Follow-up behavior: {option}", + "settings.openchamber.visual.field.followUpBehaviorDescription": "Choose what happens when you press Enter on a follow-up message while the agent is still responding.", + "settings.openchamber.visual.option.followUpBehavior.steer.label": "Steer (insert into the running turn)", + "settings.openchamber.visual.option.followUpBehavior.queue.label": "Queue (deliver after the current turn)", } as const; diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index 8d1daedd..b1204653 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -1800,4 +1800,10 @@ export const settingsDict = { 'settings.magicPrompts.page.toast.resetFailed': 'Échec de la réinitialisation du prompt', 'settings.magicPrompts.page.toast.resetAllSuccess': 'Tous les prompts ont été réinitialisés', 'settings.magicPrompts.page.toast.resetAllFailed': 'Échec de la réinitialisation de tous les prompts', + 'settings.openchamber.visual.section.followUpBehavior': 'Follow-up behavior', + 'settings.openchamber.visual.section.followUpBehaviorAria': 'Follow-up behavior', + 'settings.openchamber.visual.field.followUpBehaviorAria': 'Follow-up behavior: {option}', + 'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.', + 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer (insert into the running turn)', + 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue (deliver after the current turn)', } as const; diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index 160bcd77..8362c34f 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -1800,4 +1800,10 @@ export const settingsDict = { 'settings.magicPrompts.page.toast.resetFailed': 'プロンプトのリセットに失敗しました', 'settings.magicPrompts.page.toast.resetAllSuccess': 'すべてのプロンプト上書きをリセットしました', 'settings.magicPrompts.page.toast.resetAllFailed': 'すべてのプロンプトのリセットに失敗しました', + 'settings.openchamber.visual.section.followUpBehavior': 'フォローアップの動作', + 'settings.openchamber.visual.section.followUpBehaviorAria': 'フォローアップの動作', + 'settings.openchamber.visual.field.followUpBehaviorAria': 'フォローアップの動作: {option}', + 'settings.openchamber.visual.field.followUpBehaviorDescription': 'エージェントが応答している間にフォローアップメッセージで Enter を押したときの動作を選択します。', + 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'ステア(実行中のターンに挿入)', + 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'キュー(現在のターンの後に送信)', } as const; diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index 7aac2181..f578af38 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -1800,4 +1800,10 @@ export const settingsDict = { 'settings.promptTemplates.page.toast.created': '템플릿이 생성되었습니다', 'settings.promptTemplates.page.toast.createFailed': '템플릿 생성 실패', 'settings.promptTemplates.page.toast.saveUnexpectedError': '저장 중 예기치 않은 오류가 발생했습니다', + 'settings.openchamber.visual.section.followUpBehavior': 'Follow-up behavior', + 'settings.openchamber.visual.section.followUpBehaviorAria': 'Follow-up behavior', + 'settings.openchamber.visual.field.followUpBehaviorAria': 'Follow-up behavior: {option}', + 'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.', + 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer (insert into the running turn)', + 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue (deliver after the current turn)', } as const; diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index cd62d24d..21f47137 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -1792,4 +1792,10 @@ export const settingsDict = { 'settings.voice.page.field.ttsInputModeSanitized': 'Oczyszczony tekst', 'settings.voice.page.field.ttsInputModeRaw': 'Surowy Markdown', 'settings.window.description': 'Okno ustawień OpenChamber.', + 'settings.openchamber.visual.section.followUpBehavior': 'Follow-up behavior', + 'settings.openchamber.visual.section.followUpBehaviorAria': 'Follow-up behavior', + 'settings.openchamber.visual.field.followUpBehaviorAria': 'Follow-up behavior: {option}', + 'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.', + 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer (insert into the running turn)', + 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue (deliver after the current turn)', }; diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index 3c38e551..34b0e241 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -1800,4 +1800,10 @@ export const settingsDict = { "settings.promptTemplates.page.toast.created": "Template criado", "settings.promptTemplates.page.toast.createFailed": "Falha ao criar template", "settings.promptTemplates.page.toast.saveUnexpectedError": "Ocorreu um erro inesperado ao salvar", + "settings.openchamber.visual.section.followUpBehavior": "Follow-up behavior", + "settings.openchamber.visual.section.followUpBehaviorAria": "Follow-up behavior", + "settings.openchamber.visual.field.followUpBehaviorAria": "Follow-up behavior: {option}", + "settings.openchamber.visual.field.followUpBehaviorDescription": "Choose what happens when you press Enter on a follow-up message while the agent is still responding.", + "settings.openchamber.visual.option.followUpBehavior.steer.label": "Steer (insert into the running turn)", + "settings.openchamber.visual.option.followUpBehavior.queue.label": "Queue (deliver after the current turn)", } as const; diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index 8d4f408d..14872f01 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -1800,4 +1800,10 @@ export const settingsDict = { "settings.promptTemplates.page.toast.created": "Шаблон створено", "settings.promptTemplates.page.toast.createFailed": "Не вдалося створити шаблон", "settings.promptTemplates.page.toast.saveUnexpectedError": "Сталася неочікувана помилка під час збереження", + "settings.openchamber.visual.section.followUpBehavior": "Follow-up behavior", + "settings.openchamber.visual.section.followUpBehaviorAria": "Follow-up behavior", + "settings.openchamber.visual.field.followUpBehaviorAria": "Follow-up behavior: {option}", + "settings.openchamber.visual.field.followUpBehaviorDescription": "Choose what happens when you press Enter on a follow-up message while the agent is still responding.", + "settings.openchamber.visual.option.followUpBehavior.steer.label": "Steer (insert into the running turn)", + "settings.openchamber.visual.option.followUpBehavior.queue.label": "Queue (deliver after the current turn)", } as const; diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index 49a1db3e..2bb3c8df 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -1800,4 +1800,10 @@ export const settingsDict = { 'settings.promptTemplates.page.toast.created': '模板已创建', 'settings.promptTemplates.page.toast.createFailed': '创建模板失败', 'settings.promptTemplates.page.toast.saveUnexpectedError': '保存时发生意外错误', + 'settings.openchamber.visual.section.followUpBehavior': 'Follow-up behavior', + 'settings.openchamber.visual.section.followUpBehaviorAria': 'Follow-up behavior', + 'settings.openchamber.visual.field.followUpBehaviorAria': 'Follow-up behavior: {option}', + 'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.', + 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer (insert into the running turn)', + 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue (deliver after the current turn)', } as const; diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index 24af70a1..b4067efd 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -1800,4 +1800,10 @@ 'settings.usage.sidebar.field.showPredictions': '顯示預測', 'settings.view.home.cards.plugins.description': '管理 opencode 外掛', 'settings.view.home.cards.plugins.title': '外掛', + 'settings.openchamber.visual.section.followUpBehavior': 'Follow-up behavior', + 'settings.openchamber.visual.section.followUpBehaviorAria': 'Follow-up behavior', + 'settings.openchamber.visual.field.followUpBehaviorAria': 'Follow-up behavior: {option}', + 'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.', + 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer (insert into the running turn)', + 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue (deliver after the current turn)', } as const; diff --git a/packages/ui/src/lib/opencode/client.ts b/packages/ui/src/lib/opencode/client.ts index aac2bd42..216ef690 100644 --- a/packages/ui/src/lib/opencode/client.ts +++ b/packages/ui/src/lib/opencode/client.ts @@ -735,6 +735,7 @@ class OpencodeService { }>; messageId?: string; agentMentions?: Array<{ name: string; source?: { value: string; start: number; end: number } }>; + delivery?: 'steer'; format?: { type: 'json_schema'; schema: Record; @@ -840,6 +841,7 @@ class OpencodeService { agent: params.agent, variant: params.variant, messageID: messageId, + ...(params.delivery ? { delivery: params.delivery } : {}), ...(params.format ? { format: params.format } : {}), parts, }); diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index 90cbae22..30adc009 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -2,7 +2,7 @@ import type { DesktopSettings } from '@/lib/desktop'; import { createProjectIdFromPath } from '@/lib/projectId'; import { useUIStore } from '@/stores/useUIStore'; import { isMonoFontOption, isUiFontOption } from '@/lib/fontOptions'; -import { useMessageQueueStore } from '@/stores/messageQueueStore'; +import { isFollowUpBehavior, normalizeFollowUpBehavior, useMessageQueueStore, type FollowUpBehavior } from '@/stores/messageQueueStore'; import { setDirectoryShowHidden } from '@/lib/directoryShowHidden'; import { setFilesViewShowGitignored } from '@/lib/filesViewShowGitignored'; import { loadAppearancePreferences, applyAppearancePreferences } from '@/lib/appearancePersistence'; @@ -444,8 +444,14 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => { } } - if (typeof settings.queueModeEnabled === 'boolean' && settings.queueModeEnabled !== queueStore.queueModeEnabled) { - queueStore.setQueueMode(settings.queueModeEnabled); + let nextFollowUpBehavior: FollowUpBehavior | null = null; + if (isFollowUpBehavior(settings.followUpBehavior)) { + nextFollowUpBehavior = settings.followUpBehavior; + } else if (typeof settings.queueModeEnabled === 'boolean') { + nextFollowUpBehavior = normalizeFollowUpBehavior(undefined, settings.queueModeEnabled); + } + if (nextFollowUpBehavior && nextFollowUpBehavior !== queueStore.followUpBehavior) { + queueStore.setFollowUpBehavior(nextFollowUpBehavior); } if (typeof settings.showDeletionDialog === 'boolean' && settings.showDeletionDialog !== store.showDeletionDialog) { @@ -838,8 +844,10 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => { if (typeof candidate.gitmojiEnabled === 'boolean') { result.gitmojiEnabled = candidate.gitmojiEnabled; } - if (typeof candidate.queueModeEnabled === 'boolean') { - result.queueModeEnabled = candidate.queueModeEnabled; + if (isFollowUpBehavior(candidate.followUpBehavior)) { + result.followUpBehavior = candidate.followUpBehavior; + } else if (typeof candidate.queueModeEnabled === 'boolean') { + result.followUpBehavior = normalizeFollowUpBehavior(undefined, candidate.queueModeEnabled); } if (typeof candidate.showDeletionDialog === 'boolean') { result.showDeletionDialog = candidate.showDeletionDialog; diff --git a/packages/ui/src/lib/settings/search.ts b/packages/ui/src/lib/settings/search.ts index 3a74ce8e..89032db3 100644 --- a/packages/ui/src/lib/settings/search.ts +++ b/packages/ui/src/lib/settings/search.ts @@ -217,11 +217,11 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ isAvailable: (ctx) => !ctx.isVSCode, }, { - id: 'chat.queue-mode', + id: 'chat.follow-up-behavior', page: 'chat', - titleKey: 'settings.openchamber.visual.field.queueMessagesByDefault', - descriptionKey: 'settings.openchamber.visual.field.queueMessagesByDefaultTooltip', - keywords: ['queue', 'enter', 'send'], + titleKey: 'settings.openchamber.visual.section.followUpBehavior', + descriptionKey: 'settings.openchamber.visual.field.followUpBehaviorDescription', + keywords: ['follow up', 'queue', 'steer', 'send immediately'], }, { id: 'chat.persist-drafts', diff --git a/packages/ui/src/stores/messageQueueStore.ts b/packages/ui/src/stores/messageQueueStore.ts index e7cf4e52..d27862a6 100644 --- a/packages/ui/src/stores/messageQueueStore.ts +++ b/packages/ui/src/stores/messageQueueStore.ts @@ -4,6 +4,40 @@ import { getSafeStorage } from './utils/safeStorage'; import type { AttachedFile } from './types/sessionTypes'; import { updateDesktopSettings } from '@/lib/persistence'; +export type FollowUpBehavior = 'steer' | 'queue'; + +export const DEFAULT_FOLLOW_UP_BEHAVIOR: FollowUpBehavior = 'queue'; + +export const isFollowUpBehavior = (value: unknown): value is FollowUpBehavior => ( + value === 'steer' || value === 'queue' +); + +export const normalizeFollowUpBehavior = ( + value: unknown, + legacyQueueModeEnabled?: boolean | null, +): FollowUpBehavior => { + // "immediate" was removed: on a busy session it was wire-identical to + // "steer" (OpenCode only supports delivery "steer" | "queue", defaulting + // to "steer"), so collapse any persisted/legacy "immediate" onto "steer". + if (value === 'immediate') { + return 'steer'; + } + + if (isFollowUpBehavior(value)) { + return value; + } + + if (legacyQueueModeEnabled === false) { + return 'steer'; + } + + if (legacyQueueModeEnabled === true) { + return 'queue'; + } + + return DEFAULT_FOLLOW_UP_BEHAVIOR; +}; + export interface QueuedMessage { id: string; content: string; @@ -20,7 +54,7 @@ export interface QueuedMessage { interface MessageQueueState { queuedMessages: Record; // sessionId → queue - queueModeEnabled: boolean; // global toggle + followUpBehavior: FollowUpBehavior; } interface MessageQueueActions { @@ -30,18 +64,24 @@ interface MessageQueueActions { popToInput: (sessionId: string, messageId: string) => QueuedMessage | null; clearQueue: (sessionId: string) => void; clearAllQueues: () => void; - setQueueMode: (enabled: boolean) => void; + setFollowUpBehavior: (behavior: FollowUpBehavior) => void; getQueueForSession: (sessionId: string) => QueuedMessage[]; } type MessageQueueStore = MessageQueueState & MessageQueueActions; +type PersistedMessageQueueState = { + queuedMessages?: Record; + followUpBehavior?: FollowUpBehavior; + queueModeEnabled?: boolean; +}; + export const useMessageQueueStore = create()( devtools( persist( (set, get) => ({ queuedMessages: {}, - queueModeEnabled: true, + followUpBehavior: DEFAULT_FOLLOW_UP_BEHAVIOR, addToQueue: (sessionId, message) => { const id = `queued-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; @@ -149,10 +189,9 @@ export const useMessageQueueStore = create()( set({ queuedMessages: {} }); }, - setQueueMode: (enabled) => { - set({ queueModeEnabled: enabled }); - // Persist to settings.json (async, fire-and-forget) - void updateDesktopSettings({ queueModeEnabled: enabled }); + setFollowUpBehavior: (behavior) => { + set({ followUpBehavior: behavior }); + void updateDesktopSettings({ followUpBehavior: behavior }); }, getQueueForSession: (sessionId) => { @@ -161,11 +200,19 @@ export const useMessageQueueStore = create()( }), { name: 'message-queue-store', + version: 1, storage: createJSONStorage(() => getSafeStorage()), partialize: (state) => ({ queuedMessages: state.queuedMessages, - queueModeEnabled: state.queueModeEnabled, + followUpBehavior: state.followUpBehavior, }), + migrate: (persistedState) => { + const state = (persistedState ?? {}) as PersistedMessageQueueState; + return { + queuedMessages: state.queuedMessages ?? {}, + followUpBehavior: normalizeFollowUpBehavior(state.followUpBehavior, state.queueModeEnabled ?? null), + }; + }, } ), { diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index f32c837e..dabad03c 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -83,6 +83,7 @@ export function routeMessage(params: { inputMode?: "normal" | "shell" files?: Array<{ type: "file"; mime: string; url: string; filename: string }> additionalParts?: Array<{ text: string; synthetic?: boolean; files?: Array<{ type: "file"; mime: string; url: string; filename: string }> }> + delivery?: 'steer' }): Promise { const requestDirectory = params.directory ?? undefined if (params.inputMode === "shell") { @@ -157,6 +158,7 @@ export function routeMessage(params: { variant: params.variant, files: params.files, additionalParts: params.additionalParts, + delivery: params.delivery, messageId: messageID, directory: requestDirectory, }).then(() => {}), @@ -165,6 +167,7 @@ export function routeMessage(params: { type SendMessageOptions = { sessionId?: string + delivery?: 'steer' } type AssistantMessageSessionExecution = { @@ -1040,6 +1043,7 @@ export const useSessionUIStore = create()((set, get) => ({ variant, inputMode, files, + delivery: options?.delivery, additionalParts: mergedAdditionalParts?.map((p) => ({ text: p.text, synthetic: p.synthetic, @@ -1118,6 +1122,7 @@ export const useSessionUIStore = create()((set, get) => ({ variant, inputMode, files, + delivery: options?.delivery, additionalParts: additionalParts?.map((p) => ({ text: p.text, synthetic: p.synthetic, diff --git a/packages/web/server/lib/opencode/settings-helpers.js b/packages/web/server/lib/opencode/settings-helpers.js index fd995069..f02f959e 100644 --- a/packages/web/server/lib/opencode/settings-helpers.js +++ b/packages/web/server/lib/opencode/settings-helpers.js @@ -106,6 +106,20 @@ export const createSettingsHelpers = (dependencies) => { return fallback; }; + const normalizeFollowUpBehavior = (value, legacyQueueModeEnabled = null) => { + // "immediate" was removed (it was wire-identical to "steer"); collapse it. + if (value === 'immediate') { + return 'steer'; + } + if (value === 'steer' || value === 'queue') { + return value; + } + if (legacyQueueModeEnabled === false) { + return 'steer'; + } + return 'queue'; + }; + const sanitizeSettingsUpdate = (payload) => { if (!payload || typeof payload !== 'object') { return {}; @@ -361,8 +375,10 @@ export const createSettingsHelpers = (dependencies) => { const trimmed = candidate.defaultGitIdentityId.trim(); result.defaultGitIdentityId = trimmed.length > 0 ? trimmed : undefined; } - if (typeof candidate.queueModeEnabled === 'boolean') { - result.queueModeEnabled = candidate.queueModeEnabled; + if (typeof candidate.followUpBehavior === 'string') { + result.followUpBehavior = normalizeFollowUpBehavior(candidate.followUpBehavior); + } else if (typeof candidate.queueModeEnabled === 'boolean') { + result.followUpBehavior = normalizeFollowUpBehavior(undefined, candidate.queueModeEnabled); } if (typeof candidate.autoCreateWorktree === 'boolean') { result.autoCreateWorktree = candidate.autoCreateWorktree; From 530c5e05da02c184a7f1a8fe28be10f0c227ea68 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 29 Jun 2026 01:32:38 +0300 Subject: [PATCH 0043/1073] docs(changelog): add unreleased entries for follow-up behavior, session fixes, and sync --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae4c1018..1497242e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ All notable changes to this project will be documented in this file. - Startup: launching the app no longer hangs for around 20 seconds before you can open a session, load a diff, or send a message — GitHub pull request status checks no longer tie up the connection to the server during startup. - OpenCode: when a separate OpenCode is already running (the TUI, `opencode serve`, or a daemon on the default port 4096), the app now starts its own server instead of attaching to it. This fixes the "OpenChamber could not finish initialization" error and stops the app from opening or closing your separate OpenCode when it starts and quits. Connecting to an external OpenCode now requires setting `OPENCODE_HOST`, `OPENCODE_PORT`, or `OPENCODE_SKIP_START`. +- Chat: a new Follow-up behavior setting (Settings → Chat) controls what happens when you press Enter on a message while the agent is still responding — Steer inserts it into the agent's current turn, or Queue holds it until the turn finishes. Replaces the previous queue-mode toggle (thanks to @bashrusakh). +- Sessions: deleting a worktree group from the sidebar, or permanently deleting an archived session that has subagent sessions, now removes those subagent sessions too instead of leaving them behind (thanks to @bashrusakh). +- Sessions: clicking a session inside a worktree group no longer briefly jumps the selection to the project's first session while the sidebar data catches up (thanks to @bashrusakh). +- Sync: a connected but quiet session (for example an agent running a long tool call) no longer triggers repeated background refreshes every ~15 seconds (thanks to @tomzx). ## [1.13.7] - 2026-06-28 From e904a43b5a5d2640dd654835bb64109a728c2568 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 29 Jun 2026 01:37:36 +0300 Subject: [PATCH 0044/1073] fix(settings): move Follow-up behavior radio group next to the other choice settings It was rendered in the middle of the checkbox list, where a labeled radio group reads as out of place. Move it into the radio/choice cluster, right after Diff Layout, and drop it from the checkbox section's visibility gate. --- .../openchamber/OpenChamberVisualSettings.tsx | 76 +++++++++---------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx index 433d5053..509a7ca3 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx @@ -1429,7 +1429,7 @@ export const OpenChamberVisualSettings: React.FC {hasBehaviorSettings && (
- {(shouldShow('userMessageRendering') || shouldShow('mermaidRendering') || shouldShow('chatRenderMode') || shouldShow('messageTransport') || (shouldShow('activityRenderMode') && chatRenderMode === 'sorted') || (shouldShow('diffLayout') && !isVSCode)) && ( + {(shouldShow('userMessageRendering') || shouldShow('mermaidRendering') || shouldShow('chatRenderMode') || shouldShow('messageTransport') || (shouldShow('activityRenderMode') && chatRenderMode === 'sorted') || (shouldShow('diffLayout') && !isVSCode) || shouldShow('followUpBehavior')) && (
{shouldShow('chatRenderMode') && (
@@ -1735,10 +1735,46 @@ export const OpenChamberVisualSettings: React.FC
)} + {shouldShow('followUpBehavior') && ( +
+

{t('settings.openchamber.visual.section.followUpBehavior')}

+
+ {FOLLOW_UP_BEHAVIOR_OPTIONS.map((option) => { + const selected = followUpBehavior === option.id; + return ( +
setFollowUpBehavior(option.id)} + onKeyDown={(event) => { + if (event.key === ' ' || event.key === 'Enter') { + event.preventDefault(); + setFollowUpBehavior(option.id); + } + }} + className="flex w-full items-center gap-2 py-0 text-left" + > + setFollowUpBehavior(option.id)} + ariaLabel={t('settings.openchamber.visual.field.followUpBehaviorAria', { option: tUnsafe(option.labelKey) })} + /> + + {tUnsafe(option.labelKey)} + +
+ ); + })} +
+
+ )} +
)} - {(shouldShow('collapsibleUserMessages') || shouldShow('stickyUserHeader') || shouldShow('wideChatLayout') || shouldShow('splitAssistantMessageActions') || shouldShow('dotfiles') || shouldShow('fileViewerPreview') || shouldShow('followUpBehavior') || shouldShow('persistDraft') || shouldShow('showToolFileIcons') || shouldShow('showTurnChangedFiles') || (!isMobile && shouldShow('inputSpellcheck')) || shouldShow('reasoning')) && ( + {(shouldShow('collapsibleUserMessages') || shouldShow('stickyUserHeader') || shouldShow('wideChatLayout') || shouldShow('splitAssistantMessageActions') || shouldShow('dotfiles') || shouldShow('fileViewerPreview') || shouldShow('persistDraft') || shouldShow('showToolFileIcons') || shouldShow('showTurnChangedFiles') || (!isMobile && shouldShow('inputSpellcheck')) || shouldShow('reasoning')) && (
{shouldShow('reasoning') && (
)} - {shouldShow('followUpBehavior') && ( -
-

{t('settings.openchamber.visual.section.followUpBehavior')}

-
- {FOLLOW_UP_BEHAVIOR_OPTIONS.map((option) => { - const selected = followUpBehavior === option.id; - return ( -
setFollowUpBehavior(option.id)} - onKeyDown={(event) => { - if (event.key === ' ' || event.key === 'Enter') { - event.preventDefault(); - setFollowUpBehavior(option.id); - } - }} - className="flex w-full items-center gap-2 py-0 text-left" - > - setFollowUpBehavior(option.id)} - ariaLabel={t('settings.openchamber.visual.field.followUpBehaviorAria', { option: tUnsafe(option.labelKey) })} - /> - - {tUnsafe(option.labelKey)} - -
- ); - })} -
-
- )} - {shouldShow('persistDraft') && (
Date: Mon, 29 Jun 2026 01:41:35 +0300 Subject: [PATCH 0045/1073] fix(settings): shorten Follow-up behavior option labels to Steer / Queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parenthetical explanations wrapped to two lines and added little — the section header already gives context and the two terms are self-explanatory. --- packages/ui/src/lib/i18n/messages/en.settings.ts | 4 ++-- packages/ui/src/lib/i18n/messages/es.settings.ts | 4 ++-- packages/ui/src/lib/i18n/messages/fr.settings.ts | 4 ++-- packages/ui/src/lib/i18n/messages/ja.settings.ts | 4 ++-- packages/ui/src/lib/i18n/messages/ko.settings.ts | 4 ++-- packages/ui/src/lib/i18n/messages/pl.settings.ts | 4 ++-- packages/ui/src/lib/i18n/messages/pt-BR.settings.ts | 4 ++-- packages/ui/src/lib/i18n/messages/uk.settings.ts | 4 ++-- packages/ui/src/lib/i18n/messages/zh-CN.settings.ts | 4 ++-- packages/ui/src/lib/i18n/messages/zh-TW.settings.ts | 4 ++-- 10 files changed, 20 insertions(+), 20 deletions(-) diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 81ad24fb..2233aa65 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -1804,6 +1804,6 @@ export const settingsDict = { 'settings.openchamber.visual.section.followUpBehaviorAria': 'Follow-up behavior', 'settings.openchamber.visual.field.followUpBehaviorAria': 'Follow-up behavior: {option}', 'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.', - 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer (insert into the running turn)', - 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue (deliver after the current turn)', + 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer', + 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue', } as const; diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index b97d5417..9d5bd6e4 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -1804,6 +1804,6 @@ export const settingsDict = { "settings.openchamber.visual.section.followUpBehaviorAria": "Follow-up behavior", "settings.openchamber.visual.field.followUpBehaviorAria": "Follow-up behavior: {option}", "settings.openchamber.visual.field.followUpBehaviorDescription": "Choose what happens when you press Enter on a follow-up message while the agent is still responding.", - "settings.openchamber.visual.option.followUpBehavior.steer.label": "Steer (insert into the running turn)", - "settings.openchamber.visual.option.followUpBehavior.queue.label": "Queue (deliver after the current turn)", + "settings.openchamber.visual.option.followUpBehavior.steer.label": "Steer", + "settings.openchamber.visual.option.followUpBehavior.queue.label": "Queue", } as const; diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index b1204653..010e89dc 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -1804,6 +1804,6 @@ export const settingsDict = { 'settings.openchamber.visual.section.followUpBehaviorAria': 'Follow-up behavior', 'settings.openchamber.visual.field.followUpBehaviorAria': 'Follow-up behavior: {option}', 'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.', - 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer (insert into the running turn)', - 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue (deliver after the current turn)', + 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer', + 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue', } as const; diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index 8362c34f..f9b73858 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -1804,6 +1804,6 @@ export const settingsDict = { 'settings.openchamber.visual.section.followUpBehaviorAria': 'フォローアップの動作', 'settings.openchamber.visual.field.followUpBehaviorAria': 'フォローアップの動作: {option}', 'settings.openchamber.visual.field.followUpBehaviorDescription': 'エージェントが応答している間にフォローアップメッセージで Enter を押したときの動作を選択します。', - 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'ステア(実行中のターンに挿入)', - 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'キュー(現在のターンの後に送信)', + 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'ステア', + 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'キュー', } as const; diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index f578af38..152f0205 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -1804,6 +1804,6 @@ export const settingsDict = { 'settings.openchamber.visual.section.followUpBehaviorAria': 'Follow-up behavior', 'settings.openchamber.visual.field.followUpBehaviorAria': 'Follow-up behavior: {option}', 'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.', - 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer (insert into the running turn)', - 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue (deliver after the current turn)', + 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer', + 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue', } as const; diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index 21f47137..3daa0224 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -1796,6 +1796,6 @@ export const settingsDict = { 'settings.openchamber.visual.section.followUpBehaviorAria': 'Follow-up behavior', 'settings.openchamber.visual.field.followUpBehaviorAria': 'Follow-up behavior: {option}', 'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.', - 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer (insert into the running turn)', - 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue (deliver after the current turn)', + 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer', + 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue', }; diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index 34b0e241..f9eb5f03 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -1804,6 +1804,6 @@ export const settingsDict = { "settings.openchamber.visual.section.followUpBehaviorAria": "Follow-up behavior", "settings.openchamber.visual.field.followUpBehaviorAria": "Follow-up behavior: {option}", "settings.openchamber.visual.field.followUpBehaviorDescription": "Choose what happens when you press Enter on a follow-up message while the agent is still responding.", - "settings.openchamber.visual.option.followUpBehavior.steer.label": "Steer (insert into the running turn)", - "settings.openchamber.visual.option.followUpBehavior.queue.label": "Queue (deliver after the current turn)", + "settings.openchamber.visual.option.followUpBehavior.steer.label": "Steer", + "settings.openchamber.visual.option.followUpBehavior.queue.label": "Queue", } as const; diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index 14872f01..8b351bfc 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -1804,6 +1804,6 @@ export const settingsDict = { "settings.openchamber.visual.section.followUpBehaviorAria": "Follow-up behavior", "settings.openchamber.visual.field.followUpBehaviorAria": "Follow-up behavior: {option}", "settings.openchamber.visual.field.followUpBehaviorDescription": "Choose what happens when you press Enter on a follow-up message while the agent is still responding.", - "settings.openchamber.visual.option.followUpBehavior.steer.label": "Steer (insert into the running turn)", - "settings.openchamber.visual.option.followUpBehavior.queue.label": "Queue (deliver after the current turn)", + "settings.openchamber.visual.option.followUpBehavior.steer.label": "Steer", + "settings.openchamber.visual.option.followUpBehavior.queue.label": "Queue", } as const; diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index 2bb3c8df..e6a959d5 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -1804,6 +1804,6 @@ export const settingsDict = { 'settings.openchamber.visual.section.followUpBehaviorAria': 'Follow-up behavior', 'settings.openchamber.visual.field.followUpBehaviorAria': 'Follow-up behavior: {option}', 'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.', - 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer (insert into the running turn)', - 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue (deliver after the current turn)', + 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer', + 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue', } as const; diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index b4067efd..06989c74 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -1804,6 +1804,6 @@ 'settings.openchamber.visual.section.followUpBehaviorAria': 'Follow-up behavior', 'settings.openchamber.visual.field.followUpBehaviorAria': 'Follow-up behavior: {option}', 'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.', - 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer (insert into the running turn)', - 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue (deliver after the current turn)', + 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer', + 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue', } as const; From 130ec9949bc603474e79805077b491b6d52dfc20 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 29 Jun 2026 01:42:16 +0300 Subject: [PATCH 0046/1073] docs(vscode-changelog): add unreleased entries for follow-up behavior and sync --- packages/vscode/CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index 2f529cb1..3fd8d5c9 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -1,6 +1,7 @@ ## [Unreleased] -- No notable changes. +- Chat: a new Follow-up behavior setting controls what happens when you press Enter on a message while the agent is still responding — Steer inserts it into the agent's current turn, or Queue holds it until the turn finishes. Replaces the previous queue-mode toggle (thanks to @bashrusakh). +- Sync: a connected but quiet session (for example an agent running a long tool call) no longer triggers repeated background refreshes every ~15 seconds (thanks to @tomzx). ## [1.13.7] - 2026-06-28 From f45089ccce687f4e5431f87c56d35d4f4eccd02d Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 29 Jun 2026 01:53:47 +0300 Subject: [PATCH 0047/1073] fix(git): don't log an error when status is requested for a deleted directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getStatus() screamed 'Failed to get Git status' and rethrew for a directory that no longer exists — a benign case hit when PR-status resolution touches a worktree that was deleted while still being watched. Treat a missing directory like a non-repo: skip the error log (callers already handle/​swallow it). --- packages/web/server/lib/git/service.js | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/web/server/lib/git/service.js b/packages/web/server/lib/git/service.js index 007b4671..4b738b00 100644 --- a/packages/web/server/lib/git/service.js +++ b/packages/web/server/lib/git/service.js @@ -824,6 +824,19 @@ const isNotGitRepositoryError = (error) => { return /not a git repository/i.test(text); }; +// A directory that no longer exists (e.g. a worktree deleted while something +// was still polling its status) is an expected, benign condition — not a fault +// to scream about. simple-git throws "Cannot use simple-git on a directory that +// does not exist"; the underlying fs errors are ENOENT/ENOTDIR. +const isMissingDirectoryError = (error) => { + const code = error?.code; + if (code === 'ENOENT' || code === 'ENOTDIR') { + return true; + } + const text = parseGitErrorText(error); + return /directory that does not exist|does not exist|no such file or directory/i.test(text); +}; + const runGitCommand = async (cwd, args) => { try { const { stdout, stderr } = await execFileAsync(getGitBinary(), args, { @@ -2184,7 +2197,7 @@ export async function getStatus(directory, options = {}) { rebaseInProgress, }; } catch (error) { - if (!isNotGitRepositoryError(error)) { + if (!isNotGitRepositoryError(error) && !isMissingDirectoryError(error)) { console.error('Failed to get Git status:', error); } throw error; From e5773662b3311462962e4160912050db5f1fcc74 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 29 Jun 2026 01:56:56 +0300 Subject: [PATCH 0048/1073] fix(github): add a per-request timeout to all Octokit calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Octokit v22 uses native fetch, which has no built-in timeout, so a stuck GitHub request hung until the PR-status route's 12s overall budget fired — and one slow request could consume the entire budget. Wrap fetch with an 8s AbortSignal.timeout via a shared createOctokit() factory, and route the inline Octokit instantiations through it too. --- packages/web/server/lib/github/index.js | 1 + packages/web/server/lib/github/octokit.js | 22 +++++++++++++++++++++- packages/web/server/lib/github/routes.js | 16 ++++++++-------- 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/packages/web/server/lib/github/index.js b/packages/web/server/lib/github/index.js index 926584b7..893a26e5 100644 --- a/packages/web/server/lib/github/index.js +++ b/packages/web/server/lib/github/index.js @@ -21,6 +21,7 @@ export { export { getOctokitOrNull, + createOctokit, } from './octokit.js'; export { diff --git a/packages/web/server/lib/github/octokit.js b/packages/web/server/lib/github/octokit.js index b6cd15cc..caf05306 100644 --- a/packages/web/server/lib/github/octokit.js +++ b/packages/web/server/lib/github/octokit.js @@ -2,6 +2,26 @@ import { Octokit } from '@octokit/rest'; import { getGitHubAuth, isGhCliActive, isGhCliDisabled } from './auth.js'; import { getGhCliToken } from './gh-cli-credential.js'; +// Per-request timeout for every GitHub call. Octokit v22 uses native fetch, +// which has no built-in timeout — without this, a stuck connection hangs until +// some outer bound (the PR-status route's 12s overall budget) fires, and a +// single slow request can eat the whole budget. Bounding each request lets the +// caller fail fast and fall back to cached state instead. +const OCTOKIT_REQUEST_TIMEOUT_MS = 8000; + +const timeoutFetch = (url, options = {}) => { + // Respect a caller-provided signal if present; otherwise attach our timeout. + if (options.signal) { + return fetch(url, options); + } + return fetch(url, { ...options, signal: AbortSignal.timeout(OCTOKIT_REQUEST_TIMEOUT_MS) }); +}; + +/** Create an Octokit instance with a per-request timeout applied. */ +export function createOctokit(token) { + return new Octokit({ auth: token, request: { fetch: timeoutFetch } }); +} + export function getOctokitOrNull() { const auth = getGitHubAuth(); const ghToken = !isGhCliDisabled() ? getGhCliToken() : null; @@ -9,5 +29,5 @@ export function getOctokitOrNull() { if (!token) { return null; } - return new Octokit({ auth: token }); + return createOctokit(token); } diff --git a/packages/web/server/lib/github/routes.js b/packages/web/server/lib/github/routes.js index 6307c268..0f20ccc0 100644 --- a/packages/web/server/lib/github/routes.js +++ b/packages/web/server/lib/github/routes.js @@ -108,8 +108,8 @@ export function registerGitHubRoutes(app) { if (ghToken !== null && !ghCliDisabled) { try { - const { Octokit } = await import('@octokit/rest'); - ghCliUser = await getGitHubUserSummary(new Octokit({ auth: ghToken })); + const { createOctokit } = await import('./octokit.js'); + ghCliUser = await getGitHubUserSummary(createOctokit(ghToken)); } catch { ghCliUser = null; } @@ -246,8 +246,8 @@ export function registerGitHubRoutes(app) { return res.status(500).json({ error: 'Missing access_token from GitHub' }); } - const { Octokit } = await import('@octokit/rest'); - const octokit = new Octokit({ auth: accessToken }); + const { createOctokit } = await import('./octokit.js'); + const octokit = createOctokit(accessToken); const user = await getGitHubUserSummary(octokit); setGitHubAuth({ @@ -283,8 +283,8 @@ export function registerGitHubRoutes(app) { return res.status(404).json({ error: 'GitHub CLI account not found' }); } - const { Octokit } = await import('@octokit/rest'); - const user = await getGitHubUserSummary(new Octokit({ auth: ghToken })); + const { createOctokit } = await import('./octokit.js'); + const user = await getGitHubUserSummary(createOctokit(ghToken)); setGhCliActive(true); const accounts = getGitHubAuthAccounts() .map((account) => ({ ...account, current: false })) @@ -319,8 +319,8 @@ export function registerGitHubRoutes(app) { let ghCliUser = null; if (ghToken) { try { - const { Octokit } = await import('@octokit/rest'); - ghCliUser = await getGitHubUserSummary(new Octokit({ auth: ghToken })); + const { createOctokit } = await import('./octokit.js'); + ghCliUser = await getGitHubUserSummary(createOctokit(ghToken)); accounts = accounts.concat({ id: GH_CLI_ACCOUNT_ID, user: ghCliUser, From 1e3139ab3814218d98687dc69c983e455441b6da Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 29 Jun 2026 02:01:30 +0300 Subject: [PATCH 0049/1073] feat(github): detect rate limiting and pause PR status calls during cooldown Octokit has no throttling plugin, so under a flood of PR-status calls a primary/secondary rate limit just surfaced as repeated 403s that the cache masked. Add a shared rate-limit gate: PR-status sub-calls note 403/429 responses, and the route short-circuits to cached/stale data during the cooldown instead of issuing more doomed requests. Transient failures (rate limit or the overall timeout) no longer log as hard errors. --- packages/web/server/lib/github/pr-status.js | 7 ++- packages/web/server/lib/github/rate-limit.js | 66 ++++++++++++++++++++ packages/web/server/lib/github/routes.js | 29 +++++++++ 3 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 packages/web/server/lib/github/rate-limit.js diff --git a/packages/web/server/lib/github/pr-status.js b/packages/web/server/lib/github/pr-status.js index 4f8e27f8..f4bfdced 100644 --- a/packages/web/server/lib/github/pr-status.js +++ b/packages/web/server/lib/github/pr-status.js @@ -1,5 +1,6 @@ import { getRemotes, getStatus } from '../git/index.js'; import { resolveGitHubRepoFromDirectory } from './repo/index.js'; +import { noteIfGitHubRateLimit } from './rate-limit.js'; const REPO_DEFAULT_BRANCH_TTL_MS = 5 * 60_000; const defaultBranchCache = new Map(); @@ -182,7 +183,8 @@ const getRepoDefaultBranch = async (octokit, repo) => { fetchedAt: Date.now(), }); return defaultBranch; - } catch { + } catch (error) { + noteIfGitHubRateLimit(error); return null; } }; @@ -210,6 +212,7 @@ const getRepoMetadata = async (octokit, repo) => { }); return data; } catch (error) { + noteIfGitHubRateLimit(error); if (error?.status === 403 || error?.status === 404) { repoMetadataCache.set(repoKey, { data: null, @@ -290,6 +293,7 @@ const safeListPulls = async (octokit, options) => { const response = await octokit.rest.pulls.list(options); return Array.isArray(response?.data) ? response.data : []; } catch (error) { + noteIfGitHubRateLimit(error); if (error?.status === 404 || error?.status === 403) { return []; } @@ -345,6 +349,7 @@ const searchFallbackPr = async ({ octokit, branch, repoNames }) => { // If we get here, search API works for this repo — clear the disabled flag _searchApiDisabledRepos.delete(repoKey); } catch (error) { + noteIfGitHubRateLimit(error); if (error?.status === 403) { _searchApiDisabledRepos.set(repoKey, Date.now()); return null; diff --git a/packages/web/server/lib/github/rate-limit.js b/packages/web/server/lib/github/rate-limit.js new file mode 100644 index 00000000..80a51410 --- /dev/null +++ b/packages/web/server/lib/github/rate-limit.js @@ -0,0 +1,66 @@ +// Lightweight, process-global GitHub rate-limit gate. +// +// Octokit is configured without the throttling plugin, so a primary or +// secondary rate limit surfaces as a thrown 403/429. Resolving PR status for +// many worktrees fans out dozens of calls; once GitHub starts limiting, every +// further call wastes a round-trip and the cache masks the failure. When we +// detect a rate-limit response we record a cooldown and skip GitHub work until +// it passes, so the burst stops and the reason is visible in the logs. + +const MAX_COOLDOWN_MS = 15 * 60 * 1000; +const DEFAULT_COOLDOWN_MS = 60 * 1000; + +let rateLimitedUntil = 0; + +const headerValue = (headers, name) => { + if (!headers) return undefined; + // Octokit/fetch headers can be a plain object or a Headers instance. + if (typeof headers.get === 'function') return headers.get(name); + return headers[name]; +}; + +const parseRetryAfterMs = (error) => { + const headers = error?.response?.headers; + const retryAfter = headerValue(headers, 'retry-after'); + if (retryAfter !== undefined && retryAfter !== null) { + const secs = Number(retryAfter); + if (Number.isFinite(secs) && secs > 0) return secs * 1000; + } + const reset = headerValue(headers, 'x-ratelimit-reset'); + if (reset !== undefined && reset !== null) { + const delta = Number(reset) * 1000 - Date.now(); + if (Number.isFinite(delta) && delta > 0) return delta; + } + return null; +}; + +/** True when an Octokit error represents a primary or secondary rate limit. */ +export const isGitHubRateLimitError = (error) => { + const status = error?.status ?? error?.response?.status; + if (status === 429) return true; + if (status !== 403) return false; + const remaining = headerValue(error?.response?.headers, 'x-ratelimit-remaining'); + if (remaining === '0' || remaining === 0) return true; + if (headerValue(error?.response?.headers, 'retry-after') != null) return true; + const message = String(error?.message ?? '').toLowerCase(); + return message.includes('rate limit'); +}; + +/** Record a cooldown after a detected rate-limit response. */ +export const noteGitHubRateLimit = (error) => { + const retryMs = Math.min(parseRetryAfterMs(error) ?? DEFAULT_COOLDOWN_MS, MAX_COOLDOWN_MS); + const until = Date.now() + retryMs; + if (until > rateLimitedUntil) { + rateLimitedUntil = until; + console.warn(`[github] rate limited — pausing GitHub PR status calls for ~${Math.round(retryMs / 1000)}s`); + } +}; + +/** Convenience: note the error if it is a rate-limit error. Returns whether it was. */ +export const noteIfGitHubRateLimit = (error) => { + if (!isGitHubRateLimitError(error)) return false; + noteGitHubRateLimit(error); + return true; +}; + +export const isGitHubRateLimited = () => Date.now() < rateLimitedUntil; diff --git a/packages/web/server/lib/github/routes.js b/packages/web/server/lib/github/routes.js index 0f20ccc0..765f4a26 100644 --- a/packages/web/server/lib/github/routes.js +++ b/packages/web/server/lib/github/routes.js @@ -419,6 +419,17 @@ export function registerGitHubRoutes(app) { return res.json(cached.data); } + // If GitHub recently rate-limited us, don't pile on more calls that will + // also fail. Serve whatever we last cached (even if stale); otherwise + // report a transient failure so the client keeps its last-known status. + const { isGitHubRateLimited } = await import('./rate-limit.js'); + if (isGitHubRateLimited()) { + if (cached) { + return res.json(cached.data); + } + return res.status(503).json({ error: 'GitHub rate limited' }); + } + // Intercept res.json to cache successful responses before sending // Only caches responses with connected:true — error/edge-case responses are not cached const originalJson = res.json.bind(res); @@ -577,6 +588,24 @@ export function registerGitHubRoutes(app) { clearGitHubAuth(); return res.json({ connected: false }); } + // Transient failures — a rate limit, or the overall resolve timeout + // firing — are expected under heavy load and should not be logged as hard + // errors. Record a rate-limit cooldown when applicable, then serve the + // last cached status (even if stale) or a 503 so the client keeps its + // last-known value instead of clearing the badge. + const { noteIfGitHubRateLimit } = await import('./rate-limit.js'); + const wasRateLimited = noteIfGitHubRateLimit(error); + const wasTimeout = error?.code === 'ETIMEDOUT'; + if (wasRateLimited || wasTimeout) { + const dir = typeof req.query?.directory === 'string' ? req.query.directory.trim() : ''; + const br = typeof req.query?.branch === 'string' ? req.query.branch.trim() : ''; + const rem = typeof req.query?.remote === 'string' ? req.query.remote.trim() : 'origin'; + const cached = prStatusCache.get(`${dir}::${br}::${rem}`); + if (cached) { + return res.json(cached.data); + } + return res.status(503).json({ error: wasRateLimited ? 'GitHub rate limited' : 'GitHub request timed out' }); + } if (isGitHubResourceUnavailable(error)) { return res.json({ connected: true, From c60b036ef567905774e03094778e3cf7af6d431a Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 29 Jun 2026 02:03:10 +0300 Subject: [PATCH 0050/1073] perf(github): resolve remote candidates and repo metadata concurrently resolveGitHubPrStatus walked remotes and candidate repos one network call at a time. Resolve all ranked remotes and fetch all candidate repo metadata with Promise.all instead, preserving rank/priority order and dedup. Cuts wall-clock on multi-remote/fork setups so a resolution is far less likely to hit the overall timeout. The PR-search loop keeps its early-return (parallelizing it would issue more calls, not fewer). --- packages/web/server/lib/github/pr-status.js | 33 ++++++++++++++------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/packages/web/server/lib/github/pr-status.js b/packages/web/server/lib/github/pr-status.js index f4bfdced..b8341360 100644 --- a/packages/web/server/lib/github/pr-status.js +++ b/packages/web/server/lib/github/pr-status.js @@ -225,21 +225,26 @@ const getRepoMetadata = async (octokit, repo) => { }; const resolveRemoteCandidates = async (directory, rankedRemoteNames) => { + // Resolve every ranked remote concurrently — they're independent git lookups. + // Dedup afterwards in rank order so the result is identical to the previous + // sequential pass, just without paying each lookup's latency back-to-back. + const resolvedRemotes = await Promise.all( + rankedRemoteNames.map((remoteName) => + resolveGitHubRepoFromDirectory(directory, remoteName) + .then((resolved) => ({ remoteName, repo: resolved?.repo || null })) + .catch(() => ({ remoteName, repo: null })), + ), + ); + const results = []; const seenRepoKeys = new Set(); - - for (const remoteName of rankedRemoteNames) { - const resolved = await resolveGitHubRepoFromDirectory(directory, remoteName).catch(() => ({ repo: null })); - const repo = resolved?.repo || null; + for (const { remoteName, repo } of resolvedRemotes) { const repoKey = normalizeRepoKey(repo?.owner, repo?.repo); if (!repo || !repoKey || seenRepoKeys.has(repoKey)) { continue; } seenRepoKeys.add(repoKey); - results.push({ - remoteName, - repo, - }); + results.push({ remoteName, repo }); } return results; @@ -258,8 +263,16 @@ const expandRepoNetwork = async (octokit, candidates) => { expanded.push({ repo, remoteName, priority }); }; - for (const candidate of candidates) { - const metadata = await getRepoMetadata(octokit, candidate.repo); + // Fetch repo metadata for all candidates concurrently (independent GET + // /repos calls), then fold them in candidate order so dedup/priority is + // unchanged from the sequential version. + const metadatas = await Promise.all( + candidates.map((candidate) => + getRepoMetadata(octokit, candidate.repo).then((metadata) => ({ candidate, metadata })), + ), + ); + + for (const { candidate, metadata } of metadatas) { if (!metadata) { continue; } From 7a0b4e5d660e41eca600013f3fa3de5bdbeab28c Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 29 Jun 2026 02:05:26 +0300 Subject: [PATCH 0051/1073] fix(github): skip PR status resolution for a directory that no longer exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A deleted worktree often still has a session in the sidebar, which keeps polling its PR status — spending a git status call (the source of the noisy 'directory does not exist' errors) plus remote/repo resolution on a gone path. Bail out early when the directory is missing; the route already returns a benign no-repo result, which caches so it stops re-polling. --- packages/web/server/lib/github/pr-status.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/web/server/lib/github/pr-status.js b/packages/web/server/lib/github/pr-status.js index b8341360..89de3dd1 100644 --- a/packages/web/server/lib/github/pr-status.js +++ b/packages/web/server/lib/github/pr-status.js @@ -1,7 +1,18 @@ +import { stat } from 'node:fs/promises'; import { getRemotes, getStatus } from '../git/index.js'; import { resolveGitHubRepoFromDirectory } from './repo/index.js'; import { noteIfGitHubRateLimit } from './rate-limit.js'; +const directoryExists = async (dir) => { + if (!dir) return false; + try { + await stat(dir); + return true; + } catch { + return false; + } +}; + const REPO_DEFAULT_BRANCH_TTL_MS = 5 * 60_000; const defaultBranchCache = new Map(); const repoMetadataCache = new Map(); @@ -453,6 +464,14 @@ const findFirstMatchingPr = async ({ octokit, target, branch, sourceCandidates } }; export async function resolveGitHubPrStatus({ octokit, directory, branch, remoteName }) { + // A deleted worktree can still have a session in the sidebar that keeps + // requesting its PR status. Bail before touching git or GitHub for a + // directory that no longer exists — otherwise every poll spends a git call + // (and the remote/repo resolution that follows) on a path that's gone. + if (!(await directoryExists(directory))) { + return { repo: null, pr: null, defaultBranch: null, resolvedRemoteName: null }; + } + const normalizedBranch = normalizeText(branch); const normalizedRemoteName = normalizeText(remoteName) || 'origin'; From c48d57bfaf63ae7d8edaa27bb2e5121f70de55fc Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 29 Jun 2026 02:13:08 +0300 Subject: [PATCH 0052/1073] release v1.13.8 --- CHANGELOG.md | 2 ++ package.json | 2 +- packages/electron/package.json | 2 +- packages/ui/package.json | 2 +- packages/vscode/CHANGELOG.md | 2 +- packages/vscode/package.json | 2 +- packages/web/package.json | 2 +- 7 files changed, 8 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1497242e..ba7637c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [1.13.8] - 2026-06-29 + - Startup: launching the app no longer hangs for around 20 seconds before you can open a session, load a diff, or send a message — GitHub pull request status checks no longer tie up the connection to the server during startup. - OpenCode: when a separate OpenCode is already running (the TUI, `opencode serve`, or a daemon on the default port 4096), the app now starts its own server instead of attaching to it. This fixes the "OpenChamber could not finish initialization" error and stops the app from opening or closing your separate OpenCode when it starts and quits. Connecting to an external OpenCode now requires setting `OPENCODE_HOST`, `OPENCODE_PORT`, or `OPENCODE_SKIP_START`. - Chat: a new Follow-up behavior setting (Settings → Chat) controls what happens when you press Enter on a message while the agent is still responding — Steer inserts it into the agent's current turn, or Queue holds it until the turn finishes. Replaces the previous queue-mode toggle (thanks to @bashrusakh). diff --git a/package.json b/package.json index 3c10dabe..5da57494 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openchamber-monorepo", - "version": "1.13.7", + "version": "1.13.8", "description": "OpenChamber monorepo workspace for web, ui, and desktop runtimes", "private": true, "type": "module", diff --git a/packages/electron/package.json b/packages/electron/package.json index bf44e013..b7208b28 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -1,6 +1,6 @@ { "name": "@openchamber/electron", - "version": "1.13.7", + "version": "1.13.8", "private": true, "description": "Electron desktop runtime for OpenChamber", "author": "OpenChamber", diff --git a/packages/ui/package.json b/packages/ui/package.json index f697ccce..85c4e41d 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@openchamber/ui", - "version": "1.13.7", + "version": "1.13.8", "private": true, "type": "module", "main": "src/main.tsx", diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index 3fd8d5c9..ee615726 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -1,4 +1,4 @@ -## [Unreleased] +## [1.13.8] - 2026-06-29 - Chat: a new Follow-up behavior setting controls what happens when you press Enter on a message while the agent is still responding — Steer inserts it into the agent's current turn, or Queue holds it until the turn finishes. Replaces the previous queue-mode toggle (thanks to @bashrusakh). - Sync: a connected but quiet session (for example an agent running a long tool call) no longer triggers repeated background refreshes every ~15 seconds (thanks to @tomzx). diff --git a/packages/vscode/package.json b/packages/vscode/package.json index adf7ce47..9b520338 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -2,7 +2,7 @@ "name": "openchamber", "displayName": "OpenChamber", "description": "%extension.description%", - "version": "1.13.7", + "version": "1.13.8", "publisher": "fedaykindev", "private": true, "repository": { diff --git a/packages/web/package.json b/packages/web/package.json index c6eb0f36..3ac7bdb4 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@openchamber/web", - "version": "1.13.7", + "version": "1.13.8", "private": false, "type": "module", "main": "./server/index.js", From 00fd15c356062f2346f65a1915af288600984128 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 29 Jun 2026 11:56:40 +0300 Subject: [PATCH 0053/1073] fix(vscode): avoid writing null agent config fields Omit unset agent fields on create Delete cleared agent fields in VS Code config updates Cover null field removal with a regression test --- packages/ui/src/stores/useAgentsStore.ts | 6 ++-- .../vscode/src/bridge-config-runtime.test.js | 33 +++++++++++++++++++ packages/vscode/src/opencodeConfig.ts | 33 ++++++++++++++++++- packages/web/server/lib/opencode/agents.js | 5 ++- 4 files changed, 73 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/stores/useAgentsStore.ts b/packages/ui/src/stores/useAgentsStore.ts index 9b642017..d9bd2077 100644 --- a/packages/ui/src/stores/useAgentsStore.ts +++ b/packages/ui/src/stores/useAgentsStore.ts @@ -177,6 +177,8 @@ const SLOW_HEALTH_POLL_BASE_MS = 800; const SLOW_HEALTH_POLL_INCREMENT_MS = 200; const SLOW_HEALTH_POLL_MAX_MS = 2000; +const hasValue = (value: T | null | undefined): value is T => value !== null && value !== undefined; + export interface AgentDraft { name: string; scope: AgentScope; @@ -345,8 +347,8 @@ export const useAgentsStore = create()( if (config.description) agentConfig.description = config.description; if (config.model) agentConfig.model = config.model; if (config.variant) agentConfig.variant = config.variant; - if (config.temperature !== undefined) agentConfig.temperature = config.temperature ?? null; - if (config.top_p !== undefined) agentConfig.top_p = config.top_p ?? null; + if (hasValue(config.temperature)) agentConfig.temperature = config.temperature; + if (hasValue(config.top_p)) agentConfig.top_p = config.top_p; if (config.prompt) agentConfig.prompt = config.prompt; if (config.permission) agentConfig.permission = config.permission; if (config.disable !== undefined) agentConfig.disable = config.disable; diff --git a/packages/vscode/src/bridge-config-runtime.test.js b/packages/vscode/src/bridge-config-runtime.test.js index fef9b3de..9cdabc23 100644 --- a/packages/vscode/src/bridge-config-runtime.test.js +++ b/packages/vscode/src/bridge-config-runtime.test.js @@ -52,6 +52,39 @@ afterEach(() => { const readJson = (filePath) => JSON.parse(fs.readFileSync(filePath, 'utf8')); describe('VS Code config bridge plugin parity', () => { + test('removes agent fields when update payload sends null', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-vscode-agent-null-')); + tempRoots.push(root); + const ctx = createCtx(root); + const configDir = path.join(root, '.opencode'); + const configPath = path.join(configDir, 'opencode.json'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(configPath, JSON.stringify({ + agent: { + build: { + variant: 'fast', + temperature: 0.3, + top_p: 0.8, + mode: 'subagent', + }, + }, + }, null, 2), 'utf8'); + + const updated = await handleConfigBridgeMessage({ + id: 'update-agent-null-fields', + type: 'api:config/agents', + payload: { + method: 'PATCH', + name: 'build', + directory: root, + body: { variant: null, temperature: null, top_p: null }, + }, + }, ctx, deps); + + expect(updated?.success).toBe(true); + expect(readJson(configPath).agent.build).toEqual({ mode: 'subagent' }); + }); + test('creates, lists, updates, and deletes project plugin entries', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-vscode-plugins-')); tempRoots.push(root); diff --git a/packages/vscode/src/opencodeConfig.ts b/packages/vscode/src/opencodeConfig.ts index 9d9c8b5c..8478e1ab 100644 --- a/packages/vscode/src/opencodeConfig.ts +++ b/packages/vscode/src/opencodeConfig.ts @@ -1605,12 +1605,30 @@ export const createAgent = (agentName: string, config: Record, } // Extract scope and prompt from config - scope is only used for path determination, not written to file - const { prompt, scope: _ignored, ...frontmatter } = config as Record & { prompt?: unknown; scope?: unknown }; + const { prompt, scope: _ignored, ...rawFrontmatter } = config as Record & { prompt?: unknown; scope?: unknown }; void _ignored; // Scope is only used for path determination + const frontmatter = Object.fromEntries( + Object.entries(rawFrontmatter).filter(([, value]) => value !== null && value !== undefined) + ); writeMdFile(targetPath, frontmatter, typeof prompt === 'string' ? prompt : ''); resetAgentLookupCache(globalAgentLookupCache); }; +const deleteAgentJsonField = (config: Record, agentName: string, field: string): boolean => { + const agentMap = config.agent as Record | undefined; + const current = agentMap?.[agentName] as Record | undefined; + if (!agentMap || !current || !(field in current)) return false; + + delete current[field]; + if (Object.keys(current).length === 0) { + delete agentMap[agentName]; + } + if (Object.keys(agentMap).length === 0) { + delete config.agent; + } + return true; +}; + export const updateAgent = (agentName: string, updates: Record, workingDirectory?: string) => { ensureDirs(); @@ -1757,6 +1775,19 @@ export const updateAgent = (agentName: string, updates: Record, const hasMdField = Boolean(mdData?.frontmatter?.[field] !== undefined); const hasJsonField = Boolean(jsonSection?.[field] !== undefined); + if (value === null) { + if (hasMdField && mdData) { + delete mdData.frontmatter[field]; + mdModified = true; + } + + if (hasJsonField && deleteAgentJsonField(config, agentName, field)) { + jsonModified = true; + } + + continue; + } + // JSON takes precedence over md, so update JSON first if field exists there if (hasJsonField) { if (!config.agent) config.agent = {}; diff --git a/packages/web/server/lib/opencode/agents.js b/packages/web/server/lib/opencode/agents.js index 09b0e478..d2acff89 100644 --- a/packages/web/server/lib/opencode/agents.js +++ b/packages/web/server/lib/opencode/agents.js @@ -409,7 +409,10 @@ function createAgent(agentName, config, workingDirectory, scope) { targetScope = AGENT_SCOPE.USER; } - const { prompt, scope: _scopeFromConfig, ...frontmatter } = config; + const { prompt, scope: _scopeFromConfig, ...rawFrontmatter } = config; + const frontmatter = Object.fromEntries( + Object.entries(rawFrontmatter).filter(([, value]) => value !== null && value !== undefined) + ); writeMdFile(targetPath, frontmatter, prompt || ''); console.log(`Created new agent: ${agentName} (scope: ${targetScope}, path: ${targetPath})`); From a1fddd2542bde396036046d95c5ec69c18867c7d Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 29 Jun 2026 12:12:16 +0300 Subject: [PATCH 0054/1073] chore: remove vacation notice from README --- README.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/README.md b/README.md index e9d870af..08ada712 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,6 @@ [![Discord](https://img.shields.io/badge/Discord-join.svg?style=flat&labelColor=100F0F&color=8B7EC8&logo=discord&logoColor=FFFCF0)](https://discord.gg/ZYRSdnwwKA) [![Support the project](https://img.shields.io/badge/Support-Project-black?style=flat&labelColor=100F0F&color=EC8B49&logo=ko-fi&logoColor=FFFCF0)](https://ko-fi.com/G2G41SAWNS) -> [!IMPORTANT] -> 🏖️ I'm on vacation from 18 Jun to 28 Jun. All issues and PRs will continue being reviewed after that. Thanks for the patience. - ## **OpenCode, everywhere.** Desktop. Browser. Phone. ### A rich interface for [OpenCode](https://opencode.ai). Review diffs, manage agents, run dev servers, and keep the big picture while your AI codes. From 9c1eb755f9954854bfa60b73601f6169f917b944 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 29 Jun 2026 12:19:28 +0300 Subject: [PATCH 0055/1073] fix: prevent embedded JSON examples from rendering as result cards Only parse full-message generated JSON results Keep markdown prose with JSON examples rendered normally Add regression coverage for embedded JSON examples --- .../message/parts/generatedJsonResult.test.ts | 40 +++++++++++++++++++ .../chat/message/parts/generatedJsonResult.ts | 18 +++------ 2 files changed, 46 insertions(+), 12 deletions(-) create mode 100644 packages/ui/src/components/chat/message/parts/generatedJsonResult.test.ts diff --git a/packages/ui/src/components/chat/message/parts/generatedJsonResult.test.ts b/packages/ui/src/components/chat/message/parts/generatedJsonResult.test.ts new file mode 100644 index 00000000..f1f6cea5 --- /dev/null +++ b/packages/ui/src/components/chat/message/parts/generatedJsonResult.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from 'bun:test'; + +import { parseGeneratedJsonResult } from './generatedJsonResult'; + +describe('parseGeneratedJsonResult', () => { + test('parses a full pull request JSON result', () => { + expect(parseGeneratedJsonResult('{"title":"Side task","body":"Details"}')).toEqual({ + kind: 'pr', + title: 'Side task', + body: 'Details', + raw: JSON.stringify({ title: 'Side task', body: 'Details' }, null, 2), + }); + }); + + test('parses a full fenced JSON result', () => { + expect(parseGeneratedJsonResult('```json\n{"subject":"Fix parser","highlights":["Narrow detection"]}\n```')).toEqual({ + kind: 'commit', + subject: 'Fix parser', + highlights: ['Narrow detection'], + raw: JSON.stringify({ subject: 'Fix parser', highlights: ['Narrow detection'] }, null, 2), + }); + }); + + test('ignores JSON examples embedded in markdown prose', () => { + const markdown = [ + 'Recommended endpoint:', + '', + '```json', + '{', + ' "title": "Side task",', + ' "prompt": "Investigate X"', + '}', + '```', + '', + 'This should stay markdown.', + ].join('\n'); + + expect(parseGeneratedJsonResult(markdown)).toBeNull(); + }); +}); diff --git a/packages/ui/src/components/chat/message/parts/generatedJsonResult.ts b/packages/ui/src/components/chat/message/parts/generatedJsonResult.ts index 783a20ef..2d95cb73 100644 --- a/packages/ui/src/components/chat/message/parts/generatedJsonResult.ts +++ b/packages/ui/src/components/chat/message/parts/generatedJsonResult.ts @@ -16,21 +16,15 @@ export type GeneratedResult = GeneratedCommitResult | GeneratedPrResult; const parseJsonObjects = (value: string): Record[] => { const text = value.trim(); - const candidates = new Set(); + const candidates: string[] = []; - const fencedMatches = text.matchAll(/```(?:json)?\s*([\s\S]*?)```/gi); - for (const match of fencedMatches) { - if (match[1]) candidates.add(match[1].trim()); + const fencedMatch = text.match(/^```(?:json)?\s*([\s\S]*?)```$/i); + if (fencedMatch?.[1]) { + candidates.push(fencedMatch[1].trim()); } - const firstObjectStart = text.indexOf('{'); - if (firstObjectStart >= 0) { - for (let end = text.length; end > firstObjectStart; end -= 1) { - if (text[end - 1] === '}') { - candidates.add(text.slice(firstObjectStart, end)); - break; - } - } + if (text.startsWith('{') && text.endsWith('}')) { + candidates.push(text); } const parsed: Record[] = []; From 359c73fcf3f7b7d453542b4d5c7d9e8b381e0d10 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 30 Jun 2026 00:30:48 +0300 Subject: [PATCH 0056/1073] feat(desktop): support remote runtime headers --- packages/electron/main.mjs | 79 +++++++++++------ packages/electron/preload.mjs | 11 +++ packages/electron/runtime-request-headers.mjs | 16 ++++ .../electron/runtime-request-headers.test.mjs | 26 ++++++ .../desktop/DesktopHostSwitcher.tsx | 12 +-- .../remote-instances/RemoteInstancesPage.tsx | 78 ++++++++++++++++- packages/ui/src/lib/desktopHosts.test.ts | 85 ++++++++++++++++++- packages/ui/src/lib/desktopHosts.ts | 35 ++++++-- .../ui/src/lib/i18n/messages/en.settings.ts | 6 ++ .../ui/src/lib/i18n/messages/es.settings.ts | 6 ++ .../ui/src/lib/i18n/messages/fr.settings.ts | 6 ++ .../ui/src/lib/i18n/messages/ja.settings.ts | 6 ++ .../ui/src/lib/i18n/messages/ko.settings.ts | 6 ++ .../ui/src/lib/i18n/messages/pl.settings.ts | 6 ++ .../src/lib/i18n/messages/pt-BR.settings.ts | 6 ++ .../ui/src/lib/i18n/messages/uk.settings.ts | 6 ++ .../src/lib/i18n/messages/zh-CN.settings.ts | 6 ++ .../src/lib/i18n/messages/zh-TW.settings.ts | 6 ++ packages/ui/src/lib/runtime-auth.test.ts | 55 ++++++++++++ packages/ui/src/lib/runtime-auth.ts | 33 +++++++ packages/ui/src/lib/runtime-switch.ts | 7 +- packages/ui/src/lib/settings/search.ts | 2 +- packages/web/src/runtimeConfig.ts | 4 +- 23 files changed, 458 insertions(+), 45 deletions(-) create mode 100644 packages/electron/runtime-request-headers.mjs create mode 100644 packages/electron/runtime-request-headers.test.mjs diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index 27312ef4..2a07debd 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -13,6 +13,7 @@ import updaterPkg from 'electron-updater'; import { ElectronSshManager } from './ssh-manager.mjs'; import { createTrayController } from './tray.mjs'; import { resolveManagedOpenCodeCwd } from './opencode-cwd.mjs'; +import { sanitizeRuntimeRequestHeaders } from './runtime-request-headers.mjs'; import { mintOutsideFileGrant } from '@openchamber/web/server/lib/fs/routes.js'; const execFileAsync = promisify(execFile); @@ -170,6 +171,7 @@ const state = { localOrigin: null, apiBaseUrl: null, clientToken: null, + requestHeaders: {}, bootOutcome: null, initScript: null, mainWindow: null, @@ -495,10 +497,11 @@ const shouldUseSameOriginDevProxy = (uiUrl, apiBaseUrl) => ( const buildRendererRuntimeConfig = (uiUrl, runtimeConfig = {}) => { const apiBaseUrl = typeof runtimeConfig.apiBaseUrl === 'string' ? runtimeConfig.apiBaseUrl : (state.apiBaseUrl || ''); const clientToken = typeof runtimeConfig.clientToken === 'string' ? runtimeConfig.clientToken : (state.clientToken || ''); + const requestHeaders = sanitizeRuntimeRequestHeaders(runtimeConfig.requestHeaders || state.requestHeaders || {}); if (shouldUseSameOriginDevProxy(uiUrl, apiBaseUrl)) { - return { apiBaseUrl: '', clientToken: '' }; + return { apiBaseUrl: '', clientToken: '', requestHeaders: {} }; } - return { apiBaseUrl, clientToken }; + return { apiBaseUrl, clientToken, requestHeaders }; }; const readDesktopLocalClientToken = () => { @@ -520,8 +523,9 @@ const readDesktopHostsConfig = () => { if (!id || id === LOCAL_HOST_ID || !url) return null; const apiUrl = sanitizeHostUrlForStorage(entry?.apiUrl) || url; const clientToken = sanitizeClientTokenForStorage(entry?.clientToken); + const requestHeaders = sanitizeRuntimeRequestHeaders(entry?.requestHeaders); const label = typeof entry?.label === 'string' && entry.label.trim() ? entry.label.trim() : url; - return { id, label, url, apiUrl, ...(clientToken ? { clientToken } : {}) }; + return { id, label, url, apiUrl, ...(clientToken ? { clientToken } : {}), ...(Object.keys(requestHeaders).length > 0 ? { requestHeaders } : {}) }; }) .filter(Boolean); @@ -544,12 +548,14 @@ const writeDesktopHostsConfig = async (config) => { if (!id || id === LOCAL_HOST_ID || !url) return null; const apiUrl = sanitizeHostUrlForStorage(entry?.apiUrl) || url; const clientToken = sanitizeClientTokenForStorage(entry?.clientToken); + const requestHeaders = sanitizeRuntimeRequestHeaders(entry?.requestHeaders); return { id, label: typeof entry?.label === 'string' && entry.label.trim() ? entry.label.trim() : url, url, apiUrl, ...(clientToken ? { clientToken } : {}), + ...(Object.keys(requestHeaders).length > 0 ? { requestHeaders } : {}), }; }) .filter(Boolean) @@ -704,7 +710,7 @@ const fetchVersionPayload = async (versionUrl, { headers, timeoutMs }) => { } }; -const probeHostWithTimeout = async (url, timeoutMs, clientToken = '') => { +const probeHostWithTimeout = async (url, timeoutMs, clientToken = '', requestHeaders = {}) => { const versionUrl = buildVersionUrl(url); if (!versionUrl) { throw new Error('Invalid URL'); @@ -712,7 +718,7 @@ const probeHostWithTimeout = async (url, timeoutMs, clientToken = '') => { const started = Date.now(); try { - const headers = { Accept: 'application/json' }; + const headers = { ...sanitizeRuntimeRequestHeaders(requestHeaders), Accept: 'application/json' }; const token = typeof clientToken === 'string' ? clientToken.trim() : ''; if (token) { headers.Authorization = `Bearer ${token}`; @@ -1317,17 +1323,18 @@ const macosMajorVersion = () => { return major === 10 ? minor : major; }; -const buildInitScript = (localOrigin, bootOutcome, apiBaseUrl = '', clientToken = '') => { +const buildInitScript = (localOrigin, bootOutcome, apiBaseUrl = '', clientToken = '', requestHeaders = {}) => { const home = JSON.stringify(os.homedir() || ''); const local = JSON.stringify(localOrigin || ''); const apiBase = JSON.stringify(apiBaseUrl || ''); const token = JSON.stringify(clientToken || ''); + const headers = JSON.stringify(sanitizeRuntimeRequestHeaders(requestHeaders)); const packagedOrigin = JSON.stringify(packagedUiOrigin()); const macVersion = macosMajorVersion(); const outcome = JSON.stringify(bootOutcome ?? null); return [ '(function(){', - `try{var __oc_local=${local};var __oc_api=${apiBase};var __oc_packaged=${packagedOrigin};var __oc_origin=window.location&&window.location.origin||'';var __oc_is_packaged=__oc_origin===__oc_packaged;var __oc_is_local=__oc_local&&__oc_origin===new URL(__oc_local).origin;window.__OPENCHAMBER_MACOS_MAJOR__=${macVersion};window.__OPENCHAMBER_LOCAL_ORIGIN__=__oc_local;window.__OPENCHAMBER_API_BASE_URL__=__oc_api;if(__oc_is_local||__oc_is_packaged){window.__OPENCHAMBER_HOME__=${home};}if((__oc_is_local||__oc_is_packaged)&&${token}){window.__OPENCHAMBER_CLIENT_TOKEN__=${token};}var __oc_bo=${outcome};if(__oc_bo){window.__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__=__oc_bo;}}catch(_e){}`, + `try{var __oc_local=${local};var __oc_api=${apiBase};var __oc_headers=${headers};var __oc_packaged=${packagedOrigin};var __oc_origin=window.location&&window.location.origin||'';var __oc_is_packaged=__oc_origin===__oc_packaged;var __oc_is_local=__oc_local&&__oc_origin===new URL(__oc_local).origin;window.__OPENCHAMBER_MACOS_MAJOR__=${macVersion};window.__OPENCHAMBER_LOCAL_ORIGIN__=__oc_local;window.__OPENCHAMBER_API_BASE_URL__=__oc_api;if(__oc_is_local||__oc_is_packaged){window.__OPENCHAMBER_HOME__=${home};window.__OPENCHAMBER_RUNTIME_HEADERS__=__oc_headers;}if((__oc_is_local||__oc_is_packaged)&&${token}){window.__OPENCHAMBER_CLIENT_TOKEN__=${token};}var __oc_bo=${outcome};if(__oc_bo){window.__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__=__oc_bo;}}catch(_e){}`, '}())', ].join(''); }; @@ -1694,10 +1701,12 @@ const switchToHostById = async (rawId) => { let targetUrl = null; let apiBaseUrl = null; let clientToken = ''; + let requestHeaders = {}; if (id === LOCAL_HOST_ID) { targetUrl = shouldUsePackagedUi() ? buildPackagedUiUrl('/index.html') : (state.sidecarUrl || state.localOrigin); apiBaseUrl = state.sidecarUrl; clientToken = readDesktopLocalClientToken(); + requestHeaders = {}; } else { const host = config.hosts.find((entry) => entry.id === id); if (!host) { @@ -1707,6 +1716,7 @@ const switchToHostById = async (rawId) => { targetUrl = shouldUsePackagedUi() ? buildPackagedUiUrl('/index.html') : host.url; apiBaseUrl = host.apiUrl || host.url; clientToken = host.clientToken || ''; + requestHeaders = sanitizeRuntimeRequestHeaders(host.requestHeaders || {}); } if (!targetUrl || !apiBaseUrl) { log.warn('[electron] deep-link host has no target URL:', id); @@ -1716,7 +1726,7 @@ const switchToHostById = async (rawId) => { ? { target: 'local', status: 'ok' } : { target: 'remote', status: 'ok', hostId: id, url: apiBaseUrl }; log.info('[electron] switching to host', { id, bootOutcome }); - await activateMainWindow(targetUrl, state.localOrigin, bootOutcome, { apiBaseUrl, clientToken }); + await activateMainWindow(targetUrl, state.localOrigin, bootOutcome, { apiBaseUrl, clientToken, requestHeaders }); }; const confirmConnectDeepLink = async (payload) => { @@ -1913,6 +1923,7 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} } const rendererRuntimeConfig = buildRendererRuntimeConfig(url, runtimeConfig); const desktopApiBaseUrl = rendererRuntimeConfig.apiBaseUrl; const desktopClientToken = rendererRuntimeConfig.clientToken; + const desktopRequestHeaders = rendererRuntimeConfig.requestHeaders || {}; const desktopHome = os.homedir() || ''; const desktopMacosMajor = String(macosMajorVersion()); const usesCustomTitleBar = process.platform === 'darwin' || process.platform === 'win32'; @@ -1949,6 +1960,7 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} } `--openchamber-local-origin=${desktopLocalOrigin}`, `--openchamber-api-base-url=${desktopApiBaseUrl}`, `--openchamber-client-token=${desktopClientToken}`, + `--openchamber-runtime-headers=${JSON.stringify(desktopRequestHeaders)}`, `--openchamber-home=${desktopHome}`, `--openchamber-macos-major=${desktopMacosMajor}`, `--openchamber-mac-vibrancy=${useVibrancy ? '1' : '0'}`, @@ -1969,8 +1981,8 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} } const browserWindow = new BrowserWindow(options); browserWindow.__ocLabel = label || nextWindowLabel(); - browserWindow.__ocRuntimeConfig = { apiBaseUrl: desktopApiBaseUrl, clientToken: desktopClientToken }; - browserWindow.__ocInitScript = buildInitScript(desktopLocalOrigin, state.bootOutcome, desktopApiBaseUrl, desktopClientToken); + browserWindow.__ocRuntimeConfig = { apiBaseUrl: desktopApiBaseUrl, clientToken: desktopClientToken, requestHeaders: desktopRequestHeaders }; + browserWindow.__ocInitScript = buildInitScript(desktopLocalOrigin, state.bootOutcome, desktopApiBaseUrl, desktopClientToken, desktopRequestHeaders); browserWindow.__ocTitleBarOverlayEnabled = titleBarOverlayEnabled; if (useSaved && saved.maximized) { @@ -2156,16 +2168,19 @@ const activateMainWindow = async (url, localOrigin, bootOutcome, runtimeConfig = state.localOrigin = localOrigin; state.apiBaseUrl = typeof runtimeConfig.apiBaseUrl === 'string' ? runtimeConfig.apiBaseUrl : state.apiBaseUrl; state.clientToken = typeof runtimeConfig.clientToken === 'string' ? runtimeConfig.clientToken : ''; + state.requestHeaders = sanitizeRuntimeRequestHeaders(runtimeConfig.requestHeaders || {}); state.bootOutcome = bootOutcome ?? null; const rendererRuntimeConfig = buildRendererRuntimeConfig(url, { apiBaseUrl: state.apiBaseUrl || '', clientToken: state.clientToken || '', + requestHeaders: state.requestHeaders || {}, }); state.initScript = buildInitScript( localOrigin, state.bootOutcome, rendererRuntimeConfig.apiBaseUrl, rendererRuntimeConfig.clientToken, + rendererRuntimeConfig.requestHeaders, ); const mainWindow = state.mainWindow; @@ -2189,8 +2204,8 @@ const activateMainWindow = async (url, localOrigin, bootOutcome, runtimeConfig = const openMainWindow = async () => { if (!state.localOrigin) { - const { initialUrl, localOrigin, bootOutcome, apiBaseUrl, clientToken } = await resolveInitialUrl(); - return activateMainWindow(initialUrl, localOrigin, bootOutcome, { apiBaseUrl, clientToken }); + const { initialUrl, localOrigin, bootOutcome, apiBaseUrl, clientToken, requestHeaders } = await resolveInitialUrl(); + return activateMainWindow(initialUrl, localOrigin, bootOutcome, { apiBaseUrl, clientToken, requestHeaders }); } const config = readDesktopHostsConfig(); @@ -2200,10 +2215,11 @@ const openMainWindow = async () => { : null; const apiBaseUrl = host?.apiUrl || host?.url || state.sidecarUrl || state.apiBaseUrl || ''; const clientToken = host?.clientToken || resolveStoredClientTokenForUrl(apiBaseUrl, config) || state.clientToken || ''; + const requestHeaders = sanitizeRuntimeRequestHeaders(host?.requestHeaders || {}); const targetUrl = host?.url && apiBaseUrl && !state.unreachableHosts.has(apiBaseUrl) ? (shouldUsePackagedUi() ? buildPackagedUiUrl('/index.html') : host.url) : localUiUrl; - return activateMainWindow(targetUrl, state.localOrigin, state.bootOutcome, { apiBaseUrl, clientToken }); + return activateMainWindow(targetUrl, state.localOrigin, state.bootOutcome, { apiBaseUrl, clientToken, requestHeaders }); }; const createAdditionalWindow = async (url, runtimeConfig = {}) => { @@ -2242,12 +2258,14 @@ const getWindowRuntimeConfig = (browserWindow) => { const fallback = { apiBaseUrl: state.apiBaseUrl || state.localOrigin || state.sidecarUrl || '', clientToken: state.clientToken || '', + requestHeaders: state.requestHeaders || {}, }; if (!browserWindow || browserWindow.isDestroyed()) return fallback; const config = browserWindow.__ocRuntimeConfig; return { apiBaseUrl: typeof config?.apiBaseUrl === 'string' ? config.apiBaseUrl : fallback.apiBaseUrl, clientToken: typeof config?.clientToken === 'string' ? config.clientToken : fallback.clientToken, + requestHeaders: sanitizeRuntimeRequestHeaders(config?.requestHeaders || fallback.requestHeaders), }; }; @@ -2255,6 +2273,7 @@ const createMiniChatWindow = async ({ mode, sessionId = '', directory = '', proj const effectiveRuntimeConfig = { apiBaseUrl: normalizeHostUrl(runtimeConfig.apiBaseUrl || state.apiBaseUrl || state.localOrigin || state.sidecarUrl || ''), clientToken: sanitizeClientTokenForStorage(runtimeConfig.clientToken || state.clientToken || ''), + requestHeaders: sanitizeRuntimeRequestHeaders(runtimeConfig.requestHeaders || state.requestHeaders || {}), }; const sessionWindowKey = mode === 'session' && sessionId ? miniChatSessionWindowKey(effectiveRuntimeConfig, sessionId) : ''; if (mode === 'session' && sessionId) { @@ -2271,6 +2290,7 @@ const createMiniChatWindow = async ({ mode, sessionId = '', directory = '', proj const desktopLocalOrigin = state.localOrigin || ''; const desktopApiBaseUrl = effectiveRuntimeConfig.apiBaseUrl || ''; const desktopClientToken = effectiveRuntimeConfig.clientToken || ''; + const desktopRequestHeaders = effectiveRuntimeConfig.requestHeaders || {}; const desktopHome = os.homedir() || ''; const desktopMacosMajor = String(macosMajorVersion()); // macOS vibrancy, on by default; users can disable it (Appearance settings). @@ -2297,6 +2317,7 @@ const createMiniChatWindow = async ({ mode, sessionId = '', directory = '', proj `--openchamber-local-origin=${desktopLocalOrigin}`, `--openchamber-api-base-url=${desktopApiBaseUrl}`, `--openchamber-client-token=${desktopClientToken}`, + `--openchamber-runtime-headers=${JSON.stringify(desktopRequestHeaders)}`, `--openchamber-home=${desktopHome}`, `--openchamber-macos-major=${desktopMacosMajor}`, ], @@ -2311,7 +2332,7 @@ const createMiniChatWindow = async ({ mode, sessionId = '', directory = '', proj }); browserWindow.__ocLabel = nextWindowLabel(); browserWindow.__ocRuntimeConfig = effectiveRuntimeConfig; - browserWindow.__ocInitScript = buildInitScript(desktopLocalOrigin, state.bootOutcome, desktopApiBaseUrl, desktopClientToken); + browserWindow.__ocInitScript = buildInitScript(desktopLocalOrigin, state.bootOutcome, desktopApiBaseUrl, desktopClientToken, desktopRequestHeaders); browserWindow.__ocMiniChat = true; browserWindow.__ocMiniChatSessionId = sessionWindowKey; browserWindow.__ocPinned = false; @@ -2402,9 +2423,11 @@ const resolveMiniChatRuntimeConfig = (browserWindow, args = {}) => { const providedToken = sanitizeClientTokenForStorage(args.clientToken); const storedToken = targetUrl ? resolveStoredClientTokenForUrl(targetUrl) : ''; const windowToken = targetUrl && sameOrigin(windowConfig.apiBaseUrl, targetUrl) ? windowConfig.clientToken : ''; + const windowHeaders = targetUrl && sameOrigin(windowConfig.apiBaseUrl, targetUrl) ? windowConfig.requestHeaders : {}; return { apiBaseUrl: targetUrl, clientToken: providedToken || windowToken || storedToken || '', + requestHeaders: sanitizeRuntimeRequestHeaders(args.requestHeaders || windowHeaders || {}), }; }; @@ -2430,6 +2453,7 @@ const resolveInitialUrl = async () => { let initialUrl = localUiUrl; let apiBaseUrl = localUrl; let clientToken = readDesktopLocalClientToken(); + let requestHeaders = {}; let remoteProbe = null; const envTarget = normalizeHostUrl(process.env.OPENCHAMBER_SERVER_URL || ''); @@ -2437,25 +2461,28 @@ const resolveInitialUrl = async () => { if (envTarget) { apiBaseUrl = envTarget; clientToken = ''; + requestHeaders = {}; initialUrl = shouldUsePackagedUi() ? localUiUrl : envTarget; } else if (config.defaultHostId && config.defaultHostId !== LOCAL_HOST_ID) { const host = config.hosts.find((entry) => entry.id === config.defaultHostId); if (host?.url) { apiBaseUrl = host.apiUrl || host.url; clientToken = host.clientToken || ''; + requestHeaders = sanitizeRuntimeRequestHeaders(host.requestHeaders || {}); initialUrl = shouldUsePackagedUi() ? localUiUrl : host.url; } } if (apiBaseUrl && apiBaseUrl !== localUrl) { - remoteProbe = await probeHostWithTimeout(apiBaseUrl, 2_000); + remoteProbe = await probeHostWithTimeout(apiBaseUrl, 2_000, clientToken, requestHeaders); if (remoteProbe.status === 'unreachable') { - remoteProbe = await probeHostWithTimeout(apiBaseUrl, 10_000); + remoteProbe = await probeHostWithTimeout(apiBaseUrl, 10_000, clientToken, requestHeaders); } if (remoteProbe.status === 'unreachable') { state.unreachableHosts.add(apiBaseUrl); apiBaseUrl = localUrl; clientToken = readDesktopLocalClientToken(); + requestHeaders = {}; initialUrl = localUiUrl; } } @@ -2467,7 +2494,7 @@ const resolveInitialUrl = async () => { localAvailable, }); - return { initialUrl, localOrigin, localUiUrl, bootOutcome, apiBaseUrl, clientToken }; + return { initialUrl, localOrigin, localUiUrl, bootOutcome, apiBaseUrl, clientToken, requestHeaders }; }; const compareSemver = (left, right) => { @@ -3459,7 +3486,7 @@ const handleInvoke = async (browserWindow, command, args = {}) => { config: updatedConfig, localAvailable: Boolean(state.sidecarUrl || state.localOrigin), }); - state.initScript = buildInitScript(state.localOrigin, state.bootOutcome, state.apiBaseUrl, state.clientToken); + state.initScript = buildInitScript(state.localOrigin, state.bootOutcome, state.apiBaseUrl, state.clientToken, state.requestHeaders || {}); log.info('[electron] hosts config updated, recomputed bootOutcome', state.bootOutcome); return null; } @@ -3468,7 +3495,7 @@ const handleInvoke = async (browserWindow, command, args = {}) => { return readDesktopLocalClientToken(); case 'desktop_host_probe': - return probeHostWithTimeout(String(args.url || ''), 2_000, String(args.clientToken || '')); + return probeHostWithTimeout(String(args.url || ''), 2_000, String(args.clientToken || ''), args.requestHeaders || {}); case 'desktop_remote_password_login': return loginRemoteAndIssueClientToken({ @@ -3658,6 +3685,7 @@ const handleInvoke = async (browserWindow, command, args = {}) => { let runtimeConfig = { apiBaseUrl: state.sidecarUrl || state.localOrigin || '', clientToken: readDesktopLocalClientToken(), + requestHeaders: {}, }; if (config.defaultHostId && config.defaultHostId !== LOCAL_HOST_ID) { const host = config.hosts.find((entry) => entry.id === config.defaultHostId); @@ -3667,6 +3695,7 @@ const handleInvoke = async (browserWindow, command, args = {}) => { runtimeConfig = { apiBaseUrl: normalizeHostUrl(apiUrl), clientToken: sanitizeClientTokenForStorage(host.clientToken), + requestHeaders: sanitizeRuntimeRequestHeaders(host.requestHeaders), }; } } @@ -3682,8 +3711,9 @@ const handleInvoke = async (browserWindow, command, args = {}) => { const config = readDesktopHostsConfig(); const providedToken = typeof args.clientToken === 'string' ? args.clientToken : ''; const clientToken = sanitizeClientTokenForStorage(providedToken) || resolveStoredClientTokenForUrl(targetUrl, config); + const requestHeaders = sanitizeRuntimeRequestHeaders(args.requestHeaders || config.hosts.find((host) => normalizeHostUrl(host.apiUrl || host.url) === targetUrl)?.requestHeaders || {}); let windowUrl = targetUrl; - const runtimeConfig = { apiBaseUrl: targetUrl, clientToken }; + const runtimeConfig = { apiBaseUrl: targetUrl, clientToken, requestHeaders }; if (shouldUsePackagedUi()) { windowUrl = buildPackagedUiUrl('/index.html'); } @@ -4468,10 +4498,11 @@ app.whenReady().then(async () => { } if (isBackgroundStart) { - const { localOrigin, bootOutcome } = await resolveInitialUrl(); + const { localOrigin, bootOutcome, requestHeaders } = await resolveInitialUrl(); state.localOrigin = localOrigin; state.bootOutcome = bootOutcome ?? null; - state.initScript = buildInitScript(localOrigin, state.bootOutcome); + state.requestHeaders = sanitizeRuntimeRequestHeaders(requestHeaders || {}); + state.initScript = buildInitScript(localOrigin, state.bootOutcome, '', '', state.requestHeaders); log.info('[electron] started in background without window'); return; } @@ -4485,8 +4516,8 @@ app.whenReady().then(async () => { const initial = extractInitialDeepLinks(); if (initial.length > 0) handleDeepLinks(initial); - const { initialUrl, localOrigin, bootOutcome, apiBaseUrl, clientToken } = await resolveInitialUrl(); - await activateMainWindow(initialUrl, localOrigin, bootOutcome, { apiBaseUrl, clientToken }); + const { initialUrl, localOrigin, bootOutcome, apiBaseUrl, clientToken, requestHeaders } = await resolveInitialUrl(); + await activateMainWindow(initialUrl, localOrigin, bootOutcome, { apiBaseUrl, clientToken, requestHeaders }); // Notify renderer on OS wake-from-sleep so the SSE event pipeline can // reconnect immediately instead of waiting for the heartbeat watchdog. diff --git a/packages/electron/preload.mjs b/packages/electron/preload.mjs index bddbe344..6f7c7923 100644 --- a/packages/electron/preload.mjs +++ b/packages/electron/preload.mjs @@ -14,6 +14,7 @@ const readArgValue = (name) => { const localOrigin = readArgValue('--openchamber-local-origin'); const apiBaseUrl = readArgValue('--openchamber-api-base-url'); const clientToken = readArgValue('--openchamber-client-token'); +const runtimeHeadersRaw = readArgValue('--openchamber-runtime-headers'); const homeDirectory = readArgValue('--openchamber-home'); const macosMajorRaw = readArgValue('--openchamber-macos-major'); const macosMajor = Number.parseInt(macosMajorRaw, 10); @@ -61,6 +62,16 @@ if (clientToken && isLocalPage) { contextBridge.exposeInMainWorld('__OPENCHAMBER_CLIENT_TOKEN__', clientToken); } +if (runtimeHeadersRaw && isLocalPage) { + try { + const runtimeHeaders = JSON.parse(runtimeHeadersRaw); + if (runtimeHeaders && typeof runtimeHeaders === 'object') { + contextBridge.exposeInMainWorld('__OPENCHAMBER_RUNTIME_HEADERS__', runtimeHeaders); + } + } catch { + } +} + // Home directory leaks the OS username — keep local-only. Remote pages // operate on the REMOTE server's filesystem, local home is irrelevant // (and would be misleading if consumed as a workspace hint). diff --git a/packages/electron/runtime-request-headers.mjs b/packages/electron/runtime-request-headers.mjs new file mode 100644 index 00000000..4fb143bb --- /dev/null +++ b/packages/electron/runtime-request-headers.mjs @@ -0,0 +1,16 @@ +const isReservedRuntimeRequestHeaderName = (name) => { + return String(name || '').trim().toLowerCase() === 'authorization'; +}; + +export const sanitizeRuntimeRequestHeaders = (headers) => { + if (!headers || typeof headers !== 'object') return {}; + const next = {}; + for (const [rawName, rawValue] of Object.entries(headers)) { + const name = typeof rawName === 'string' ? rawName.trim() : ''; + const value = typeof rawValue === 'string' ? rawValue.trim() : ''; + if (!name || !value || /[\r\n:]/.test(name) || /[\r\n]/.test(value)) continue; + if (isReservedRuntimeRequestHeaderName(name)) continue; + next[name] = value; + } + return next; +}; diff --git a/packages/electron/runtime-request-headers.test.mjs b/packages/electron/runtime-request-headers.test.mjs new file mode 100644 index 00000000..9945a6b4 --- /dev/null +++ b/packages/electron/runtime-request-headers.test.mjs @@ -0,0 +1,26 @@ +import { describe, expect, test } from 'bun:test'; +import { sanitizeRuntimeRequestHeaders } from './runtime-request-headers.mjs'; + +describe('sanitizeRuntimeRequestHeaders', () => { + test('preserves safe custom headers', () => { + expect(sanitizeRuntimeRequestHeaders({ + ' CF-Access-Client-Id ': ' client-id ', + 'X-Custom-Header': 'value', + })).toEqual({ + 'CF-Access-Client-Id': 'client-id', + 'X-Custom-Header': 'value', + }); + }); + + test('drops invalid and reserved headers', () => { + expect(sanitizeRuntimeRequestHeaders({ + Authorization: 'Bearer proxy-token', + 'authorization': 'Bearer lower-token', + 'Bad:Name': 'value', + 'Bad\nName': 'value', + 'Bad-Value': 'line\nbreak', + Empty: '', + Good: 'ok', + })).toEqual({ Good: 'ok' }); + }); +}); diff --git a/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx b/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx index 4ba3ccad..269d4b90 100644 --- a/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx +++ b/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx @@ -424,7 +424,7 @@ export function DesktopHostSwitcherDialog({ return [h.id, { status: 'unreachable' as const, latencyMs: 0 } satisfies HostStatus] as const; } const clientToken = h.id === LOCAL_HOST_ID ? localClientToken : (h.clientToken || ''); - const res = await desktopHostProbe(url, { clientToken: clientToken || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 })); + const res = await desktopHostProbe(url, { clientToken: clientToken || null, requestHeaders: h.requestHeaders || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 })); return [h.id, { status: res.status, latencyMs: res.latencyMs } satisfies HostStatus] as const; }) ); @@ -492,7 +492,7 @@ export function DesktopHostSwitcherDialog({ if (!apiOrigin) return; setSwitchingHostId(host.id); const clientToken = host.id === LOCAL_HOST_ID ? await getLocalClientToken() : (host.clientToken || ''); - const probe = await desktopHostProbe(apiOrigin, { clientToken: clientToken || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 })); + const probe = await desktopHostProbe(apiOrigin, { clientToken: clientToken || null, requestHeaders: host.requestHeaders || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 })); setStatusById((prev) => ({ ...prev, [host.id]: { status: probe.status, latencyMs: probe.latencyMs }, @@ -504,7 +504,7 @@ export function DesktopHostSwitcherDialog({ return; } - switchRuntimeEndpoint({ apiBaseUrl: apiOrigin, clientToken: clientToken || null, runtimeKey: runtimeKeyForHost(host) }); + switchRuntimeEndpoint({ apiBaseUrl: apiOrigin, clientToken: clientToken || null, requestHeaders: host.requestHeaders || null, runtimeKey: runtimeKeyForHost(host) }); onHostSwitched?.(); setSwitchingHostId(null); return; @@ -598,7 +598,7 @@ export function DesktopHostSwitcherDialog({ if (host.id !== LOCAL_HOST_ID && isDesktopShell()) { setSwitchingHostId(host.id); - const probe = await desktopHostProbe(origin, { clientToken: host.clientToken || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 })); + const probe = await desktopHostProbe(origin, { clientToken: host.clientToken || null, requestHeaders: host.requestHeaders || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 })); setStatusById((prev) => ({ ...prev, [host.id]: { status: probe.status, latencyMs: probe.latencyMs }, @@ -646,7 +646,7 @@ export function DesktopHostSwitcherDialog({ const url = resolved.persistedUrl; const label = (editLabel || redactSensitiveUrl(url)).trim(); - const nextHosts = configHosts.map((h) => (h.id === editingId ? { ...h, label, url } : h)); + const nextHosts = configHosts.map((h) => (h.id === editingId ? { ...h, label, url, apiUrl: url } : h)); await persist(nextHosts, defaultHostId); cancelEdit(); if (resolved.redeemUrl) { @@ -663,7 +663,7 @@ export function DesktopHostSwitcherDialog({ const origin = host.id === LOCAL_HOST_ID ? localOrigin : getDesktopHostApiUrl(host); if (!origin) return; const target = toNavigationUrl(origin); - desktopOpenNewWindowAtUrl(target, { clientToken: host.clientToken || null }).catch((err: unknown) => { + desktopOpenNewWindowAtUrl(target, { clientToken: host.clientToken || null, requestHeaders: host.requestHeaders || null }).catch((err: unknown) => { toast.error(t('desktopHostSwitcher.error.failedToOpenNewWindow'), { description: err instanceof Error ? err.message : String(err), }); diff --git a/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx b/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx index 638bfac3..5bbeea9a 100644 --- a/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx +++ b/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx @@ -199,6 +199,36 @@ const formatLogLine = (line: string): string => { return `[${iso}] [${level}] ${message}`; }; +type HeaderDraft = { + id: string; + name: string; + value: string; +}; + +const createHeaderDraft = (name = '', value = ''): HeaderDraft => ({ + id: typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : `header-${Date.now()}-${Math.random().toString(16).slice(2)}`, + name, + value, +}); + +const isReservedRequestHeaderName = (name: string): boolean => name.trim().toLowerCase() === 'authorization'; + +const buildRequestHeaders = (headers: HeaderDraft[]): Record | undefined => { + const next: Record = {}; + for (const header of headers) { + const name = header.name.trim(); + const value = header.value.trim(); + if (name && value && !isReservedRequestHeaderName(name)) next[name] = value; + } + return Object.keys(next).length > 0 ? next : undefined; +}; + +const readRequestHeaderDrafts = (headers: Record | undefined): HeaderDraft[] => { + return Object.entries(headers || {}).map(([name, value]) => createHeaderDraft(name, value)); +}; + const navigateToUrl = (rawUrl: string): void => { const target = rawUrl.trim(); if (!target) { @@ -301,6 +331,7 @@ export const RemoteInstancesPage: React.FC = () => { const [directLabel, setDirectLabel] = React.useState(''); const [directUrl, setDirectUrl] = React.useState(''); const [directToken, setDirectToken] = React.useState(''); + const [directHeaders, setDirectHeaders] = React.useState([]); const [directConnectLink, setDirectConnectLink] = React.useState(''); const [directError, setDirectError] = React.useState(null); const [directAddDialogOpen, setDirectAddDialogOpen] = React.useState(false); @@ -309,6 +340,7 @@ export const RemoteInstancesPage: React.FC = () => { const [directEditLabel, setDirectEditLabel] = React.useState(''); const [directEditUrl, setDirectEditUrl] = React.useState(''); const [directEditToken, setDirectEditToken] = React.useState(''); + const [directEditHeaders, setDirectEditHeaders] = React.useState([]); const [remoteClients, setRemoteClients] = React.useState([]); const [remoteClientsLoading, setRemoteClientsLoading] = React.useState(false); const [remoteClientLabel, setRemoteClientLabel] = React.useState(''); @@ -374,16 +406,18 @@ export const RemoteInstancesPage: React.FC = () => { url, apiUrl: url, ...(directToken.trim() ? { clientToken: directToken.trim() } : {}), + ...(buildRequestHeaders(directHeaders) ? { requestHeaders: buildRequestHeaders(directHeaders) } : {}), }; await persistDirectHosts([host, ...directHosts], directDefaultHostId); setDirectLabel(''); setDirectUrl(''); setDirectToken(''); + setDirectHeaders([]); setDirectAddDialogOpen(false); if (resolved.redeemUrl) { navigateToUrl(resolved.redeemUrl); } - }, [directDefaultHostId, directHosts, directLabel, directToken, directUrl, persistDirectHosts, t]); + }, [directDefaultHostId, directHeaders, directHosts, directLabel, directToken, directUrl, persistDirectHosts, t]); const importDirectConnectLink = React.useCallback(async () => { const payload = parseClientConnectionPayload(directConnectLink); @@ -427,6 +461,7 @@ export const RemoteInstancesPage: React.FC = () => { setDirectEditLabel(host.label); setDirectEditUrl(host.apiUrl || host.url); setDirectEditToken(host.clientToken || ''); + setDirectEditHeaders(readRequestHeaderDrafts(host.requestHeaders)); setDirectError(null); }, []); @@ -445,6 +480,7 @@ export const RemoteInstancesPage: React.FC = () => { url, apiUrl: url, clientToken: directEditToken.trim() || undefined, + requestHeaders: buildRequestHeaders(directEditHeaders), } : host); await persistDirectHosts(nextHosts, directDefaultHostId); @@ -452,7 +488,7 @@ export const RemoteInstancesPage: React.FC = () => { if (resolved.redeemUrl) { navigateToUrl(resolved.redeemUrl); } - }, [directDefaultHostId, directEditLabel, directEditToken, directEditUrl, directEditingId, directHosts, persistDirectHosts, t]); + }, [directDefaultHostId, directEditHeaders, directEditLabel, directEditToken, directEditUrl, directEditingId, directHosts, persistDirectHosts, t]); const createSshInstanceFromDialog = React.useCallback(async () => { const command = sshCommandDraft.trim(); @@ -1095,6 +1131,25 @@ export const RemoteInstancesPage: React.FC = () => { setDirectLabel(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.labelPlaceholder')} disabled={directSaving} /> setDirectUrl(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.urlPlaceholder')} disabled={directSaving} autoFocus /> setDirectToken(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.tokenPlaceholder')} type="password" disabled={directSaving} /> +
+
+

{t('settings.remoteInstances.direct.headers.title')}

+

{t('settings.remoteInstances.direct.headers.description')}

+
+ {directHeaders.map((header) => ( +
+ setDirectHeaders((headers) => headers.map((item) => item.id === header.id ? { ...item, name: event.target.value } : item))} placeholder={t('settings.remoteInstances.direct.headers.field.namePlaceholder')} disabled={directSaving} /> + setDirectHeaders((headers) => headers.map((item) => item.id === header.id ? { ...item, value: event.target.value } : item))} placeholder={t('settings.remoteInstances.direct.headers.field.valuePlaceholder')} type="password" disabled={directSaving} /> + +
+ ))} + +
@@ -1113,6 +1168,25 @@ export const RemoteInstancesPage: React.FC = () => { setDirectEditLabel(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.labelPlaceholder')} disabled={directSaving} /> setDirectEditUrl(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.urlPlaceholder')} disabled={directSaving} autoFocus /> setDirectEditToken(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.tokenPlaceholder')} type="password" disabled={directSaving} /> +
+
+

{t('settings.remoteInstances.direct.headers.title')}

+

{t('settings.remoteInstances.direct.headers.description')}

+
+ {directEditHeaders.map((header) => ( +
+ setDirectEditHeaders((headers) => headers.map((item) => item.id === header.id ? { ...item, name: event.target.value } : item))} placeholder={t('settings.remoteInstances.direct.headers.field.namePlaceholder')} disabled={directSaving} /> + setDirectEditHeaders((headers) => headers.map((item) => item.id === header.id ? { ...item, value: event.target.value } : item))} placeholder={t('settings.remoteInstances.direct.headers.field.valuePlaceholder')} type="password" disabled={directSaving} /> + +
+ ))} + +
diff --git a/packages/ui/src/lib/desktopHosts.test.ts b/packages/ui/src/lib/desktopHosts.test.ts index d5f4cc75..1a24853a 100644 --- a/packages/ui/src/lib/desktopHosts.test.ts +++ b/packages/ui/src/lib/desktopHosts.test.ts @@ -1,5 +1,26 @@ import { describe, expect, test } from 'bun:test'; -import { redactSensitiveUrl, resolveDesktopHostUrl } from './desktopHosts'; +import { desktopHostProbe, desktopHostsGet, desktopHostsSet, redactSensitiveUrl, resolveDesktopHostUrl } from './desktopHosts'; + +const withDesktopBridge = async (handler: (cmd: string, args: Record) => unknown | Promise, run: () => Promise): Promise => { + const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + __OPENCHAMBER_DESKTOP__: { + invoke: handler, + }, + }, + }); + try { + return await run(); + } finally { + if (previousWindow) { + Object.defineProperty(globalThis, 'window', previousWindow); + } else { + Reflect.deleteProperty(globalThis, 'window'); + } + } +}; describe('resolveDesktopHostUrl', () => { test('keeps regular host URLs unchanged', () => { @@ -32,3 +53,65 @@ describe('resolveDesktopHostUrl', () => { ); }); }); + +describe('desktop host runtime headers', () => { + test('parses persisted request headers from desktop config', async () => { + await withDesktopBridge(async (cmd) => { + expect(cmd).toBe('desktop_hosts_get'); + return { + hosts: [{ + id: 'remote-1', + label: 'Remote', + url: 'https://remote.example', + requestHeaders: { + ' CF-Access-Client-Id ': ' client-id ', + Authorization: 'Bearer should-not-be-read', + 'Bad:Name': 'bad', + }, + }], + defaultHostId: 'remote-1', + initialHostChoiceCompleted: true, + }; + }, async () => { + const config = await desktopHostsGet(); + expect(config.hosts[0]?.requestHeaders).toEqual({ + 'CF-Access-Client-Id': 'client-id', + }); + }); + }); + + test('passes request headers through host save and probe IPC calls', async () => { + const calls: Array<{ cmd: string; args: Record }> = []; + await withDesktopBridge(async (cmd, args) => { + calls.push({ cmd, args }); + if (cmd === 'desktop_host_probe') return { status: 'ok', latencyMs: 7 }; + return null; + }, async () => { + const requestHeaders = { 'CF-Access-Client-Id': 'client-id' }; + await desktopHostsSet({ + hosts: [{ id: 'remote-1', label: 'Remote', url: 'https://remote.example', requestHeaders }], + defaultHostId: 'remote-1', + }); + const probe = await desktopHostProbe('https://remote.example', { requestHeaders }); + expect(probe).toEqual({ status: 'ok', latencyMs: 7 }); + }); + + expect(calls[0]).toEqual({ + cmd: 'desktop_hosts_set', + args: { + input: { + hosts: [{ id: 'remote-1', label: 'Remote', url: 'https://remote.example', requestHeaders: { 'CF-Access-Client-Id': 'client-id' } }], + defaultHostId: 'remote-1', + initialHostChoiceCompleted: undefined, + }, + }, + }); + expect(calls[1]).toEqual({ + cmd: 'desktop_host_probe', + args: { + url: 'https://remote.example', + requestHeaders: { 'CF-Access-Client-Id': 'client-id' }, + }, + }); + }); +}); diff --git a/packages/ui/src/lib/desktopHosts.ts b/packages/ui/src/lib/desktopHosts.ts index 5dc39e96..a566bddb 100644 --- a/packages/ui/src/lib/desktopHosts.ts +++ b/packages/ui/src/lib/desktopHosts.ts @@ -2,6 +2,25 @@ import { hasDesktopInvoke, invokeDesktop } from '@/lib/desktop'; type DesktopInvoke = (cmd: string, args?: Record) => Promise; +const isRecord = (value: unknown): value is Record => { + return typeof value === 'object' && value !== null; +}; + +const isReservedRequestHeaderName = (name: string): boolean => name.trim().toLowerCase() === 'authorization'; + +const sanitizeRequestHeaders = (headers: unknown): Record | undefined => { + if (!isRecord(headers)) return undefined; + const next: Record = {}; + for (const [key, value] of Object.entries(headers)) { + const name = key.trim(); + const headerValue = typeof value === 'string' ? value.trim() : ''; + if (!name || !headerValue || /[\r\n:]/.test(name) || /[\r\n]/.test(headerValue)) continue; + if (isReservedRequestHeaderName(name)) continue; + next[name] = headerValue; + } + return Object.keys(next).length > 0 ? next : undefined; +}; + export type DesktopHost = { id: string; label: string; @@ -11,6 +30,8 @@ export type DesktopHost = { apiUrl?: string; /** Remote client bearer token for packaged-client API access. */ clientToken?: string; + /** Extra headers for desktop runtime API requests. */ + requestHeaders?: Record; }; export type DesktopHostsConfig = { @@ -135,10 +156,6 @@ export const locationMatchesHost = (locationHref: string, hostUrl: string): bool } }; -const isRecord = (value: unknown): value is Record => { - return typeof value === 'object' && value !== null; -}; - const readString = (obj: Record, key: string): string | null => { const val = obj[key]; return typeof val === 'string' ? val : null; @@ -156,6 +173,7 @@ const parseHost = (value: unknown): DesktopHost | null => { const url = readString(value, 'url'); const apiUrl = readString(value, 'apiUrl') || readString(value, 'api_url'); const clientToken = readString(value, 'clientToken') || readString(value, 'client_token'); + const requestHeaders = sanitizeRequestHeaders(value.requestHeaders); if (!id || !label || !url) return null; return { id, @@ -163,6 +181,7 @@ const parseHost = (value: unknown): DesktopHost | null => { url, ...(apiUrl ? { apiUrl } : {}), ...(clientToken ? { clientToken } : {}), + ...(requestHeaders ? { requestHeaders } : {}), }; }; @@ -226,13 +245,13 @@ export const desktopLocalClientTokenGet = async (): Promise => { return typeof raw === 'string' ? raw.trim() : ''; }; -export const desktopHostProbe = async (url: string, options?: { clientToken?: string | null }): Promise => { +export const desktopHostProbe = async (url: string, options?: { clientToken?: string | null; requestHeaders?: Record | null }): Promise => { const invoke = getInvoke(); if (!invoke) { return { status: 'unreachable', latencyMs: 0 }; } - const raw = await invoke('desktop_host_probe', { url, clientToken: options?.clientToken || undefined }); + const raw = await invoke('desktop_host_probe', { url, clientToken: options?.clientToken || undefined, requestHeaders: options?.requestHeaders || undefined }); if (!isRecord(raw)) { return { status: 'unreachable', latencyMs: 0 }; } @@ -247,8 +266,8 @@ export const desktopHostProbe = async (url: string, options?: { clientToken?: st return { status, latencyMs }; }; -export const desktopOpenNewWindowAtUrl = async (url: string, options?: { clientToken?: string | null }): Promise => { +export const desktopOpenNewWindowAtUrl = async (url: string, options?: { clientToken?: string | null; requestHeaders?: Record | null }): Promise => { const invoke = getInvoke(); if (!invoke) return; - await invoke('desktop_new_window_at_url', { url, clientToken: options?.clientToken || undefined }); + await invoke('desktop_new_window_at_url', { url, clientToken: options?.clientToken || undefined, requestHeaders: options?.requestHeaders || undefined }); }; diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 2233aa65..0ae24ab1 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -258,6 +258,12 @@ export const settingsDict = { 'settings.remoteInstances.direct.field.urlPlaceholder': 'https://host:port', 'settings.remoteInstances.direct.field.tokenPlaceholder': 'Connection token (optional for trusted local servers)', 'settings.remoteInstances.direct.note': 'Connection tokens are saved on this device and used only when this app connects to that server.', + 'settings.remoteInstances.direct.headers.title': 'Additional headers', + 'settings.remoteInstances.direct.headers.description': 'Optional HTTP headers for desktop API requests. Authorization is reserved for the connection token.', + 'settings.remoteInstances.direct.headers.field.namePlaceholder': 'Header name', + 'settings.remoteInstances.direct.headers.field.valuePlaceholder': 'Header value', + 'settings.remoteInstances.direct.headers.actions.add': 'Add header', + 'settings.remoteInstances.direct.headers.removeAria': 'Remove header', 'settings.remoteInstances.direct.actions.add': 'Add Server', 'settings.remoteInstances.direct.import.description': 'Paste a connection link from another OpenChamber server.', 'settings.remoteInstances.direct.import.placeholder': 'openchamber://connect?...', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index 9d5bd6e4..7318c404 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -225,6 +225,12 @@ export const settingsDict = { "settings.remoteInstances.direct.field.urlPlaceholder": "https://host:port", "settings.remoteInstances.direct.field.tokenPlaceholder": "Token de conexión (opcional para servidores locales de confianza)", "settings.remoteInstances.direct.note": "Los tokens de conexión se guardan en este dispositivo y solo se usan cuando esta app se conecta a ese servidor.", + "settings.remoteInstances.direct.headers.title": "Additional headers", + "settings.remoteInstances.direct.headers.description": "Optional HTTP headers for desktop API requests. Authorization is reserved for the connection token.", + "settings.remoteInstances.direct.headers.field.namePlaceholder": "Header name", + "settings.remoteInstances.direct.headers.field.valuePlaceholder": "Header value", + "settings.remoteInstances.direct.headers.actions.add": "Add header", + "settings.remoteInstances.direct.headers.removeAria": "Remove header", "settings.remoteInstances.direct.actions.add": "Añadir servidor", "settings.remoteInstances.direct.import.description": "Pega un enlace de conexión de otro servidor de OpenChamber.", "settings.remoteInstances.direct.import.placeholder": "openchamber://connect?...", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index 010e89dc..d41c9aaf 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -1729,6 +1729,12 @@ export const settingsDict = { 'settings.remoteInstances.direct.field.urlPlaceholder': 'https://hôte:port', 'settings.remoteInstances.direct.field.tokenPlaceholder': 'Token de connexion (facultatif pour les serveurs locaux de confiance)', 'settings.remoteInstances.direct.note': 'Les tokens de connexion sont enregistrés sur cet appareil et utilisés uniquement lorsque cette application se connecte à ce serveur.', + 'settings.remoteInstances.direct.headers.title': 'Additional headers', + 'settings.remoteInstances.direct.headers.description': 'Optional HTTP headers for desktop API requests. Authorization is reserved for the connection token.', + 'settings.remoteInstances.direct.headers.field.namePlaceholder': 'Header name', + 'settings.remoteInstances.direct.headers.field.valuePlaceholder': 'Header value', + 'settings.remoteInstances.direct.headers.actions.add': 'Add header', + 'settings.remoteInstances.direct.headers.removeAria': 'Remove header', 'settings.remoteInstances.direct.actions.add': 'Ajouter le serveur', 'settings.remoteInstances.direct.import.description': 'Collez un lien de connexion provenant d’un autre serveur OpenChamber.', 'settings.remoteInstances.direct.import.placeholder': 'openchamber://connect?...', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index f9b73858..6c329cb9 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -258,6 +258,12 @@ export const settingsDict = { 'settings.remoteInstances.direct.field.urlPlaceholder': 'https://host:port', 'settings.remoteInstances.direct.field.tokenPlaceholder': '接続 Token(信頼できるローカルサーバーでは任意)', 'settings.remoteInstances.direct.note': '接続 Token はこのデバイスに保存され、このアプリがそのサーバーに接続するときにのみ使用されます。', + 'settings.remoteInstances.direct.headers.title': 'Additional headers', + 'settings.remoteInstances.direct.headers.description': 'Optional HTTP headers for desktop API requests. Authorization is reserved for the connection token.', + 'settings.remoteInstances.direct.headers.field.namePlaceholder': 'Header name', + 'settings.remoteInstances.direct.headers.field.valuePlaceholder': 'Header value', + 'settings.remoteInstances.direct.headers.actions.add': 'Add header', + 'settings.remoteInstances.direct.headers.removeAria': 'Remove header', 'settings.remoteInstances.direct.actions.add': 'サーバーを追加', 'settings.remoteInstances.direct.import.description': '別の OpenChamber サーバーからの接続リンクを貼り付けてください。', 'settings.remoteInstances.direct.import.placeholder': 'openchamber://connect?...', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index 152f0205..270ca640 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -225,6 +225,12 @@ export const settingsDict = { 'settings.remoteInstances.direct.field.urlPlaceholder': 'https://host:port', 'settings.remoteInstances.direct.field.tokenPlaceholder': '연결 토큰(신뢰할 수 있는 로컬 서버는 선택 사항)', 'settings.remoteInstances.direct.note': '연결 토큰은 이 기기에 저장되며 이 앱이 해당 서버에 연결할 때만 사용됩니다.', + 'settings.remoteInstances.direct.headers.title': 'Additional headers', + 'settings.remoteInstances.direct.headers.description': 'Optional HTTP headers for desktop API requests. Authorization is reserved for the connection token.', + 'settings.remoteInstances.direct.headers.field.namePlaceholder': 'Header name', + 'settings.remoteInstances.direct.headers.field.valuePlaceholder': 'Header value', + 'settings.remoteInstances.direct.headers.actions.add': 'Add header', + 'settings.remoteInstances.direct.headers.removeAria': 'Remove header', 'settings.remoteInstances.direct.actions.add': '서버 추가', 'settings.remoteInstances.direct.import.description': '다른 OpenChamber 서버에서 만든 연결 링크를 붙여넣으세요.', 'settings.remoteInstances.direct.import.placeholder': 'openchamber://connect?...', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index 3daa0224..5734196a 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -1434,6 +1434,12 @@ export const settingsDict = { 'settings.remoteInstances.direct.field.urlPlaceholder': 'https://host:port', 'settings.remoteInstances.direct.field.tokenPlaceholder': 'Token połączenia (opcjonalny dla zaufanych serwerów lokalnych)', 'settings.remoteInstances.direct.note': 'Tokeny połączenia są zapisywane na tym urządzeniu i używane tylko wtedy, gdy ta aplikacja łączy się z danym serwerem.', + 'settings.remoteInstances.direct.headers.title': 'Additional headers', + 'settings.remoteInstances.direct.headers.description': 'Optional HTTP headers for desktop API requests. Authorization is reserved for the connection token.', + 'settings.remoteInstances.direct.headers.field.namePlaceholder': 'Header name', + 'settings.remoteInstances.direct.headers.field.valuePlaceholder': 'Header value', + 'settings.remoteInstances.direct.headers.actions.add': 'Add header', + 'settings.remoteInstances.direct.headers.removeAria': 'Remove header', 'settings.remoteInstances.direct.actions.add': 'Dodaj serwer', 'settings.remoteInstances.direct.import.description': 'Wklej link połączenia z innego serwera OpenChamber.', 'settings.remoteInstances.direct.import.placeholder': 'openchamber://connect?...', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index f9eb5f03..13ff5d0e 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -225,6 +225,12 @@ export const settingsDict = { "settings.remoteInstances.direct.field.urlPlaceholder": "https://host:port", "settings.remoteInstances.direct.field.tokenPlaceholder": "Token de conexão (opcional para servidores locais confiáveis)", "settings.remoteInstances.direct.note": "Os tokens de conexão ficam salvos neste dispositivo e são usados apenas quando este app se conecta a esse servidor.", + "settings.remoteInstances.direct.headers.title": "Additional headers", + "settings.remoteInstances.direct.headers.description": "Optional HTTP headers for desktop API requests. Authorization is reserved for the connection token.", + "settings.remoteInstances.direct.headers.field.namePlaceholder": "Header name", + "settings.remoteInstances.direct.headers.field.valuePlaceholder": "Header value", + "settings.remoteInstances.direct.headers.actions.add": "Add header", + "settings.remoteInstances.direct.headers.removeAria": "Remove header", "settings.remoteInstances.direct.actions.add": "Adicionar servidor", "settings.remoteInstances.direct.import.description": "Cole um link de conexão de outro servidor OpenChamber.", "settings.remoteInstances.direct.import.placeholder": "openchamber://connect?...", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index 8b351bfc..6464e347 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -225,6 +225,12 @@ export const settingsDict = { "settings.remoteInstances.direct.field.urlPlaceholder": "https://host:port", "settings.remoteInstances.direct.field.tokenPlaceholder": "Токен підключення (необов’язково для довірених локальних серверів)", "settings.remoteInstances.direct.note": "Токени підключення зберігаються на цьому пристрої й використовуються лише коли цей застосунок підключається до відповідного сервера.", + "settings.remoteInstances.direct.headers.title": "Additional headers", + "settings.remoteInstances.direct.headers.description": "Optional HTTP headers for desktop API requests. Authorization is reserved for the connection token.", + "settings.remoteInstances.direct.headers.field.namePlaceholder": "Header name", + "settings.remoteInstances.direct.headers.field.valuePlaceholder": "Header value", + "settings.remoteInstances.direct.headers.actions.add": "Add header", + "settings.remoteInstances.direct.headers.removeAria": "Remove header", "settings.remoteInstances.direct.actions.add": "Додати сервер", "settings.remoteInstances.direct.import.description": "Вставте посилання для підключення з іншого сервера OpenChamber.", "settings.remoteInstances.direct.import.placeholder": "openchamber://connect?...", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index e6a959d5..ccd1b42a 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -225,6 +225,12 @@ export const settingsDict = { 'settings.remoteInstances.direct.field.urlPlaceholder': 'https://host:port', 'settings.remoteInstances.direct.field.tokenPlaceholder': '连接令牌(受信任的本地服务器可选)', 'settings.remoteInstances.direct.note': '连接令牌会保存在此设备上,并且只在此应用连接到该服务器时使用。', + 'settings.remoteInstances.direct.headers.title': 'Additional headers', + 'settings.remoteInstances.direct.headers.description': 'Optional HTTP headers for desktop API requests. Authorization is reserved for the connection token.', + 'settings.remoteInstances.direct.headers.field.namePlaceholder': 'Header name', + 'settings.remoteInstances.direct.headers.field.valuePlaceholder': 'Header value', + 'settings.remoteInstances.direct.headers.actions.add': 'Add header', + 'settings.remoteInstances.direct.headers.removeAria': 'Remove header', 'settings.remoteInstances.direct.actions.add': '添加服务器', 'settings.remoteInstances.direct.import.description': '粘贴来自另一个 OpenChamber 服务器的连接链接。', 'settings.remoteInstances.direct.import.placeholder': 'openchamber://connect?...', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index 06989c74..835419c3 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -231,6 +231,12 @@ 'settings.remoteInstances.direct.field.urlPlaceholder': 'https://openchamber.example.com', 'settings.remoteInstances.direct.field.tokenPlaceholder': '用戶端 token(可選)', 'settings.remoteInstances.direct.note': '直接連線假設遠端伺服器已在執行並可從此裝置存取。', + 'settings.remoteInstances.direct.headers.title': 'Additional headers', + 'settings.remoteInstances.direct.headers.description': 'Optional HTTP headers for desktop API requests. Authorization is reserved for the connection token.', + 'settings.remoteInstances.direct.headers.field.namePlaceholder': 'Header name', + 'settings.remoteInstances.direct.headers.field.valuePlaceholder': 'Header value', + 'settings.remoteInstances.direct.headers.actions.add': 'Add header', + 'settings.remoteInstances.direct.headers.removeAria': 'Remove header', 'settings.remoteInstances.direct.actions.add': '新增直接連線', 'settings.remoteInstances.direct.import.description': '貼上 connect-url 輸出或 openchamber://connect 連結來匯入。', 'settings.remoteInstances.direct.import.placeholder': '貼上連線連結', diff --git a/packages/ui/src/lib/runtime-auth.test.ts b/packages/ui/src/lib/runtime-auth.test.ts index 270acc21..f5cbb58d 100644 --- a/packages/ui/src/lib/runtime-auth.test.ts +++ b/packages/ui/src/lib/runtime-auth.test.ts @@ -2,9 +2,12 @@ import { describe, expect, test } from 'bun:test'; import { buildRuntimeAuthHeaders, clearRuntimeAuthCredentialProvider, + clearRuntimeUrlAuthToken, getRuntimeBearerTokenSync, + refreshRuntimeUrlAuthToken, setRuntimeAuthCredentialProvider, setRuntimeBearerToken, + setRuntimeExtraHeaders, } from './runtime-auth'; describe('runtime auth headers', () => { @@ -60,4 +63,56 @@ describe('runtime auth headers', () => { } } }); + + test('adds runtime extra headers without overriding bearer authorization', async () => { + try { + setRuntimeBearerToken('runtime-token'); + setRuntimeExtraHeaders({ + 'CF-Access-Client-Id': 'client-id', + Authorization: 'Bearer proxy-token', + }); + + const headers = await buildRuntimeAuthHeaders(); + + expect(headers.get('CF-Access-Client-Id')).toBe('client-id'); + expect(headers.get('Authorization')).toBe('Bearer runtime-token'); + } finally { + setRuntimeExtraHeaders(null); + clearRuntimeAuthCredentialProvider(); + } + }); + + test('sends runtime extra headers when minting URL auth tokens', async () => { + const previousFetch = globalThis.fetch; + let seenUrl = ''; + let seenHeaders = new Headers(); + try { + clearRuntimeUrlAuthToken(); + setRuntimeBearerToken('runtime-token'); + setRuntimeExtraHeaders({ + 'CF-Access-Client-Id': 'client-id', + Authorization: 'Bearer proxy-token', + }); + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + seenUrl = String(input); + seenHeaders = new Headers(init?.headers); + return new Response(JSON.stringify({ token: 'url-token', expiresAt: Date.now() + 60_000 }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }) as typeof fetch; + + const token = await refreshRuntimeUrlAuthToken('https://runtime.example'); + + expect(token).toBe('url-token'); + expect(seenUrl).toBe('https://runtime.example/auth/url-token'); + expect(seenHeaders.get('CF-Access-Client-Id')).toBe('client-id'); + expect(seenHeaders.get('Authorization')).toBe('Bearer runtime-token'); + } finally { + globalThis.fetch = previousFetch; + clearRuntimeUrlAuthToken(); + setRuntimeExtraHeaders(null); + clearRuntimeAuthCredentialProvider(); + } + }); }); diff --git a/packages/ui/src/lib/runtime-auth.ts b/packages/ui/src/lib/runtime-auth.ts index 811a8232..b02fd8f5 100644 --- a/packages/ui/src/lib/runtime-auth.ts +++ b/packages/ui/src/lib/runtime-auth.ts @@ -6,6 +6,7 @@ export type RuntimeAuthCredentialProvider = () => RuntimeAuthCredential | Promis let credentialProvider: RuntimeAuthCredentialProvider = () => null; let runtimeBearerToken = ''; +let runtimeExtraHeaders: Record = {}; let runtimeUrlAuthToken = ''; let runtimeUrlAuthTokenExpiresAt = 0; let runtimeUrlAuthRefreshPromise: Promise | null = null; @@ -13,6 +14,18 @@ let runtimeAuthGeneration = 0; const URL_AUTH_REFRESH_SKEW_MS = 10_000; +const isReservedRuntimeExtraHeaderName = (name: string): boolean => name.toLowerCase() === 'authorization'; + +const sanitizeRuntimeExtraHeaders = (headers: Record | null | undefined): Record => { + const next: Record = {}; + for (const [key, value] of Object.entries(headers || {})) { + const name = key.trim(); + const headerValue = value.trim(); + if (name && headerValue && !isReservedRuntimeExtraHeaderName(name)) next[name] = headerValue; + } + return next; +}; + const normalizeBearerToken = (token: string | null | undefined): string => { if (typeof token !== 'string') return ''; return token.trim(); @@ -74,6 +87,20 @@ export const setRuntimeBearerToken = (token: string | null | undefined): void => credentialProvider = () => normalized ? { type: 'bearer', token: normalized } : null; }; +export const setRuntimeExtraHeaders = (headers: Record | null | undefined): void => { + // These headers are for runtime HTTP fetches and URL-token minting. Browser-owned + // realtime transports (EventSource/WebSocket) cannot attach arbitrary headers. + runtimeExtraHeaders = sanitizeRuntimeExtraHeaders(headers); + resetRuntimeAuthGeneration(); +}; + +export const getRuntimeExtraHeadersSync = (): Record => { + if (Object.keys(runtimeExtraHeaders).length > 0) return runtimeExtraHeaders; + if (typeof window === 'undefined') return {}; + const injected = (window as typeof window & { __OPENCHAMBER_RUNTIME_HEADERS__?: Record }).__OPENCHAMBER_RUNTIME_HEADERS__; + return injected && typeof injected === 'object' ? sanitizeRuntimeExtraHeaders(injected) : {}; +}; + export const getRuntimeBearerTokenSync = (): string => runtimeBearerToken || readInjectedBearerToken(); export const setRuntimeUrlAuthToken = (token: string | null | undefined, expiresAt: number | null | undefined): void => { @@ -127,6 +154,9 @@ const mintRuntimeUrlAuthToken = (apiBaseUrl?: string | null): Promise => const refreshPromise = (async () => { const credential = await getRuntimeAuthCredential(); const headers = new Headers(); + for (const [key, value] of Object.entries(getRuntimeExtraHeadersSync())) { + headers.set(key, value); + } if (credential?.type === 'bearer') { headers.set('Authorization', `Bearer ${credential.token}`); } @@ -257,6 +287,9 @@ export const subscribeRuntimeUrlAuthToken = (listener: () => void): (() => void) export const buildRuntimeAuthHeaders = async (headers?: HeadersInit): Promise => { const next = new Headers(headers); + for (const [key, value] of Object.entries(getRuntimeExtraHeadersSync())) { + if (!next.has(key)) next.set(key, value); + } if (next.has('Authorization')) { return next; } diff --git a/packages/ui/src/lib/runtime-switch.ts b/packages/ui/src/lib/runtime-switch.ts index 6d8084d6..6cb02dfd 100644 --- a/packages/ui/src/lib/runtime-switch.ts +++ b/packages/ui/src/lib/runtime-switch.ts @@ -1,4 +1,4 @@ -import { refreshRuntimeUrlAuthToken, setRuntimeBearerToken } from '@/lib/runtime-auth'; +import { refreshRuntimeUrlAuthToken, setRuntimeBearerToken, setRuntimeExtraHeaders } from '@/lib/runtime-auth'; import { configureRuntimeUrlResolver } from '@/lib/runtime-url'; export type RuntimeEndpointChangedDetail = { @@ -68,7 +68,7 @@ export const initializeRuntimeEndpoint = (options: { apiBaseUrl?: string | null; activeRuntimeKey = options.runtimeKey?.trim() || (sameOrigin(apiBaseUrl, readInjectedLocalOrigin()) ? 'local' : normalizeRuntimeUrlKey(apiBaseUrl)); }; -export const switchRuntimeEndpoint = (options: { apiBaseUrl: string; clientToken?: string | null; runtimeKey?: string | null }): void => { +export const switchRuntimeEndpoint = (options: { apiBaseUrl: string; clientToken?: string | null; runtimeKey?: string | null; requestHeaders?: Record | null }): void => { const apiBaseUrl = options.apiBaseUrl.trim(); const previousApiBaseUrl = getRuntimeApiBaseUrl(); const previousRuntimeKey = getRuntimeKey(); @@ -79,11 +79,14 @@ export const switchRuntimeEndpoint = (options: { apiBaseUrl: string; clientToken const runtimeWindow = window as typeof window & { __OPENCHAMBER_API_BASE_URL__?: string; __OPENCHAMBER_CLIENT_TOKEN__?: string; + __OPENCHAMBER_RUNTIME_HEADERS__?: Record; }; runtimeWindow.__OPENCHAMBER_API_BASE_URL__ = apiBaseUrl; runtimeWindow.__OPENCHAMBER_CLIENT_TOKEN__ = options.clientToken || undefined; + runtimeWindow.__OPENCHAMBER_RUNTIME_HEADERS__ = options.requestHeaders || undefined; } configureRuntimeUrlResolver({ apiBaseUrl, realtimeBaseUrl: apiBaseUrl }); + setRuntimeExtraHeaders(options.requestHeaders || null); setRuntimeBearerToken(options.clientToken || null); void refreshRuntimeUrlAuthToken(apiBaseUrl).catch(() => {}); if (typeof window !== 'undefined') { diff --git a/packages/ui/src/lib/settings/search.ts b/packages/ui/src/lib/settings/search.ts index 89032db3..7c97b377 100644 --- a/packages/ui/src/lib/settings/search.ts +++ b/packages/ui/src/lib/settings/search.ts @@ -404,7 +404,7 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ page: 'remote-instances', titleKey: 'settings.remoteInstances.direct.title', descriptionKey: 'settings.remoteInstances.direct.description', - keywords: ['server url', 'connection token', 'import link', 'host switcher'], + keywords: ['server url', 'connection token', 'import link', 'host switcher', 'additional headers', 'request headers', 'cloudflare access', 'service token'], isAvailable: (ctx) => ctx.isDesktop, }, { diff --git a/packages/web/src/runtimeConfig.ts b/packages/web/src/runtimeConfig.ts index 3d18044e..ba91ca9b 100644 --- a/packages/web/src/runtimeConfig.ts +++ b/packages/web/src/runtimeConfig.ts @@ -1,4 +1,4 @@ -import { refreshRuntimeUrlAuthToken, setRuntimeBearerToken } from '@openchamber/ui/lib/runtime-auth'; +import { refreshRuntimeUrlAuthToken, setRuntimeBearerToken, setRuntimeExtraHeaders } from '@openchamber/ui/lib/runtime-auth'; import { installRuntimeFetchBridge } from '@openchamber/ui/lib/runtime-fetch'; import { initializeRuntimeEndpoint } from '@openchamber/ui/lib/runtime-switch'; import { configureRuntimeUrlResolver } from '@openchamber/ui/lib/runtime-url'; @@ -17,6 +17,7 @@ declare global { interface Window { __OPENCHAMBER_API_BASE_URL__?: string; __OPENCHAMBER_CLIENT_TOKEN__?: string; + __OPENCHAMBER_RUNTIME_HEADERS__?: Record; __OPENCHAMBER_LOCAL_ORIGIN__?: string; } } @@ -41,6 +42,7 @@ export const createConfiguredWebAPIs = () => { runtimeKey: sameOrigin(apiBaseUrl, localOrigin) ? 'local' : null, }); setRuntimeBearerToken(clientToken || null); + setRuntimeExtraHeaders(window.__OPENCHAMBER_RUNTIME_HEADERS__ || null); void refreshRuntimeUrlAuthToken(apiBaseUrl || undefined).catch(() => {}); installRuntimeFetchBridge(); return createWebAPIs({ urls }); From c10930dfd0ca6286416531b31b897d4b5bb2e819 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 30 Jun 2026 01:21:42 +0300 Subject: [PATCH 0057/1073] feat(desktop): proxy realtime requests with runtime headers --- packages/electron/main.mjs | 4 + packages/ui/src/lib/runtime-auth.ts | 70 +++++ packages/ui/src/lib/runtime-url.test.ts | 50 +++- packages/ui/src/lib/runtime-url.ts | 39 ++- packages/web/server/index.js | 23 +- packages/web/server/lib/realtime-proxy.js | 279 ++++++++++++++++++ .../web/server/lib/realtime-proxy.test.js | 259 ++++++++++++++++ packages/web/server/lib/ui-auth/ui-auth.js | 2 + packages/web/src/runtimeConfig.ts | 5 +- 9 files changed, 724 insertions(+), 7 deletions(-) create mode 100644 packages/web/server/lib/realtime-proxy.js create mode 100644 packages/web/server/lib/realtime-proxy.test.js diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index 2a07debd..1272ec3a 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -1204,6 +1204,10 @@ const spawnLocalServer = async () => { apiOnly: false, onDesktopNotification: (payload) => maybeShowNativeNotification(payload), getIsWindowFocused: isAnyWindowFocused, + getDesktopRuntimeConfig: () => ({ + apiBaseUrl: state.apiBaseUrl || '', + requestHeaders: sanitizeRuntimeRequestHeaders(state.requestHeaders || {}), + }), }); const port = handle.getPort(); diff --git a/packages/ui/src/lib/runtime-auth.ts b/packages/ui/src/lib/runtime-auth.ts index b02fd8f5..efd1cd93 100644 --- a/packages/ui/src/lib/runtime-auth.ts +++ b/packages/ui/src/lib/runtime-auth.ts @@ -10,6 +10,9 @@ let runtimeExtraHeaders: Record = {}; let runtimeUrlAuthToken = ''; let runtimeUrlAuthTokenExpiresAt = 0; let runtimeUrlAuthRefreshPromise: Promise | null = null; +let localRuntimeUrlAuthToken = ''; +let localRuntimeUrlAuthTokenExpiresAt = 0; +let localRuntimeUrlAuthRefreshPromise: Promise | null = null; let runtimeAuthGeneration = 0; const URL_AUTH_REFRESH_SKEW_MS = 10_000; @@ -58,6 +61,8 @@ const buildAuthUrl = (apiBaseUrl: string | null | undefined, path: string): stri export const clearRuntimeUrlAuthToken = (): void => { runtimeUrlAuthToken = ''; runtimeUrlAuthTokenExpiresAt = 0; + localRuntimeUrlAuthToken = ''; + localRuntimeUrlAuthTokenExpiresAt = 0; }; const resetRuntimeAuthGeneration = (): void => { @@ -120,6 +125,17 @@ export const setRuntimeUrlAuthToken = (token: string | null | undefined, expires } }; +export const setLocalRuntimeUrlAuthToken = (token: string | null | undefined, expiresAt: number | null | undefined): void => { + const normalized = normalizeBearerToken(token); + if (!normalized || typeof expiresAt !== 'number' || !Number.isFinite(expiresAt)) { + localRuntimeUrlAuthToken = ''; + localRuntimeUrlAuthTokenExpiresAt = 0; + return; + } + localRuntimeUrlAuthToken = normalized; + localRuntimeUrlAuthTokenExpiresAt = expiresAt; +}; + const readValidRuntimeUrlAuthTokenSync = (): string => { if (!runtimeUrlAuthToken || runtimeUrlAuthTokenExpiresAt <= Date.now() + URL_AUTH_REFRESH_SKEW_MS) { clearRuntimeUrlAuthToken(); @@ -128,6 +144,15 @@ const readValidRuntimeUrlAuthTokenSync = (): string => { return runtimeUrlAuthToken; }; +const readValidLocalRuntimeUrlAuthTokenSync = (): string => { + if (!localRuntimeUrlAuthToken || localRuntimeUrlAuthTokenExpiresAt <= Date.now() + URL_AUTH_REFRESH_SKEW_MS) { + localRuntimeUrlAuthToken = ''; + localRuntimeUrlAuthTokenExpiresAt = 0; + return ''; + } + return localRuntimeUrlAuthToken; +}; + export const getRuntimeUrlAuthTokenSync = (): string => { const token = readValidRuntimeUrlAuthTokenSync(); if (!token && (getRuntimeBearerTokenSync() || typeof window !== 'undefined')) { @@ -136,6 +161,14 @@ export const getRuntimeUrlAuthTokenSync = (): string => { return token; }; +export const getLocalRuntimeUrlAuthTokenSync = (localOrigin?: string | null): string => { + const token = readValidLocalRuntimeUrlAuthTokenSync(); + if (!token && localOrigin && typeof window !== 'undefined') { + void refreshLocalRuntimeUrlAuthToken(localOrigin).catch(() => {}); + } + return token; +}; + const getRuntimeAuthCredential = async (): Promise => { const credential = await credentialProvider(); const token = credential?.type === 'bearer' @@ -193,6 +226,37 @@ const mintRuntimeUrlAuthToken = (apiBaseUrl?: string | null): Promise => return runtimeUrlAuthRefreshPromise; }; +const mintLocalRuntimeUrlAuthToken = (localOrigin: string): Promise => { + if (localRuntimeUrlAuthRefreshPromise) return localRuntimeUrlAuthRefreshPromise; + const refreshPromise = (async () => { + const response = await fetch(buildAuthUrl(localOrigin, '/auth/url-token'), { + method: 'POST', + credentials: 'include', + }); + if (!response.ok) { + localRuntimeUrlAuthToken = ''; + localRuntimeUrlAuthTokenExpiresAt = 0; + throw new Error(`Failed to mint local runtime URL auth token (${response.status})`); + } + const payload = await response.json().catch(() => null) as { token?: unknown; expiresAt?: unknown } | null; + const token = typeof payload?.token === 'string' ? payload.token.trim() : ''; + const expiresAt = typeof payload?.expiresAt === 'number' ? payload.expiresAt : 0; + if (!token || !Number.isFinite(expiresAt)) { + throw new Error('Local runtime URL auth token response was invalid'); + } + localRuntimeUrlAuthToken = token; + localRuntimeUrlAuthTokenExpiresAt = expiresAt; + return token; + })(); + const trackedPromise = refreshPromise.finally(() => { + if (localRuntimeUrlAuthRefreshPromise === trackedPromise) { + localRuntimeUrlAuthRefreshPromise = null; + } + }); + localRuntimeUrlAuthRefreshPromise = trackedPromise; + return localRuntimeUrlAuthRefreshPromise; +}; + // Returns a valid token without a network call, minting only when the current // token is missing or already inside the skew window. export const refreshRuntimeUrlAuthToken = async (apiBaseUrl?: string | null): Promise => { @@ -201,6 +265,12 @@ export const refreshRuntimeUrlAuthToken = async (apiBaseUrl?: string | null): Pr return mintRuntimeUrlAuthToken(apiBaseUrl); }; +export const refreshLocalRuntimeUrlAuthToken = async (localOrigin: string): Promise => { + const existing = readValidLocalRuntimeUrlAuthTokenSync(); + if (existing) return existing; + return mintLocalRuntimeUrlAuthToken(localOrigin); +}; + // ── Proactive URL auth token refresh ────────────────────────────────────── // The url token has a short server TTL. Instead of each consumer minting on its // own timer (and clearing the shared token, which 401s other consumers during diff --git a/packages/ui/src/lib/runtime-url.test.ts b/packages/ui/src/lib/runtime-url.test.ts index 2b30836c..fffceec4 100644 --- a/packages/ui/src/lib/runtime-url.test.ts +++ b/packages/ui/src/lib/runtime-url.test.ts @@ -5,7 +5,7 @@ import { getRuntimeUrlResolver, setRuntimeUrlResolver, } from './runtime-url'; -import { setRuntimeBearerToken, setRuntimeUrlAuthToken } from './runtime-auth'; +import { setLocalRuntimeUrlAuthToken, setRuntimeBearerToken, setRuntimeExtraHeaders, setRuntimeUrlAuthToken } from './runtime-auth'; describe('createRuntimeUrlResolver', () => { const withWindow = (value: unknown, callback: () => T): T => { @@ -76,6 +76,54 @@ describe('createRuntimeUrlResolver', () => { }); }); + test('routes realtime URLs through local desktop proxy when runtime headers are configured', () => { + setRuntimeExtraHeaders({ 'CF-Access-Client-Id': 'client-id' }); + try { + withWindow({ + location: { origin: 'openchamber-ui://app', href: 'openchamber-ui://app/index.html' }, + __OPENCHAMBER_API_BASE_URL__: 'https://remote.example', + __OPENCHAMBER_LOCAL_ORIGIN__: 'http://127.0.0.1:57123', + }, () => { + const urls = createRuntimeUrlResolver({}); + const sse = new URL(urls.sse('/api/global/event')); + const ws = new URL(urls.websocket('/api/global/event/ws')); + + expect(sse.origin).toBe('http://127.0.0.1:57123'); + expect(sse.pathname).toBe('/api/openchamber/realtime-proxy/sse'); + expect(sse.searchParams.get('url')).toBe('https://remote.example/api/global/event'); + expect(ws.origin).toBe('ws://127.0.0.1:57123'); + expect(ws.pathname).toBe('/api/openchamber/realtime-proxy/ws'); + expect(ws.searchParams.get('url')).toBe('wss://remote.example/api/global/event/ws'); + }); + } finally { + setRuntimeExtraHeaders(null); + } + }); + + test('adds local URL auth token to desktop realtime proxy URL', () => { + setRuntimeExtraHeaders({ 'CF-Access-Client-Id': 'client-id' }); + setRuntimeUrlAuthToken('remote-url-token', Date.now() + 60_000); + setLocalRuntimeUrlAuthToken('local-url-token', Date.now() + 60_000); + try { + withWindow({ + location: { origin: 'openchamber-ui://app', href: 'openchamber-ui://app/index.html' }, + __OPENCHAMBER_API_BASE_URL__: 'https://remote.example', + __OPENCHAMBER_LOCAL_ORIGIN__: 'http://127.0.0.1:57123', + }, () => { + const urls = createRuntimeUrlResolver({}); + const sse = new URL(urls.sse('/api/global/event')); + const target = new URL(sse.searchParams.get('url') || ''); + + expect(sse.searchParams.get('oc_url_token')).toBe('local-url-token'); + expect(target.searchParams.get('oc_url_token')).toBe('remote-url-token'); + }); + } finally { + setRuntimeExtraHeaders(null); + setRuntimeUrlAuthToken(null, null); + setLocalRuntimeUrlAuthToken(null, null); + } + }); + test('reads injected desktop API base URL at call time', () => { withWindow({ location: { origin: 'openchamber-ui://app', href: 'openchamber-ui://app/index.html' }, diff --git a/packages/ui/src/lib/runtime-url.ts b/packages/ui/src/lib/runtime-url.ts index ba3797c0..c27930b8 100644 --- a/packages/ui/src/lib/runtime-url.ts +++ b/packages/ui/src/lib/runtime-url.ts @@ -1,4 +1,4 @@ -import { getRuntimeUrlAuthTokenSync } from '@/lib/runtime-auth'; +import { getLocalRuntimeUrlAuthTokenSync, getRuntimeExtraHeadersSync, getRuntimeUrlAuthTokenSync } from '@/lib/runtime-auth'; type QueryValue = string | number | boolean | null | undefined; @@ -39,6 +39,14 @@ const readInjectedApiBaseUrl = (): string => { return normalizeBaseUrl(injected); }; +const readInjectedLocalOrigin = (): string => { + if (typeof window === 'undefined') return ''; + const injected = (window as typeof window & { __OPENCHAMBER_LOCAL_ORIGIN__?: string }).__OPENCHAMBER_LOCAL_ORIGIN__; + return normalizeBaseUrl(injected); +}; + +const hasRuntimeExtraHeaders = (): boolean => Object.keys(getRuntimeExtraHeadersSync()).length > 0; + const currentHref = (config: RuntimeUrlConfig): string => { const configured = config.currentHref?.(); if (configured) return configured; @@ -110,6 +118,25 @@ const toWebSocketUrl = (candidate: string, config: RuntimeUrlConfig): string => return url.toString(); }; +const toRealtimeProxyUrl = (kind: 'sse' | 'ws', targetUrl: string, config: RuntimeUrlConfig): string | null => { + if (!hasRuntimeExtraHeaders()) return null; + const localOrigin = readInjectedLocalOrigin(); + if (!localOrigin) return null; + try { + const proxy = new URL(`/api/openchamber/realtime-proxy/${kind === 'sse' ? 'sse' : 'ws'}`, `${localOrigin}/`); + proxy.searchParams.set('url', targetUrl); + const localToken = getLocalRuntimeUrlAuthTokenSync(localOrigin); + if (localToken) proxy.searchParams.set('oc_url_token', localToken); + if (kind === 'ws') { + proxy.protocol = proxy.protocol === 'https:' ? 'wss:' : 'ws:'; + return toWebSocketUrl(proxy.toString(), config); + } + return proxy.toString(); + } catch { + return null; + } +}; + export const createRuntimeUrlResolver = (config: RuntimeUrlConfig = {}): RuntimeUrlResolver => { const configuredApiBaseUrl = normalizeBaseUrl(config.apiBaseUrl); const configuredRealtimeBaseUrl = normalizeBaseUrl(config.realtimeBaseUrl); @@ -131,8 +158,14 @@ export const createRuntimeUrlResolver = (config: RuntimeUrlConfig = {}): Runtime allowOutsideWorkspace: options?.allowOutsideWorkspace === true ? true : undefined, outsideFileGrant: options?.outsideFileGrant, }), - sse: (path, query) => withUrlAuth(realtime(path, query)), - websocket: (path, query) => toWebSocketUrl(withUrlAuth(realtime(path, query)), config), + sse: (path, query) => { + const target = withUrlAuth(realtime(path, query)); + return toRealtimeProxyUrl('sse', target, config) || target; + }, + websocket: (path, query) => { + const target = toWebSocketUrl(withUrlAuth(realtime(path, query)), config); + return toRealtimeProxyUrl('ws', target, config) || target; + }, }; }; diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 4f7339dc..21238b5e 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -84,6 +84,7 @@ import { createGracefulShutdownRuntime } from './lib/opencode/shutdown-runtime.j import { createProjectConfigRuntime } from './lib/projects/project-config.js'; import { createRemoteClientAuthRuntime } from './lib/client-auth/remote-clients.js'; import { createPreviewProxyRuntime } from './lib/preview/proxy-runtime.js'; +import { attachRealtimeProxy } from './lib/realtime-proxy.js'; import { createProxyMiddleware, responseInterceptor } from 'http-proxy-middleware'; import webPush from 'web-push'; @@ -135,9 +136,14 @@ const SSE_PATH_PREFIXES = [ '/api/global/event', '/api/notifications/stream', '/api/openchamber/events', + '/api/openchamber/realtime-proxy/sse', ]; function shouldSkipCompression(req, res) { + if (process.env.OPENCHAMBER_RUNTIME === 'desktop') { + return true; + } + if (headerIncludesEventStream(req.headers.accept)) { return true; } @@ -1087,6 +1093,9 @@ async function main(options = {}) { if (typeof options.getIsWindowFocused === 'function') { notificationTriggerRuntime.setGetIsWindowFocused(options.getIsWindowFocused); } + const getDesktopRuntimeConfig = typeof options.getDesktopRuntimeConfig === 'function' + ? options.getDesktopRuntimeConfig + : null; console.log(`Starting OpenChamber on port ${port === 0 ? 'auto' : port}`); @@ -1132,6 +1141,7 @@ async function main(options = {}) { })); expressApp = app; server = http.createServer(app); + let realtimeProxyRuntime = { stop: () => {} }; const bootstrapResult = bootstrapRuntime.setupBaseRoutes(app, { process, @@ -1202,6 +1212,13 @@ async function main(options = {}) { setAutoAcceptSession, }); uiAuthController = bootstrapResult.uiAuthController; + realtimeProxyRuntime = attachRealtimeProxy({ + app, + server, + getDesktopRuntimeConfig, + getUiAuthController: () => uiAuthController, + isRequestOriginAllowed, + }); const tunnelRuntimeContext = tunnelWiringRuntime.initialize(app, port); const { tunnelService, startTunnelWithNormalizedRequest } = tunnelRuntimeContext; @@ -1341,8 +1358,10 @@ async function main(options = {}) { port: managed ? openCodePort : null, }; }, - stop: (shutdownOptions = {}) => - gracefulShutdown({ exitProcess: shutdownOptions.exitProcess ?? false }) + stop: (shutdownOptions = {}) => { + realtimeProxyRuntime.stop(); + return gracefulShutdown({ exitProcess: shutdownOptions.exitProcess ?? false }); + } }; } diff --git a/packages/web/server/lib/realtime-proxy.js b/packages/web/server/lib/realtime-proxy.js new file mode 100644 index 00000000..7bcf49f2 --- /dev/null +++ b/packages/web/server/lib/realtime-proxy.js @@ -0,0 +1,279 @@ +import { WebSocket, WebSocketServer } from 'ws'; + +const PROXY_SSE_PATH = '/api/openchamber/realtime-proxy/sse'; +const PROXY_WS_PATH = '/api/openchamber/realtime-proxy/ws'; + +const isAllowedSsePath = (pathname) => { + return pathname === '/api/event' + || pathname === '/api/global/event' + || pathname === '/api/openchamber/events' + || pathname === '/api/notifications/stream' + || /^\/api\/terminal\/[^/]+\/stream$/.test(pathname); +}; + +const isAllowedWebSocketPath = (pathname) => { + return pathname === '/api/event/ws' + || pathname === '/api/global/event/ws' + || pathname === '/api/terminal/ws'; +}; + +const normalizeBaseUrl = (value) => { + if (typeof value !== 'string') return ''; + return value.trim().replace(/\/+$/, ''); +}; + +const sanitizeHeaders = (headers) => { + if (!headers || typeof headers !== 'object') return {}; + const next = {}; + for (const [rawName, rawValue] of Object.entries(headers)) { + const name = typeof rawName === 'string' ? rawName.trim() : ''; + const value = typeof rawValue === 'string' ? rawValue.trim() : ''; + if (!name || !value || /[\r\n:]/.test(name) || /[\r\n]/.test(value)) continue; + if (name.toLowerCase() === 'authorization') continue; + next[name] = value; + } + return next; +}; + +const hasHeaders = (headers) => Object.keys(headers).length > 0; + +const getTargetParam = (req) => { + let raw = typeof req.query?.url === 'string' ? req.query.url : ''; + if (!raw) { + try { + raw = new URL(req.url || '/', 'http://127.0.0.1').searchParams.get('url') || ''; + } catch { + raw = ''; + } + } + if (!raw) return null; + try { + return new URL(raw); + } catch { + return null; + } +}; + +const urlsMatchRuntime = (target, apiBaseUrl) => { + const base = normalizeBaseUrl(apiBaseUrl); + if (!base) return false; + try { + const baseUrl = new URL(base); + const targetForCompare = new URL(target.toString()); + if (targetForCompare.protocol === 'ws:') targetForCompare.protocol = 'http:'; + if (targetForCompare.protocol === 'wss:') targetForCompare.protocol = 'https:'; + return targetForCompare.origin === baseUrl.origin; + } catch { + return false; + } +}; + +const protocolMatchesProxyType = (target, type) => { + if (type === 'ws') return target.protocol === 'ws:' || target.protocol === 'wss:'; + return target.protocol === 'http:' || target.protocol === 'https:'; +}; + +const pathMatchesProxyType = (target, type) => { + return type === 'ws' ? isAllowedWebSocketPath(target.pathname) : isAllowedSsePath(target.pathname); +}; + +const resolveProxyTarget = (req, getDesktopRuntimeConfig, type) => { + const config = typeof getDesktopRuntimeConfig === 'function' ? getDesktopRuntimeConfig() : null; + const requestHeaders = sanitizeHeaders(config?.requestHeaders); + const apiBaseUrl = normalizeBaseUrl(config?.apiBaseUrl); + const target = getTargetParam(req); + if (!target || !apiBaseUrl || !hasHeaders(requestHeaders)) return null; + if (!protocolMatchesProxyType(target, type)) return null; + if (!pathMatchesProxyType(target, type)) return null; + if (!urlsMatchRuntime(target, apiBaseUrl)) return null; + return { target, requestHeaders }; +}; + +const safeHeader = (headers, name) => { + const value = headers?.[name.toLowerCase()]; + if (Array.isArray(value)) return value.find((item) => typeof item === 'string' && item.trim()) || ''; + return typeof value === 'string' ? value.trim() : ''; +}; + +const buildSseRequestHeaders = (req, requestHeaders) => { + const headers = {}; + const accept = safeHeader(req.headers, 'accept'); + const lastEventId = safeHeader(req.headers, 'last-event-id'); + if (accept) headers.Accept = accept; + if (lastEventId) headers['Last-Event-ID'] = lastEventId; + return { ...headers, ...requestHeaders }; +}; + +const rejectWebSocketUpgrade = (socket, statusCode, message) => { + socket.write(`HTTP/1.1 ${statusCode} ${message}\r\nConnection: close\r\n\r\n`); + socket.destroy(); +}; + +export const buildRealtimeProxySseUrl = (localOrigin, targetUrl) => { + const url = new URL(PROXY_SSE_PATH, localOrigin); + url.searchParams.set('url', targetUrl); + return url.toString(); +}; + +export const buildRealtimeProxyWsUrl = (localOrigin, targetUrl) => { + const url = new URL(PROXY_WS_PATH, localOrigin); + url.searchParams.set('url', targetUrl); + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; + return url.toString(); +}; + +export const attachRealtimeProxy = ({ app, server, getDesktopRuntimeConfig, getUiAuthController, isRequestOriginAllowed }) => { + if (!app || !server || typeof getDesktopRuntimeConfig !== 'function') { + return { stop: () => {} }; + } + + const originAllowed = async (req) => { + if (typeof isRequestOriginAllowed !== 'function') return false; + try { + return await isRequestOriginAllowed(req); + } catch { + return false; + } + }; + + const ensureAuthenticated = async (req, res) => { + const controller = typeof getUiAuthController === 'function' ? getUiAuthController() : null; + if (typeof controller?.ensureSessionToken !== 'function') return false; + const response = res || { setHeader: () => {} }; + const token = await controller.ensureSessionToken(req, response); + return Boolean(token); + }; + + app.get(PROXY_SSE_PATH, async (req, res) => { + if (!await ensureAuthenticated(req, res)) { + res.status(401).json({ error: 'UI authentication required' }); + return; + } + if (!await originAllowed(req)) { + res.status(403).json({ error: 'Realtime proxy origin is not allowed' }); + return; + } + const resolved = resolveProxyTarget(req, getDesktopRuntimeConfig, 'sse'); + if (!resolved) { + res.status(404).json({ error: 'Realtime proxy is unavailable' }); + return; + } + + const abort = new AbortController(); + req.on('close', () => abort.abort()); + try { + const response = await fetch(resolved.target.toString(), { + headers: buildSseRequestHeaders(req, resolved.requestHeaders), + signal: abort.signal, + }); + if (!response.ok || !response.body) { + res.status(response.status || 502).end(); + return; + } + + res.status(response.status); + res.setHeader('Content-Type', response.headers.get('content-type') || 'text/event-stream'); + res.setHeader('Cache-Control', response.headers.get('cache-control') || 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + + for await (const chunk of response.body) { + if (abort.signal.aborted) break; + res.write(chunk); + } + res.end(); + } catch (error) { + if (!abort.signal.aborted && !res.headersSent) { + res.status(502).json({ error: error instanceof Error ? error.message : 'Realtime proxy failed' }); + } else if (!res.destroyed) { + res.end(); + } + } + }); + + const wsServer = new WebSocketServer({ noServer: true }); + + wsServer.on('connection', (client, request) => { + const resolved = resolveProxyTarget(request, getDesktopRuntimeConfig, 'ws'); + if (!resolved) { + client.close(1008, 'Realtime proxy is unavailable'); + return; + } + + const upstream = new WebSocket(resolved.target.toString(), { + headers: resolved.requestHeaders, + }); + const pending = []; + + const flush = () => { + while (pending.length > 0 && upstream.readyState === WebSocket.OPEN) { + const [data, isBinary] = pending.shift(); + upstream.send(data, { binary: isBinary }); + } + }; + + client.on('message', (data, isBinary) => { + if (upstream.readyState === WebSocket.OPEN) { + upstream.send(data, { binary: isBinary }); + return; + } + if (upstream.readyState === WebSocket.CONNECTING) { + pending.push([data, isBinary]); + } + }); + upstream.on('open', flush); + upstream.on('message', (data, isBinary) => { + if (client.readyState === WebSocket.OPEN) { + client.send(data, { binary: isBinary }); + } + }); + upstream.on('close', (code, reason) => { + if (client.readyState === WebSocket.OPEN || client.readyState === WebSocket.CONNECTING) { + client.close(code || 1000, reason); + } + }); + upstream.on('error', () => { + if (client.readyState === WebSocket.OPEN || client.readyState === WebSocket.CONNECTING) { + client.close(1011, 'Realtime proxy upstream error'); + } + }); + client.on('close', () => { + if (upstream.readyState === WebSocket.OPEN || upstream.readyState === WebSocket.CONNECTING) { + upstream.close(); + } + }); + }); + + const upgradeHandler = (req, socket, head) => { + const pathname = (() => { + try { return new URL(req.url || '/', 'http://127.0.0.1').pathname; } catch { return ''; } + })(); + if (pathname !== PROXY_WS_PATH) return; + void ensureAuthenticated(req, null).then((authenticated) => { + if (!authenticated) { + rejectWebSocketUpgrade(socket, 401, 'Unauthorized'); + return; + } + void originAllowed(req).then((allowed) => { + if (!allowed) { + rejectWebSocketUpgrade(socket, 403, 'Forbidden'); + return; + } + wsServer.handleUpgrade(req, socket, head, (ws) => { + wsServer.emit('connection', ws, req); + }); + }).catch(() => { + rejectWebSocketUpgrade(socket, 403, 'Forbidden'); + }); + }).catch(() => { + rejectWebSocketUpgrade(socket, 401, 'Unauthorized'); + }); + }; + + server.on('upgrade', upgradeHandler); + return { + stop: () => { + server.off('upgrade', upgradeHandler); + wsServer.close(); + }, + }; +}; diff --git a/packages/web/server/lib/realtime-proxy.test.js b/packages/web/server/lib/realtime-proxy.test.js new file mode 100644 index 00000000..ce25992b --- /dev/null +++ b/packages/web/server/lib/realtime-proxy.test.js @@ -0,0 +1,259 @@ +import { afterEach, describe, expect, it } from 'bun:test'; +import express from 'express'; +import http from 'node:http'; +import { WebSocket, WebSocketServer } from 'ws'; + +import { attachRealtimeProxy, buildRealtimeProxySseUrl, buildRealtimeProxyWsUrl } from './realtime-proxy.js'; +import { createUiAuth } from './ui-auth/ui-auth.js'; + +const servers = []; + +const listen = async (server) => { + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + servers.push(server); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Expected TCP server address'); + return `http://127.0.0.1:${address.port}`; +}; + +const closeServer = async (server) => { + await new Promise((resolve) => server.close(() => resolve())); +}; + +const startProxyServer = async ({ apiBaseUrl, authToken = 'ui-token', originAllowed = true } = {}) => { + const app = express(); + const server = http.createServer(app); + const runtime = attachRealtimeProxy({ + app, + server, + getDesktopRuntimeConfig: () => ({ + apiBaseUrl, + requestHeaders: { 'X-Proxy-Auth': 'secret' }, + }), + getUiAuthController: () => ({ + ensureSessionToken: async () => authToken, + }), + isRequestOriginAllowed: async () => originAllowed, + }); + const origin = await listen(server); + return { origin, runtime }; +}; + +const startProxyServerWithAuthController = async ({ apiBaseUrl, uiAuthController, originAllowed = true } = {}) => { + const app = express(); + const server = http.createServer(app); + const runtime = attachRealtimeProxy({ + app, + server, + getDesktopRuntimeConfig: () => ({ + apiBaseUrl, + requestHeaders: { 'X-Proxy-Auth': 'secret' }, + }), + getUiAuthController: () => uiAuthController, + isRequestOriginAllowed: async () => originAllowed, + }); + const origin = await listen(server); + return { origin, runtime }; +}; + +const startSseUpstream = async ({ path = '/api/global/event' } = {}) => { + const requests = []; + const server = http.createServer((req, res) => { + requests.push({ url: req.url, headers: req.headers }); + if (new URL(req.url || '/', 'http://127.0.0.1').pathname !== path) { + res.writeHead(404).end(); + return; + } + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + }); + res.write('data: first\n\n'); + res.end('data: second\n\n'); + }); + const origin = await listen(server); + return { origin, requests }; +}; + +afterEach(async () => { + while (servers.length > 0) { + const server = servers.pop(); + await closeServer(server); + } +}); + +describe('realtime proxy URL builders', () => { + it('builds local SSE proxy URLs with target URL encoded as query data', () => { + const url = new URL(buildRealtimeProxySseUrl('http://127.0.0.1:57123', 'https://remote.example/api/global/event?x=1')); + + expect(url.origin).toBe('http://127.0.0.1:57123'); + expect(url.pathname).toBe('/api/openchamber/realtime-proxy/sse'); + expect(url.searchParams.get('url')).toBe('https://remote.example/api/global/event?x=1'); + }); + + it('builds local WebSocket proxy URLs with ws protocol', () => { + const url = new URL(buildRealtimeProxyWsUrl('https://127.0.0.1:57123', 'wss://remote.example/api/global/event/ws')); + + expect(url.protocol).toBe('wss:'); + expect(url.host).toBe('127.0.0.1:57123'); + expect(url.pathname).toBe('/api/openchamber/realtime-proxy/ws'); + expect(url.searchParams.get('url')).toBe('wss://remote.example/api/global/event/ws'); + }); +}); + +describe('realtime proxy', () => { + it('streams SSE chunks and forwards safe SSE headers with configured runtime headers', async () => { + const upstream = await startSseUpstream(); + const { origin, runtime } = await startProxyServer({ apiBaseUrl: upstream.origin }); + + try { + const response = await fetch(buildRealtimeProxySseUrl(origin, `${upstream.origin}/api/global/event`), { + headers: { + Accept: 'text/event-stream', + 'Last-Event-ID': 'evt-42', + Origin: 'openchamber-ui://app', + }, + }); + + expect(response.status).toBe(200); + expect(await response.text()).toBe('data: first\n\ndata: second\n\n'); + expect(upstream.requests).toHaveLength(1); + expect(upstream.requests[0].headers.accept).toBe('text/event-stream'); + expect(upstream.requests[0].headers['last-event-id']).toBe('evt-42'); + expect(upstream.requests[0].headers['x-proxy-auth']).toBe('secret'); + } finally { + runtime.stop(); + } + }); + + it('rejects unauthenticated SSE proxy requests', async () => { + const upstream = await startSseUpstream(); + const { origin, runtime } = await startProxyServer({ apiBaseUrl: upstream.origin, authToken: null }); + + try { + const response = await fetch(buildRealtimeProxySseUrl(origin, `${upstream.origin}/api/global/event`), { + headers: { Origin: 'openchamber-ui://app' }, + }); + + expect(response.status).toBe(401); + expect(upstream.requests).toHaveLength(0); + } finally { + runtime.stop(); + } + }); + + it('rejects SSE proxy requests from disallowed origins', async () => { + const upstream = await startSseUpstream(); + const { origin, runtime } = await startProxyServer({ apiBaseUrl: upstream.origin, originAllowed: false }); + + try { + const response = await fetch(buildRealtimeProxySseUrl(origin, `${upstream.origin}/api/global/event`), { + headers: { Origin: 'https://evil.example' }, + }); + + expect(response.status).toBe(403); + expect(upstream.requests).toHaveLength(0); + } finally { + runtime.stop(); + } + }); + + it('rejects targets outside the active runtime origin', async () => { + const upstream = await startSseUpstream(); + const { origin, runtime } = await startProxyServer({ apiBaseUrl: 'https://different.example' }); + + try { + const response = await fetch(buildRealtimeProxySseUrl(origin, `${upstream.origin}/api/global/event`), { + headers: { Origin: 'openchamber-ui://app' }, + }); + + expect(response.status).toBe(404); + expect(upstream.requests).toHaveLength(0); + } finally { + runtime.stop(); + } + }); + + it('rejects targets outside the realtime path allowlist', async () => { + const upstream = await startSseUpstream({ path: '/api/config/settings' }); + const { origin, runtime } = await startProxyServer({ apiBaseUrl: upstream.origin }); + + try { + const response = await fetch(buildRealtimeProxySseUrl(origin, `${upstream.origin}/api/config/settings`), { + headers: { Origin: 'openchamber-ui://app' }, + }); + + expect(response.status).toBe(404); + expect(upstream.requests).toHaveLength(0); + } finally { + runtime.stop(); + } + }); + + it('proxies WebSocket upgrades using query params from the raw upgrade request URL', async () => { + let upstreamRequest = null; + const upstreamServer = http.createServer(); + const upstreamWs = new WebSocketServer({ server: upstreamServer }); + upstreamWs.on('connection', (socket, request) => { + upstreamRequest = request; + socket.on('message', (data, isBinary) => { + socket.send(isBinary ? data : `echo:${data.toString()}`, { binary: isBinary }); + }); + }); + const upstreamOrigin = await listen(upstreamServer); + const { origin, runtime } = await startProxyServer({ apiBaseUrl: upstreamOrigin }); + + try { + const target = `${upstreamOrigin.replace(/^http:/, 'ws:')}/api/global/event/ws?lastEventId=evt-1`; + const client = new WebSocket(buildRealtimeProxyWsUrl(origin, target), { + headers: { Origin: 'openchamber-ui://app' }, + }); + await new Promise((resolve, reject) => { + client.once('open', resolve); + client.once('error', reject); + }); + + const message = await new Promise((resolve) => { + client.once('message', (data) => resolve(data.toString())); + client.send('ping'); + }); + + expect(message).toBe('echo:ping'); + expect(upstreamRequest?.url).toBe('/api/global/event/ws?lastEventId=evt-1'); + expect(upstreamRequest?.headers['x-proxy-auth']).toBe('secret'); + client.close(); + upstreamWs.close(); + } finally { + runtime.stop(); + } + }); + + it('allows first passwordless WebSocket proxy upgrade without an existing cookie', async () => { + const upstreamServer = http.createServer(); + const upstreamWs = new WebSocketServer({ server: upstreamServer }); + upstreamWs.on('connection', (socket) => { + socket.send('ready'); + }); + const upstreamOrigin = await listen(upstreamServer); + const uiAuthController = createUiAuth({ password: '' }); + const { origin, runtime } = await startProxyServerWithAuthController({ apiBaseUrl: upstreamOrigin, uiAuthController }); + + try { + const target = `${upstreamOrigin.replace(/^http:/, 'ws:')}/api/global/event/ws`; + const client = new WebSocket(buildRealtimeProxyWsUrl(origin, target), { + headers: { Origin: 'openchamber-ui://app' }, + }); + const message = await new Promise((resolve, reject) => { + client.once('message', (data) => resolve(data.toString())); + client.once('error', reject); + }); + + expect(message).toBe('ready'); + client.close(); + upstreamWs.close(); + } finally { + runtime.stop(); + uiAuthController.dispose?.(); + } + }); +}); diff --git a/packages/web/server/lib/ui-auth/ui-auth.js b/packages/web/server/lib/ui-auth/ui-auth.js index 926aaae2..4862bd65 100644 --- a/packages/web/server/lib/ui-auth/ui-auth.js +++ b/packages/web/server/lib/ui-auth/ui-auth.js @@ -295,6 +295,7 @@ const isUrlAuthReadableHttpPath = (pathname) => { return pathname === '/api/event' || pathname === '/api/global/event' || pathname === '/api/openchamber/events' + || pathname === '/api/openchamber/realtime-proxy/sse' || pathname === '/api/notifications/stream' || pathname === '/api/fs/raw' || pathname === '/api/fs/serve' @@ -307,6 +308,7 @@ const isUrlAuthReadableHttpPath = (pathname) => { const isUrlAuthWebSocketPath = (pathname) => { return pathname === '/api/event/ws' || pathname === '/api/global/event/ws' + || pathname === '/api/openchamber/realtime-proxy/ws' || pathname === '/api/terminal/ws' || pathname.startsWith('/api/preview/proxy/'); }; diff --git a/packages/web/src/runtimeConfig.ts b/packages/web/src/runtimeConfig.ts index ba91ca9b..de16d298 100644 --- a/packages/web/src/runtimeConfig.ts +++ b/packages/web/src/runtimeConfig.ts @@ -1,4 +1,4 @@ -import { refreshRuntimeUrlAuthToken, setRuntimeBearerToken, setRuntimeExtraHeaders } from '@openchamber/ui/lib/runtime-auth'; +import { refreshLocalRuntimeUrlAuthToken, refreshRuntimeUrlAuthToken, setRuntimeBearerToken, setRuntimeExtraHeaders } from '@openchamber/ui/lib/runtime-auth'; import { installRuntimeFetchBridge } from '@openchamber/ui/lib/runtime-fetch'; import { initializeRuntimeEndpoint } from '@openchamber/ui/lib/runtime-switch'; import { configureRuntimeUrlResolver } from '@openchamber/ui/lib/runtime-url'; @@ -44,6 +44,9 @@ export const createConfiguredWebAPIs = () => { setRuntimeBearerToken(clientToken || null); setRuntimeExtraHeaders(window.__OPENCHAMBER_RUNTIME_HEADERS__ || null); void refreshRuntimeUrlAuthToken(apiBaseUrl || undefined).catch(() => {}); + if (localOrigin && !sameOrigin(apiBaseUrl, localOrigin)) { + void refreshLocalRuntimeUrlAuthToken(localOrigin).catch(() => {}); + } installRuntimeFetchBridge(); return createWebAPIs({ urls }); }; From 0e65a435ee5eb735073b51050f218c566eedd92a Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 30 Jun 2026 02:48:01 +0300 Subject: [PATCH 0058/1073] fix: restore desktop remote authentication Fixes switching and unlocking password-protected remote instances Stores SSH forwarded host client tokens from saved UI passwords Avoids unnecessary auth churn when no runtime headers are configured --- packages/electron/main.mjs | 6 +- packages/electron/ssh-manager.mjs | 70 +++++++++++++++- packages/electron/ssh-manager.test.mjs | 70 ++++++++++++++++ .../src/components/auth/SessionAuthGate.tsx | 82 ++++++++++++++++--- packages/ui/src/lib/runtime-auth.test.ts | 30 +++++++ packages/ui/src/lib/runtime-auth.ts | 10 ++- packages/ui/src/lib/runtime-switch.test.ts | 65 +++++++++++++++ packages/ui/src/lib/runtime-switch.ts | 23 +++++- packages/web/src/runtimeConfig.ts | 4 +- 9 files changed, 338 insertions(+), 22 deletions(-) create mode 100644 packages/electron/ssh-manager.test.mjs create mode 100644 packages/ui/src/lib/runtime-switch.test.ts diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index 1272ec3a..91ec2f78 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -1517,9 +1517,10 @@ const extractCookieHeader = (response) => { .join('; '); }; -const loginRemoteAndIssueClientToken = async ({ url, password, trustDevice }) => { +const loginRemoteAndIssueClientToken = async ({ url, password, trustDevice, requestHeaders }) => { const baseUrl = normalizeHostUrl(String(url || '')); const candidatePassword = typeof password === 'string' ? password : ''; + const safeRequestHeaders = sanitizeRuntimeRequestHeaders(requestHeaders || {}); if (!baseUrl) throw new Error('Invalid URL'); if (!candidatePassword) throw new Error('Password is required'); @@ -1527,6 +1528,7 @@ const loginRemoteAndIssueClientToken = async ({ url, password, trustDevice }) => method: 'POST', signal: AbortSignal.timeout(10_000), headers: { + ...safeRequestHeaders, Accept: 'application/json', 'Content-Type': 'application/json', }, @@ -1559,6 +1561,7 @@ const loginRemoteAndIssueClientToken = async ({ url, password, trustDevice }) => method: 'POST', signal: AbortSignal.timeout(10_000), headers: { + ...safeRequestHeaders, Accept: 'application/json', 'Content-Type': 'application/json', Cookie: cookie, @@ -3506,6 +3509,7 @@ const handleInvoke = async (browserWindow, command, args = {}) => { url: args.url, password: args.password, trustDevice: args.trustDevice === true, + requestHeaders: args.requestHeaders || {}, }); case 'desktop_set_window_theme': { diff --git a/packages/electron/ssh-manager.mjs b/packages/electron/ssh-manager.mjs index 38c14bae..7075b552 100644 --- a/packages/electron/ssh-manager.mjs +++ b/packages/electron/ssh-manager.mjs @@ -700,19 +700,84 @@ export class ElectronSshManager { } async updateHostUrl(instanceId, label, localUrl) { + return this.updateHostRuntime(instanceId, label, localUrl, ''); + } + + async updateHostRuntime(instanceId, label, localUrl, clientToken = '') { const root = readJsonRoot(this.settingsFilePath); const hosts = Array.isArray(root.desktopHosts) ? root.desktopHosts : []; const existing = hosts.find((entry) => entry?.id === instanceId); + const token = typeof clientToken === 'string' ? clientToken.trim() : ''; if (existing) { existing.label = label; existing.url = localUrl; + existing.apiUrl = localUrl; + if (token) existing.clientToken = token; } else { - hosts.push({ id: instanceId, label, url: localUrl }); + hosts.push({ id: instanceId, label, url: localUrl, apiUrl: localUrl, ...(token ? { clientToken: token } : {}) }); } root.desktopHosts = hosts; await writeJsonRoot(this.settingsFilePath, root); } + async issueClientToken(localUrl, openchamberPassword) { + const password = typeof openchamberPassword === 'string' ? openchamberPassword.trim() : ''; + if (!password) return ''; + + const loginResponse = await fetch(new URL('/auth/session', `${localUrl}/`).toString(), { + method: 'POST', + signal: AbortSignal.timeout(10_000), + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + password, + trustDevice: true, + issueClientToken: true, + clientLabel: 'OpenChamber Desktop SSH', + }), + }); + if (!loginResponse.ok) { + throw new Error(`Configured OpenChamber UI password was rejected by forwarded server (status ${loginResponse.status})`); + } + + const payload = await loginResponse.json().catch(() => null); + const token = typeof payload?.clientToken === 'string' ? payload.clientToken.trim() : ''; + if (token) return token; + + const cookie = this.extractCookieHeader(loginResponse); + if (!cookie) return ''; + + const tokenResponse = await fetch(new URL('/api/client-auth/clients', `${localUrl}/`).toString(), { + method: 'POST', + signal: AbortSignal.timeout(10_000), + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Cookie: cookie, + }, + body: JSON.stringify({ label: 'OpenChamber Desktop SSH' }), + }); + if (!tokenResponse.ok) return ''; + const tokenPayload = await tokenResponse.json().catch(() => null); + return typeof tokenPayload?.token === 'string' ? tokenPayload.token.trim() : ''; + } + + extractCookieHeader(response) { + const getSetCookie = typeof response.headers?.getSetCookie === 'function' + ? response.headers.getSetCookie.bind(response.headers) + : null; + const cookies = getSetCookie ? getSetCookie() : []; + const rawCookies = cookies.length > 0 + ? cookies + : String(response.headers?.get?.('set-cookie') || '').split(/,(?=\s*[^;,=]+=[^;,]+)/); + return rawCookies + .map((cookie) => String(cookie || '').split(';')[0].trim()) + .filter(Boolean) + .join('; '); + } + async persistLocalPort(instanceId, localPort) { const root = readJsonRoot(this.settingsFilePath); const instances = Array.isArray(root.desktopSshInstances) ? root.desktopSshInstances : []; @@ -1090,7 +1155,8 @@ export class ElectronSshManager { const localUrl = `http://127.0.0.1:${localPort}`; const label = instance.nickname?.trim() || parsed.destination || id; - await this.updateHostUrl(id, label, localUrl); + const clientToken = await this.issueClientToken(localUrl, this.configuredOpenChamberPassword(instance)); + await this.updateHostRuntime(id, label, localUrl, clientToken); if (instance.localForward?.preferredLocalPort !== localPort) { await this.persistLocalPort(id, localPort); } diff --git a/packages/electron/ssh-manager.test.mjs b/packages/electron/ssh-manager.test.mjs new file mode 100644 index 00000000..f31c90f8 --- /dev/null +++ b/packages/electron/ssh-manager.test.mjs @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; + +import { ElectronSshManager } from './ssh-manager.mjs'; + +const servers = []; +const tempDirs = []; + +const listen = async (server) => { + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + servers.push(server); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Expected TCP server address'); + return `http://127.0.0.1:${address.port}`; +}; + +const readBody = async (req) => { + let body = ''; + for await (const chunk of req) body += chunk.toString(); + return body; +}; + +afterEach(async () => { + while (servers.length > 0) { + const server = servers.pop(); + await new Promise((resolve) => server.close(() => resolve())); + } + while (tempDirs.length > 0) { + await fsp.rm(tempDirs.pop(), { recursive: true, force: true }); + } +}); + +describe('ElectronSshManager', () => { + test('stores a client token for forwarded OpenChamber hosts when UI password is configured', async () => { + let loginPayload = null; + const server = http.createServer(async (req, res) => { + if (req.method === 'POST' && req.url === '/auth/session') { + loginPayload = JSON.parse(await readBody(req)); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ authenticated: true, clientToken: 'ssh-client-token' })); + return; + } + res.writeHead(404).end(); + }); + const localUrl = await listen(server); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-ssh-manager-test-')); + tempDirs.push(tempDir); + const settingsFilePath = path.join(tempDir, 'settings.json'); + const manager = new ElectronSshManager({ + settingsFilePath, + appVersion: '0.0.0-test', + emit: () => undefined, + }); + + const token = await manager.issueClientToken(localUrl, 'ui-secret'); + await manager.updateHostRuntime('ssh-1', 'SSH Host', localUrl, token); + + const settings = JSON.parse(fs.readFileSync(settingsFilePath, 'utf8')); + expect(loginPayload).toMatchObject({ + password: 'ui-secret', + trustDevice: true, + issueClientToken: true, + }); + expect(settings.desktopHosts).toEqual([{ id: 'ssh-1', label: 'SSH Host', url: localUrl, apiUrl: localUrl, clientToken: 'ssh-client-token' }]); + }); +}); diff --git a/packages/ui/src/components/auth/SessionAuthGate.tsx b/packages/ui/src/components/auth/SessionAuthGate.tsx index 9f4e23b1..8dbe460b 100644 --- a/packages/ui/src/components/auth/SessionAuthGate.tsx +++ b/packages/ui/src/components/auth/SessionAuthGate.tsx @@ -12,6 +12,7 @@ import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo'; import { Icon } from "@/components/icon/Icon"; import { useI18n } from '@/lib/i18n'; import { runtimeFetch } from '@/lib/runtime-fetch'; +import { getRuntimeExtraHeadersSync } from '@/lib/runtime-auth'; import { getRuntimeApiBaseUrl, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch'; import { desktopHostsGet, desktopHostsSet, getDesktopHostApiUrl, normalizeHostUrl } from '@/lib/desktopHosts'; import { @@ -129,20 +130,30 @@ const shouldUseDesktopShellPasswordLogin = (): boolean => { return isDesktopShell() && !isLocalDesktopRuntime(); }; -const issueDesktopClientTokenViaShell = async (password: string, trustDevice: boolean): Promise => { +type DesktopPasswordLoginResult = { + token: string; + status?: number; +}; + +const issueDesktopClientTokenViaShell = async (password: string, trustDevice: boolean): Promise => { if (!isDesktopShell() || typeof window === 'undefined') { - return ''; + return null; } const response = await invokeDesktop('desktop_remote_password_login', { url: getRuntimeApiBaseUrl(), password, trustDevice, + requestHeaders: getRuntimeExtraHeadersSync(), }).catch(() => null); if (!response || typeof response !== 'object') { - return ''; + return null; } const token = (response as { token?: unknown }).token; - return typeof token === 'string' ? token.trim() : ''; + const status = (response as { status?: unknown }).status; + return { + token: typeof token === 'string' ? token.trim() : '', + ...(typeof status === 'number' ? { status } : {}), + }; }; const persistDesktopClientToken = async (apiBaseUrl: string, clientToken: string): Promise => { @@ -180,8 +191,13 @@ const persistDesktopClientToken = async (apiBaseUrl: string, clientToken: string const applyDesktopClientToken = async (clientToken: string): Promise => { if (!clientToken) return; const apiBaseUrl = getRuntimeApiBaseUrl(); + const requestHeaders = getRuntimeExtraHeadersSync(); await persistDesktopClientToken(apiBaseUrl, clientToken); - switchRuntimeEndpoint({ apiBaseUrl, clientToken }); + switchRuntimeEndpoint({ + apiBaseUrl, + clientToken, + requestHeaders: Object.keys(requestHeaders).length > 0 ? requestHeaders : null, + }); }; const AuthShell: React.FC<{ children: React.ReactNode }> = ({ children }) => { @@ -486,15 +502,43 @@ export const SessionAuthGate: React.FC = ({ children }) => setErrorMessage(''); try { + if (shouldUseDesktopShellPasswordLogin()) { + const shellLogin = await issueDesktopClientTokenViaShell(password, trustDevice); + if (shellLogin?.token) { + setPassword(''); + setIsTunnelLocked(false); + await applyDesktopClientToken(shellLogin.token); + setState('authenticated'); + return; + } + if (shellLogin?.status === 401) { + setErrorMessage(t('sessionAuth.error.incorrectPassword')); + setIsTunnelLocked(false); + setState('locked'); + return; + } + if (shellLogin?.status === 429) { + setRetryAfter(undefined); + setIsTunnelLocked(false); + setState('rate-limited'); + return; + } + } + const response = await submitPassword(password, trustDevice); if (response.ok) { const payload = await response.json().catch(() => null) as { clientToken?: unknown } | null; const shouldUseClientToken = shouldIssueDesktopClientToken(); - const clientToken = shouldUseClientToken - ? (typeof payload?.clientToken === 'string' && payload.clientToken.trim() + let clientToken = ''; + if (shouldUseClientToken) { + clientToken = typeof payload?.clientToken === 'string' && payload.clientToken.trim() ? payload.clientToken.trim() - : await issueDesktopClientTokenViaShell(password, trustDevice) || await issueDesktopClientToken()) - : ''; + : ''; + if (!clientToken) { + const shellLogin = await issueDesktopClientTokenViaShell(password, trustDevice); + clientToken = shellLogin?.token || await issueDesktopClientToken(); + } + } setPassword(''); setIsTunnelLocked(false); if (clientToken) { @@ -541,16 +585,28 @@ export const SessionAuthGate: React.FC = ({ children }) => setState('error'); } catch (error) { console.warn('Failed to submit UI password:', error); - const clientToken = shouldUseDesktopShellPasswordLogin() + const shellLogin = shouldUseDesktopShellPasswordLogin() ? await issueDesktopClientTokenViaShell(password, trustDevice) - : ''; - if (clientToken) { + : null; + if (shellLogin?.token) { setPassword(''); setIsTunnelLocked(false); - await applyDesktopClientToken(clientToken); + await applyDesktopClientToken(shellLogin.token); setState('authenticated'); return; } + if (shellLogin?.status === 401) { + setErrorMessage(t('sessionAuth.error.incorrectPassword')); + setIsTunnelLocked(false); + setState('locked'); + return; + } + if (shellLogin?.status === 429) { + setRetryAfter(undefined); + setIsTunnelLocked(false); + setState('rate-limited'); + return; + } setErrorMessage(t('sessionAuth.error.networkRetry')); setIsTunnelLocked(false); setState('error'); diff --git a/packages/ui/src/lib/runtime-auth.test.ts b/packages/ui/src/lib/runtime-auth.test.ts index f5cbb58d..58858dfa 100644 --- a/packages/ui/src/lib/runtime-auth.test.ts +++ b/packages/ui/src/lib/runtime-auth.test.ts @@ -115,4 +115,34 @@ describe('runtime auth headers', () => { clearRuntimeAuthCredentialProvider(); } }); + + test('does not remint URL auth token when setting equivalent empty runtime headers', async () => { + const previousFetch = globalThis.fetch; + let fetchCount = 0; + try { + clearRuntimeUrlAuthToken(); + setRuntimeBearerToken('runtime-token'); + setRuntimeExtraHeaders(null); + globalThis.fetch = (async () => { + fetchCount += 1; + return new Response(JSON.stringify({ token: `url-token-${fetchCount}`, expiresAt: Date.now() + 60_000 }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }) as typeof fetch; + + const firstToken = await refreshRuntimeUrlAuthToken('https://runtime.example'); + setRuntimeExtraHeaders({}); + const secondToken = await refreshRuntimeUrlAuthToken('https://runtime.example'); + + expect(firstToken).toBe('url-token-1'); + expect(secondToken).toBe('url-token-1'); + expect(fetchCount).toBe(1); + } finally { + globalThis.fetch = previousFetch; + clearRuntimeUrlAuthToken(); + setRuntimeExtraHeaders(null); + clearRuntimeAuthCredentialProvider(); + } + }); }); diff --git a/packages/ui/src/lib/runtime-auth.ts b/packages/ui/src/lib/runtime-auth.ts index efd1cd93..6aa181db 100644 --- a/packages/ui/src/lib/runtime-auth.ts +++ b/packages/ui/src/lib/runtime-auth.ts @@ -29,6 +29,12 @@ const sanitizeRuntimeExtraHeaders = (headers: Record | null | un return next; }; +const runtimeExtraHeadersEqual = (left: Record, right: Record): boolean => { + const leftEntries = Object.entries(left); + if (leftEntries.length !== Object.keys(right).length) return false; + return leftEntries.every(([key, value]) => right[key] === value); +}; + const normalizeBearerToken = (token: string | null | undefined): string => { if (typeof token !== 'string') return ''; return token.trim(); @@ -95,7 +101,9 @@ export const setRuntimeBearerToken = (token: string | null | undefined): void => export const setRuntimeExtraHeaders = (headers: Record | null | undefined): void => { // These headers are for runtime HTTP fetches and URL-token minting. Browser-owned // realtime transports (EventSource/WebSocket) cannot attach arbitrary headers. - runtimeExtraHeaders = sanitizeRuntimeExtraHeaders(headers); + const next = sanitizeRuntimeExtraHeaders(headers); + if (runtimeExtraHeadersEqual(runtimeExtraHeaders, next)) return; + runtimeExtraHeaders = next; resetRuntimeAuthGeneration(); }; diff --git a/packages/ui/src/lib/runtime-switch.test.ts b/packages/ui/src/lib/runtime-switch.test.ts new file mode 100644 index 00000000..666b845b --- /dev/null +++ b/packages/ui/src/lib/runtime-switch.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from 'bun:test'; +import { getRuntimeApiBaseUrl, switchRuntimeEndpoint } from './runtime-switch'; +import { clearRuntimeUrlAuthToken, setRuntimeExtraHeaders } from './runtime-auth'; + +describe('runtime endpoint switching', () => { + test('does not throw when Electron preload globals are read-only', () => { + const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); + const previousFetch = globalThis.fetch; + const runtimeWindow = { + addEventListener: () => undefined, + removeEventListener: () => undefined, + dispatchEvent: () => true, + }; + + try { + clearRuntimeUrlAuthToken(); + setRuntimeExtraHeaders(null); + globalThis.fetch = (async () => new Response(JSON.stringify({ token: 'url-token', expiresAt: Date.now() + 60_000 }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })) as typeof fetch; + Object.defineProperty(runtimeWindow, '__OPENCHAMBER_API_BASE_URL__', { + configurable: true, + value: 'http://127.0.0.1:3000', + writable: false, + }); + Object.defineProperty(runtimeWindow, '__OPENCHAMBER_CLIENT_TOKEN__', { + configurable: true, + value: '', + writable: false, + }); + Object.defineProperty(runtimeWindow, '__OPENCHAMBER_RUNTIME_HEADERS__', { + configurable: true, + value: {}, + writable: false, + }); + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: runtimeWindow, + }); + + let thrown: unknown = null; + try { + switchRuntimeEndpoint({ + apiBaseUrl: 'https://remote.example', + clientToken: 'client-token', + requestHeaders: null, + }); + } catch (error) { + thrown = error; + } + expect(thrown).toBeNull(); + expect(getRuntimeApiBaseUrl()).toBe('https://remote.example'); + } finally { + globalThis.fetch = previousFetch; + clearRuntimeUrlAuthToken(); + setRuntimeExtraHeaders(null); + if (previousWindow) { + Object.defineProperty(globalThis, 'window', previousWindow); + } else { + Reflect.deleteProperty(globalThis, 'window'); + } + } + }); +}); diff --git a/packages/ui/src/lib/runtime-switch.ts b/packages/ui/src/lib/runtime-switch.ts index 6cb02dfd..b8cfdada 100644 --- a/packages/ui/src/lib/runtime-switch.ts +++ b/packages/ui/src/lib/runtime-switch.ts @@ -13,6 +13,23 @@ const RUNTIME_ENDPOINT_CHANGED_EVENT = 'openchamber:runtime-endpoint-changed'; let activeApiBaseUrl = ''; let activeRuntimeKey = ''; +const setWindowRuntimeValue = ( + runtimeWindow: typeof window & { + __OPENCHAMBER_API_BASE_URL__?: string; + __OPENCHAMBER_CLIENT_TOKEN__?: string; + __OPENCHAMBER_RUNTIME_HEADERS__?: Record; + }, + key: K, + value: (typeof runtimeWindow)[K], +): void => { + try { + runtimeWindow[key] = value; + } catch { + // Electron preload exposes some initial globals through contextBridge, which + // makes them read-only. Runtime switching must still update in-memory state. + } +}; + const normalizeRuntimeUrlKey = (value: string): string => { try { const url = new URL(value); @@ -81,9 +98,9 @@ export const switchRuntimeEndpoint = (options: { apiBaseUrl: string; clientToken __OPENCHAMBER_CLIENT_TOKEN__?: string; __OPENCHAMBER_RUNTIME_HEADERS__?: Record; }; - runtimeWindow.__OPENCHAMBER_API_BASE_URL__ = apiBaseUrl; - runtimeWindow.__OPENCHAMBER_CLIENT_TOKEN__ = options.clientToken || undefined; - runtimeWindow.__OPENCHAMBER_RUNTIME_HEADERS__ = options.requestHeaders || undefined; + setWindowRuntimeValue(runtimeWindow, '__OPENCHAMBER_API_BASE_URL__', apiBaseUrl); + setWindowRuntimeValue(runtimeWindow, '__OPENCHAMBER_CLIENT_TOKEN__', options.clientToken || undefined); + setWindowRuntimeValue(runtimeWindow, '__OPENCHAMBER_RUNTIME_HEADERS__', options.requestHeaders || undefined); } configureRuntimeUrlResolver({ apiBaseUrl, realtimeBaseUrl: apiBaseUrl }); setRuntimeExtraHeaders(options.requestHeaders || null); diff --git a/packages/web/src/runtimeConfig.ts b/packages/web/src/runtimeConfig.ts index de16d298..7622e6d4 100644 --- a/packages/web/src/runtimeConfig.ts +++ b/packages/web/src/runtimeConfig.ts @@ -1,4 +1,4 @@ -import { refreshLocalRuntimeUrlAuthToken, refreshRuntimeUrlAuthToken, setRuntimeBearerToken, setRuntimeExtraHeaders } from '@openchamber/ui/lib/runtime-auth'; +import { getRuntimeExtraHeadersSync, refreshLocalRuntimeUrlAuthToken, refreshRuntimeUrlAuthToken, setRuntimeBearerToken, setRuntimeExtraHeaders } from '@openchamber/ui/lib/runtime-auth'; import { installRuntimeFetchBridge } from '@openchamber/ui/lib/runtime-fetch'; import { initializeRuntimeEndpoint } from '@openchamber/ui/lib/runtime-switch'; import { configureRuntimeUrlResolver } from '@openchamber/ui/lib/runtime-url'; @@ -44,7 +44,7 @@ export const createConfiguredWebAPIs = () => { setRuntimeBearerToken(clientToken || null); setRuntimeExtraHeaders(window.__OPENCHAMBER_RUNTIME_HEADERS__ || null); void refreshRuntimeUrlAuthToken(apiBaseUrl || undefined).catch(() => {}); - if (localOrigin && !sameOrigin(apiBaseUrl, localOrigin)) { + if (localOrigin && !sameOrigin(apiBaseUrl, localOrigin) && Object.keys(getRuntimeExtraHeadersSync()).length > 0) { void refreshLocalRuntimeUrlAuthToken(localOrigin).catch(() => {}); } installRuntimeFetchBridge(); From a5e4fb3b214aed198196fc19bef2f4931639e786 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 30 Jun 2026 02:56:33 +0300 Subject: [PATCH 0059/1073] chore: update changelog for desktop remote instances --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba7637c2..73b75e4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- Desktop: remote instances can now save additional request headers for proxy-auth setups such as Cloudflare Access, including for live updates and terminal streams. +- Desktop: SSH remote instances with a saved UI password no longer ask for that UI password again after the tunnel connects. + ## [1.13.8] - 2026-06-29 - Startup: launching the app no longer hangs for around 20 seconds before you can open a session, load a diff, or send a message — GitHub pull request status checks no longer tie up the connection to the server during startup. From 9e6d0df9424d99e2ba7363c2a37c48892cb3ba97 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 30 Jun 2026 00:16:10 +0300 Subject: [PATCH 0060/1073] fix(chat): hold bottom on first session open while late async content lands On the first open of a session, late async data (most visibly a task/subagent tool whose nested rows are fetched from the child session after entry) grew the timeline a beat after the one-shot entry pin, stranding the viewport mid-history. The steady-state idle gate intentionally ignores that growth, so re-pinning could not recover it. Add a short, gesture-cancellable entry-stick window that forces the bottom on every growth until content quiesces (or the user scrolls), covering both the ResizeObserver and the structural notifyContentChange path. --- packages/ui/src/hooks/useChatAutoFollow.ts | 98 ++++++++++++++++++++-- 1 file changed, 93 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/hooks/useChatAutoFollow.ts b/packages/ui/src/hooks/useChatAutoFollow.ts index f8b543b6..0ef2fdc3 100644 --- a/packages/ui/src/hooks/useChatAutoFollow.ts +++ b/packages/ui/src/hooks/useChatAutoFollow.ts @@ -79,6 +79,17 @@ const AUTO_MATCH_TOLERANCE_PX = 2; // After streaming stops, keep following the bottom for a short window so the // final content can settle into place. const SETTLE_MS = 300; +// Entry-stick window. On the FIRST open of a session, late async data (most +// visibly a task/subagent tool whose nested rows are fetched from the child +// session after entry — see useEnsureSessionMessages in ToolPart.tsx) grows the +// timeline a beat or two AFTER we have already pinned to the bottom, leaving the +// viewport stranded mid-history. The steady-state idle gate deliberately ignores +// that growth (it can't tell entry from a user reading idle history). So instead +// of weakening the gate, we open a short, gesture-cancellable window on entry +// during which we FORCE the bottom on every growth. It ends QUIESCENCE_MS after +// growth stops (capped by MAX_MS), or instantly on any real user scroll gesture. +const ENTRY_STICK_QUIESCENCE_MS = 600; +const ENTRY_STICK_MAX_MS = 8000; const now = (): number => (typeof performance !== 'undefined' ? performance.now() : Date.now()); @@ -173,6 +184,12 @@ export const useChatAutoFollow = ({ const autoRef = React.useRef<{ top: number; time: number } | null>(null); const autoTimerRef = React.useRef | null>(null); + // Entry-stick window state (see ENTRY_STICK_* above). + const entryStickRef = React.useRef(false); + const entryStickQuietTimerRef = React.useRef | null>(null); + const entryStickCapTimerRef = React.useRef | null>(null); + const entryStickLastHeightRef = React.useRef(0); + const saveTimerRef = React.useRef | null>(null); const pendingSaveRef = React.useRef<{ sessionId: string; anchor: number } | null>(null); // When restoreSnapshot is invoked while ChatViewport is still hydrating @@ -234,6 +251,48 @@ export const useChatAutoFollow = ({ return Math.abs(el.scrollTop - a.top) < AUTO_MATCH_TOLERANCE_PX; }, []); + // ── entry-stick window ─────────────────────────────────────────────────── + const endEntryStick = React.useCallback(() => { + entryStickRef.current = false; + if (entryStickQuietTimerRef.current) { + clearTimeout(entryStickQuietTimerRef.current); + entryStickQuietTimerRef.current = null; + } + if (entryStickCapTimerRef.current) { + clearTimeout(entryStickCapTimerRef.current); + entryStickCapTimerRef.current = null; + } + }, []); + + // (Re)arm the quiescence timer: the window closes this long after the last + // growth. Called once on begin and again on every growth-driven re-pin. + const armEntryStickQuiet = React.useCallback(() => { + if (entryStickQuietTimerRef.current) { + clearTimeout(entryStickQuietTimerRef.current); + } + entryStickQuietTimerRef.current = setTimeout(() => { + entryStickQuietTimerRef.current = null; + endEntryStick(); + }, ENTRY_STICK_QUIESCENCE_MS); + }, [endEntryStick]); + + const beginEntryStick = React.useCallback(() => { + const el = scrollRef.current; + if (!el) return; + entryStickRef.current = true; + entryStickLastHeightRef.current = el.scrollHeight; + armEntryStickQuiet(); + // Reset the absolute cap fresh on every entry (e.g. session switch) so a + // stale cap from a previous open can't cut this window short. + if (entryStickCapTimerRef.current) { + clearTimeout(entryStickCapTimerRef.current); + } + entryStickCapTimerRef.current = setTimeout(() => { + entryStickCapTimerRef.current = null; + endEntryStick(); + }, ENTRY_STICK_MAX_MS); + }, [armEntryStickQuiet, endEntryStick]); + // ── overflow / scroll-to-bottom button ────────────────────────────────── const updateOverflowAndButton = React.useCallback(() => { const container = scrollRef.current; @@ -322,8 +381,11 @@ export const useChatAutoFollow = ({ }, [setStateValue, updateOverflowAndButton]); const releaseFromUserIntent = React.useCallback(() => { + // A genuine user gesture (wheel/touch/key/scrollbar) cancels the entry + // window immediately so we never fight the user's read position. + endEntryStick(); stop(); - }, [stop]); + }, [endEntryStick, stop]); // ── per-session snapshot persistence (kept; restore still goes to bottom) ─ const flushSave = React.useCallback(() => { @@ -389,9 +451,13 @@ export const useChatAutoFollow = ({ // history measures in, so there is no smooth scroll-from-mid artifact. setStateValue('following'); scrollToBottom(true); + // Hold the bottom across late async growth (e.g. task/subagent child + // session data landing a beat after entry) until content quiesces or the + // user scrolls. + beginEntryStick(); updateOverflowAndButton(); return false; - }, [scrollToBottom, setStateValue, updateOverflowAndButton]); + }, [beginEntryStick, scrollToBottom, setStateValue, updateOverflowAndButton]); // ── session change ─────────────────────────────────────────────────────── React.useEffect(() => { @@ -570,6 +636,18 @@ export const useChatAutoFollow = ({ return; } updateOverflowAndButton(); + // Entry-stick window: on first session open, FORCE the bottom on + // every growth so late async data (task/subagent child rows, code + // highlight, mermaid) can't strand the viewport mid-history. Force + // overrides any false `released` from the growth itself; only a real + // user gesture clears the window (releaseFromUserIntent). + if (entryStickRef.current && el) { + const grew = el.scrollHeight > entryStickLastHeightRef.current + 1; + entryStickLastHeightRef.current = el.scrollHeight; + scrollToBottom(true); + if (grew) armEntryStickQuiet(); + return; + } // Idle resize = layout churn (virtualizer re-measurement, async // tool/code rendering), NOT live growth. Never re-pin when idle, or // tall items re-measuring as the user scrolls cause an endless @@ -584,7 +662,7 @@ export const useChatAutoFollow = ({ observer.observe(inner); } return () => observer.disconnect(); - }, [containerEl, isActive, scrollToBottom, setStateValue, updateOverflowAndButton]); + }, [armEntryStickQuiet, containerEl, isActive, scrollToBottom, setStateValue, updateOverflowAndButton]); React.useEffect(() => { updateOverflowAndButton(); @@ -593,10 +671,19 @@ export const useChatAutoFollow = ({ const notifyContentChange = React.useCallback((_reason?: ContentChangeReason) => { void _reason; updateOverflowAndButton(); + // Entry-stick window: late structural growth (notably the task/subagent + // summary landing from the child session — ToolPart emits 'structural' + // here) must keep us pinned and refresh the quiescence timer, even though + // the session is idle. + if (entryStickRef.current) { + scrollToBottom(true); + armEntryStickQuiet(); + return; + } if (stateRef.current === 'following') { scrollToBottom(false); } - }, [scrollToBottom, updateOverflowAndButton]); + }, [armEntryStickQuiet, scrollToBottom, updateOverflowAndButton]); const animationHandlersRef = React.useRef>(new Map()); @@ -635,13 +722,14 @@ export const useChatAutoFollow = ({ clearTimeout(settleTimerRef.current); settleTimerRef.current = null; } + endEntryStick(); flushSave(); if (saveTimerRef.current !== null) { clearTimeout(saveTimerRef.current); saveTimerRef.current = null; } }; - }, [flushSave]); + }, [endEntryStick, flushSave]); React.useEffect(() => { if (!onActiveTurnChange) return; From ea34ca4b92d89c7703fa117253c2fe3393433a7c Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 30 Jun 2026 00:20:55 +0300 Subject: [PATCH 0061/1073] fix(chat): disable markdown file-reference probing on mobile The file-reference annotation pass issues filesystem stat probes (fileReferenceExists -> /api/fs/stat) to decide which inline-code/link tokens become openable file links. On mobile surfaces this feature is disabled entirely: gate the annotation effect on !isMobileSurfaceRuntime() so the pass short-circuits before scheduling, guaranteeing no probe requests are ever sent from a mobile runtime. --- .../ui/src/components/chat/MarkdownRendererImpl.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx index b050fd25..5b5252f2 100644 --- a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx @@ -16,6 +16,7 @@ import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import type { EditorAPI } from '@/lib/api/types'; import { isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime } from '@/lib/desktop'; +import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface'; import { ensureOutsideFileGrantForDesktop } from '@/lib/outsideFileGrants'; import { getDirectoryForFilePath, isAbsoluteFilePath, isFilePathWithinDirectory, normalizeFilePath, toAbsoluteFilePath } from '@/lib/path-utils'; import { renderMarkdownBlocks, renderMarkdownSync } from './markdown/markdownCore'; @@ -570,6 +571,11 @@ const useFileReferenceInteractions = ({ } let cancelled = false; const fileReferenceLinkLimit = getFileReferenceLinkLimit(); + // On mobile surfaces, file-reference highlighting is disabled entirely — not + // just visually. The annotation pass is what issues the filesystem `stat` + // probes (fileReferenceExists → /api/fs/stat), so skipping it here guarantees + // no probe requests are ever sent from a mobile runtime. + const fileReferencesEnabled = enabled && !isMobileSurfaceRuntime(); const clearFileLinkAttributes = (candidate: HTMLElement) => { candidate.removeAttribute('data-openchamber-file-link'); @@ -592,7 +598,7 @@ const useFileReferenceInteractions = ({ unwrapBlockCodePathTokens(container); }; - if (!enabled) { + if (!fileReferencesEnabled) { clearAnnotatedFileLinks(); return; } @@ -616,7 +622,7 @@ const useFileReferenceInteractions = ({ }; const annotateFileLinks = () => { - if (enabled) { + if (fileReferencesEnabled) { wrapBlockCodePathTokens(container); } const candidates = container.querySelectorAll( From 088a70fe5a9742dd0902c65743511b3eb77724e3 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 30 Jun 2026 03:05:15 +0300 Subject: [PATCH 0062/1073] fix(chat): stop Thinking stream from fighting chat scroll Render a streaming Thinking block inline instead of inside a capped, independently-scrollable max-height box (the cap now applies only to finished thinking, for compact review). The nested scroll box was capturing the wheel and auto-pinning to its own bottom, so the chat could not be scrolled while thinking streamed. With it gone the chat's own auto-follow owns the scroll. Two auto-follow refinements make that solid: - Direction-aware bottom-zone re-engage: scrolling UP into the bottom spacer zone no longer re-arms follow (which the next growth would yank back). Follow resumes only when the user arrives at the bottom by scrolling down, is already following, or is at the true bottom. Kills the dead-zone fight near the bottom. - Animation guard: while a Thinking block COLLAPSE animation runs, transient geometry / trailing async scroll events are treated as our own and never trigger a false release. Genuine user gestures still release instantly. --- .../chat/message/parts/ReasoningPart.tsx | 84 ++++++++++++------- packages/ui/src/hooks/useChatAutoFollow.ts | 63 ++++++++++++-- 2 files changed, 106 insertions(+), 41 deletions(-) diff --git a/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx b/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx index ee0443f3..59e1fc5b 100644 --- a/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx @@ -118,10 +118,14 @@ export const ReasoningTimelineBlock: React.FC = ({ : expansion.expanded; const [shouldRenderExpandedContent, setShouldRenderExpandedContent] = React.useState(defaultExpanded === true || canAutoExpand); const contentId = React.useId(); - const scrollRef = React.useRef(null); const contentRef = React.useRef(null); const contentAnimationRef = React.useRef(null); const contentMountedRef = React.useRef(false); + // Stable handle to onContentChange so the height-animation layout effect can + // signal auto-follow without taking onContentChange as a dependency (which + // would risk re-running — and thus restarting — the animation on re-render). + const onContentChangeRef = React.useRef(onContentChange); + onContentChangeRef.current = onContentChange; const summary = React.useMemo(() => getReasoningSummary(text), [text]); const toggleAriaLabel = isExpanded @@ -160,12 +164,6 @@ export const ReasoningTimelineBlock: React.FC = ({ onContentChange?.('structural'); }, [onContentChange, text]); - React.useEffect(() => { - if (isStreaming && isExpanded && scrollRef.current) { - scrollRef.current.scrollTop = scrollRef.current.scrollHeight; - } - }, [text, isStreaming, isExpanded]); - React.useEffect(() => { if (isExpanded || isStreaming) { setShouldRenderExpandedContent(true); @@ -239,6 +237,11 @@ export const ReasoningTimelineBlock: React.FC = ({ element.style.height = '0px'; } else { element.style.height = `${element.scrollHeight}px`; + // Only the COLLAPSE animation needs the guard: it shrinks the + // timeline and the trailing async scroll events can be misread as a + // user scroll-away. Expansion grows the timeline and re-pins cleanly, + // and guarding it caused a faint scroll fight while thinking streams. + onContentChangeRef.current?.('animation'); } const animation = animate( @@ -280,6 +283,27 @@ export const ReasoningTimelineBlock: React.FC = ({ return null; } + const reasoningBody = ( + <> +
+ +
+ {actions ? ( +
+
+ {actions} +
+
+ ) : null} + + ); + return (
= ({ className="pointer-events-none absolute left-0 top-0 bottom-0 w-px" style={{ backgroundColor: 'var(--tools-border)' }} /> - -
- + {isStreaming ? ( + // While streaming, let the thinking grow inline — no + // capped, independently-scrollable box. The chat's own + // auto-follow then handles following / releasing, so the + // box never captures the wheel or fights the user's + // scroll. The max-height scroll box is applied only once + // the thinking has finished (the branch below). +
+ {reasoningBody}
- {actions ? ( -
-
- {actions} -
-
- ) : null} - + ) : ( + + {reasoningBody} + + )}
) : null} diff --git a/packages/ui/src/hooks/useChatAutoFollow.ts b/packages/ui/src/hooks/useChatAutoFollow.ts index 0ef2fdc3..f8730530 100644 --- a/packages/ui/src/hooks/useChatAutoFollow.ts +++ b/packages/ui/src/hooks/useChatAutoFollow.ts @@ -6,7 +6,7 @@ import { useViewportStore } from '@/sync/viewport-store'; type AutoFollowState = 'following' | 'released'; -export type ContentChangeReason = 'text' | 'structural' | 'permission'; +export type ContentChangeReason = 'text' | 'structural' | 'permission' | 'animation'; export interface AnimationHandlers { onChunk: () => void; @@ -76,6 +76,17 @@ const TOUCH_FINGER_DOWN_THRESHOLD = 2; // a user scroll. const AUTO_MARK_TTL_MS = 1500; const AUTO_MATCH_TOLERANCE_PX = 2; +// While a tracked height animation runs (e.g. a Thinking block auto-collapsing +// mid-stream), the timeline shrinks/grows over a couple hundred ms and the +// virtualizer re-measures, producing transient geometry. Browsers dispatch the +// resulting `scroll` events asynchronously, so a stale event can land after we +// have already re-pinned — its position matching neither the bottom zone nor the +// freshly-moved auto marker — and be misread as a user scroll-away. During this +// guard window we treat any `following`-state scroll event as our own and never +// release via the heuristic. GENUINE user gestures still release instantly +// through releaseFromUserIntent, so this is not glue. Sized to the reasoning +// animation (200ms) plus headroom for trailing async scroll events. +const ANIMATION_GUARD_MS = 350; // After streaming stops, keep following the bottom for a short window so the // final content can settle into place. const SETTLE_MS = 300; @@ -184,6 +195,15 @@ export const useChatAutoFollow = ({ const autoRef = React.useRef<{ top: number; time: number } | null>(null); const autoTimerRef = React.useRef | null>(null); + // Timestamp until which a tracked height animation is in flight (see + // ANIMATION_GUARD_MS). 0 = no animation guard active. + const animationGuardUntilRef = React.useRef(0); + + // Last observed scrollTop, used to derive scroll DIRECTION in the scroll + // handler so the bottom-zone re-engage only fires when arriving at the bottom + // by scrolling down — never when a user scrolling UP merely lands in the zone. + const lastScrollTopRef = React.useRef(0); + // Entry-stick window state (see ENTRY_STICK_* above). const entryStickRef = React.useRef(false); const entryStickQuietTimerRef = React.useRef | null>(null); @@ -251,6 +271,10 @@ export const useChatAutoFollow = ({ return Math.abs(el.scrollTop - a.top) < AUTO_MATCH_TOLERANCE_PX; }, []); + const isAnimationGuardActive = React.useCallback((): boolean => { + return now() < animationGuardUntilRef.current; + }, []); + // ── entry-stick window ─────────────────────────────────────────────────── const endEntryStick = React.useCallback(() => { entryStickRef.current = false; @@ -522,6 +546,10 @@ export const useChatAutoFollow = ({ const el = scrollRef.current; if (!el) return; + const previousTop = lastScrollTopRef.current; + lastScrollTopRef.current = el.scrollTop; + const scrollingDown = el.scrollTop > previousTop + 0.5; + updateOverflowAndButton(); if (!canScroll(el)) { @@ -530,16 +558,25 @@ export const useChatAutoFollow = ({ } // Within the bottom zone → (re-)pin to following. This is how scrolling - // back down to the bottom resumes auto-follow. + // back DOWN to the bottom resumes auto-follow. Crucially, re-engage only + // when the user arrives by scrolling down (or is already following, or is + // essentially at the true bottom). A user scrolling UP that merely lands + // in the bottom spacer zone must NOT be yanked back into follow — that is + // the dead-zone fight that made small upward scrolls impossible while + // content streams. if (isNearBottom(el, isMobileRef.current)) { - setStateValue('following'); + const atTrueBottom = distanceFromBottom(el) <= AUTO_MATCH_TOLERANCE_PX; + if (scrollingDown || stateRef.current === 'following' || atTrueBottom) { + setStateValue('following'); + } queueSave(); return; } - // Our own programmatic write that landed at the bottom but where content - // grew between the write and this event — keep following, don't release. - if (stateRef.current === 'following' && isAuto(el)) { + // Our own geometry change (a programmatic write that landed at the bottom + // but where content grew between the write and this event, OR a tracked + // height animation in flight) — keep following, don't release. + if (stateRef.current === 'following' && (isAuto(el) || isAnimationGuardActive())) { scrollToBottom(false); queueSave(); return; @@ -548,12 +585,14 @@ export const useChatAutoFollow = ({ // Genuine user scroll away from the bottom. stop(); queueSave(); - }, [isAuto, queueSave, scrollToBottom, setStateValue, stop, updateOverflowAndButton]); + }, [isAnimationGuardActive, isAuto, queueSave, scrollToBottom, setStateValue, stop, updateOverflowAndButton]); React.useEffect(() => { const container = containerEl; if (!container) return; + lastScrollTopRef.current = container.scrollTop; + const handleWheel = (event: WheelEvent) => { if (event.deltaY >= 0) return; if (nestedScrollableCanConsumeUp(container, event.target)) return; @@ -668,8 +707,14 @@ export const useChatAutoFollow = ({ updateOverflowAndButton(); }, [sessionMessageCount, updateOverflowAndButton]); - const notifyContentChange = React.useCallback((_reason?: ContentChangeReason) => { - void _reason; + const notifyContentChange = React.useCallback((reason?: ContentChangeReason) => { + // A tracked height animation (e.g. Thinking auto-collapse) opens a guard + // window so its transient geometry / async scroll events are not misread + // as a user scroll-away. Real gestures still release through + // releaseFromUserIntent, so the user can always scroll up freely. + if (reason === 'animation') { + animationGuardUntilRef.current = now() + ANIMATION_GUARD_MS; + } updateOverflowAndButton(); // Entry-stick window: late structural growth (notably the task/subagent // summary landing from the child session — ToolPart emits 'structural' From 1505274f941c15d18df9d142c7d504e6bf654581 Mon Sep 17 00:00:00 2001 From: Tom Rochette Date: Tue, 30 Jun 2026 04:47:52 -0400 Subject: [PATCH 0063/1073] perf(stores): defer safeStorage writes off the interaction path (#1941) * perf(stores): defer safeStorage writes off the interaction path Session switches funnel every persisted store slice through safeStorage.setItem, and doing those large JSON.stringify writes synchronously blocked the main thread for over a second. Add a write-behind buffer that: - Defers each setItem/removeItem to a later task via setTimeout(0) so the click-to-paint path is not blocked. - Coalesces repeated writes to the same key into a single backing flush. - Serves pending values from memory so read-after-write stays consistent within the deferral window. - Flushes synchronously on pagehide/beforeunload/visibilitychange/freeze so deferred state survives tab close, reload, and the mobile freeze lifecycle. Adds a test covering write deferral, coalescing, and pending read serving. * fix(stores): defer persisted JSON serialization * fix(stores): defer direct safeStorage writes --------- Co-authored-by: Bohdan Triapitsyn --- .../src/components/session/SessionSidebar.tsx | 10 +- .../components/update/OpenCodeUpdateToast.tsx | 6 +- packages/ui/src/hooks/usePwaInstallPrompt.ts | 6 +- packages/ui/src/lib/directoryShowHidden.ts | 6 +- .../ui/src/lib/filesViewShowGitignored.ts | 6 +- packages/ui/src/stores/contextStore.ts | 6 +- packages/ui/src/stores/messageQueueStore.ts | 6 +- packages/ui/src/stores/permissionStore.ts | 6 +- packages/ui/src/stores/useAgentsStore.ts | 6 +- packages/ui/src/stores/useAutoReviewStore.ts | 6 +- packages/ui/src/stores/useCommandsStore.ts | 6 +- packages/ui/src/stores/useConfigStore.test.ts | 16 ++ packages/ui/src/stores/useConfigStore.ts | 6 +- packages/ui/src/stores/useDirectoryStore.ts | 4 +- .../ui/src/stores/useFilesViewTabsStore.ts | 6 +- .../ui/src/stores/useGitHubPrStatusStore.ts | 6 +- .../ui/src/stores/useGitIdentitiesStore.ts | 6 +- packages/ui/src/stores/useGitStore.ts | 6 +- .../src/stores/useInlineCommentDraftStore.ts | 6 +- packages/ui/src/stores/useMcpConfigStore.ts | 6 +- packages/ui/src/stores/usePluginsStore.ts | 6 +- packages/ui/src/stores/useProjectsStore.ts | 4 +- .../src/stores/useSessionFoldersStore.test.ts | 1 + .../ui/src/stores/useSessionFoldersStore.ts | 4 +- .../ui/src/stores/useSessionPinnedStore.ts | 4 +- packages/ui/src/stores/useSkillsStore.ts | 6 +- .../ui/src/stores/useTodosPersistStore.ts | 6 +- packages/ui/src/stores/useUIStore.ts | 6 +- .../ui/src/stores/utils/safeStorage.test.ts | 113 ++++++++++ packages/ui/src/stores/utils/safeStorage.ts | 193 ++++++++++++++++++ packages/ui/src/sync/selection-store.ts | 6 +- packages/ui/src/sync/session-ui-store.ts | 8 +- 32 files changed, 406 insertions(+), 83 deletions(-) diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index eefb1666..af841951 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -13,7 +13,7 @@ import { useSync } from '@/sync/use-sync'; import { useSessionPrefetch } from './sidebar/hooks/useSessionPrefetch'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useUIStore } from '@/stores/useUIStore'; -import { getSafeStorage } from '@/stores/utils/safeStorage'; +import { getDeferredSafeStorage } from '@/stores/utils/safeStorage'; import { useGitStore, useGitAllBranches, useGitRepoStatusMap } from '@/stores/useGitStore'; import { isVSCodeRuntime } from '@/lib/desktop'; import { NewWorktreeDialog } from './NewWorktreeDialog'; @@ -188,7 +188,7 @@ export const SessionSidebar: React.FC = ({ const [editTitle, setEditTitle] = React.useState(''); const [editingProjectDialogId, setEditingProjectDialogId] = React.useState(null); const [expandedParents, setExpandedParents] = React.useState>(new Set()); - const safeStorage = React.useMemo(() => getSafeStorage(), []); + const safeStorage = React.useMemo(() => getDeferredSafeStorage(), []); const [collapsedProjects, setCollapsedProjects] = React.useState>(new Set()); const [projectRepoStatus, setProjectRepoStatus] = React.useState>(new Map()); @@ -207,7 +207,7 @@ export const SessionSidebar: React.FC = ({ const togglePinnedSession = useSessionPinnedStore((state) => state.toggle); const [collapsedGroups, setCollapsedGroups] = React.useState>(() => { try { - const raw = getSafeStorage().getItem(GROUP_COLLAPSE_STORAGE_KEY); + const raw = getDeferredSafeStorage().getItem(GROUP_COLLAPSE_STORAGE_KEY); if (!raw) { return new Set(); } @@ -219,7 +219,7 @@ export const SessionSidebar: React.FC = ({ }); const [groupOrderByProject, setGroupOrderByProject] = React.useState>(() => { try { - const raw = getSafeStorage().getItem(GROUP_ORDER_STORAGE_KEY); + const raw = getDeferredSafeStorage().getItem(GROUP_ORDER_STORAGE_KEY); if (!raw) { return new Map(); } @@ -237,7 +237,7 @@ export const SessionSidebar: React.FC = ({ }); const [activeSessionByProject, setActiveSessionByProject] = React.useState>(() => { try { - const raw = getSafeStorage().getItem(PROJECT_ACTIVE_SESSION_STORAGE_KEY); + const raw = getDeferredSafeStorage().getItem(PROJECT_ACTIVE_SESSION_STORAGE_KEY); if (!raw) { return new Map(); } diff --git a/packages/ui/src/components/update/OpenCodeUpdateToast.tsx b/packages/ui/src/components/update/OpenCodeUpdateToast.tsx index 7a4a321c..8d1a3732 100644 --- a/packages/ui/src/components/update/OpenCodeUpdateToast.tsx +++ b/packages/ui/src/components/update/OpenCodeUpdateToast.tsx @@ -6,7 +6,7 @@ import { useUIStore } from '@/stores/useUIStore'; import { useI18n } from '@/lib/i18n'; import { runtimeFetch } from '@/lib/runtime-fetch'; import { updateDesktopSettings } from '@/lib/persistence'; -import { getSafeStorage } from '@/stores/utils/safeStorage'; +import { getDeferredSafeStorage } from '@/stores/utils/safeStorage'; import { resolveOpenCodeUpdateVersion, resolveOpenCodeUpgradeStatusVersion, @@ -100,7 +100,7 @@ export const OpenCodeUpdateToast: React.FC = () => { } const decision = shouldShowOpenCodeUpdateToast({ version, - dismissedVersion: getSafeStorage().getItem(UPDATE_TOAST_DISMISSED_VERSION_KEY), + dismissedVersion: getDeferredSafeStorage().getItem(UPDATE_TOAST_DISMISSED_VERSION_KEY), seenVersions: seenVersionsRef.current, }); if (!decision) { @@ -119,7 +119,7 @@ export const OpenCodeUpdateToast: React.FC = () => { cancel: { label: t('opencodeUpdate.toast.actions.dismiss'), onClick: () => { - getSafeStorage().setItem(UPDATE_TOAST_DISMISSED_VERSION_KEY, version); + getDeferredSafeStorage().setItem(UPDATE_TOAST_DISMISSED_VERSION_KEY, version); void updateDesktopSettings({ openCodeUpdateToastDismissedVersion: version }); toast.dismiss(UPDATE_TOAST_ID); }, diff --git a/packages/ui/src/hooks/usePwaInstallPrompt.ts b/packages/ui/src/hooks/usePwaInstallPrompt.ts index 7f2f829c..5c424cb1 100644 --- a/packages/ui/src/hooks/usePwaInstallPrompt.ts +++ b/packages/ui/src/hooks/usePwaInstallPrompt.ts @@ -3,7 +3,7 @@ import { toast } from '@/components/ui'; import { isWebRuntime } from '@/lib/desktop'; import { usePwaDetection } from '@/hooks/usePwaDetection'; import { useI18n } from '@/lib/i18n'; -import { getSafeSessionStorage, getSafeStorage } from '@/stores/utils/safeStorage'; +import { getDeferredSafeStorage, getSafeSessionStorage } from '@/stores/utils/safeStorage'; import { shouldShowPwaInstallToast } from '@/components/update/openCodeUpdateDedup'; type InstallPromptOutcome = 'accepted' | 'dismissed'; @@ -66,7 +66,7 @@ export const usePwaInstallPrompt = () => { installEvent.preventDefault(); deferredPrompt = installEvent; - const localStorage = getSafeStorage(); + const localStorage = getDeferredSafeStorage(); const sessionStorage = getSafeSessionStorage(); const decision = shouldShowPwaInstallToast({ dismissed: localStorage.getItem(INSTALL_TOAST_DISMISSED_KEY), @@ -90,7 +90,7 @@ export const usePwaInstallPrompt = () => { cancel: { label: tRef.current('pwa.installPrompt.dismiss'), onClick: () => { - getSafeStorage().setItem(INSTALL_TOAST_DISMISSED_KEY, 'true'); + getDeferredSafeStorage().setItem(INSTALL_TOAST_DISMISSED_KEY, 'true'); dismissInstallToast(); }, }, diff --git a/packages/ui/src/lib/directoryShowHidden.ts b/packages/ui/src/lib/directoryShowHidden.ts index 47b9c116..0282b1b8 100644 --- a/packages/ui/src/lib/directoryShowHidden.ts +++ b/packages/ui/src/lib/directoryShowHidden.ts @@ -1,5 +1,5 @@ import React from 'react'; -import { getSafeStorage } from '@/stores/utils/safeStorage'; +import { getDeferredSafeStorage } from '@/stores/utils/safeStorage'; import { updateDesktopSettings } from '@/lib/persistence'; const SHOW_HIDDEN_STORAGE_KEY = 'directoryTreeShowHidden'; @@ -10,7 +10,7 @@ const readStoredShowHidden = (): boolean => { return true; } try { - const stored = getSafeStorage().getItem(SHOW_HIDDEN_STORAGE_KEY); + const stored = getDeferredSafeStorage().getItem(SHOW_HIDDEN_STORAGE_KEY); if (stored === null) { return true; } @@ -35,7 +35,7 @@ export const setDirectoryShowHidden = ( return; } try { - getSafeStorage().setItem(SHOW_HIDDEN_STORAGE_KEY, value ? 'true' : 'false'); + getDeferredSafeStorage().setItem(SHOW_HIDDEN_STORAGE_KEY, value ? 'true' : 'false'); notifyDirectoryShowHiddenChanged(); } catch { // ignore storage errors diff --git a/packages/ui/src/lib/filesViewShowGitignored.ts b/packages/ui/src/lib/filesViewShowGitignored.ts index 416d667e..210e517f 100644 --- a/packages/ui/src/lib/filesViewShowGitignored.ts +++ b/packages/ui/src/lib/filesViewShowGitignored.ts @@ -1,6 +1,6 @@ import React from 'react'; -import { getSafeStorage } from '@/stores/utils/safeStorage'; +import { getDeferredSafeStorage } from '@/stores/utils/safeStorage'; import { updateDesktopSettings } from '@/lib/persistence'; const SHOW_GITIGNORED_STORAGE_KEY = 'filesViewShowGitignored'; @@ -11,7 +11,7 @@ const readStoredShowGitignored = (): boolean => { return false; } try { - const stored = getSafeStorage().getItem(SHOW_GITIGNORED_STORAGE_KEY); + const stored = getDeferredSafeStorage().getItem(SHOW_GITIGNORED_STORAGE_KEY); return stored === 'true'; } catch { return false; @@ -33,7 +33,7 @@ export const setFilesViewShowGitignored = ( return; } try { - getSafeStorage().setItem(SHOW_GITIGNORED_STORAGE_KEY, value ? 'true' : 'false'); + getDeferredSafeStorage().setItem(SHOW_GITIGNORED_STORAGE_KEY, value ? 'true' : 'false'); notifyFilesViewShowGitignoredChanged(); } catch { // ignore storage errors diff --git a/packages/ui/src/stores/contextStore.ts b/packages/ui/src/stores/contextStore.ts index 09c37383..ad824f15 100644 --- a/packages/ui/src/stores/contextStore.ts +++ b/packages/ui/src/stores/contextStore.ts @@ -1,11 +1,11 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { create } from "zustand"; -import { devtools, persist, createJSONStorage } from "zustand/middleware"; +import { devtools, persist } from "zustand/middleware"; import type { EditPermissionMode } from "./types/sessionTypes"; import { getAgentDefaultEditPermission } from "./utils/permissionUtils"; import { extractTokensFromMessage } from "./utils/tokenUtils"; import { calculateContextUsage } from "./utils/contextUtils"; -import { getSafeStorage } from "./utils/safeStorage"; +import { createDeferredSafeJSONStorage } from "./utils/safeStorage"; interface ContextUsage { totalTokens: number; @@ -449,7 +449,7 @@ export const useContextStore = create()( }), { name: "context-store", - storage: createJSONStorage(() => getSafeStorage()), + storage: createDeferredSafeJSONStorage(), partialize: (state) => ({ sessionModelSelections: Array.from(state.sessionModelSelections.entries()), sessionAgentSelections: Array.from(state.sessionAgentSelections.entries()), diff --git a/packages/ui/src/stores/messageQueueStore.ts b/packages/ui/src/stores/messageQueueStore.ts index d27862a6..ae964feb 100644 --- a/packages/ui/src/stores/messageQueueStore.ts +++ b/packages/ui/src/stores/messageQueueStore.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; -import { devtools, persist, createJSONStorage } from 'zustand/middleware'; -import { getSafeStorage } from './utils/safeStorage'; +import { devtools, persist } from 'zustand/middleware'; +import { createDeferredSafeJSONStorage } from './utils/safeStorage'; import type { AttachedFile } from './types/sessionTypes'; import { updateDesktopSettings } from '@/lib/persistence'; @@ -201,7 +201,7 @@ export const useMessageQueueStore = create()( { name: 'message-queue-store', version: 1, - storage: createJSONStorage(() => getSafeStorage()), + storage: createDeferredSafeJSONStorage(), partialize: (state) => ({ queuedMessages: state.queuedMessages, followUpBehavior: state.followUpBehavior, diff --git a/packages/ui/src/stores/permissionStore.ts b/packages/ui/src/stores/permissionStore.ts index 6f54c5e2..6ac01416 100644 --- a/packages/ui/src/stores/permissionStore.ts +++ b/packages/ui/src/stores/permissionStore.ts @@ -1,11 +1,11 @@ import { create } from "zustand"; -import { devtools, persist, createJSONStorage } from "zustand/middleware"; +import { devtools, persist } from "zustand/middleware"; import type { Session } from "@opencode-ai/sdk/v2/client"; import { autoRespondsPermission, type PermissionAutoAcceptMap, } from "./utils/permissionAutoAccept"; -import { getSafeStorage } from "./utils/safeStorage"; +import { createDeferredSafeJSONStorage } from "./utils/safeStorage"; import { getAllSyncSessions, getSyncChildStores } from "@/sync/sync-refs"; import { opencodeClient } from "@/lib/opencode/client"; import { respondToPermission } from "@/sync/session-actions"; @@ -201,7 +201,7 @@ const autoRespondsPermissionBySession = ( }); }; -const getStorage = () => createJSONStorage(() => getSafeStorage()); +const getStorage = () => createDeferredSafeJSONStorage(); export const usePermissionStore = create()( devtools( diff --git a/packages/ui/src/stores/useAgentsStore.ts b/packages/ui/src/stores/useAgentsStore.ts index d9bd2077..0b9cfef9 100644 --- a/packages/ui/src/stores/useAgentsStore.ts +++ b/packages/ui/src/stores/useAgentsStore.ts @@ -1,6 +1,6 @@ import { create } from "zustand"; import type { StoreApi, UseBoundStore } from "zustand"; -import { devtools, persist, createJSONStorage } from "zustand/middleware"; +import { devtools, persist } from "zustand/middleware"; import type { Agent, PermissionConfig } from "@opencode-ai/sdk/v2"; import { opencodeClient } from "@/lib/opencode/client"; import { emitConfigChange, scopeMatches, subscribeToConfigChanges, type ConfigChangeScope } from "@/lib/configSync"; @@ -9,7 +9,7 @@ import { finishConfigUpdate, updateConfigUpdateMessage, } from "@/lib/configUpdate"; -import { getSafeStorage } from "./utils/safeStorage"; +import { createDeferredSafeJSONStorage } from "./utils/safeStorage"; import { useConfigStore } from "@/stores/useConfigStore"; import { invalidateCommandsLoadCache, useCommandsStore } from "@/stores/useCommandsStore"; import { useProjectsStore } from "@/stores/useProjectsStore"; @@ -556,7 +556,7 @@ export const useAgentsStore = create()( }), { name: "agents-store", - storage: createJSONStorage(() => getSafeStorage()), + storage: createDeferredSafeJSONStorage(), partialize: (state) => ({ selectedAgentName: state.selectedAgentName, }), diff --git a/packages/ui/src/stores/useAutoReviewStore.ts b/packages/ui/src/stores/useAutoReviewStore.ts index b5da3f19..fe4f5acb 100644 --- a/packages/ui/src/stores/useAutoReviewStore.ts +++ b/packages/ui/src/stores/useAutoReviewStore.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; -import { persist, createJSONStorage } from 'zustand/middleware'; -import { getSafeStorage } from '@/stores/utils/safeStorage'; +import { persist } from 'zustand/middleware'; +import { createDeferredSafeJSONStorage } from '@/stores/utils/safeStorage'; type AutoReviewPhase = 'waiting_for_reviewer' | 'waiting_for_implementer'; type AutoReviewStatus = 'running' | 'completed' | 'stopped' | 'error'; @@ -88,7 +88,7 @@ export const useAutoReviewStore = create()( }), { name: 'auto-review-store', - storage: createJSONStorage(() => getSafeStorage()), + storage: createDeferredSafeJSONStorage(), partialize: (state) => ({ runsByOriginalSessionID: state.runsByOriginalSessionID }), }, ), diff --git a/packages/ui/src/stores/useCommandsStore.ts b/packages/ui/src/stores/useCommandsStore.ts index 517a0567..9e1dc608 100644 --- a/packages/ui/src/stores/useCommandsStore.ts +++ b/packages/ui/src/stores/useCommandsStore.ts @@ -1,6 +1,6 @@ import { create } from "zustand"; import type { StoreApi, UseBoundStore } from "zustand"; -import { devtools, persist, createJSONStorage } from "zustand/middleware"; +import { devtools, persist } from "zustand/middleware"; import { opencodeClient } from "@/lib/opencode/client"; import { startConfigUpdate, @@ -8,7 +8,7 @@ import { updateConfigUpdateMessage, } from "@/lib/configUpdate"; import { emitConfigChange, scopeMatches, subscribeToConfigChanges } from "@/lib/configSync"; -import { getSafeStorage } from "./utils/safeStorage"; +import { createDeferredSafeJSONStorage } from "./utils/safeStorage"; import { useProjectsStore } from "@/stores/useProjectsStore"; import { runtimeFetch } from "@/lib/runtime-fetch"; @@ -427,7 +427,7 @@ export const useCommandsStore = create()( }), { name: "commands-store", - storage: createJSONStorage(() => getSafeStorage()), + storage: createDeferredSafeJSONStorage(), partialize: (state) => ({ selectedCommandName: state.selectedCommandName, }), diff --git a/packages/ui/src/stores/useConfigStore.test.ts b/packages/ui/src/stores/useConfigStore.test.ts index ca4562c0..0b2b18c6 100644 --- a/packages/ui/src/stores/useConfigStore.test.ts +++ b/packages/ui/src/stores/useConfigStore.test.ts @@ -129,7 +129,23 @@ const deferred = () => { }; mock.module('@/stores/utils/safeStorage', () => ({ + getDeferredSafeStorage: () => makeStorage(), getSafeStorage: () => makeStorage(), + createDeferredSafeJSONStorage: () => { + const testStorage = makeStorage(); + return { + getItem: (name: string) => { + const value = testStorage.getItem(name); + return value === null ? null : JSON.parse(value); + }, + setItem: (name: string, value: unknown) => { + testStorage.setItem(name, JSON.stringify(value)); + }, + removeItem: (name: string) => { + testStorage.removeItem(name); + }, + }; + }, })); mock.module('@/stores/useProjectsStore', () => ({ diff --git a/packages/ui/src/stores/useConfigStore.ts b/packages/ui/src/stores/useConfigStore.ts index ae0f3131..88b808a8 100644 --- a/packages/ui/src/stores/useConfigStore.ts +++ b/packages/ui/src/stores/useConfigStore.ts @@ -1,11 +1,11 @@ import { create } from "zustand"; import type { StoreApi, UseBoundStore } from "zustand"; -import { devtools, persist, createJSONStorage } from "zustand/middleware"; +import { devtools, persist } from "zustand/middleware"; import type { Provider, Agent, Config } from "@opencode-ai/sdk/v2"; import { opencodeClient } from "@/lib/opencode/client"; import { scopeMatches, subscribeToConfigChanges } from "@/lib/configSync"; import type { ModelMetadata } from "@/types"; -import { getSafeStorage } from "./utils/safeStorage"; +import { createDeferredSafeJSONStorage } from "./utils/safeStorage"; import { filterVisibleAgents } from "./useAgentsStore"; import { isPrimaryMode } from "@/components/chat/mobileControlsUtils"; import { useSessionUIStore } from "@/sync/session-ui-store"; @@ -3283,7 +3283,7 @@ export const useConfigStore = create()( }), { name: "config-store", - storage: createJSONStorage(() => getSafeStorage()), + storage: createDeferredSafeJSONStorage(), merge: (persistedState, currentState) => hydrateActiveDirectorySnapshot({ ...currentState, diff --git a/packages/ui/src/stores/useDirectoryStore.ts b/packages/ui/src/stores/useDirectoryStore.ts index ea7d59ff..b0b32af5 100644 --- a/packages/ui/src/stores/useDirectoryStore.ts +++ b/packages/ui/src/stores/useDirectoryStore.ts @@ -6,7 +6,7 @@ import { subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch'; import { updateDesktopSettings } from '@/lib/persistence'; import { useFileSearchStore } from '@/stores/useFileSearchStore'; import { streamDebugEnabled } from '@/stores/utils/streamDebug'; -import { getSafeStorage } from './utils/safeStorage'; +import { getDeferredSafeStorage } from './utils/safeStorage'; interface DirectoryStore { @@ -28,7 +28,7 @@ interface DirectoryStore { let cachedHomeDirectory: string | null = null; let homeResolveGeneration = 0; -const safeStorage = getSafeStorage(); +const safeStorage = getDeferredSafeStorage(); const persistedLastDirectory = safeStorage.getItem('lastDirectory'); const initialHasPersistedDirectory = typeof persistedLastDirectory === 'string' && persistedLastDirectory.length > 0; diff --git a/packages/ui/src/stores/useFilesViewTabsStore.ts b/packages/ui/src/stores/useFilesViewTabsStore.ts index 1f391cf0..b069f507 100644 --- a/packages/ui/src/stores/useFilesViewTabsStore.ts +++ b/packages/ui/src/stores/useFilesViewTabsStore.ts @@ -1,7 +1,7 @@ import { create } from 'zustand'; -import { createJSONStorage, devtools, persist } from 'zustand/middleware'; +import { devtools, persist } from 'zustand/middleware'; -import { getSafeStorage } from './utils/safeStorage'; +import { createDeferredSafeJSONStorage } from './utils/safeStorage'; type RootTabsState = { openPaths: string[]; @@ -451,7 +451,7 @@ export const useFilesViewTabsStore = create()( { name: 'files-view-tabs-store', version: 2, - storage: createJSONStorage(() => getSafeStorage()), + storage: createDeferredSafeJSONStorage(), migrate: (persistedState) => { if (!persistedState || typeof persistedState !== 'object') { return { byRoot: {} }; diff --git a/packages/ui/src/stores/useGitHubPrStatusStore.ts b/packages/ui/src/stores/useGitHubPrStatusStore.ts index 92933750..0dd0b24d 100644 --- a/packages/ui/src/stores/useGitHubPrStatusStore.ts +++ b/packages/ui/src/stores/useGitHubPrStatusStore.ts @@ -1,8 +1,8 @@ import { create } from 'zustand'; -import { createJSONStorage, persist } from 'zustand/middleware'; +import { persist } from 'zustand/middleware'; import type { GitHubPullRequestStatus, RuntimeAPIs } from '@/lib/api/types'; import { mapWithConcurrency } from '@/lib/concurrency'; -import { getSafeStorage } from './utils/safeStorage'; +import { createDeferredSafeJSONStorage } from './utils/safeStorage'; const PR_REVALIDATE_TTL_MS = 90_000; const PR_REVALIDATE_INTERVAL_MS = 15_000; @@ -647,7 +647,7 @@ export const useGitHubPrStatusStore = create()( }), { name: PR_STATUS_STORAGE_KEY, - storage: createJSONStorage(() => getSafeStorage()), + storage: createDeferredSafeJSONStorage(), partialize: (state) => ({ entries: Object.fromEntries( Object.entries(state.entries) diff --git a/packages/ui/src/stores/useGitIdentitiesStore.ts b/packages/ui/src/stores/useGitIdentitiesStore.ts index 917a049a..d43b2cda 100644 --- a/packages/ui/src/stores/useGitIdentitiesStore.ts +++ b/packages/ui/src/stores/useGitIdentitiesStore.ts @@ -1,7 +1,7 @@ import { create } from "zustand"; import type { StoreApi, UseBoundStore } from "zustand"; -import { devtools, persist, createJSONStorage } from "zustand/middleware"; -import { getSafeStorage } from "./utils/safeStorage"; +import { devtools, persist } from "zustand/middleware"; +import { createDeferredSafeJSONStorage } from "./utils/safeStorage"; import { getGitIdentities, createGitIdentity, @@ -271,7 +271,7 @@ export const useGitIdentitiesStore = create()( }), { name: "git-identities-store", - storage: createJSONStorage(() => getSafeStorage()), + storage: createDeferredSafeJSONStorage(), partialize: (state) => ({ selectedProfileId: state.selectedProfileId, }), diff --git a/packages/ui/src/stores/useGitStore.ts b/packages/ui/src/stores/useGitStore.ts index 87aebe8c..05a5648b 100644 --- a/packages/ui/src/stores/useGitStore.ts +++ b/packages/ui/src/stores/useGitStore.ts @@ -7,7 +7,7 @@ import type { GitLogResponse, GitIdentitySummary, } from '@/lib/api/types'; -import { getSafeStorage } from '@/stores/utils/safeStorage'; +import { getDeferredSafeStorage } from '@/stores/utils/safeStorage'; const LOG_STALE_THRESHOLD = 10000; const REPO_CHECK_STALE_THRESHOLD = 60_000; @@ -155,7 +155,7 @@ const GIT_BRANCH_CACHE_KEY = 'oc.gitBranchCache'; const readBranchCache = (): Record => { try { - const raw = getSafeStorage().getItem(GIT_BRANCH_CACHE_KEY); + const raw = getDeferredSafeStorage().getItem(GIT_BRANCH_CACHE_KEY); if (!raw) return {}; const parsed = JSON.parse(raw) as Record; return parsed && typeof parsed === 'object' ? parsed : {}; @@ -169,7 +169,7 @@ const writeCachedBranches = (directory: string, branches: GitBranch): void => { try { const cache = readBranchCache(); cache[directory] = branches; - getSafeStorage().setItem(GIT_BRANCH_CACHE_KEY, JSON.stringify(cache)); + getDeferredSafeStorage().setItem(GIT_BRANCH_CACHE_KEY, JSON.stringify(cache)); } catch { // quota / serialization — ignore; live fetch still refreshes the store } diff --git a/packages/ui/src/stores/useInlineCommentDraftStore.ts b/packages/ui/src/stores/useInlineCommentDraftStore.ts index e6c48129..4148b3ab 100644 --- a/packages/ui/src/stores/useInlineCommentDraftStore.ts +++ b/packages/ui/src/stores/useInlineCommentDraftStore.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; -import { devtools, persist, createJSONStorage } from 'zustand/middleware'; -import { getSafeStorage } from './utils/safeStorage'; +import { devtools, persist } from 'zustand/middleware'; +import { createDeferredSafeJSONStorage } from './utils/safeStorage'; export type InlineCommentSource = 'diff' | 'plan' | 'file' | 'preview-console' | 'preview-annotation'; @@ -207,7 +207,7 @@ export const useInlineCommentDraftStore = create()( }), { name: 'openchamber-inline-comment-drafts', - storage: createJSONStorage(() => getSafeStorage()), + storage: createDeferredSafeJSONStorage(), version: 1, migrate: (persistedState: unknown) => { if (!persistedState || typeof persistedState !== 'object') { diff --git a/packages/ui/src/stores/useMcpConfigStore.ts b/packages/ui/src/stores/useMcpConfigStore.ts index 41a4be33..48a25a25 100644 --- a/packages/ui/src/stores/useMcpConfigStore.ts +++ b/packages/ui/src/stores/useMcpConfigStore.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; -import { devtools, persist, createJSONStorage } from 'zustand/middleware'; -import { getSafeStorage } from './utils/safeStorage'; +import { devtools, persist } from 'zustand/middleware'; +import { createDeferredSafeJSONStorage } from './utils/safeStorage'; import { startConfigUpdate, finishConfigUpdate, @@ -350,7 +350,7 @@ export const useMcpConfigStore = create()( }), { name: 'mcp-config-store', - storage: createJSONStorage(() => getSafeStorage()), + storage: createDeferredSafeJSONStorage(), partialize: (state) => ({ selectedMcpName: state.selectedMcpName }), }, ), diff --git a/packages/ui/src/stores/usePluginsStore.ts b/packages/ui/src/stores/usePluginsStore.ts index 2a6aa5e2..364da000 100644 --- a/packages/ui/src/stores/usePluginsStore.ts +++ b/packages/ui/src/stores/usePluginsStore.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; -import { devtools, persist, createJSONStorage } from 'zustand/middleware'; -import { getSafeStorage } from './utils/safeStorage'; +import { devtools, persist } from 'zustand/middleware'; +import { createDeferredSafeJSONStorage } from './utils/safeStorage'; import { startConfigUpdate, finishConfigUpdate, @@ -352,7 +352,7 @@ export const usePluginsStore = create()( }), { name: 'plugins-store', - storage: createJSONStorage(() => getSafeStorage()), + storage: createDeferredSafeJSONStorage(), partialize: (state) => ({ selectedId: state.selectedId }), }, ), diff --git a/packages/ui/src/stores/useProjectsStore.ts b/packages/ui/src/stores/useProjectsStore.ts index d5460f51..bb3f258f 100644 --- a/packages/ui/src/stores/useProjectsStore.ts +++ b/packages/ui/src/stores/useProjectsStore.ts @@ -6,7 +6,7 @@ import type { ProjectEntry } from '@/lib/api/types'; import type { DesktopSettings } from '@/lib/desktop'; import { updateDesktopSettings } from '@/lib/persistence'; import { createProjectIdFromPath } from '@/lib/projectId'; -import { getSafeStorage } from './utils/safeStorage'; +import { getDeferredSafeStorage } from './utils/safeStorage'; import { useDirectoryStore } from './useDirectoryStore'; import { streamDebugEnabled } from '@/stores/utils/streamDebug'; import { PROJECT_COLORS } from '@/lib/projectMeta'; @@ -64,7 +64,7 @@ interface ProjectsStore { getActiveProject: () => ProjectEntry | null; } -const safeStorage = getSafeStorage(); +const safeStorage = getDeferredSafeStorage(); const PROJECTS_STORAGE_KEY = 'projects'; const ACTIVE_PROJECT_STORAGE_KEY = 'activeProjectId'; diff --git a/packages/ui/src/stores/useSessionFoldersStore.test.ts b/packages/ui/src/stores/useSessionFoldersStore.test.ts index 42264081..95b8a9a4 100644 --- a/packages/ui/src/stores/useSessionFoldersStore.test.ts +++ b/packages/ui/src/stores/useSessionFoldersStore.test.ts @@ -22,6 +22,7 @@ const safeStorage = { } as Storage; mock.module('./utils/safeStorage', () => ({ + getDeferredSafeStorage: () => safeStorage, getSafeStorage: () => safeStorage, })); diff --git a/packages/ui/src/stores/useSessionFoldersStore.ts b/packages/ui/src/stores/useSessionFoldersStore.ts index 7b61d65f..c4254873 100644 --- a/packages/ui/src/stores/useSessionFoldersStore.ts +++ b/packages/ui/src/stores/useSessionFoldersStore.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; import { devtools } from 'zustand/middleware'; -import { getSafeStorage } from './utils/safeStorage'; +import { getDeferredSafeStorage } from './utils/safeStorage'; import { isVSCodeRuntime } from '@/lib/desktop'; import { runtimeFetch } from '@/lib/runtime-fetch'; @@ -46,7 +46,7 @@ const SESSION_FOLDERS_API_PATH = '/api/session-folders'; const DISK_WRITE_DEBOUNCE_MS = 250; const ARCHIVED_SCOPE_PREFIX = '__archived__:'; -const safeStorage = getSafeStorage(); +const safeStorage = getDeferredSafeStorage(); let diskWriteTimer: ReturnType | null = null; let diskHydrated = false; let diskHydrationInFlight = false; diff --git a/packages/ui/src/stores/useSessionPinnedStore.ts b/packages/ui/src/stores/useSessionPinnedStore.ts index 1f60304b..56b42f71 100644 --- a/packages/ui/src/stores/useSessionPinnedStore.ts +++ b/packages/ui/src/stores/useSessionPinnedStore.ts @@ -1,5 +1,5 @@ import { create } from 'zustand'; -import { getSafeStorage } from './utils/safeStorage'; +import { getDeferredSafeStorage } from './utils/safeStorage'; const SESSION_PINNED_STORAGE_KEY = 'oc.sessions.pinned'; @@ -29,7 +29,7 @@ type SessionPinnedStore = { toggle: (sessionId: string) => void; }; -const safeStorage = getSafeStorage(); +const safeStorage = getDeferredSafeStorage(); export const useSessionPinnedStore = create((set, get) => ({ ids: readPinned(safeStorage), diff --git a/packages/ui/src/stores/useSkillsStore.ts b/packages/ui/src/stores/useSkillsStore.ts index f2e3dae0..f94c1f2a 100644 --- a/packages/ui/src/stores/useSkillsStore.ts +++ b/packages/ui/src/stores/useSkillsStore.ts @@ -1,13 +1,13 @@ import { create } from "zustand"; import type { StoreApi, UseBoundStore } from "zustand"; -import { devtools, persist, createJSONStorage } from "zustand/middleware"; +import { devtools, persist } from "zustand/middleware"; import { emitConfigChange, scopeMatches, subscribeToConfigChanges } from "@/lib/configSync"; import { startConfigUpdate, finishConfigUpdate, updateConfigUpdateMessage, } from "@/lib/configUpdate"; -import { getSafeStorage } from "./utils/safeStorage"; +import { createDeferredSafeJSONStorage } from "./utils/safeStorage"; import { runtimeFetch } from "@/lib/runtime-fetch"; import { opencodeClient } from '@/lib/opencode/client'; @@ -490,7 +490,7 @@ export const useSkillsStore = create()( }), { name: "skills-store", - storage: createJSONStorage(() => getSafeStorage()), + storage: createDeferredSafeJSONStorage(), partialize: (state) => ({ selectedSkillName: state.selectedSkillName, }), diff --git a/packages/ui/src/stores/useTodosPersistStore.ts b/packages/ui/src/stores/useTodosPersistStore.ts index b9d394fb..0a5ba49f 100644 --- a/packages/ui/src/stores/useTodosPersistStore.ts +++ b/packages/ui/src/stores/useTodosPersistStore.ts @@ -1,7 +1,7 @@ import { create } from 'zustand'; -import { createJSONStorage, devtools, persist } from 'zustand/middleware'; +import { devtools, persist } from 'zustand/middleware'; import type { Todo } from '@opencode-ai/sdk/v2/client'; -import { getSafeStorage } from './utils/safeStorage'; +import { createDeferredSafeJSONStorage } from './utils/safeStorage'; const MAX_SESSIONS = 50; @@ -55,7 +55,7 @@ export const useTodosPersistStore = create()( { name: 'openchamber-session-todos', version: 1, - storage: createJSONStorage(() => getSafeStorage()), + storage: createDeferredSafeJSONStorage(), partialize: (state) => ({ sessions: state.sessions }), }, ), diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index e4db27f1..b8abc30c 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -1,7 +1,7 @@ import { create } from 'zustand'; -import { devtools, persist, createJSONStorage } from 'zustand/middleware'; +import { devtools, persist } from 'zustand/middleware'; import type { SidebarSection } from '@/constants/sidebar'; -import { getSafeStorage } from './utils/safeStorage'; +import { createDeferredSafeJSONStorage } from './utils/safeStorage'; import { SEMANTIC_TYPOGRAPHY, getTypographyVariable, type SemanticTypographyKey } from '@/lib/typography'; import type { ShortcutCombo } from '@/lib/shortcuts'; import type { DraftStarterRef } from '@/lib/draftStarters'; @@ -2106,7 +2106,7 @@ export const useUIStore = create()( }), { name: 'ui-store', - storage: createJSONStorage(() => getSafeStorage()), + storage: createDeferredSafeJSONStorage(), version: 10, migrate: (persistedState, version) => { if (!persistedState || typeof persistedState !== 'object') { diff --git a/packages/ui/src/stores/utils/safeStorage.test.ts b/packages/ui/src/stores/utils/safeStorage.test.ts index 31301261..359d2ed1 100644 --- a/packages/ui/src/stores/utils/safeStorage.test.ts +++ b/packages/ui/src/stores/utils/safeStorage.test.ts @@ -4,6 +4,24 @@ const importSafeStorage = async () => { return await import(`./safeStorage.ts?test=${Date.now()}-${Math.random()}`) as typeof import('./safeStorage'); }; +const createFakeStorage = (): Storage => { + const store = new Map(); + return { + getItem: (k) => (store.has(k) ? store.get(k)! : null), + setItem: (k, v) => { + store.set(k, String(v)); + }, + removeItem: (k) => { + store.delete(k); + }, + clear: () => store.clear(), + key: (i) => Array.from(store.keys())[i] ?? null, + get length() { + return store.size; + }, + } as Storage; +}; + describe('safeStorage', () => { test('falls back to memory when storage getters throw', async () => { const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); @@ -45,4 +63,99 @@ describe('safeStorage', () => { } } }); + + test('defers persisted JSON serialization and serves pending reads', async () => { + const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); + const previousStringify = JSON.stringify; + const stringifyCalls: unknown[] = []; + const backingStorage = createFakeStorage(); + const fakeWindow = { + localStorage: backingStorage, + sessionStorage: createFakeStorage(), + addEventListener: () => {}, + }; + + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: fakeWindow, + }); + + try { + JSON.stringify = ((value: unknown, replacer?: Parameters[1], space?: Parameters[2]) => { + stringifyCalls.push(value); + return previousStringify(value, replacer, space); + }) as typeof JSON.stringify; + + const { createDeferredSafeJSONStorage } = await importSafeStorage(); + const storage = createDeferredSafeJSONStorage<{ value: string }>(); + + expect(Boolean(storage)).toBe(true); + if (!storage) throw new Error('storage unavailable'); + + storage.setItem('k', { state: { value: 'v' } }); + + // Neither serialization nor the backing write runs on the call site... + expect(stringifyCalls).toHaveLength(0); + expect(backingStorage.getItem('k')).toBeNull(); + // ...but read-after-write still returns the pending value. + expect(storage.getItem('k')).toEqual({ state: { value: 'v' } }); + + // Coalesce: a second write to the same key should not produce two + // stringifications/backing writes, and the latest value wins. + storage.setItem('k', { state: { value: 'v2' } }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(stringifyCalls).toEqual([{ state: { value: 'v2' } }]); + expect(backingStorage.getItem('k')).toBe('{"state":{"value":"v2"}}'); + expect(storage.getItem('k')).toEqual({ state: { value: 'v2' } }); + } finally { + JSON.stringify = previousStringify; + if (previousWindow) { + Object.defineProperty(globalThis, 'window', previousWindow); + } else { + delete (globalThis as { window?: unknown }).window; + } + } + }); + + test('defers direct storage writes and flushes on pagehide', async () => { + const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); + const backingStorage = createFakeStorage(); + const listeners = new Map void>>(); + const fakeWindow = { + localStorage: backingStorage, + sessionStorage: createFakeStorage(), + addEventListener: (event: string, listener: () => void) => { + listeners.set(event, [...(listeners.get(event) ?? []), listener]); + }, + }; + + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: fakeWindow, + }); + + try { + const { getDeferredSafeStorage } = await importSafeStorage(); + const storage = getDeferredSafeStorage(); + + storage.setItem('direct-k', 'direct-v'); + + expect(backingStorage.getItem('direct-k')).toBeNull(); + expect(storage.getItem('direct-k')).toBe('direct-v'); + + for (const listener of listeners.get('pagehide') ?? []) { + listener(); + } + + expect(backingStorage.getItem('direct-k')).toBe('direct-v'); + } finally { + if (previousWindow) { + Object.defineProperty(globalThis, 'window', previousWindow); + } else { + delete (globalThis as { window?: unknown }).window; + } + } + }); }); diff --git a/packages/ui/src/stores/utils/safeStorage.ts b/packages/ui/src/stores/utils/safeStorage.ts index 3a568aa8..8389d089 100644 --- a/packages/ui/src/stores/utils/safeStorage.ts +++ b/packages/ui/src/stores/utils/safeStorage.ts @@ -1,5 +1,191 @@ +import type { PersistStorage, StateStorage, StorageValue } from 'zustand/middleware'; + let safeStorageInstance: Storage | null = null; let safeSessionStorageInstance: Storage | null = null; +let deferredSafeStorageInstance: Storage | null = null; + +const deferredFlushers = new Set<() => void>(); +let deferredFlushListenersRegistered = false; + +type JsonStorageOptions = { + reviver?: (key: string, value: unknown) => unknown; + replacer?: (key: string, value: unknown) => unknown; +}; + +const registerDeferredFlusher = (flush: () => void) => { + deferredFlushers.add(flush); + if (deferredFlushListenersRegistered || typeof window === 'undefined') return; + + deferredFlushListenersRegistered = true; + const flushAll = () => { + for (const flushDeferredStorage of deferredFlushers) { + flushDeferredStorage(); + } + }; + + try { + window.addEventListener('pagehide', flushAll, { capture: true }); + window.addEventListener('beforeunload', flushAll, { capture: true }); + window.addEventListener('visibilitychange', () => { + if (typeof document !== 'undefined' && document.visibilityState === 'hidden') flushAll(); + }); + window.addEventListener('freeze', flushAll); + } catch { + // Restricted environments can reject listeners; timers still flush. + } +}; + +const createDeferredJSONStorage = ( + getStorage: () => StateStorage, + options?: JsonStorageOptions, +): PersistStorage | undefined => { + let storage: StateStorage; + try { + storage = getStorage(); + } catch { + return undefined; + } + + const pendingWrites = new Map>(); + const pendingDeletes = new Set(); + let flushTimer: ReturnType | undefined; + + const flush = () => { + flushTimer = undefined; + if (pendingWrites.size === 0 && pendingDeletes.size === 0) return; + + const writes = Array.from(pendingWrites.entries()); + const deletes = Array.from(pendingDeletes); + pendingWrites.clear(); + pendingDeletes.clear(); + + for (const [name, value] of writes) { + try { + storage.setItem(name, JSON.stringify(value, options?.replacer)); + } catch (error) { + console.error('Failed to persist deferred storage value', error); + } + } + for (const name of deletes) { + try { + storage.removeItem(name); + } catch (error) { + console.error('Failed to remove deferred storage value', error); + } + } + }; + + const scheduleFlush = () => { + if (flushTimer !== undefined) return; + flushTimer = setTimeout(flush, 0); + }; + + registerDeferredFlusher(flush); + + return { + getItem: (name) => { + if (pendingWrites.has(name)) { + return pendingWrites.get(name) ?? null; + } + if (pendingDeletes.has(name)) { + return null; + } + + const parse = (value: string | null): StorageValue | null => { + if (value === null) return null; + return JSON.parse(value, options?.reviver) as StorageValue; + }; + const value = storage.getItem(name); + if (value instanceof Promise) { + return value.then(parse); + } + return parse(value); + }, + setItem: (name, value) => { + pendingWrites.set(name, value); + pendingDeletes.delete(name); + scheduleFlush(); + }, + removeItem: (name) => { + pendingWrites.delete(name); + pendingDeletes.add(name); + scheduleFlush(); + }, + }; +}; + +export const createDeferredSafeJSONStorage = (options?: JsonStorageOptions) => ( + createDeferredJSONStorage(() => getSafeStorage(), options) +); + +const createDeferredStorage = (storage: Storage): Storage => { + const pendingWrites = new Map(); + const pendingDeletes = new Set(); + let flushTimer: ReturnType | undefined; + + const flush = () => { + flushTimer = undefined; + if (pendingWrites.size === 0 && pendingDeletes.size === 0) return; + + const writes = Array.from(pendingWrites.entries()); + const deletes = Array.from(pendingDeletes); + pendingWrites.clear(); + pendingDeletes.clear(); + + for (const [key, value] of writes) { + try { + storage.setItem(key, value); + } catch (error) { + console.error('Failed to persist deferred storage value', error); + } + } + for (const key of deletes) { + try { + storage.removeItem(key); + } catch (error) { + console.error('Failed to remove deferred storage value', error); + } + } + }; + + const scheduleFlush = () => { + if (flushTimer !== undefined) return; + flushTimer = setTimeout(flush, 0); + }; + + registerDeferredFlusher(flush); + + return { + getItem: (key) => { + if (pendingWrites.has(key)) return pendingWrites.get(key) ?? null; + if (pendingDeletes.has(key)) return null; + return storage.getItem(key); + }, + setItem: (key, value) => { + pendingWrites.set(key, value); + pendingDeletes.delete(key); + scheduleFlush(); + }, + removeItem: (key) => { + pendingWrites.delete(key); + pendingDeletes.add(key); + scheduleFlush(); + }, + clear: () => { + pendingWrites.clear(); + pendingDeletes.clear(); + if (flushTimer !== undefined) { + clearTimeout(flushTimer); + flushTimer = undefined; + } + storage.clear(); + }, + key: (index) => storage.key(index), + get length() { + return storage.length; + }, + } as Storage; +}; const getWindowStorage = (key: 'localStorage' | 'sessionStorage'): Storage | null => { if (typeof window === 'undefined') { @@ -135,6 +321,13 @@ export const getSafeStorage = (): Storage => { return safeStorageInstance; }; +export const getDeferredSafeStorage = (): Storage => { + if (!deferredSafeStorageInstance) { + deferredSafeStorageInstance = createDeferredStorage(getSafeStorage()); + } + return deferredSafeStorageInstance; +}; + const createSafeSessionStorage = (): Storage => { const baseStorage = getWindowStorage('sessionStorage'); diff --git a/packages/ui/src/sync/selection-store.ts b/packages/ui/src/sync/selection-store.ts index 7b3058e3..a8c1cb21 100644 --- a/packages/ui/src/sync/selection-store.ts +++ b/packages/ui/src/sync/selection-store.ts @@ -4,8 +4,8 @@ */ import { create } from "zustand" -import { persist, createJSONStorage } from "zustand/middleware" -import { getSafeStorage } from "@/stores/utils/safeStorage" +import { persist } from "zustand/middleware" +import { createDeferredSafeJSONStorage } from "@/stores/utils/safeStorage" type ModelSelection = { providerId: string; modelId: string } type LastUsedProvider = { providerID: string; modelID: string } @@ -126,7 +126,7 @@ export const useSelectionStore = create()( { name: "selection-store", version: 1, - storage: createJSONStorage(() => getSafeStorage()), + storage: createDeferredSafeJSONStorage(), partialize: (state) => { // Convert Maps to arrays and slice to keep only the most recent MAX_PERSISTED_SESSIONS const models = Array.from(state.sessionModelSelections.entries()).slice(-MAX_PERSISTED_SESSIONS) diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index dabad03c..9b09c711 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -25,7 +25,7 @@ import { useDirectoryStore } from "@/stores/useDirectoryStore" import { useSessionFoldersStore } from "@/stores/useSessionFoldersStore" import { useCommandsStore } from "@/stores/useCommandsStore" import { useSkillsStore } from "@/stores/useSkillsStore" -import { getSafeStorage } from "@/stores/utils/safeStorage" +import { getDeferredSafeStorage } from "@/stores/utils/safeStorage" import { markPendingUserSendAnimation } from "@/lib/userSendAnimation" import { flattenAssistantTextParts } from "@/lib/messages/messageText" import { composeForkSessionMessage } from "@/lib/messages/executionMeta" @@ -327,7 +327,7 @@ const resolveDirectoryKey = (session: Session): string | null => { ?? normalizePath(sessionRecord.project?.worktree ?? null) } -const safeStorage = getSafeStorage() +const safeStorage = getDeferredSafeStorage() const DRAFT_TARGET_STORAGE_KEY = "oc.chatInput.lastDraftTarget" type PersistedDraftTarget = { projectId: string | null; directory: string | null } @@ -515,7 +515,7 @@ const WORKTREE_MAP_STORAGE_KEY = 'oc.worktreeMap' const loadPersistedWorktreeMap = (): Map => { try { - const raw = getSafeStorage().getItem(WORKTREE_MAP_STORAGE_KEY) + const raw = getDeferredSafeStorage().getItem(WORKTREE_MAP_STORAGE_KEY) if (!raw) return new Map() const entries = JSON.parse(raw) as Array<[string, WorktreeMetadata[]]> if (!Array.isArray(entries)) return new Map() @@ -529,7 +529,7 @@ const loadPersistedWorktreeMap = (): Map => { const persistWorktreeMap = (map: Map): void => { try { - getSafeStorage().setItem(WORKTREE_MAP_STORAGE_KEY, JSON.stringify([...map.entries()])) + getDeferredSafeStorage().setItem(WORKTREE_MAP_STORAGE_KEY, JSON.stringify([...map.entries()])) } catch { // quota / serialization error — ignore; discovery still refreshes at runtime } 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 0064/1073] 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 4e0dded547f48bd6b822d399b2a7004ff7c59730 Mon Sep 17 00:00:00 2001 From: Tom Rochette Date: Tue, 30 Jun 2026 21:42:59 -0400 Subject: [PATCH 0065/1073] ci: add merge-conflict label automation workflow (#1942) Adds label-merge-conflict workflow using eps1lon/actions-label-merge-conflict to label PRs with merge-conflict:true when they have conflicts. Triggers on push to main, pull_request_target (opened/synchronize/reopened), and manual dispatch. Uses the bot app token for label writes and is scoped to the openchamber/openchamber repo. --- .github/workflows/label-merge-conflict.yml | 31 ++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/workflows/label-merge-conflict.yml diff --git a/.github/workflows/label-merge-conflict.yml b/.github/workflows/label-merge-conflict.yml new file mode 100644 index 00000000..8d61194e --- /dev/null +++ b/.github/workflows/label-merge-conflict.yml @@ -0,0 +1,31 @@ +name: label-merge-conflict + +on: + push: + branches: [main] + pull_request_target: + types: [opened, synchronize, reopened] + workflow_dispatch: + +permissions: {} + +jobs: + label: + if: ${{ github.repository == 'openchamber/openchamber' }} + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - name: Generate bot app token + id: app-token + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2.2.2 + with: + app-id: ${{ secrets.OC_REVIEW_APP_ID }} + private-key: ${{ secrets.OC_REVIEW_APP_PRIVATE_KEY }} + + - name: Label pull requests with merge conflicts + uses: eps1lon/actions-label-merge-conflict@0273be72a0bbd58fcd71d0d6c02c209b50d1e5e1 # v3.1.0 + with: + dirtyLabel: "merge-conflict:true" + repoToken: ${{ steps.app-token.outputs.token }} From 72a24c388f6dbb6eb4ccc93619edf91b4dcf9c88 Mon Sep 17 00:00:00 2001 From: Brian Ketelsen Date: Tue, 30 Jun 2026 23:26:51 -0400 Subject: [PATCH 0066/1073] 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 61a4a23add405c65234c1a2987c40ef14a73ee28 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 1 Jul 2026 09:55:41 +0300 Subject: [PATCH 0067/1073] feat: native iOS & Android mobile apps (Capacitor) (#1954) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mobile): add Capacitor native shell * docs: add serve-sim workflow guidance * docs(mobile): add implementation handoff * chore(mobile): clean up generated defaults * feat(mobile): add connection onboarding * feat(mobile): manage saved instances * feat(mobile): refine connection management UI * chore(mobile): upgrade Capacitor 8 * fix(mobile): reliable saved-instance auth with secure token storage - store client tokens in the OS secure store (iOS Keychain / Android Keystore) per instance URL via direct native plugin calls; keep only token-less metadata in localStorage. Bound every secure call so a stalled bridge can't hang unlock. - bypass the secure-storage JS wrapper's lazy platform load (which stalled in the webview) by calling internalSetItem/internalGetItem/internalRemoveItem directly. - harden the shared connect/unlock controller (health + session + progressive password) and drop the heavy pre-connect hydration that stalled no-token hosts. - await token persistence before switching runtime endpoints (no fire-and-forget). - sync native iOS/Android projects + Keyboard/StatusBar config for Capacitor 8. * fix(mobile): keep UI stable across connection churn (no transport hardcoding) The "reload every ~10s" was a UX bug, not a transport one: - MobileSurfaceShell received a fresh inline onClose each parent render, so any re-render (e.g. an SSE/WS event) re-ran the focus effect and refocused the first element — stealing focus from the active input and collapsing the keyboard mid-edit. onClose now lives in a ref so the focus/keydown effect depends only on `open`. Fixes all sheets (Instances/Files/Changes/Settings). - Gate the mobile shell on connectionPhase, not the live isConnected flag, so a transient reconnect keeps MobileShell mounted instead of flashing the loader. - Instances form: populate fields imperatively on edit/cancel/save instead of via an effect keyed on the derived connection, so list churn can't wipe input. Transport stays on `auto` (WS-first with SSE fallback) — no hardcoded override, so WS-only Quick Tunnels and SSE-capable proxies both keep working. * feat(mobile): add native QR pairing-code scanner Wire the connection onboarding + Instances scan buttons to a real native scanner via @capacitor-mlkit/barcode-scanning, which registers as the BarcodeScanner plugin the existing mobileQrScan helper already resolves at runtime. Add NSCameraUsageDescription and bump the iOS deployment target to 15.5 (GoogleMLKit 8 requirement). * fix(cli): repair connect-url host resolution Define the missing isWildcardBindHost helper that connect-url called but was never declared, which crashed any link generation that reached host resolution. Also treat a full http(s) --host value as a public server URL so '--host https://example.com' produces a correct link instead of 'http://https://example.com:port'. * fix(mobile): make input follow the keyboard across all surfaces Switch the native Capacitor Keyboard plugin to resize: 'none' and drive the layout from an --oc-keyboard-inset CSS variable set on keyboardWillShow, which fires at the start of the iOS keyboard animation. A transition tuned to the native keyboard curve/duration (0.25s, cubic-bezier(0.38, 0.7, 0.125, 1)) makes the layout rise together with the keyboard instead of snapping into place after the built-in 'native' resize finished (~1.5s lag). The inset is consumed by every surface that can hold a focused input: - chat shell shrinks its height; - portal sheets/overlays raise their bottom edge; - the full-screen connect/login view caps its height so it actually scrolls (and is now generally scrollable for long saved-connection lists). * feat(mobile): rounder chat composer + native bottom safe area Round the mobile chat composer corners a touch more (1rem), and reserve a small app-level bottom safe area for the native shell via the --oc-app-bottom-safe token so controls clear the phone's rounded hardware corners. The reservation folds into the keyboard inset (no gap above the keyboard), and the composer's own bottom padding tightens while the keyboard is open. * fix(mobile): remove iOS 26 dark status-bar band; polish composer The dark band behind the status bar in system Dark Mode was iOS 26's automatic scroll edge effect (Liquid Glass) dimming the WebView's top edge beneath the status bar — appearance-coloured, so it tracked the system theme regardless of the in-app theme. Hide it via UIScrollView.topEdgeEffect/bottomEdgeEffect on the WebView's scroll view (iOS 26+), and make the WebView non-opaque so the themed web background shows under the overlaid status bar. Also: re-assert the status-bar overlay on resume, paint the document canvas with the theme background in the native shell, round the composer corners to 1.5rem, and enlarge the app-level bottom safe area so controls clear the rounded corners. * feat(mobile): logo splash until first paint is final (no FOUT / layout shift) Cold start flashed the fallback font and then reflowed once the real font and persisted appearance prefs landed, and text jumped a frame after mount because the mobile typography classes were applied from a hook effect. Fix it on three fronts: - apply device classes (device-mobile / mobile-pointer) synchronously in renderMobileApp before the first React paint, so mobile --text-* sizes are in effect from the start; - hold a logo splash (useFontsReady) until the UI web font has loaded; - gate that splash on appBootReady too, resolved once async appearance/typography preferences are applied, plus a double rAF so styles commit before reveal. All under a 2.5s safety timeout so a slow/offline CDN can't block startup. * feat(mobile): native local notifications; APNs implemented but frozen The native app now delivers agent ready/error/question/permission events as iOS (and Android) Local Notifications: a native notifications API backed by @capacitor/local-notifications replaces the Web Notifications API (which doesn't display in a WKWebView), driven by the notification SSE stream now subscribed in the mobile app. Tapping a notification opens its session. Also fix the settings toggle, which treated the Capacitor app as a browser and gated 'Enable Notifications' on the absent Web Notification permission, leaving it un-toggleable. Remote APNs push is implemented end-to-end (dependency-free HTTP/2 + ES256 JWT server runtime, token routes, client registration, iOS native config) but kept dormant: config-gated so it never fires, client registration not wired, and the aps-environment entitlement / background mode removed so the app builds with no Apple push setup. It will be reused once OpenChamber ships its own encrypted relay so users don't each configure APNs. See notifications/APNS.md. WKWebView can't use web push (unlike an installed PWA), so true background-when-suspended delivery on native requires APNs via that relay. * feat(mobile): APNs relay-mode background push Deliver native iOS background push through the central relay: the server posts device tokens + generic, model-based text to api.openchamber.dev/v1/push/send (default), which holds the single APNs key and signs+sends; dead tokens (410) are dropped from the per-session store. Direct APNs (HTTP/2 + ES256 JWT) stays as a fallback when OPENCHAMBER_PUSH_RELAY_DISABLED=true. The mobile push payload is generic only (model + scenario) so no session content crosses the relay. Re-enable the client token registration (useNativePushRegistration) and the aps-environment entitlement (alert pushes need no background mode). Wired into the same fanout as web push; focus-suppressed and only when tokens exist. * fix(mobile): APNs-only native notifications, generic templates, no foreground Make APNs the single notification channel for the native app and fix delivery: - Remove local notifications entirely (the @capacitor/local-notifications plugin and the SSE-driven path). A WKWebView can't tell foreground from background (document.hasFocus() is unreliable), so local notifications leaked while the app was open; the in-app dispatch is no-op'd on native. - Stop gating APNs on UI visibility — a backgrounded WebView can't report 'hidden' before iOS suspends it, which dropped background push. Instead always send and let iOS suppress the foreground banner (PushNotifications presentationOptions: []). - Fix a ReferenceError (out-of-scope 'variables') that crashed maybeSendPushForTrigger before any push was sent. - Mobile push text is generic: a scenario title ('Agent response is ready' / 'needs your input' / 'needs permission' / 'hit an error') + the session name, no model or message content. - Hide the focus toggle, templates, and test button in mobile notification settings. * feat(push): sign relay requests + bind tokens per server Each OpenChamber server now auto-generates an ECDSA P-256 keypair (persisted in settings, like the VAPID keys) and uses it to: - bind every newly-seen device token to the server on the relay (POST /v1/push/register-token, signed), and - sign every push send (publicKeyJwk + ts + signature over ts.sortedTokens.title). The relay derives serverId = SHA-256(publicKey), verifies the signature + timestamp, and only delivers to tokens bound to that server. Result: a leaked device token alone can no longer be used to push to a device — the sender also needs the server's private key. Stays zero-config (the keypair generates on first use). Drops the soft PUSH_RELAY_TOKEN bearer. * docs(push): describe relay data-confidentiality model Document that the push payload is not application-encrypted (TLS-in-transit only), what the relay and Apple can see (generic scenario title + session name, plus token/sessionId), that the signature is authentication rather than encryption, and what an end-to-end encrypted payload would require. * fix: invalid skill description * feat(push): app-icon badge for native notifications Send an absolute aps.badge with each native push = the count of distinct collapse-ids (tag) pushed since the app was last foregrounded, mirroring the lock-screen banner stack. Cleared server-side on user engagement (session view, message-sent, visibility beacon) and on-device via sceneDidBecomeActive. * feat(mobile): auto-connect last instance on launch + notification deep-links Cold launch silently reconnects to the most-recent saved instance (when reachable and a token is saved), holding the splash instead of flashing the connect screen; falls back to the connect screen when there's no saved instance, it's unreachable, or it needs a re-login. Notification-tap deep-links are now captured unconditionally (even before connect / on cold launch) and applied once the app is ready, so a tap opens the target session instead of being lost on the login screen. * fix(mobile): resolve theme background before first paint on cold launch The mobile shell entry (mobile.html) had no pre-paint theme step, so a cold launch flashed the WebView's default light canvas, then the baked design-system default (.dark { --background: #151313 }) via body.bg-background, before React's theme system injected the real theme vars. Add a blocking script that resolves dark/light from the persisted theme + system preference and sets --background (plus color-scheme and the element background) inline on the root, so the very first paint matches the resolved theme. Falls back to the default flexoki backgrounds when no theme has been persisted yet. * feat(mobile): openchamber:// deep-link foundation + arm64 simulator build Add a typed deep-link vocabulary (deepLinks.ts: parse/build + DeepLinkIntent) and a single native navigation layer (deepLinkNavigation.ts) that handles both the openchamber:// URL scheme (App.appUrlOpen — widgets, Live Activities, external links) and notification taps, normalising each into an intent. Session and new-session resolve against the store; shell surfaces (sessions/settings/ views/changes) register handlers. Cold-launch intents stash until the app is ready. Replaces the push-only useNativePushDeepLink and keeps backwards compatibility with bare sessionId payloads. Register the openchamber:// scheme in Info.plist. Dev tooling: with-mobile-env now honours xcode-select (-p) instead of hardcoding Xcode.app, so an Xcode beta is used. build:ios:simulator runs a new ios-sim-build script that temporarily drops the MLKit barcode-scanning pod (no arm64-simulator slice) so the app builds an arm64 binary installable on Apple Silicon simulators, then restores the Podfile + Pods for device builds. QR scanning already degrades cleanly when the native plugin is absent. * feat(mobile): iOS home/lock/Control Center widgets + push-driven refresh Add a Widget Extension (OpenChamberWidget) and a Notification Service Extension (OpenChamberNotificationService), wired into the Xcode project, sharing an App Group with the app. Widgets: - Overview (medium): recent sessions with read/unread dots + four quick actions (new, status, instances, settings). - Sessions (large): session list with per-session project label, attention count and a new-session button in the header. - Quick Actions (small): New chat pill + status/instances. - Lock Screen (accessoryCircular x2): brand logo to new session, attention counter. - Control Center control: brand logo (custom SF Symbol) to new session. Data: the app writes a session-overview snapshot (attention count + recent sessions with project labels) to the App Group on scene activate/resign; the NSE refreshes it from each push (aps.badge + sessionId) so widgets update even when the app is closed (needs aps mutable-content, added to the server + relay). Deep links: add openchamber://status (session status panel) and reuse view/instances; all widget taps route through the existing deep-link channel. * feat(mobile): large Sessions widget lists 6 sessions with project labels * feat(mobile): edge-swipe to switch sessions with directional slide+fade * fix(mobile): keep widgets in sync via reload-on-change + periodic refresh Widgets sharing the app's WidgetKit reload budget refreshed unevenly, leaving the large Sessions widget stale (no unread dot / attention count) while medium updated. Drop the per-call updatedAt from the snapshot, only write + reloadAllTimelines when the session overview actually changed (so we don't burn the budget on every scene activate/resign), and give each widget a periodic timeline refresh so a missed reload self-corrects. * feat(mobile): Android support — chrome fixes, SSE lock, icon, QR scan Cosmetics: - Status bar: on Android inset the WebView below the bar (overlay:false) and paint it with the resolved theme background + correct content Style, since Android doesn't feed env(safe-area-inset-top) to CSS. - Keyboard: skip the manual --oc-keyboard-inset on Android (the window resizes natively, so applying it double-counted and floated the composer); declare windowSoftInputMode=adjustResize and disable the shell height transition on Android so the header no longer bounces on keyboard open. Transport: lock Capacitor apps to SSE — native WebSocket streaming is unreliable on Android (events only arrive once a run finishes). Forced in sync-context and the other options are disabled in the Chat settings UI. Push: gate APNs registration to iOS only; on Android @capacitor/push-notifications register() needs Firebase/FCM (not configured) and crashes at launch. QR pairing: declare CAMERA permission + the ML Kit barcode_ui dependency, and install/await the Google barcode scanner module (with a post-install retry) before scanning so the first scan works without a manual retry. Icon: Android adaptive launcher icon generated from the cube logo (full-bleed white background, no edge artifact on One UI). Source assets under mobile/assets. Tooling: adb-based android-device.mjs + android:* scripts for device deploy. * feat(notifications): presence-aware push routing (don't spam the phone) Only push to a device when the notification would otherwise be missed there. A notification is suppressed on devices where the user is already present. - Tag every client's visibility beacon and web-push subscription with a platform ('ios' | 'android' | 'vscode' | 'desktop' | 'web') via getClientPlatform(). - Server tracks visibility per client (keyed by oc_ui_session) with the platform, and exposes isAnyInteractiveClientVisible() = any visible non-mobile client. - Native push (APNs) and mobile PWA web-push are now suppressed when an interactive (desktop/web/vscode) client is visible — it already shows the in-app notification. Gated on the desktop's visibility (reliable), never the phone's own (a backgrounded WKWebView can't report "hidden"). - Desktop/web web-push keeps the any-visible gate (a visible client absorbs it). - Skipping APNs also skips the badge increment so it doesn't drift. Fixes the case where every session on a shared instance pushed to the phone even while the user was actively working on desktop. * feat(mobile): Android FCM push notifications Enable native background push on Android via Firebase Cloud Messaging, in parallel with the existing iOS APNs path. - Add google-services.json + declare POST_NOTIFICATIONS (Android 13+). The Google Services Gradle plugin is applied when the file is present, so register() returns an FCM token instead of crashing. - Un-gate native push registration to iOS OR Android, and tag the registered token with its platform ('ios' | 'android') so the relay routes it to APNs vs FCM. - Server stores the platform per device token and binds it to the relay (platform included in the signed register message). - Notification small icon: monochrome cube silhouette with a mark on the top face, set as the FCM default_notification_icon so the status-bar icon reads as the logo. Relay-side FCM sending ships in openchamber-website. * docs(mobile): refresh HANDOFF with current state, dev/deploy process, and CI gap * chore(mobile): iOS store-review prerequisites (privacy manifest, encryption flag) - Add the app's PrivacyInfo.xcprivacy (no tracking; required-reason UserDefaults for the App Group snapshot shared with the widget + notification service extension) and wire it into the App target's resources — Apple requires an app-level privacy manifest. - Set ITSAppUsesNonExemptEncryption=false to skip the per-build export-compliance prompt. - HANDOFF: add a store-review-readiness checklist (in-repo vs release-time console/infra items). Verified: plist lint, xcodebuild parse, and an iOS simulator build with PrivacyInfo.xcprivacy bundled into App.app. * refactor(mobile): dedupe capacitor detection + make beacon guard explicit Addresses non-blocking PR review notes: - Consolidate the repeated Capacitor-native check (mobileConnections, deepLinkNavigation, usePushVisibilityBeacon each redefined it) onto the single isCapacitorApp() in lib/platform. - usePushVisibilityBeacon now guards on isWebRuntime() OR isCapacitorApp() instead of relying on isWebRuntime() being true for Capacitor, so the beacon can't silently stop if that changes. --- .agents/skills/locale-ui-patterns/SKILL.md | 8 +- .agents/skills/serve-sim/SKILL.md | 69 ++ AGENTS.md | 1 + bun.lock | 167 ++- package.json | 18 + packages/mobile/HANDOFF.md | 218 ++++ packages/mobile/README.md | 71 ++ packages/mobile/android/.gitignore | 101 ++ packages/mobile/android/app/.gitignore | 2 + packages/mobile/android/app/build.gradle | 50 + .../mobile/android/app/capacitor.build.gradle | 24 + .../mobile/android/app/google-services.json | 29 + .../mobile/android/app/proguard-rules.pro | 21 + .../android/app/src/main/AndroidManifest.xml | 56 + .../com/openchamber/app/MainActivity.java | 5 + .../main/res/drawable-land-hdpi/splash.png | Bin 0 -> 7705 bytes .../main/res/drawable-land-mdpi/splash.png | Bin 0 -> 4040 bytes .../main/res/drawable-land-xhdpi/splash.png | Bin 0 -> 9251 bytes .../main/res/drawable-land-xxhdpi/splash.png | Bin 0 -> 13984 bytes .../main/res/drawable-land-xxxhdpi/splash.png | Bin 0 -> 17683 bytes .../main/res/drawable-port-hdpi/splash.png | Bin 0 -> 7934 bytes .../main/res/drawable-port-mdpi/splash.png | Bin 0 -> 4096 bytes .../main/res/drawable-port-xhdpi/splash.png | Bin 0 -> 9875 bytes .../main/res/drawable-port-xxhdpi/splash.png | Bin 0 -> 13346 bytes .../main/res/drawable-port-xxxhdpi/splash.png | Bin 0 -> 17489 bytes .../drawable-v24/ic_launcher_foreground.xml | 34 + .../res/drawable/ic_launcher_background.xml | 170 +++ .../src/main/res/drawable/ic_stat_notify.xml | 22 + .../app/src/main/res/drawable/splash.png | Bin 0 -> 4040 bytes .../app/src/main/res/layout/activity_main.xml | 12 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 7 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 7 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 2398 bytes .../mipmap-hdpi/ic_launcher_background.png | Bin 0 -> 240 bytes .../mipmap-hdpi/ic_launcher_foreground.png | Bin 0 -> 2915 bytes .../res/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 3662 bytes .../src/main/res/mipmap-ldpi/ic_launcher.png | Bin 0 -> 764 bytes .../mipmap-ldpi/ic_launcher_background.png | Bin 0 -> 157 bytes .../mipmap-ldpi/ic_launcher_foreground.png | Bin 0 -> 1235 bytes .../res/mipmap-ldpi/ic_launcher_round.png | Bin 0 -> 1630 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 1231 bytes .../mipmap-mdpi/ic_launcher_background.png | Bin 0 -> 176 bytes .../mipmap-mdpi/ic_launcher_foreground.png | Bin 0 -> 1816 bytes .../res/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 2303 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 3693 bytes .../mipmap-xhdpi/ic_launcher_background.png | Bin 0 -> 308 bytes .../mipmap-xhdpi/ic_launcher_foreground.png | Bin 0 -> 3929 bytes .../res/mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 4903 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 6619 bytes .../mipmap-xxhdpi/ic_launcher_background.png | Bin 0 -> 444 bytes .../mipmap-xxhdpi/ic_launcher_foreground.png | Bin 0 -> 6176 bytes .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 7804 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 9968 bytes .../mipmap-xxxhdpi/ic_launcher_background.png | Bin 0 -> 608 bytes .../mipmap-xxxhdpi/ic_launcher_foreground.png | Bin 0 -> 8340 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 10605 bytes .../res/values/ic_launcher_background.xml | 4 + .../app/src/main/res/values/strings.xml | 7 + .../app/src/main/res/values/styles.xml | 22 + .../app/src/main/res/xml/file_paths.xml | 5 + packages/mobile/android/build.gradle | 36 + .../mobile/android/capacitor.settings.gradle | 21 + packages/mobile/android/gradle.properties | 22 + .../android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43583 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + packages/mobile/android/gradlew | 252 ++++ packages/mobile/android/gradlew.bat | 94 ++ packages/mobile/android/settings.gradle | 5 + packages/mobile/android/variables.gradle | 13 + packages/mobile/assets/icon-background.png | Bin 0 -> 20607 bytes packages/mobile/assets/icon-foreground.png | Bin 0 -> 68840 bytes packages/mobile/assets/icon-only.png | Bin 0 -> 75485 bytes packages/mobile/capacitor.config.ts | 33 + packages/mobile/ios/.gitignore | 13 + .../ios/App/App.xcodeproj/project.pbxproj | 738 ++++++++++++ .../xcshareddata/xcschemes/App.xcscheme | 78 ++ .../xcschemes/OpenChamberWidget.xcscheme | 123 ++ .../App.xcworkspace/contents.xcworkspacedata | 10 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + packages/mobile/ios/App/App/App.entitlements | 18 + packages/mobile/ios/App/App/AppDelegate.swift | 162 +++ .../AppIcon.appiconset/AppIcon-512@2x.png | Bin 0 -> 110522 bytes .../AppIcon.appiconset/Contents.json | 14 + .../ios/App/App/Assets.xcassets/Contents.json | 6 + .../Splash.imageset/Contents.json | 23 + .../Splash.imageset/splash-2732x2732-1.png | Bin 0 -> 41273 bytes .../Splash.imageset/splash-2732x2732-2.png | Bin 0 -> 41273 bytes .../Splash.imageset/splash-2732x2732.png | Bin 0 -> 41273 bytes .../App/Base.lproj/LaunchScreen.storyboard | 32 + .../ios/App/App/Base.lproj/Main.storyboard | 19 + packages/mobile/ios/App/App/Info.plist | 92 ++ .../mobile/ios/App/App/PrivacyInfo.xcprivacy | 30 + .../OpenChamberNotificationService/Info.plist | 29 + .../NotificationService.swift | 71 ++ ...penChamberNotificationService.entitlements | 11 + .../Assets.xcassets/Contents.json | 6 + .../OCLogoSymbol.symbolset/Contents.json | 13 + .../OCLogoSymbol.symbolset/oclogo-symbol.svg | 55 + .../ios/App/OpenChamberWidget/Info.plist | 27 + .../OpenChamberControl.swift | 38 + .../OpenChamberWidget.entitlements | 11 + .../OpenChamberWidgets.swift | 321 +++++ .../App/OpenChamberWidget/WidgetShared.swift | 137 +++ packages/mobile/ios/App/Podfile | 40 + packages/mobile/ios/App/Podfile.lock | 134 +++ packages/mobile/package.json | 47 + packages/mobile/scripts/android-device.mjs | 93 ++ packages/mobile/scripts/ios-sim-build.mjs | 69 ++ packages/mobile/scripts/ios-sim.mjs | 95 ++ .../mobile/scripts/prepare-web-assets.mjs | 16 + packages/mobile/scripts/with-mobile-env.mjs | 48 + packages/mobile/tsconfig.json | 15 + packages/ui/package.json | 6 + packages/ui/src/App.tsx | 23 +- packages/ui/src/apps/MobileApp.tsx | 1042 ++++++++++++++++- packages/ui/src/apps/MobileSurfaceShell.tsx | 15 +- packages/ui/src/apps/appBootReady.ts | 17 + packages/ui/src/apps/deepLinkNavigation.ts | 198 ++++ packages/ui/src/apps/deepLinks.ts | 169 +++ packages/ui/src/apps/mobileConnections.ts | 680 +++++++++++ packages/ui/src/apps/mobileQrScan.ts | 176 +++ packages/ui/src/apps/mobileWidgetSnapshot.ts | 122 ++ packages/ui/src/apps/renderMobileApp.tsx | 33 +- packages/ui/src/apps/runtimeEndpointReset.ts | 31 + .../ui/src/apps/useEdgeSwipeSessionSwitch.ts | 125 ++ packages/ui/src/apps/useFontsReady.ts | 51 + .../ui/src/apps/useNativePushRegistration.ts | 101 ++ packages/ui/src/components/chat/ChatInput.tsx | 5 +- .../openchamber/NotificationSettings.tsx | 23 +- .../openchamber/OpenChamberVisualSettings.tsx | 10 +- .../src/components/ui/MobileOverlayPanel.tsx | 2 +- .../ui/src/hooks/usePushVisibilityBeacon.ts | 57 +- packages/ui/src/lib/api/types.ts | 13 +- packages/ui/src/lib/i18n/messages/en.ts | 39 + packages/ui/src/lib/i18n/messages/es.ts | 39 + packages/ui/src/lib/i18n/messages/fr.ts | 39 + packages/ui/src/lib/i18n/messages/ja.ts | 39 + packages/ui/src/lib/i18n/messages/ko.ts | 39 + packages/ui/src/lib/i18n/messages/pl.ts | 39 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 39 + packages/ui/src/lib/i18n/messages/uk.ts | 39 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 39 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 39 + packages/ui/src/lib/opencode/client.ts | 24 +- packages/ui/src/lib/platform.ts | 26 + packages/ui/src/styles/mobile.css | 85 ++ packages/ui/src/sync/sync-context.tsx | 10 +- packages/web/bin/lib/commands-connect-url.js | 10 + packages/web/mobile.html | 59 + packages/web/server/index.js | 43 +- packages/web/server/lib/notifications/APNS.md | 131 +++ .../server/lib/notifications/DOCUMENTATION.md | 13 + .../server/lib/notifications/apns-runtime.js | 512 ++++++++ .../lib/notifications/apns-runtime.test.js | 196 ++++ .../server/lib/notifications/push-runtime.js | 53 +- .../lib/notifications/push-runtime.test.js | 34 + .../web/server/lib/notifications/routes.js | 63 +- .../web/server/lib/notifications/runtime.js | 95 +- .../server/lib/opencode/bootstrap-runtime.js | 6 + .../server/lib/security/request-security.js | 2 +- .../lib/security/request-security.test.js | 10 +- packages/web/src/api/push.ts | 24 +- 162 files changed, 8842 insertions(+), 98 deletions(-) create mode 100644 .agents/skills/serve-sim/SKILL.md create mode 100644 packages/mobile/HANDOFF.md create mode 100644 packages/mobile/README.md create mode 100644 packages/mobile/android/.gitignore create mode 100644 packages/mobile/android/app/.gitignore create mode 100644 packages/mobile/android/app/build.gradle create mode 100644 packages/mobile/android/app/capacitor.build.gradle create mode 100644 packages/mobile/android/app/google-services.json create mode 100644 packages/mobile/android/app/proguard-rules.pro create mode 100644 packages/mobile/android/app/src/main/AndroidManifest.xml create mode 100644 packages/mobile/android/app/src/main/java/com/openchamber/app/MainActivity.java create mode 100644 packages/mobile/android/app/src/main/res/drawable-land-hdpi/splash.png create mode 100644 packages/mobile/android/app/src/main/res/drawable-land-mdpi/splash.png create mode 100644 packages/mobile/android/app/src/main/res/drawable-land-xhdpi/splash.png create mode 100644 packages/mobile/android/app/src/main/res/drawable-land-xxhdpi/splash.png create mode 100644 packages/mobile/android/app/src/main/res/drawable-land-xxxhdpi/splash.png create mode 100644 packages/mobile/android/app/src/main/res/drawable-port-hdpi/splash.png create mode 100644 packages/mobile/android/app/src/main/res/drawable-port-mdpi/splash.png create mode 100644 packages/mobile/android/app/src/main/res/drawable-port-xhdpi/splash.png create mode 100644 packages/mobile/android/app/src/main/res/drawable-port-xxhdpi/splash.png create mode 100644 packages/mobile/android/app/src/main/res/drawable-port-xxxhdpi/splash.png create mode 100644 packages/mobile/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml create mode 100644 packages/mobile/android/app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 packages/mobile/android/app/src/main/res/drawable/ic_stat_notify.xml create mode 100644 packages/mobile/android/app/src/main/res/drawable/splash.png create mode 100644 packages/mobile/android/app/src/main/res/layout/activity_main.xml create mode 100644 packages/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 packages/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 packages/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 packages/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png create mode 100644 packages/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png create mode 100644 packages/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png create mode 100644 packages/mobile/android/app/src/main/res/mipmap-ldpi/ic_launcher.png create mode 100644 packages/mobile/android/app/src/main/res/mipmap-ldpi/ic_launcher_background.png create mode 100644 packages/mobile/android/app/src/main/res/mipmap-ldpi/ic_launcher_foreground.png create mode 100644 packages/mobile/android/app/src/main/res/mipmap-ldpi/ic_launcher_round.png create mode 100644 packages/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 packages/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png create mode 100644 packages/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png create mode 100644 packages/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png create mode 100644 packages/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 packages/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png create mode 100644 packages/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png create mode 100644 packages/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png create mode 100644 packages/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 packages/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png create mode 100644 packages/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png create mode 100644 packages/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 packages/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 packages/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png create mode 100644 packages/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png create mode 100644 packages/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png create mode 100644 packages/mobile/android/app/src/main/res/values/ic_launcher_background.xml create mode 100644 packages/mobile/android/app/src/main/res/values/strings.xml create mode 100644 packages/mobile/android/app/src/main/res/values/styles.xml create mode 100644 packages/mobile/android/app/src/main/res/xml/file_paths.xml create mode 100644 packages/mobile/android/build.gradle create mode 100644 packages/mobile/android/capacitor.settings.gradle create mode 100644 packages/mobile/android/gradle.properties create mode 100644 packages/mobile/android/gradle/wrapper/gradle-wrapper.jar create mode 100644 packages/mobile/android/gradle/wrapper/gradle-wrapper.properties create mode 100755 packages/mobile/android/gradlew create mode 100644 packages/mobile/android/gradlew.bat create mode 100644 packages/mobile/android/settings.gradle create mode 100644 packages/mobile/android/variables.gradle create mode 100644 packages/mobile/assets/icon-background.png create mode 100644 packages/mobile/assets/icon-foreground.png create mode 100644 packages/mobile/assets/icon-only.png create mode 100644 packages/mobile/capacitor.config.ts create mode 100644 packages/mobile/ios/.gitignore create mode 100644 packages/mobile/ios/App/App.xcodeproj/project.pbxproj create mode 100644 packages/mobile/ios/App/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme create mode 100644 packages/mobile/ios/App/App.xcodeproj/xcshareddata/xcschemes/OpenChamberWidget.xcscheme create mode 100644 packages/mobile/ios/App/App.xcworkspace/contents.xcworkspacedata create mode 100644 packages/mobile/ios/App/App.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 packages/mobile/ios/App/App/App.entitlements create mode 100644 packages/mobile/ios/App/App/AppDelegate.swift create mode 100644 packages/mobile/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png create mode 100644 packages/mobile/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 packages/mobile/ios/App/App/Assets.xcassets/Contents.json create mode 100644 packages/mobile/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json create mode 100644 packages/mobile/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png create mode 100644 packages/mobile/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png create mode 100644 packages/mobile/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png create mode 100644 packages/mobile/ios/App/App/Base.lproj/LaunchScreen.storyboard create mode 100644 packages/mobile/ios/App/App/Base.lproj/Main.storyboard create mode 100644 packages/mobile/ios/App/App/Info.plist create mode 100644 packages/mobile/ios/App/App/PrivacyInfo.xcprivacy create mode 100644 packages/mobile/ios/App/OpenChamberNotificationService/Info.plist create mode 100644 packages/mobile/ios/App/OpenChamberNotificationService/NotificationService.swift create mode 100644 packages/mobile/ios/App/OpenChamberNotificationService/OpenChamberNotificationService.entitlements create mode 100644 packages/mobile/ios/App/OpenChamberWidget/Assets.xcassets/Contents.json create mode 100644 packages/mobile/ios/App/OpenChamberWidget/Assets.xcassets/OCLogoSymbol.symbolset/Contents.json create mode 100644 packages/mobile/ios/App/OpenChamberWidget/Assets.xcassets/OCLogoSymbol.symbolset/oclogo-symbol.svg create mode 100644 packages/mobile/ios/App/OpenChamberWidget/Info.plist create mode 100644 packages/mobile/ios/App/OpenChamberWidget/OpenChamberControl.swift create mode 100644 packages/mobile/ios/App/OpenChamberWidget/OpenChamberWidget.entitlements create mode 100644 packages/mobile/ios/App/OpenChamberWidget/OpenChamberWidgets.swift create mode 100644 packages/mobile/ios/App/OpenChamberWidget/WidgetShared.swift create mode 100644 packages/mobile/ios/App/Podfile create mode 100644 packages/mobile/ios/App/Podfile.lock create mode 100644 packages/mobile/package.json create mode 100644 packages/mobile/scripts/android-device.mjs create mode 100644 packages/mobile/scripts/ios-sim-build.mjs create mode 100644 packages/mobile/scripts/ios-sim.mjs create mode 100644 packages/mobile/scripts/prepare-web-assets.mjs create mode 100644 packages/mobile/scripts/with-mobile-env.mjs create mode 100644 packages/mobile/tsconfig.json create mode 100644 packages/ui/src/apps/appBootReady.ts create mode 100644 packages/ui/src/apps/deepLinkNavigation.ts create mode 100644 packages/ui/src/apps/deepLinks.ts create mode 100644 packages/ui/src/apps/mobileConnections.ts create mode 100644 packages/ui/src/apps/mobileQrScan.ts create mode 100644 packages/ui/src/apps/mobileWidgetSnapshot.ts create mode 100644 packages/ui/src/apps/runtimeEndpointReset.ts create mode 100644 packages/ui/src/apps/useEdgeSwipeSessionSwitch.ts create mode 100644 packages/ui/src/apps/useFontsReady.ts create mode 100644 packages/ui/src/apps/useNativePushRegistration.ts create mode 100644 packages/ui/src/lib/platform.ts create mode 100644 packages/web/server/lib/notifications/APNS.md create mode 100644 packages/web/server/lib/notifications/apns-runtime.js create mode 100644 packages/web/server/lib/notifications/apns-runtime.test.js diff --git a/.agents/skills/locale-ui-patterns/SKILL.md b/.agents/skills/locale-ui-patterns/SKILL.md index ff57255b..93153562 100644 --- a/.agents/skills/locale-ui-patterns/SKILL.md +++ b/.agents/skills/locale-ui-patterns/SKILL.md @@ -11,10 +11,16 @@ User-facing UI text must go through `@/lib/i18n`; do not hardcode English string Use this skill for any React UI change that adds or edits visible text, accessible labels, placeholders, tooltips, toasts, dialogs, settings labels, navigation labels, or empty/error states. +## Translate everything immediately (no English placeholders) + +Every key you add to a non-English dictionary MUST contain a real translation in that language — never the English source string as a stand-in. There is NO "leave it in English for now" convention in this project; if an agent told you there was, it was wrong. Copying the English value into `es.ts`/`fr.ts`/`ko.ts`/`pl.ts`/`pt-BR.ts`/`uk.ts`/`zh-CN.ts`/`zh-TW.ts` is a defect, not a deferral. The app ships every locale at once, so an untranslated key is a visible bug for those users. + +If you genuinely cannot translate a language, say so explicitly to the user instead of silently pasting English. Do not invent a fallback policy. + ## Required Flow 1. Add or reuse a key in `packages/ui/src/lib/i18n/messages/en.ts`. -2. Add the same key to every non-English dictionary in `packages/ui/src/lib/i18n/messages/`. +2. Add the same key — fully translated, not the English text — to every non-English dictionary in `packages/ui/src/lib/i18n/messages/`. 3. In components, call `const { t } = useI18n()` from `@/lib/i18n` and render `t('key')`. 4. For locale names or language picker labels, use `label(locale)` from `useI18n()`. 5. Keep locale state in `packages/ui/src/lib/i18n/*`; do not add locale fields to broad stores like `useUIStore`. diff --git a/.agents/skills/serve-sim/SKILL.md b/.agents/skills/serve-sim/SKILL.md new file mode 100644 index 00000000..cd18d986 --- /dev/null +++ b/.agents/skills/serve-sim/SKILL.md @@ -0,0 +1,69 @@ +--- +name: serve-sim +description: Use when working with the OpenChamber iOS Simulator app without opening Xcode - boot/install/launch the Capacitor iOS app, start a browser stream, tap/type/gesture/rotate, inspect accessibility, or hand a simulator URL to the user. +--- + +# serve-sim + +Use `serve-sim` to stream and control a booted Apple Simulator from the terminal. It captures the simulator framebuffer, serves a browser preview, and exposes CLI controls for taps, typing, gestures, hardware buttons, rotation, memory warnings, permissions, camera injection, and accessibility inspection. + +## OpenChamber Defaults + +- Mobile package: `packages/mobile` +- iOS bundle id: `com.openchamber.app` +- Headless env wrapper: `packages/mobile/scripts/with-mobile-env.mjs` +- iOS simulator helper: `packages/mobile/scripts/ios-sim.mjs` +- Preferred scripts: + - `bun run mobile:build:ios:simulator` + - `bun run mobile:sim:run` + - `bun run mobile:sim:serve` + - `bun run mobile:sim:list` + - `bun run mobile:sim:kill` + +## Workflow + +1. Build the simulator app without opening Xcode: + ```sh + bun run mobile:build:ios:simulator + ``` + +2. Boot a simulator if needed, install, and launch the app: + ```sh + bun run mobile:sim:run + ``` + +3. Start the browser stream in detached JSON mode: + ```sh + bun run mobile:sim:serve + ``` + Surface the returned `url` to the user. It normally starts at `http://localhost:3200`. + +4. Stop helpers when finished unless the user asks to keep them running: + ```sh + bun run mobile:sim:kill + ``` + +## Direct CLI Controls + +- Tap normalized coordinates: `bunx serve-sim tap 0.5 0.5` +- Type focused text: `bunx serve-sim type "hello"` +- Hardware home: `bunx serve-sim button home` +- Rotate: `bunx serve-sim rotate portrait` +- List streams: `bunx serve-sim --list -q` +- Accessibility tree: `curl http://localhost:3100/ax` + +Coordinates are normalized `0..1`, not pixels. Prefer `tap` for simple taps; do not emulate taps using separate `gesture` begin/end commands because that can register as long press. + +## Preconditions + +- macOS host. +- Xcode installed; use `DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer` if `xcode-select` points at CommandLineTools. +- Node 18+. +- At least one simulator can be booted with `xcrun simctl`. + +## Anti-Patterns + +- Do not open Xcode just to build/install/launch during agent work; use the scripts above. +- Do not parse human output from `serve-sim`; use `-q` for JSON. +- Do not leave helper streams running unintentionally. +- Do not guess coordinates after accessibility lookup fails; report the missing target instead. diff --git a/AGENTS.md b/AGENTS.md index 263dd21d..9d21944a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -340,6 +340,7 @@ Project skills live under `.agents/skills/*/SKILL.md`. Before editing, agents ** | User-facing UI text: labels, buttons, placeholders, aria labels, empty/error/loading states, toasts, dialogs, settings copy, or navigation labels | `skill({ name: "locale-ui-patterns" })` | | Settings pages, settings dialogs, configuration UI, or visual/layout changes inside Settings | `skill({ name: "settings-ui-patterns" })` | | Drag-to-reorder, sortable lists/chips/grids, or `@dnd-kit` behavior including touch/mobile and wrapping variable-width items | `skill({ name: "drag-to-reorder" })` | +| iOS Simulator preview/control for the mobile app, `serve-sim`, simulator taps/typing/gestures/rotation, or headless install/launch workflows outside Xcode | `skill({ name: "serve-sim" })` | Skill docs are the source of truth for detailed patterns. Do not duplicate their full guidance here; load the skill and follow it before making matching changes. diff --git a/bun.lock b/bun.lock index 20ce1bac..72a8a905 100644 --- a/bun.lock +++ b/bun.lock @@ -112,11 +112,38 @@ "electron-builder": "^26.0.0", }, }, + "packages/mobile": { + "name": "@openchamber/mobile", + "version": "1.13.2", + "dependencies": { + "@aparajita/capacitor-secure-storage": "^8.0.0", + "@capacitor-mlkit/barcode-scanning": "^8.1.0", + "@capacitor/app": "^8.0.0", + "@capacitor/core": "^8.4.1", + "@capacitor/keyboard": "^8.0.0", + "@capacitor/push-notifications": "^8.1.1", + "@capacitor/status-bar": "^8.0.0", + }, + "devDependencies": { + "@capacitor/android": "^8.4.1", + "@capacitor/cli": "^8.4.1", + "@capacitor/ios": "^8.4.1", + "@types/node": "^24.3.1", + "serve-sim": "^0.1.34", + "typescript": "~5.9.0", + }, + }, "packages/ui": { "name": "@openchamber/ui", "version": "1.13.4", "dependencies": { + "@aparajita/capacitor-secure-storage": "^8.0.0", "@base-ui/react": "^1.4.0", + "@capacitor/app": "^8.0.0", + "@capacitor/core": "^8.4.1", + "@capacitor/keyboard": "^8.0.0", + "@capacitor/push-notifications": "^8.1.1", + "@capacitor/status-bar": "^8.0.0", "@codemirror/autocomplete": "^6.20.0", "@codemirror/commands": "^6.10.1", "@codemirror/lang-cpp": "^6.0.3", @@ -335,6 +362,8 @@ "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], + "@aparajita/capacitor-secure-storage": ["@aparajita/capacitor-secure-storage@8.0.0", "", { "dependencies": { "@capacitor/android": "^8.0.2", "@capacitor/app": "^8.0.0", "@capacitor/core": "^8.0.2", "@capacitor/ios": "^8.0.2", "@capacitor/keyboard": "^8.0.0" } }, "sha512-oYnwSjdIh23aRNgz8982+TmFvQH/2yZkEdw1iIg+H2ziFJoOVELPTc7u6Ez2HwOuDIW5AGqBX75GvrzQ+D70Qg=="], + "@apideck/better-ajv-errors": ["@apideck/better-ajv-errors@0.3.6", "", { "dependencies": { "json-schema": "^0.4.0", "jsonpointer": "^5.0.0", "leven": "^3.1.0" }, "peerDependencies": { "ajv": ">=8" } }, "sha512-P+ZygBLZtkp0qqOAJJVX4oX/sFo5JR3eBWwwuqHHhK0GIgQOKWrAfiAaWX0aArHkRWHMuggFEgAZNxVPwPZYaA=="], "@azu/format-text": ["@azu/format-text@1.0.2", "", {}, "sha512-Swi4N7Edy1Eqq82GxgEECXSSLyn6GOb5htRFPzBDdUkECGXtlf12ynO5oJSpWKPwCaUssOu7NfhDcCWpIC6Ywg=="], @@ -551,6 +580,24 @@ "@base-ui/utils": ["@base-ui/utils@0.2.7", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@floating-ui/utils": "^0.2.11", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-nXYKhiL/0JafyJE8PfcflipGftOftlIwKd72rU15iZ1M5yqgg5J9P8NHU71GReDuXco5MJA/eVQqUT5WRqX9sA=="], + "@capacitor-mlkit/barcode-scanning": ["@capacitor-mlkit/barcode-scanning@8.1.0", "", { "peerDependencies": { "@capacitor/core": ">=8.0.0" } }, "sha512-lhOYHZINLOCT0i5YSbSMkouik3zh0BncJouumNTgXCT0/533Z4733jAX9zv+nEd++bE8QHeUl2g0NrewreumnQ=="], + + "@capacitor/android": ["@capacitor/android@8.4.1", "", { "peerDependencies": { "@capacitor/core": "^8.4.0" } }, "sha512-igtDCJ7QQn0P2qHFD9p4KXaa6V1b2PRNt+MxjVwtjTm/BJvqmiazOJq6rPjwFSZnfHm6iFoZk8TfzHd44pyBGw=="], + + "@capacitor/app": ["@capacitor/app@8.1.0", "", { "peerDependencies": { "@capacitor/core": ">=8.0.0" } }, "sha512-MlmttTOWHDedr/G4SrhNRxsXMqY+R75S4MM4eIgzsgCzOYhb/MpCkA5Q3nuOCfL1oHm26xjUzqZ5aupbOwdfYg=="], + + "@capacitor/cli": ["@capacitor/cli@8.4.1", "", { "dependencies": { "@ionic/cli-framework-output": "^2.2.8", "@ionic/utils-subprocess": "^3.0.1", "@ionic/utils-terminal": "^2.3.5", "commander": "^12.1.0", "debug": "^4.4.0", "env-paths": "^2.2.0", "fs-extra": "^11.2.0", "kleur": "^4.1.5", "native-run": "^2.0.3", "open": "^8.4.0", "plist": "^3.1.0", "prompts": "^2.4.2", "rimraf": "^6.0.1", "semver": "^7.6.3", "tar": "^7.5.3", "tslib": "^2.8.1", "xml2js": "^0.6.2" }, "bin": { "cap": "bin/capacitor", "capacitor": "bin/capacitor" } }, "sha512-t7F2s7fFHCq113xgrggrmK6ctV0/8E5YfLNVLfPHp4GCTDO+tly9fZvWPf2/sOI8lMm18dLT43qbXLRTz/OZgw=="], + + "@capacitor/core": ["@capacitor/core@8.4.1", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-xqhOGLbTAYeOWK+IDUNSjQJAPapQjRHrIcgk9PYp52or9zFTaaMko31uNi16N6W+CRJ8VrRram6fOYILkBG2Hg=="], + + "@capacitor/ios": ["@capacitor/ios@8.4.1", "", { "peerDependencies": { "@capacitor/core": "^8.4.0" } }, "sha512-EgcAk7NYheHMTyP3CUrA65qKs4D2UEAKgw44HI6Uk3dTw6KjLQkdLOQWvbeRncHaZ2gklfOojUoc5DlSw2lhYg=="], + + "@capacitor/keyboard": ["@capacitor/keyboard@8.0.5", "", { "peerDependencies": { "@capacitor/core": ">=8.0.0" } }, "sha512-oFXygC4eKYA5l2MdpTR06L2M/4x6e2SLD5yS1T9+UBDKTkzyvhWKEhbYLUaTIBPpLKqlfGudJw1X73S1H9eUzQ=="], + + "@capacitor/push-notifications": ["@capacitor/push-notifications@8.1.1", "", { "peerDependencies": { "@capacitor/core": ">=8.0.0" } }, "sha512-WqzjPKIbYbARMN+GC0XMAJcxJpUUzqgzS/Ny8RODLrro38pQhm3GXYwX2Mwd+LZlLY39rGImkCkrKyQSNfuikA=="], + + "@capacitor/status-bar": ["@capacitor/status-bar@8.0.2", "", { "peerDependencies": { "@capacitor/core": ">=8.0.0" } }, "sha512-WXs8YB8B9eEaPZz+bcdY6t2nForF1FLoj/JU0Dl9RRgQnddnS98FEEyDooQhaY7wivr000j4+SC1FyeJkrFO7A=="], + "@clack/core": ["@clack/core@1.1.0", "", { "dependencies": { "sisteransi": "^1.0.5" } }, "sha512-SVcm4Dqm2ukn64/8Gub2wnlA5nS2iWJyCkdNHcvNHPIeBTGojpdJ+9cZKwLfmqy7irD4N5qLteSilJlE0WLAtA=="], "@clack/prompts": ["@clack/prompts@1.1.0", "", { "dependencies": { "@clack/core": "1.1.0", "sisteransi": "^1.0.5" } }, "sha512-pkqbPGtohJAvm4Dphs2M8xE29ggupihHdy1x84HNojZuMtFsHiUlRvqD24tM2+XmI+61LlfNceM3Wr7U5QES5g=="], @@ -835,6 +882,22 @@ "@internationalized/string": ["@internationalized/string@3.2.7", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-D4OHBjrinH+PFZPvfCXvG28n2LSykWcJ7GIioQL+ok0LON15SdfoUssoHzzOUmVZLbRoREsQXVzA6r8JKsbP6A=="], + "@ionic/cli-framework-output": ["@ionic/cli-framework-output@2.2.8", "", { "dependencies": { "@ionic/utils-terminal": "2.3.5", "debug": "^4.0.0", "tslib": "^2.0.1" } }, "sha512-TshtaFQsovB4NWRBydbNFawql6yul7d5bMiW1WYYf17hd99V6xdDdk3vtF51bw6sLkxON3bDQpWsnUc9/hVo3g=="], + + "@ionic/utils-array": ["@ionic/utils-array@2.1.6", "", { "dependencies": { "debug": "^4.0.0", "tslib": "^2.0.1" } }, "sha512-0JZ1Zkp3wURnv8oq6Qt7fMPo5MpjbLoUoa9Bu2Q4PJuSDWM8H8gwF3dQO7VTeUj3/0o1IB1wGkFWZZYgUXZMUg=="], + + "@ionic/utils-fs": ["@ionic/utils-fs@3.1.7", "", { "dependencies": { "@types/fs-extra": "^8.0.0", "debug": "^4.0.0", "fs-extra": "^9.0.0", "tslib": "^2.0.1" } }, "sha512-2EknRvMVfhnyhL1VhFkSLa5gOcycK91VnjfrTB0kbqkTFCOXyXgVLI5whzq7SLrgD9t1aqos3lMMQyVzaQ5gVA=="], + + "@ionic/utils-object": ["@ionic/utils-object@2.1.6", "", { "dependencies": { "debug": "^4.0.0", "tslib": "^2.0.1" } }, "sha512-vCl7sl6JjBHFw99CuAqHljYJpcE88YaH2ZW4ELiC/Zwxl5tiwn4kbdP/gxi2OT3MQb1vOtgAmSNRtusvgxI8ww=="], + + "@ionic/utils-process": ["@ionic/utils-process@2.1.12", "", { "dependencies": { "@ionic/utils-object": "2.1.6", "@ionic/utils-terminal": "2.3.5", "debug": "^4.0.0", "signal-exit": "^3.0.3", "tree-kill": "^1.2.2", "tslib": "^2.0.1" } }, "sha512-Jqkgyq7zBs/v/J3YvKtQQiIcxfJyplPgECMWgdO0E1fKrrH8EF0QGHNJ9mJCn6PYe2UtHNS8JJf5G21e09DfYg=="], + + "@ionic/utils-stream": ["@ionic/utils-stream@3.1.7", "", { "dependencies": { "debug": "^4.0.0", "tslib": "^2.0.1" } }, "sha512-eSELBE7NWNFIHTbTC2jiMvh1ABKGIpGdUIvARsNPMNQhxJB3wpwdiVnoBoTYp+5a6UUIww4Kpg7v6S7iTctH1w=="], + + "@ionic/utils-subprocess": ["@ionic/utils-subprocess@3.0.1", "", { "dependencies": { "@ionic/utils-array": "2.1.6", "@ionic/utils-fs": "3.1.7", "@ionic/utils-process": "2.1.12", "@ionic/utils-stream": "3.1.7", "@ionic/utils-terminal": "2.3.5", "cross-spawn": "^7.0.3", "debug": "^4.0.0", "tslib": "^2.0.1" } }, "sha512-cT4te3AQQPeIM9WCwIg8ohroJ8TjsYaMb2G4ZEgv9YzeDqHZ4JpeIKqG2SoaA3GmVQ3sOfhPM6Ox9sxphV/d1A=="], + + "@ionic/utils-terminal": ["@ionic/utils-terminal@2.3.5", "", { "dependencies": { "@types/slice-ansi": "^4.0.0", "debug": "^4.0.0", "signal-exit": "^3.0.3", "slice-ansi": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0", "tslib": "^2.0.1", "untildify": "^4.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-3cKScz9Jx2/Pr9ijj1OzGlBDfcmx7OMVBt4+P1uRR0SSW4cm1/y3Mo4OY3lfkuaYifMNBW8Wz6lQHbs1bihr7A=="], + "@isaacs/cliui": ["@isaacs/cliui@9.0.0", "", {}, "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg=="], "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], @@ -937,6 +1000,8 @@ "@openchamber/electron": ["@openchamber/electron@workspace:packages/electron"], + "@openchamber/mobile": ["@openchamber/mobile@workspace:packages/mobile"], + "@openchamber/ui": ["@openchamber/ui@workspace:packages/ui"], "@openchamber/web": ["@openchamber/web@workspace:packages/web"], @@ -1329,6 +1394,8 @@ "@types/sarif": ["@types/sarif@2.1.7", "", {}, "sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ=="], + "@types/slice-ansi": ["@types/slice-ansi@4.0.0", "", {}, "sha512-+OpjSaq85gvlZAYINyzKpLeiFkSC4EsC6IIiT6v6TLSU5k5U83fHGj9Lel8oKEXM0HqgrMVCjXPDPVICtxF7EQ=="], + "@types/superagent": ["@types/superagent@8.1.9", "", { "dependencies": { "@types/cookiejar": "^2.1.5", "@types/methods": "^1.1.4", "@types/node": "*", "form-data": "^4.0.0" } }, "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ=="], "@types/supertest": ["@types/supertest@7.2.0", "", { "dependencies": { "@types/methods": "^1.1.4", "@types/superagent": "^8.1.0" } }, "sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw=="], @@ -1519,6 +1586,8 @@ "better-sqlite3": ["better-sqlite3@12.10.0", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ=="], + "big-integer": ["big-integer@1.6.52", "", {}, "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="], + "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], "binaryextensions": ["binaryextensions@6.11.0", "", { "dependencies": { "editions": "^6.21.0" } }, "sha512-sXnYK/Ij80TO3lcqZVV2YgfKN5QjUWIRk/XSm2J/4bd/lPko3lvk0O4ZppH6m+6hB2/GTu+ptNwVFe1xh+QLQw=="], @@ -1537,6 +1606,8 @@ "boundary": ["boundary@2.0.0", "", {}, "sha512-rJKn5ooC9u8q13IMCrW0RSp31pxBCHE3y9V/tp3TdWSLf8Em3p6Di4NBpfzbJge9YjjFEsD0RtFEjtvHL5VyEA=="], + "bplist-parser": ["bplist-parser@0.3.2", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ=="], + "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], @@ -1643,7 +1714,7 @@ "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], - "commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], + "commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], "common-tags": ["common-tags@1.8.2", "", {}, "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA=="], @@ -1731,7 +1802,7 @@ "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], - "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], + "define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="], "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], @@ -1809,6 +1880,8 @@ "electron-winstaller": ["electron-winstaller@5.4.0", "", { "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", "fs-extra": "^7.0.1", "lodash": "^4.17.21", "temp": "^0.9.0" }, "optionalDependencies": { "@electron/windows-sign": "^1.1.2" } }, "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg=="], + "elementtree": ["elementtree@0.1.7", "", { "dependencies": { "sax": "1.1.4" } }, "sha512-wkgGT6kugeQk/P6VZ/f4T+4HB41BVgNBq5CDIZVbQ02nvTVqAiVTbskxxu3eA/X96lMlfYOwnLQpN2v5E1zDEg=="], + "elkjs": ["elkjs@0.11.0", "", {}, "sha512-u4J8h9mwEDaYMqo0RYJpqNMFDoMK7f+pu4GjcV+N8jIC7TRdORgzkfSjTJemhqONFfH6fBI3wpysgWbhgVWIXw=="], "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -2135,10 +2208,12 @@ "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + "ini": ["ini@4.1.3", "", {}, "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg=="], "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], + "inspect-webkit": ["inspect-webkit@0.0.5", "", { "peerDependencies": { "typescript": "^5" }, "bin": { "inspect-webkit": "dist/cli.js" } }, "sha512-584wP/2nJO1LX74nqHP2j0tQzlK9ZTi+D0Z9qeLQjtUR/LCMXQHGX8M0vrsqBwTeGakF7q5GMFpg81nxUYlCvw=="], + "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], "intl-messageformat": ["intl-messageformat@10.7.18", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "@formatjs/fast-memoize": "2.2.7", "@formatjs/icu-messageformat-parser": "2.11.4", "tslib": "^2.8.0" } }, "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g=="], @@ -2297,6 +2372,8 @@ "klaw-sync": ["klaw-sync@6.0.0", "", { "dependencies": { "graceful-fs": "^4.1.11" } }, "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ=="], + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + "lazy-val": ["lazy-val@1.0.5", "", {}, "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q=="], "leven": ["leven@3.1.0", "", {}, "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="], @@ -2543,6 +2620,8 @@ "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="], + "native-run": ["native-run@2.0.3", "", { "dependencies": { "@ionic/utils-fs": "^3.1.7", "@ionic/utils-terminal": "^2.3.4", "bplist-parser": "^0.3.2", "debug": "^4.3.4", "elementtree": "^0.1.7", "ini": "^4.1.1", "plist": "^3.1.0", "split2": "^4.2.0", "through2": "^4.0.2", "tslib": "^2.6.2", "yauzl": "^2.10.0" }, "bin": { "native-run": "bin/native-run" } }, "sha512-U1PllBuzW5d1gfan+88L+Hky2eZx+9gv3Pf6rNBxKbORxi7boHzqiA6QFGSnqMem4j0A9tZ08NMIs5+0m/VS1Q=="], + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], "negotiator": ["negotiator@0.6.4", "", {}, "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w=="], @@ -2707,6 +2786,8 @@ "promise-retry": ["promise-retry@2.0.1", "", { "dependencies": { "err-code": "^2.0.2", "retry": "^0.12.0" } }, "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g=="], + "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], + "proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="], "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], @@ -2843,7 +2924,7 @@ "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], - "rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], + "rimraf": ["rimraf@6.1.3", "", { "dependencies": { "glob": "^13.0.3", "package-json-from-dist": "^1.0.1" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA=="], "roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="], @@ -2885,6 +2966,8 @@ "serialize-javascript": ["serialize-javascript@6.0.2", "", { "dependencies": { "randombytes": "^2.1.0" } }, "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g=="], + "serve-sim": ["serve-sim@0.1.43", "", { "dependencies": { "inspect-webkit": "^0.0.5", "ws": "^8.21.0" }, "bin": { "serve-sim": "dist/serve-sim.js" } }, "sha512-kLcWWucVZxPD2+73EAhku6iThATYXUTYlt8M4+sw1ZHZYkfFNhqCuhy8g+Z5JHCNUTf93t2qwCHPOPqvbYKRrw=="], + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], "set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="], @@ -2967,6 +3050,8 @@ "spdx-license-ids": ["spdx-license-ids@3.0.23", "", {}, "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw=="], + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + "sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], "ssri": ["ssri@9.0.1", "", { "dependencies": { "minipass": "^3.1.1" } }, "sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q=="], @@ -3065,6 +3150,8 @@ "textextensions": ["textextensions@6.11.0", "", { "dependencies": { "editions": "^6.21.0" } }, "sha512-tXJwSr9355kFJI3lbCkPpUH5cP8/M0GGy2xLO34aZCjMXBaK3SoPnZwr/oWmo1FdCnELcs4npdCIOFtq9W3ruQ=="], + "through2": ["through2@4.0.2", "", { "dependencies": { "readable-stream": "3" } }, "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw=="], + "tiny-async-pool": ["tiny-async-pool@1.3.0", "", { "dependencies": { "semver": "^5.5.0" } }, "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA=="], "tiny-typed-emitter": ["tiny-typed-emitter@2.1.0", "", {}, "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA=="], @@ -3181,6 +3268,8 @@ "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + "untildify": ["untildify@4.0.0", "", {}, "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw=="], + "unused-filename": ["unused-filename@4.0.1", "", { "dependencies": { "escape-string-regexp": "^5.0.0", "path-exists": "^5.0.0" } }, "sha512-ZX6U1J04K1FoSUeoX1OicAhw4d0aro2qo+L8RhJkiGTNtBNkd/Fi1Wxoc9HzcVu6HfOzm0si/N15JjxFmD1z6A=="], "upath": ["upath@1.2.0", "", {}, "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg=="], @@ -3301,9 +3390,9 @@ "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], - "xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="], + "xml2js": ["xml2js@0.6.2", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA=="], - "xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], + "xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], @@ -3345,6 +3434,14 @@ "@babel/preset-env/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@capacitor/cli/fs-extra": ["fs-extra@11.3.3", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg=="], + + "@capacitor/cli/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="], + + "@capacitor/cli/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "@capacitor/cli/tar": ["tar@7.5.13", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng=="], + "@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], "@electron/asar/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], @@ -3375,6 +3472,12 @@ "@heroui/theme/tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="], + "@ionic/utils-fs/@types/fs-extra": ["@types/fs-extra@8.1.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-0dzKcwO+S8s2kuF5Z9oUWatQJj5Uq/iqphEtE3GQJVRRYm/tD1LglU2UnXi2A8jLq5umkGouOXOR9y0n613ZwQ=="], + + "@ionic/utils-fs/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], + + "@ionic/utils-terminal/slice-ansi": ["slice-ansi@4.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ=="], + "@isaacs/fs-minipass/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], "@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], @@ -3383,6 +3486,8 @@ "@npmcli/agent/socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="], + "@npmcli/move-file/rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], + "@openchamber/web/cron-parser": ["cron-parser@4.9.0", "", { "dependencies": { "luxon": "^3.2.1" } }, "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q=="], "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], @@ -3437,15 +3542,13 @@ "@textlint/linter-formatter/pluralize": ["pluralize@2.0.0", "", {}, "sha512-TqNZzQCD4S42De9IfnnBvILN7HAW7riLqsCyp8lgjXeysyPlX5HhqKAcJHHHb9XskE4/a+7VGC9zzx8Ls0jOAw=="], - "@types/plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], - "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], - "@vscode/vsce/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], + "@vscode/vsce/xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="], "@xenova/transformers/sharp": ["sharp@0.32.6", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.2", "node-addon-api": "^6.1.0", "prebuild-install": "^7.1.1", "semver": "^7.5.4", "simple-get": "^4.0.1", "tar-fs": "^3.0.4", "tunnel-agent": "^0.6.0" } }, "sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w=="], @@ -3475,6 +3578,8 @@ "cacache/p-map": ["p-map@4.0.0", "", { "dependencies": { "aggregate-error": "^3.0.0" } }, "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ=="], + "cacache/rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], + "chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], @@ -3499,6 +3604,8 @@ "electron-winstaller/fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="], + "elementtree/sax": ["sax@1.1.4", "", {}, "sha512-5f3k2PbGGp+YtKJjOItpg3P99IMD84E4HOvcfleTb5joCHNXYLsR9yWFPOYGgaeMPDubQILTCMdsFb2OMeOjtg=="], + "encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "encoding-sniffer/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], @@ -3539,6 +3646,8 @@ "is-inside-container/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], + "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], + "keytar/node-addon-api": ["node-addon-api@4.3.0", "", {}, "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ=="], "make-fetch-happen/http-proxy-agent": ["http-proxy-agent@5.0.0", "", { "dependencies": { "@tootallnate/once": "2", "agent-base": "6", "debug": "4" } }, "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w=="], @@ -3601,14 +3710,16 @@ "path-scurry/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], - "plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], - "postject/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="], "prebuild-install/tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="], + "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], + "qrcode/yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="], + "rc/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + "rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], "react-syntax-highlighter/@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="], @@ -3623,10 +3734,12 @@ "rehype-katex/katex": ["katex@0.16.45", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA=="], - "rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + "rimraf/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], "serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], + "serve-sim/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], + "sharp/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], @@ -3685,10 +3798,22 @@ "wsl-utils/is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], + "xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], + "@apideck/better-ajv-errors/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "@azure/identity/open/define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], + "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "@capacitor/cli/tar/chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], + + "@capacitor/cli/tar/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + + "@capacitor/cli/tar/minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], + + "@capacitor/cli/tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], + "@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], "@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], @@ -3697,6 +3822,8 @@ "@electron/universal/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "@npmcli/move-file/rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + "@rollup/plugin-node-resolve/@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], "@secretlint/config-loader/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], @@ -3705,6 +3832,8 @@ "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="], + "@vscode/vsce/xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], + "@xenova/transformers/sharp/node-addon-api": ["node-addon-api@6.1.0", "", {}, "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA=="], "app-builder-lib/@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], @@ -3727,6 +3856,8 @@ "cacache/glob/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], + "cacache/rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + "cli-truncate/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "cli-truncate/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], @@ -3767,6 +3898,8 @@ "mdast-util-mdx-jsx/parse-entities/is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], + "micromark-extension-math/katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], + "node-gyp/make-fetch-happen/cacache": ["cacache@19.0.1", "", { "dependencies": { "@npmcli/fs": "^4.0.0", "fs-minipass": "^3.0.0", "glob": "^10.2.2", "lru-cache": "^10.0.1", "minipass": "^7.0.3", "minipass-collect": "^2.0.1", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "p-map": "^7.0.2", "ssri": "^12.0.0", "tar": "^7.4.3", "unique-filename": "^4.0.0" } }, "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ=="], "node-gyp/make-fetch-happen/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], @@ -3809,6 +3942,12 @@ "qrcode/yargs/yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="], + "rehype-katex/katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], + + "rimraf/glob/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], + + "rimraf/glob/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + "source-map/whatwg-url/tr46": ["tr46@1.0.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA=="], "source-map/whatwg-url/webidl-conversions": ["webidl-conversions@4.0.2", "", {}, "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg=="], @@ -3961,6 +4100,8 @@ "qrcode/yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], + "rimraf/glob/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="], + "node-gyp/make-fetch-happen/cacache/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], "node-gyp/make-fetch-happen/cacache/glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], @@ -3971,6 +4112,8 @@ "qrcode/yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], + "rimraf/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "node-gyp/make-fetch-happen/cacache/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], "node-gyp/make-fetch-happen/cacache/glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], diff --git a/package.json b/package.json index 5da57494..3d6f9a0c 100644 --- a/package.json +++ b/package.json @@ -26,14 +26,17 @@ "build:web": "bun run --cwd packages/web build", "build:ui": "bun run --cwd packages/ui build", "build:electron": "bun run --cwd packages/electron build", + "build:mobile": "bun run --cwd packages/mobile build", "type-check": "bun run --filter '*' type-check", "type-check:web": "bun run --cwd packages/web type-check", "type-check:ui": "bun run --cwd packages/ui type-check", "type-check:electron": "bun run --cwd packages/electron type-check", + "type-check:mobile": "bun run --cwd packages/mobile type-check", "lint": "bun run --filter '*' lint", "lint:web": "bun run --cwd packages/web lint", "lint:ui": "bun run --cwd packages/ui lint", "lint:electron": "bun run --cwd packages/electron lint", + "lint:mobile": "bun run --cwd packages/mobile lint", "clean": "bun run --filter '*' clean", "changelog-card": "node scripts/changelog-card/generate.mjs", "postinstall": "node ./fix-deprecation.js && patch-package", @@ -46,6 +49,21 @@ "electron:dev": "node ./packages/electron/scripts/electron-dev.mjs", "electron:dev:bundled": "OPENCHAMBER_ELECTRON_USE_BUNDLED_UI=1 node ./packages/electron/scripts/electron-dev.mjs", "electron:build": "bun run --cwd packages/electron package", + "mobile:build": "bun run --cwd packages/mobile build", + "mobile:sync": "bun run --cwd packages/mobile sync", + "mobile:add:ios": "bun run --cwd packages/mobile add:ios", + "mobile:add:android": "bun run --cwd packages/mobile add:android", + "mobile:build:android:debug": "bun run --cwd packages/mobile build:android:debug", + "mobile:build:ios:simulator": "bun run --cwd packages/mobile build:ios:simulator", + "mobile:sim:boot": "bun run --cwd packages/mobile sim:boot", + "mobile:sim:install": "bun run --cwd packages/mobile sim:install", + "mobile:sim:launch": "bun run --cwd packages/mobile sim:launch", + "mobile:sim:run": "bun run --cwd packages/mobile sim:run", + "mobile:sim:serve": "bun run --cwd packages/mobile sim:serve", + "mobile:sim:list": "bun run --cwd packages/mobile sim:list", + "mobile:sim:kill": "bun run --cwd packages/mobile sim:kill", + "mobile:open:ios": "bun run --cwd packages/mobile open:ios", + "mobile:open:android": "bun run --cwd packages/mobile open:android", "vscode:dev": "node ./scripts/dev-vscode.mjs", "vscode:build": "bun run --cwd packages/vscode build", "vscode:package": "bun run --cwd packages/vscode package", diff --git a/packages/mobile/HANDOFF.md b/packages/mobile/HANDOFF.md new file mode 100644 index 00000000..92fa01e7 --- /dev/null +++ b/packages/mobile/HANDOFF.md @@ -0,0 +1,218 @@ +# OpenChamber Mobile Handoff + +Status and process reference for the native iOS/Android apps. Written so work can continue after +merge — either by finishing CI/release automation, or by adding features as follow-up fixes. The +apps are feature-complete for a first public/TestFlight-style test; CI/signing is the main gap. + +## What this package is + +`packages/mobile` is a Capacitor workspace that wraps the **hosted mobile web UI** (the `MobileApp` +renderer), not the desktop shell. The native app is a WKWebView (iOS) / Android WebView loading a +bundled copy of the web build; native capabilities are added via Capacitor plugins and two iOS app +extensions. + +- App id / package: `com.openchamber.app`; app name `OpenChamber`. +- Capacitor config: `capacitor.config.ts` (Keyboard `resize: 'none'`, StatusBar overlay, Push + `presentationOptions: []`). +- Renderer: the web build's `mobile.html` entry (`MobileApp`), copied into `dist/` and served by + Capacitor. Mobile-only surfaces (connection onboarding, `Instances`, QR pairing, widgets) exist + only in the Capacitor shell — hosted `mobile.html` in a plain browser does not expose them. + +## Build pipeline (how a native build is produced) + +``` +bun run --cwd packages/web build # web/dist + → scripts/prepare-web-assets.mjs # copy web/dist → mobile/dist, mobile.html → index.html + → cap sync # copy dist → native, sync plugins/config + → xcodebuild / gradle assembleDebug # native binary +``` + +`sync` (in `packages/mobile/package.json`) runs `bun run build && cap sync` inside the mobile env +wrapper. Everything native-facing goes through `scripts/with-mobile-env.mjs`. + +### `with-mobile-env.mjs` (toolchain wrapper — read this before debugging build env issues) + +Every build/deploy script runs through it. It sets, with env overrides honored first: + +- `DEVELOPER_DIR` — `$DEVELOPER_DIR` → `xcode-select -p` → `/Applications/Xcode.app/...`. It + intentionally honors `xcode-select` so an Xcode beta / non-default install is used (hardcoding + the path previously forced builds onto the wrong Xcode / Command Line Tools). +- `JAVA_HOME` — `$JAVA_HOME` → `/opt/homebrew/opt/openjdk@21`. +- `ANDROID_HOME` / `ANDROID_SDK_ROOT` — `$ANDROID_HOME` → `/opt/homebrew/share/android-commandlinetools`. +- `PATH` — prepends `$JAVA_HOME/bin` and `$ANDROID_HOME/platform-tools` (so `adb` resolves). + +On another machine, override these env vars rather than editing the script. `xcode-select` may +point at Command Line Tools; the wrapper's `DEVELOPER_DIR` handling covers that for mobile commands. + +## Commands + +Root aliases (from repo root): + +```sh +bun run mobile:build # web build + prepare-web-assets +bun run mobile:sync # build + cap sync +bun run mobile:build:android:debug # sync + gradle assembleDebug +bun run mobile:build:ios:simulator # simulator build (strips MLKit pod, see quirks) +bun run mobile:open:ios # open in Xcode +bun run mobile:open:android # open in Android Studio +bun run type-check:mobile +bun run lint:mobile +``` + +Android physical-device deploy (adb-based, in `scripts/android-device.mjs`) — **not aliased at +root**, run from the package: + +```sh +bun run --cwd packages/mobile android:devices # list adb devices (want `device`, not `unauthorized`) +bun run --cwd packages/mobile android:install # adb install -r the debug APK +bun run --cwd packages/mobile android:launch # am start MainActivity +bun run --cwd packages/mobile android:run # install + launch +bun run --cwd packages/mobile android:logcat # app logs +``` + +Typical device iteration: `bun run --cwd packages/mobile build:android:debug` then +`android:run`. APK path: `android/app/build/outputs/apk/debug/app-debug.apk`. + +iOS Simulator helpers: `mobile:sim:{boot,install,launch,run,serve,list,kill}` (see +`scripts/ios-sim.mjs`; `serve-sim` for a browser preview of the simulator). + +## Native capabilities implemented + +- **Connection onboarding** — server URL entry, password unlock for locked servers, client-token + issuance, saved connections, `Instances` management sheet, auto-connect to the last instance on + launch. Deleting the active instance resets the runtime to the connect screen. +- **QR pairing** — `@capacitor-mlkit/barcode-scanning`. Android's Google code scanner module is + downloaded on first scan (needs Play Services + network); `mobileQrScan.ts` installs/awaits it + and retries. CAMERA permission + `NSCameraUsageDescription` declared. +- **Secure storage** — `@aparajita/capacitor-secure-storage` for connection tokens. +- **Deep links** — `openchamber://` URL scheme; a reusable intent vocabulary (`apps/deepLinks.ts`) + used by notification taps, widgets, and Control Center. Cold-launch intents are stashed. +- **Push notifications** — iOS APNs + Android FCM (see below). Presence-aware routing suppresses a + device's push when an interactive (desktop/web) client is visible. +- **iOS widgets + Control Center + Notification Service Extension** — WidgetKit extension + (`OpenChamberWidget`), a Control Center control, and an NSE (`OpenChamberNotificationService`) + that refreshes widgets from push. All share the App Group `group.com.openchamber.app`. +- **Native chrome** — status bar (iOS overlay + safe-area; Android inset + themed background), + keyboard handling (iOS CSS inset; Android native `adjustResize`), edge-swipe session switch, + back-button handling, app-icon badge. +- **App icons** — iOS `AppIcon`; Android adaptive launcher icon; notification small icon + (`ic_stat_notify`). + +## Push / notifications architecture + +- Registration: on launch the app registers a device token — **iOS → APNs, Android → FCM** — and + sends it to the connected server tagged with `platform` (`ios`/`android`). +- The server forwards notification-worthy events to a signed **relay**; the relay routes each token + to APNs or FCM by its bound platform. The app itself only needs to obtain and register the token. +- **Presence-aware suppression**: each client reports foreground visibility + its platform; a + mobile push is skipped while an interactive (desktop/web/vscode) client is visible (it already + shows the in-app notification). Gated on the desktop's visibility, never the phone's own. +- Foreground behavior: iOS suppresses the banner via `presentationOptions: []`; the web/PWA service + worker suppresses when a window is focused. + +## Platform config specifics + +### iOS (`ios/App`) + +- Extensions: `OpenChamberWidget` (WidgetKit, deployment 17.0) and `OpenChamberNotificationService` + (NSE, 15.5), both hand-wired into `App.xcodeproj/project.pbxproj` and embedded via a copy phase. +- App Group `group.com.openchamber.app` in all three targets' entitlements (app + widget + NSE). +- `Info.plist`: `CFBundleURLTypes` scheme `openchamber`, `NSCameraUsageDescription`. +- Push entitlement (aps-environment) required. +- APNs `mutable-content: 1` (set server/relay side) wakes the NSE to refresh widgets. + +### Android (`android/app`) + +- `google-services.json` (committed; Firebase project `openchamber-8bf7e`). The Google Services + Gradle plugin is applied conditionally when the file exists; `@capacitor/push-notifications` + brings `firebase-messaging`. +- Manifest: permissions `INTERNET`, `CAMERA` (+ optional camera feature), `POST_NOTIFICATIONS` + (Android 13+; older versions allow notifications by default). `windowSoftInputMode=adjustResize`. + ML Kit `com.google.mlkit.vision.DEPENDENCIES=barcode_ui` meta (preloads the code scanner). FCM + `default_notification_icon=@drawable/ic_stat_notify`. +- Adaptive launcher icon: full-bleed color background + `ic_launcher_foreground` (sources under + `packages/mobile/assets/`, regenerable with `@capacitor/assets`). + +## Quirks / gotchas + +- **iOS Simulator + MLKit**: `GoogleMLKit` barcode has no arm64-simulator slice, so + `scripts/ios-sim-build.mjs` temporarily strips the `CapacitorMlkitBarcodeScanning` pod, builds, + then restores it. Device builds include it normally. +- **Android WebView version**: the UI uses `color-mix()` (Tailwind v4 + theme) which needs + Chromium **111+**. An outdated Android System WebView renders translucency/selection wrong — tell + testers to keep Android System WebView updated (or use a device with a current one). +- **Capacitor stream transport is locked to SSE** on the native apps (native WebSocket streaming is + unreliable on Android). The Chat transport setting shows SSE selected and disables the others in + the Capacitor shell. +- **Android push needs the app rebuilt with `google-services.json`**; without it `register()` used + to crash ("Default FirebaseApp is not initialized"). Registration is gated to iOS/Android natives. + +## Validation + +```sh +bun run type-check:mobile +bun run lint:mobile +bun run mobile:build:android:debug +bun run mobile:build:ios:simulator +``` + +Web-inherited build warnings (KaTeX font URLs, `onnxruntime-web` eval, chunk-size) are expected and +non-fatal. + +## The gap: CI / release automation (next work) + +The apps build and deploy locally; there is no CI/signing/publishing yet. To take them to +TestFlight / Play internal testing: + +### iOS + +- Apple Developer account; App IDs for the app **and** both extensions + (`com.openchamber.app`, `.OpenChamberWidget`, `.OpenChamberNotificationService`), each enabled for + the **App Group** and (app) **Push**. +- Signing certificate + provisioning profiles for all three targets (extensions need their own). +- App Store Connect API key for non-interactive TestFlight upload (`xcodebuild archive` + + `notarytool`/`altool`, or fastlane `gym`+`pilot`). +- Runner: macOS with the same Xcode as `DEVELOPER_DIR`. + +### Android + +- Release keystore (kept as a CI secret); build a signed **AAB** (`bundleRelease`) — the debug + scripts here produce an unsigned debug APK. +- Play Console app + internal testing track; a Play service account for automated upload (fastlane + `supply` or the Play Developer API). +- `google-services.json` is committed, so FCM builds in CI without extra setup. +- Runner: Linux with the Android SDK + `openjdk@21`. + +### Notes for CI + +- Reuse `with-mobile-env.mjs`'s env contract (`DEVELOPER_DIR`, `JAVA_HOME`, `ANDROID_HOME`) — set + them in the workflow instead of relying on local Homebrew paths. +- Relay/push secrets (APNs key, FCM service account) live in the relay infrastructure, not app CI. +- Version/build-number bumping is not automated yet. + +## Store review readiness + +Xcode build warnings do not block review; the concrete items are store requirements, not code +quality. Done in-repo vs. to-do at release time: + +**Done in-repo (this branch):** + +- iOS app **Privacy Manifest** (`ios/App/App/PrivacyInfo.xcprivacy`) — declares no tracking and the + required-reason UserDefaults API (App Group snapshot). Bundled SDKs ship their own manifests. +- iOS **`ITSAppUsesNonExemptEncryption = false`** in `Info.plist` (skips the per-build export- + compliance prompt). +- iOS camera + local-network usage strings; Android SDK levels (`target/compile 35`, `min 24`) meet + Play's current requirements. + +**To-do at release (console / infra, not code):** + +- **Privacy policy URL** — required by both stores because the app uses camera + notifications. +- iOS **App Privacy nutrition label** (App Store Connect) and Android **Data Safety** form — declare + what's collected (device push token; the app otherwise talks only to the user's own server). +- **Production APNs** for App Store / TestFlight builds: the app's `aps-environment` must be + `production` in the release build, and the relay must send to production APNs (not sandbox). +- **Demo instance + credentials** for reviewers — the app connects to a user's server, so review + needs a reachable test instance (App Store 2.1 / Play). +- **Guideline 4.2 (minimum functionality)** — WebView-wrapper apps can be scrutinized; cite the + native features (push, widgets, Control Center, QR pairing) in the review notes. +- Signing/upload as covered in the CI section above (all three iOS targets; signed Android AAB). diff --git a/packages/mobile/README.md b/packages/mobile/README.md new file mode 100644 index 00000000..6f21cb5f --- /dev/null +++ b/packages/mobile/README.md @@ -0,0 +1,71 @@ +# OpenChamber Mobile + +Capacitor shell for the dedicated OpenChamber mobile web surface. + +The mobile package reuses the web build, then rewrites `mobile.html` to `index.html` in `packages/mobile/dist` so native iOS/Android always launch `MobileApp` instead of the hosted surface selector. + +## Runtime Model + +- The native app bundles the mobile UI only; it does not embed the OpenChamber web server or OpenCode server. +- On first launch in Capacitor, the app shows a connection screen for an existing OpenChamber server. +- Connections are saved locally in the app and can be managed from the mobile overflow menu under `Instances`. +- The connection screen and `Instances` menu item are Capacitor-only. Hosted `mobile.html` in a normal browser keeps the regular web behavior. +- Password-protected OpenChamber servers can be unlocked from the mobile app. The app stores the issued client token with the saved connection. + +## Commands + +Run these from `packages/mobile`, or use the root `mobile:*` aliases. + +- `bun run build`: builds `packages/web` and prepares mobile web assets. +- `bun run sync`: prepares assets and runs `cap sync`. +- `bun run add:ios`: creates the native iOS project. +- `bun run add:android`: creates the native Android project. +- `bun run build:android:debug`: builds a debug Android APK without launching an emulator. +- `bun run build:ios:simulator`: builds an iOS Simulator app without launching Xcode or Simulator. +- `bun run sim:run`: boots a simulator if needed, installs the built iOS app, and launches it. +- `bun run sim:serve`: starts `serve-sim` in detached JSON mode and prints the browser preview URL. +- `bun run sim:list`: lists running `serve-sim` streams. +- `bun run sim:kill`: stops running `serve-sim` streams. +- `bun run open:ios`: opens the iOS project. +- `bun run open:android`: opens the Android project. + +## Headless Quickstart + +```sh +bun run build +bun run sync +bun run build:ios:simulator +bun run build:android:debug +``` + +These commands build and sync the native projects without launching Xcode, Android Studio, Simulator, or an emulator. + +## Local Tooling + +The default scripts assume the local Homebrew/Xcode paths prepared for this workspace: + +- Xcode: `/Applications/Xcode.app/Contents/Developer` +- JDK 21: `/opt/homebrew/opt/openjdk@21` +- Android SDK: `/opt/homebrew/share/android-commandlinetools` + +Override `DEVELOPER_DIR`, `JAVA_HOME`, `ANDROID_HOME`, or `ANDROID_SDK_ROOT` when using a different local setup. + +Required local tools: + +- Xcode with iOS Simulator support. +- CocoaPods for iOS dependency installation. +- JDK 21 for Android Gradle builds. +- Android SDK command-line tools with platform/build-tools 35. + +## Troubleshooting + +- If `xcodebuild` reports that the active developer directory is Command Line Tools, keep using the provided scripts or set `DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer`. +- If Android builds fail with `Unable to locate a Java Runtime` or `source release: 21`, install/use JDK 21 and set `JAVA_HOME` accordingly. +- If Android SDK packages are missing, install `platform-tools`, `platforms;android-35`, and `build-tools;35.0.0`, then accept SDK licenses. +- If CocoaPods cannot find Capacitor pods after reinstalling dependencies, run `bun install` from the workspace root, then rerun `bun run sync`. +- If connecting to a remote OpenChamber server fails from the app while `/health` works in curl, check that the server build includes the packaged-client CORS allowlist for `capacitor://localhost` and local dev origins. +- If `serve-sim` preview says the stream is not producing frames, check the raw MJPEG stream before assuming the simulator stopped. In prior testing the raw stream worked while the browser preview UI stayed stale. + +## Generated Assets + +The native projects currently use Capacitor-generated launcher and splash assets. Replace them before release branding work. diff --git a/packages/mobile/android/.gitignore b/packages/mobile/android/.gitignore new file mode 100644 index 00000000..48354a3d --- /dev/null +++ b/packages/mobile/android/.gitignore @@ -0,0 +1,101 @@ +# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore + +# Built application files +*.apk +*.aar +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ +# Uncomment the following line in case you need and you don't have the release build type files in your app +# release/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +# IntelliJ +*.iml +.idea/workspace.xml +.idea/tasks.xml +.idea/gradle.xml +.idea/assetWizardSettings.xml +.idea/dictionaries +.idea/libraries +# Android Studio 3 in .gitignore file. +.idea/caches +.idea/modules.xml +# Comment next line if keeping position of elements in Navigation Editor is relevant for you +.idea/navEditor.xml + +# Keystore files +# Uncomment the following lines if you do not want to check your keystore files in. +#*.jks +#*.keystore + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild +.cxx/ + +# Google Services (e.g. APIs or Firebase) +# google-services.json + +# Freeline +freeline.py +freeline/ +freeline_project_description.json + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +fastlane/readme.md + +# Version control +vcs.xml + +# lint +lint/intermediates/ +lint/generated/ +lint/outputs/ +lint/tmp/ +# lint/reports/ + +# Android Profiling +*.hprof + +# Cordova plugins for Capacitor +capacitor-cordova-android-plugins + +# Copied web assets +app/src/main/assets/public + +# Generated Config files +app/src/main/assets/capacitor.config.json +app/src/main/assets/capacitor.plugins.json +app/src/main/res/xml/config.xml diff --git a/packages/mobile/android/app/.gitignore b/packages/mobile/android/app/.gitignore new file mode 100644 index 00000000..043df802 --- /dev/null +++ b/packages/mobile/android/app/.gitignore @@ -0,0 +1,2 @@ +/build/* +!/build/.npmkeep diff --git a/packages/mobile/android/app/build.gradle b/packages/mobile/android/app/build.gradle new file mode 100644 index 00000000..d86e8dd7 --- /dev/null +++ b/packages/mobile/android/app/build.gradle @@ -0,0 +1,50 @@ +apply plugin: 'com.android.application' + +android { + namespace "com.openchamber.app" + compileSdk rootProject.ext.compileSdkVersion + defaultConfig { + applicationId "com.openchamber.app" + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + versionCode 1 + versionName "1.0" + aaptOptions { + // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. + // Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61 + ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' + } + } + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } +} + +repositories { + flatDir{ + dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs' + } +} + +dependencies { + implementation fileTree(include: ['*.jar'], dir: 'libs') + implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" + implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion" + implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion" + implementation project(':capacitor-android') + implementation project(':capacitor-cordova-android-plugins') +} + +apply from: 'capacitor.build.gradle' + +try { + def servicesJSON = file('google-services.json') + if (servicesJSON.text) { + apply plugin: 'com.google.gms.google-services' + } +} catch(Exception e) { + logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work") +} diff --git a/packages/mobile/android/app/capacitor.build.gradle b/packages/mobile/android/app/capacitor.build.gradle new file mode 100644 index 00000000..32b2e26b --- /dev/null +++ b/packages/mobile/android/app/capacitor.build.gradle @@ -0,0 +1,24 @@ +// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN + +android { + compileOptions { + sourceCompatibility JavaVersion.VERSION_21 + targetCompatibility JavaVersion.VERSION_21 + } +} + +apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" +dependencies { + implementation project(':aparajita-capacitor-secure-storage') + implementation project(':capacitor-mlkit-barcode-scanning') + implementation project(':capacitor-app') + implementation project(':capacitor-keyboard') + implementation project(':capacitor-push-notifications') + implementation project(':capacitor-status-bar') + +} + + +if (hasProperty('postBuildExtras')) { + postBuildExtras() +} diff --git a/packages/mobile/android/app/google-services.json b/packages/mobile/android/app/google-services.json new file mode 100644 index 00000000..3d271911 --- /dev/null +++ b/packages/mobile/android/app/google-services.json @@ -0,0 +1,29 @@ +{ + "project_info": { + "project_number": "519320768353", + "project_id": "openchamber-8bf7e", + "storage_bucket": "openchamber-8bf7e.firebasestorage.app" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:519320768353:android:e70fd113d740a86c233f20", + "android_client_info": { + "package_name": "com.openchamber.app" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyADEQ6yRHXBMlwbaG6y8Vb1elC2q7mx6-A" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/packages/mobile/android/app/proguard-rules.pro b/packages/mobile/android/app/proguard-rules.pro new file mode 100644 index 00000000..f1b42451 --- /dev/null +++ b/packages/mobile/android/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/packages/mobile/android/app/src/main/AndroidManifest.xml b/packages/mobile/android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..0229e443 --- /dev/null +++ b/packages/mobile/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/mobile/android/app/src/main/java/com/openchamber/app/MainActivity.java b/packages/mobile/android/app/src/main/java/com/openchamber/app/MainActivity.java new file mode 100644 index 00000000..0d8e2a72 --- /dev/null +++ b/packages/mobile/android/app/src/main/java/com/openchamber/app/MainActivity.java @@ -0,0 +1,5 @@ +package com.openchamber.app; + +import com.getcapacitor.BridgeActivity; + +public class MainActivity extends BridgeActivity {} diff --git a/packages/mobile/android/app/src/main/res/drawable-land-hdpi/splash.png b/packages/mobile/android/app/src/main/res/drawable-land-hdpi/splash.png new file mode 100644 index 0000000000000000000000000000000000000000..e31573b4fc93e60d171f4046c0220e1463075d9e GIT binary patch literal 7705 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 wKmFJ0_JHhk1&0B>;%YXATM literal 0 HcmV?d00001 diff --git a/packages/mobile/android/app/src/main/res/drawable-land-mdpi/splash.png b/packages/mobile/android/app/src/main/res/drawable-land-mdpi/splash.png new file mode 100644 index 0000000000000000000000000000000000000000..f7a64923ea1a0565d25fa139c176d6bf42184e48 GIT binary patch literal 4040 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 XyOn+%7f`N3b2T^T3uj5+eShz7v)7qy literal 0 HcmV?d00001 diff --git a/packages/mobile/android/app/src/main/res/drawable-land-xhdpi/splash.png b/packages/mobile/android/app/src/main/res/drawable-land-xhdpi/splash.png new file mode 100644 index 0000000000000000000000000000000000000000..807725501bdd92e94e51e7b2b0006f69e0083a0b GIT binary patch literal 9251 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 literal 0 HcmV?d00001 diff --git a/packages/mobile/android/app/src/main/res/drawable-land-xxhdpi/splash.png b/packages/mobile/android/app/src/main/res/drawable-land-xxhdpi/splash.png new file mode 100644 index 0000000000000000000000000000000000000000..14c6c8fe39fcd51a0414866ad28cbe8ff3acb060 GIT binary patch literal 13984 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®q@N z14_H8ofW)l{|J8q+a)eH)I0r)>WXdzV%7J>PA~6_J)KLT90iYa^K=Wz7D!OybzqSru=f4?|KFl;Y)gP_H6V4x`~kZ6fE(xM1&;?72-TZNk+0 zr+Crr5yl%Iy@vfmt3eYFl!jIvPGFz^8Ek+2`48O1_pCX3xNWh-zBa{rIcc%+=|XVj zANYTg&s}TKb#OztQrCW(Xk?V^i{`q~%HtcveTxq(_HKeC9GzrtguMT4Nvs@KakPTA z9>*8bBZmLz`lK5=l)=b|=dT3a5ag^a1^znZyx5QKfUb1b9yacArRp%3@QWo(hrsCU z-K!-=jDmv!zb7XT>)r|-Z0Ry}lk2;dk-ECqMwr_nKN#x*X6~B5hVIN>6$1HwBz3Of z=Pk){AL5*=d90f17_qZEJLm;Q%WMdX=*N&!ki@E&cy7?>{1ssAH(tACtp*r@d^til z)x(1#6(kPD+joSF&J3sxJU@{-sWCS+pZq{Gsx=?z4wP;>?)1yHv0?X?VP{}cX4~aH zxeBPKw_rgW8rvewS1W2#^y+c>-183iMbJCqc38RN_o~__9-n|jcd&oA`m7*&Fqqpc z;Tev*0LS-ZK47Sq1unfvP1S43uA12P?PJmI8BeTYPr~R*tYUm^0;U%Hmu?bSZHEK6 zPjsW=E67Kq-&trmf;)UkmRABH2U)V)-eRT$j(%G12lLMsThSsU10iP#{)ZnvjzN$d z*K%P3`}oqyvpWP~venr>3viH8^`)Ma*=B31hw*Q+tqE>i2y7w!(o^lI^Yss^=tHW( z;cnCT(%B1gLz+TRGW9roFjI1EQTu-u`(f#RmZ8;FSN(bsC1J;+(i_R6mrW=yYx$cy z#%QKVrEx~kVMg~yo?^N28Wnk6x%L;J8i|*|ANEiNjq(Vhzuzl3ikpA*G!Z}kLAzAI z9qnySo%D|AuJj12%h;Otqjs(>LPj?rNdeU8so>P(C>XMzlho94ZD#w=cCOOU;=3&^ zsqAG!i{~lY271D|m>ztPV`)X@FO_;`wPjppYNQpM+ncvtz1lZjN>!Q^*I}T%uP78Z7tbV2$q3W_)14=kLFyJ z1GqL6T>ClgeZorL!}xP4f%OB_EsmJ`uw7dGWNV9OLlhb|UMpVhc{4@Bhh`tO!ZqzD zhusd<=K^ah!L@gQ?6dOpI-ge^e>S5W9eII57Zu16eU?GRbgKTeVk9yS{iK|O(zLR> zheb?;jwGCHS80NCn=jKxgJ>}qu4l%5NPihjzazGv#J?Jcyl;<#IW&x4mm>nrW8>}C z3U@aeD~)*F(0o^2{GnKVm$Jr#aZE ztl~TOkM^SdzJapQ((!-i8b!RkVQBKkL`2ZCBuy!qI1L{3Er526plVols~68U-^9Px zR(3{j;Z9RHX^muc0dUywJ|`yyZFf=k&-Gb#m4u73Lm5Ks%BfHj%2|gjn#i> zLC5pO$2Em9H;qoKQmMtl<@wgtPF1%2HariD5O~u>8=^*J&au~JH%Ih@&2Uging3U_ z0bzfKucW$ZHSx}!#buB?+-J)%RQbbXM-!BJTS&#dU_@lxU6>te2O+9 z@F{F{Nb!;{Cd`Gx+$G?11aB~S#wIH%D=*=7f7H@D@%B1)&bF$@t3JDq4l*%(wJTlh zo`?uMq{YilKUewPNaC)GuOr<8j9&ofqRU__BRUX^x8Cj3a;a$rXzgXqW>LR#CUn%~m)t zYC&ol(gAkbc^fd`xWU&bk5vT6KbFmsR=O78Bn%t7 znbw&=c+|T&#r+bls5rU6D#HMvqA<|;)BV%jOMonkm^p$7Vcel-Wwn$=uAJv&(8W>% z9))Fxpl*(%E#wFm_m!U~2HqgZs^2vaGeY(UfYKrSHV}w^D0N6!se5Ewy)Yy-!(2

aKj2hWG7>znxs|SE zN4rHtiSPqLskWp(?(_YYwgq+1@8v+~8As|(bC>$D(atG3ZE8-ZM3SVcg|vHQz$I=!(A`k`5= zOqR>&%G)$)k*QLz7MTB9wleWpv&N9Sta64wy}3Ytd?x!Ja8z>(z~(3UNFu^eFmn#6 zw!!gUxOuZi$PQIs*ixfZR3iLyADJ z5&s%tPfk>V!x|A-;oq%1!yk9H$UBP0ToA*EDtz(^!_AnF1bBQ7joj|? z5b)gSI8c8O$PYFE!vXJ<4gebg*9G9P2wcB{#kv0FItc5T@PDNo)}Rh4Us}L{e}xzW zhwt`)j`M)mP=G6H0;^&q=I0{jU%bIRkF#uLF;{vVC&H|_uc literal 0 HcmV?d00001 diff --git a/packages/mobile/android/app/src/main/res/drawable-land-xxxhdpi/splash.png b/packages/mobile/android/app/src/main/res/drawable-land-xxxhdpi/splash.png new file mode 100644 index 0000000000000000000000000000000000000000..244ca2506dbe0fd8f6a05520ac7d1a629ea81438 GIT binary patch literal 17683 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 literal 0 HcmV?d00001 diff --git a/packages/mobile/android/app/src/main/res/drawable-port-xxhdpi/splash.png b/packages/mobile/android/app/src/main/res/drawable-port-xxhdpi/splash.png new file mode 100644 index 0000000000000000000000000000000000000000..bfabe6871a17a5e95b78fb30d49b7d2b4d2fe4c0 GIT binary patch literal 13346 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

VwO4Hu8X}%XHUAz_J@50rzbkIsat>4oWtQt< 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 literal 0 HcmV?d00001 diff --git a/packages/mobile/android/app/src/main/res/drawable-port-xxxhdpi/splash.png b/packages/mobile/android/app/src/main/res/drawable-port-xxxhdpi/splash.png new file mode 100644 index 0000000000000000000000000000000000000000..6929071268eb03ee0f088142b6523566b78550e2 GIT binary patch literal 17489 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 KR&><$`~L^lv%S~= literal 0 HcmV?d00001 diff --git a/packages/mobile/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/packages/mobile/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 00000000..c7bd21db --- /dev/null +++ b/packages/mobile/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + diff --git a/packages/mobile/android/app/src/main/res/drawable/ic_launcher_background.xml b/packages/mobile/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 00000000..d5fccc53 --- /dev/null +++ b/packages/mobile/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/mobile/android/app/src/main/res/drawable/ic_stat_notify.xml b/packages/mobile/android/app/src/main/res/drawable/ic_stat_notify.xml new file mode 100644 index 00000000..e984b381 --- /dev/null +++ b/packages/mobile/android/app/src/main/res/drawable/ic_stat_notify.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/packages/mobile/android/app/src/main/res/drawable/splash.png b/packages/mobile/android/app/src/main/res/drawable/splash.png new file mode 100644 index 0000000000000000000000000000000000000000..f7a64923ea1a0565d25fa139c176d6bf42184e48 GIT binary patch literal 4040 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 XyOn+%7f`N3b2T^T3uj5+eShz7v)7qy literal 0 HcmV?d00001 diff --git a/packages/mobile/android/app/src/main/res/layout/activity_main.xml b/packages/mobile/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 00000000..b5ad1387 --- /dev/null +++ b/packages/mobile/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/packages/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/packages/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..24335ca3 --- /dev/null +++ b/packages/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/packages/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/packages/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 00000000..24335ca3 --- /dev/null +++ b/packages/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/packages/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/packages/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..0d8378276806a95297e95082e4566e5b2ff66f40 GIT binary patch literal 2398 zcmV-k38D6hP)lGufLtz@Udbr|1CZ0{6o5LbUC2_k721aoYzqKu)LAz0l8}Kg-gkOC?K|ERr{GUfHr`i$q05>BW7XJb5HjrcAPA z$r5dg7>)`$fSj=eY-Iob{ZhJgX-StZolKoNRUSTkC@WX4l!_HAN~KDb^!~wv2QqEi zG;Kp$`XmKE`4k|R%O&5xe|InD?%li6xN&1in>MYqZQEAv+_@u%48$0RZ`Qu_Ao zD}@Uema=8bO3j)zm846SEGeZ*m69@L%1E(d#iUQ4KHB!gi4&?}j4^)vczON$^)K@T z1V9cC^+$V_FJCUXbLW=q*|W>0O`B8$Fsl+JO6XOiMh&T6y}Ftae~%e6Mt?6~zPthl zSX_LEHZcZc!BHz#tk5~)Gh@F4$l-AOk?SWXKS?b?cTKJa|yYXKtQ>XYqVCp8@2I)tYVFwyAbx%$QMDuU@StFlWvjeQ2}< zQ1DLt4GH(}-(N0VxZv*p=FJ;*;Z}~(IzyDxtNo= zc?O<^XEM*|4X7jo$PxQ?qobphgqt*JBF~;ZlU1u$DZjugm=O!9TD7V!7*pB3d$*fd zzkdCyTr+RpJee?Ig3O*hTP9DQtQv&J%9k&nbnDhlon z*Q{BiUW-0}Gi%l?{hTsoihd6_y?y&u`#|1DjvP_00c?O_d@jrmK=E{6zI@Rzf(V`P zz~=x(MMX)@oH4n)w<;HLp*^(OwQHA~ zsF7>euF0@r!`v?40Ape%;vzzTJPGK>;X}lJ(5gKI1;Z2isp;4Hu;T%kB=FFLDqKu2V!atZ7V`6@oC_rGQ zcsIx#(k52&8-Q};$RP&~9MCrjFVv}1M;C%d@n+E~(ge5y?}f>+7(m5z5KIFXZ@7to zuwlc73QXn7m32%$iJ=-bYNYNTw@z%;t5+|LxQUyVEnB99@0S3DhllH1CN^Oa@C4+& zdGltuefzd7TC_+PY23WwrYl#j=va6g;v2NifC-0TPG}6=hNhrp@DXI)wQE-yG-!|v zA3j|BQ6cbafI>q<)r(k`rJvMy00nb^JPGa~V7LQQ!Spa8ObHD%p#Yi)*t9cdOJBkR zJYMI{on_#_feMoO3^;%+%Tm+gZ%hud9zA-rY7Cmvv13QoCO|QH7hqvdq$x#;6p{Ar z+iTE>sV-QsK!GM+0uYL7Xb)Gw0%XNrm>OWA6{H#q7cO)spKuLen1B+Vfz0tp1b}!t zIHpIB9tsL(X@ChDfGiK7;3v$E{0y>%pXSb;tMLuILu5>-K#60-h!JW|2>}yy09lr$ z01;KAB>;uVoH=twuhXYb%hjt_H69|OCO0DnN(h*s1jzE>Co~Gqfrl7_*a_ajq|g{| zS`+jDS(c^zLs`VsqKU}S9NaeEDKX##JwRb$VG7Xt_3JeX1|ZDJ2FUsuAfjS`wgIx@ z*B~1p%e#r$09pSyKoJoU8VTdNeE|?@h~H-Od-v|ugpwo^FUG=?3n1Fjo^l8I+MYdo z{HI_R85t?rvSriclTrkkI2pW2DUuo>lY$HwFhE+jZmoG6$t`7IbBxBP07b_Z?TLyX zJ$j^Xj~t5yo03OT0c6^ctBo8vQd5;DPoC(@;hB6|v`=W;hwWg1f-K3};Uqu;z`q3$ zT4OkeYU0kFJ9S3|{_&e_A0uY+4%r89BsFWf8CdDX!W52|&i2NN#Dv zO8}UC`}V16XULF2rca;lKO2V$Z9S80(v^k70~FSA!GOT-1{!66@&-^`jUlt|)Txv1 zm_Wenc3@7-)uBTNtsQwDjY*6o3qaluFL1zs9n!~-AFJ8}4kZo%G9_7W8e}Sm@C^5C z?VvSOCR3+Qt@~l-@B$j+`Lg^9px+&T!P9NrxKT9>Z%6fzf;=Emt$n_V3XeDvfw1c5bOQG6rLL9`FpP z161CQ`ci|&v@ko4)tRy?8iMCx}t>#AYIOYBYqcXn>(DeS+=~=kJaIFJ8P@GH1@Lr#Rpr_=MlN=V&m; zfN5h6SN$mg2OK~N9VEw<Y3@eUt%oErX}4pUXO@geCx3-a#q= literal 0 HcmV?d00001 diff --git a/packages/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/packages/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..48f80b576ec596c437820ecd235c287d50531636 GIT binary patch literal 2915 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 literal 0 HcmV?d00001 diff --git a/packages/mobile/android/app/src/main/res/mipmap-ldpi/ic_launcher.png b/packages/mobile/android/app/src/main/res/mipmap-ldpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..dd12e409048fcf270cbd6072c61b6b2f3ab8aa76 GIT binary patch literal 764 zcmVs|hc_SzV1zxEcNuZGX#0(iyC%QQ9IbG}n%PzdL@BEmVcjg@!P^3ta zqF*ljpiZX)Ns{=X@1G zi9`ZUrxR|sn{DZz-aY;rNP6F>*=%BCV}t8Ouh&DT)1gwS;Njr`kw^p{j|bP+*C?0E z464;?addQq&CN|bKR-91at!-~_%L`<;+ZkwdbQHtG z!(1=qm|&)+rr`JcvADR%Km;i(kSK~D5gHj8;od`Oq6qEo?qYU!mSL#Qxn>>j{R*o{ks?I{^6CwJdKn+gIfyR+0000|k1|%Oc%$NbBI14-?iy0WWg+Z8+Vb&Z8prF5} zi(^Q|oU`W)85tOOnhpNUPOInb(cn0iz(X?nAREE18>I900<+EspwSGTu6{1-oD!M< DwFE7& literal 0 HcmV?d00001 diff --git a/packages/mobile/android/app/src/main/res/mipmap-ldpi/ic_launcher_foreground.png b/packages/mobile/android/app/src/main/res/mipmap-ldpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..79528e933358f80e63f352b930b10f18040625ba GIT binary patch literal 1235 zcmV;^1T6cBP)RHEB_-!97VA5_@nE&snE)Y}Yc7Yw+3D%&Nh~ZZKp+qR>~lDr_xKwF zM!Xc*n+N8}%gV|cRVWm(`T2Pm9v)7L#bT1r=aWXGF^OPJO-(U^AV%?79Keiy7XMmfXmvWB zAdyH&ySuneg8A z^)=kw+`#_+J`Ht#eolj2US5L5Vu7-Qw61Vmpm z7!1_N$OwrmNf3lRZJ@TcHWEV`eFUMf@AY~i7z~0=rvr>Qp->0|0|O9=L}+$F}(3B zWilCs`x(Og9S|xe#J;{hG7tzPF_Ct3bi{LVa@L-@xqhtt_{nOuQp?NBluoBhB8XC{ zr0VPI?}0E8V*WuekH?b?g+gd5s-U1C?egOuNb~37s=d6tJc`Aa&*vjC`XmwwU1}gu zS63G%Mlu)-(h(&T3V*{f&sm^-tc3lBAc!$6VKGCa8L>iKUtg!qv%S5IWk-y~VvXUm z&t1Yk6#TNRhedvVe*Sx{R+~6JK8DlNQ!Gysd3kw1W}?Xd literal 0 HcmV?d00001 diff --git a/packages/mobile/android/app/src/main/res/mipmap-ldpi/ic_launcher_round.png b/packages/mobile/android/app/src/main/res/mipmap-ldpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..9f6912e5cf087c8f7c733be76024b5d9041d1738 GIT binary patch literal 1630 zcmV-k2BGZkQ347E#O1Qca%Fj7t1Xa- zNCXug41pkPh#F)wC_7?g(EuT+fW|~$v{vXsh%qJj;)8^QQi2eqtpDc!b>?Qg%*^E~ zCpo!uXU_7Q|Mz|8d}mx-oGbu50AvB^2Jk05MKlM3BXI} zl+DdesRrd204D+L+9{ZD0Fy=_>+9<_AobdyOaM6kB_Q7dXf+yrV`D=ASmx*FrK-Oj>%58w|oRn+V5?rvBt76b(a;qKkLwsve|0`?n#J%gf8Cs;a`_!-r8&P#~%#CMII<-o4njZyy2z z0^s4{Aqw5PbxYc?E&F`@_)*4KTU*mJW1|twajkjSc~g0#fq?;0AuTNp?d|P2ckUea z@82&l2M-=Zad9!Gr>CXe(W6JBdb(X>=0H#b+-tfx<(A|oRMy}iBmK$ObWMcF8~08yEX7cb((i4!sz2TMsw5w)~$ zI6FIQ^P`!W8I+Zki6V?VZ*OlTCnw|Hy?ZidPEHQ4U%xIY>Hx81xF|cbE>LlCu|y3` z#WyAt;bi~5UXw1lDg@uLU z1B?)=!UZocFHfG+Z)g%u&b5bX(BY39IU*`Odh`f4ZrqT`S*Z?{A*2b=?=DIXV+dri zSj23sj4lF3MnPnAyVUt}NT10I_p!ZY~ZTIwb4RmjcnZQd3hU z0M4F0tEUhdeo*(bm)eJegM&DB?3jcx<>g|>-QM1G+X3Q#W{-S!<;oR2d-hBgST$SF z06o|4_9yMr4>jYftgN(UhXhj>Nk>355r1iVu1$P7rngj_=?CY~&Iuqv3$#m{*sG1k z$Hx&LA1@|j5>Gg;J*uNK?Dc>M!=NBgS63H0J3HkiI2OmW17Ufy86vhr}RBsfm z8CiIIW;8N-sM0o%(yz9Cl(sWpqtS@kj|v|?e89rO!j}66rQV^L9Ds!=^6D1*&oL(D zjbSu>2GlM9Kk}%eZZq0q^1+HXp94q)@YRmM=)k#h6J$cJe3)Q9#buZEZ>A8+Gq(AE cZ*rpi2btXFR7b#F#sB~S07*qoM6N<$f`U5p<^TWy literal 0 HcmV?d00001 diff --git a/packages/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/packages/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..5c1290e0e8d957a825706645ec82b8115beb11d7 GIT binary patch literal 1231 zcmV;=1Tg!FP)I>?6p~!XaK3)0Z>C|4&xd(uzOVPx)4M&-^Pb;1zw?~)JQ9g0qKG1j zD588IWMPqfSHAOetyatJp97%PYU%m;S^tdtuL6+>3E<`BMXxBXlarG)H8u6>9r{=u zfaf`c|w)ogKi!@l|@piloc;1C?FRX7gjz!J)O3F(~1&d$y#Cntx!B9qA& zh*T=&Id*qm zdwV<0&COA2YAU(AyOT<#qNAfDrVbPaO%4tYc--FJ9%W``ax6GHI`Y!Gu&}^+VtRU- zY+>*k%c;M=KiSyWa4g^(Vga7X%gdv} z!a`1T}(_JTNHpyCZmpyjz0oG68i{%LZM&)h!a6qA^fzVp@9PyS@o*`R4Nr4 zV`OB6S0+I`04t%AM9qWB6Cey%q0a!&-OJ^2uBDLFFy5@CgyF#@=xBhLDmJOpdf%iSpWgGQ0riYL7fAR!&}$@VXFymX*3%B zhQ`#c%q##*Z7HD?mQK_2=H`aST4YQ4(WbtrsEEsJSbKGK zm3L$Sgn-Ac?d0UdXxv9)b^z~L5lW*TLfr#EiHV8)PA7kJJ|qzWfT@EmBq$tiS^dG* te{;0>-yH7#q)0>&MHEp)5k+EJegTH{4@({1PeK3y002ovPDHLkV1gJWLxcbT literal 0 HcmV?d00001 diff --git a/packages/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png b/packages/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png new file mode 100644 index 0000000000000000000000000000000000000000..e91ef4c160a2639ab6bb95adf3ba64e9b6429c02 GIT binary patch literal 176 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpTC&H|6fVg?3oVGw3ym^DWND468w z;uumf=ju5}K?Vk%W`jSo)0BSQ5M(`Q(Q}vzXdm-x=2O@1@jc%-V>8e;22WQ%mvv4F FO#ruDKY9QF literal 0 HcmV?d00001 diff --git a/packages/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/packages/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..9549e8877e32f9c18b297358c9c21b8c902739b3 GIT binary patch literal 1816 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 LNkvXXu0mjf_ek~{ literal 0 HcmV?d00001 diff --git a/packages/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png b/packages/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png new file mode 100644 index 0000000000000000000000000000000000000000..10c3ebc671d941283e291339ef72c7774fdd40b7 GIT binary patch literal 308 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7OGooCO|{#S9GG!XV7ZFl&wkQ1F?j zi(^Q|oVOPZ85tOOnm7D3zkBk8GfzPQcd6NYwglb-QVpgI*$meh)-XmeZy1sS4L2G2 Y)9+LyNi?PR0e#Hi>FVdQ&MBb@0Go_gZ2$lO literal 0 HcmV?d00001 diff --git a/packages/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/packages/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..39fa8bfe87abe10f2b60ea50f817ffadaf840113 GIT binary patch literal 3929 zcmV-f52o;mP)sx0XLZEWodbJgkcypUJ)GQTyytm`R!u)dUI#nw%3}b*<=`od-c^d zVwiJWW140;_i%4B^=Ubc;w{yCgTde^%d&n#sewSCdfK#U;f98W((>|hcfo=MqI>u5 zE`{G5<6Kr&RyYs{C|dd-b@-@A+lP}#z|Yx^<6Ljs_U1q!0M9q1q@*0m%gZ|=glJf^ zW{pTmNfF(;b#wdm>nAAuPDx2|Imb1*xw%KU$1sdW>Yy&iac*etS#4bcF}?C1j^nH_ zO|yX>e8m-46n*^h#|MQFkxxGPMBIAot!~$@UELdRyiug4ri#9O`^v)aK7INK&T-AQ zZQB4Ml9Q8DKph?}HBh%<7=QPk)#lw+)2H}$wr&5%vaC{+)vjH;ie<}|!Sly+b93Ek z)24~U#6&kaIr#*|Qy2{tj#GN}>?yD1p1iy~mpYa%U7Amw9xavfES}j`Y3YQX7YqhN zmSugf+{&?I#}-soRh2{{k)|h}ctZ5((L?e)N``Kf;`z<7>#x5aTOf-X4IJnH8*aG4 zee%gC1$9y9xN+mcKDYG;%d$egcAfXM6wC8nZQK4p@qBV}@}YhE_7w^t>fe0xP0_!9 ze^>E53iT@XH!)(w2(f9?Cehf~D3oyjq)C$`Fui*9iqcZ=-o0g=qeqW+zyA7bF0I+HVM77W z_UuE1Hqo~84G8~>I0*>}(=5v>vJW^a-90frAEh{S%%a<>gbuc27q2@2o;+Z_VzP`Sx ztgNhvHqkcPNLy)hyt=K?UK0!kueNRbZPPSSJTBzn9Xocw^L6jN_nsIuXpoD?qI!8O z-Jo=oH)YBcv17*$saSt+FE1~bN**v^fOzPkhoazcUR`tGzyUFD-aKg=7y)WD@C=?! z8)yq{+PQP*p)Osz9QMK**oU_h5)!V8Ct6ayti!Ux+vt~^+d~nAd zcSwJ}SFc`fEZr!d9{RZDmRrR8@4x@6-VF#_w{8_fhYl58x^$7(0~A0&PgPY_(NUu0 z(@#I;xst{JRvB|;A2_#r_iiq&qOBKQbdhw4c?ZXF9`l90TkS|tTsgupjC>kz+jix= zdGqpJ*DWtCEp_j||9(0AaSu<%^GQib(t~;a`R7GNMa8e^=I5V(7O%egs`Pqh&YUT^ z9)qju0=QA5Mo9x*TU#sdRlr3e5wULFIteVG`MoMe2W@-cfd@oMNr_9F@4N55Fz?{G z$h_0=)(V1ZkR1pFWb7s*BO|#et<0AidK z5TAYanYnnHNb(i@3^UtFSSIRne?3mcHWs6w4bg5XhXpwm8 zsi$N?@l3M#3lIP^cI;RQ;)Dqk#Ft-wDHX4Zs*8~|XwaZ$+d$iBBW z*=sj%-W=_%4Gj%ZrE%}0k3K3EE?g*ky;8m^{0|UZgK`0@rl#fu8vvLeee{tSK76>O zC`L-G@|8Vc^w8Gr+qcWM=jP@DKvBnz9r+G%C@ug927{4EBytS5apJ^@?!JBdq#~e% zjEoGa1ohJW`t_3_U>NaM{Pd$nL1}5J^zES@iW&tJ6b}G!{_*3-qY78b|L(i*(QB3BIR-fNYUUAY4iFo}qYbo$wgMpS;~9W9 zZrnI`_3G8wkN5=O@y8z*0|yQyL@a3rxAC>tUXu!CXiuL$T^c<#`}Mn^x7~J|bU~q` zSUMr(fMWLU-5VV(>U?!|wLAyt)P1U066a|Ha|HyNnlWRB$jrtG*<^DqMZlQ5LwpM)tzt5M?$OOM>Tl zrfS2kUAx5m`SWEQ>7IM;k?t*J^5n_!4FDrR0T2p>WC4i5g9l4R&zUnvaz0ArYZjx# zZy)?&2>gD37(&H>5R5$vv5O zNfjso&c{Z;+qjY_Q^jvoAdc&q=_+G;^ypD(i0Rd__9QlzM4i}0)c4Gbd_0b*;_0VIL=c4EXs=EF5+ohofNa)1x&G>nqd#Qt1 z(w;qgWZk%_@cZP+lcm!Ay}r!>5b^Ws7Qk1ed~?d27fROuIHK~ zM~+C~_za2Cp`hk?UIP$1H4ywoxg$r86d!*0Vboo4M6C(72-sAK7 z?%XNk|E#Jw7mC+5Aar^Fpd;K)7Gz-)a52vX?Pvf(Ee8PilP_ohfYH?eoTGU_NCWWa zPQ*eQ01d#5wj7EG=@Fm-n4w2NNP9t=f@W$P5YqF2vogXJthE_1U_jg&;mpj;_*@s1 zkdP3msi`@}ntc|!61#~v0A|mgE$+PYPM2@b0MMaBht>~(TAXX!c2#X{?NI_{?8{0` zO?6olbgo9iEx#iG)Wh1KNs}hIbLY-=fBNYs04QkJu3c@bUz{7WJUuTQ4i~YQZSC5% zd~}z)6;(*NwE=Li7s`fqrca;ldSBog^YZeF($mxPTKn>J{|1ebhGB$#8#KaAO-E(pGPi-g@349#h_&u(i{&tPIPtc6i%VYe$S2!8X<6rlzJw*8B_} zJlIw4YD<9ltpF&VpE6|%xg^|o-g!szJX>aIlW&{q4$HFAn|qhI-l%Qc_B7Kp4|^N6 zD;F$S04-Ipjt7@DEiH|0qo=hGzXbq~=UsRn*OL4~JexMq7B7E<+NkZs`$p~4r7Bpi zQWac&`Q^nMH*S=v3UFKbyoh~Zsp82-!_bZcQ2*28XU&@BdV2xtckkYPn8YByWKJKD zI3%Wx=e3;hlEGkb7^!1ax|;Fh$A?KR!v;B031nnsxa_h41Vu}y0zj>JW|_*=sZ-t8 zUw@s6lv`0zQ9|7@>1u}g+7-7vf36ZX&9*G7#7o#zzGTS~nXrikx^w5wm7{^RqA34l z08rRyfp^2!)#Bn}nXoB4J3D;gg%_54bW_sS6E?+)IF7SLC38wmO+Dx(?W<>3BzfV;qNPT_m@#AAnKNg)>?a^;-+==Mj&P4JDbr@^ zZ0=cFP85HyG#$qoZCTdODs5#}R#tv}eSKMVb+!B8gAcO7&)v0am;2mv&#@Q4t*xys z;~HPu$^(w${9C83JUbvd1cSkc{E1svty)FQpwhR8xRPY9zQnB++~Z5!N?n?6&N8Lt zziiuHr&1SRbImnJwr$&1xOMB+LVk11OI^H literal 0 HcmV?d00001 diff --git a/packages/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/packages/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..72cf0b1a6abd747476ce4cfaa53c713824feac3f GIT binary patch literal 4903 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 Z{{S=NA9YgLNiqNc002ovPDHLkV1goAY#aap literal 0 HcmV?d00001 diff --git a/packages/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/packages/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..f6bb54d6876a62073eca5d05b285904509f69052 GIT binary patch literal 6619 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 literal 0 HcmV?d00001 diff --git a/packages/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/packages/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..c01ebf996f4fcd19a9fb17eeb7bda3423e37d709 GIT binary patch literal 6176 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 literal 0 HcmV?d00001 diff --git a/packages/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/packages/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..9238f654706dd2969debb1683859aa92f383bb27 GIT binary patch literal 9968 zcmb7~bySpZ6zAU=x@KtUmPWb+VHoKK0YOk21_3Dv$)Q0&Md=O+X(W~IZjh9cQaS}B z_T{(x$NsVB?CzX1Gv~~C=Xsv{)V=rfy^-3QN<{cH_y7P9sVK|qg5RxoZ(MBfQ+L4u z2mHcwRyJ}200OeRHv~vYqXGaXKt*0o&ns;=9jDqvf2xNA!9eV7idHp>qA6liCKU)N zI$5+Q&$Ge}V$$%w$ECX86uyY$XNh1E&1O~El4B;;V{vXim1@4dJ~PR05*K#r|LIfO zu-KSvJUnZA`*(IXEpSGd_7g@c*F@Ygbt~6EoEsovo$Lw&3V5FkLzd&L z`z3+2uj-OG=qCwWn}Bn_&^jHUX_TJ^{^kGnFzWf`*}T)Ql}QO#pqh#K#MJLp|9n5- zmKH7}MNIP;hNjFdZ@GD>o+?&~SiTtayxPz+1)s!aUPJldUC?_jkoUj@0c4~5`5-1`_^eu8}1ER2yxjx9KRof<7=K5s9 zdB!|E^xH-X56Km^O%gN1W#*DZxEu5HeZn1e$$m`nzEtb$tl?F$C&r6GKZaHW8F%H1)2Vrqb&iq=TC3p)Iw|wXHq_} zX;=6V%O2}V8Xe$=z%6ut0h&5hF(UIXmf9+*iIsf%ut3Ati zL(2ab-S9e1*?PcU*CW1BO#FhQHDD{)tGW8-v&yITWZMjC$`@WMo!vcc3P~3M%@-R4 zK0B34UcX|*o89=Ee~AyTn3SzNyTD!eb5{kw#xk|E+Pklg;S-*H5N+F! z8~Py|E8&bVW5*hOmK~I1sDz-Y!E^U1=Zp134(LG1VR^tc&w}TYpLybQk4T)%3r%+N zyQcabzW!Y|lKx~{Oz*+&dKusfOwKMaTk?Fun)h9Nvl)Yo{P}8#UOi#yde3tu-E%Q= zQ%&Obtga;#11>FawWTDB&r?!?ls*SHgU!hLvpwcdsx~Aes{t7rv{t%fV&YNw;P7yC zn_wpwo0;Kcni9--_Bq*Oy(y6MfF-}bM8CRYc!)Q^afzqB9#=8%j36-wNBn*)Q*_!y znBjlTdCjyd|K68b#K^>Lb*R{wn6kT~W*bDCfNPb7M%YGOM9n76rMt6FftaG!#Hs1D zo%Ox+M>N*u(ExwP)KgP>v7fIeDpud}bZ>{l85#VqelaTfWCV}+_Bf?=d^72&VcF|z zdJ~4smp0ZEa$NPELj|UuF31fJWcz6163snrKOR@(SICYNF4E2maq7FDNci-{8|$1v z_xC*vKD;~Mx691q^$j9>{uGxpg!;`?m@B}+jgmf6v(SMrII?Uc{e-ES*e_d2s*gA$TM%25T0AeO$EEz?GZ84%GdIaZ@lz*lH^WUa@ia2iN1CnaKx&@)eqkf)4U*mnZVaZK-=I*#y#8cMuE_Bv*{k_a@ zb){rMC=AEgV+~qIBP_oxe(vJlXMQ@v5bX>B_?m--u%#_e27e4cZRTy{%)*<^^u8Km zX7y%Pe_7|{)~(3|VRvtDXa2j%#2#5qt|jtSnrA!bE*`x-iEQpGsJz~@as~7ZlX6D* zo6jw%Bwej?R4R*Ic<&?Bcq`>4z~i6G9)H#;cs*cxLwdI0Ho}>f#3S$SoqH%rJnkwC z{kuUe9c6puAh}Q-Sv#gU$K5)P6RPo>*6(vU>5UzIGjR_@n#a8!t9e1+Bh#$+Umz)pQDBgm94R!EoPF9rCWjLDMU#ZRS){ z?HX(_QggG)Pu{F{M)rxOr^q@3RLFUiZ88n!`G;Ny%WNq~OochpP11Rqs|%T#t8BDS4cuFoothov|+ zhM(D&Qef4IKg+bQPw*i=GO^2x&#~cP4zt<(r&<+QmS`kDkIJQc5g{{r@6yT^<1%e~ zPqq=w(d9L@7iS0bK1eX|+55KH&hLL1CC}QwpkrY_rf`qQHqAxx9K7d#T?p}Hc>now zP;7e5jZc#wKKNk~zjWxE`SpfE)05cd@l6v?qfi?RFO2ug@q=GH_=|h#0EY``fYGxE;=bCauazcl zyJ?;1Zb07=9&r0_DYCYMy?N*BK)WKXBe~_lO#jy*ANy@LrmUHZnO)g`vm@=Fv}BUe z3&XNf9=7s8mS1uP3GVo0_{g>+YzH-kZJnukRS~MTwQ*7|O$Y8EsMn10HEa zLKr~al>+EZ9A3ln)?Ka~Oz5(|gN~(r^2?J?_QdTtjJ^U6;F6B9D?QGAV^87xyk)_4 zKq2sMcM|loJA6TUU+;r+Ogb#J-#7!a!A#{UJ5e9bg`b4VjEU3or95xErD;8y_CSB2$usYxO5&;>|`mE)^L|`>@&wyr52R4+&qiKe?i(z1vB&{x4zB<45~60nsWzIs8!>)@asOC&bK6h^$Bb*8xGXe4~gQ}V3? zCM92mF}`7F$DH^(e}5uBjq`bNh~GQOF1G`oBGIWh%d8PvhYGp7DQgUTnr8o|5EXlb zbFh$LS^aXRIv2=tlw(R$GSFtW`hA3D$M0Y)LaCYQ4!Fn!+#F{rF@Fd)E{@^v`Gh+d zM*E72UdyiEA}EuRE+pl&?N27fxvq38o8)@jlaGa@D^KQhG~!fu=_Q=a*07{20|A*1 zDX&5!k>>`GeQFHJBM;*wgY0(zTI_v1LOn@BQUk6^qpbYgweS8)=IxbjV8y*u(H#q~ zBb+27?tv_=w^aJ7pG#+n7{KLQj>Y1Y4oQ5Lz?F$;!|FzFQ<2Tr83;zr(Ey~r#?8*) z+3$i>Z9C`1pGO;52y-b1pSR&Kb}IKj+%6##rJ?!CuuedS|A_^S($V2!uFxTOI4Car z&OJK-pMBy?lX65HAAYDp)WLyx{8X+Xy!3TmeYyIj(NQX-_YRPCl`f9lUQ0` z$J?YRIN|{D{1Jfx*W0GQ##<#f)2dKoG{NqmOXgTnzzwU4bDk{J!EzUi@5kq+CuO6^ zGB4Zumc-g6@?!478VAR~OVI7}y!8N^7tHUMIJbBWi#pVl_pMdSrsNK1+jY|OwE{iv zVk#M5FDTLr2fmU=VR1<~VrN>(BGvs?&~m3U*d|`TnjYxfg3DeBbT`#lGi6Iu$E2Cm zVw!WTzvdFStw4HHoykU-2T$TmvEv@X?jRi55_SwMIQVfEb4!ikNYpVS zpopI;VY4+RBt@v*dJf`hQ!(1j%@ z4Ufy{o^Zn}jAj<`TSB0;=_9=Iw>woMA8QlF&CaA&kEcxo{OFuvcrpR*0QBb0y;eGf zGkBwBz$vicBwMD~4n`vyl46O)US1E>sI}xhPPOuV`uo8}VHkfx-Kuz_E#Xd*fZ|EG zVAX)o#o)}#vn?LC!cqY37D~om72YDx3ZA>B^M)Nq56#EUN&REWr{uoal)GLBZ8-OM zgL(Cv&01^^`mR_OP#$>x&(5RhPr@xvw#HnJc_4D_1u{qJ$h1jHE)yfob3TbR)>Ds1 z9e={64>AO^env;8Q&0B@?Ccl{)y@_4LbUG#3;|b% zP;*QC`@`y2&ymH%rcT&8$O_PD_I!>TEYV#jFlar%8!dFO>}4U{@vS!3!j$!6xRHB0^4iO4rGvys)B84$RlYpVi?{A5ipFoV_kw}Gz*zrfVwR8x3{3+; z;OaoNCf9x!WIh-jmPZyU$bI^BFg95tSKNKI;_Kj_q5q{~0E!liWkccuQ0Cz!Fq(ZN z7BJon1SIPyTah^G)YyD^nEG0bDr4E#GLt9y$2{Be=p>De7h$=+_-Bu{;zUz_$>E&2 zCYb+2;14E{#?cn0szvhy@ydpz9mj zb1-+Qs8_k@+(Bf%-}LvLW@C;G4E+#_R_7M0_+wom^(C@Sg<6IzYVlRWZVo{FwA@Av zz{JHzAS`v29{aE)nUbi?DhsCo(j_flpiPE+W45-Lv5Z4~El2%@b79MDch3<6g4B!;k; z`n}Q5n3*xd+1Z2we?1)%WB17PpMo=MOMD=`(k1Aah|(uE6UxWM5cB1#Qr>`>tE9bf zuu0wKqzu&MG`#MD2jY0w0Z7w<%dl*lREq!ZbL1Eef9-SgjhvF;;PyvC>ubVv01*^` zs(gFkew_4pvL`DKz(TU6>Onhiealgy)fV@MI3T(+vAVXQ=c>DPnJ@Z6iHS88daWtV zH#p_{Z3pQ^WS_;|*s*izuVxWe57MjO$E0YhlNvm&J+X{=#qo4Z^iZT}mxY~k14hHh zL3`tTb(Glh8W5oY8l`DgY$X}hpcm0Y1jOg+8wQm&xPfw2MJI;ya060o>*p?PHNBW^ zQ~r|cf1G#zofZ|@%JSi zY%q3tj(nRbw-;EXc_xHB7`wy;FHi{}1s9-4ql0y!=vF2jm_v;a4Rxx?m#de7Oy+WG zLaf^HUr-TfZLVh_XWgHbTU5C;&R$%t(}0m9+0_2YSr@Hco9UN^ILwS0K4XdQ_l#ij zhMC!~f1XU~i-RD5v_b>j);NVDP-eyY)F#~AW1r{h3Z5~|@D~1H+iK1e>a|118OvsQ z=qhQw!3x6P`jdC4Zy;FTP(=R1u#KZ6GP&zTwnRPZuLW`+KYxpF^bZ2n5!wnrE>|`E54_t_cnLlP?Cm5 z&YIu9Js%$9WKWl-;Dii^mGFMsQ9I_;gebG2vV|scIR;}?9SnyHE!!Y z31AKrI5T56qeNtlhOs#vz)KX_G1Mh7TF8iE(Z+%NiF{pb$X;S%S_Lix1M@n?72$6f znA*kV^`=ez_w-GszA~ph)X9E7)nC{oUX7>E5aOcF!??_oOW$%52jzKemz{L=qNz zOr1?6;8sYz+Y0}jKkO+vj^VNvdEx4*p-ke0@ z5)5;G!`k+m&N;35M&Xz@G#4XxgJnaFC#g>V(cs^1o#~Wj8AetIUekdwZ;8=kHGY@Z+d9pykE)sZ z9djIi%yutiFF*qeeC6&FJc`^~qX%XIG#0d7Ylb|#OOX+zXl)GgNy?Ldof+DvCmdY) z>|Gow6G67*;B8-PZllBOGb)F3-(4z(__A>XNXG#JTNCuN?8Yie6su}vQHDY_SBsw< zTpxX{2Z@wDhh0q+4QqS4MEx4sAM0P6U~dqF;6%g~LjIioO6DLm}OC@aew2TCP(6G`PK?5EP1Wu$#Mhu!|r*jsBS=Lw~Op;pr z&@zwA8w+N=y|$+Mf}a$@BRjJr-=XNhU^SNFaxB?>VMHy^>XwYB{Mnq6xn#0`-tUT; zvbyR#^n8b&vqHmB-Osh)tyyE#075*KAN7nF>g z#5MO@kAI4P*(Fsy@DR}pdZ2f~4r^sZ3iyf# z_>8g(^(XP21fDmn9=m?TGtxzOU#Rgny@mpJjGe3Gi}-p)6O(?KYeSycR<6sxEcB_INkIW ziFYx5uAgz7!#`jO2vjDyW(B<}u7y73p|7LVxMonMCorj*4g=_`lifQ?#&bR+#bp0s zRw~o)E;+~zO99ek3tJ12K4Cf&2)csHMvF)4+nY=Xjgk=tS}cbYn8^z~_b+CvIf4P@ zSgydDANGNTL?-0u6;w210n?SNP-oI$AJ2oa)er6c2_p+D&D1lf;p$P_dao@Y9!R4g zgHes%057NkTeRz%FnR=tVabF%LmdAj99M*zO83qS0wBkC-=3&Le# zL~gGwnsW3?2IKVwq4qmp>;vj7oi$+u0m?rU^4%AkQ%MHnoN$yAd>%`rWdwLGgAvD? z3Gu~7kzWvwLQ}x=`ydn254J0VA@Uad88QTJzQhsWZdjmo|NF4K2LY*_ir{^M$($b4 z5^Z%QV77Dx>MWw*HT%%Tvc$&T4YYR&0vLb0BlAR0^3@|PyJu!AtsJN=r8Z*n-w6h? zYh}=RKD7y+<*S`(_YAYow$%Gea5*!Rc&#A|f>(*V;0!>Ee?~M$>g=ytH@LizA9IM= zlR?Np(T_AB5ZbE@mu~95d0uL%Ovp=2fd2#9D-!d`HprAoYe7(N*kV;A_A-Z(fjS%JGigve)n^S@2`<4j_PeEaG^VX~Y`Q2@eW9-dbu2fMPCY z`}ELzc0MIk@iJQjwGZN@vY-q3IAsK?w$&3KQ1oocjpl^!3obi~X+1jCI~tdb5Hxq7 zV#SlO?4f!oO>G=eSz8{CZ}c82Cm>JQwDwW09h4_Bab=zqR`;GvXffu>X~|x{J_kjF zkK1FsPfc7DW^7U&S%?MRJI5L0;qGFi$8wdFa6HSK&femOiN9c3L%kufY&8^MkB+f% zN`3^U?tMvff_pqKX@Lk4QH#fMBN)kaBMTz( z+*Wh9qkGw(fQNuLvCCzPE<%IyBB3ZD^`EqcsN@uZZGLb|IUXVd1 zL@|bj0xQ#FdD&vya-`*;od8P}`$dPFS2=ZxVC=UJLqVHp`p%lO&=o%w5XJCx3TP0r zUG;6pT44`~OsJc0B-?7{Kc_1ZwCpBd_%*=a68%IhIC8WUv_>#8?ENSqQ#Chm*N5X3 z226rU(H%wLR1mOJ+4J$~_qWTHPzo@cHRBbz<30CV1;U0#uN>jPHr;?+_we0day7Vy zI^v?Lx^7X-NT+@Vdtw=oV>r)RCw7U8z-YyIp=QTJ;-0|TEe?S0S^DWQ&lj`|GF7ei z_*G`<(7FSv|7=N40@s{;eXL&e@wO)+Fpl_uaS9^bPUR2>EfM12Y*a@(0YBZ7BQkdN zh-o+aXImgTj&O2N3O>wNtQORCH66k(6~l<$^Kf->7WDLbeBBvI##+vHen1Q|*u#5Z z9~TzUSe7gtbrhH_xd4-eSud{rOFs}&b$HA>CeCF1Mvb)wVswDg%u|7wW9s?1wnU@J zr`8mum;hz~$ZexX9)X~;9dr-mtPe>|&V4}i7;c^TycYEuC!@os8Myr#CYLZrB|9TQ$UgzV6$p7BZDK(NQpN8S#Dap5#Htr-)@e@FQx>-7Djvzdp1nc4t=op4tRfGVH&x&i4vD||VW&$L2JR$5Qm zRQe@O+Xx*X{}$QH)o}TzxC4aaY3kcQbT$x7nZ8GTpPO+nuZ~ALK&g|gcFF%=k_LM% z5$;PYq$z+1(z<}UONG<9D%Wpj_>g8DOJAyHiqAV#g}{Hbt@BF1Ut=1I<_Hm~fW zjQVo>UiPLQfijF^y+tFX+VgQ;wdAy&0Ozv)dlIAPaa@+0Pq<(GYyGGTiU^KBHE;K} zVE-ZDAlo4jXmeQ803htQ6GD2S+>NbznCBDOW#2@v+n&l|}UA=_V%&1K02v%tsK z3DV_ZXmVy}gU28L$gYHtwId1m^vo#j#Fl+9HLWRxcozNt?bfrrRsY)0J^Fbat<3|R z4<+zx*N5d^?ltUFN)%SdA5U@E`6WyFYGraH$;|RPbHrj~;d3Par*=-}laeY#S+Vn0 zc9M>T)dscOBBLhznI_M1&vEh+tbX7F{)yk*=IjbW4}bM=_z_pwu{zcakcbwKf!Ug= z^{TqP8%G_fB*6Va;}MB<11so`DV#Fdj5wI!J$Wr%MYenP;ldqN2d99;C&% z6-DQLT17ULZUL4Hn4zG_XF{V!rNA)t&y;+MD)Rq5T!&h904S4*; zQfGJ>e}^qU`^`O$6ta@6w`(gHT6q6I73?=u)thl;?r*^BDHpIGg0zQq>x)=_$xC=U zTv+Qk_DDcq;5lb8KnS1}B)b5_(c2H~LGMS&Mr<902S`enj|elmgejVG_@MV4KbKa2 zGc^6GD2apE$II5?0y3TISHh?s>a7y4R1H8XukjwuX|v?x#3z=-cJa0ovK0GX{x0AE z%-SSXcy|JBKE7@MRRuCB#?_34RNEQrY$!9BPgU!*YtC13pR?c~=yBzAx8jm6?tn9; zsqZ0Ft~#srgb8Ws=&nu`lHq7PBNeW|2?fL+zQGc^`2MG;c$HMshwtxdhQSQq0gAv; zS30v6zuu@IZ3H-F4yDff=^_)^VUHtHs+I7;9~3L$1sUEi+rFyr$wflNWY|B@>8dj& zy!<+lqD~oM{|)CaY5mSa6R_T5EqbC>GiLK|0v+Jajts|5{y|Ol>@yoO)SX+n!hC|K zyirIoLf)4>mK|W)`Vp|~SypMT;bRI;)tlNM`{Vh%A)-F2Ff?{7AFY`+1#Krw1vfuC zKCAX1s09gLcI@?uS*F5Nj*{IZ67AV`-XdKN2s84gOwtXTwY6M#IEm*b!@kb^{yH^l9kWzDfi-Q&k?7;BjttXnkTPOD5QTne` z3E%Pi^0X#c0d-0;gHGrYq4Nz}=q{I)1fIQSemAu@cQs9Bbjkq|q)m7n&ViTzFL1o= zaZ@zc&1_&LRzBC}RM!7v5!U}t#n#&Y@fA0~ea+a$`Mrj`^MM&y!Um`)Xv&u%&4T_1 D9q+7o literal 0 HcmV?d00001 diff --git a/packages/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png b/packages/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png new file mode 100644 index 0000000000000000000000000000000000000000..8a72324c90ab6b78988df0c755f8f5064bb5fe55 GIT binary patch literal 608 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE4M+yv$zcaloCO|{#S9GG!XV7ZFl&wk0|S$g zr;B4q#hka74H+32I2sIoProK1m%~{xqjkeiz6O6bg|iGxBpXbaC!{f4;&GV8=wZx| hIVw1O1LVLoS((&_Q>Hpk*a}QC44$rjF6*2UngA6Tt8f4S literal 0 HcmV?d00001 diff --git a/packages/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/packages/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..9d7e54868047cf42cefb94804355403372b9158b GIT binary patch literal 8340 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 literal 0 HcmV?d00001 diff --git a/packages/mobile/android/app/src/main/res/values/ic_launcher_background.xml b/packages/mobile/android/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 00000000..c5d5899f --- /dev/null +++ b/packages/mobile/android/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ No newline at end of file diff --git a/packages/mobile/android/app/src/main/res/values/strings.xml b/packages/mobile/android/app/src/main/res/values/strings.xml new file mode 100644 index 00000000..75f28bfd --- /dev/null +++ b/packages/mobile/android/app/src/main/res/values/strings.xml @@ -0,0 +1,7 @@ + + + OpenChamber + OpenChamber + com.openchamber.app + com.openchamber.app + diff --git a/packages/mobile/android/app/src/main/res/values/styles.xml b/packages/mobile/android/app/src/main/res/values/styles.xml new file mode 100644 index 00000000..be874e54 --- /dev/null +++ b/packages/mobile/android/app/src/main/res/values/styles.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/packages/mobile/android/app/src/main/res/xml/file_paths.xml b/packages/mobile/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 00000000..bd0c4d80 --- /dev/null +++ b/packages/mobile/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/packages/mobile/android/build.gradle b/packages/mobile/android/build.gradle new file mode 100644 index 00000000..9183d42f --- /dev/null +++ b/packages/mobile/android/build.gradle @@ -0,0 +1,36 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +buildscript { + + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:8.7.2' + classpath 'com.google.gms:google-services:4.4.2' + + // NOTE: Do not place your application dependencies here; they belong + // in the individual module build.gradle files + } +} + +apply from: "variables.gradle" + +allprojects { + repositories { + google() + mavenCentral() + } + configurations.all { + resolutionStrategy { + force 'org.jetbrains.kotlin:kotlin-stdlib:1.8.22' + force 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.22' + force 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.22' + } + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/packages/mobile/android/capacitor.settings.gradle b/packages/mobile/android/capacitor.settings.gradle new file mode 100644 index 00000000..4516c4b1 --- /dev/null +++ b/packages/mobile/android/capacitor.settings.gradle @@ -0,0 +1,21 @@ +// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN +include ':capacitor-android' +project(':capacitor-android').projectDir = new File('../../../node_modules/.bun/@capacitor+android@8.4.1+767ac80cbab8ae50/node_modules/@capacitor/android/capacitor') + +include ':aparajita-capacitor-secure-storage' +project(':aparajita-capacitor-secure-storage').projectDir = new File('../../../node_modules/.bun/@aparajita+capacitor-secure-storage@8.0.0/node_modules/@aparajita/capacitor-secure-storage/android') + +include ':capacitor-mlkit-barcode-scanning' +project(':capacitor-mlkit-barcode-scanning').projectDir = new File('../../../node_modules/.bun/@capacitor-mlkit+barcode-scanning@8.1.0+767ac80cbab8ae50/node_modules/@capacitor-mlkit/barcode-scanning/android') + +include ':capacitor-app' +project(':capacitor-app').projectDir = new File('../../../node_modules/.bun/@capacitor+app@8.1.0+767ac80cbab8ae50/node_modules/@capacitor/app/android') + +include ':capacitor-keyboard' +project(':capacitor-keyboard').projectDir = new File('../../../node_modules/.bun/@capacitor+keyboard@8.0.5+767ac80cbab8ae50/node_modules/@capacitor/keyboard/android') + +include ':capacitor-push-notifications' +project(':capacitor-push-notifications').projectDir = new File('../../../node_modules/.bun/@capacitor+push-notifications@8.1.1+767ac80cbab8ae50/node_modules/@capacitor/push-notifications/android') + +include ':capacitor-status-bar' +project(':capacitor-status-bar').projectDir = new File('../../../node_modules/.bun/@capacitor+status-bar@8.0.2+767ac80cbab8ae50/node_modules/@capacitor/status-bar/android') diff --git a/packages/mobile/android/gradle.properties b/packages/mobile/android/gradle.properties new file mode 100644 index 00000000..2e87c52f --- /dev/null +++ b/packages/mobile/android/gradle.properties @@ -0,0 +1,22 @@ +# Project-wide Gradle settings. + +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx1536m + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true + +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true diff --git a/packages/mobile/android/gradle/wrapper/gradle-wrapper.jar b/packages/mobile/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..a4b76b9530d66f5e68d973ea569d8e19de379189 GIT binary patch literal 43583 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 yu?e%!_ptzGB|8GrCfu%p?(_ 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 GZ~q4|mN%#X literal 0 HcmV?d00001 diff --git a/packages/mobile/android/gradle/wrapper/gradle-wrapper.properties b/packages/mobile/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..c1d5e018 --- /dev/null +++ b/packages/mobile/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/packages/mobile/android/gradlew b/packages/mobile/android/gradlew new file mode 100755 index 00000000..f5feea6d --- /dev/null +++ b/packages/mobile/android/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/packages/mobile/android/gradlew.bat b/packages/mobile/android/gradlew.bat new file mode 100644 index 00000000..9b42019c --- /dev/null +++ b/packages/mobile/android/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/packages/mobile/android/settings.gradle b/packages/mobile/android/settings.gradle new file mode 100644 index 00000000..3b4431d7 --- /dev/null +++ b/packages/mobile/android/settings.gradle @@ -0,0 +1,5 @@ +include ':app' +include ':capacitor-cordova-android-plugins' +project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/') + +apply from: 'capacitor.settings.gradle' \ No newline at end of file diff --git a/packages/mobile/android/variables.gradle b/packages/mobile/android/variables.gradle new file mode 100644 index 00000000..fefc2451 --- /dev/null +++ b/packages/mobile/android/variables.gradle @@ -0,0 +1,13 @@ +ext { + minSdkVersion = 24 + compileSdkVersion = 35 + targetSdkVersion = 35 + androidxActivityVersion = '1.9.2' + androidxAppCompatVersion = '1.7.0' + androidxCoordinatorLayoutVersion = '1.2.0' + androidxCoreVersion = '1.15.0' + androidxFragmentVersion = '1.8.4' + coreSplashScreenVersion = '1.0.1' + androidxWebkitVersion = '1.12.1' + cordovaAndroidVersion = '13.0.0' +} diff --git a/packages/mobile/assets/icon-background.png b/packages/mobile/assets/icon-background.png new file mode 100644 index 0000000000000000000000000000000000000000..00751d9f0b5d4e7286d433b1fc1a6bb570bcbe98 GIT binary patch literal 20607 zcmeI4F>4e-6vyA}4(AvzWMfc5NN@=T#aLV+7;K!27n2hbLJ%~9utlN>5rj0UbQ22| z8x>pOK0ySi^T5Yeuw#Z=tp-pKfUw^6GL)049KX?WZ1Oe|k*-k6=0Q?6}9J-Ah#pxoY& zdx=D?sAJL6iuc?8!}gI6Cq%3?8}-X;>gShwzogZ;Uc9@RJ$Zk7Wa;yjA8*eNt4Nz9 zwYlsz+HZ>A=U&_iY&I_jYOB*mZL@&sEVE7-Zeq z()m{p!#gfy>p-_#Y;J8K$;rh==!8t#ad>8$W@4V79u56klV71B#eL4dxQ~Ayomcbh z^AAUc4u+1@jk1iq1TGPAA7Qg`GYrCJW*to0nDe>-5o7>y0T}?SO3nxG1DApfEVS9t ze2@Wa9d6MHWB@V%8L&|4*LOh%AOny=g37>)Gf)|zGC*a3%Agdcp)x>a05VX>Bfb0- zWB@V%8GsBx20k_d8GsBx24#W=$N*#jG5{HX415+GDg#sot|y=}02vtad3}ILVgl*V z|C7q#WqtL#NZ+=v1<=W-Co!LnOkIPr$^&vC*dOc<_6Pg($u*S3p`wuM2^3^JJ>&Jfe5S9f&czu_i2?i;!YdvQexvp_R9B@c?LIvZ{vhjUuIZ$sLnQ{jW}>2wa6R||YVfC1;6ExV znhcu%{U1824BG$qYsz1)aDNe|qEeyKymiCakD7pE9*s33x38|fD$yS~U;WiWbjNz| zLYx(ATs6%1YxvROiPa3N!!GGJ}zgF;HC;YDy{?`fr>xBPx z!v8wqf1U9Eznw6wnsd`^#PQBYCV@~Y8YY3GMJm)#^Sc$~c$VOPqF~#CgtB)yDUOIF_Vh^~Q({Nq!Y1YR-F z(8o=pBb#?VlhftN5HcQB0WCSq^3Urr(TS8l_o}~LVO~*llYg;f;JdyZ-0)v@D{wUI zN^JX3q`AT_xjJ>g6>jwJZ+T3^SVR=&CijQw^^+kygU9Iq_440{*lKU%3O99L=j-lw zFI1q4b=;F5{%aA^=p~Kz@ZyL;yNz|EJJu`X|GP%uVM7_iCXHu>S!{TB8VB9$%yqf` z)owLHTlNdHn(eF3mZkdOqia(x$aKE`_q8rD5#B-NMNGv4eWxZ%@Lq)c@)Xg3FQ<0c z+WPz9NI!*{uVZ6gsL_9aOF3Plz0yJ;ozzq}aa^rXexV6l6I2yc9hCFWHFyQ5adwH$1NYr>vj9&TASM3u z;Z#hSPG_P6@3L#PUmy0zG$m92^Nzw^aAC}l@LGHH+2Pcs-jOTeNG>PUe-@m;IXc95 z>#HUg3?B`$>%owWsiZ~rO{K{Z?78hd;axsa*gQ!`X@S)}&nK%KJsrO6QFUdURN;>>C6Z9A+d#7twO zYm`7dq5sd#wIXys%`L5fslccaD%lr{-CFp@|ITj>Ccmw)BZZw|D!OS0heCWcHi#$s zjsNx28u=}+3!%klvvpTcbr;M$Q`@8YZSL@HGIH@C2K(?ImZ4b2V#y*SJ2(t$3O?g4 zrsCZ{7vX4viXC(>!#?Y5cGU8qrL<_-nd#01sZw1eXImw&4`FRvyr}5e02C@xTv9@O z;^W@_IqEg9_`lwnpDs%SdHa>>+O7dvwT zd`LXv&c8n77R}BazJY6z>kQnsw#)I>{_d7`!J3uXs>31Pjax8#x&E4zd(vlZWfXD| z9>f#>?3I^>O`$AsK1@*_%r{3UDb(l+@ z8RZVT|ET^iXQ`9>m$chZsjaV;q>Meg2I`blF3D2!!to~NQHOgwpyOIR4I@=lLM>L@8pE0cHEmT?Wf7k+gHD$b9sGc*t;b3)UBNd(hiJY!y^)3BLCLV?!*bl7J`wY_q%>nBRY6Ehf zS>{VsoK1G0p5Sf#em=(cC~l16_2i~wi68fSf}NvREj{?L7$t0z_&0&r$qFbRDWzgI zN-^T^1@6VRG+%auVNOzTD$L_J#)?XUYeR&@#xH+5#D5E(JQBNK%CVRwE7(itEK(M=u-Wqi>Z_)c?%$m()3d%X z&>2J;cJ_ChdBmxEpNOsX1sA`chI;<KJez|j%0W*P3kZS{wRZ9;W- z+7$bN%DcaG|4d`Wc~!!#5t*;GL0i)a!bNK#NlPJFD}!4$bzV7(_~vGJOu1c}0=Ywc z!Op63d zvbm&9=&9tmzTV4@9iy_&-6?BuGWBCC@=}h+SmW@^lUF{k6R|@j<`rkpK)EiM_tv=f zeI6*j+efT?KD2zu!@h;5GhSk6d$i8Gt03&7d-x?9Xg@-c3&+*mYR;H^=752{x=hVL zWq4cf(*Xmx#6l<}o)X{B63Jw$2+YX#`b|Wf!C6F9RQ3ph9=aSrBFsB=RTi;w*mNXF zEqi%+S=P9X34ck?CMe8RhECTL3(Btusb1)e?bnGE=1S)J-}5uJcG7>7vy$_oWzk$f0nF~v9RX4cK(pjfifFq*VGWAUVnUzD-?2= z@A+_$cxRbz{He1n-X+H>ju|DNiC2Ev=ikTE+PJ4^Rqs>4bKS2T2{yvOo%cDWB^FEdi>2Ob&yTGqX2bIt z#g}?jRG<-@}WM{TJD6r!#mDdql1Oke%k@ccd_W70U+wByj0)bJZ; zIA}&Ga#!lRLq0xYR!Y{iz?l@8zMNsq*MIkLSsHIbD|d=XBo~5I@=t%I0&O*%aXr^? z!PfW!PZisrVpwIlNOiPK0DDjit!M97OIJpX%vX4%Jc0(B@@y>dZ02dliaaPvAi9vUB)&X+O6J8;tpHcO|jxlOTfi%nJi>?&;R$y9s3G;os1 zY_W;A_M;Ndi>#%Mni4j;KwfoTkAqSORY{|740`+3Tgc;217V{fv6ZB%lH<;{b~ks; zDbKxih>|nrU9DNz4arL<{-To#k&^Koox2@%`lQvYvVH5*7mK@{5+lM`nPmPJK;wsv zDjfR9q7r!(6rODRXMBW&KOjUXZV<28CP-l1AO78MhIF#D0;1opQvP@k-QvmN^6tI5 z!9{(x9aB`Ce6)1Y?m3o4l>qU{_GsTDN}CzU${|g)(yGOq8R3V$WJd3Y7nznME&t9f z2$Pnl{UBsGm&f#=Hu8rX zw&Uer;JA-(e(RIpMF*^Vr3Y^p^f)~%+iB(0^P%8~w7_^>WA3&BXiyh%O4%z)XKuXM zxnhMK&Jl8O7)|T?L+mgGp$SH(d`HBN9DvCe2I|H8=+g4TI!{x{aUDgS?;lL+8qSgh zap}brIH_@q&FywV~8iP6@z<{EDEG|+{l-W1?598 z(1^c?B4`oM46Dub+;JFrylA@N`I`F2$VHgVHp4BPYLQc8LoFKMPL z8OH-_{JBjcE_zfdci7N-c(q7FN8Yb>EA$+|iIB&&h>+t_*1IpyYsX*D-&yBA6MU@F zpOw#aG;dt<9Nhu$Rk5d2-OWmPtC)4{U?1FLHSZ~@m{5+)j6B+@uGJC0E}j8_JRWyR z*9->RsZ>3huRDGti(uIj&r0Zh7p}1FQWnw1v1MV`$xu32qIg8t;E{Q}SV1zEo`|&g z-zRH5-$JEg8R+qX(wNHCGU`1`&YAdk_PoPg&{wtUUkx#<-4Ht^(#S2rKgV$R^xRlr zU-w+@l*#z0cMrTPS;p6*TdFL1W_|T$&4-dxO!=kQ25v?%)eAAPu@XF+o=?BN=A>*f zm2Df#eJoqZDW&Jyi;=3v;9W+{!ppa;*&!s zCl-dv^k!{(D~|EVUbAICd#0L| zu5MJ3LDuce!ri=p;9e<@novEsyK|N0FTEpoR>|0>U-np%Keb5O{q+-GxdX8G(rKeT z;4y->Au^@L7&^xst~2qgS{?HHi#}LwJ6fu-j#_*$57?`Y9b7P|eucL>pZ~q9$}K-5 z_?MDDF;I=T3)mjPji{(x!?S%hC#yn-G%s1&aYFvOD3}5&S}KL}6VwL{9zv(vTLOPd zkZ!Hi(8SdNZfO9pZnRIY^O8@|qD%kHdHn_i)E5UIsynL|x#{sk zzCXJ3ZdI;(7!Ccx+&*t4$3aKxsV?a{Vq5#SuOmbNf+$S=OgOk4M>%Yp-y{&wm$hbV z|Mfz%vN410!4N67nij>d2y)B_fy0XU-;r+&YINJz2iLhFCi5e5Ergt5(6i%H|h#Pim zkz_6ZkfRj27ASMVVNY7PC6xs!QT)8(u=Gtz2l`?QUfAW$@k)0}MV_XqsV+%fvXPFG z5A>8biSspXC#&N~`YH2O6~k4|>O))6{IEV&eO1nUfWcRlRqDK~+e18R@diGpwD|5fN>;Fg|@W=G-5&>Sw-4^-3OTLFsv_T=~V>#gjIQty8gmQjwlloVx=n z1Gj$F&ILo-N?D{F+akHY8~XGplN+&({#6Om`ByK|G!QDEH|+lEw92~u((=PYdQPKx z{7|JP8RtA$d>1EXoPYo2621_#yv(2UiyUNj5~I?R9qX+62gq(P0U&F&6JVy4|1^zS z+y`RhAPbClL6-DG-YVLx)N*U^8K#Ox}t|#?z;)@mD0JCqnAH98bGexmMBx zKF>dtsmAL2UF~=sxZN#}F@`Kpu-F~XV}x|-%{IzR9&L~K->Spq*uK;{L)m#@9>)Mk zCr2yoM1e*k>#UeP!?M`I#lkfOogTQ6w{yG?YSZ>pX)WcV-9zNuyNFRhql2Vx;?n3l zVnrwA;R{oRrX>q?=Qm1Dxny%s>^lyhF=dQJ(QI^8?QP&2#?Z)yXr=8h&R6nUOQ@g?(l|aqn^gm$-_E*2*msgw&FjgLFFCWgbP?3#BoZ; zYN?A^Tj7sU?oN;U8PVqVxa`@Z*;1(r%4C^BmZqh+iwDr-vWZqB$;f(eb{ zg(!SR6e}J>M(Ltvixqw0c+;NpSudA1>ow9y?FJQ6>Q5uN_ZrBRd*44enXyw^7K>I! z)kHv>C$-ydX-b-?MfDt;cKii7bpS3ROX7OT{mWf4cppH#A9N;Qhrh?(IWA(H|H>VU zDpA-N^-k3}HCJ)L6i1c>sD#PUPEZ2q!gVHiW+bDLKscq~VE6bB2LM9;%>Jd0`(B76 z;Q2J_L)U#yiSg-U;J?qr>%|&JH7Vk5)}o%&4YAfbXK$^>h&`JeRlj}5F=X*6D zJh$&rxprGrN&!cf#UadMNr|%!%T0@*O-~<_LUmS(GVP*OfOTlKLn2xM?M&XMezt>O zHZf68Wm|ddi4>94wH#nB`N|;T;YpSJ$yP_zJ@+!GKB}--N?W-caEj7aI3(}qxK%Tw zA1a~Ec|(#Taiwd@cYl`LNtTwV?+PIRyOZuIh2pn=5E!DTDz;#2f zzhIZ}js6dnNn*LksFK7d`-5`-g8GJ+G*%POwhp&JpH+<2xDCjw;W?#m=HwyV#@o2~))j6GP}gfoK_Iu@_TP`)fWg zy?zPT(7+$O`2oUXoPwrdJbgcO5mS++u-eKQbncrWEz#$0D2`;1GD*>PHaWz4u?y;D zRl@4e zAkfGSgiYXwyI$#Z|UDEwjsbfRe932c+{i*I)rVck_9R!8ZS*e)@U>{?RIbXWZ@DlUJOs8@y9} z++B~it|%(&2kg*H=H8d1GWM-Zwf<{PHeLEsG$HI5_>k5AX%-UB7vH?A6h^Mlgs&&b zdCvqzoc7Xiz}3bnvWTi)fQc6zj{N$6Ix__5rIkGT4N7GmrH>RT0JM1;4~dYE;;q%~ zc=c<#)7Mmengh3-yCUsAlB<(E3L4`A(3`WSrIshvM?4}`0%>*#RnhJPg>ellA6Zk` zSo|(17cD`qmXm%I->o97519X~xiP@6U9s`7DYAj9)4m`S3NA-Yx668wY8?Oe80FgL zbiZ7)oFb9jJ8gzHI8gp((5X2e@(hV2a6wsGq8EFow}N_Cgq}qp^;W`>@h)hk9ZF`R1A$8BiTmW z4K^CY2sI}yDmE@A^R$fh5zsFWHpS6UfHQRWk7`g;(^XoxIB_)G{jOPo3_aB>p7)c& zBCA24x&kLRv%nTZiyS6DNAWIh|DK-=-XE3>$)$Kdk6Di+pjZ@~VkGs+tSiNkHRH$) zDq%FsF1x4#C?Gj$RvvDX6|D)141ddMdKcgcYGoj1{T+i4#+gW98 zMrYp2I2n@LNRSUn_9;%7IFarjyw)W{(J@P)Ypx9+f}eZE6?XH8Vn2MY_k&5H@mF7@ zO+gsxlt=lqO^?K0&I6kFyc(wyna6bj&wlRg0Sv5CI8Zbn_AlBL>JY;NYA{7B_0y^p zv?0sD-8tE%e-DpLv^~!<%CiaCgQs`t%g~6j%L3ci3V2PHs}s^TxkJCQ-I`5ksb?2R zo@%0HC2iJQe$b?>&5I;43Zfb}^ z7rrTS14z$W&s5|>L7pWLyQfn_G>tABwxnT6r)y{H6=wlSC<%5hvqG{h?$fd$b$~;L z?ir&^H^m^&9(dmZn!q4?CFqiLgDR}}_!p_LMebS=5{t1z^{>jlJ>n;S-?ke{Yk|w? zjZDYAV-l5B4@H^oMW{UEIG^cA+ao)92(NJ6hRvscn2H2#NQD|>Db$LbjH((q#AoW4 zs(9)1F6Iz;rY|BCio9^Y@(Qr2@I`3M)O+VqnEql3&SY5b4|7CsqerZfSrUVd%rrRJXgj)c=qQT=!Ubo zMLtwPu;v>rE%X;mmNqQO17WqZ)ru+ZV07b-E(OT5!CfB`k(uAaFqyw|4(h%}i)npw zAZ)_uSp9Y~62`9=90nKG5Dw$eLWEARssWeaWk*q&kdT5Z@2b_nR(feVM67XF2lM_x z7SAR7M-2;wMWbI{pPS!hO?nv0j8S=u79!Nxm9ZmB{KQBjo~~tZWEa-mMtQ@;&;4T5 zUiroyz*SJ6VjMixG&$f14cZwcnML;eYEP#n&JXCOhF-2G=E&aqSrP~+ zcPVmXX=!J{p=xmThzfV&}h6%ancDfb^PTP3|?@EC5Xt0$JoLfGx^7G&3pZHo;4 zRq+of_LZa)L>lw6~r6o&4y~@y}Bp^%Ech-k& zg09^}Q!|0``9z`p+)~!pJDf(UT}z#N(v|>GVisulrfr80+JKi>)Oa1YydQ=Ipcu3? ze%WNK&a%!cYxY~B!}9T$fIu7ETRVn1PwgunZ2E`){SpAoiLcyG1VFL?^$y*b@pm1& zaH=!$8*#A`i_TlXq>Kv`Du5l%a@@PxJ6?5iLARpCE`s-%1jM)BKz$D?vCAtK&6%N&W2(}Y|gs+r!}9lD*65Nc~bCWiH+%!3zqMDDJX>o z7Sz~C#za3Wm!u`;8g!(8pwOgqxDu`)<~~#k*HhZcMuJ+zZLfbDMx84%5AmHzbgqw= zfYcl2Xti0WdDim0V;n1X@Vvq?a*~>H7V$|?*|TzFF~6Ky_s^VDfgQ7I^DEWkTNwp5 zFR+W0?d?WSRH|(F*8B=^0j??fl(dHnrz0*O^U17~0%)I}uzBB%I1J~J_E;G>w4Q5j zTZ=<@Cs-lw`jmu#+2~qqm!9(8RKUg(aSZJ7>(KUaZ(-Z^+#BHxrNMjKl4b2ez7o5u z`SALUPzHE_o9;tWJzmpbJR1K5K1-kD;C z7}}QXa!BgZpEgF^%XAlF&4$RkW))=qX3Z7e)Vt#V1bKAspga<-B=3AR4vW#17nR5D zhSDB>>V8~UH|0dvk&B=kzF)ALY(6exnElkfFZZPaz(+&?;xm5e)gpP%AbPi!foE)w zSc~WTu_q3kz9-dvx_ri%lltO+CSqY^cBa*?oe;w*b;@g}gr~Y5dTgwIZ`JL^$ISgjCaJCieYzA+J0^ixyKBm%-vhV49j| z7LB2l7WI`2-kVHPU4y$E=KQ6QJeTa1AAfLmdp^$!;OyKktDMnuIBBuJfKzA0 zcRJ22z{;r<@Rib_9nw-kUYeJ1nxB2{n_Qn}4^drY*NFR&^{2E*^ehJ1;~O9)=CrI? z56ibaJ&D^&h}y%#d8roR{m^nq{e+#S8=_c z%XuPGo7&p%C;bL6-rvWGCsuW}Q_F47Wo3nh-aIVVs=kgL+)4|c%LohoSYNXtu5d-5 z^|FnoRhi$J)~PSd;LbeocvhQwzJJDXno+j=mhG8r3S?n^K1#FUWzlbejx2C zaK3^@+8KX7!E&M$O|qb1{(00dqI*^NPzaFHzHuT36Jog9jk2>?Wx==UO8P=zB=*Sm z@>zDuW?}|XNsu~G2z+@WY@%aIQtis)+QabENcSsQZ2h7QR(LQB4XB6J9ea`0#OvwC zS@H6;+~uUH9wC>kd%2qGT$ceD59L2^8viPzjseU5c(u1rdB$NydE7+?&kOdc5Ud4MY9>1`AKV`Dtx>4@zb)I~lyVc|_|M zU@h6RmzODEMV5>SJlgb{m>J%h@22<`e(>4{m-W-3BkMyThv3V4?7|A*!+GWzUeg`l z=kXDI3+wZ-E9lcQo+%WpgS@|Q_i-^-b@uS}6_ue12X$XC{oR=<|EdKYRzYUXXUX;* zFuo#G!_Dtz6=y!nAJ9DwRQ#pl8|oW zg*g^o!gL&BSIonZP*!V_&{}>d-wC8cKKA z%sgVdQRNzeCvV56kZha!D@srXS>5q zcb5yaY500`sE6i*oz?Q4@1fs~%8NCb95|XUC|HqoSXcqPF%FUJxlETbruC%0yx(j@ zousLCgU<|#Uz|fOxfet`VXr%Nm!UwG@s&V ztf&!(<&}X%6(G$mL6!hVf825$G2FJuC8|AO>8z+{Gk7m-V=?&V;~@r~zVMJzH8&_EZ_4hBDA?>bY|Wq!c(Gymo_F2yd+)qDzt8WJN)jYuOw@o6^!DPJ zBz@@2x5Ph4k6GW7lauQ%-RSvkUu68^1RTcl~O`QVjQ zOh;zOs`UysUq>^$JJsuJV2!1}5=Wzz_fG^(lEJf6*rw41+s?ZGv5gHGl;~Jrvoy?` z<~nfTJU3TW=JY!hjvgX|qMTc6caS#$8~X3pYYly1~qhf+-Ct2W6>` zatXx0gK9Eu58+=b-1 z04-4pXhT$YbM}>L%r7XS3k44b=nw-3Lai7{tTDU#^_>of-{+Ig_vT*f5$1ifXS`U&|qRoFP4 zuJLWsjg`&u%OID0Y(5wGCuIvHJ~fBOwHxL;xgot7HO60V1SqN1t&fje&rxh)!Z)huHu-)%5zjuAmdEuq+O%3C zsu$A5MU(l*`~Hq(Y#~6UV@$($0O!kWq1IYpZfmD#bZOe*Pi#EW*9}w2AM44f6BZy6 z(X8X=G^99GWES1s{OU>#=CTJOsM%^ZTUCi2pRyG^l*WQIQaCdK#V4rr_m_BYC6`)eV`&J=c|YS3b@8gd|U=zp0}E!L2*}VG>|+GqZG$ zV}k%f6AvgB;@T|M{&eguR*V?6^tjhT!I;@6&=bxW_?^;@uJ# zYpj?_M~x?L#00dow#0kZYm|x6x!W)9-+XfD0pc0e`BfT9)Iuo#f=yH4ggCzG^Mmb* za0Z&l#lf)w4C8 zmdM^55c&NCzp(sc&xr*VUirXfm}!ZsjW_cAgZ@vs6goKycuK2|2K_-g$a4f$^HH8` z-7Fb-uD$kPN~u+gD#qJA=&$M1Y2KW84qQ(EwHe~?it7SF^ilE)`Ex$=pAN5W`)Ju@ z2s-YAw%Y)tQ5~QnqM8y;W>+Yoyx-TAm)=hXE^l7FX4}ai*^|y%SUyHo-3V3Z zLK$D6m`xh>fSqUx=iNBSY@~S-eaJUyxf635J&oH#Dm|e}G-aH+AYLetluww9hMsi= zo0d!5^xHMq*l23V{#IiBbJtp%#le#2PF4K!KKYP_csXp~=@v@dhoZuHK_*eD5Rd9M zTmQ5U#yMKGM?}EO(regH!ozsKjuXUoCA`A+wu&~Z8;KhC+*3G8b z3922#GGDN&@28~vUIJsKfjk@0%0vL!s!~r}y>91L9SuE;VD69RvNk{rR)Gbnq@Q)0 ztwAuO%09h0#HEA00e9>*u%o3pU@f8)V);1n_E8lT8boBsaWy31l{E`dHM^y3>0)4N z*9E3_6(64Ma7lMoC)jhoLho-cexGlbzI5`uH+{G4o#?(7cs$BbWOA*A8?lqZ|M6DCwf=ljJ3Dw_n z2K2!56T?ns{2P}r01en0kKrHZe;!J7j@#)1)uU63cw#nxYgzl@LUMipmx)Bq zYf^J}BoHsxfcOAw?gtF`zO9~6+Ef6#`QC1vbZLmBbso`Y$I7Vi`Bgoxa zQW|U-yc^JNk3BLaz-Ag)c6cmq-=N$GhZrCR%+uMI4_tRdz2+)RE&ocYV%ezt{LPG1 zhHab2L$EN9#0n6Ck?j)C$Q4NxyZ@0{_SW^(vnU*S+a1>MR8pCtVEtV2cgjCq8AqYNiHh$%2MMci^$P&sw1=j99JLOUq z7K-+9Pbe~3r_5-{zyUT%a^{k@lb}@UQ_7vZoyQynZ;0u18DwYN$3Ek%H0N)kg(s|9 zcNVP<;st)lrssl*;8$GPUh#{T7hBZ7PZn5 zscV;2!F-zC_Hd|=oZ-|n6+fCC@tk&UQkWnJCIw6npn>R7Ux1K<2DWP9@z85DQlMA_ zJb=>qEwqlm>A2X3ocmpynX%W^+k@jK$p}{4hqx1ArF+Dh<_`xgrUwi$nP%l! zrGb&=!-2W^6`?0uB$JC7)-X{)wu|{u%~n#b{G;6+lO+FuO~P7ntRdgEjsZ^wuALzH(jwoy!oL4fkzr0`#Ie4j4$$iBXVZ|~Y$q?b z&R1sr)>6``1YVazmcPuKi$^z58uBgzEi*M0x+pf02$07qSmZu;^G;L>ke1g$EBdG) zbnlVMQ<}KgSa|+TBK2P8=u#ROaP|VdhD~HHIba)L2sA+X%%!&DHa^KRFwzf(xLj*n zU?o##ZBOGxus~%RrRzstc}ro;xt&1F?Muf9md@_i5+i=6gEYu0b$|H!A!$up;z&)9-e=eFZ&p6>oln92593>NBlXEbhxI*c z#p6XO$-x#Ix)W5JtjuH(3+E_z?ShCd8T&2j7cKd9D2%)hZ?Y0EfG8;ZeCj8L9Y&>x z?ECxFlR@Oim!uz@{+Wi7fb)^xVORGHlFxbEOUWTWWj{B{sdxP+A&J}=3kGxQv?7}3 zJ&IDUIfM#W^BxL0^;&%tm`R55H-%&@fldJd`N`;7HP^=YfD;~%UW7bM z{z0xL1dIV!80meFs@Ohn@vmpw3TPkhSQdisHV+cRBjs6O(_q*Vt@OSb9OL>clrK9- zSX4h1s_u)}=@*WjIHa^W9`4ez2omit#ACGL#MEfTC^uDlJ*Hw6=r>Sjl6*@M{N#v9ArCO$i%nWLoU!!vm<)~6lUZeKk4nhE$nmZ5rb zqRMoFp(+$xKM|^p0+t%Zz6*XF<(Z_J6P{t6btOQle^BGi@y|PsY`b+4=~`= z%3xq(*57`4O0Ho>8T-5x9;JIX{i2YZJ?2yF0j#m_850l~IxcFS zF}+Hn%T=3E+5^+;>Z+W+bz8g3t>7bL&DDG>A>XRA{c#VhO+8b&u24=GeDR{-_bzkG z(m4U@!8P|-5?D5t2zH)#!tE9$kqdAftOqpQ-@5fvE^>ILJ2-uGR z!PT^f9ha&4Rd6D+k@&ofN}Q{7?)(Mi|+y{=*| zLr1D_nP@6vnFzq&`@Xi z^1delQ=ybh5nz;)CR0%tK(Dj9vqaNg@pQf%L7EFOT4+K|8On<<+CF9<7gZ zrua`3RcS|gRH`NWJow9+s3F5yKv|XEVulI7g6jCcT^cP83q4g|LrtPZ?Vl}&G;|BA zuimire|?yS$?q7`17^;ihCAy__i9>hj36Orzm%@yl~tbAbj*i!rz*(&5WF|Luz&w? zZJ8_dY$-(x;~up0dV-_?wbv`dvvdd?+{|~_F`*8yYB`C?>B^ut%@q3(xOVN7a4O}5 z4SVaQfr|S-Kf2u2>HJ`7g#q(H`$Az(=}PUh9xAQ!1^Aip@jt@U#>eCuhQ|)M_hpn4HNOO+x0zk<8rZ^l*yPV*;*6?a+KldIVhQnP|>xCsird5i37>hl{ z&ZLu(#!;zE$hoQQp{>LTcOTj+>xb$+9%2nAZU-w6hf6o|?v=KLG4cW49`Zv|*l|gB z#;eD}Ub~@tRMNWwaC}Khcql44h$oHM#^7oVP{+mVGeER!Sh+?*a~JC?Fdgzpqw1Ny zIRIOuC1gQ%6wKPZkrk$7v~;x;Yud9K8q`gyuod9LGLcWbK0{N%a=M6(82pX5 zmr|egIf5yElljK7As1`^io>+y8g1Z7m*DKjR5TALxei9YFT23sOh)q_h#@-s2?;nSM*$i6~ zvrzrZ|B7ZM2&Jnh(Rgq#W`D0JK=@b$5+T*w`!K8 z%g^y_-t@s*8GLYl!w&`@00@sL@BSSJe^=+{P5q4ga)wmhu~dm5TF7`{E^hqp(^Jq< ziDy3n@-|UUFv?-Xs$d#*g^VQQ?)4=ccC~3{b{^`S3Y4;NI$>|BE^#D0>QrNOpLc+W z+xBqQd-s~owIu`dsKnJ=4jE={fmdUo+OjuR&mrkhEd7J25!}ewmiNHDwI5BtJ5m~B zZ~OvVbrN`CXMq(Y!1(YgNP&!NK%IFPsvJhgHk>*KT5#adNk}0ur4df5?rKUlFGp%B zft~yw3A9+9>ELlMs&y~`NTV3Jl=<&nw9n|^%}W45KqlpDj4*7k>_t;e&M%Mgb|2Q!e%DF`)(Z3CSi8Vv@K^yCcoTT$j2CH8lF>@{zn3jxO1J2!N zZo^0BV}^#jTt=z~G}3k!EFWR?sX8zaRqW2g^D^V?hp0HKzeX`6NZIV860bO0=k7>< zwPCmMA89qOkN?4TTDJER1SK_naiON0Vue^$*uU#ejk}%)w(8!c3ddg-Y*{&gaDJl9 zy$;bp z{28G>G|dN(9m+G(-`UmsimBN{T_2_Br{JBg_~q^~!^~UEL!i@9l)|wu4((Adg+eSF z{T^6MC3|X)OjCi`T1ETFSq1Cosof3<^cMz7_IOK5nJUa@@eI`4;)DwfU_WGsQ3jLu zUum$1Nzfp0jaK|Fd3%7Gixuz;tn!y($zqZQe&W*9g9j5DSydd@I&Rp$B0jCz(g2U@-UjF8E6NWHv4j z%nx2%>3-UMOY7t1I4RMc5O;x$Bu{?*Sa9lv;1wFhlH3ytT-Y!WA3VBAeO3{~P;abe zWa?&|DgyD~-*#0tU}Eg?P+;e1c|@5d^kWeP+7v-Zv0Lb4CdGaH1pgoRf~1LKpTmFS z1J`tFK|Y=+0>sQTVI-t!bs#AZGU#1vRCAZ*M;$n_rxv5y1R@t$Md!^kmuN_QCbgbc zzFpUbHiM%p4N{hRuCIN&nY7W`P1ShUK;RPc^@_#qqT4&CFJAe1&FBp9zwS`{F9Sn% z8s_8WIlG=ln0pGSRvVYLK_5-ZDcatl|_?Z58!`xa#g{m^S^0Bdc`82QA78 zZyHXf)tBckp8atH1Q?`$>YZx~isbmi!%8Ej^KaVMk&OM&0zaC55`bp$>F{3qF=;R_ zqF-uRH@7_k4!H^O_UXBFQ~c%0&o8|P{i`%3y^TCVZ2{Gxkc$-FbLC)0qTc!byd5|@ zeL5{x>9ZuKKhRrQ&kRGr1H{g}DR{|`z}S6v6w!Q}H>G_NHFQ!+nhC_0qmT6ay#7_M zw)*JdV(cT3U*-Z7S5=N8~ zqviHDC&2_AoHf4pajCWD2!c6IB2FFzaGk^N#I~}g050Ki+<`HCMw0*7hOPlMjjPE03@_AOp9XO)`nSzi;hVO3x~Zq! zXfE%RU$XoOef3eO^c3)07r`?9hB}4vWyey5mAHHOE)I5?lvoT9E47~%S)I34_rBW( zWI|Tdunazs(`@#eC8gs7dmyA1442oR*qCL-&B8jid%v`pp{)+7NU9)^4yBU zj`l1tSW!Ealf@6|Su5i{VuRhclw`c7zT^i((1JEfkj8fHf?^8@kOA;Fl6!Sa!1b+w z4M!V+c-&`ndCyfHt_U##;e!n&`l9iUa!~tddM^8`7VAi!+FQUDLuc>G&qFA&Nv92E z^PeRc*B&s(dnQ!=YmkXM&oUI|NWM--Dk-*_h2!e|%;g_r>|7oXT;M2oTrwc=ZOHNinSZ`F)u9^R!tHjIAa z`XbchiON}^Yc(F7g$o=_ZZR48w$H@Jg`F9zP0Btd4{EqC3j0VYZ^z>SMK{CuIfZq9 zA57(2N;^?UTBX0+ppG|4Vs+Qa3q@rU+n<$Xq6P=QC4z_Q_EN}G@m$auYQKnYO;_)h zq%VgRGalhPoqFllEbM)PNEmQnZ>`au_WE+>S>dnr1%I1nO5{SdzNQ(Ah*x>)*z~4S zN}1rH3fik6#X=Jd?>h~o_j({vo~(~l(T>CNT9^{ltUmcAEZO8?)F_i_V~U#LQ4FX~ zfGMK|b#nZVl_w5^9Y&ytFej&kw4ie^=LQ%0m)9Ymt3H58NEV8shW!e^zJ)26)d_@9=pL_B5(cel4&^Tt zO6Io>{OFc_77P&n@AR7BoffCx&1MfuK2l=h9?;wQ>fkt8aIL%$8=Q<#`yji3{h3QD z=>tq{98B>mvV9*S-kxZ_lOGy1haP{(N|~4kh0N)=j5Ue%wOCZAOzSBRM_Lz%ORqQ; zxnDfj_?#j8O?4V_;jkZ{1xnih9Iii^n)oK7%g;rUzWPPF`;Y8r;AmB6VD}N*;bQ&K zoX#U@fj|j1%murvxvbHO~sEm|M&aUFa345-Lz@;X6Bf0Lg3eznQ^)yW6G^0PuJ1-+67e?V(EzUmE_WS4QJ;-Qmz zbTIkaawf`D?2XgeFR=~Z57osjfZopU19?SsM+~MZMbTN%-Sx%;Ib{CiK^m1QC$oNW00Mv00kQck)~W6Gz*I9% zGvG&S9A0HB(&udQ&`U<JeB@!CP6~?ElNv{O zeu;&>)TC{*S8U~}>aNQ%tkD7)#+#Ni_c9;uBPtQXr}Jd7+mfN566+g7L$tGdOPaHS zP8}&*friua{dxgssrGnEu|79`Fj$`U24M9EpwM%Hxfy__V=}rv_?lh~+x3!DWq28y zJgxWL)3;rsS-y#^gNVsa;6rl&gWxa@M1i?=A~nb@!n83>uBS?C=9RV@5yXTSHN7QR zYakc{Z*HMnXu%6HKLt%5$JEjg62k2f&VJkc!`g|{{!FLuWG8?YG7>?_xtB zDdR0VLhMKCmLxi?pRL;l;@!EvSayI)+X-qu!hV}o255=e&1;PE#YF-|Xv@ZSzdI+D zvd&@XnlLx?Rfa}XR(Nl6)?l9F7ZstO*0&)5j zcS~;Tm);pq)M~pDy%D$@U>`3qHmC-lRa{NU`Tl+q74= z@v3HY*Tq3ippd(NM;dYyv`cNJuJ+E*vx!8H0j$N7VhfmdNN)Ak`Lyk?Fll|y6J(oQ0=^ibNMtGc%hLnnSZJ_m?SSF03W~!zsdmUcQZDn#? zAuoG%G)1$-p<(?YskdO+C-;=`f}>NjG#jAdpFi8bw2>%g{E3{xAP~nq6|gxVW2pY- z%gm8;dN}*o$KD^N58QHDk6H#)3OJDpn0QL8{;XUylsBcw28{RgyYx)Em0tHOQTRc* z$eK+M;mmzB_iF%ovV=HfmX^l|e5%JCrLs}^o+lO5M$!?UjW3xJX z_f9g8Ly5>cUvFsWYaHV>)sV* zm-Rn5V@6?7*5>l10{=wNBHDcCOu3qS>!=NZB(fx|sGGk;Vhcpm&I=1e^ekYe)gw>^KA4ZVy9EaLwgM74JvLD6W?1sCu4vna~8-Z9_`7B zznm`A?37x=m>m2lnZ{2Ma@Oib`(a;i+UyCUitSYAE3>xDg}$UNoq!>R-T>{1ZkWdw zf=s^C3hlsxsUkpxkxacr#I1|(IfHgYkepK+|*tOv&%9~X55T@^nC zUykF9q4iR`;qk2UKku3%jjh(~O5VqWnvjUn5yI2*__M{BN-VO=$eAZC2I2zr#S2!P zTNLZP-Qp<*W)2S%slORO#6Q0_nq!?tpUoLsMs(~o+U?!AskvC$eZ6KzO)Fz|`47HH z*HSu_Pk-|2ZeYFJ?Nq`2$Pr+J4>O0-n(o^)CP`11t@P@PFLF!O42x!~59Cgr^{a5t z=`@IdR>l6L%!}0$UE>yFM?yV9yJ5yX^wxUpWGrx<$>tq7Z@Dz`Qhz2-EheWhfb@w_ zXEvF9I_<{o$i&BiGPm{f9^mj4E)#49d)e0g36d1-<`{wz%f##I_9M+gJlqZyxrqeH zU-T>9UAwmiK0PVni1%phAiW9rO!h=M62UrdLB{L*(OoSD(g};TFU;2GhSnq_I%wme zmwCPpg%fdrCUU(`@K7>$mlKTtu1RolK)&O}SP+7Kciz?7N>#bQpiu zsAh(@cmi&I@SIIQf!U$r!Mz~%RX$8GsX^3#wH4JgH@;v9r>A5%JSV*KtSp^~=P9kL zjFx7aQi5`&v*9f2rK}a8$y_cq9((8WuiTR_3M2*+p?Jz27a?lRZ~VRsvY0Cl7lY*~ zqQdkbd0ir)3z-n*(% zaDa3CvViD8qt8|HRJ{QP zRDROdhQtj1-Z=hTYv-lPjn`H}x!= z=EQY_$+<0i>rWMGkbYrC2An}w68sXRkDsNg{n@X|5%4A`jUgRf*^@5&&Y2#;YyXup2kJD0~#2(T&e$*jD{lbQ9 z8KnNBv@dITN`4?^t#M-dEmzmrec6`^${75$1cOO1@lt`sxwj#zycb`bEHw7LdH|#K z&O^5mLtyC%pL@9uhkp=e23Tvyr_%S0%a?7B!&;v|AUc`d%n>#Sj!reu|1<%w=NvOO zICrOAhQ!I~nuz=nv#`6ZncAX%PQ9!?7n^(Wh=r@9=#|Hz5?Bzj-W15HL2d*5V?~EC zr_;%Klu9EAkN*yUX5WHulR?{kJot|9!vmPq1#9!Uz#A4(MBQjl;ex=5eVCi))e@jw z&IHZWbc}snb$9HKPjMX!Jx1K{Fg%ao2VZ}nY&RVetiRXC(&aXHj47Hb7iiHsg>?Y3 z!tU;87W}b~RiwFt*89aGeKrXuy1#{-RO}^}07bU6BR{5pPlOrGjTZpE_=hGPA;mMz z!h87UR9$;{QBM9h>baU|Y5ZcohM8!ER6hy*=g)YOL{sJigy-Qw(u{&7dLJhLSy|uI_9{dsqBHG$h7V&w*sL^$A_jv<*%G> z*R-~9P15?3zFHB$)XO@S2NT>b?qc(eP<=$W4Z#&JWAhYukIaJ#(=J!C=TLEhbsy0C z3@J+<+9JBq(d}2lBlBZjha*u2uM78wFZ9zfy6|k+sbtv^eK?Da@CHGANcIr2;H)su zEc()tq1ncFDA^t+BAmqC7X5g-Zmd-v^L@A%Bq_+7gh|^WBv*PDo$9bTCgH*OAe9fP zukt=@IcaGfhxF@4U&}WOGyyACeSe3qxUNEmLG$eKlBWQI!6H&Tel*SvzY*pG`!`Ldps5+|JZcbr>u%CHxY@q z-!8tw#!2jApX8U}aenXwhL@hf8~&N%s}V&KPVZJ?XI>L#>V*ySruSllR1b`pJ*YS( zq_iG4w$v^p3gOg-Nyp{x@rn*9%QzJ;t2ud)u3%y^|JfY@igVVzrAcK> zw=%UZ9%dJXGEe`9n7v?b^*O0$2M6L@2KVT2McUsX=5Q=q5`ovAj1sgQ7n8Gy%>H@W zTRMga6#hd>n?1P87t^SGB}qNBu=P6mYQ#5>NP9m>V`eIdUP7jgNu;eZx2oy04dYPH z*j8w(ua@9baO-$jk*sZgzFs9(XqebuLRT35Zi`5a{}!W8(|99F$zPdWmG+SA!xyd! z*KT~$?wj^n#+&8WJvcB8wbJsSGx7V4NYuA1? zA!3}7A3xzRRo1FAk!$qhoaGg%fTJYbyKj22+`$Ha#}E9bFv!EeyuL=vJoSwdLR?KC;h=qa4XRB#>DN*lsr3DfpN_?VaQv4#3TN^7{a4Yp@ zW%HpHkwsna7RI50q)(OS@>?Y}@fHIHhrbrR*RAMv+<3*U$@tayQi~#c0wFwLOBbv& z=*k4jDps6=LVE+|yg(eA3oohna=$JGd%lkqb!PGSnP@TBoaaX+dzYtsqMMH>Nv@%` z!t&N_kh-L*wYYEQ;k~E3i|`(6c1=;<${yPTT#HFtFj$>~%vVa}8K_hmHeXujaMT9C z1o$(}Ki60yK5q*A`WEsjO32y@N?1`}Zn^^4#X{Q14uyJDy9*uGpDTf)y^}}5{M(cL ztmBEv9Mc_NMk2>JO`+Y+00u8PjaVHAL))e>qrz{=D<#81LN+Q2Sw@au^LCbwrABK= z=l=B873Z5L-*9R>9p_yebwOLN2FAFgKDaf5O#9wh9F@;O zAxss=d!L|#7;bDv=OJ!92&4;GUnCEO-s4KZC^(Kc+mgi057#nxeZQLEFm)npY%4%Z zLB_)u6phuE%&TB07Bg$mGG_|oYwcYzvDw|J<$41jAGKC^dkgu|wumTaE$-Fwn5$~d zH&^3=MD~YY)^mT;gG(j0m0f-$!EU0xzR*U`j-I|!#7X75N>~5~TOfzh{yG672S@M^I*`iB~Q z7BHzNOqZ`)-M>p-PB|-L(~nyRNuqLEZOxE$KhVgIyFNt~!GazVQU$pG1pkMt;^37+ z(sQTV=F>i_snO;%6-($~zZv)-JEES>-+(F@<4hZPF2`;HBh>}62yRCAFH)Kuu4X9- zMt~)JfKIfLHGr6)3Sumw8_C>cVd9(zhk?zo0D#x2oufmZc5Z{AU3r&|25mSl_uE zEh&oAH`64T;c@cczZ=CW;_xk5F@Cyr&AghF)0Rwt`Fhjlg@cSCpJ{#k1V~$O{a3L+ zFv20!6)+Z5v1j!0P&{!Xa(WC62LR>c)Y*Mn)*5)7(CGl#r^WIj19Z3lzJ{kPbP zW=@wihPsCCy?T$_2me!<-jXb6;pO7zF)+l;2RV5MxIB0=gf*|72`!3S)Pj;jdMY7I zjeS8q*Qn@1znrB)UC;yaPXe{NHKFsrs>5O^q+;^#-!=Oh4*lJXU;Mmf;+|hniNMgy zU2yJL@RDx}WHyd-H8R^1NfNBVrxS#G2H2q=jab#GQXD%--{*q73sV*lb5#{o5)koa&l&8WEBsmrnzW|Fdexf%2gfnMSn&BFJpx~9ni%}4v})d^}Y zNVoS+Di-%vlFuxx={;NwuO7zFMIJFWFq~ zlJDbiZ&biU(I73eLCTHMg*IbtSseHd1=o*6hK8v~Spp)z40uon@C(ZGj5>5FST!x% zM~*YSJhru9D1817pMjOSGrzBZT)t|MG+8##er_UYAu;Va(QD5hSVJA zNZ{%W_cG@qUy@51)y0?wc)o6@Pg-RZ-dt?z5g~$V`16YY!<(QcBKBTeZp&d(9;A%1 z`_AdqrtMsGNJnCqrWs>32nj4<44EhMyY96}l36vHt>?IVd(Q1*`_LJddx`BDc0587 zhVGJ?Ej(Lv79<9DjglvW^y)SY18D1wXMq6 zjLg9c<9BUzTbJ~^p{$A^08#k&oe2ST!bP$OlHi!BI`wrXzeOsaia0Nr$;$w7UUJF- ziLh34^FQN&Yk4w;<+;Lybkjca<}BTK%ZB@kzHv63=ZjklLKArt45c*|Jb-7tUXgR? z<|nefjO{)oyX2I%r2V;W;w{vLp32HbdL+X*UoF}5=7c$aT7HZI`{x-NKWh>r%xC9y zC3n)xG^=19DK7=WimBvitNB*f=6!VlNO!wknL0>#Zh5+Pr6O{aT(KO@e3!hITlO(U zL)@B@64t$91yaRE<-v&@2^~^yommy)VCog5L&jft9pJ~e6RZ3;qbg?)X~h`zbJpv? z8(kLuUfY%agdu3Kb!v{;gHrV<`W}j!N=>V@e(8rWQjO!}S~Xan;?d*0HQ4>H-lk;) z?3;xn!k52vPUf2~_*u_jrI9B0*a(YvhpElg%1lsP=|z#t20SLfw5b`Wn(}~ok$Q8P z@BJe@o<-E@Ju^W+O_hrIqkRL&q_Rb)3qNHntsH7s_8t+4NUC(Il9HCcM~*YgbK%C$ zalq+aHRm^b=A-5dU4c3bzV}FU1&Pi30Zsg^2Iphtk>^yV5G!I;fRS%G^@uCmX5eyx zp&lqTy4%ED=O1d~Sbit}rqMY3+B5S4HDTHGgQZyhS3rDb$PeplhPA)SjO2v!wRrEh z&{F2fmd{SCdZ5hVY!7TB`B>f)Gtth1wVNs}rU$-qZhi$=o+99wS9ru~a zXK{2+hcuBF?#O5X&4sIM$yo5#5oARmi{hk5Pu|3r8zKIPpj}~q#*sH5xDFNJq%QH# zt1>{)rmw4N;!fE&F8ddA!f;4nhE`Eyo94fZ=Bsr+ITYf96u&d zwO+-bYWGb7PrMMn+Zj=#9|zmiZ@tGTuVSOTG7^H|h1x5*AY7yL;6$gO4OCa3chA;4 zkx|mykZc7o@lGFGS^ci8vcVx2p$F}aQ9WmQ6wj^v7Fr?hi)H{6wRPA>O=nfI%dZ@; zU!&m1D7SQ%9`r4wg9p-m;Sj@hHYe=(Y3T4glSjva*Ph3Zl!h}qfQxN58_yZDu7MHV zo{PKsvr=@ykB~`=2eOU#=B-p>tJ9r;0||a#l4i*S{7HG*S6^QF~RsQB0^chFoU_rI{H4$o11Kpq5DLDwIMltPBqGW}{`fm2&`L5Ye10{!2T zV8H18T0qDNarqTMg^Nna|7=Bn#^46MbfDN~C)A6@?*g{-m50O(frJp=gcK_nXBTXXIs+67y*JXmlHP>ubloWD?(^4YPFq*l%sN=W+Km!W5 zDV=*e*DgQ7Dw_paa?{)xPC9fEAdm7MRAu^&RL3=6LU!Wd_Hm|zllIRBvc{R{F3Q!M zA6~e6;E0$BjFx5AM(}+y=KJ*={JhRUpA;%6mh#-k+81En2Fszg5@%~BvTik-i}DTS z5KE@szS7BoOY|a_t-h3S)irb0+)iYw!Xx1$Qdu2CKd>Iam1#rMbAb!jWFN-OSzOe* z#^qv=lLGRL4ru(Rz|T+4Dd3?55gbQ4w4Hl?JMEoADL^rg-W+O%V|bdS3JqA6%AEq^!Eug``PGKfm{tl=(bM`A^lFoVg6wT*Vp(4ytMOqGM{8wnOP|a z?jj2{V)AK@FjJfjWEFMW7m@U5wEBJc?$jn3N?n}dCjiKG(lrZsx=P7Pqn5?t#667l zOf(eRDHhxg5%v=4#)ZB@y-;WJo0=K?WX(@6UA@-SE=k6OT0J|quXZH^?5EJipq9`9yM$ z$9y#h-lXAp!3BLtL}ivFYblpc{5ilKWKy5tYwA`=j-_Yki3=qk&Kvp!1D3RkEz8Pk zWOu<3vUuVLZLvgbzMD)HD!bh zb!H5xe=AVz@rgg0i~M?m&1c<+;=p8Un=gk5dXU5#M5Bn89Ho5)i1t_m!zu-4syyv&z5Z)s zVZrQDXr}QvA-@dSv9b!=^v?#Z9ZvK~Zap*l2Ja)coK#7}y|*2DH1@$=zkbH|Wi0UO zl)B^yJ@2XJX;a zr23%m4Rn3lTHHxEx;K2mU;^H=p6;+FD3YScNj=zmFlL$`!40kh2JR+~pI<-C5efB| z_0)z9v{M@3tXbOt_$}*ChQ@NAhE$FG;jO?+7oQp zqSPS^t1RGEE1XdC&xWIkf+J$mX$er3+}sh5x=D(PVJ|zaLYvo`bnllc4~67SCF9S& z2WxWETJM|`AP!1g*q}X@MbXvU(lBjG#`A!SDPv@Kco~Q@w4 zhxxl-t=G-thlP4wPT2W&4VJwI+cd)r%>t({ookhCjKOpLN$T7M-F8n=or8jWfu~-? zQftnx+QNF3dIQBMQ$Bzcnl!xkl?FpE==})`Ilu~sO>y% z2^Ip&@U4rpI!`^Lk0U_d>T#I-3m~8=-@UvG!aveL7?6C$mkzeQycqA{MDjdT9<+c# z82uz~M-vPVc8hq+H=F_-A-r&O=Iezvt)V6M3v}|bQUWg02)i3ZERT!j#c`+)6mTkT zfKQk~h~*RCocnMVM>h6FSyA;?+_FO}9hlWKdk+E_N`LWbl8Z z#o({K>prM|4TE_CJGt|3we+n-TNPJ#7Fb&Y5~(=VrLy6+>DiZW{z`Ksi+Dg9y~xKY zviIVS=N%uRzDD9i;&(UQ96lEYs|Uxbk?Cz&4;0W7jA%e)V?Bq&oNdnUU6lx zO0AMEzp8zpgF`*Xz*aMQY++iTz@?+Lzik18$#zYf1r{B4&}GTvd}Vpdnk^?%-kz5T z3+IrJ6YIzmx5zo^L~;YqIN}i|?{f%K-8vY1V_gUFTz%drJ{U^+L0v%WeGn)v=f z`_o2Pz&Z1FJ##wH(B}{@ty$>>9y@~I>Ksq>aChMJb}!LdLx*N`%X1|8pWur{XA8MX}%yT4&tELm*eW6G9f%M@YElu929t=)}DxTe}#!d&WN4iMA2EW+h(r^0H z^=du>JrQ`bmhS*%=p%zUMN`xJHe`#cR)COOwe7ZK9W~$k4XS3VyL(6XOf8NzX-iGP zYp`F`eg0{%5Bcp2>my)MwxE#?nE`20EI_KXgNfkSDB-GCUo)h_RJ9A!Di}0o032ua zCskyeGJE*M*!3lUj<|D{qRFcmzyOz&iXav9=$(^paH9*-;7Nvw#s%CChI>* zc9FM3N{?mXh)FU|%Q#!&G-dWm%g$eoq_(w@_C!?>8?s2PMxz2bc>*IlmG6?H10aoO z)G{YC7E+nj#FZMuzJsDT8h`S{BaQ?PCjdM1O#c*!P$CC8m&h=cU!?Tyin4AXtY<1J zR(d^4N=biO4`BVEmQ3xgu2AllW$0z`E_3(Xx7J2JrHfre7qvZ3&<_(fJ^co+DQWY3_o>A{zI(d?@^FC}MUWg=&Z*d%MI9%ZaybVc z#szJnu|)cis^3rZYMyLeAGdzHW%MVYEwN$=#!Q`T?~ZPK{VnxK=7bN90%}MKl902| zI!lnDvqg+G>I6+pO|($fD>|hlC$cOsREtAn^Ag~Mza3}+Q278aq$V;~95DBnI?rtU zt0?`AAk6nOi#VLlH9k)(L2igRE0|X_0?c7Sqj>PzGw`YvFu8%ESt!zy8d!POv67I+ z__R)>58VMKdIfUWduRk7tpDKu8G+|gNx-%8J*g@6w%k%L#QgOFfTYK;g}lgfvF~oB zGg^`S3O|PZ{PP?2a0HVbNDoUe-fB9oZ`+Dlpfg8pE1;DtrQuh$vwkTs5NtXc0dB{A ze4KsZzpJ_yp_b>WTZ46=EgonIJ6%ip4cNAo1sOcnX}a4KDOr&f4panT7AZ#yMoRR* z?stcqlmT57>Cb0fx5aF$mV^|&*B6EAjYo97KdHUHbN7JQEGCvnC$u*U$WC@zC_QN} z2&RCu1P7zA%WMKN0KbjYXZ~k(N()~tr9yykGS{&NXtdyqx?%>yKpbQecr&b!#;c1n z0f@HS*?N_;=Q&d9N$LHyMMX{I@#P#RlYO>^q5iGObC-$}b1jX9GQO+8+SywT=7xS} zu$)J(*Z(tb=#V+=O5+GYU9x8UxcN`l9*T)Qw*aS`>?3X!Kts|z;d)Yk_(=-UjQQK+ zeaul=Cy!K{MEBZz5jR)R^sYTCesQ>J{mnL#3TC=IM>2fm~(9x^;(+_))0GwbnC`goJ4yRO-qdf1Ttiv& z&B9e!6da>(sel%OzJi0Bw;4AHDh*_qBwN$vdH^MbMU)gar7#%4o6-p|axE_f8!|GW zmQOz222VT{YG*TkqaQYkrFq(i;gQFCZ*}}xnXyI)`qKk9fo+~WuQ(VZ2s?&EwgTQ< zg7UpdL9{ln=8L>g&o3y2^inF5V9d&$`^gXF@OhQ#+lx_-Z)+%MmXpy}l|spd2RD|i zi73Yh;!tN6;KOe$4-||8jXrezCD(NV1Wslz66w%4ND^Xg?AMpS>sz;&| zff^GAXeQQHN#DV!(|-U>9>AouI<&ONK8f$0v{ZXPj(L2ncUewA2(*5E*S)s*!1h4L0CLhJrqDpBXT~mAtb6Hx5#tL^szr`l zkewpia+R(UAp!PSJ{`|zJ1>Re@lAw)SvV*hLQdzUK*(9uZh=P07|5j9g1R?WpYzFD zA!A!Ejy5D)w!+!@wgA*Zj+(A#i=zjOwl%`z^NGpJNOXFqcS;5;Fl?)E3|v&L3Z8+! z-+f&(8Fpkq83CgU4t+;cWHyk8JdW)-R;=#E2^MR#Cq;ohA-V&Y%`cWhDI9M!YQ|5D zRp*71mVyzJRi{YEJj)@3>TO zRrc;;!8LDvkJ|eUeb=jcw$mdxOF3+*G~Fm=gzMgT3y)QK&zBl>keVrBn)kr;m@TjY zS}{JQt_#V6PwC5a%v-=odvY1ymV?RA=Xk_*&VAWpz5l@ht=U`zT!ZWzQV-2fdDeW0 z-o@ISc?U$HP6NJD4>ChOaOUpf)xh{veSvi@hYe1kb4szwq=z9?@SQ|4LjhUcBy%K# zy3R&7*Pwdv+Az$K+3+7= z4c#4buKON5b&kRK3ZEb}t}hEC(6MEszC;p^xtgt%3I!WGo&W@Y25f+|dMW^Ujy-jA z5^Ri&LDG^3?-jls>CaxONjSbFblID0QRs@d zsK?38TG-g~Rvat}9JV}DNpt z7fNB{z>}xGcrBjEpF7a=&HYU)5tN;#@AjF@~Ut*Paat;s>>QL0Z zhlL?lof%miOT{1B=!Cc>LCI6~l&J|2nN<5Xl`kQ?{v`8kraAy|@ACU)zT7BL=S&## zvIkWHUcdaY?&k^uTCO%21_|P#j^g!8C-O6r$!l(G2mKGolMui4Vtez9FYxW{4x`dJ zabMN~eq}Lx?!%NqtI87`jkiVhVPG8~q!wTS-I}Jtkq+IUOQbPQ0c|m|JrT~M9Yyk1 z>FRsZ0ML1nA3;j4tXn`pN_O+mAYQD^?i8gfAGwIk^6$OTQTZ8 znT}ecie_Vv$+`q2PL2F`PYH4S#tOWT(yk=n9%>XIl}9HOg{3dT-bb1p&U5mr$2{%K z1!^)JQecEV0vxR_euKoOufuevU$H;&DNhc_2|7{ll@-ipyiaS~K66THC#yH02V87< zWqX+bM*_i4BrmxX&S0-d&^X)a%PRmhS^^EgrtJJJz5?)>_6p*rHJCZJY4zuGWfmU) zMXMZ+ruU(<--ki+5wKseQVW1y-%Y$4hW#TYps!pE{m;{7@s%4exWco2xXQ`Eg~$Na zOd!$zG9hB=c+i1AE5{>~Zh4BKWupR30A5{w{p85`D#~xENt|0xB(FGZ@ym!0)ie-u z|9Gnr53Emam={+_)MzAM9g!UBiljx+E{_rrxD2~ew0y_U*C*~c@}WiTH*)1E5FY_H zb9$Mq&0z_)3Uz{e8})-+`N`uWKjUZOVgS;af;yOgA^-CgMH3Lv>?`2WtlWZUXM>h~ zC4_-8XwEGZnJB;}u+BU=|LuB^S{J>WYVJy!aEJ(dOc;Gco_C&~1*0<+mr#X# znedr+1cFNM+G z;%h_Fc7;cA*A&`tHPH8ti`W6xVp~kP3SZ3v9K)A^qDtX9+RI|;3)8k85EzEAvVZ<~ z>tds>B~~UuCD7}6)RMvM$#&H%Mk47P@Rcj1Z#&XIJd+T;KwKei#djH50iNGGxGVL+ z>#51SUNuqjtH(k;Q-f)~$?=N{(Di@#dL$QuaZ>ShaN>7V*^Cx2y*wh>;x@NHzFCn% z+`U9h+=^dpS!)v^f+J1q5pZ2%mh=*fC9bad-Bf{l+8===Pp9@(K)p>mDBBOQ(|N*s zDQ^op^F{o~26!tCCbp*!y|&!dvCOscvl`62w7MU`$?S}E?sffm^2m7Vn7G?`)=v`1 zU}z<*k^hU+N*^br&zK~MY@&WzV?UIY@G}kGgMRSPguE$=0L~;CZE7hWfF^4NeP&JQ z$)~T*w~#d8odQm0;e0!F0-=dP6a+TIG*SxczxROjNY!pYQ*JY{0ob8qbKkhU*F;boI}o zArzEw62Z=31}~p`L%=WT&e6l9+wDIEd$Lajm-Ib1+uP_=Y3(^}wgMs93YyvN>fTd) zEGg`=zTc>56r-Kt=eM162qA;$ULxN>tJdQVaa;C`Pi%qd3V*xCzy!R&eVlpB;UfQzo`s9*MyXo5pxgq+V!ZFb(jU=~!H zONrWtqlZSf3iXqm6qx+UDTaDpn^*g42neR2aUF}$30zkrsBrpMu_^Ih#muQC1CN;l zi60%dmLa)iL%UyM$}B)PTv#Xpr;k&l(>CYoqa*&6TN@x zLS`lA+Od_8=7Ec)p0VZAo%uCVeBkfKP6o!HqvV}=`j3NHFYb@ea~imrqI+p;=i5=R zFwWXLI}1CPqr^_}IhFvyF9S6+V9jAYN76jkz-Ww8#RZsJfWi2~&I+mreVZvp`7?hO zODz1ETpx!}Peh$g_)`6vqWs=D4LCz=7lGIaW|{h-jy^VxwjG2>NG-RRR}Q;wZo^c) zYMJC)-d4K@Fg2|nE8^IlNfIEZaG9-v6pb$mZs$(!lr@G6_Rv1;RB% zZ;8JGe6SrRRqvuf!+)a7=L(1}bA69vNOs^LUQr#PpxLx;X>%aL{*q3>@8!(5cCt7{%nb-1bbIF9;kyGMVKdu* z2@#z=PDsp@kJQdJB1Vo8AW$*xJc7h>D7tikXUZimHfmQAPbce=DZvt`6}f>k?j27L zBtlta2`{geyQDQ)2;cU*`w{I)vyuP42i+7eG6WsL4b1^LQ+F-Jw=j;NSJ)Qc@z#uJ zW-1pbz$~F8()oT&h4dqJA&~hQ5M!NSw7TVgFZH3d`3Us-Z4*Lq92kq9!-!D z9*(nxpshfjnbv^ZqU{#Hfqm5c@JGwMs20QvmH@K!em|zaeRF;x%k-ko=v*X-Dy2mA zuH!QpZ2vV#I?9ixBy)Ps>dF1z!({W1!jOuirvhzNwIUy|~+9Di9w$^wkNQRoDzB!!|(S>CBg zXx`cp;(~S+t5Hoky(U`cB|YA*gM54r_{vF~KdHEV4oTS)EWgvU+W@&cZ2zuY#0a3& z90>Uu04r2V0iEv|z8KkPTmzUQ(HhD7!B%bIs7zA`c+!Fo9v|W?sSbK=@6V+UBtXy~ z77+xNG!C&ou*o6M=KPi{57bolvH2)o)>1}5iK?qpDM@pq2nSD+a+K&^mdX*^pT)T6Bl zmv%87)dQHq^gW-o>~&udX_h!{q4~tNCO{7Rkle|!OVf?p4`Da(*}|9Mojh@F6?Nk{ zLInZl?c}9EB#{9nLO5qwe2d9Af%E2fzEG9}w5$N8}$i1qCLPYAq1 zy>d7M%hn;?ozI06Bk8Pt=$89;aJ%uBb5McNhjqcgGzc$av%SsCD zE3_Tb;y4Esae2V8KEFMw84T{1EoX8UVC(^u4f$XWKp5SSn!JK@M5lqB34rWSZ;bHv z22?XF=nwL|w-(|Nlo68z>MP868W4H3_6=pgEaTVeClJ+v2KLM^8f*t& zAKXE#*L32?)I+YQvVds(knnf#p%qw@0$s%sMh)dQ_1?SYvV*E z1i-7hDs;AJ>P_-`mQGnG_<87qL^?=h@6z_FVCav|VF1W=~~L4V`3A&FcGB**gA0QD%f<;qOrKLau6B z+=GloTi?nuiGozU=~cs?-ZFp~g!r#^-3Z;j$JBmO@c0Hlw50N*Amu|B0WzmC)WEs_ zNjI612o(tj|Mm}P^5GB(<;@!bWj;;;H@nobbb8DU^G^N-YZr_E$e__f^Vn-JuZ>yT zZaon9-n4kZP?x6NQvvMQ?P2KI35F;^)!E?@&$xE<3U1kKSNR=$D@Nl)OEnvR>T=jLH<_EZ|aa zd9U=wp78}lp?Gx5?avJVCZIBcu;!@Aj_&U%^FIzrGck{S1RO~bAqv@HByK+!TZ{;( zSd3SWzu7;Ex(0;u&5^)-=fqo<^P5DnJgO@b8~MF`#>#-i>bTc}rQnsnd@dX+J{mZ> zrzYuvG1?||L)JmpH74>L>5vS+|1xfg7D98Oox6m2HQY0YcE6}yYL1fI3P1To1mZ#??6xatVYcg7w86) z!SEs-5!{D`tSmUNjjZ>@m9`oB7Gt2=fmW+71UF~+8z<&n5A$pD&5(rO-ZJ2 z9`6xT-HtPCR3zdrXQ0xFme?ZfI^fd9A%R3`o^DVNSwO2HcS2ErNZY>l0z$&LKumPl z{zgX^WIcWGUvu`|QeNJ#1Fn1U1#uq8Gplb(YHB;UD<8|gS4FuU8X=Xp=9#KM%PD)N zH+KHjyP-con|Fc6y$44?%iZb~^pxJIK~HQStEKpWQJ#0mAa$&GYadZK9rv!9TYvyL(% zO$H=L9)LlUo(2i+oVRpB<%8W*(7K$zw$S!xhW4b4i@I@U{$wX-0#s~*;7$sjxrZly z5Z7NT>0Rf3WdbSncXim;E-7hNB2a5}YctS3XkIkf&hb9DW>5(Ex$RYwgxG)~$-*M0 zZ(^BCHMQS~YIZ3}hqW6`?G6YHP%ky7(Si(z0WCKG zm*&O)-UsAA^7#sv650cXA(_DR6b3c5F0F^Zb@1=I&nS41ART;Z)kkQ zPX6*E^j%5sac5iiNiZ?>L-NT3JhBT};+~Z7aXMC?Ns0y^{>DhU36&LPL>pTmo&+L| zTeWWFyCGDaAgMvOoA*DpJdp5b;hkm?I&y2f)1BqY23@*&PjSAs_vR8B(XNT)Pl0-# zuNC(I&(S>Z^HY-8Eqn1$>=>GMO^lB3VU!PE&*ky7U;L4`Sx=&4iPMNP67%lRQTsl>i?l9-KzeJ#K6 zb{@c5dNN+=-Tq7p_GfT=X_GlGqP0^PkS~wF5CUm5a)!GAFLWI$cJ|(gfb~&*DGVB0 z@6zv|em5;4N{pH&LK&hCN$6Rfw@Izk_3e+7W`k)0b`t{yn(YjBK$ zwb!%fC6A{7|JntX6XsA!=AdC#zh$Wn*bWERZ9(-ff&w)PpowZgy(4mY_-|Qq6)C%D zG+)|pXQ5f1Z0K@V^47yOtv;Xv7~*;9i?sbq5H`0eAZCKd`)y2~kw%VzH1y-(86a!U z$m!MxZO(RW0X2Ej=u#;K`oeG?ws>H~u}^+=yG+JlIT>9hG77X7x1gF*hgzFk<&lAC>3`vu9|#L&YY~kE5sSfbXJ{t2z6(17o9( zgCcW4J@juL*yf(B3VcxK_?Oq#F=(bCwpDByB9Mcb0xcqaWrs{<+vX~e81a|4a1(%Y z#lPvH*B9A1u>)1WP4UB?C83}Rd%yETgq0CK*P4Cz%F4rDpw3>V1g^o-GHnhdxR$zo zOuLVZSA0&b7fxV~`iFau5RY_yL{eM+OZ4B)AQRGbV9h7qoqxO$?-OSxiS!cCxfWP^ z!e-gYq%Z{;-DD}(m1>T7izr(g2sfRmC4`YWQyn31;Z_`=TW4WNU5b-C=4jebg=Lo@ z+8zKe6n~7|HnEECGEB~32z;iiWGF6y+GY>x8$}#O^)#gVP?7Wdh04d84ZoP?yJ{Q0 zfLL{+S_lN8QX9*k`_Pnb%M45-iqAc5nhQYFWDr(=#ciaVVD8;c+whTT)Ns%l?v#RG zCfpt0Mat89@qwV4=pvhI3Ufay&FVr%5d)MHvL}YsG9^QdDxg(Gg%}X`zYDWuXLyOL zLkHAS01A35#I=)c(dW{$ftb0{<>Jmazf19qRkYPIC52;AVrWLgnAspkU$3D+h6;XS&2Y?0?Ul_ z^SAdO+9}+82>y_|{(HCy=>#aK^(hO{+FfYvYn9@gpD2+yF0R8&B($&g9ojAfkM0_{ z{}2-(@PRWAY?&M2)BVKz_g!Hdf-3f#u(`}%JgRVK220~f5o%f@5eri7{IN{twEDPFrtuwUiVQt3Pmaga0%V#$aFXNe%0Px62(8m%RC;eyr*f(7|==Iv@t$xApm@wVjJ3Av&M_^4$5I zM|_VvLBScEwI<`-r_t>8`BNbJuk(hh!|s8g4saGf>R;)1r(o~zohL@k3oo_btMJ6Q zosZj4(}NOXd!}72GX`OZFe4v*Ob{%Bb0-$FU}4IY?{BDgu0>6u2G_O`uim$F>S6L0 zfIx&?qM8!g&m0&ja^YMDAxd`nq{iT?^b0M~1KDhF?gT^x3tu~Gjjg}mmAkCX6 zh^}7q?5%eoq7_X&6r&sN<`UZqCuo&EA*AzeGyPwnMbdY3eWeS87^VoV2L$;J*wRm- zxS`?;Qe$W;uLm8ODo^I5>;30sgwVme`k$%-c{M;TdJm`hcAD}@rlV~6HCWV*A(RHB z7;npH|0V$v(&L;2TAv$&dtdGxM>bC^D4VkLNGF;Efg*1*{#mjU%KY9z3|6u{G`>^a zqE~JEeM2FX3iHrU`u0En;-5ddiX^|Oc&eqHPu|W9JL%yn^jVibvvd8u1nf~9e zdkkIt_$l@O_4{ri;Gpw-^WN=K_us$3#xJlJ|4)AU&z++F_i_CDIQ}_Z|Gpjn+q3bpJ^k+s(nhmDT^3$|~{H z&3v<4pl#4?es|6D?w^vA?ivVT6{Fcy?Jc)*D|fdV=FoKk5oS{F;e_^9cemtqsa0&J1*y)ksu z3ZsXFaG)I;RSo=$%lLpt9zCb>XT;SWhACi}ZUO0Wu$X0SU7_vRHsJE&_oP zh3pWP0l|0@!u^+!^}(@Fm>B5+k~9TMo&veZ%X@b-eixSk!ucBMpboUPX!x4VpS1O% zW2VMLijsE^2I=%*tum`9{E|BzVcUcdaVO>UrrZI>^WV22u* zE!D5J?Ws7-LiF#AR5=08jOl=TZk_p9LF1Q-J7-rhFG*bcS(xC){V{Ve(m2jkQEcuZ zw^m_2{ZT!*>35kf!R5CV>Ca4h7N&6(3Sagy*4Z&Rv2^Ed7 zs^BAb`mg*2gPl8W0~^gdz(jF!aKmMCW8%oKi#=hZz3i)Ao;IU8j{u9IVVV_TKR9Qf z;s)X_g6XA49H4BBfy!cV(~{-rG_lH_r+%T|R0j0y-6R2CAozKK$fD3OR1RPvr_ zH{IPy@tTuGV&=|EZkL=s|1$UmOellqwE*Y*Uck1j3FBP%QpwKW`ydeeLL?TTzfE@| zAbD1f+8`V5T87?>C6f=cuu}nH( z^+UrYbKLIOEV|{4I%>l%55?WPC_AXk+@AM*^y}=lNl%uhlKtpsDS_3xX}(2 zVAtF^$&fl<>!_h2o3#dr6}6K~M&~9*m#V8rh0-Is)-G$kL8+?q5U5}^5Bruw8mG;* zLf$^7TTLAe@Nw@vTO&`bc6FLxy~g@ObPGz8hM)L38z zr^;4xyaU_IA9hw@mbOzz@fy=+KR7eIg`+R3^W1<0(V4HWChIN4QXa493yA+H^M-8a zTtKjU7&3o>am(Au33SJE;MVCwS{snI31|Z_fHpp#gQx#Ykse9(%g%#Jepb92ZLM}T#EI1 z>p~LoaN9IDsFkhj<*jo*?c{MDBr)&#>4*LKR>mb&ypd8UVI=pz_qoR*6#5{dO^`w3 zh4byM&zL4gRAz|D%-AnJ0c$_0PaP6I8y&#v6ZeF5`7pFRl|h;CvO|L?`b=o8R$cIy zT@sPcWIesIBqmPWr>iLbF?(Pim5^y|L7lDwTzw>MDwyQ)4k2N(rp9h&jWPWR@lfLt zK*4-CXtn#^N6mU0mE2o+w7JUG370}wWWD$LilO26S67Eo4J~C?0rTggI zv{?vF6>lDk^mMw-#`43e4i z%B}W)?{=R3^wKsUn3Mx*Qn+-2M^{ zr5xb6LZuZ?-qbx(-Q8GsUt4wit_g9GK8AXgp?W_}*xY^o)hIz7E|`=Q`~?PU1~PCv zKp}o#)xN*<)-V+e#F#DqiSY3+zOCUt*eNhWv~5jVuXCXM~ z_e30YaOY*e;yQwXlM)k$?trL{EXB%w_5j&{9Qy!&KhgU+I#f-4pzvX;UcrS^A0Tgk zi|Ocv+#dra4^4Ofsqz`!{GqqBl=E((al7k=LUWCdQ)2Hu-|c|$>m8FXr8koUWQVyq z(mp+-T!ev*QM91dth6ilLPcvP;GV8i<&)tMr7AO7s^mSgG}E$uCbi@P zdD{RG+-5@2A9!Hz{ok=ZJ^&goW9V-8#U5a`S{YpLmELkT>2|Ycs3t4PrS`A)R&_n7 zJ2ef|M!cg&qen)lBUDi|TNhK*mzXY=NRmrNFyni?#74y9=#R+~-wvS;?|xWP!;Dl- zgVD!u)z(Mg!tz`IkovwWk(G>RlGVW3F}X{w^sLg}{rThj*!!ouwlk>Ait2NmoG?=2 z`$WD7$f*QbOuhzkViBlbW=XX^Hn`^kG9TP5f_TZO0dFgq%@Ar(P zXS@5-v={(ul$?n3y&8LDL&19)fuJ&;xl1XILlA;P&_anQA6qwSlL|L_hU(;l9J*lm zhPBqz%4+~T!V|&Rd2=+N*(V z+rE027RXc&x$bPC(-ba=p=dZX`~)bpPJPp&sn=IyZNcqn@~o5MliR;r>+=%_ITbnw zwZl}2tL*m|rOgRdIp<7)JXprOmI6UT+gudbX?&#c7otH(U$e)_(3|>fy1gbl&*W|! zeRfrU;&j0%-eTHpO|0C#OF3l0YG!K>%@L|pgo8WE@P0YZ(k=g?I@JNHhdo2MrD-#b zF|&=$t{Z^onG1-2e7f%B_NTgJesI|i+U_!KZkd|!pG()wG{ z+^(w9?5^sNRMK1*+#qa>Qga4aQN{=4oQA&apy7z55gZ^WcLI!R;{sm5^gErTwD{eB z5TSxJH5)*C)y3A&O6h9>TLXJIapE=X92~mUq!HH7I?lAe9af7!9ab%xnRf~&7QdMpa~rvO)az}n?A|54_j;#hKk7NtkeE4i_B@R3!_8)L zL&uDN8V$yIqf__ohj0LU)S$|1RnQ#Y?R55qokrxGJ({^L^n5hHei!;jHy;L!3z|h& zjy)0;`-=L2FhAFEHUH(>_#a_zPExKg3i#D|n&ByO;L!}T{cmh<*(Z+}^bc*>A z>b_aks(VYw0;zZ#RVlOpVHe6cXpKdFgO<#AN_`E$^MyVR2+lS`l+$d-JxoO1+CCSZ zAeLBf%o-jpdf~l)$a{thv&M&XDP z*Wu1ls*zZ_Fwbg{r?QTwfLw?lkW%xOiu@f)o`EsyKqvQTeguL10h-z{|IDoS-HT%B zwlA`y>(4nbVi}Kma)%$pTiGP}00+aKhs6`Y z{l>)e0Saxj4o;?ftBd!I?Yg?JTICSOt=(EPvq-xU0%(>25~=uOHv*`2uKn$~9?*e) z85F{c+`W9f!^ScKw>Vl(bY8mF`(9BgHnwLTM>j0TBS%7Wcy~XeMa(30%kJV)us?N? z@l1}N#K)?D)7?MJ&O2Hs4d<{YNCvPctpKvw!*i*sXFuut8?lVbFCRf)(#M6;@%_Lx z8lPj)%!=GMo_1yiz3f`svTy%^`3LeGZ0RcZyp59qSok#}KEMP-Q zh@ug!1EfN&E_L>P-?zHni{VmXh8saRqRG;e&44}%L!Gaxa)*+NOB3%41PF-bWvWDYer}zxfj2-($LOy*{;#lZ9j-^tk zfL8U~_@*b&%whCFo1q;z-Ug~EP~ZI~!OdZRKh4qUAZ?hCz!0&LZIDKMZ}q+XyHR&f zy!a88jf8Q})gNR+lNiFnkC+wU5|j8#%y@jMfIs5~FoBi;&%}WH%ON+3(&0B8%GcIh z66wAjDW>*R;^KH8Fs{lwp~V7G>HoF7^X{ENshpHMNBz)x*E~bmYk?seAC5t`qP@k0 z40h~@g4{{P+VdhN<%I~%C}62>LTlUF<=szr96-ztWtxMH{@TKjk2}dt+&0TLKHIr& zjeC#QK}B-8Y^QR-0`uJtfLnlP@;_uqTLz=kqsb=s$KQ>{t%Ek5G82bKRLGQ`ZDFNa zxYDWCMnfFZ`muUY(^grUNv&I@D3s@S?|L5fY6;kq9QT0@WG0GANywL?xsmdi*=Mh8 zjg}T;&|khN`!CSn4Nvi=hO2#~6hZt|`pYX*h4EezC$1ONId@6Ch%d%Sie%R>LMMY{( z9R1C?jY+GL_*I)y_nf_?3~ll}<1!zlZSee*T;2`3mb%xd)DL!2lk-PA8*22N4$>ee zd+Z{eL&_AjS;dyDir;RryQi7GL@vL;Bcs-!?P0GhW3F>rBlmA08bQJdFznD8^75R` zy^7w#<4EK%)X$9NWao&kii!F45l1t;p<4Ic_B*#tN1!{OpIRl-!(eI@=EWc3OKC#f zq}KW{%Erv0TZ!(Z|NLY$@Ov?|=BIlt`IL-ywnfL@oqZYGk95yLS6YmsyMv@vgfQZx z_~HD`hpM2bsslkYCc!}-b?cTs*ZX#KQ=ksr09uq#-ZS?s#};=mXZ=(WyzZ!xA0@@e zyNPhMRqEA~YIQ*;`PGb;HNk7TU}RK|$&aP93?!n&++Z_17GqqF@lY_BdYYQq}@2WJ0> z-Tg58XZaN9J)R(Hm=;kV+VO_pX`g?8T?d(yW14GxEsvDvkgq`1iYnf1_K->|dUPp9 z#&OsVLDff#N>@bwp@yYp+{IJfAKd>+2N_p@Ao05MB|2;zB5<22<79G?JNKy9RjaYu z@IM&U)oB^kW1TOypL^1Zt(goM-r=sF)vV;`GHhYWc{qldzeq4lPqZXv;>~(RCZHi?q>O2_bUaKHfREYI?CS@3)ck5u zd&Pk5WYTZfejy+%5{+gjd)mp1hD&>X4ElyWeK9zctaN=^N-=!Mb6+o!fhvf?zl_?L zB5vS48zst>PKiBDOUsUx5L!n5T>rIJs38ivA(RgbTB#zV7Gy#Q;Tl^nHD{MOKLLY~ zM>@_Ay3Pxx6kpgLG)tnQVVq$_>5jx!3VFB7Pe(!Hb@Y72%gZg6ADWiGK52?m>Z$h_ z=IkBgEc6U%w{+twSotcB=20`RMoCb7jBUUj;5imv>m!v^q}SJ)fA$#V7z5sK5)k~7>-*kf{9rjZD##jL4}(eN$9=bm zPkailUhw=VClSu;DHilCM{6SQWl>bqs1nPx)cPwL(`bO>}GnTw(9V#w#>(p=)@saWA8aIFnk^Iv{!9qmIdHwEd-HS za_#n{TZwMh`y47N-&Yy7Sk~`ZiF@1>#^!sHFI+(UmC~;%DY@_lanlgTt_x4$OEqe3 zDeU*cozmlRRIC)^WA7J#HD8QVuM{7RCwSV_e|`2K0YcyOa6d;ZKX+3U&1$w^FU7Cy znlg7Cb4Xy$uP!9lko2^Z!?v2V-m5A`wGD5NT=MiaX(N&1mJ0ET70>)<;mUVf2p;X( zONsJ1&!dA`zV*~yt5Vh?8Xt2xsoCea_Ix#S^7xp#TJ`azWxPF~mr_>ca?2!u;>DnW z=yusc`CJQw@6vs)X0m4Jp5MSIIW z9F30xBxCFq9p$2%>HcsgR5X?4wE*27@Qe~sT+bLJc9fHcoW=IPG!|AOgzU*)T%{2jqCAv$&T&xqm9dX>F&t5y^-+9YAe;1ryV!t zSMGI)u!>o5Sta}Y5(ZeR5Kz5JtRFtLb91LyntO>>W+Ll`H757^UQUT#FUU?#a>H@$ zTGnSAUktfTc>Gl$>k?u8O%rGPfginPwS*(^7Yq`qD8v8Q4)?l4uLH+HRR(PvU|WLhzLEEkDIIeXDaj2h8WHi&1&+ak@U4-|)Prx(64zob3kyC zTDg+AC=&1rng0VJPmGjG%G00 zEoXLHVvR99gshiJEK^ca;h~-(QOMVtKs&UX#R*W2T>cL&u^o7RK3}Vh!LRmB7) zt=Y!#&wUrqUuaiQ3M`o3%Ir2e?>XOUY0gEr5+{Uc;m5QyyUZW!#-k}wD>OI9H_Z57 zXstQ%V!(@6ASCGmiu;gv?~J9s z4xAS4Kfw7Pn;QRiQtJC$)7Xi**==^WP5Y?1F+79DR(02L5f)0+ws9>d#Wt4ONC_26 zH$mI!iC&){Z$nFJ6~-)MmcPm64bGDL%Cn zcPe9w8BTXs0pw(FS**UsbMh@b+tj%jLLYMZLe*r95m!*;M?o{t`t+7tJI+-K^CU;_NTD7T&@orEyOJP25jihm@I$jZ(_1yk&AI1;t}IzAWP0ePfnG_> za$HPPu~Oq?o;IJFpIXjdP#YOr*4loxN~n=ALL&b#n($c(XM5vrv{3!UE?07v^QWgd zk^K^5>_vM4sGaKq9%(EP@f6zSpRF!8L>>~4f$6?4cSb4j zj?J*CsEmCuq;B0`2%}t5k2{amKk4{<4s7LZ4f6-g*M(p(^@XR$Zm55#z98}{(=xn6 zL3VDKt$jZ^U&29rQc5)3#B*P( zt#1vxS+j!l+pl!RAIoI|xr3X_OB$k@-YQv7=81;iKs5O?CJz%=fy+FgWj>mXwh=~R zH42HrIx%v%#Aj@dbj0ge?Qk|GU2$ZdkriZ$CgVr8tcyIGOBd-T;sscW_xysc?xLG# zs7R9j;>BN60ssAK!tz{ncMXQ*nf_%z;c3-oj_!{6Q0Bh zFeWL^atVw)&#@doEs7~-DQ!Q*Zw+v>s^6W1CFRw^$aWeDV_m{#zt}QpUCR zHkpCU3&|=~NM24LxnburccXx9z;3$jNZ$uDL^DPU-Wq!9c6;9lQx_sY%C20&AUqfl z9!DCu!LmJ(m%&>rsm-xib>!E?V67HZrKMuR^{Q;4Q*tCY@~f4~T3LrZ%ebd(E%%)$ zhaiC%A?P4+oYwY(v4o{l86|giJjRnE$INT}^5H*$avT9J`*S_g(U*HdW35b{x>Btobk!6g5Ir6 z?h$?DTvZgg6Br*)?(EAg>Q&0lTY8BBVCNsIB}Gi;9B1Nh4?(n|((`TOgi&8k!esQxCP$h&n#?}QX5TxCP_>wA)VqI9o0sA$AwP@WdQzTxM& zN7K;aDv?hGOGkp3hJBzRc5cYR<8r+u-`f{)T4li2(W*F12s;i#ZA4Zggh9)mHU}CA zDP*Kg0JT5=SnHe&WL1_Zf`+PZ0G zfWP=r9CMf9VMgl1WN4YaNv4p*EN-uVefJTs+ADNh<$Zk36buJR12I*RPX3mqi}gIf zbIE|``}2ITda+PREde$SE`hm_bHa0_PYtH6hGN~!L&C#f%e;3GYs!q1K^*4~mX8qK z;mZ%p*BC^z{6XRGNyQaHD8X)aVXwKeMHKNft@eXXm4 z{H9^I#V3OZ{LZPBle$`*MW^>Avl9jkA7H!Vhp%RrO+9n@DAg1N<5tRh>?Y3pshvh4 zI!6s5DtnpTdH%h53@ceaMQqcZ5x4~L#px>hbspG zAZMBuX8s@{YbOA_toz6YdZAX{JAc}WgbIdp8XFmdcskIpEao}42)r1IiyIE=YP>Xz zulaIeF8jTt72KTBh@s0!PE>yO2Bycv;GvZX%Xl|mzJo2837&9j&kN0#_mQJ+P=}Qp zEToOjps1$M#AK!#_eOGw$5_dosJX@Qr-MdgM>hJ|+7(d?k6u4P0xl%HlPH^bl22&t z6MPQQIu703M@L!MdC2!jidOEVwjNR*Le~w5Ld_3+L{Edk}36{-2oeLd4 zr6Js^)e|S)!FKuRnL{mD5*oMeK9zH}FaX14D{-dhte`b#wA;0PBk0F&dbYxI#LT&Z z_KxzJmc(n1bC8dH1yS~Gub)C3l&D}e9!1uLOZdXINjqDwUTIJ?`WsVnQcPN;_{jRkTT`F(5_d#3^WMfJ)*L!Qt3e7)Fo;D?q zBx}+htsg)wgm#?g*X$MR0$J^y+%gmc>H6fl0#fO+;C{qt5@T5Xe_U z{!XgRj8k$n^U=Zls;_auoEO$9RAm+%9>I4wJM;4BRIdbR{3;w8w~4-XT^NaSJ%P7F z(Vcp0MoabkZ_!$y%>>3KD`spQc`j1)8Tb1LgV7Ljz(&PtVPXUU^8`xWF9EXz0-@yQgJQ#B!>dY9p~07||8Ozh6jFD!VtXIu~;#Kl_7%U|)=B~yN-%~l zm6uz}Czdmf^20uqA8pimw4+G1db$ksZY3tVmB4)iCm!9orxtr@ixp0t#NfX@PL>R6 zHhp1qK^3n)X-OI0bc?hh{nnZ{+3Wk8c1udgwBy?Pnk(2*8x%9UP17Q$G8BMXsq;zr z^i^%Mur0F8VbWZ~EmQqW6802p?DVG8P(8lG-PtP=aend+z+x6f<>F$LsG)<+Odpkm zRg^96)GwpOUn-poxxOM7l_-7I9wS}2`F$<19f|(vI@hY z13XJ3sJ>B%24#j$Ik5cOqlwxfi!9D}>veWKdQnZ@Y$N-kFV%+y*JPWP zuA>XUGXU#Y4fOWeD~)~+e8|{n)Q2cF?t{&}>xs^dhbDnpPOPsR*KksFd01o)R<oiO&rs4eT$4@vWn+SrT1oj1^{9WZl={&?r3iz7LBa*C!W6}rxq3HPaD^zZ6CHuoI zcV9_UDV0?z;F3F;+OTSIP$Zc^uE38J>8n{GX4;I&X^)zjk!1@QImF5Ja;OhPlLv2~ zTEUPX%MnJl01DYJ5jet5P|wPr5^dSBjW9(#_SF5|U!H|xX;*FhU&a`|oriYKNnj=x zf!t_WPL1r^cIGa`HeLtU=%KaAGq<;mIG!_zwxad#{fRlc3~5yg|Mk7|2g%+B7@0z& z3mur}N1KKy%z`_D`NQz9?->)Sf+5&DtjmB5SbN zR~BCK%cOMt*{-D#eig|_`r`13KZHKppxuk~<$ix3EAoA2_gi#!z7JJ#x_$(6)3Tx{ zirx>0-(6RuVT^w7SvO_`DXdTDM5BRiJT@FV!Eg1Re&wNZQ0K_~EStq{vZT6||fFpnD^fe@HOEmM}nzXmZROgE%TW$Mb zY=t*>5v@<|9|lX) zO{{NHP0?|$L4<6!>b6+sJurizA?iMIsV&sOaaw)A3Z;-kuLKTzD-=$Qq%Q48U89PD zNyY6lI3&%?dGG7<36)F2lPa6v1|PbbwfiVEhG(42S+I_GyU`P5AZyJj4ENPf&C36d zR&vW^j56Hi4QP?hqlneKxws|%DjAMblT5)-583(qwk*rw#|ihE;e5&YFx^=8A!gS9|!^2zfQ6<0_7iJX|rsv(vh- zSNO9-h_1oZSC4|EuDVl(Q>?B@+pR%J`?=^)e+0_&@~a^CGKd%DxuCX|5R4_kI%pYREaEcORXvM?SDi(l@oK5 zbiz!9wD}2`y?(KG%`~@H$b=Ku)^SCfWPnq5E-$#(RA6pdTV-S|Kei51t3Uzoqq969 zYtP@ib4YoySM2tJYy7lI+@{68=`{H=$Kn{$iM>&72FME_(I`)+pPvfFVSU<1h@OA| zB8VWVk-#M-om%AmO*Z8C&|&%r$;Ro@8E*3`V&xG&a?S6$Q9HD!FaL1FReVg4FM!HX z_0R>r&cIqEDZsg9U)*P1M7O#PWuvc)T;UzF=*`bbH&|E5ZWq>`zV3D&z6MhmlTW#P>ExN=12(a2Ne2iSh7SfEDt@#;kZPi> zM5ynt$?Y+uTK~oGk3k>bYIx(jz`+se34O3kB>e-Hi5}H`wEsd8^o1LL$I+NAWoBAQsCa1)QtDd@D z>CrXNvr0sgBYtXWP%S!HZgDUe@o{JdxyLIsIoSWNTy!WSyZn1>THZ2&ZTRTeFGFf0 zm}t}r*0smeQf<=A%vL-AyO~l8Hb?KMPLgq$!uQwt^ZBhk#sP!w#UFfx*WR!tp&xJR zgAbpY8JX|UqHqq34~2gtwPEF>LAj@6zinT>^Y$#>2F=9(_LmIHx}@FVLrhrl!xCgk z6&*Z@@*che%jNL{&!yvt9cOQad58KAHQT?IyTW}7l_2r7p+6S_JjW56&WIsx|&?q zzww~|Ao<{lI1YO9(Adg*wBu{#_YzY$vkUHqm};*}JA17nGP>VSrF`sQJe)Dq%V<6C z)w*5A1amrB-Jcros$IW}sV=}y;~llGo&^d}7y0p!6D=C7CRb&?n%{@4hYguo-!`kL z1xRLEXay54RS=RlYun3oCaTfz*FAKgoRfJa-{FIT=AW+msuffNH)tyEK#AM8xAu|0HmS()HLm3Ojr0l`e@qKGEd3S+ zv81hR)M7(ZFy`9+gGZO$e0L&4xbSzX4Ud-)f?eV}>{ZwN*;K&?Xp$;eXjV8f_ayVm z@3jR{;#_w(XtNp+$znozYY8vwRVqDol!6qAH1Eb9k7Ft;1ixMUZ(qXXi&u!E!p<4> zeefddY*2XobW?&j>A3aBxF_MESG7wpB!~l(9X~X0J10V2onQAgEt))R@hxhn&Q&fs znjW#h-r^5uDo!^Mv=xI+*SwiCB&t~$)f4m%d-Sko!I&%7n5MaX-wKr24|R^)6n(iq zxAkdrXwkRCRj*dZcR3O%5Du_+MvlD2ovJBu7*Hz)T>cf0yY@2hBF1ai5<(Ptb}REDlO=yY^FUwX$`^T$cexv zjV-0rbNv><;w?x*Nn2s|1)?3UgY*nA>03XwY5`to!&eucUKWuhxejo~4!AgUOJQ;K z3y^frq|4WCF6W)22_>r`RV%1sLZP9zsn_jFbk%;3X!J6H3 zTppwT7cI8l;zdHA!1S*u;7iF}Ia5oyhvQO={yW~hpsW;iUY8-bQ`wV1>Lc#xyAp4k zJvVqgr^Q~jX@ zD2S=Rg%T^3EYfy7p75qWuJEZ0ec5*bA|NC$qQ*_(<31!Tk7y^+2ztNy1Unau3=IOF zV83McX!o^WXObZM9p;C%tldB>CoOL;0&SNs;$^jl$`0!%0f8iPysU|c4QvJa8e`2c z4g>F3s`x_}!hWoFf$_Ac=|3@VLF$XgnJ%^Z2$>NC5t~qJ0oGsKCivT(+-5|gr2Xw^ zu*dkkPj5cNvyA*qq<{M`)xW6q)E>^lEBkapiEfzaLI}@dz*M|VaC%2;1qsq?Q@q_? zC~!jW2K{)E;5qdaGNono+HLm7OWN3cg%JOFp}Wj+S|w?-{LlD6-x$tU-?+DO@+iE+ zAgZH&#u+`Jg>^+@K$&Cbq-KFLY(4z8U7Ig}Czcod;`vAiy2forYyA`3$3Sd!kwLc4 z76TUv`I6a(8t>`e1AnXCh|48WyzFLCJclcgzn9bvvbe^FfU8^p2tEcVN3c1`@Z#gA6pxbgEJe@nlXGhG$-D|qXMtU|Mmrt%|#>r zJ5v3IxkZI7N;ms`{AH^Np;kawISJJ3+1uA24)zqc+DjJKNj5J1M3-HJRY%%<<6YEv4T%VPd?f|s6f%?pZ=Igc zyB%kL5(6n5871Iz*qBao$>C#~e5#uDjfu9TubB$gKAVd2#cs^swu|iz{VmpJI46% zREka#p@Ck^ZAKd68Vue zEurK=&Ee>Q3l54>39L^H?Y5>>VmD0}?w3j4+1eBEJB0z6*xCU3b*KX)0Yk*1S~6pv zK|SkEerz2-5Co$>NTw&Ju5W$3r4l*0bp{jdp3;a*OYrh&FmVkah?PV)AW z=$4rHthY(QCS`&7KqtH&r!l!&){Ik!GLo*(Sdy_c7c~*}NTmR} zIF}dMYBe32o9}PltS`kE@CRxXM&;jF%jp6}Piz?OCC;xh)T{gO1MGYD;Y;lRu3CnI zOBA%a#sGREwb5?FBq<}S*bvFS_IZpW(20%kW*@8_Lr49z8`}G7qEYV&vc`oCfBbyM zn2t$DV-(z29zb+c$55Qwc@M8MJnTa2qIXo{6F4Kcm@rYvlcfzR22$n3@lYkMN2NP{&;%>X~KRkr>N4+xoM zgJ0MyYQwa-z*iS75aTqnot(w9)fzv%`U>joh#;TMI49iuL%J;6HF3H;oN$HjZCfr7 zb8cQ(P%LAZtUiLM2MT{KCMT9pmQlxdoJlAmJtAzm{anIxx2=@0Xs$>-5JmjNfmB_h zqg16;y`C+zn0EoL!+W^9j2{qBuGfRz`vK9=3NVpY7@cbhBy5+N_Z@>gdZYO~-SaJtG7;nAIdGhw9X-90Rx51bpW28!d@D`htaM5W>aun7*K5M}E(12)CC4eI<8L<9GphuVb20 zq_w|r6dYjXtbkeC3PL_RREj2VF&sCXFU-MoH*ooIN0xg^VPn(yR@s6JXCv_tx=b5( z`^U6O#HRWJ)9>BZKLS3p*a7PkyuU;4g&;suXyZLZQi}@D9h?dDkl9}8imT)eED0xC zXQ}UJm&v>kckDS*^}Z)bEMmD)y$*Tw@*SfWXBj38Rm4GTP4{W?(>d!+W4*F=3%Oo{ z?DsvF9p(fv6UE7!emgpgiscR5>foq>kBCs2@Aq$)EQVA@z8NE-LY~yqV*abq*ZZWH zPPp9hz!la3L0e`Cg*T!|-7LMU5>j?wo?ZNj)q@ZSDOb5k+{rBJF_fN;og0fYmMsgj zApXJ|-^zM9SnSH)VPDnHDUc;zxV)n=yy0^TOSGf#u-U=!wRCy4>ytJv)AwI0Z^R2f5`=Oms#Oz6D$mod)y6|-gRi*H$uKDP3ZkZuL+ ziXvI^=ST9Z?L96g6AD4!n)qBei{f13+wB!g2!lO;ntEGQ&Rmgvywq=VUsLOV04nUH z$f|1_m0&jHV05;*;~1E8*4C&6nJg97-pddJbj%~mi-&FuB8L)FioCwaLx zpt?kWq}K^49I@W}^kCb!`kKY%lUpk?8}stG&R_y06zXjadn?EOf6g zAj{M}CsavLxaZB8*4#B~m4)WS$M&aVRMJ@wZ*H zM{+AY85zU3aw3U1v`hWgWaeQJbk;?le+5 z01dr>z^oO$UO^@pJUHxf=5U5D!jA=YzRvM&7pMHCrk=<`Ybj&{+-4IvsF}(4N%$Dn^>_=a1+f`*wCe$n^ zb9a3o7pB)025c%7P2rJCKdP%@SS}mve4-icMAkR%pypdyIpMbn26J|$zOsk&H(~O7 z&-(TIThYW=^tDei19n#+sq}U^+r27dqF`M)8m9XU+7NFE{sETI<^*rnC$Yd9cEk>r z6b(+f47A$kwxn_G)O~m42Ki*9AorDz3R|2zD ztNq2_r?(>`u%D(Za!OO=G$?39FlhDtdbE478J{C@^R(yl8d4hf_ROTm5%cHi-ZRf1 znub+Vvv`$DT!`2mpMDn8Usyiu65nEf{-4haq>2g4PXN#XJTWRcC_jrebR-Y0Io->1|sKcfaNIh-%MxRVmy`C#OII$zg**Ui?!Oq>`HmpO*xp4{kkZsIJt zd@?XyW9wB;$#>S2t+J4Am#40M{GU=9+#(UDv5fv-57DB!7vkb8n%WR(9W(nB&y;#& zYXc8Zbb-{Gm&UJ*i+iW@tMfl*Wrrg!-(%!tcYK|lBWBoJDba#eukS`@$tW#`=KnYl zI`M;l;n9qwaIDp)aWUSw@%pywfE+Jh3GT;QOmBg&KR{=Pu z0O!Ymsr;;6L6ScI#A;hDMmd(3FZSh^Mx4X0GI6Bp^ndT(*+D&gpmoh0R{xxg>DwQ^ z`==7d#QyJsYUWbRY%Hxlp7q@PhIX|i`4(wcsKgscA080Y zQ0lF{<|p?4tidZWBTP)5Mul;DnS(1Qf~Nm#T|{Z{=bqi#YyhB1zQHRqjRcs~zdz^zowa(R0Up-~2YKe?MVL)X98nagE>5{;PH0etaNN6K$sd z2P>tEDf?vQrk_ss>3?o7P35cFT4Lh_e(Ous7dlF_NBQ;#LYKGsxH3F=c~*ucrJ%zcx0|t5vuavT21B8B|z~@s7nRBfoqW;n}oo#?s}&O*Du8m_)A+z$Z8e#>yu2Pek^LsPew(j zI^)jQZ*kEzlPd&jB1b}-Z46D43bkGT_1o1)we3>9j__M2Uxk)?*psUdHMUIZSrB{4)AqTfdQ6>nF4>j8+Z5{XB2z@}W;h>_46P*E6=&%^UO| ziSm=!x-Roq%CQA7R`{K;vDqV&R z0>y&=byhT259&)SR|}1;q3OCk7TeujP31chegj+nK5#%qs{7Mhm?nqLXutP8^{UNt zqViuyp_al*t=Uk?9i$)8xb+Szxbm^`Uq_*qg7z0k;~3{7iCWw^yuPyOF!k8eD(T-_ z*qKs$PWM#?R8XaA8n-jH&9o!Ct-1c6|7#DGCgxDh0m2=6k~}$Pwg!30u$2H)Xdp?L;yoiP!M|x#!#Zqi)+CePV}yUk88?MX+%mB5df9 zHg6Hu&FkrMLN@<#4gS71|NZel7xI4<#Q$8Je@f7QJNDm>{Yy>#cP0M268~L^|D@nQ qDfmwc{*!|Lq~QN2DM%6UCo|*(qj%jir;w$9f6i)XW3tuF1O7j!yhH5( literal 0 HcmV?d00001 diff --git a/packages/mobile/assets/icon-only.png b/packages/mobile/assets/icon-only.png new file mode 100644 index 0000000000000000000000000000000000000000..2171b7dc5ce4006affb592242ea2fc01516a5f15 GIT binary patch literal 75485 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 literal 0 HcmV?d00001 diff --git a/packages/mobile/capacitor.config.ts b/packages/mobile/capacitor.config.ts new file mode 100644 index 00000000..d397caff --- /dev/null +++ b/packages/mobile/capacitor.config.ts @@ -0,0 +1,33 @@ +import type { CapacitorConfig } from '@capacitor/cli'; + +const config: CapacitorConfig = { + appId: 'com.openchamber.app', + appName: 'OpenChamber', + webDir: 'dist', + server: { + androidScheme: 'https', + }, + plugins: { + Keyboard: { + // 'none' leaves the WebView at full height; the UI follows the keyboard + // itself via the --oc-keyboard-inset CSS variable driven by keyboardWillShow + // (see useNativeMobileChrome). The built-in 'native' resize lands only after + // the keyboard animation finishes, which looked like a ~1.5s lag. + resize: 'none', + resizeOnFullScreen: true, + autoBackdropColor: 'dom', + }, + StatusBar: { + overlaysWebView: true, + style: 'DEFAULT', + }, + PushNotifications: { + // Never display an APNs alert while the app is foreground. The server always sends + // (no racy visibility gate); iOS suppresses the foreground banner, so there is no + // notification when the app is active. Background pushes are shown by iOS as usual. + presentationOptions: [], + }, + }, +}; + +export default config; diff --git a/packages/mobile/ios/.gitignore b/packages/mobile/ios/.gitignore new file mode 100644 index 00000000..f4702997 --- /dev/null +++ b/packages/mobile/ios/.gitignore @@ -0,0 +1,13 @@ +App/build +App/Pods +App/output +App/App/public +DerivedData +xcuserdata + +# Cordova plugins for Capacitor +capacitor-cordova-ios-plugins + +# Generated Config files +App/App/capacitor.config.json +App/App/config.xml diff --git a/packages/mobile/ios/App/App.xcodeproj/project.pbxproj b/packages/mobile/ios/App/App.xcodeproj/project.pbxproj new file mode 100644 index 00000000..94498243 --- /dev/null +++ b/packages/mobile/ios/App/App.xcodeproj/project.pbxproj @@ -0,0 +1,738 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; }; + 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; }; + 504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; }; + 504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; }; + 504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; }; + D0C2000000000000000000B1 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = D0C1000000000000000000A1 /* PrivacyInfo.xcprivacy */; }; + 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; }; + 50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; }; + 8E7A4F1A2C4B4C749E0A1001 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 8E7A4F192C4B4C749E0A1001 /* AppIcon.icon */; }; + A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */; }; + D0A2000000000000000000B1 /* WidgetShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A1000000000000000000A1 /* WidgetShared.swift */; }; + D0A2000000000000000000B2 /* OpenChamberWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A1000000000000000000A2 /* OpenChamberWidgets.swift */; }; + D0A2000000000000000000B3 /* OpenChamberControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A1000000000000000000A3 /* OpenChamberControl.swift */; }; + D0A2000000000000000000B6 /* OpenChamberControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A1000000000000000000A3 /* OpenChamberControl.swift */; }; + D0A2000000000000000000B4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D0A1000000000000000000A4 /* Assets.xcassets */; }; + D0A2000000000000000000B5 /* OpenChamberWidget.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = D0A1000000000000000000A7 /* OpenChamberWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + D0B2000000000000000000B1 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B1000000000000000000A1 /* NotificationService.swift */; }; + D0B2000000000000000000B5 /* OpenChamberNotificationService.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = D0B1000000000000000000A7 /* OpenChamberNotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = ""; }; + 50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = ""; }; + 504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + D0C1000000000000000000A1 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; + 504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = ""; }; + 8E7A4F192C4B4C749E0A1001 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; name = AppIcon.icon; path = ../../../electron/resources/icons/AppIcon.icon; sourceTree = SOURCE_ROOT; }; + AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = ""; }; + FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = ""; }; + D0A1000000000000000000A1 /* WidgetShared.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetShared.swift; sourceTree = ""; }; + D0A1000000000000000000A2 /* OpenChamberWidgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenChamberWidgets.swift; sourceTree = ""; }; + D0A1000000000000000000A3 /* OpenChamberControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenChamberControl.swift; sourceTree = ""; }; + D0A1000000000000000000A4 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + D0A1000000000000000000A5 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + D0A1000000000000000000A6 /* OpenChamberWidget.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = OpenChamberWidget.entitlements; sourceTree = ""; }; + D0A1000000000000000000A7 /* OpenChamberWidget.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = OpenChamberWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + D0B1000000000000000000A1 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; + D0B1000000000000000000A2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + D0B1000000000000000000A3 /* OpenChamberNotificationService.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = OpenChamberNotificationService.entitlements; sourceTree = ""; }; + D0B1000000000000000000A7 /* OpenChamberNotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = OpenChamberNotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 504EC3011FED79650016851F /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D0A3000000000000000000C2 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D0B3000000000000000000C2 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXContainerItemProxy section */ + D0A4000000000000000000D3 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 504EC2FC1FED79650016851F /* Project object */; + proxyType = 1; + remoteGlobalIDString = D0A4000000000000000000D1; + remoteInfo = OpenChamberWidget; + }; + D0B4000000000000000000D3 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 504EC2FC1FED79650016851F /* Project object */; + proxyType = 1; + remoteGlobalIDString = D0B4000000000000000000D1; + remoteInfo = OpenChamberNotificationService; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + D0A3000000000000000000C4 /* Embed App Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + D0A2000000000000000000B5 /* OpenChamberWidget.appex in Embed App Extensions */, + D0B2000000000000000000B5 /* OpenChamberNotificationService.appex in Embed App Extensions */, + ); + name = "Embed App Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXGroup section */ + 27E2DDA53C4D2A4D1A88CE4A /* Frameworks */ = { + isa = PBXGroup; + children = ( + AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 504EC2FB1FED79650016851F = { + isa = PBXGroup; + children = ( + 504EC3061FED79650016851F /* App */, + D0A6000000000000000000F1 /* OpenChamberWidget */, + D0B6000000000000000000F1 /* OpenChamberNotificationService */, + 504EC3051FED79650016851F /* Products */, + 7F8756D8B27F46E3366F6CEA /* Pods */, + 27E2DDA53C4D2A4D1A88CE4A /* Frameworks */, + ); + sourceTree = ""; + }; + 504EC3051FED79650016851F /* Products */ = { + isa = PBXGroup; + children = ( + 504EC3041FED79650016851F /* App.app */, + D0A1000000000000000000A7 /* OpenChamberWidget.appex */, + D0B1000000000000000000A7 /* OpenChamberNotificationService.appex */, + ); + name = Products; + sourceTree = ""; + }; + D0A6000000000000000000F1 /* OpenChamberWidget */ = { + isa = PBXGroup; + children = ( + D0A1000000000000000000A1 /* WidgetShared.swift */, + D0A1000000000000000000A2 /* OpenChamberWidgets.swift */, + D0A1000000000000000000A3 /* OpenChamberControl.swift */, + D0A1000000000000000000A4 /* Assets.xcassets */, + D0A1000000000000000000A5 /* Info.plist */, + D0A1000000000000000000A6 /* OpenChamberWidget.entitlements */, + ); + path = OpenChamberWidget; + sourceTree = ""; + }; + D0B6000000000000000000F1 /* OpenChamberNotificationService */ = { + isa = PBXGroup; + children = ( + D0B1000000000000000000A1 /* NotificationService.swift */, + D0B1000000000000000000A2 /* Info.plist */, + D0B1000000000000000000A3 /* OpenChamberNotificationService.entitlements */, + ); + path = OpenChamberNotificationService; + sourceTree = ""; + }; + 504EC3061FED79650016851F /* App */ = { + isa = PBXGroup; + children = ( + 50379B222058CBB4000EE86E /* capacitor.config.json */, + 504EC3071FED79650016851F /* AppDelegate.swift */, + 504EC30B1FED79650016851F /* Main.storyboard */, + 504EC30E1FED79650016851F /* Assets.xcassets */, + D0C1000000000000000000A1 /* PrivacyInfo.xcprivacy */, + 8E7A4F192C4B4C749E0A1001 /* AppIcon.icon */, + 504EC3101FED79650016851F /* LaunchScreen.storyboard */, + 504EC3131FED79650016851F /* Info.plist */, + 2FAD9762203C412B000D30F8 /* config.xml */, + 50B271D01FEDC1A000F3C39B /* public */, + ); + path = App; + sourceTree = ""; + }; + 7F8756D8B27F46E3366F6CEA /* Pods */ = { + isa = PBXGroup; + children = ( + FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */, + AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */, + ); + name = Pods; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 504EC3031FED79650016851F /* App */ = { + isa = PBXNativeTarget; + buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */; + buildPhases = ( + 6634F4EFEBD30273BCE97C65 /* [CP] Check Pods Manifest.lock */, + 504EC3001FED79650016851F /* Sources */, + 504EC3011FED79650016851F /* Frameworks */, + 504EC3021FED79650016851F /* Resources */, + 9592DBEFFC6D2A0C8D5DEB22 /* [CP] Embed Pods Frameworks */, + D0A3000000000000000000C4 /* Embed App Extensions */, + ); + buildRules = ( + ); + dependencies = ( + D0A4000000000000000000D2 /* PBXTargetDependency */, + D0B4000000000000000000D2 /* PBXTargetDependency */, + ); + name = App; + productName = App; + productReference = 504EC3041FED79650016851F /* App.app */; + productType = "com.apple.product-type.application"; + }; + D0A4000000000000000000D1 /* OpenChamberWidget */ = { + isa = PBXNativeTarget; + buildConfigurationList = D0A5000000000000000000E1 /* Build configuration list for PBXNativeTarget "OpenChamberWidget" */; + buildPhases = ( + D0A3000000000000000000C1 /* Sources */, + D0A3000000000000000000C2 /* Frameworks */, + D0A3000000000000000000C3 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = OpenChamberWidget; + productName = OpenChamberWidget; + productReference = D0A1000000000000000000A7 /* OpenChamberWidget.appex */; + productType = "com.apple.product-type.app-extension"; + }; + D0B4000000000000000000D1 /* OpenChamberNotificationService */ = { + isa = PBXNativeTarget; + buildConfigurationList = D0B5000000000000000000E1 /* Build configuration list for PBXNativeTarget "OpenChamberNotificationService" */; + buildPhases = ( + D0B3000000000000000000C1 /* Sources */, + D0B3000000000000000000C2 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = OpenChamberNotificationService; + productName = OpenChamberNotificationService; + productReference = D0B1000000000000000000A7 /* OpenChamberNotificationService.appex */; + productType = "com.apple.product-type.app-extension"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 504EC2FC1FED79650016851F /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 2700; + TargetAttributes = { + 504EC3031FED79650016851F = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + }; + D0A4000000000000000000D1 = { + CreatedOnToolsVersion = 16.0; + ProvisioningStyle = Automatic; + }; + D0B4000000000000000000D1 = { + CreatedOnToolsVersion = 16.0; + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */; + compatibilityVersion = "Xcode 8.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 504EC2FB1FED79650016851F; + packageReferences = ( + ); + productRefGroup = 504EC3051FED79650016851F /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 504EC3031FED79650016851F /* App */, + D0A4000000000000000000D1 /* OpenChamberWidget */, + D0B4000000000000000000D1 /* OpenChamberNotificationService */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 504EC3021FED79650016851F /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */, + 50B271D11FEDC1A000F3C39B /* public in Resources */, + 504EC30F1FED79650016851F /* Assets.xcassets in Resources */, + D0C2000000000000000000B1 /* PrivacyInfo.xcprivacy in Resources */, + 8E7A4F1A2C4B4C749E0A1001 /* AppIcon.icon in Resources */, + 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */, + 504EC30D1FED79650016851F /* Main.storyboard in Resources */, + 2FAD9763203C412B000D30F8 /* config.xml in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D0A3000000000000000000C3 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + D0A2000000000000000000B4 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 6634F4EFEBD30273BCE97C65 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-App-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 9592DBEFFC6D2A0C8D5DEB22 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "[CP] Embed Pods Frameworks"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-App/Pods-App-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 504EC3001FED79650016851F /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 504EC3081FED79650016851F /* AppDelegate.swift in Sources */, + D0A2000000000000000000B6 /* OpenChamberControl.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D0A3000000000000000000C1 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + D0A2000000000000000000B1 /* WidgetShared.swift in Sources */, + D0A2000000000000000000B2 /* OpenChamberWidgets.swift in Sources */, + D0A2000000000000000000B3 /* OpenChamberControl.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D0B3000000000000000000C1 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + D0B2000000000000000000B1 /* NotificationService.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + D0A4000000000000000000D2 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D0A4000000000000000000D1 /* OpenChamberWidget */; + targetProxy = D0A4000000000000000000D3 /* PBXContainerItemProxy */; + }; + D0B4000000000000000000D2 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D0B4000000000000000000D1 /* OpenChamberNotificationService */; + targetProxy = D0B4000000000000000000D3 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 504EC30B1FED79650016851F /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 504EC30C1FED79650016851F /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 504EC3101FED79650016851F /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 504EC3111FED79650016851F /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 504EC3141FED79650016851F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = 5J7WJGPA2Q; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.5; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 504EC3151FED79650016851F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = 5J7WJGPA2Q; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.5; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 504EC3171FED79650016851F /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CODE_SIGN_ENTITLEMENTS = App/App.entitlements; + CURRENT_PROJECT_VERSION = 1; + INFOPLIST_FILE = App/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = OpenChamber; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; + IPHONEOS_DEPLOYMENT_TARGET = "$(RECOMMENDED_IPHONEOS_DEPLOYMENT_TARGET)"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; + PRODUCT_BUNDLE_IDENTIFIER = com.openchamber.app; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 504EC3181FED79650016851F /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CODE_SIGN_ENTITLEMENTS = App/App.entitlements; + CURRENT_PROJECT_VERSION = 1; + INFOPLIST_FILE = App/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = OpenChamber; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; + IPHONEOS_DEPLOYMENT_TARGET = "$(RECOMMENDED_IPHONEOS_DEPLOYMENT_TARGET)"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.openchamber.app; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + D0A5000000000000000000E2 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = OpenChamberWidget/OpenChamberWidget.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 5J7WJGPA2Q; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = OpenChamberWidget/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.openchamber.app.OpenChamberWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + D0A5000000000000000000E3 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = OpenChamberWidget/OpenChamberWidget.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 5J7WJGPA2Q; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = OpenChamberWidget/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.openchamber.app.OpenChamberWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + D0B5000000000000000000E2 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = OpenChamberNotificationService/OpenChamberNotificationService.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 5J7WJGPA2Q; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = OpenChamberNotificationService/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.5; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.openchamber.app.OpenChamberNotificationService; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + D0B5000000000000000000E3 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = OpenChamberNotificationService/OpenChamberNotificationService.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 5J7WJGPA2Q; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = OpenChamberNotificationService/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.5; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.openchamber.app.OpenChamberNotificationService; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 504EC3141FED79650016851F /* Debug */, + 504EC3151FED79650016851F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 504EC3171FED79650016851F /* Debug */, + 504EC3181FED79650016851F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + D0A5000000000000000000E1 /* Build configuration list for PBXNativeTarget "OpenChamberWidget" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D0A5000000000000000000E2 /* Debug */, + D0A5000000000000000000E3 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + D0B5000000000000000000E1 /* Build configuration list for PBXNativeTarget "OpenChamberNotificationService" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D0B5000000000000000000E2 /* Debug */, + D0B5000000000000000000E3 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 504EC2FC1FED79650016851F /* Project object */; +} diff --git a/packages/mobile/ios/App/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme b/packages/mobile/ios/App/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme new file mode 100644 index 00000000..f57acce6 --- /dev/null +++ b/packages/mobile/ios/App/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/mobile/ios/App/App.xcodeproj/xcshareddata/xcschemes/OpenChamberWidget.xcscheme b/packages/mobile/ios/App/App.xcodeproj/xcshareddata/xcschemes/OpenChamberWidget.xcscheme new file mode 100644 index 00000000..427e1b90 --- /dev/null +++ b/packages/mobile/ios/App/App.xcodeproj/xcshareddata/xcschemes/OpenChamberWidget.xcscheme @@ -0,0 +1,123 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/mobile/ios/App/App.xcworkspace/contents.xcworkspacedata b/packages/mobile/ios/App/App.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..b301e824 --- /dev/null +++ b/packages/mobile/ios/App/App.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/packages/mobile/ios/App/App.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/mobile/ios/App/App.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/packages/mobile/ios/App/App.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/mobile/ios/App/App/App.entitlements b/packages/mobile/ios/App/App/App.entitlements new file mode 100644 index 00000000..14e33ec3 --- /dev/null +++ b/packages/mobile/ios/App/App/App.entitlements @@ -0,0 +1,18 @@ + + + + + + aps-environment + development + + com.apple.security.application-groups + + group.com.openchamber.app + + + diff --git a/packages/mobile/ios/App/App/AppDelegate.swift b/packages/mobile/ios/App/App/AppDelegate.swift new file mode 100644 index 00000000..d9364555 --- /dev/null +++ b/packages/mobile/ios/App/App/AppDelegate.swift @@ -0,0 +1,162 @@ +import UIKit +import Capacitor +import UserNotifications +import WidgetKit + +@UIApplicationMain +class AppDelegate: UIResponder, UIApplicationDelegate { + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // Override point for customization after application launch. + return true + } + + func applicationWillResignActive(_ application: UIApplication) { + // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. + // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game. + } + + func applicationDidEnterBackground(_ application: UIApplication) { + // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. + // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. + } + + func applicationWillEnterForeground(_ application: UIApplication) { + // Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background. + } + + func applicationDidBecomeActive(_ application: UIApplication) { + // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. + } + + func applicationWillTerminate(_ application: UIApplication) { + // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. + } + + func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { + // Called when the app was launched with a url. Feel free to add additional processing here, + // but if you want the App API to support tracking app url opens, make sure to keep this call + return ApplicationDelegateProxy.shared.application(app, open: url, options: options) + } + + func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool { + // Called when the app was launched with an activity, including Universal Links. + // Feel free to add additional processing here, but if you want the App API to support + // tracking app url opens, make sure to keep this call + return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler) + } + + // Forward APNs registration to Capacitor so @capacitor/push-notifications can + // deliver the device token / error to the JS `registration` / `registrationError` + // listeners. Required because this app uses a custom AppDelegate (not the stock + // Capacitor template, which already posts these notifications). + func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + NotificationCenter.default.post(name: .capacitorDidRegisterForRemoteNotifications, object: deviceToken) + } + + func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { + NotificationCenter.default.post(name: .capacitorDidFailToRegisterForRemoteNotifications, object: error) + } + +} + +// iOS 26 (TN3187) requires apps built with the latest SDK to adopt the UIScene +// lifecycle. Capacitor 7's template still uses the legacy window setup, so we host a +// minimal scene delegate here that loads the Main storyboard (CAPBridgeViewController) +// and forwards deep links / universal links into Capacitor's delegate proxy. +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + + var window: UIWindow? + + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + guard let windowScene = scene as? UIWindowScene else { return } + let window = UIWindow(windowScene: windowScene) + let storyboard = UIStoryboard(name: "Main", bundle: nil) + window.rootViewController = storyboard.instantiateInitialViewController() + self.window = window + window.makeKeyAndVisible() + + configureWebViewChrome() + + if let urlContext = connectionOptions.urlContexts.first { + _ = ApplicationDelegateProxy.shared.application(UIApplication.shared, open: urlContext.url, options: [:]) + } + if let userActivity = connectionOptions.userActivities.first { + _ = ApplicationDelegateProxy.shared.application(UIApplication.shared, continue: userActivity) { _ in } + } + } + + func sceneDidBecomeActive(_ scene: UIScene) { + // Re-assert in case the WebView wasn't ready at scene-connect time, or the + // effect was re-enabled while backgrounded. + configureWebViewChrome() + + // Clear the app-icon badge whenever the app becomes active. The server sends + // an absolute badge count (sessions needing attention) on each push; once the + // user is looking at the app, the in-app indicators take over, so reset to 0. + if #available(iOS 17.0, *) { + UNUserNotificationCenter.current().setBadgeCount(0) + } else { + UIApplication.shared.applicationIconBadgeNumber = 0 + } + + // Refresh the widgets' session overview now that the WebView is loaded and state is fresh. + writeWidgetSnapshot() + } + + func sceneWillResignActive(_ scene: UIScene) { + // Capture the latest session overview before the app leaves the foreground, so the + // home/lock-screen/Control Center widgets reflect what the user just saw. + writeWidgetSnapshot() + } + + private static let widgetAppGroup = "group.com.openchamber.app" + private static let widgetSnapshotKey = "widgetSnapshot" + + /// Pulls the session overview JSON from the web layer (window.__OPENCHAMBER_WIDGET_SNAPSHOT__), + /// stores it in the shared App Group, and reloads the widget timelines. localStorage/stores + /// aren't reachable from the widget process, so this is how the bundled UI feeds the widgets — + /// no server involved. Failures are ignored so a transient read never clobbers a good snapshot. + private func writeWidgetSnapshot() { + guard let bridge = window?.rootViewController as? CAPBridgeViewController, + let webView = bridge.webView else { return } + let js = "(typeof window.__OPENCHAMBER_WIDGET_SNAPSHOT__ === 'function') ? window.__OPENCHAMBER_WIDGET_SNAPSHOT__() : null" + webView.evaluateJavaScript(js) { result, _ in + guard let json = result as? String, !json.isEmpty, + let defaults = UserDefaults(suiteName: SceneDelegate.widgetAppGroup) else { return } + // Only write + reload when the overview actually changed. We write this on every + // scene activate/resign; reloading WidgetCenter every time burns the WidgetKit + // reload budget and leaves some widgets stale (the snapshot no longer contains a + // per-call timestamp, so identical overviews compare equal). + if defaults.string(forKey: SceneDelegate.widgetSnapshotKey) == json { return } + defaults.set(json, forKey: SceneDelegate.widgetSnapshotKey) + WidgetCenter.shared.reloadAllTimelines() + } + } + + /// iOS 26 (Liquid Glass) automatically applies a "scroll edge effect" — a blur + + /// appearance-coloured dim — to the top/bottom of a scroll view beneath the system + /// bars. On the full-screen WKWebView that renders as a dark band behind the status + /// bar in Dark Mode (independent of the in-app theme). Hide it so the web content + /// (which paints its own themed background) is what shows under the status bar. + private func configureWebViewChrome() { + guard let bridge = window?.rootViewController as? CAPBridgeViewController, + let webView = bridge.webView else { return } + webView.isOpaque = false + webView.backgroundColor = .clear + webView.scrollView.backgroundColor = .clear + if #available(iOS 26.0, *) { + webView.scrollView.topEdgeEffect.isHidden = true + webView.scrollView.bottomEdgeEffect.isHidden = true + } + } + + func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { + guard let urlContext = URLContexts.first else { return } + _ = ApplicationDelegateProxy.shared.application(UIApplication.shared, open: urlContext.url, options: [:]) + } + + func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { + _ = ApplicationDelegateProxy.shared.application(UIApplication.shared, continue: userActivity) { _ in } + } +} diff --git a/packages/mobile/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png b/packages/mobile/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..adf6ba01dbe256605c5152ac1fd78ae99aaa2a8d GIT binary patch literal 110522 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 C`SD)> literal 0 HcmV?d00001 diff --git a/packages/mobile/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json b/packages/mobile/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..9b7d382d --- /dev/null +++ b/packages/mobile/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon-512@2x.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/packages/mobile/ios/App/App/Assets.xcassets/Contents.json b/packages/mobile/ios/App/App/Assets.xcassets/Contents.json new file mode 100644 index 00000000..da4a164c --- /dev/null +++ b/packages/mobile/ios/App/App/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/packages/mobile/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json b/packages/mobile/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json new file mode 100644 index 00000000..d7d96a67 --- /dev/null +++ b/packages/mobile/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "splash-2732x2732-2.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "splash-2732x2732-1.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "splash-2732x2732.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/packages/mobile/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png b/packages/mobile/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png new file mode 100644 index 0000000000000000000000000000000000000000..33ea6c970f2df1db62a624a55e5bbcc4ee07bbdf GIT binary patch literal 41273 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 literal 0 HcmV?d00001 diff --git a/packages/mobile/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png b/packages/mobile/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png new file mode 100644 index 0000000000000000000000000000000000000000..33ea6c970f2df1db62a624a55e5bbcc4ee07bbdf GIT binary patch literal 41273 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 literal 0 HcmV?d00001 diff --git a/packages/mobile/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png b/packages/mobile/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png new file mode 100644 index 0000000000000000000000000000000000000000..33ea6c970f2df1db62a624a55e5bbcc4ee07bbdf GIT binary patch literal 41273 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 literal 0 HcmV?d00001 diff --git a/packages/mobile/ios/App/App/Base.lproj/LaunchScreen.storyboard b/packages/mobile/ios/App/App/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 00000000..e7ae5d78 --- /dev/null +++ b/packages/mobile/ios/App/App/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/mobile/ios/App/App/Base.lproj/Main.storyboard b/packages/mobile/ios/App/App/Base.lproj/Main.storyboard new file mode 100644 index 00000000..b44df7be --- /dev/null +++ b/packages/mobile/ios/App/App/Base.lproj/Main.storyboard @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/packages/mobile/ios/App/App/Info.plist b/packages/mobile/ios/App/App/Info.plist new file mode 100644 index 00000000..3ffde0d6 --- /dev/null +++ b/packages/mobile/ios/App/App/Info.plist @@ -0,0 +1,92 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + OpenChamber + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + ITSAppUsesNonExemptEncryption + + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSAllowsArbitraryLoadsInWebContent + + NSAllowsLocalNetworking + + + NSLocalNetworkUsageDescription + OpenChamber connects to OpenChamber servers on your local network. + NSCameraUsageDescription + OpenChamber uses the camera to scan a server's pairing QR code. + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + com.openchamber.app.deeplink + CFBundleURLSchemes + + openchamber + + + + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + + UILaunchStoryboardName + LaunchScreen + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + + diff --git a/packages/mobile/ios/App/App/PrivacyInfo.xcprivacy b/packages/mobile/ios/App/App/PrivacyInfo.xcprivacy new file mode 100644 index 00000000..71a204d6 --- /dev/null +++ b/packages/mobile/ios/App/App/PrivacyInfo.xcprivacy @@ -0,0 +1,30 @@ + + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + + NSPrivacyCollectedDataTypes + + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + C56D.1 + + + + + diff --git a/packages/mobile/ios/App/OpenChamberNotificationService/Info.plist b/packages/mobile/ios/App/OpenChamberNotificationService/Info.plist new file mode 100644 index 00000000..51328fd3 --- /dev/null +++ b/packages/mobile/ios/App/OpenChamberNotificationService/Info.plist @@ -0,0 +1,29 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + OpenChamberNotificationService + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.usernotifications.service + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).NotificationService + + + diff --git a/packages/mobile/ios/App/OpenChamberNotificationService/NotificationService.swift b/packages/mobile/ios/App/OpenChamberNotificationService/NotificationService.swift new file mode 100644 index 00000000..ed29076a --- /dev/null +++ b/packages/mobile/ios/App/OpenChamberNotificationService/NotificationService.swift @@ -0,0 +1,71 @@ +import UserNotifications +import WidgetKit + +/// Runs on every incoming push that carries `mutable-content: 1` — even when the app is closed +/// — and refreshes the widgets' shared snapshot so the home/lock-screen attention count and +/// unread dot stay current without the app having to foreground. It makes NO network calls: +/// it reads the count the server already put in `aps.badge` and the `sessionId` from the push, +/// updates the App Group snapshot the app wrote, and reloads the widget timelines. The app +/// still overwrites the snapshot with the authoritative full list on its next foreground. +class NotificationService: UNNotificationServiceExtension { + private static let appGroup = "group.com.openchamber.app" + private static let snapshotKey = "widgetSnapshot" + + private var contentHandler: ((UNNotificationContent) -> Void)? + private var bestAttempt: UNMutableNotificationContent? + + override func didReceive( + _ request: UNNotificationRequest, + withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void + ) { + self.contentHandler = contentHandler + self.bestAttempt = request.content.mutableCopy() as? UNMutableNotificationContent + + refreshWidgetSnapshot(from: request) + + // Deliver the notification unchanged (we only used the push to refresh widgets). + contentHandler(bestAttempt ?? request.content) + } + + override func serviceExtensionTimeWillExpire() { + if let handler = contentHandler { + handler(bestAttempt ?? UNNotificationContent()) + } + } + + private func refreshWidgetSnapshot(from request: UNNotificationRequest) { + guard let defaults = UserDefaults(suiteName: Self.appGroup) else { return } + + var snapshot: [String: Any] = [ + "attentionCount": 0, + "recentSessions": [], + ] + if let json = defaults.string(forKey: Self.snapshotKey), + let data = json.data(using: .utf8), + let stored = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + snapshot = stored + } + + // Attention count: authoritative server value carried in aps.badge. + if let badge = request.content.badge as? Int { + snapshot["attentionCount"] = badge + } + + // Mark the pushed session unread in the existing recent list (best-effort; the full + // list/titles only refresh when the app next foregrounds). + if let sessionId = request.content.userInfo["sessionId"] as? String, + var sessions = snapshot["recentSessions"] as? [[String: Any]] { + for index in sessions.indices where sessions[index]["id"] as? String == sessionId { + sessions[index]["unread"] = true + } + snapshot["recentSessions"] = sessions + } + + if let data = try? JSONSerialization.data(withJSONObject: snapshot), + let json = String(data: data, encoding: .utf8) { + defaults.set(json, forKey: Self.snapshotKey) + } + + WidgetCenter.shared.reloadAllTimelines() + } +} diff --git a/packages/mobile/ios/App/OpenChamberNotificationService/OpenChamberNotificationService.entitlements b/packages/mobile/ios/App/OpenChamberNotificationService/OpenChamberNotificationService.entitlements new file mode 100644 index 00000000..149617ae --- /dev/null +++ b/packages/mobile/ios/App/OpenChamberNotificationService/OpenChamberNotificationService.entitlements @@ -0,0 +1,11 @@ + + + + + + com.apple.security.application-groups + + group.com.openchamber.app + + + diff --git a/packages/mobile/ios/App/OpenChamberWidget/Assets.xcassets/Contents.json b/packages/mobile/ios/App/OpenChamberWidget/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/packages/mobile/ios/App/OpenChamberWidget/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/packages/mobile/ios/App/OpenChamberWidget/Assets.xcassets/OCLogoSymbol.symbolset/Contents.json b/packages/mobile/ios/App/OpenChamberWidget/Assets.xcassets/OCLogoSymbol.symbolset/Contents.json new file mode 100644 index 00000000..03c3f191 --- /dev/null +++ b/packages/mobile/ios/App/OpenChamberWidget/Assets.xcassets/OCLogoSymbol.symbolset/Contents.json @@ -0,0 +1,13 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "symbols" : [ + { + "filename" : "oclogo-symbol.svg", + "idiom" : "universal", + "rendering-intent" : "template" + } + ] +} diff --git a/packages/mobile/ios/App/OpenChamberWidget/Assets.xcassets/OCLogoSymbol.symbolset/oclogo-symbol.svg b/packages/mobile/ios/App/OpenChamberWidget/Assets.xcassets/OCLogoSymbol.symbolset/oclogo-symbol.svg new file mode 100644 index 00000000..e7ac3b37 --- /dev/null +++ b/packages/mobile/ios/App/OpenChamberWidget/Assets.xcassets/OCLogoSymbol.symbolset/oclogo-symbol.svg @@ -0,0 +1,55 @@ + + + + + + Small + Medium + Large + + + Ultralight + Regular + Black + Template v.3.0 + + https://github.com/swhitty/SwiftDraw + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/mobile/ios/App/OpenChamberWidget/Info.plist b/packages/mobile/ios/App/OpenChamberWidget/Info.plist new file mode 100644 index 00000000..3eb8c038 --- /dev/null +++ b/packages/mobile/ios/App/OpenChamberWidget/Info.plist @@ -0,0 +1,27 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + OpenChamber + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/packages/mobile/ios/App/OpenChamberWidget/OpenChamberControl.swift b/packages/mobile/ios/App/OpenChamberWidget/OpenChamberControl.swift new file mode 100644 index 00000000..93f345f8 --- /dev/null +++ b/packages/mobile/ios/App/OpenChamberWidget/OpenChamberControl.swift @@ -0,0 +1,38 @@ +import AppIntents +import SwiftUI +import WidgetKit + +// Control Center control (iOS 18+): tap the OpenChamber logo to start a new session. +// +// IMPORTANT: this file is a member of BOTH the app target and the widget extension target. +// iOS requires the control's AppIntent to exist in the app target too, otherwise tapping the +// control can't open the app (the tap does nothing). It's kept self-contained (inline URL, no +// dependency on the widget's shared code) so it compiles cleanly in the app target. +@available(iOS 18.0, *) +struct OpenChamberNewSessionControl: ControlWidget { + var body: some ControlWidgetConfiguration { + StaticControlConfiguration(kind: "OpenChamberNewSessionControl") { + ControlWidgetButton(action: OpenNewSessionIntent()) { + // Custom symbol is referenced via `image:` (the asset-catalog symbol path; + // `systemImage:` only finds Apple's system SF Symbols → shows a "?"). The glyph + // uses bold strokes so it stays visible at the control's small, tinted size — + // thin strokes rendered blank. + Label("New Session", image: "OCLogoSymbol") + } + } + .displayName("New Session") + .description("Start a new OpenChamber session.") + } +} + +@available(iOS 18.0, *) +struct OpenNewSessionIntent: AppIntent { + static let title: LocalizedStringResource = "New OpenChamber Session" + static let openAppWhenRun: Bool = true + static let isDiscoverable: Bool = true + + @MainActor + func perform() async throws -> some IntentResult & OpensIntent { + return .result(opensIntent: OpenURLIntent(URL(string: "openchamber://new")!)) + } +} diff --git a/packages/mobile/ios/App/OpenChamberWidget/OpenChamberWidget.entitlements b/packages/mobile/ios/App/OpenChamberWidget/OpenChamberWidget.entitlements new file mode 100644 index 00000000..3fc5495f --- /dev/null +++ b/packages/mobile/ios/App/OpenChamberWidget/OpenChamberWidget.entitlements @@ -0,0 +1,11 @@ + + + + + + com.apple.security.application-groups + + group.com.openchamber.app + + + diff --git a/packages/mobile/ios/App/OpenChamberWidget/OpenChamberWidgets.swift b/packages/mobile/ios/App/OpenChamberWidget/OpenChamberWidgets.swift new file mode 100644 index 00000000..51122f3c --- /dev/null +++ b/packages/mobile/ios/App/OpenChamberWidget/OpenChamberWidgets.swift @@ -0,0 +1,321 @@ +import SwiftUI +import WidgetKit + +// MARK: - Medium home-screen widget: recent sessions (left) + quick actions (right) + +struct OverviewWidgetView: View { + let entry: OverviewEntry + + var body: some View { + HStack(alignment: .center, spacing: 16) { + sessionsColumn + actionsGrid + } + } + + private var sessionsColumn: some View { + VStack(alignment: .leading, spacing: 0) { + if entry.snapshot.recentSessions.isEmpty { + Text("No sessions yet") + .font(.subheadline) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + } else { + ForEach(entry.snapshot.recentSessions.prefix(4)) { session in + Link(destination: WidgetDeepLink.session(session.id)) { + HStack(spacing: 8) { + // Every row shows a same-size dot so titles align: a filled orange + // dot for unread, a hollow grey ring for read. + unreadIndicator(session.unread) + Text(session.title.isEmpty ? "Untitled" : session.title) + .font(.subheadline) + .fontWeight(session.unread ? .semibold : .regular) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 0) + } + // Each row claims an equal share of the height → even distribution, no gap. + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + } + .foregroundStyle(.primary) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + } + + @ViewBuilder + private func unreadIndicator(_ unread: Bool) -> some View { + if unread { + Circle() + .fill(Color.orange) + .frame(width: 7, height: 7) + } else { + Circle() + .strokeBorder(Color.secondary.opacity(0.4), lineWidth: 1.5) + .frame(width: 7, height: 7) + } + } + + private var actionsGrid: some View { + VStack(spacing: 16) { + HStack(spacing: 16) { + actionButton(systemImage: "plus", url: WidgetDeepLink.newSession()) + actionButton(systemImage: "square.stack.3d.up", url: WidgetDeepLink.status()) + } + HStack(spacing: 16) { + actionButton(systemImage: "server.rack", url: WidgetDeepLink.instances()) + actionButton(systemImage: "gearshape", url: WidgetDeepLink.settings()) + } + } + .frame(maxHeight: .infinity) + } + + private func actionButton(systemImage: String, url: URL) -> some View { + Link(destination: url) { + Image(systemName: systemImage) + .font(.system(size: 22, weight: .medium)) + .frame(width: 56, height: 56) + .background(.quaternary, in: Circle()) + } + .foregroundStyle(.primary) + } +} + +struct OverviewWidget: Widget { + let kind = "OpenChamberOverview" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: OverviewProvider()) { entry in + OverviewWidgetView(entry: entry) + .containerBackground(.fill.tertiary, for: .widget) + } + .configurationDisplayName("OpenChamber") + .description("Recent sessions and quick actions.") + .supportedFamilies([.systemMedium]) + } +} + +// MARK: - Small home-screen widget: New Session + quick actions + +struct QuickActionsWidgetView: View { + var body: some View { + VStack(spacing: 10) { + // Wide primary button: New Session. + Link(destination: WidgetDeepLink.newSession()) { + HStack(spacing: 8) { + CubeLogoView() + .frame(width: 26, height: 26) + Text("Chat") + .font(.title3) + .fontWeight(.semibold) + .lineLimit(1) + Spacer(minLength: 0) + } + .padding(.horizontal, 14) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.quaternary, in: Capsule()) + } + .foregroundStyle(.primary) + + // Two round secondary actions. + HStack(spacing: 10) { + quickCircle(systemImage: "square.stack.3d.up", url: WidgetDeepLink.status()) + quickCircle(systemImage: "server.rack", url: WidgetDeepLink.instances()) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + private func quickCircle(systemImage: String, url: URL) -> some View { + Link(destination: url) { + Image(systemName: systemImage) + .font(.system(size: 20, weight: .medium)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.quaternary, in: Circle()) + } + .foregroundStyle(.primary) + } +} + +struct QuickActionsWidget: Widget { + let kind = "OpenChamberQuickActions" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: OverviewProvider()) { _ in + QuickActionsWidgetView() + .containerBackground(.fill.tertiary, for: .widget) + } + .configurationDisplayName("Quick Actions") + .description("New session, status and instances.") + .supportedFamilies([.systemSmall]) + } +} + +// MARK: - Large home-screen widget: full session list with project labels + +struct SessionsWidgetView: View { + let entry: OverviewEntry + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + header + if entry.snapshot.recentSessions.isEmpty { + Text("No sessions yet") + .font(.subheadline) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } else { + VStack(spacing: 0) { + ForEach(entry.snapshot.recentSessions.prefix(6)) { session in + row(session) + } + } + .frame(maxHeight: .infinity, alignment: .top) + } + } + } + + private var header: some View { + HStack(spacing: 8) { + CubeLogoView() + .frame(width: 20, height: 20) + Text("Sessions") + .font(.headline) + Spacer(minLength: 0) + if entry.snapshot.attentionCount > 0 { + Text("\(entry.snapshot.attentionCount)") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.orange) + } + Link(destination: WidgetDeepLink.newSession()) { + Image(systemName: "plus") + .font(.system(size: 15, weight: .semibold)) + .frame(width: 30, height: 30) + .background(.quaternary, in: Circle()) + } + .foregroundStyle(.primary) + } + } + + private func row(_ session: WidgetSession) -> some View { + Link(destination: WidgetDeepLink.session(session.id)) { + HStack(spacing: 10) { + Group { + if session.unread { + Circle().fill(Color.orange) + } else { + Circle().strokeBorder(Color.secondary.opacity(0.4), lineWidth: 1.5) + } + } + .frame(width: 8, height: 8) + VStack(alignment: .leading, spacing: 2) { + Text(session.title.isEmpty ? "Untitled" : session.title) + .font(.subheadline) + .fontWeight(session.unread ? .semibold : .regular) + .lineLimit(1) + if let project = session.project, !project.isEmpty { + Text(project) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 7) + } + .foregroundStyle(.primary) + } +} + +struct SessionsWidget: Widget { + let kind = "OpenChamberSessions" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: OverviewProvider()) { entry in + SessionsWidgetView(entry: entry) + .containerBackground(.fill.tertiary, for: .widget) + } + .configurationDisplayName("Sessions") + .description("Recent sessions with their project.") + .supportedFamilies([.systemLarge]) + } +} + +// MARK: - Lock Screen: logo → new session + +struct LockNewSessionView: View { + var body: some View { + ZStack { + AccessoryWidgetBackground() + CubeLogoView() + .padding(7) + } + .widgetURL(WidgetDeepLink.newSession()) + } +} + +struct LockNewSessionWidget: Widget { + let kind = "OpenChamberLockNew" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: OverviewProvider()) { _ in + LockNewSessionView() + .containerBackground(.clear, for: .widget) + } + .configurationDisplayName("New Session") + .description("Start a new OpenChamber session.") + .supportedFamilies([.accessoryCircular]) + } +} + +// MARK: - Lock Screen: attention counter + +struct LockAttentionView: View { + let entry: OverviewEntry + + var body: some View { + ZStack { + AccessoryWidgetBackground() + VStack(spacing: 0) { + Text("\(entry.snapshot.attentionCount)") + .font(.system(size: 22, weight: .semibold, design: .rounded)) + Image(systemName: "bell.badge") + .font(.system(size: 10)) + } + } + .widgetURL(WidgetDeepLink.attention()) + } +} + +struct LockAttentionWidget: Widget { + let kind = "OpenChamberLockAttention" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: OverviewProvider()) { entry in + LockAttentionView(entry: entry) + .containerBackground(.clear, for: .widget) + } + .configurationDisplayName("Needs Attention") + .description("How many sessions need attention.") + .supportedFamilies([.accessoryCircular]) + } +} + +// MARK: - Bundle + +@main +struct OpenChamberWidgetBundle: WidgetBundle { + var body: some Widget { + OverviewWidget() + SessionsWidget() + QuickActionsWidget() + LockNewSessionWidget() + LockAttentionWidget() + if #available(iOS 18.0, *) { + OpenChamberNewSessionControl() + } + } +} diff --git a/packages/mobile/ios/App/OpenChamberWidget/WidgetShared.swift b/packages/mobile/ios/App/OpenChamberWidget/WidgetShared.swift new file mode 100644 index 00000000..e98e08e5 --- /dev/null +++ b/packages/mobile/ios/App/OpenChamberWidget/WidgetShared.swift @@ -0,0 +1,137 @@ +import SwiftUI +import WidgetKit + +// MARK: - Shared model + App Group reader + +/// One row of the session overview the app writes to the shared App Group. +/// Mirrors MobileWidgetSession in packages/ui/src/apps/mobileWidgetSnapshot.ts. +struct WidgetSession: Codable, Identifiable, Hashable { + let id: String + let title: String + let unread: Bool + /// Project label for the session's directory. Optional so snapshots written before this + /// field existed still decode. + var project: String? +} + +/// The session overview snapshot. Mirrors MobileWidgetSnapshot (same field names) so the +/// JSON the app stores decodes directly. +struct WidgetSnapshot: Codable { + let attentionCount: Int + let recentSessions: [WidgetSession] + + static let empty = WidgetSnapshot(attentionCount: 0, recentSessions: []) +} + +enum WidgetStore { + static let appGroup = "group.com.openchamber.app" + static let snapshotKey = "widgetSnapshot" + + /// Reads the latest snapshot the app persisted. Returns `.empty` when nothing has been + /// written yet (fresh install / app never foregrounded) so widgets render a clean state. + static func load() -> WidgetSnapshot { + guard let defaults = UserDefaults(suiteName: appGroup), + let json = defaults.string(forKey: snapshotKey), + let data = json.data(using: .utf8), + let snapshot = try? JSONDecoder().decode(WidgetSnapshot.self, from: data) else { + return .empty + } + return snapshot + } +} + +// MARK: - Deep links (mirror packages/ui/src/apps/deepLinks.ts) + +enum WidgetDeepLink { + static func newSession() -> URL { URL(string: "openchamber://new")! } + static func attention() -> URL { URL(string: "openchamber://sessions?filter=attention")! } + static func status() -> URL { URL(string: "openchamber://status")! } + static func settings() -> URL { URL(string: "openchamber://settings")! } + static func changes() -> URL { URL(string: "openchamber://changes")! } + static func files() -> URL { URL(string: "openchamber://view/files")! } + static func instances() -> URL { URL(string: "openchamber://view/instances")! } + static func session(_ id: String) -> URL { + let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id + return URL(string: "openchamber://session/\(encoded)") ?? newSession() + } +} + +// MARK: - Timeline provider + +struct OverviewEntry: TimelineEntry { + let date: Date + let snapshot: WidgetSnapshot +} + +struct OverviewProvider: TimelineProvider { + func placeholder(in context: Context) -> OverviewEntry { + OverviewEntry(date: Date(), snapshot: .empty) + } + + func getSnapshot(in context: Context, completion: @escaping (OverviewEntry) -> Void) { + completion(OverviewEntry(date: Date(), snapshot: WidgetStore.load())) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + // The app/NSE reload timelines (WidgetCenter) when the snapshot changes, but with several + // widgets sharing the app's WidgetKit reload budget iOS can refresh them unevenly and + // leave one stale. Ask for a periodic refresh too so every widget independently re-reads + // the shared snapshot and converges to the latest state (budget permitting). + let entry = OverviewEntry(date: Date(), snapshot: WidgetStore.load()) + let nextRefresh = Date().addingTimeInterval(10 * 60) + completion(Timeline(entries: [entry], policy: .after(nextRefresh))) + } +} + +// MARK: - Logo (full OpenChamber mark drawn from the SVG) + +/// The OpenChamber logo, drawn to match packages/web/public/logo-dark-512x512.svg: an +/// isometric cube with translucent face fills, stroked edges, and the OpenCode mark on the +/// top face. Faces use low-opacity `.primary` so the system tint on the Lock Screen / Control +/// Center reads as a translucent fill (no colour) rather than a flat wireframe. Coordinates are +/// the SVG inner group (range x:-41.568…41.568, y:-48…48). +struct CubeLogoView: View { + var body: some View { + Canvas { context, size in + let halfW: CGFloat = 41.568 + let halfH: CGFloat = 48 + let scale = min(size.width / (halfW * 2), size.height / (halfH * 2)) + let cx = size.width / 2 + let cy = size.height / 2 + let lineWidth = max(1.5, 3 * scale) + + // Cube coordinate → canvas point. + func p(_ x: CGFloat, _ y: CGFloat) -> CGPoint { CGPoint(x: cx + x * scale, y: cy + y * scale) } + // OpenCode-mark local coordinate → canvas point (SVG: matrix(0.866,0.5,-0.866,0.5,0,-24) · scale(0.75)). + func m(_ x: CGFloat, _ y: CGFloat) -> CGPoint { + let s: CGFloat = 0.75 + let mx = 0.866 * s * x - 0.866 * s * y + let my = 0.5 * s * x + 0.5 * s * y - 24 + return p(mx, my) + } + + var left = Path() + left.move(to: p(0, 0)); left.addLine(to: p(-halfW, -24)); left.addLine(to: p(-halfW, 24)); left.addLine(to: p(0, 48)); left.closeSubpath() + var right = Path() + right.move(to: p(0, 0)); right.addLine(to: p(halfW, -24)); right.addLine(to: p(halfW, 24)); right.addLine(to: p(0, 48)); right.closeSubpath() + var top = Path() + top.move(to: p(0, -48)); top.addLine(to: p(-halfW, -24)); top.addLine(to: p(0, 0)); top.addLine(to: p(halfW, -24)); top.closeSubpath() + + context.fill(left, with: .color(.primary.opacity(0.2))) + context.fill(right, with: .color(.primary.opacity(0.35))) + context.stroke(left, with: .color(.primary), style: StrokeStyle(lineWidth: lineWidth, lineJoin: .round)) + context.stroke(right, with: .color(.primary), style: StrokeStyle(lineWidth: lineWidth, lineJoin: .round)) + context.stroke(top, with: .color(.primary), style: StrokeStyle(lineWidth: lineWidth, lineJoin: .round)) + + // OpenCode mark: square ring (even-odd) + a partial inner fill. + var ring = Path() + ring.move(to: m(-16, -20)); ring.addLine(to: m(16, -20)); ring.addLine(to: m(16, 20)); ring.addLine(to: m(-16, 20)); ring.closeSubpath() + ring.move(to: m(-8, -12)); ring.addLine(to: m(-8, 12)); ring.addLine(to: m(8, 12)); ring.addLine(to: m(8, -12)); ring.closeSubpath() + context.fill(ring, with: .color(.primary), style: FillStyle(eoFill: true)) + + var inner = Path() + inner.move(to: m(-8, -4)); inner.addLine(to: m(8, -4)); inner.addLine(to: m(8, 12)); inner.addLine(to: m(-8, 12)); inner.closeSubpath() + context.fill(inner, with: .color(.primary.opacity(0.4))) + } + } +} diff --git a/packages/mobile/ios/App/Podfile b/packages/mobile/ios/App/Podfile new file mode 100644 index 00000000..cb62cfa5 --- /dev/null +++ b/packages/mobile/ios/App/Podfile @@ -0,0 +1,40 @@ +require_relative '../../../../node_modules/.bun/@capacitor+ios@8.4.1+767ac80cbab8ae50/node_modules/@capacitor/ios/scripts/pods_helpers' + +platform :ios, '15.5' +use_frameworks! + +# workaround to avoid Xcode caching of Pods that requires +# Product -> Clean Build Folder after new Cordova plugins installed +# Requires CocoaPods 1.6 or newer +install! 'cocoapods', :disable_input_output_paths => true + +def capacitor_pods + pod 'Capacitor', :path => '../../../../node_modules/.bun/@capacitor+ios@8.4.1+767ac80cbab8ae50/node_modules/@capacitor/ios' + pod 'CapacitorCordova', :path => '../../../../node_modules/.bun/@capacitor+ios@8.4.1+767ac80cbab8ae50/node_modules/@capacitor/ios' + pod 'AparajitaCapacitorSecureStorage', :path => '../../../../node_modules/.bun/@aparajita+capacitor-secure-storage@8.0.0/node_modules/@aparajita/capacitor-secure-storage' + pod 'CapacitorMlkitBarcodeScanning', :path => '../../../../node_modules/.bun/@capacitor-mlkit+barcode-scanning@8.1.0+767ac80cbab8ae50/node_modules/@capacitor-mlkit/barcode-scanning' + pod 'CapacitorApp', :path => '../../../../node_modules/.bun/@capacitor+app@8.1.0+767ac80cbab8ae50/node_modules/@capacitor/app' + pod 'CapacitorKeyboard', :path => '../../../../node_modules/.bun/@capacitor+keyboard@8.0.5+767ac80cbab8ae50/node_modules/@capacitor/keyboard' + pod 'CapacitorPushNotifications', :path => '../../../../node_modules/.bun/@capacitor+push-notifications@8.1.1+767ac80cbab8ae50/node_modules/@capacitor/push-notifications' + pod 'CapacitorStatusBar', :path => '../../../../node_modules/.bun/@capacitor+status-bar@8.0.2+767ac80cbab8ae50/node_modules/@capacitor/status-bar' +end + +target 'App' do + capacitor_pods + # Add your Pods here +end + +post_install do |installer| + assertDeploymentTarget(installer) + # Xcode 16+/iOS 26 SDK rejects deployment targets below 15.0, and GoogleMLKit + # (pulled in by the barcode scanner) requires iOS 15.5+. Force every Pods target + # up so the Capacitor/Cordova/MLKit pods build for a real device. + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.5' + # Capacitor's Cordova compatibility headers use quoted includes; newer Xcode + # treats those as errors in framework headers. Keep it a (non-fatal) warning. + config.build_settings['CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER'] = 'NO' + end + end +end diff --git a/packages/mobile/ios/App/Podfile.lock b/packages/mobile/ios/App/Podfile.lock new file mode 100644 index 00000000..cd65e1a5 --- /dev/null +++ b/packages/mobile/ios/App/Podfile.lock @@ -0,0 +1,134 @@ +PODS: + - AparajitaCapacitorSecureStorage (8.0.0): + - Capacitor + - KeychainSwift (~> 21.0) + - Capacitor (8.4.1): + - CapacitorCordova + - CapacitorApp (8.1.0): + - Capacitor + - CapacitorCordova (8.4.1) + - CapacitorKeyboard (8.0.5): + - Capacitor + - CapacitorMlkitBarcodeScanning (8.1.0): + - Capacitor + - GoogleMLKit/BarcodeScanning (~> 8.0.0) + - CapacitorPushNotifications (8.1.1): + - Capacitor + - CapacitorStatusBar (8.0.2): + - Capacitor + - GoogleDataTransport (10.1.0): + - nanopb (~> 3.30910.0) + - PromisesObjC (~> 2.4) + - GoogleMLKit/BarcodeScanning (8.0.0): + - GoogleMLKit/MLKitCore + - MLKitBarcodeScanning (~> 7.0.0) + - GoogleMLKit/MLKitCore (8.0.0): + - MLKitCommon (~> 13.0.0) + - GoogleToolboxForMac/Defines (4.2.1) + - GoogleToolboxForMac/Logger (4.2.1): + - GoogleToolboxForMac/Defines (= 4.2.1) + - "GoogleToolboxForMac/NSData+zlib (4.2.1)": + - GoogleToolboxForMac/Defines (= 4.2.1) + - GoogleUtilities/Environment (8.1.1): + - GoogleUtilities/Privacy + - GoogleUtilities/Logger (8.1.1): + - GoogleUtilities/Environment + - GoogleUtilities/Privacy + - GoogleUtilities/Privacy (8.1.1) + - GoogleUtilities/UserDefaults (8.1.1): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - GTMSessionFetcher/Core (3.5.0) + - KeychainSwift (21.0.0) + - MLImage (1.0.0-beta7) + - MLKitBarcodeScanning (7.0.0): + - MLKitCommon (~> 13.0) + - MLKitVision (~> 9.0) + - MLKitCommon (13.0.0): + - GoogleDataTransport (~> 10.0) + - GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1) + - "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)" + - GoogleUtilities/Logger (~> 8.0) + - GoogleUtilities/UserDefaults (~> 8.0) + - GTMSessionFetcher/Core (< 4.0, >= 3.3.2) + - MLKitVision (9.0.0): + - GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1) + - "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)" + - GTMSessionFetcher/Core (< 4.0, >= 3.3.2) + - MLImage (= 1.0.0-beta7) + - MLKitCommon (~> 13.0) + - nanopb (3.30910.0): + - nanopb/decode (= 3.30910.0) + - nanopb/encode (= 3.30910.0) + - nanopb/decode (3.30910.0) + - nanopb/encode (3.30910.0) + - PromisesObjC (2.4.1) + +DEPENDENCIES: + - "AparajitaCapacitorSecureStorage (from `../../../../node_modules/.bun/@aparajita+capacitor-secure-storage@8.0.0/node_modules/@aparajita/capacitor-secure-storage`)" + - "Capacitor (from `../../../../node_modules/.bun/@capacitor+ios@8.4.1+767ac80cbab8ae50/node_modules/@capacitor/ios`)" + - "CapacitorApp (from `../../../../node_modules/.bun/@capacitor+app@8.1.0+767ac80cbab8ae50/node_modules/@capacitor/app`)" + - "CapacitorCordova (from `../../../../node_modules/.bun/@capacitor+ios@8.4.1+767ac80cbab8ae50/node_modules/@capacitor/ios`)" + - "CapacitorKeyboard (from `../../../../node_modules/.bun/@capacitor+keyboard@8.0.5+767ac80cbab8ae50/node_modules/@capacitor/keyboard`)" + - "CapacitorMlkitBarcodeScanning (from `../../../../node_modules/.bun/@capacitor-mlkit+barcode-scanning@8.1.0+767ac80cbab8ae50/node_modules/@capacitor-mlkit/barcode-scanning`)" + - "CapacitorPushNotifications (from `../../../../node_modules/.bun/@capacitor+push-notifications@8.1.1+767ac80cbab8ae50/node_modules/@capacitor/push-notifications`)" + - "CapacitorStatusBar (from `../../../../node_modules/.bun/@capacitor+status-bar@8.0.2+767ac80cbab8ae50/node_modules/@capacitor/status-bar`)" + +SPEC REPOS: + trunk: + - GoogleDataTransport + - GoogleMLKit + - GoogleToolboxForMac + - GoogleUtilities + - GTMSessionFetcher + - KeychainSwift + - MLImage + - MLKitBarcodeScanning + - MLKitCommon + - MLKitVision + - nanopb + - PromisesObjC + +EXTERNAL SOURCES: + AparajitaCapacitorSecureStorage: + :path: "../../../../node_modules/.bun/@aparajita+capacitor-secure-storage@8.0.0/node_modules/@aparajita/capacitor-secure-storage" + Capacitor: + :path: "../../../../node_modules/.bun/@capacitor+ios@8.4.1+767ac80cbab8ae50/node_modules/@capacitor/ios" + CapacitorApp: + :path: "../../../../node_modules/.bun/@capacitor+app@8.1.0+767ac80cbab8ae50/node_modules/@capacitor/app" + CapacitorCordova: + :path: "../../../../node_modules/.bun/@capacitor+ios@8.4.1+767ac80cbab8ae50/node_modules/@capacitor/ios" + CapacitorKeyboard: + :path: "../../../../node_modules/.bun/@capacitor+keyboard@8.0.5+767ac80cbab8ae50/node_modules/@capacitor/keyboard" + CapacitorMlkitBarcodeScanning: + :path: "../../../../node_modules/.bun/@capacitor-mlkit+barcode-scanning@8.1.0+767ac80cbab8ae50/node_modules/@capacitor-mlkit/barcode-scanning" + CapacitorPushNotifications: + :path: "../../../../node_modules/.bun/@capacitor+push-notifications@8.1.1+767ac80cbab8ae50/node_modules/@capacitor/push-notifications" + CapacitorStatusBar: + :path: "../../../../node_modules/.bun/@capacitor+status-bar@8.0.2+767ac80cbab8ae50/node_modules/@capacitor/status-bar" + +SPEC CHECKSUMS: + AparajitaCapacitorSecureStorage: 8128d05cafcb13b00448e20fb388a0edccd44b12 + Capacitor: 35242afe195b1e53c58ca1b827d1b444c5e6602b + CapacitorApp: 449ffe26375e96f8aaaee625ac6e01e5c57c8650 + CapacitorCordova: eebe6bcf807b1b06f3f48237650f96bbcd0eef09 + CapacitorKeyboard: b6b0744890cdb1d9a96e2cafcc9253fdcc55de3b + CapacitorMlkitBarcodeScanning: 31c6af9f39873ff69e16ed5b39ebe2e1915f16fe + CapacitorPushNotifications: ec08d589c226a2c0db7c032ec1bf5b044ec85f8e + CapacitorStatusBar: 01d5763b4ed720de5ce2edbc938de6a98f4c8f32 + GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 + GoogleMLKit: ddd51d7dff36ff28defa69afedd9cdce684fd857 + GoogleToolboxForMac: d1a2cbf009c453f4d6ded37c105e2f67a32206d8 + GoogleUtilities: 4f2618a4a1e762a1ee134a1e2323bba9843e06da + GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 + KeychainSwift: 4a71a45c802fd9e73906457c2dcbdbdc06c9419d + MLImage: 2ab9c968e75f57911c16f4c9d9e8a8e9604a86a1 + MLKitBarcodeScanning: 72c6437f13a900833b400136be53a8a5d86f42fa + MLKitCommon: 26b779f072a182c1603d4c88a101c350cac837b1 + MLKitVision: fa8dea9012ac59497c79ddbe9ebf32051047ac4c + nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 + PromisesObjC: 752c3227f599e3467650e47ea36f433eeb10c273 + +PODFILE CHECKSUM: 99c62a30e73aa8ec805869506d2b1969ee4fb92d + +COCOAPODS: 1.16.2 diff --git a/packages/mobile/package.json b/packages/mobile/package.json new file mode 100644 index 00000000..75596240 --- /dev/null +++ b/packages/mobile/package.json @@ -0,0 +1,47 @@ +{ + "name": "@openchamber/mobile", + "version": "1.13.2", + "private": true, + "type": "module", + "scripts": { + "build": "bun run --cwd ../web build && node scripts/prepare-web-assets.mjs", + "sync": "node scripts/with-mobile-env.mjs \"bun run build && cap sync\"", + "add:ios": "cap add ios", + "add:android": "cap add android", + "build:android:debug": "node scripts/with-mobile-env.mjs \"bun run sync && ./android/gradlew -p android assembleDebug\"", + "android:devices": "node scripts/with-mobile-env.mjs \"node scripts/android-device.mjs devices\"", + "android:install": "node scripts/with-mobile-env.mjs \"node scripts/android-device.mjs install\"", + "android:launch": "node scripts/with-mobile-env.mjs \"node scripts/android-device.mjs launch\"", + "android:run": "node scripts/with-mobile-env.mjs \"node scripts/android-device.mjs run\"", + "android:logcat": "node scripts/with-mobile-env.mjs \"node scripts/android-device.mjs logcat\"", + "build:ios:simulator": "node scripts/with-mobile-env.mjs \"node scripts/ios-sim-build.mjs\"", + "sim:boot": "node scripts/with-mobile-env.mjs \"node scripts/ios-sim.mjs boot\"", + "sim:install": "node scripts/with-mobile-env.mjs \"node scripts/ios-sim.mjs install\"", + "sim:launch": "node scripts/with-mobile-env.mjs \"node scripts/ios-sim.mjs launch\"", + "sim:run": "node scripts/with-mobile-env.mjs \"node scripts/ios-sim.mjs run\"", + "sim:serve": "node scripts/with-mobile-env.mjs \"serve-sim --detach -q\"", + "sim:list": "node scripts/with-mobile-env.mjs \"serve-sim --list -q\"", + "sim:kill": "node scripts/with-mobile-env.mjs \"serve-sim --kill\"", + "open:ios": "cap open ios", + "open:android": "cap open android", + "type-check": "tsc --noEmit", + "lint": "eslint \"./**/*.{ts,tsx,js,mjs}\" --config ../../eslint.config.js --ignore-pattern dist --ignore-pattern ios --ignore-pattern android" + }, + "dependencies": { + "@aparajita/capacitor-secure-storage": "^8.0.0", + "@capacitor-mlkit/barcode-scanning": "^8.1.0", + "@capacitor/app": "^8.0.0", + "@capacitor/core": "^8.4.1", + "@capacitor/keyboard": "^8.0.0", + "@capacitor/push-notifications": "^8.1.1", + "@capacitor/status-bar": "^8.0.0" + }, + "devDependencies": { + "@capacitor/android": "^8.4.1", + "@capacitor/cli": "^8.4.1", + "@capacitor/ios": "^8.4.1", + "@types/node": "^24.3.1", + "serve-sim": "^0.1.34", + "typescript": "~5.9.0" + } +} diff --git a/packages/mobile/scripts/android-device.mjs b/packages/mobile/scripts/android-device.mjs new file mode 100644 index 00000000..82aab84c --- /dev/null +++ b/packages/mobile/scripts/android-device.mjs @@ -0,0 +1,93 @@ +// Install / launch the debug APK on a connected Android device via adb. +// +// Mirrors scripts/ios-sim.mjs for the iOS simulator. Run through with-mobile-env.mjs so adb +// (ANDROID_HOME/platform-tools) and the JDK are on PATH. Build the APK first with +// `bun run build:android:debug`; `run` installs + launches it. + +import { spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const mobileRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); +const APK_PATH = join(mobileRoot, 'android', 'app', 'build', 'outputs', 'apk', 'debug', 'app-debug.apk'); +const APP_ID = 'com.openchamber.app'; +const LAUNCH_ACTIVITY = `${APP_ID}/.MainActivity`; + +const adb = (args, { capture = false, allowFail = false } = {}) => { + const result = spawnSync('adb', args, { stdio: capture ? 'pipe' : 'inherit', encoding: 'utf8' }); + if (!allowFail && result.status !== 0) { + throw new Error(`adb ${args.join(' ')} exited with ${result.status ?? result.signal}`); + } + return result; +}; + +const connectedDevices = () => { + const output = adb(['devices'], { capture: true, allowFail: true }).stdout || ''; + return output + .split('\n') + .slice(1) + .map((line) => line.trim()) + .filter((line) => line.endsWith('\tdevice')) + .map((line) => line.split('\t')[0]); +}; + +const requireDevice = () => { + const devices = connectedDevices(); + if (devices.length === 0) { + console.error( + 'No authorized Android device found. Enable Developer options + USB debugging on the device, ' + + 'connect it, and accept the "Allow USB debugging" prompt. Check with: bun run android:devices', + ); + process.exit(1); + } + return devices; +}; + +const requireApk = () => { + if (!existsSync(APK_PATH)) { + throw new Error(`Debug APK not found at ${APK_PATH}. Build it first: bun run build:android:debug`); + } +}; + +const install = () => { + requireDevice(); + requireApk(); + adb(['install', '-r', APK_PATH]); +}; + +const launch = () => { + requireDevice(); + adb(['shell', 'am', 'start', '-n', LAUNCH_ACTIVITY]); +}; + +const command = process.argv[2]; +switch (command) { + case 'devices': + adb(['devices', '-l']); + break; + case 'install': + install(); + break; + case 'launch': + launch(); + break; + case 'run': + install(); + launch(); + break; + case 'logcat': { + requireDevice(); + const pid = (adb(['shell', 'pidof', APP_ID], { capture: true, allowFail: true }).stdout || '').trim().split(/\s+/)[0]; + if (pid) { + adb(['logcat', `--pid=${pid}`]); + } else { + console.warn(`[android] ${APP_ID} is not running; streaming Capacitor/Chromium logs. Launch the app to see its logs.`); + adb(['logcat', '-s', 'Capacitor:V', 'Capacitor/Console:V', 'chromium:V']); + } + break; + } + default: + console.error('Usage: node scripts/android-device.mjs '); + process.exit(1); +} diff --git a/packages/mobile/scripts/ios-sim-build.mjs b/packages/mobile/scripts/ios-sim-build.mjs new file mode 100644 index 00000000..78b8a1fd --- /dev/null +++ b/packages/mobile/scripts/ios-sim-build.mjs @@ -0,0 +1,69 @@ +// Builds the iOS app for the Apple-Silicon simulator. +// +// Why this is special: the barcode scanner (`@capacitor-mlkit/barcode-scanning` → +// GoogleMLKit) ships only device-arm64 + simulator-x86_64 slices — there is NO +// arm64-simulator slice. CocoaPods therefore adds `EXCLUDED_ARCHS[sdk=iphonesimulator*] = +// arm64`, so a normal build produces an x86_64-only binary that can't install on an +// arm64-only iOS 26+ simulator ("does not contain code for ... arm64"). +// +// QR scanning needs a camera, which the simulator doesn't have, so dropping the scanner for +// simulator builds loses nothing: this script temporarily removes the MLKit pod, builds an +// arm64 simulator binary, then restores the Podfile + Pods so device/TestFlight builds keep +// the scanner. The JS side already degrades cleanly when the native plugin is absent +// (mobileQrScan: getScannerPlugin() → null → isQrScanSupported() false). + +import { spawnSync } from 'node:child_process'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const mobileRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); +const iosAppDir = join(mobileRoot, 'ios', 'App'); +const podfilePath = join(iosAppDir, 'Podfile'); + +const run = (command, args, cwd = mobileRoot) => { + const result = spawnSync(command, args, { stdio: 'inherit', cwd, env: process.env }); + if (result.status !== 0) { + throw new Error(`${command} ${args.join(' ')} exited with ${result.status ?? result.signal}`); + } +}; + +// 1. Build the web bundle and copy it into the iOS project (no pod regen — `copy`, not `sync`). +run('bun', ['run', 'build']); +run('cap', ['copy', 'ios']); + +// 2. Strip the MLKit barcode-scanning pod, then reinstall pods without it. +const originalPodfile = readFileSync(podfilePath, 'utf8'); +const strippedPodfile = originalPodfile + .split('\n') + .filter((line) => !line.includes('CapacitorMlkitBarcodeScanning')) + .join('\n'); + +if (strippedPodfile === originalPodfile) { + console.warn('[ios-sim-build] CapacitorMlkitBarcodeScanning not found in Podfile — building as-is.'); +} + +try { + writeFileSync(podfilePath, strippedPodfile); + run('pod', ['install'], iosAppDir); + + // 3. Build for the simulator. With MLKit gone the arm64 simulator slice builds cleanly. + run('xcodebuild', [ + '-workspace', 'ios/App/App.xcworkspace', + '-scheme', 'App', + '-configuration', 'Debug', + '-sdk', 'iphonesimulator', + '-destination', 'generic/platform=iOS Simulator', + 'CODE_SIGNING_ALLOWED=NO', + 'build', + ]); +} finally { + // 4. Always restore the Podfile + Pods so device/TestFlight builds keep the scanner. Pods/ + // and Podfile.lock return to their original state (a no-op for git once this completes). + if (strippedPodfile !== originalPodfile) { + writeFileSync(podfilePath, originalPodfile); + run('pod', ['install'], iosAppDir); + } +} + +console.log('[ios-sim-build] Simulator build complete. Run `bun run sim:run` to install + launch.'); diff --git a/packages/mobile/scripts/ios-sim.mjs b/packages/mobile/scripts/ios-sim.mjs new file mode 100644 index 00000000..c5fe3a0e --- /dev/null +++ b/packages/mobile/scripts/ios-sim.mjs @@ -0,0 +1,95 @@ +import { spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; + +const BUNDLE_ID = 'com.openchamber.app'; +const DEFAULT_DEVICE = 'iPhone 17 Pro'; + +const run = (command, args, options = {}) => { + const result = spawnSync(command, args, { + env: process.env, + stdio: options.capture ? ['ignore', 'pipe', 'pipe'] : 'inherit', + encoding: 'utf8', + }); + + if (result.status !== 0) { + if (options.capture && result.stderr) process.stderr.write(result.stderr); + process.exit(result.status ?? 1); + } + + return result.stdout?.trim() ?? ''; +}; + +const getBootedDevice = () => { + const json = run('xcrun', ['simctl', 'list', 'devices', 'booted', '--json'], { capture: true }); + const data = JSON.parse(json); + for (const devices of Object.values(data.devices ?? {})) { + const device = devices.find((item) => item.state === 'Booted'); + if (device) return device; + } + return null; +}; + +const bootDevice = (name = DEFAULT_DEVICE) => { + const booted = getBootedDevice(); + if (booted) return booted.udid; + + const json = run('xcrun', ['simctl', 'list', 'devices', 'available', '--json'], { capture: true }); + const data = JSON.parse(json); + for (const devices of Object.values(data.devices ?? {})) { + const match = devices.find((device) => device.name === name && device.isAvailable !== false); + if (!match) continue; + run('xcrun', ['simctl', 'boot', match.udid]); + return match.udid; + } + + throw new Error(`No available simulator named "${name}" found.`); +}; + +const getBuiltAppPath = () => { + const appPath = run('xcodebuild', [ + '-workspace', 'ios/App/App.xcworkspace', + '-scheme', 'App', + '-configuration', 'Debug', + '-sdk', 'iphonesimulator', + '-showBuildSettings', + ], { capture: true }) + .split('\n') + .map((line) => line.trim()) + .find((line) => line.startsWith('TARGET_BUILD_DIR = ')) + ?.replace('TARGET_BUILD_DIR = ', ''); + + if (!appPath) throw new Error('Unable to resolve iOS simulator build output directory.'); + const fullPath = path.join(appPath, 'App.app'); + if (!existsSync(fullPath)) throw new Error(`Built app not found at ${fullPath}. Run bun run build:ios:simulator first.`); + return fullPath; +}; + +const command = process.argv[2]; + +switch (command) { + case 'boot': { + const udid = bootDevice(process.argv.slice(3).join(' ') || DEFAULT_DEVICE); + console.log(udid); + break; + } + case 'install': { + const udid = bootDevice(); + run('xcrun', ['simctl', 'install', udid, getBuiltAppPath()]); + break; + } + case 'launch': { + const udid = bootDevice(); + run('xcrun', ['simctl', 'launch', udid, BUNDLE_ID]); + break; + } + case 'run': { + const udid = bootDevice(); + run('xcrun', ['simctl', 'install', udid, getBuiltAppPath()]); + run('xcrun', ['simctl', 'launch', udid, BUNDLE_ID]); + break; + } + default: + console.error('Usage: node scripts/ios-sim.mjs [device name]'); + process.exit(1); +} diff --git a/packages/mobile/scripts/prepare-web-assets.mjs b/packages/mobile/scripts/prepare-web-assets.mjs new file mode 100644 index 00000000..13103ff1 --- /dev/null +++ b/packages/mobile/scripts/prepare-web-assets.mjs @@ -0,0 +1,16 @@ +import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const mobileRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const webDist = path.resolve(mobileRoot, '../web/dist'); +const mobileDist = path.resolve(mobileRoot, 'dist'); +const mobileHtml = path.join(mobileDist, 'mobile.html'); +const indexHtml = path.join(mobileDist, 'index.html'); + +await rm(mobileDist, { recursive: true, force: true }); +await mkdir(mobileDist, { recursive: true }); +await cp(webDist, mobileDist, { recursive: true }); + +const html = await readFile(mobileHtml, 'utf8'); +await writeFile(indexHtml, html); diff --git a/packages/mobile/scripts/with-mobile-env.mjs b/packages/mobile/scripts/with-mobile-env.mjs new file mode 100644 index 00000000..100c0f8b --- /dev/null +++ b/packages/mobile/scripts/with-mobile-env.mjs @@ -0,0 +1,48 @@ +import { spawn, spawnSync } from 'node:child_process'; + +const command = process.argv.slice(2).join(' '); + +if (!command) { + console.error('Usage: node scripts/with-mobile-env.mjs '); + process.exit(1); +} + +// Respect an explicit DEVELOPER_DIR, then fall back to whatever the user selected via +// `xcode-select` (so an Xcode beta / non-default install is honoured). Hardcoding +// /Applications/Xcode.app overrode `xcode-select` and forced builds onto the wrong Xcode, +// whose simulator runtimes may not match — xcodebuild then can't find the chosen simulator. +const selectedDeveloperDir = () => { + try { + const result = spawnSync('xcode-select', ['-p'], { encoding: 'utf8' }); + const path = result.status === 0 ? result.stdout.trim() : ''; + return path.length > 0 ? path : null; + } catch { + return null; + } +}; + +const developerDir = + process.env.DEVELOPER_DIR || selectedDeveloperDir() || '/Applications/Xcode.app/Contents/Developer'; +const javaHome = process.env.JAVA_HOME || '/opt/homebrew/opt/openjdk@21'; +const androidHome = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT || '/opt/homebrew/share/android-commandlinetools'; + +const child = spawn(command, { + env: { + ...process.env, + DEVELOPER_DIR: developerDir, + JAVA_HOME: javaHome, + ANDROID_HOME: androidHome, + ANDROID_SDK_ROOT: androidHome, + PATH: `${javaHome}/bin:${androidHome}/platform-tools:${process.env.PATH || ''}`, + }, + shell: true, + stdio: 'inherit', +}); + +child.on('exit', (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(code ?? 1); +}); diff --git a/packages/mobile/tsconfig.json b/packages/mobile/tsconfig.json new file mode 100644 index 00000000..80af150c --- /dev/null +++ b/packages/mobile/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "moduleDetection": "force", + "strict": true, + "skipLibCheck": true, + "types": ["node"], + "noEmit": true + }, + "include": ["capacitor.config.ts"] +} diff --git a/packages/ui/package.json b/packages/ui/package.json index 85c4e41d..509eb743 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -11,7 +11,13 @@ "lint": "eslint \"./src/**/*.{ts,tsx}\" --config ../../eslint.config.js" }, "dependencies": { + "@aparajita/capacitor-secure-storage": "^8.0.0", "@base-ui/react": "^1.4.0", + "@capacitor/app": "^8.0.0", + "@capacitor/core": "^8.4.1", + "@capacitor/keyboard": "^8.0.0", + "@capacitor/push-notifications": "^8.1.1", + "@capacitor/status-bar": "^8.0.0", "@codemirror/autocomplete": "^6.20.0", "@codemirror/commands": "^6.10.1", "@codemirror/lang-cpp": "^6.0.3", diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 8ce5f1b6..842e8372 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -34,7 +34,6 @@ import { markSessionViewed } from '@/sync/notification-store'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { opencodeClient } from '@/lib/opencode/client'; -import { disposeTerminalInputTransport } from '@/lib/terminalApi'; import { runtimeFetch } from '@/lib/runtime-fetch'; import { getRuntimeKey, subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch'; import { useAutoReviewStore } from '@/stores/useAutoReviewStore'; @@ -57,8 +56,8 @@ import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery'; import { useI18n } from '@/lib/i18n'; import { applyMobileKeyboardMode } from '@/lib/mobileKeyboardMode'; import { SyncAppEffects } from '@/apps/AppEffects'; +import { resetAppForRuntimeEndpointChange } from '@/apps/runtimeEndpointReset'; import { useAppFontEffects } from '@/apps/useAppFontEffects'; -import { resetStreamingState } from '@/sync/streaming'; import { OpenCodeUpdateToast } from '@/components/update/OpenCodeUpdateToast'; import { markStartupTrace, startupTraceEnabled } from '@/lib/startupTrace'; @@ -268,25 +267,7 @@ function App({ apis }: AppProps) { React.useEffect(() => { return subscribeRuntimeEndpointChanged((detail) => { - useSessionUIStore.getState().prepareForRuntimeSwitch(detail.previousRuntimeKey); - useUIStore.getState().prepareForRuntimeSwitch(detail.previousRuntimeKey); - if (detail.previousRuntimeKey) { - useAutoReviewStore.getState().stopRunningRunsForRuntime(detail.previousRuntimeKey); - } - disposeTerminalInputTransport(); - opencodeClient.reconnectToRuntimeBaseUrl(); - useConfigStore.setState({ - providers: [], - agents: [], - isConnected: false, - isInitialized: false, - connectionPhase: 'connecting', - lastDisconnectReason: null, - }); - useProjectsStore.getState().resetForRuntimeSwitch(); - useSessionUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey); - useUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey); - resetStreamingState(); + resetAppForRuntimeEndpointChange(detail); setRuntimeEndpointEpoch((epoch) => epoch + 1); setInitRetryExhausted(false); setInitRetryEpoch((epoch) => epoch + 1); diff --git a/packages/ui/src/apps/MobileApp.tsx b/packages/ui/src/apps/MobileApp.tsx index 1dc98318..756e564c 100644 --- a/packages/ui/src/apps/MobileApp.tsx +++ b/packages/ui/src/apps/MobileApp.tsx @@ -7,6 +7,8 @@ import { McpDropdownContent } from '@/components/mcp/McpDropdown'; import { AboutSettings } from '@/components/sections/openchamber/AboutSettings'; import { OpenCodeUpdateToast } from '@/components/update/OpenCodeUpdateToast'; import { ConfigUpdateOverlay } from '@/components/ui/ConfigUpdateOverlay'; +import { Button } from '@/components/ui/button'; +import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo'; import { ProviderLogo } from '@/components/ui/ProviderLogo'; import { ChatView } from '@/components/views/ChatView'; import { SettingsView } from '@/components/views/SettingsView'; @@ -29,6 +31,7 @@ import { resolveProjectForDirectory, resolveProjectForSessionDirectory } from '@ import { clampPercent, formatQuotaResetLabel, formatQuotaValueLabel, formatWindowLabel, QUOTA_PROVIDERS, resolveUsageTone } from '@/lib/quota'; import { getDisplayModelName } from '@/lib/quota/model-families'; import { runtimeFetch } from '@/lib/runtime-fetch'; +import { getRuntimeApiBaseUrl, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch'; import { sessionEvents } from '@/lib/sessionEvents'; import { cn } from '@/lib/utils'; import { useConfigStore } from '@/stores/useConfigStore'; @@ -55,7 +58,14 @@ import { MobileFilesSurface } from './MobileFilesSurface'; import { MobileSessionsSheet } from './MobileSessionsSheet'; import { MobileSurfaceShell } from './MobileSurfaceShell'; import { DedicatedMobileAppProvider, type MobileAppActions } from './mobileAppContext'; +import { autoConnectLastInstance, isSameConnectionUrl, useMobileConnection } from './mobileConnections'; +import { isQrScanSupported, parseConnectionPayload, scanConnectionQr } from './mobileQrScan'; +import { resetAppForRuntimeEndpointChange } from './runtimeEndpointReset'; import { useAppFontEffects } from './useAppFontEffects'; +import { useFontsReady } from './useFontsReady'; +import { useDeepLinkHandlers, useDeepLinkSource } from './deepLinkNavigation'; +import { useEdgeSwipeSessionSwitch } from './useEdgeSwipeSessionSwitch'; +import { useNativePushRegistration } from './useNativePushRegistration'; const MOBILE_SETTINGS_PAGES = [ 'appearance', @@ -76,6 +86,177 @@ type MobileAppProps = { apis: RuntimeAPIs; }; +const isCapacitorMobileApp = (): boolean => { + if (typeof window === 'undefined') return false; + const maybeCapacitor = (window as typeof window & { + Capacitor?: { isNativePlatform?: () => boolean; getPlatform?: () => string }; + }).Capacitor; + if (maybeCapacitor?.isNativePlatform?.() === true) return true; + return window.location.protocol === 'capacitor:'; +}; + +const useNativeMobileChrome = (): void => { + React.useEffect(() => { + if (!isCapacitorMobileApp()) return; + + let disposed = false; + const cleanup: Array<() => void> = []; + const root = document.documentElement; + // Marks the Capacitor shell so keyboard-inset CSS only applies here, not in + // the browser-hosted PWA (which handles the keyboard via dvh / interactive-widget). + root.classList.add('oc-capacitor-app'); + // Platform marker: Android resizes the window for the keyboard (no manual inset), so the + // shell's height transition (meant for iOS's animated --oc-keyboard-inset) must be off there + // — otherwise the height animates against the instant native resize and the header bounces. + const capacitorPlatform = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor?.getPlatform?.(); + if (capacitorPlatform === 'android') { + root.classList.add('oc-platform-android'); + } + + const setInset = (px: number) => { + root.style.setProperty('--oc-keyboard-inset', `${Math.max(0, Math.round(px))}px`); + }; + + void import('@capacitor/status-bar').then(async ({ StatusBar, Style }) => { + if (disposed) return; + // Keep the status bar transparent over the WebView. A custom UIScene lifecycle + // (iOS 26) plus returning from background can silently drop the overlay state, + // letting an opaque status-bar background flash in at the top — so re-assert it + // on mount, once shortly after (startup race), and whenever the app re-activates. + const platform = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor?.getPlatform?.(); + const applyStatusBar = async () => { + if (platform === 'android') { + // Android doesn't feed env(safe-area-inset-top) to CSS, so overlaying the status bar + // makes content render under it. Inset the WebView below the bar instead and paint the + // bar with the resolved theme background (the splash colours the theme system persists). + const isDark = document.documentElement.classList.contains('dark'); + const themeBg = + (isDark ? localStorage.getItem('splashBgDark') : localStorage.getItem('splashBgLight')) || + (isDark ? '#171515' : '#fffdf4'); + await StatusBar.setOverlaysWebView({ overlay: false }).catch(() => undefined); + await StatusBar.setBackgroundColor({ color: themeBg }).catch(() => undefined); + // Capacitor Style is named for the CONTENT: Style.Light = dark text (light bg), + // Style.Dark = light text (dark bg). So dark theme → Style.Dark, light theme → Style.Light. + await StatusBar.setStyle({ style: isDark ? Style.Dark : Style.Light }).catch(() => undefined); + await StatusBar.show().catch(() => undefined); + return; + } + await StatusBar.setStyle({ style: Style.Default }).catch(() => undefined); + await StatusBar.setOverlaysWebView({ overlay: true }).catch(() => undefined); + await StatusBar.show().catch(() => undefined); + }; + await applyStatusBar(); + const retry = window.setTimeout(() => void applyStatusBar(), 400); + cleanup.push(() => window.clearTimeout(retry)); + + const { App } = await import('@capacitor/app'); + const stateHandle = await App.addListener('appStateChange', ({ isActive }) => { + if (isActive) void applyStatusBar(); + }); + if (disposed) { + void stateHandle.remove(); + return; + } + cleanup.push(() => void stateHandle.remove()); + }).catch(() => undefined); + + void import('@capacitor/keyboard').then(async ({ Keyboard }) => { + if (disposed) return; + // iOS (WKWebView, resize: 'none') keeps 100dvh at full height with the keyboard + // overlaying, so we lift the UI manually via --oc-keyboard-inset. Android resizes the + // window for the keyboard (dvh already shrinks), so applying the inset on top double- + // counts and floats the composer a keyboard-height above the keyboard — skip it there. + const platform = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor?.getPlatform?.(); + if (platform === 'android') return; + await Keyboard.setAccessoryBarVisible({ isVisible: true }).catch(() => undefined); + + // `keyboardWillShow` fires at the START of the iOS keyboard animation and + // carries the final height, so we set the inset once here and let the CSS + // transition (tuned to mimic the iOS keyboard curve/duration) carry the rise. + // visualViewport tracking was tried but doesn't shrink under WKWebView's + // `resize: 'none'`, so it never reported the keyboard — this event is the + // reliable signal. + const showHandle = await Keyboard.addListener('keyboardWillShow', (info) => { + root.classList.add('oc-keyboard-open'); + setInset(info.keyboardHeight); + }); + const hideHandle = await Keyboard.addListener('keyboardWillHide', () => { + root.classList.remove('oc-keyboard-open'); + setInset(0); + }); + if (disposed) { + void showHandle.remove(); + void hideHandle.remove(); + return; + } + cleanup.push(() => void showHandle.remove(), () => void hideHandle.remove()); + }).catch(() => undefined); + + return () => { + disposed = true; + cleanup.forEach((remove) => remove()); + root.classList.remove('oc-capacitor-app', 'oc-keyboard-open', 'oc-platform-android'); + root.style.removeProperty('--oc-keyboard-inset'); + }; + }, []); +}; + +const useNativeMobileLifecycle = (onResume: () => void): void => { + React.useEffect(() => { + if (!isCapacitorMobileApp()) return; + + let disposed = false; + const cleanup: Array<() => void> = []; + + void import('@capacitor/app').then(async ({ App }) => { + if (disposed) return; + const state = await App.addListener('appStateChange', ({ isActive }) => { + document.documentElement.classList.toggle('oc-native-app-active', isActive); + if (isActive) onResume(); + }); + const resume = await App.addListener('resume', onResume); + if (disposed) { + void state.remove(); + void resume.remove(); + return; + } + cleanup.push(() => void state.remove(), () => void resume.remove()); + }).catch(() => undefined); + + return () => { + disposed = true; + cleanup.forEach((remove) => remove()); + }; + }, [onResume]); +}; + +const useNativeAndroidBackButton = (onBack: () => boolean): void => { + React.useEffect(() => { + if (!isCapacitorMobileApp()) return; + + let disposed = false; + let remove: (() => void) | null = null; + + void import('@capacitor/app').then(async ({ App }) => { + if (disposed) return; + const listener = await App.addListener('backButton', () => { + if (onBack()) return; + void App.minimizeApp().catch(() => undefined); + }); + if (disposed) { + void listener.remove(); + return; + } + remove = () => void listener.remove(); + }).catch(() => undefined); + + return () => { + disposed = true; + remove?.(); + }; + }, [onBack]); +}; + const normalizePath = (value?: string | null): string => (value || '').replace(/\\/g, '/').replace(/\/+$/g, ''); @@ -95,6 +276,12 @@ const formatTokens = (value: number): string => { return String(value); }; +const mobileInputKeyboardProps = { + autoComplete: 'off', + autoCorrect: 'off', + spellCheck: false, +} as const; + const getProjectLabel = (path: string): string => { const normalized = normalizePath(path); if (!normalized) return ''; @@ -103,7 +290,7 @@ const getProjectLabel = (path: string): string => { }; type OverflowItem = { - key: 'files' | 'changes' | 'mcp' | 'update' | 'settings'; + key: 'files' | 'changes' | 'mcp' | 'instances' | 'update' | 'settings'; icon?: IconName; iconNode?: React.ReactNode; label: string; @@ -122,6 +309,540 @@ const getProjectDisplayLabel = (project: ProjectEntry | null, fallbackDirectory: return getProjectLabel(fallbackDirectory); }; +const MobileConnectionWelcome: React.FC<{ onConnected: () => void }> = ({ onConnected }) => { + const { t } = useI18n(); + const conn = useMobileConnection(onConnected); + const { connections, isBusy, isPasswordBusy, error, pendingConnection } = conn; + const [serverUrl, setServerUrl] = React.useState(''); + const [connectionName, setConnectionName] = React.useState(''); + const [clientToken, setClientToken] = React.useState(''); + const [isScanning, setIsScanning] = React.useState(false); + const [advancedOpen, setAdvancedOpen] = React.useState(false); + const qrScanSupported = React.useMemo(() => isQrScanSupported(), []); + const [password, setPassword] = React.useState(''); + + const handleSubmit = React.useCallback((event: React.FormEvent) => { + event.preventDefault(); + void conn.connect({ url: serverUrl, clientToken, label: connectionName }); + }, [clientToken, conn, connectionName, serverUrl]); + + // Accept a pasted pairing link (openchamber://connect?...) in the URL field and + // split it back into the server URL + token, revealing the token field when present. + const handleUrlChange = React.useCallback((value: string) => { + if (/^openchamber:\/\//i.test(value.trim())) { + const payload = parseConnectionPayload(value); + if (payload) { + setServerUrl(payload.url); + if (payload.label) setConnectionName(payload.label); + if (payload.clientToken) setClientToken(payload.clientToken); + if (payload.label || payload.clientToken) setAdvancedOpen(true); + return; + } + } + setServerUrl(value); + }, []); + + const handleScanQr = React.useCallback(async () => { + if (isScanning || isBusy) return; + conn.setError(null); + setIsScanning(true); + try { + const result = await scanConnectionQr(); + switch (result.status) { + case 'ok': + setServerUrl(result.url); + if (result.label) setConnectionName(result.label); + if (result.clientToken) setClientToken(result.clientToken); + if (result.label || result.clientToken) setAdvancedOpen(true); + await conn.connect({ url: result.url, clientToken: result.clientToken, label: result.label }); + break; + case 'permission-denied': + conn.setError(t('mobile.connect.scan.permissionDenied')); + break; + case 'invalid': + conn.setError(t('mobile.connect.scan.invalid')); + break; + case 'unsupported': + conn.setError(t('mobile.connect.scan.unsupported')); + break; + case 'failed': + conn.setError(t('mobile.connect.scan.failed')); + break; + case 'cancelled': + default: + break; + } + } finally { + setIsScanning(false); + } + }, [conn, isBusy, isScanning, t]); + + const handlePasswordSubmit = React.useCallback((event: React.FormEvent) => { + event.preventDefault(); + void conn.submitPassword(password); + }, [conn, password]); + + const cancelPassword = React.useCallback(() => { + setPassword(''); + conn.cancelPassword(); + }, [conn]); + + return ( +

+
+
+ +

{t('mobile.connect.welcome.title')}

+
+ + {pendingConnection ? ( +
+
+ + + +
+

{pendingConnection.label}

+

{pendingConnection.url}

+
+
+ setPassword(event.target.value)} + placeholder={t('mobile.connect.password.placeholder')} + aria-label={t('mobile.connect.password.label')} + type="password" + autoFocus + className="h-12 w-full rounded-[16px] border border-border/70 bg-surface-elevated px-4 text-[16px] text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-primary focus:ring-2 focus:ring-primary/20" + /> + {error ?

{error}

: null} + + +
+ ) : ( +
+
+ setConnectionName(event.target.value)} + placeholder={t('mobile.instances.label.placeholder')} + aria-label={t('mobile.instances.label.label')} + autoComplete="off" + autoCapitalize="words" + autoCorrect="off" + spellCheck={false} + className="h-12 w-full rounded-[16px] border border-border/70 bg-surface-elevated px-4 text-center text-[16px] text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-primary focus:ring-2 focus:ring-primary/20" + /> + handleUrlChange(event.target.value)} + placeholder={t('mobile.connect.url.placeholder')} + aria-label={t('mobile.connect.url.label')} + type="url" + inputMode="url" + autoCapitalize="none" + className="h-12 w-full rounded-[16px] border border-border/70 bg-surface-elevated px-4 text-center text-[16px] text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-primary focus:ring-2 focus:ring-primary/20" + /> + +
+ +
+
+
+ +

{t('mobile.connect.token.hint')}

+
+
+
+
+ + {error ?

{error}

: null} + + +
+ + + {!qrScanSupported ? ( +

+ {t('mobile.connect.scan.unsupported')} +

+ ) : null} +
+ )} + + {!pendingConnection && connections.length > 0 ? ( +
+

+ {t('mobile.connect.saved.title')} +

+
+ {connections.map((connection) => ( + + ))} +
+
+ ) : null} +
+
+ ); +}; + +const MobileInstancesSurface: React.FC<{ + onConnect: () => void; + onActiveConnectionDeleted: () => void; +}> = ({ onActiveConnectionDeleted, onConnect }) => { + const { t } = useI18n(); + const conn = useMobileConnection(onConnect); + const { + connections, isBusy, isPasswordBusy, error, pendingConnection, + connect, submitPassword, cancelPassword, saveConnection, removeConnection, setError, + } = conn; + const [editingId, setEditingId] = React.useState(null); + const editingConnection = editingId ? connections.find((connection) => connection.id === editingId) ?? null : null; + const [confirmingDeleteId, setConfirmingDeleteId] = React.useState(null); + const [url, setUrl] = React.useState(''); + const [label, setLabel] = React.useState(''); + const [clientToken, setClientToken] = React.useState(''); + const [password, setPassword] = React.useState(''); + const [isScanning, setIsScanning] = React.useState(false); + const qrScanSupported = React.useMemo(() => isQrScanSupported(), []); + + // Populate/clear the form imperatively (on edit tap / cancel / save) rather than via + // an effect keyed on the derived connection object. With an effect, any churn of the + // connections list re-fires it and overwrites what the user is typing — the keyboard + // "resets" mid-edit. Imperative population is immune to that. + const resetForm = React.useCallback(() => { + setEditingId(null); + setUrl(''); + setLabel(''); + setClientToken(''); + setError(null); + }, [setError]); + + const saveInstance = React.useCallback((event: React.FormEvent) => { + event.preventDefault(); + void saveConnection({ url, label, clientToken }).then((saved) => { + if (saved) resetForm(); + }); + }, [clientToken, label, resetForm, saveConnection, url]); + + // Scan a pairing QR into the add/edit form fields (does not change edit mode, so + // the form-reset effect doesn't wipe the scanned values). The user reviews + saves. + const handleScanInstance = React.useCallback(async () => { + if (isScanning) return; + setError(null); + setIsScanning(true); + try { + const result = await scanConnectionQr(); + switch (result.status) { + case 'ok': + setUrl(result.url); + if (result.label) setLabel(result.label); + if (result.clientToken) setClientToken(result.clientToken); + break; + case 'permission-denied': + setError(t('mobile.connect.scan.permissionDenied')); + break; + case 'invalid': + setError(t('mobile.connect.scan.invalid')); + break; + case 'unsupported': + setError(t('mobile.connect.scan.unsupported')); + break; + case 'failed': + setError(t('mobile.connect.scan.failed')); + break; + case 'cancelled': + default: + break; + } + } finally { + setIsScanning(false); + } + }, [isScanning, setError, t]); + + const handlePasswordSubmit = React.useCallback((event: React.FormEvent) => { + event.preventDefault(); + void submitPassword(password); + }, [password, submitPassword]); + + const cancelPasswordPrompt = React.useCallback(() => { + setPassword(''); + cancelPassword(); + }, [cancelPassword]); + + // Two-step delete (mirrors the session sheet): the trash icon arms the row, a + // second tap on the destructive button confirms, the X disarms. No hover relied on. + const toggleConfirmDelete = React.useCallback((id: string) => { + setConfirmingDeleteId((current) => (current === id ? null : id)); + }, []); + + const confirmDelete = React.useCallback((id: string) => { + setConfirmingDeleteId(null); + if (editingId === id) resetForm(); + void removeConnection(id).then((removed) => { + if (removed && isSameConnectionUrl(removed.url, getRuntimeApiBaseUrl())) { + onActiveConnectionDeleted(); + } + }); + }, [editingId, onActiveConnectionDeleted, removeConnection, resetForm]); + + const inputClass = 'h-12 w-full rounded-[16px] border border-border/70 bg-surface-elevated px-4 text-[16px] text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-primary focus:ring-2 focus:ring-primary/20'; + + if (pendingConnection) { + return ( +
+
+
+
+ + + +
+

{pendingConnection.label}

+

{pendingConnection.url}

+
+
+ setPassword(event.target.value)} + placeholder={t('mobile.connect.password.placeholder')} + aria-label={t('mobile.connect.password.label')} + type="password" + autoFocus + className={inputClass} + /> + {error ?

{error}

: null} + + +
+
+
+ ); + } + + return ( +
+
+
+ {connections.length > 0 ? ( +
+ {connections.map((connection) => { + const confirming = confirmingDeleteId === connection.id; + return ( +
+ +
+ {confirming ? ( + + ) : ( + + )} + +
+
+ ); + })} +
+ ) : ( +

+ {t('mobile.connect.saved.empty')} +

+ )} + +
+
+

+ {editingConnection ? t('mobile.instances.editTitle') : t('mobile.instances.addTitle')} +

+ {editingConnection ? ( + + ) : null} +
+
+ + {!qrScanSupported ? ( +

{t('mobile.connect.scan.unsupported')}

+ ) : null} +
+ + + + {error ?

{error}

: null} + +
+
+
+
+ ); +}; + type MobileUsageLimitRow = { key: string; label: string; @@ -787,12 +1508,13 @@ const MobileHeader: React.FC<{ ); }; -const MobileShell: React.FC = () => { +const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onActiveConnectionDeleted }) => { const { t } = useI18n(); const [sessionsSheetOpen, setSessionsSheetOpen] = React.useState(false); const [filesOpen, setFilesOpen] = React.useState(false); const [changesOpen, setChangesOpen] = React.useState(false); const [mcpOpen, setMcpOpen] = React.useState(false); + const [instancesOpen, setInstancesOpen] = React.useState(false); const [isMcpRefreshing, setIsMcpRefreshing] = React.useState(false); const [settingsOpen, setSettingsOpen] = React.useState(false); const [updateOpen, setUpdateOpen] = React.useState(false); @@ -804,6 +1526,7 @@ const MobileShell: React.FC = () => { const setSettingsPage = useUIStore((state) => state.setSettingsPage); const updateAvailable = useUpdateStore((state) => state.available); const updateRuntimeType = useUpdateStore((state) => state.runtimeType); + const showCapacitorOnlyFeatures = React.useMemo(() => isCapacitorMobileApp(), []); const mcpServers = useMcpConfigStore((state) => state.mcpServers); const setMcpDraft = useMcpConfigStore((state) => state.setMcpDraft); const setSelectedMcp = useMcpConfigStore((state) => state.setSelectedMcp); @@ -832,6 +1555,101 @@ const MobileShell: React.FC = () => { setPendingChangesDiff(null); }, []); + // Expose the shell's panel-opening actions to the deep-link layer so openchamber:// URLs + // (and notification taps / widgets) can navigate to these surfaces. Session and + // new-session intents resolve directly against the store, so they aren't wired here. + const deepLinkHandlers = React.useMemo( + () => ({ + openSessions: () => setSessionsSheetOpen(true), + openView: (target: 'files' | 'mcp' | 'instances' | 'update') => { + if (target === 'files') setFilesOpen(true); + else if (target === 'mcp') setMcpOpen(true); + else if (target === 'instances') setInstancesOpen(true); + else if (target === 'update') setUpdateOpen(true); + }, + openChanges: ({ path, staged }: { path?: string; staged?: boolean } = {}) => { + setPendingChangesDiff(path ? { path, staged: staged === true } : null); + setChangesOpen(true); + }, + openSettings: (section?: string) => { + if (section) setSettingsPage(section as Parameters[0]); + setSettingsInitialMobileStage(section ? 'page-content' : 'nav'); + setSettingsOpen(true); + }, + }), + [setSettingsPage], + ); + useDeepLinkHandlers(deepLinkHandlers); + + // Edge swipe (left/right screen edge → centre) switches between sessions, with a directional + // slide+fade on the chat content so it's obvious the session changed. + const chatMainRef = React.useRef(null); + const chatAnimRef = React.useRef(null); + const swipeDirectionRef = React.useRef<'prev' | 'next' | null>(null); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + // Record the swipe direction; the animation itself runs in the layout effect below, once the + // new session's content has committed — running it inline in the swipe callback raced the + // re-render and dropped the animation on roughly every other switch. + const recordSwipeDirection = React.useCallback((direction: 'prev' | 'next') => { + swipeDirectionRef.current = direction; + }, []); + useEdgeSwipeSessionSwitch(chatMainRef, { onSwitch: recordSwipeDirection }); + + React.useLayoutEffect(() => { + const direction = swipeDirectionRef.current; + swipeDirectionRef.current = null; + if (!direction) return; // only animate swipe-driven switches + const element = chatAnimRef.current; + if (!element || typeof element.animate !== 'function') return; + element.getAnimations().forEach((animation) => animation.cancel()); + const fromX = direction === 'prev' ? -70 : 70; + element.animate( + [ + { opacity: 0.1, transform: `translateX(${fromX}px)` }, + { opacity: 1, transform: 'translateX(0)' }, + ], + { duration: 300, easing: 'cubic-bezier(0.22, 1, 0.36, 1)' }, + ); + }, [currentSessionId]); + + const handleNativeBack = React.useCallback(() => { + if (overflowOpen) { + setOverflowOpen(false); + return true; + } + if (sessionsSheetOpen) { + setSessionsSheetOpen(false); + return true; + } + if (filesOpen) { + setFilesOpen(false); + return true; + } + if (changesOpen) { + closeChanges(); + return true; + } + if (mcpOpen) { + setMcpOpen(false); + return true; + } + if (instancesOpen) { + setInstancesOpen(false); + return true; + } + if (settingsOpen) { + setSettingsOpen(false); + return true; + } + if (updateOpen) { + setUpdateOpen(false); + return true; + } + return false; + }, [changesOpen, closeChanges, filesOpen, instancesOpen, mcpOpen, overflowOpen, sessionsSheetOpen, settingsOpen, updateOpen]); + + useNativeAndroidBackButton(handleNativeBack); + const showUpdateItem = updateAvailable && (updateRuntimeType === 'desktop' || updateRuntimeType === 'web'); const openMcpCreateSettings = React.useCallback(() => { @@ -881,7 +1699,8 @@ const MobileShell: React.FC = () => { }, [currentDirectory, isMcpRefreshing, loadMcpConfigs, refreshMcpStatus]); const overflowItems: OverflowItem[] = React.useMemo( - () => [ + () => { + const items: OverflowItem[] = [ { key: 'files', icon: 'file-text', @@ -901,13 +1720,24 @@ const MobileShell: React.FC = () => { label: t('mobile.menu.mcp'), onSelect: () => setMcpOpen(true), }, - ...(showUpdateItem ? [{ - key: 'update' as const, - icon: 'download' as const, - label: t('mobile.menu.update'), - onSelect: () => setUpdateOpen(true), - }] : []), - { + ]; + if (showCapacitorOnlyFeatures) { + items.push({ + key: 'instances', + icon: 'server', + label: t('mobile.menu.instances'), + onSelect: () => setInstancesOpen(true), + }); + } + if (showUpdateItem) { + items.push({ + key: 'update', + icon: 'download', + label: t('mobile.menu.update'), + onSelect: () => setUpdateOpen(true), + }); + } + items.push({ key: 'settings', icon: 'settings-3', label: t('mobile.menu.settings'), @@ -915,25 +1745,28 @@ const MobileShell: React.FC = () => { setSettingsInitialMobileStage('nav'); setSettingsOpen(true); }, - }, - ], - [dirtyChangeCount, showUpdateItem, t], + }); + return items; + }, + [dirtyChangeCount, showCapacitorOnlyFeatures, showUpdateItem, t], ); return (
setSessionsSheetOpen(true)} onOpenMenu={() => setOverflowOpen(true)} /> -
- - - +
+
+ + + +
{ ) : null} + {instancesOpen && showCapacitorOnlyFeatures ? ( + setInstancesOpen(false)} + ariaLabel={t('mobile.menu.instances')} + title={t('mobile.menu.instances')} + > + setInstancesOpen(false)} + onActiveConnectionDeleted={onActiveConnectionDeleted} + /> + + ) : null} + {settingsOpen ? ( { }; export function MobileApp({ apis }: MobileAppProps) { + const { t } = useI18n(); const initializeApp = useConfigStore((state) => state.initializeApp); const isInitialized = useConfigStore((state) => state.isInitialized); const isConnected = useConfigStore((state) => state.isConnected); + const connectionPhase = useConfigStore((state) => state.connectionPhase); const providersCount = useConfigStore((state) => state.providers.length); const agentsCount = useConfigStore((state) => state.agents.length); const loadProviders = useConfigStore((state) => state.loadProviders); @@ -1089,19 +1938,75 @@ export function MobileApp({ apis }: MobileAppProps) { const refreshGitHubAuthStatus = useGitHubAuthStore((state) => state.refreshStatus); const setPlanModeEnabled = useFeatureFlagsStore((state) => state.setPlanModeEnabled); const projects = useProjectsStore((state) => state.projects); + const [connectionEpoch, setConnectionEpoch] = React.useState(0); + const [runtimeEndpointEpoch, setRuntimeEndpointEpoch] = React.useState(0); + const [showConnectionRecovery, setShowConnectionRecovery] = React.useState(false); + // Cold-launch auto-connect to the last instance: 'pending'/'attempting' hold the + // splash so we don't flash the connect screen; 'done' means we either connected or + // exhausted the attempt (then the connect screen shows). + const [autoConnectPhase, setAutoConnectPhase] = React.useState<'pending' | 'attempting' | 'done'>('pending'); + const isNativeMobileApp = React.useMemo(() => isCapacitorMobileApp(), []); + + const handleNativeResume = React.useCallback(() => { + if (!getRuntimeApiBaseUrl()) return; + void initializeApp(); + void refreshGitHubAuthStatus(apis.github, { force: true }); + if (providersCount === 0) void loadProviders({ source: 'mobileApp:nativeResume' }); + if (agentsCount === 0) void loadAgents({ source: 'mobileApp:nativeResume' }); + }, [agentsCount, apis.github, initializeApp, loadAgents, loadProviders, providersCount, refreshGitHubAuthStatus]); + + useNativeMobileChrome(); + useNativeMobileLifecycle(handleNativeResume); React.useEffect(() => { registerRuntimeAPIs(apis); return () => registerRuntimeAPIs(null); }, [apis]); + // Switching instances (or disconnecting) only changes the runtime endpoint; the + // stores still hold the previous instance's data. Mirror the web App.tsx reset + // sequence so the UI fully re-bootstraps against the new server instead of going + // stale. The SyncProvider is keyed by runtimeEndpointEpoch so it remounts too. + React.useEffect(() => { + return subscribeRuntimeEndpointChanged((detail) => { + resetAppForRuntimeEndpointChange(detail); + setRuntimeEndpointEpoch((epoch) => epoch + 1); + setConnectionEpoch((epoch) => epoch + 1); + }); + }, []); + + // On cold launch, silently reconnect to the most-recent saved instance so a + // returning user — and notification deep-links — land in the app instead of the + // connect screen. The splash is held while we try (see render below). If there's + // no saved instance, it's unreachable, or it needs a (re)login, we fall through + // to the connect screen. A successful switchRuntimeEndpoint fires the endpoint- + // changed subscription above, which bumps the epochs and bootstraps the app. + React.useEffect(() => { + if (!isNativeMobileApp || isConnected || getRuntimeApiBaseUrl()) { + setAutoConnectPhase('done'); + return; + } + let cancelled = false; + setAutoConnectPhase('attempting'); + void autoConnectLastInstance() + .catch(() => false) + .then(() => { + if (!cancelled) setAutoConnectPhase('done'); + }); + return () => { + cancelled = true; + }; + // Run once on mount — auto-connect is a cold-launch concern only. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + React.useEffect(() => { setIsMobile(true); }, [setIsMobile]); React.useEffect(() => { void initializeApp(); - }, [initializeApp]); + }, [connectionEpoch, initializeApp]); React.useEffect(() => { if (!isConnected) return; @@ -1187,21 +2092,116 @@ export function MobileApp({ apis }: MobileAppProps) { return () => window.clearTimeout(timeout); }, [clearError, error]); + React.useEffect(() => { + if (!isNativeMobileApp || isConnected || !getRuntimeApiBaseUrl()) { + setShowConnectionRecovery(false); + return; + } + const timeout = window.setTimeout(() => setShowConnectionRecovery(true), 8000); + return () => window.clearTimeout(timeout); + }, [isConnected, isNativeMobileApp, connectionEpoch, runtimeEndpointEpoch]); + useAppFontEffects(); usePushVisibilityBeacon({ enabled: true }); useUpdatePolling(); useWindowTitle(); useRouter(); + // APNs is the only notification channel on the native app (background-capable, + // focus-suppressed server-side via the visibility beacon). Local notifications are + // intentionally disabled — they can't tell foreground from background in a WKWebView + // (document.hasFocus() is unreliable) and leaked while the app was open; the in-app SSE + // notification dispatch is no-op'd for native in renderMobileApp. + useNativePushRegistration({ enabled: isNativeMobileApp && isConnected }); + // Single native deep-link entry point: notification taps AND the openchamber:// URL + // scheme (widgets, Live Activities, external links). Registered unconditionally so a + // cold-launch tap/open isn't lost on the connect/splash screen; intents stash until + // the app is ready (connected + initialized) and shell handlers are registered. + useDeepLinkSource({ ready: isNativeMobileApp && isConnected && isInitialized }); + const fontsReady = useFontsReady(); + + // `isConnected` is a LIVE flag that flips false on every transient SSE/WS drop and + // back true on reconnect. We must NOT blank the whole app to a loader on those — + // only on the initial connect / instance switch (connectionPhase 'connecting'). + // While 'reconnecting' (we were connected before), keep MobileShell mounted so the + // UI doesn't reload on every network blip. + const isReconnecting = !isConnected && connectionPhase === 'reconnecting'; + + // Hold a logo splash until the UI web font is loaded, so the first UI the user sees + // already uses the real font instead of flashing the fallback and reflowing (FOUT). + if (!fontsReady) { + return ( +
+ +
+ ); + } + + if (!isConnected && !isReconnecting && isNativeMobileApp) { + // A runtime endpoint is already selected (first connect or switching instances): + // show a loader while it re-bootstraps instead of flashing the onboarding screen. + if (getRuntimeApiBaseUrl()) { + return ( +
+
+ + {showConnectionRecovery ? ( + <> +
+

{t('sessionAuth.error.networkTitle')}

+

{t('sessionAuth.error.networkDescription')}

+
+ + + ) : null} +
+
+ ); + } + // Cold-launch auto-connect is still resolving — hold the splash instead of + // flashing the connect screen. Only show the connect screen once we've finished + // (no saved instance, unreachable, or needs re-login). + if (autoConnectPhase !== 'done') { + return ( +
+ +
+ ); + } + return setConnectionEpoch((value) => value + 1)} />; + } + + if (!isConnected && !isReconnecting) { + return ( +
+
+

{t('sessionAuth.error.networkTitle')}

+

{t('sessionAuth.error.networkDescription')}

+
+
+ ); + } return ( - +
- + { + switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' }); + setConnectionEpoch((value) => value + 1); + }} /> {isInitialized ? : null}
diff --git a/packages/ui/src/apps/MobileSurfaceShell.tsx b/packages/ui/src/apps/MobileSurfaceShell.tsx index 5ba246e5..ad9f4af1 100644 --- a/packages/ui/src/apps/MobileSurfaceShell.tsx +++ b/packages/ui/src/apps/MobileSurfaceShell.tsx @@ -67,6 +67,15 @@ export const MobileSurfaceShell: React.FC = ({ const isDraggingRef = React.useRef(false); const surfaceRef = React.useRef(null); const previousFocusRef = React.useRef(null); + // Keep onClose in a ref so the focus/keydown effect below depends only on `open`. + // The parent passes a fresh inline onClose on every render; if the effect depended + // on it, each parent re-render (e.g. an SSE store update) would re-run it and + // refocus the first element — stealing focus from whatever input the user is in + // and collapsing the keyboard mid-edit. + const onCloseRef = React.useRef(onClose); + React.useEffect(() => { + onCloseRef.current = onClose; + }, [onClose]); if (typeof document !== 'undefined' && !rootRef.current) { rootRef.current = ensureSurfaceRoot(); @@ -112,7 +121,7 @@ export const MobileSurfaceShell: React.FC = ({ const focusTimer = window.setTimeout(focusFirstElement, ENTER_DELAY_MS); const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { - onClose(); + onCloseRef.current(); return; } if (event.key !== 'Tab') return; @@ -145,7 +154,7 @@ export const MobileSurfaceShell: React.FC = ({ previousFocusRef.current?.focus?.({ preventScroll: true }); previousFocusRef.current = null; }; - }, [onClose, open]); + }, [open]); const handleDragStart = (event: React.TouchEvent) => { if (disableSwipeDismiss) return; @@ -208,7 +217,7 @@ export const MobileSurfaceShell: React.FC = ({ return createPortal(
void) | null = null; +let resolved = false; + +export const appBootReadyPromise = new Promise((resolve) => { + resolveBoot = resolve; +}); + +export function markAppBootReady(): void { + if (resolved) return; + resolved = true; + resolveBoot?.(); +} diff --git a/packages/ui/src/apps/deepLinkNavigation.ts b/packages/ui/src/apps/deepLinkNavigation.ts new file mode 100644 index 00000000..cff08359 --- /dev/null +++ b/packages/ui/src/apps/deepLinkNavigation.ts @@ -0,0 +1,198 @@ +import React from 'react'; + +import { isCapacitorApp } from '@/lib/platform'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useUIStore } from '@/stores/useUIStore'; + +import { buildDeepLink, parseDeepLink, type DeepLinkIntent, type SessionsFilter, type ViewTarget } from './deepLinks'; + +/** + * Navigation layer for {@link DeepLinkIntent}s — the only place that knows how to *apply* a + * deep link. Producers (notification taps, widget `widgetURL`, Live Activities) feed intents + * in via {@link useDeepLinkSource}; the surfaces that can satisfy them register imperative + * handlers via {@link useDeepLinkHandlers}. Session/new-session navigation goes straight to + * the session store (always available), so those resolve even before the shell has mounted. + * + * Intents that arrive before the app is ready (cold launch from a tap/widget) or before their + * handler is registered are stashed in a module-level holder that survives the connect flow + * and SyncProvider remount, then applied as soon as the app becomes ready / the handler + * appears. Only the most recent intent is kept (newest wins) — a burst of taps shouldn't queue. + */ + +export interface DeepLinkHandlers { + /** Open the sessions sheet, optionally pre-filtered (filter support is best-effort for now). */ + openSessions?: (filter?: SessionsFilter) => void; + /** Open a non-session surface (files / mcp / instances / update). */ + openView?: (target: ViewTarget) => void; + /** Open the Changes surface, optionally jumping straight to a file diff. */ + openChanges?: (options?: { path?: string; staged?: boolean }) => void; + /** Open Settings, optionally at a specific section. */ + openSettings?: (section?: string) => void; +} + +let handlers: DeepLinkHandlers = {}; +let ready = false; +let pending: DeepLinkIntent | null = null; + +const execute = (intent: DeepLinkIntent): boolean => { + switch (intent.type) { + case 'session': + void useSessionUIStore.getState().setCurrentSession(intent.sessionId, intent.directory ?? null); + return true; + + case 'new-session': { + const store = useSessionUIStore.getState(); + store.openNewSessionDraft(); + if (intent.directory || intent.projectId) { + store.setNewSessionDraftTarget({ + directoryOverride: intent.directory ?? null, + projectId: intent.projectId ?? null, + selectedProjectId: intent.projectId ?? null, + }); + } + return true; + } + + case 'sessions': + if (!handlers.openSessions) return false; + handlers.openSessions(intent.filter); + return true; + + case 'status': + // The session status panel is store-backed (useUIStore.mobileSessionPanelOpen), + // so it opens without a shell handler — like session/new-session. + useUIStore.getState().setMobileSessionPanelOpen(true); + return true; + + case 'view': + if (!handlers.openView) return false; + handlers.openView(intent.target); + return true; + + case 'changes': + if (!handlers.openChanges) return false; + handlers.openChanges({ path: intent.path, staged: intent.staged }); + return true; + + case 'settings': + if (!handlers.openSettings) return false; + handlers.openSettings(intent.section); + return true; + } +}; + +const flush = (): void => { + if (!ready || !pending) return; + const intent = pending; + // Drop the stash before executing; if the handler isn't registered yet, execute() returns + // false and we re-stash so a later registerDeepLinkHandlers() flush can retry it. + pending = null; + if (!execute(intent)) { + pending = intent; + } +}; + +/** Apply an intent now if possible, otherwise stash it until the app is ready / a handler appears. */ +export const applyDeepLinkIntent = (intent: DeepLinkIntent): void => { + pending = intent; + flush(); +}; + +/** Convenience: parse a raw `openchamber://…` URL and apply it. No-op for unrecognised URLs. */ +export const applyDeepLinkUrl = (raw: string | null | undefined): void => { + const intent = parseDeepLink(raw); + if (intent) { + applyDeepLinkIntent(intent); + } +}; + +const setReady = (value: boolean): void => { + ready = value; + flush(); +}; + +/** + * Register the surfaces that can satisfy shell-scoped intents (sessions/settings/views/changes). + * Call from the component that owns those panels; the handlers are torn down on unmount. + * Registering also flushes any pending intent that was waiting for these handlers. + */ +export const useDeepLinkHandlers = (next: DeepLinkHandlers): void => { + React.useEffect(() => { + handlers = next; + flush(); + return () => { + if (handlers === next) { + handlers = {}; + } + }; + }, [next]); +}; + +/** + * Single native entry point for deep links. Subscribes to both the custom URL scheme + * (`App.appUrlOpen` — widgets, Live Activities, external links) and notification taps + * (`pushNotificationActionPerformed`), normalising each into a {@link DeepLinkIntent}. + * Both listeners are registered UNCONDITIONALLY so a cold-launch tap/open isn't lost while + * the app is still connecting; intents stash until `ready` (connected + initialized). + */ +export const useDeepLinkSource = (options: { ready: boolean }): void => { + const { ready: isReady } = options; + + React.useEffect(() => { + setReady(isReady); + }, [isReady]); + + React.useEffect(() => { + if (!isCapacitorApp()) return; + let disposed = false; + const cleanup: Array<() => void> = []; + + void import('@capacitor/app') + .then(async ({ App }) => { + if (disposed) return; + const handle = await App.addListener('appUrlOpen', (event) => { + applyDeepLinkUrl(event?.url); + }); + if (disposed) { + void handle.remove(); + return; + } + cleanup.push(() => void handle.remove()); + }) + .catch(() => undefined); + + void import('@capacitor/push-notifications') + .then(async ({ PushNotifications }) => { + if (disposed) return; + const handle = await PushNotifications.addListener('pushNotificationActionPerformed', (action) => { + const data = action?.notification?.data as Record | undefined; + // Prefer an explicit deep link in the payload (richest); fall back to a bare + // sessionId for backwards compatibility with existing push senders. + const url = typeof data?.url === 'string' ? data.url : typeof data?.deeplink === 'string' ? data.deeplink : undefined; + if (url) { + applyDeepLinkUrl(url); + return; + } + const sessionId = typeof data?.sessionId === 'string' ? data.sessionId : undefined; + if (sessionId) { + applyDeepLinkIntent({ type: 'session', sessionId }); + } + }); + if (disposed) { + void handle.remove(); + return; + } + cleanup.push(() => void handle.remove()); + }) + .catch(() => undefined); + + return () => { + disposed = true; + cleanup.forEach((remove) => remove()); + }; + }, []); +}; + +// Re-export so producers (notifications, future widgets) have one import for the whole vocabulary. +export { buildDeepLink, parseDeepLink }; +export type { DeepLinkIntent, SessionsFilter, ViewTarget }; diff --git a/packages/ui/src/apps/deepLinks.ts b/packages/ui/src/apps/deepLinks.ts new file mode 100644 index 00000000..f4c3f213 --- /dev/null +++ b/packages/ui/src/apps/deepLinks.ts @@ -0,0 +1,169 @@ +/** + * OpenChamber deep-link vocabulary — the single source of truth for the `openchamber://` + * URL scheme used across every native entry point: notification taps, home-screen / lock- + * screen widgets, and (later) Live Activities. Anything that wants to drive navigation + * builds a URL with {@link buildDeepLink} and anything that receives one parses it with + * {@link parseDeepLink} into a typed {@link DeepLinkIntent}; the navigation layer + * (deepLinkNavigation) is the only place that knows how to *apply* an intent. + * + * Keep this file pure (no React, no stores, no Capacitor) so it can be imported from any + * context — including, eventually, a tiny encoder shared with the native widget/extension. + */ + +export const DEEP_LINK_SCHEME = 'openchamber'; + +export type SessionsFilter = 'all' | 'attention' | 'recent'; +export type ViewTarget = 'files' | 'mcp' | 'instances' | 'update'; + +/** + * Every navigable destination the app exposes to the outside world. New widget/notification + * ideas should add a variant here first, then teach deepLinkNavigation how to apply it — + * that keeps the "blocks" composable without leaking ad-hoc URL parsing into features. + */ +export type DeepLinkIntent = + | { type: 'session'; sessionId: string; directory?: string } + | { type: 'new-session'; directory?: string; projectId?: string; agent?: string; model?: string } + | { type: 'sessions'; filter?: SessionsFilter } + | { type: 'status' } + | { type: 'settings'; section?: string } + | { type: 'changes'; path?: string; staged?: boolean } + | { type: 'view'; target: ViewTarget }; + +const trimSlashes = (value: string): string => value.replace(/^\/+|\/+$/g, ''); + +const segmentsOf = (url: URL): string[] => { + // Custom-scheme URLs put the first route token in `host` (openchamber://session/), + // but be tolerant of authority-less forms (openchamber:/session/) where it lands in + // the pathname instead. + const pathSegments = trimSlashes(url.pathname).split('/').filter(Boolean); + if (url.host) { + return [url.host, ...pathSegments]; + } + return pathSegments; +}; + +/** + * Parse a raw `openchamber://…` string into a typed intent, or `null` if it isn't a + * recognised OpenChamber deep link. Tolerant by design: unknown routes return `null` + * rather than throwing, so callers can fall back without a try/catch. + */ +export function parseDeepLink(raw: string | null | undefined): DeepLinkIntent | null { + if (typeof raw !== 'string' || raw.length === 0) { + return null; + } + + let url: URL; + try { + url = new URL(raw); + } catch { + return null; + } + + if (url.protocol !== `${DEEP_LINK_SCHEME}:`) { + return null; + } + + const segments = segmentsOf(url); + const route = (segments[0] ?? '').toLowerCase(); + const rest = segments.slice(1); + const query = url.searchParams; + + switch (route) { + case 'session': { + const sessionId = rest[0] || query.get('id') || ''; + if (!sessionId) { + return null; + } + return { type: 'session', sessionId, directory: query.get('dir') ?? undefined }; + } + + case 'new': + case 'new-session': + return { + type: 'new-session', + directory: query.get('dir') ?? undefined, + projectId: query.get('project') ?? undefined, + agent: query.get('agent') ?? undefined, + model: query.get('model') ?? undefined, + }; + + case 'sessions': { + const filter = query.get('filter'); + return { + type: 'sessions', + filter: filter === 'attention' || filter === 'recent' || filter === 'all' ? filter : undefined, + }; + } + + case 'status': + return { type: 'status' }; + + case 'settings': + return { type: 'settings', section: rest[0] || query.get('section') || undefined }; + + case 'changes': + return { + type: 'changes', + path: rest.join('/') || query.get('path') || undefined, + staged: query.get('staged') === 'true', + }; + + case 'view': { + const target = (rest[0] || '').toLowerCase(); + // `changes` has its own richer intent (diff path); route the bare view token to it. + if (target === 'changes') { + return { type: 'changes' }; + } + if (target === 'files' || target === 'mcp' || target === 'instances' || target === 'update') { + return { type: 'view', target }; + } + return null; + } + + default: + return null; + } +} + +/** + * Build a canonical `openchamber://…` URL for an intent. Used by anything that needs to hand + * a deep link to iOS — notification payloads, `widgetURL(...)`, Live Activity tap targets — + * so every producer emits the exact shape {@link parseDeepLink} understands. + */ +export function buildDeepLink(intent: DeepLinkIntent): string { + const base = `${DEEP_LINK_SCHEME}://`; + const withQuery = (path: string, params: Record): string => { + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (typeof value === 'string' && value.length > 0) { + search.set(key, value); + } + } + const query = search.toString(); + return query ? `${base}${path}?${query}` : `${base}${path}`; + }; + + switch (intent.type) { + case 'session': + return withQuery(`session/${encodeURIComponent(intent.sessionId)}`, { dir: intent.directory }); + case 'new-session': + return withQuery('new', { + dir: intent.directory, + project: intent.projectId, + agent: intent.agent, + model: intent.model, + }); + case 'sessions': + return withQuery('sessions', { filter: intent.filter }); + case 'status': + return `${base}status`; + case 'settings': + return intent.section ? `${base}settings/${encodeURIComponent(intent.section)}` : `${base}settings`; + case 'changes': + return withQuery(intent.path ? `changes/${intent.path}` : 'changes', { + staged: intent.staged ? 'true' : undefined, + }); + case 'view': + return `${base}view/${intent.target}`; + } +} diff --git a/packages/ui/src/apps/mobileConnections.ts b/packages/ui/src/apps/mobileConnections.ts new file mode 100644 index 00000000..35a748af --- /dev/null +++ b/packages/ui/src/apps/mobileConnections.ts @@ -0,0 +1,680 @@ +// Saved-connection storage + the shared connect/unlock flow for the dedicated +// mobile app. Both the onboarding welcome screen and the Instances sheet drive +// connections through `useMobileConnection` so the health-check + progressive +// password unlock + client-token issuance + runtime switch all behave identically. +// +// Persistence model (deliberately simple so it is correct-by-inspection): +// - Instance *metadata* (id/label/url/lastUsedAt + a `hasToken` flag) lives in +// localStorage. On native it NEVER contains the client token. +// - The client token lives in the OS secure store (iOS Keychain / Android +// Keystore) via @aparajita/capacitor-secure-storage, keyed per instance URL. +// - On web (browser-hosted mobile.html) there is no secure store, so the token +// stays inline in localStorage — that surface is not the native security target. +// +// Token writes are AWAITED before we switch the runtime endpoint, so a successful +// unlock guarantees the token is actually persisted (no fire-and-forget). + +import { SecureStorage } from '@aparajita/capacitor-secure-storage'; +import React from 'react'; + +import { useI18n } from '@/lib/i18n'; +import { isCapacitorApp } from '@/lib/platform'; +import { switchRuntimeEndpoint } from '@/lib/runtime-switch'; + +const MOBILE_CONNECTIONS_STORAGE_KEY = 'openchamber.mobile.connections.v1'; +const MOBILE_SECURE_STORAGE_PREFIX = 'openchamber.mobile.'; +const MOBILE_CONNECTIONS_LIMIT = 12; +const MOBILE_CONNECT_TIMEOUT_MS = 8000; +const MOBILE_NATIVE_HTTP_TIMEOUT_MS = 2500; +const MOBILE_SECURE_TIMEOUT_MS = 3000; + +export type MobileSavedConnection = { + id: string; + label: string; + url: string; + lastUsedAt: number; + // Native: indicates a token exists in the secure store. Web: unused. + hasToken?: boolean; + // Web only: the token stored inline. On native this stays undefined in the list. + clientToken?: string; +}; + +export type MobilePendingConnection = { + label: string; + url: string; +}; + +export type MobileConnectInput = { + url: string; + clientToken?: string; + label?: string; +}; + +type MobileFetchResponse = { + ok: boolean; + status: number; + source: 'native-http' | 'browser-fetch'; + json: () => Promise; +}; + +type MobileSessionStatus = { + authenticated?: boolean; + disabled?: boolean; + scope?: string; +}; + +// --------------------------------------------------------------------------- +// URL helpers +// --------------------------------------------------------------------------- + +export const normalizeConnectionUrl = (value: string): string => { + const trimmed = value.trim(); + if (!trimmed) return ''; + const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`; + const url = new URL(withScheme); + url.hash = ''; + url.search = ''; + url.pathname = url.pathname.replace(/\/+$/, ''); + return url.toString().replace(/\/+$/, ''); +}; + +export const getConnectionLabel = (url: string): string => { + try { + return new URL(url).host; + } catch { + return url; + } +}; + +const getConnectionStorageKey = (url: string): string => { + try { + return normalizeConnectionUrl(url); + } catch { + return url.trim().replace(/\/+$/g, ''); + } +}; + +export const isSameConnectionUrl = (left: string, right: string): boolean => + getConnectionStorageKey(left) === getConnectionStorageKey(right); + +// --------------------------------------------------------------------------- +// Request helpers (native CapacitorHttp first — needed to reach plain-http LAN +// servers the secure webview cannot fetch — then a browser-fetch fallback). +// --------------------------------------------------------------------------- + +const logConnect = (step: string, detail: Record = {}): void => { + console.info('[mobile-connect]', step, detail); +}; + +const logStorage = (step: string, detail: Record = {}): void => { + console.info('[mobile-storage]', step, detail); +}; + +const parseMaybeJson = (value: unknown): unknown => { + if (typeof value !== 'string') return value; + try { + return JSON.parse(value) as unknown; + } catch { + return value; + } +}; + +const getJsonRequestData = (body: BodyInit | null | undefined): unknown => { + if (typeof body !== 'string') return body ?? undefined; + try { + return JSON.parse(body) as unknown; + } catch { + return body; + } +}; + +const nativeHttpRequest = async (url: string, init?: RequestInit): Promise => { + if (!isCapacitorApp()) return null; + try { + const { CapacitorHttp } = await import('@capacitor/core'); + const headers = Object.fromEntries(new Headers(init?.headers).entries()); + const response = await CapacitorHttp.request({ + url, + method: init?.method || 'GET', + headers, + data: getJsonRequestData(init?.body), + }); + return { + ok: response.status >= 200 && response.status < 300, + status: response.status, + source: 'native-http', + json: async () => parseMaybeJson(response.data), + }; + } catch (error) { + console.warn('[mobile-connect] native-http failed', { url, error }); + return null; + } +}; + +const browserFetchRequest = async (url: string, init?: RequestInit): Promise => { + const response = await fetch(url, init).catch((error) => { + console.warn('[mobile-connect] browser-fetch failed', { url, error }); + return null; + }); + if (!response) return null; + return { ok: response.ok, status: response.status, source: 'browser-fetch', json: () => response.json() }; +}; + +const raceWithTimeout = async (timeoutMs: number, operation: Promise, onTimeout?: () => void): Promise => { + let timeoutId: number | undefined; + const timeout = new Promise((resolve) => { + timeoutId = window.setTimeout(() => { + onTimeout?.(); + resolve(null); + }, timeoutMs); + }); + try { + return await Promise.race([operation, timeout]); + } catch { + return null; + } finally { + if (timeoutId !== undefined) window.clearTimeout(timeoutId); + } +}; + +const requestWithTimeout = async (url: string, init?: RequestInit): Promise => { + const startedAt = Date.now(); + const native = await raceWithTimeout( + Math.min(MOBILE_NATIVE_HTTP_TIMEOUT_MS, MOBILE_CONNECT_TIMEOUT_MS), + nativeHttpRequest(url, init), + ); + if (native) return native; + + const controller = new AbortController(); + const remainingMs = Math.max(1000, MOBILE_CONNECT_TIMEOUT_MS - (Date.now() - startedAt)); + return raceWithTimeout( + remainingMs, + browserFetchRequest(url, { ...init, signal: controller.signal }), + () => controller.abort(), + ); +}; + +const readSessionStatus = async (response: MobileFetchResponse | null): Promise => { + if (!response) return null; + const payload = await response.json().catch(() => null); + if (!payload || typeof payload !== 'object') return null; + const record = payload as Record; + return { + authenticated: typeof record.authenticated === 'boolean' ? record.authenticated : undefined, + disabled: typeof record.disabled === 'boolean' ? record.disabled : undefined, + scope: typeof record.scope === 'string' ? record.scope : undefined, + }; +}; + +// --------------------------------------------------------------------------- +// Metadata storage (localStorage) — never holds the token on native. +// --------------------------------------------------------------------------- + +const readConnections = (): MobileSavedConnection[] => { + if (typeof window === 'undefined') return []; + let parsed: unknown; + try { + parsed = JSON.parse(window.localStorage.getItem(MOBILE_CONNECTIONS_STORAGE_KEY) || '[]'); + } catch { + return []; + } + if (!Array.isArray(parsed)) return []; + const native = isCapacitorApp(); + return parsed + .flatMap((item): MobileSavedConnection[] => { + if (!item || typeof item !== 'object') return []; + const c = item as Partial; + if (typeof c.id !== 'string' || typeof c.url !== 'string') return []; + const inlineToken = typeof c.clientToken === 'string' && c.clientToken.trim() ? c.clientToken : undefined; + const base: MobileSavedConnection = { + id: c.id, + label: typeof c.label === 'string' && c.label.trim() ? c.label : getConnectionLabel(c.url), + url: c.url, + lastUsedAt: typeof c.lastUsedAt === 'number' ? c.lastUsedAt : 0, + }; + if (native) return [{ ...base, hasToken: Boolean(c.hasToken) || Boolean(inlineToken) }]; + return [{ ...base, clientToken: inlineToken, hasToken: Boolean(inlineToken) }]; + }) + .sort((a, b) => b.lastUsedAt - a.lastUsedAt); +}; + +const writeConnections = (connections: MobileSavedConnection[]): void => { + if (typeof window === 'undefined') return; + const native = isCapacitorApp(); + const serialized = connections.slice(0, MOBILE_CONNECTIONS_LIMIT).map((c) => ( + native + ? { id: c.id, label: c.label, url: c.url, lastUsedAt: c.lastUsedAt, hasToken: Boolean(c.hasToken || c.clientToken) } + : { id: c.id, label: c.label, url: c.url, lastUsedAt: c.lastUsedAt, clientToken: c.clientToken } + )); + try { + window.localStorage.setItem(MOBILE_CONNECTIONS_STORAGE_KEY, JSON.stringify(serialized)); + } catch (error) { + console.warn('[mobile-storage] failed to persist connection metadata', error); + } +}; + +const upsertConnectionInList = ( + connections: MobileSavedConnection[], + draft: { label: string; url: string; clientToken?: string; hasToken?: boolean }, +): MobileSavedConnection[] => { + const key = getConnectionStorageKey(draft.url); + const existing = connections.find((item) => getConnectionStorageKey(item.url) === key); + const native = isCapacitorApp(); + const next: MobileSavedConnection = { + id: existing?.id || crypto.randomUUID(), + label: draft.label, + url: draft.url, + lastUsedAt: Date.now(), + ...(native + ? { hasToken: draft.hasToken ?? (Boolean(draft.clientToken) || existing?.hasToken || false) } + : { clientToken: draft.clientToken ?? existing?.clientToken, hasToken: Boolean(draft.clientToken ?? existing?.clientToken) }), + }; + return [ + next, + ...connections.filter((item) => item.id !== next.id && getConnectionStorageKey(item.url) !== key), + ].slice(0, MOBILE_CONNECTIONS_LIMIT); +}; + +// --------------------------------------------------------------------------- +// Secure token storage (native only), per-instance URL. Every call is bounded +// so a hung/unavailable Keychain can never block the connect flow. +// --------------------------------------------------------------------------- + +// We call the plugin's NATIVE methods (`internalSetItem`/`internalGetItem`/ +// `internalRemoveItem`) directly. Capacitor routes native methods straight to the +// iOS/Android plugin via the bridge — unlike the high-level `setItem`/`setKeyPrefix` +// JS methods, which make the `registerPlugin` proxy lazy-load its platform JS module +// (the step that stalls in this webview). We also build the prefixed key ourselves +// so we never touch the JS-only `setKeyPrefix`. +type NativeSecureStorage = { + internalSetItem: (options: { prefixedKey: string; data: string; sync: boolean; access: number }) => Promise; + internalGetItem: (options: { prefixedKey: string; sync: boolean }) => Promise<{ data: string | null }>; + internalRemoveItem: (options: { prefixedKey: string; sync: boolean }) => Promise<{ success: boolean }>; +}; + +const nativeSecure = SecureStorage as unknown as NativeSecureStorage; +const KEYCHAIN_ACCESS_WHEN_UNLOCKED = 0; // KeychainAccess.whenUnlocked + +const prefixedTokenKey = (url: string): string => + `${MOBILE_SECURE_STORAGE_PREFIX}token.${encodeURIComponent(getConnectionStorageKey(url))}`; + +const withTimeout = async (operation: Promise, fallback: T): Promise => { + let timeoutId: number | undefined; + const timeout = new Promise((resolve) => { + timeoutId = window.setTimeout(() => resolve(fallback), MOBILE_SECURE_TIMEOUT_MS); + }); + try { + return await Promise.race([operation.catch(() => fallback), timeout]); + } finally { + if (timeoutId !== undefined) window.clearTimeout(timeoutId); + } +}; + +// Bound a native Keychain call so a stalled/failed bridge can never hang the flow. +const boundedSecure = async (label: string, run: () => Promise, fallback: T): Promise => { + if (!isCapacitorApp()) return fallback; + return withTimeout( + run().catch((error) => { + console.warn(`[mobile-storage] ${label} failed`, error); + return fallback; + }), + fallback, + ); +}; + +const readSecureToken = async (url: string): Promise => { + logStorage('secure:read-start', { url }); + const value = await boundedSecure( + 'secure:read', + async () => (await nativeSecure.internalGetItem({ prefixedKey: prefixedTokenKey(url), sync: false })).data, + null, + ); + const token = typeof value === 'string' && value.trim() ? value : undefined; + logStorage('secure:read', { url, hasToken: Boolean(token) }); + return token; +}; + +const writeSecureToken = async (url: string, token: string): Promise => { + logStorage('secure:write-start', { url }); + const ok = await boundedSecure('secure:write', async () => { + await nativeSecure.internalSetItem({ + prefixedKey: prefixedTokenKey(url), + data: token, + sync: false, + access: KEYCHAIN_ACCESS_WHEN_UNLOCKED, + }); + return true; + }, false); + logStorage('secure:write', { url, ok }); + return ok; +}; + +const deleteSecureToken = async (url: string): Promise => { + await boundedSecure('secure:delete', async () => { + await nativeSecure.internalRemoveItem({ prefixedKey: prefixedTokenKey(url), sync: false }); + return true; + }, false); +}; + +// --------------------------------------------------------------------------- +// Public storage API +// --------------------------------------------------------------------------- + +// One-time migration: a legacy localStorage record on native might still carry an +// inline `clientToken`. Move it into the secure store and strip the metadata. +const migrateLegacyInlineTokens = async (): Promise => { + if (typeof window === 'undefined' || !isCapacitorApp()) return; + let parsed: unknown; + try { + parsed = JSON.parse(window.localStorage.getItem(MOBILE_CONNECTIONS_STORAGE_KEY) || '[]'); + } catch { + return; + } + if (!Array.isArray(parsed)) return; + const legacy = parsed.filter((item): item is { url: string; clientToken: string } => + Boolean(item) && typeof item === 'object' + && typeof (item as { url?: unknown }).url === 'string' + && typeof (item as { clientToken?: unknown }).clientToken === 'string' + && Boolean((item as { clientToken: string }).clientToken.trim())); + if (legacy.length === 0) return; + logStorage('secure:migrate-start', { count: legacy.length }); + for (const { url, clientToken } of legacy) { + await writeSecureToken(url, clientToken); + } + writeConnections(readConnections()); + logStorage('secure:migrate-done', { count: legacy.length }); +}; + +export const loadMobileConnections = async (): Promise => { + await migrateLegacyInlineTokens(); + return readConnections(); +}; + +export const upsertMobileConnection = async ( + connection: { label: string; url: string; clientToken?: string }, +): Promise => { + const next = upsertConnectionInList(readConnections(), connection); + writeConnections(next); + if (isCapacitorApp() && connection.clientToken) { + await writeSecureToken(connection.url, connection.clientToken); + } + return next; +}; + +export const deleteMobileConnection = async (id: string): Promise => { + const connections = readConnections(); + const removed = connections.find((connection) => connection.id === id) ?? null; + const next = connections.filter((connection) => connection.id !== id); + writeConnections(next); + if (removed && isCapacitorApp()) await deleteSecureToken(removed.url); + return next; +}; + +// Cold-launch auto-connect: silently reconnect to the most-recently-used saved +// instance so a returning user (and notification deep-links) land straight in the +// app instead of the connect screen. Returns true and switches the runtime endpoint +// when the instance is reachable AND we already have a usable bearer token; returns +// false — caller shows the connect screen — when there is no saved instance, it's +// unreachable, or it needs a (re)login. Mirrors the success path of +// `useMobileConnection.connect`, with no prompts or UI state. +export const autoConnectLastInstance = async (): Promise => { + await migrateLegacyInlineTokens(); + const candidate = readConnections()[0]; // sorted most-recent-first + if (!candidate) return false; + + const url = normalizeConnectionUrl(candidate.url); + if (!url) return false; + + // The native runtime transport needs a bearer token; only auto-connect when one is + // already saved. A missing/expired token must go through the login UI, not silently. + let token: string | undefined; + if (isCapacitorApp()) { + if (!candidate.hasToken) return false; + token = await readSecureToken(url); + if (!token) return false; + } else { + token = candidate.clientToken; + } + + const headers = token ? { Authorization: `Bearer ${token}` } : undefined; + + const health = await requestWithTimeout(`${url}/health`, { method: 'GET', headers }); + if (!health?.ok) return false; + + const session = await requestWithTimeout(`${url}/auth/session`, { method: 'GET', credentials: 'include', headers }); + // Token rejected / session invalid → fall back to the login screen. + if (!session || (!session.ok && session.status !== 404)) return false; + const status = await readSessionStatus(session); + if (status && status.disabled !== true && status.authenticated === false) return false; + + await upsertMobileConnection({ label: candidate.label, url }); // bump lastUsedAt (keeps hasToken) + switchRuntimeEndpoint({ apiBaseUrl: url, clientToken: token ?? null }); + return true; +}; + +// --------------------------------------------------------------------------- +// Shared connection controller +// --------------------------------------------------------------------------- + +export type UseMobileConnection = { + connections: MobileSavedConnection[]; + isBusy: boolean; + isPasswordBusy: boolean; + error: string | null; + pendingConnection: MobilePendingConnection | null; + connect: (input: MobileConnectInput) => Promise; + submitPassword: (password: string) => Promise; + cancelPassword: () => void; + saveConnection: (input: MobileConnectInput) => Promise; + removeConnection: (id: string) => Promise; + setError: (message: string | null) => void; +}; + +// `onConnected` fires once the runtime endpoint is switched (the caller navigates +// away / closes its surface from there). +export const useMobileConnection = (onConnected: () => void): UseMobileConnection => { + const { t } = useI18n(); + const [connections, setConnections] = React.useState(() => readConnections()); + const [busyOperation, setBusyOperation] = React.useState<'connect' | 'password' | null>(null); + const [error, setError] = React.useState(null); + const [pendingConnection, setPendingConnection] = React.useState(null); + const connectionsRef = React.useRef(connections); + const busyRef = React.useRef<'connect' | 'password' | null>(null); + + const applyConnections = React.useCallback((next: MobileSavedConnection[]) => { + connectionsRef.current = next; + setConnections(next); + }, []); + + const beginBusy = React.useCallback((operation: 'connect' | 'password') => { + busyRef.current = operation; + setBusyOperation(operation); + }, []); + + const endBusy = React.useCallback((operation: 'connect' | 'password') => { + if (busyRef.current !== operation) return; + busyRef.current = null; + setBusyOperation(null); + }, []); + + // Refresh from storage on mount (runs the legacy-token migration too). + React.useEffect(() => { + let disposed = false; + void loadMobileConnections().then((loaded) => { + if (!disposed) applyConnections(loaded); + }); + return () => { disposed = true; }; + }, [applyConnections]); + + // Persist metadata for a connection and reflect it in state immediately. + const persistMetadata = React.useCallback((draft: { label: string; url: string; clientToken?: string }) => { + const next = upsertConnectionInList(connectionsRef.current, draft); + applyConnections(next); + writeConnections(next); + return next; + }, [applyConnections]); + + const connect = React.useCallback(async (input: MobileConnectInput) => { + setError(null); + beginBusy('connect'); + try { + const url = normalizeConnectionUrl(input.url); + if (!url) { + setError(t('mobile.connect.error.urlRequired')); + return; + } + + const label = input.label?.trim() + || connectionsRef.current.find((c) => isSameConnectionUrl(c.url, url))?.label + || getConnectionLabel(url); + + // Resolve a token: explicit input wins, otherwise read the saved one from + // the secure store (single bounded read — never blocks the flow). + let token = input.clientToken?.trim() || undefined; + const tokenIsNew = Boolean(token); + if (!token && isCapacitorApp()) { + const saved = connectionsRef.current.find((c) => isSameConnectionUrl(c.url, url)); + if (saved?.hasToken) token = await readSecureToken(url); + } + + const headers = token ? { Authorization: `Bearer ${token}` } : undefined; + + logConnect('health:start', { url }); + const health = await requestWithTimeout(`${url}/health`, { method: 'GET', headers }); + logConnect('health:done', { ok: health?.ok === true, source: health?.source ?? null, status: health?.status ?? null }); + if (!health?.ok) { + setError(t('mobile.connect.error.unreachable')); + return; + } + + logConnect('session:start', { url, hasToken: Boolean(token) }); + const session = await requestWithTimeout(`${url}/auth/session`, { method: 'GET', credentials: 'include', headers }); + const status = await readSessionStatus(session); + logConnect('session:done', { ok: session?.ok === true, status: session?.status ?? null, scope: status?.scope ?? null, disabled: status?.disabled === true }); + + // A cookie-only native session (authenticated, but not a `client` bearer + // scope and not auth-disabled) is not enough — the runtime transport needs a + // bearer token, so fall through to the password flow to mint one. + const cookieOnlyNeedsToken = isCapacitorApp() + && session?.ok === true + && !token + && status?.authenticated === true + && status.disabled !== true + && status.scope !== 'client'; + + if (!token && (session?.status === 401 || cookieOnlyNeedsToken)) { + persistMetadata({ label, url }); + setPendingConnection({ label, url }); + return; + } + + if (!session || (!session.ok && session.status !== 404)) { + setError(t('mobile.connect.error.authRequired')); + return; + } + + // Connected. If the token came from the user (not the secure store), persist + // it first so a cold restart won't re-prompt. + if (token && tokenIsNew && isCapacitorApp()) { + await writeSecureToken(url, token); + } + persistMetadata({ label, url, clientToken: token }); + switchRuntimeEndpoint({ apiBaseUrl: url, clientToken: token ?? null }); + onConnected(); + } catch (error) { + console.warn('[mobile-connect] connect threw', error); + setError(t('mobile.connect.error.invalidUrl')); + } finally { + endBusy('connect'); + } + }, [beginBusy, endBusy, onConnected, persistMetadata, t]); + + const submitPassword = React.useCallback(async (password: string) => { + if (!pendingConnection || !password.trim() || busyRef.current === 'password') return; + setError(null); + beginBusy('password'); + const { url, label } = pendingConnection; + try { + logConnect('password:start', { url }); + const response = await requestWithTimeout(`${url}/auth/session`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ password, trustDevice: true, issueClientToken: true, clientLabel: 'OpenChamber Mobile' }), + }); + logConnect('password:done', { ok: response?.ok === true, status: response?.status ?? null }); + if (!response?.ok) { + setError(t('mobile.connect.error.passwordFailed')); + return; + } + + const payload = await response.json().catch(() => null) as { clientToken?: unknown } | null; + const issuedToken = typeof payload?.clientToken === 'string' ? payload.clientToken.trim() : ''; + logConnect('password:token', { issued: Boolean(issuedToken) }); + + // Native runtime transport needs a bearer token; a cookie-only success is + // not acceptable for a saved protected instance. + if (isCapacitorApp() && !issuedToken) { + setError(t('mobile.connect.error.authRequired')); + return; + } + + // Guarantee the token is persisted BEFORE switching (no fire-and-forget). + if (isCapacitorApp() && issuedToken) { + await writeSecureToken(url, issuedToken); + } + persistMetadata({ label, url, clientToken: issuedToken || undefined }); + setPendingConnection(null); + switchRuntimeEndpoint({ apiBaseUrl: url, clientToken: issuedToken || null }); + onConnected(); + } catch (error) { + console.warn('[mobile-connect] password threw', error); + setError(t('mobile.connect.error.passwordFailed')); + } finally { + endBusy('password'); + } + }, [beginBusy, endBusy, onConnected, pendingConnection, persistMetadata, t]); + + const cancelPassword = React.useCallback(() => { + setPendingConnection(null); + setError(null); + }, []); + + const saveConnection = React.useCallback(async (input: MobileConnectInput): Promise => { + setError(null); + const url = normalizeConnectionUrl(input.url); + if (!url) { + setError(t('mobile.connect.error.urlRequired')); + return null; + } + const clientToken = input.clientToken?.trim() || undefined; + const label = input.label?.trim() || getConnectionLabel(url); + // Awaited token write so "Save" truly persisted the secret before returning. + if (isCapacitorApp() && clientToken) { + await writeSecureToken(url, clientToken); + } + const next = persistMetadata({ label, url, clientToken }); + return next.find((connection) => isSameConnectionUrl(connection.url, url)) ?? null; + }, [persistMetadata, t]); + + const removeConnection = React.useCallback(async (id: string): Promise => { + const removed = connectionsRef.current.find((connection) => connection.id === id) ?? null; + const next = await deleteMobileConnection(id); + applyConnections(next); + return removed; + }, [applyConnections]); + + return { + connections, + isBusy: busyOperation !== null, + isPasswordBusy: busyOperation === 'password', + error, + pendingConnection, + connect, + submitPassword, + cancelPassword, + saveConnection, + removeConnection, + setError, + }; +}; diff --git a/packages/ui/src/apps/mobileQrScan.ts b/packages/ui/src/apps/mobileQrScan.ts new file mode 100644 index 00000000..7fd77b1a --- /dev/null +++ b/packages/ui/src/apps/mobileQrScan.ts @@ -0,0 +1,176 @@ +// Connection payload parsing + native QR scanning for the dedicated mobile app. +// +// The pairing link format is produced by `openchamber connect-url --qr`: +// openchamber://connect?v=1&server=&token=&label=
- {nativeNotificationsEnabled && canShowNotifications && ( + {/* The native Capacitor app never notifies while focused (hard rule) and uses + generic, non-customizable text, so the "notify while focused" toggle and the + test button are hidden there. */} + {nativeNotificationsEnabled && canShowNotifications && !isNativeApp && ( <>
{
- {/* --- Template Customization --- */} + {/* --- Template Customization (not on the native app — it uses generic text) --- */} + {!isNativeApp && (

@@ -666,6 +680,7 @@ export const NotificationSettings: React.FC = () => { ))}

+ )} )} diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx index 509a7ca3..f25a821c 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx @@ -7,6 +7,7 @@ import type { ThemeMode } from '@/types/theme'; import { useUIStore } from '@/stores/useUIStore'; import { useMessageQueueStore, type FollowUpBehavior } from '@/stores/messageQueueStore'; import { cn } from '@/lib/utils'; +import { isCapacitorApp } from '@/lib/platform'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { NumberInput } from '@/components/ui/number-input'; @@ -321,6 +322,10 @@ export const OpenChamberVisualSettings: React.FC const setShowSplitAssistantMessageActions = useUIStore(state => state.setShowSplitAssistantMessageActions); const messageStreamTransport = useConfigStore((state) => state.settingsMessageStreamTransport); const setMessageStreamTransport = useConfigStore((state) => state.setSettingsMessageStreamTransport); + // Capacitor apps are locked to SSE (native WebSocket streaming is unreliable on mobile); + // sync-context forces it too. Show SSE selected and disable the other options here. + const isCapacitorAppRuntime = React.useMemo(() => isCapacitorApp(), []); + const effectiveMessageStreamTransport = isCapacitorAppRuntime ? 'sse' : messageStreamTransport; const settingsDefaultFileViewerPreview = useConfigStore((state) => state.settingsDefaultFileViewerPreview); const setSettingsDefaultFileViewerPreview = useConfigStore((state) => state.setSettingsDefaultFileViewerPreview); const isSettingsDialogOpen = useUIStore(state => state.isSettingsDialogOpen); @@ -1525,7 +1530,8 @@ export const OpenChamberVisualSettings: React.FC key={option.id} variant="chip" size="xs" - aria-pressed={messageStreamTransport === option.id} + aria-pressed={effectiveMessageStreamTransport === option.id} + disabled={isCapacitorAppRuntime && option.id !== 'sse'} className="!font-normal" onClick={() => handleMessageStreamTransportChange(option.id)} > @@ -1535,7 +1541,7 @@ export const OpenChamberVisualSettings: React.FC
{(() => { - const option = MESSAGE_STREAM_TRANSPORT_OPTIONS.find((item) => item.id === messageStreamTransport); + const option = MESSAGE_STREAM_TRANSPORT_OPTIONS.find((item) => item.id === effectiveMessageStreamTransport); return option?.descriptionKey ? tUnsafe(option.descriptionKey) : ''; })()} diff --git a/packages/ui/src/components/ui/MobileOverlayPanel.tsx b/packages/ui/src/components/ui/MobileOverlayPanel.tsx index acf21d3b..156fe911 100644 --- a/packages/ui/src/components/ui/MobileOverlayPanel.tsx +++ b/packages/ui/src/components/ui/MobileOverlayPanel.tsx @@ -85,7 +85,7 @@ export const MobileOverlayPanel: React.FC = ({ const content = (
{ }; const sendVisibility = (visible: boolean) => { - if (!isWebRuntime()) { + if (!isWebRuntime() && !isCapacitorApp()) { return; } @@ -19,13 +20,62 @@ const sendVisibility = (visible: boolean) => { return; } - void apis.push.setVisibility({ visible }); + // platform lets the server distinguish mobile (push recipients) from interactive surfaces + // (desktop/web/vscode) so it can suppress phone push only while an interactive client is visible. + void apis.push.setVisibility({ visible, platform: getClientPlatform() }); }; export const usePushVisibilityBeacon = (options?: { enabled?: boolean }) => { const enabled = options?.enabled ?? true; React.useEffect(() => { - if (!enabled || !isWebRuntime() || typeof document === 'undefined') { + if (!enabled || (!isWebRuntime() && !isCapacitorApp()) || typeof window === 'undefined') { + return; + } + + // Native (Capacitor): drive visibility AUTHORITATIVELY from App.appStateChange. The + // web signals (document.visibilityState / hasFocus) are unreliable in a WKWebView — + // hasFocus() often returns false while the app is active — which made the app report + // "hidden" while foregrounded and leaked push notifications. The server's focus gate + // suppresses push whenever a UI client is visible, so getting this right is what + // guarantees "no push while the app is active". + if (isCapacitorApp()) { + let active = true; + let disposed = false; + let removeListener: (() => void) | null = null; + const reportActive = () => sendVisibility(active); + + void import('@capacitor/app') + .then(async ({ App }) => { + if (disposed) return; + const state = await App.getState().catch(() => null); + if (state) active = state.isActive === true; + reportActive(); + const handle = await App.addListener('appStateChange', ({ isActive }) => { + active = isActive === true; + reportActive(); + }); + if (disposed) { + void handle.remove(); + return; + } + removeListener = () => void handle.remove(); + }) + .catch(() => undefined); + + // Heartbeat so the server's visibility TTL never expires while the app is active. + const interval = window.setInterval(() => { + if (active) sendVisibility(true); + }, HEARTBEAT_MS); + + return () => { + disposed = true; + window.clearInterval(interval); + removeListener?.(); + }; + } + + // Web / desktop: document-based visibility. + if (typeof document === 'undefined') { return; } @@ -45,7 +95,6 @@ export const usePushVisibilityBeacon = (options?: { enabled?: boolean }) => { report(); - // Heartbeat while visible so server TTL (30s) never expires. const interval = window.setInterval(reportVisibleOnly, HEARTBEAT_MS); document.addEventListener('visibilitychange', report); diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index d3539c29..f2e3f3c4 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -759,17 +759,28 @@ export interface PushSubscribePayload { auth: string; }; origin?: string; + /** Runtime surface ('ios' | 'android' | 'vscode' | 'desktop' | 'web') for presence-aware routing. */ + platform?: string; } export interface PushUnsubscribePayload { endpoint: string; } +export interface ApnsTokenPayload { + token: string; + /** 'ios' (APNs) or 'android' (FCM) — lets the relay route the token to the right service. */ + platform?: string; +} + export interface PushAPI { getVapidPublicKey(): Promise<{ publicKey: string } | null>; subscribe(payload: PushSubscribePayload): Promise<{ ok: true } | null>; unsubscribe(payload: PushUnsubscribePayload): Promise<{ ok: true } | null>; - setVisibility(payload: { visible: boolean }): Promise<{ ok: true } | null>; + setVisibility(payload: { visible: boolean; platform?: string }): Promise<{ ok: true } | null>; + /** Register a native iOS APNs device token (Capacitor mobile app only). */ + registerApnsToken(payload: ApnsTokenPayload): Promise<{ ok: true } | null>; + unregisterApnsToken(payload: ApnsTokenPayload): Promise<{ ok: true } | null>; } export type GitHubUserSummary = { diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index f5dcb6d5..18375b2b 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -30,6 +30,44 @@ export const dict = { 'layout.mainTab.terminal': 'Terminal', 'layout.mainTab.context': 'Context', 'mobile.nav.aria': 'Mobile navigation', + 'mobile.connect.welcome.title': 'Connect to OpenChamber', + 'mobile.connect.welcome.description': 'Add a server URL or scan a pairing QR code to start using the mobile app.', + 'mobile.connect.url.label': 'Server URL', + 'mobile.connect.url.placeholder': 'http://192.168.1.74:2606', + 'mobile.connect.token.label': 'Client token', + 'mobile.connect.token.placeholder': 'Paste access token', + 'mobile.connect.token.hint': 'Only needed if your server requires a token instead of a password.', + 'mobile.connect.password.label': 'Password', + 'mobile.connect.password.placeholder': 'OpenChamber password', + 'mobile.connect.connectButton': 'Connect', + 'mobile.connect.unlockButton': 'Unlock and connect', + 'mobile.connect.cancelPassword': 'Use another server', + 'mobile.connect.connecting': 'Connecting...', + 'mobile.connect.scanQr': 'Scan QR code', + 'mobile.connect.advanced': 'Advanced', + 'mobile.connect.scan.permissionDenied': 'Camera access is off. Enable it in Settings to scan a QR code.', + 'mobile.connect.scan.failed': 'Could not scan that QR code. Try again or enter the URL manually.', + 'mobile.connect.scan.invalid': 'That QR code is not an OpenChamber connection code.', + 'mobile.connect.scan.unsupported': 'QR scanning is only available in the installed mobile app.', + 'mobile.connect.saved.title': 'Saved connections', + 'mobile.connect.saved.empty': 'No saved connections yet.', + 'mobile.connect.error.urlRequired': 'Enter a server URL.', + 'mobile.connect.error.invalidUrl': 'That server URL is not valid.', + 'mobile.connect.error.unreachable': 'Could not reach that OpenChamber server.', + 'mobile.connect.error.authRequired': 'This server needs a password or client token.', + 'mobile.connect.error.passwordFailed': 'Could not unlock that server. Check the password.', + 'mobile.instances.addTitle': 'Add instance', + 'mobile.instances.editTitle': 'Edit instance', + 'mobile.instances.edit': 'Edit', + 'mobile.instances.delete': 'Delete', + 'mobile.instances.deleteAria': 'Delete {label}', + 'mobile.instances.confirmDeleteAria': 'Confirm deleting {label}', + 'mobile.instances.cancelDeleteAria': 'Keep {label}', + 'mobile.instances.cancelEdit': 'Cancel', + 'mobile.instances.label.label': 'Name', + 'mobile.instances.label.placeholder': 'Optional display name', + 'mobile.instances.saveNew': 'Save instance', + 'mobile.instances.saveEdit': 'Save changes', 'mobile.nav.changes': 'Changes', 'mobile.nav.settings': 'Settings', 'mobile.surface.closeAria': 'Close', @@ -42,6 +80,7 @@ export const dict = { 'mobile.menu.files': 'Files', 'mobile.menu.changes': 'Changes', 'mobile.menu.mcp': 'MCP', + 'mobile.menu.instances': 'Instances', 'mobile.menu.update': 'Update', 'mobile.menu.settings': 'Settings', 'mobile.sessions.newChatCta': 'New chat in {project}', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 7d4bba4f..d1a93005 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -31,6 +31,44 @@ export const dict: Record = { "layout.mainTab.terminal": "Terminal", "layout.mainTab.context": "Contexto", "mobile.nav.aria": "Navegación móvil", + "mobile.connect.welcome.title": "Conéctate a OpenChamber", + "mobile.connect.welcome.description": "Agrega una URL de servidor o escanea un código QR de emparejamiento para empezar a usar la app móvil.", + "mobile.connect.url.label": "URL del servidor", + "mobile.connect.url.placeholder": "http://192.168.1.74:2606", + "mobile.connect.token.label": "Token de cliente", + "mobile.connect.token.placeholder": "Pega el token de acceso", + "mobile.connect.token.hint": "Solo es necesario si tu servidor requiere un token en lugar de una contraseña.", + "mobile.connect.password.label": "Contraseña", + "mobile.connect.password.placeholder": "Contraseña de OpenChamber", + "mobile.connect.connectButton": "Conectar", + "mobile.connect.unlockButton": "Desbloquear y conectar", + "mobile.connect.cancelPassword": "Usar otro servidor", + "mobile.connect.connecting": "Conectando...", + "mobile.connect.scanQr": "Escanear código QR", + "mobile.connect.advanced": "Avanzado", + "mobile.connect.scan.permissionDenied": "El acceso a la cámara está desactivado. Actívalo en Ajustes para escanear un código QR.", + "mobile.connect.scan.failed": "No se pudo escanear ese código QR. Inténtalo de nuevo o introduce la URL manualmente.", + "mobile.connect.scan.invalid": "Ese código QR no es un código de conexión de OpenChamber.", + "mobile.connect.scan.unsupported": "El escaneo de QR solo está disponible en la app móvil instalada.", + "mobile.connect.saved.title": "Conexiones guardadas", + "mobile.connect.saved.empty": "Aún no hay conexiones guardadas.", + "mobile.connect.error.urlRequired": "Introduce una URL de servidor.", + "mobile.connect.error.invalidUrl": "Esa URL de servidor no es válida.", + "mobile.connect.error.unreachable": "No se pudo conectar con ese servidor de OpenChamber.", + "mobile.connect.error.authRequired": "Este servidor requiere una contraseña o un token de cliente.", + "mobile.connect.error.passwordFailed": "No se pudo desbloquear ese servidor. Revisa la contraseña.", + "mobile.instances.addTitle": "Agregar instancia", + "mobile.instances.editTitle": "Editar instancia", + "mobile.instances.edit": "Editar", + "mobile.instances.delete": "Eliminar", + "mobile.instances.deleteAria": "Eliminar {label}", + "mobile.instances.confirmDeleteAria": "Confirmar la eliminación de {label}", + "mobile.instances.cancelDeleteAria": "Conservar {label}", + "mobile.instances.cancelEdit": "Cancelar", + "mobile.instances.label.label": "Nombre", + "mobile.instances.label.placeholder": "Nombre para mostrar (opcional)", + "mobile.instances.saveNew": "Guardar instancia", + "mobile.instances.saveEdit": "Guardar cambios", "mobile.nav.changes": "Cambios", "mobile.nav.settings": "Ajustes", "mobile.surface.closeAria": "Cerrar", @@ -43,6 +81,7 @@ export const dict: Record = { "mobile.menu.files": "Archivos", "mobile.menu.changes": "Cambios", "mobile.menu.mcp": "MCP", + "mobile.menu.instances": "Instancias", "mobile.menu.update": "Actualizar", "mobile.menu.settings": "Ajustes", "mobile.sessions.newChatCta": "Nuevo chat en {project}", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 335d431f..c4eb7f12 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -2448,6 +2448,44 @@ export const dict = { 'quota.window.premiumInteractions': 'Interactions premium', 'layout.mainTab.diagram': 'Diagramme', 'mobile.nav.aria': 'Navigation mobile', + 'mobile.connect.welcome.title': 'Se connecter à OpenChamber', + 'mobile.connect.welcome.description': 'Ajoutez une URL de serveur ou scannez un code QR d\'appairage pour commencer à utiliser l\'app mobile.', + 'mobile.connect.url.label': 'URL du serveur', + 'mobile.connect.url.placeholder': 'http://192.168.1.74:2606', + 'mobile.connect.token.label': 'Jeton client', + 'mobile.connect.token.placeholder': 'Collez le jeton d\'accès', + 'mobile.connect.token.hint': 'Nécessaire uniquement si votre serveur exige un jeton au lieu d\'un mot de passe.', + 'mobile.connect.password.label': 'Mot de passe', + 'mobile.connect.password.placeholder': 'Mot de passe OpenChamber', + 'mobile.connect.connectButton': 'Se connecter', + 'mobile.connect.unlockButton': 'Déverrouiller et se connecter', + 'mobile.connect.cancelPassword': 'Utiliser un autre serveur', + 'mobile.connect.connecting': 'Connexion...', + 'mobile.connect.scanQr': 'Scanner le code QR', + 'mobile.connect.advanced': 'Avancé', + 'mobile.connect.scan.permissionDenied': 'L\'accès à la caméra est désactivé. Activez-le dans les Réglages pour scanner un code QR.', + 'mobile.connect.scan.failed': 'Impossible de scanner ce code QR. Réessayez ou saisissez l\'URL manuellement.', + 'mobile.connect.scan.invalid': 'Ce code QR n\'est pas un code de connexion OpenChamber.', + 'mobile.connect.scan.unsupported': 'Le scan QR est disponible uniquement dans l\'app mobile installée.', + 'mobile.connect.saved.title': 'Connexions enregistrées', + 'mobile.connect.saved.empty': 'Aucune connexion enregistrée pour le moment.', + 'mobile.connect.error.urlRequired': 'Saisissez une URL de serveur.', + 'mobile.connect.error.invalidUrl': 'Cette URL de serveur n\'est pas valide.', + 'mobile.connect.error.unreachable': 'Impossible de joindre ce serveur OpenChamber.', + 'mobile.connect.error.authRequired': 'Ce serveur nécessite un mot de passe ou un jeton client.', + 'mobile.connect.error.passwordFailed': 'Impossible de déverrouiller ce serveur. Vérifiez le mot de passe.', + 'mobile.instances.addTitle': 'Ajouter une instance', + 'mobile.instances.editTitle': 'Modifier l\'instance', + 'mobile.instances.edit': 'Modifier', + 'mobile.instances.delete': 'Supprimer', + 'mobile.instances.deleteAria': 'Supprimer {label}', + 'mobile.instances.confirmDeleteAria': 'Confirmer la suppression de {label}', + 'mobile.instances.cancelDeleteAria': 'Conserver {label}', + 'mobile.instances.cancelEdit': 'Annuler', + 'mobile.instances.label.label': 'Nom', + 'mobile.instances.label.placeholder': 'Nom d\'affichage facultatif', + 'mobile.instances.saveNew': 'Enregistrer l\'instance', + 'mobile.instances.saveEdit': 'Enregistrer les modifications', 'mobile.nav.changes': 'Modifications', 'mobile.nav.settings': 'Paramètres', 'mobile.surface.closeAria': 'Fermer', @@ -2460,6 +2498,7 @@ export const dict = { 'mobile.menu.files': 'Fichiers', 'mobile.menu.changes': 'Modifications', 'mobile.menu.mcp': 'MCP', + 'mobile.menu.instances': 'Instances', 'mobile.menu.update': 'Mettre à jour', 'mobile.menu.settings': 'Paramètres', 'mobile.sessions.newChatCta': 'Nouveau chat dans {project}', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 97e58f37..306ae0fe 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -33,6 +33,45 @@ export const dict: Record = { 'mobile.nav.aria': 'モバイルナビゲーション', 'mobile.nav.changes': '変更', 'mobile.nav.settings': '設定', + 'mobile.menu.instances': 'インスタンス', + 'mobile.connect.welcome.title': 'OpenChamber に接続', + 'mobile.connect.welcome.description': 'サーバー URL を追加するか、ペアリング QR コードをスキャンしてモバイルアプリを使い始めましょう。', + 'mobile.connect.url.label': 'サーバー URL', + 'mobile.connect.url.placeholder': 'http://192.168.1.74:2606', + 'mobile.connect.scanQr': 'QR コードをスキャン', + 'mobile.connect.advanced': '詳細設定', + 'mobile.connect.token.label': 'クライアントトークン', + 'mobile.connect.token.placeholder': 'アクセストークンを貼り付け', + 'mobile.connect.token.hint': 'サーバーがパスワードの代わりにトークンを必要とする場合のみ必要です。', + 'mobile.connect.connectButton': '接続', + 'mobile.connect.connecting': '接続中...', + 'mobile.connect.password.label': 'パスワード', + 'mobile.connect.password.placeholder': 'OpenChamber のパスワード', + 'mobile.connect.unlockButton': 'ロックを解除して接続', + 'mobile.connect.cancelPassword': '別のサーバーを使用', + 'mobile.connect.saved.title': '保存された接続', + 'mobile.connect.saved.empty': '保存された接続はまだありません。', + 'mobile.connect.error.urlRequired': 'サーバー URL を入力してください。', + 'mobile.connect.error.invalidUrl': 'そのサーバー URL は無効です。', + 'mobile.connect.error.unreachable': 'その OpenChamber サーバーに接続できませんでした。', + 'mobile.connect.error.authRequired': 'このサーバーにはパスワードまたはクライアントトークンが必要です。', + 'mobile.connect.error.passwordFailed': 'サーバーのロックを解除できませんでした。パスワードを確認してください。', + 'mobile.connect.scan.unsupported': 'QR スキャンはインストール済みのモバイルアプリでのみ利用できます。', + 'mobile.connect.scan.permissionDenied': 'カメラへのアクセスがオフになっています。QR コードを読み取るには設定で有効にしてください。', + 'mobile.connect.scan.invalid': 'その QR コードは OpenChamber の接続コードではありません。', + 'mobile.connect.scan.failed': 'その QR コードを読み取れませんでした。もう一度試すか、URL を手動で入力してください。', + 'mobile.instances.addTitle': 'インスタンスを追加', + 'mobile.instances.editTitle': 'インスタンスを編集', + 'mobile.instances.label.label': '名前', + 'mobile.instances.label.placeholder': '表示名(任意)', + 'mobile.instances.saveNew': 'インスタンスを保存', + 'mobile.instances.saveEdit': '変更を保存', + 'mobile.instances.cancelEdit': 'キャンセル', + 'mobile.instances.edit': '編集', + 'mobile.instances.delete': '削除', + 'mobile.instances.deleteAria': '{label} を削除', + 'mobile.instances.confirmDeleteAria': '{label} の削除を確定', + 'mobile.instances.cancelDeleteAria': '{label} を残す', 'mobile.surface.closeAria': '閉じる', 'mobile.header.openMenuAria': 'メニューを開く', 'mobile.header.openMetadataAria': 'セッションメタデータを開く', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 0e590934..d329885d 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -31,6 +31,44 @@ export const dict: Record = { 'layout.mainTab.terminal': '터미널', 'layout.mainTab.context': '컨텍스트', 'mobile.nav.aria': '모바일 내비게이션', + 'mobile.connect.welcome.title': 'OpenChamber에 연결', + 'mobile.connect.welcome.description': '서버 URL을 추가하거나 페어링 QR 코드를 스캔하여 모바일 앱을 시작하세요.', + 'mobile.connect.url.label': '서버 URL', + 'mobile.connect.url.placeholder': 'http://192.168.1.74:2606', + 'mobile.connect.token.label': '클라이언트 토큰', + 'mobile.connect.token.placeholder': '액세스 토큰 붙여넣기', + 'mobile.connect.token.hint': '서버가 비밀번호 대신 토큰을 요구하는 경우에만 필요합니다.', + 'mobile.connect.password.label': '비밀번호', + 'mobile.connect.password.placeholder': 'OpenChamber 비밀번호', + 'mobile.connect.connectButton': '연결', + 'mobile.connect.unlockButton': '잠금 해제 후 연결', + 'mobile.connect.cancelPassword': '다른 서버 사용', + 'mobile.connect.connecting': '연결 중...', + 'mobile.connect.scanQr': 'QR 코드 스캔', + 'mobile.connect.advanced': '고급', + 'mobile.connect.scan.permissionDenied': '카메라 접근이 꺼져 있습니다. QR 코드를 스캔하려면 설정에서 사용 설정하세요.', + 'mobile.connect.scan.failed': 'QR 코드를 스캔하지 못했습니다. 다시 시도하거나 URL을 직접 입력하세요.', + 'mobile.connect.scan.invalid': '이 QR 코드는 OpenChamber 연결 코드가 아닙니다.', + 'mobile.connect.scan.unsupported': 'QR 스캔은 설치된 모바일 앱에서만 사용할 수 있습니다.', + 'mobile.connect.saved.title': '저장된 연결', + 'mobile.connect.saved.empty': '아직 저장된 연결이 없습니다.', + 'mobile.connect.error.urlRequired': '서버 URL을 입력하세요.', + 'mobile.connect.error.invalidUrl': '유효하지 않은 서버 URL입니다.', + 'mobile.connect.error.unreachable': '해당 OpenChamber 서버에 연결할 수 없습니다.', + 'mobile.connect.error.authRequired': '이 서버에는 비밀번호 또는 클라이언트 토큰이 필요합니다.', + 'mobile.connect.error.passwordFailed': '서버 잠금을 해제할 수 없습니다. 비밀번호를 확인하세요.', + 'mobile.instances.addTitle': '인스턴스 추가', + 'mobile.instances.editTitle': '인스턴스 편집', + 'mobile.instances.edit': '편집', + 'mobile.instances.delete': '삭제', + 'mobile.instances.deleteAria': '{label} 삭제', + 'mobile.instances.confirmDeleteAria': '{label} 삭제 확인', + 'mobile.instances.cancelDeleteAria': '{label} 유지', + 'mobile.instances.cancelEdit': '취소', + 'mobile.instances.label.label': '이름', + 'mobile.instances.label.placeholder': '표시 이름 (선택 사항)', + 'mobile.instances.saveNew': '인스턴스 저장', + 'mobile.instances.saveEdit': '변경 사항 저장', 'mobile.nav.changes': '변경사항', 'mobile.nav.settings': '설정', 'mobile.surface.closeAria': '닫기', @@ -43,6 +81,7 @@ export const dict: Record = { 'mobile.menu.files': '파일', 'mobile.menu.changes': '변경사항', 'mobile.menu.mcp': 'MCP', + 'mobile.menu.instances': '인스턴스', 'mobile.menu.update': '업데이트', 'mobile.menu.settings': '설정', 'mobile.sessions.newChatCta': '{project}에서 새 채팅', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index f4c2f728..30cd07a1 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -32,6 +32,44 @@ export const dict: Record = { 'layout.mainTab.terminal': 'Terminal', 'layout.mainTab.context': 'Kontekst', 'mobile.nav.aria': 'Nawigacja mobilna', + 'mobile.connect.welcome.title': 'Połącz z OpenChamber', + 'mobile.connect.welcome.description': 'Dodaj adres URL serwera lub zeskanuj kod QR parowania, aby zacząć korzystać z aplikacji mobilnej.', + 'mobile.connect.url.label': 'Adres URL serwera', + 'mobile.connect.url.placeholder': 'http://192.168.1.74:2606', + 'mobile.connect.token.label': 'Token klienta', + 'mobile.connect.token.placeholder': 'Wklej token dostępu', + 'mobile.connect.token.hint': 'Potrzebny tylko, gdy serwer wymaga tokenu zamiast hasła.', + 'mobile.connect.password.label': 'Hasło', + 'mobile.connect.password.placeholder': 'Hasło OpenChamber', + 'mobile.connect.connectButton': 'Połącz', + 'mobile.connect.unlockButton': 'Odblokuj i połącz', + 'mobile.connect.cancelPassword': 'Użyj innego serwera', + 'mobile.connect.connecting': 'Łączenie...', + 'mobile.connect.scanQr': 'Skanuj kod QR', + 'mobile.connect.advanced': 'Zaawansowane', + 'mobile.connect.scan.permissionDenied': 'Dostęp do aparatu jest wyłączony. Włącz go w Ustawieniach, aby zeskanować kod QR.', + 'mobile.connect.scan.failed': 'Nie udało się zeskanować tego kodu QR. Spróbuj ponownie lub wpisz adres URL ręcznie.', + 'mobile.connect.scan.invalid': 'Ten kod QR nie jest kodem połączenia OpenChamber.', + 'mobile.connect.scan.unsupported': 'Skanowanie QR jest dostępne tylko w zainstalowanej aplikacji mobilnej.', + 'mobile.connect.saved.title': 'Zapisane połączenia', + 'mobile.connect.saved.empty': 'Brak zapisanych połączeń.', + 'mobile.connect.error.urlRequired': 'Podaj adres URL serwera.', + 'mobile.connect.error.invalidUrl': 'Ten adres URL serwera jest nieprawidłowy.', + 'mobile.connect.error.unreachable': 'Nie udało się połączyć z tym serwerem OpenChamber.', + 'mobile.connect.error.authRequired': 'Ten serwer wymaga hasła lub tokenu klienta.', + 'mobile.connect.error.passwordFailed': 'Nie udało się odblokować tego serwera. Sprawdź hasło.', + 'mobile.instances.addTitle': 'Dodaj instancję', + 'mobile.instances.editTitle': 'Edytuj instancję', + 'mobile.instances.edit': 'Edytuj', + 'mobile.instances.delete': 'Usuń', + 'mobile.instances.deleteAria': 'Usuń {label}', + 'mobile.instances.confirmDeleteAria': 'Potwierdź usunięcie {label}', + 'mobile.instances.cancelDeleteAria': 'Zachowaj {label}', + 'mobile.instances.cancelEdit': 'Anuluj', + 'mobile.instances.label.label': 'Nazwa', + 'mobile.instances.label.placeholder': 'Opcjonalna nazwa wyświetlana', + 'mobile.instances.saveNew': 'Zapisz instancję', + 'mobile.instances.saveEdit': 'Zapisz zmiany', 'mobile.nav.changes': 'Zmiany', 'mobile.nav.settings': 'Ustawienia', 'mobile.surface.closeAria': 'Zamknij', @@ -44,6 +82,7 @@ export const dict: Record = { 'mobile.menu.files': 'Pliki', 'mobile.menu.changes': 'Zmiany', 'mobile.menu.mcp': 'MCP', + 'mobile.menu.instances': 'Instancje', 'mobile.menu.update': 'Aktualizuj', 'mobile.menu.settings': 'Ustawienia', 'mobile.sessions.newChatCta': 'Nowy czat w {project}', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 5063bb75..bcbf1aed 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -31,6 +31,44 @@ export const dict: Record = { "layout.mainTab.terminal": "Terminal", "layout.mainTab.context": "Contexto", "mobile.nav.aria": "Navegação móvel", + "mobile.connect.welcome.title": "Conectar ao OpenChamber", + "mobile.connect.welcome.description": "Adicione a URL de um servidor ou leia um código QR de pareamento para começar a usar o app móvel.", + "mobile.connect.url.label": "URL do servidor", + "mobile.connect.url.placeholder": "http://192.168.1.74:2606", + "mobile.connect.token.label": "Token do cliente", + "mobile.connect.token.placeholder": "Cole o token de acesso", + "mobile.connect.token.hint": "Só é necessário se o seu servidor exigir um token em vez de senha.", + "mobile.connect.password.label": "Senha", + "mobile.connect.password.placeholder": "Senha do OpenChamber", + "mobile.connect.connectButton": "Conectar", + "mobile.connect.unlockButton": "Desbloquear e conectar", + "mobile.connect.cancelPassword": "Usar outro servidor", + "mobile.connect.connecting": "Conectando...", + "mobile.connect.scanQr": "Ler código QR", + "mobile.connect.advanced": "Avançado", + "mobile.connect.scan.permissionDenied": "O acesso à câmera está desativado. Ative-o nos Ajustes para ler um código QR.", + "mobile.connect.scan.failed": "Não foi possível ler esse código QR. Tente novamente ou digite a URL manualmente.", + "mobile.connect.scan.invalid": "Esse código QR não é um código de conexão do OpenChamber.", + "mobile.connect.scan.unsupported": "A leitura de QR só está disponível no app móvel instalado.", + "mobile.connect.saved.title": "Conexões salvas", + "mobile.connect.saved.empty": "Nenhuma conexão salva ainda.", + "mobile.connect.error.urlRequired": "Informe a URL de um servidor.", + "mobile.connect.error.invalidUrl": "Essa URL de servidor não é válida.", + "mobile.connect.error.unreachable": "Não foi possível acessar esse servidor OpenChamber.", + "mobile.connect.error.authRequired": "Este servidor requer uma senha ou token do cliente.", + "mobile.connect.error.passwordFailed": "Não foi possível desbloquear esse servidor. Verifique a senha.", + "mobile.instances.addTitle": "Adicionar instância", + "mobile.instances.editTitle": "Editar instância", + "mobile.instances.edit": "Editar", + "mobile.instances.delete": "Excluir", + "mobile.instances.deleteAria": "Excluir {label}", + "mobile.instances.confirmDeleteAria": "Confirmar exclusão de {label}", + "mobile.instances.cancelDeleteAria": "Manter {label}", + "mobile.instances.cancelEdit": "Cancelar", + "mobile.instances.label.label": "Nome", + "mobile.instances.label.placeholder": "Nome de exibição opcional", + "mobile.instances.saveNew": "Salvar instância", + "mobile.instances.saveEdit": "Salvar alterações", "mobile.nav.changes": "Alterações", "mobile.nav.settings": "Configurações", "mobile.surface.closeAria": "Fechar", @@ -43,6 +81,7 @@ export const dict: Record = { "mobile.menu.files": "Arquivos", "mobile.menu.changes": "Alterações", "mobile.menu.mcp": "MCP", + "mobile.menu.instances": "Instâncias", "mobile.menu.update": "Atualizar", "mobile.menu.settings": "Configurações", "mobile.sessions.newChatCta": "Novo chat em {project}", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 5fca9424..38414a0a 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -31,6 +31,44 @@ export const dict: Record = { "layout.mainTab.terminal": "Термінал", "layout.mainTab.context": "Контекст", "mobile.nav.aria": "Мобільна навігація", + "mobile.connect.welcome.title": "Підключись до OpenChamber", + "mobile.connect.welcome.description": "Додай адресу сервера або відскануй QR-код pairing, щоб почати користуватись мобільною апкою.", + "mobile.connect.url.label": "Адреса сервера", + "mobile.connect.url.placeholder": "http://192.168.1.74:2606", + "mobile.connect.token.label": "Токен клієнта", + "mobile.connect.token.placeholder": "Встав токен доступу", + "mobile.connect.token.hint": "Потрібен, лише якщо сервер вимагає токен замість пароля.", + "mobile.connect.password.label": "Пароль", + "mobile.connect.password.placeholder": "Пароль OpenChamber", + "mobile.connect.connectButton": "Підключити", + "mobile.connect.unlockButton": "Розблокувати і підключити", + "mobile.connect.cancelPassword": "Інший сервер", + "mobile.connect.connecting": "Підключення...", + "mobile.connect.scanQr": "Сканувати QR-код", + "mobile.connect.advanced": "Додатково", + "mobile.connect.scan.permissionDenied": "Доступ до камери вимкнено. Увімкни його в Налаштуваннях, щоб сканувати QR-код.", + "mobile.connect.scan.failed": "Не вдалося відсканувати QR-код. Спробуй ще раз або введи адресу вручну.", + "mobile.connect.scan.invalid": "Це не QR-код підключення OpenChamber.", + "mobile.connect.scan.unsupported": "Сканування QR доступне лише у встановленій мобільній апці.", + "mobile.connect.saved.title": "Збережені підключення", + "mobile.connect.saved.empty": "Збережених підключень ще немає.", + "mobile.connect.error.urlRequired": "Введи адресу сервера.", + "mobile.connect.error.invalidUrl": "Ця адреса сервера некоректна.", + "mobile.connect.error.unreachable": "Не вдалося достукатись до цього OpenChamber сервера.", + "mobile.connect.error.authRequired": "Цьому серверу потрібен пароль або client token.", + "mobile.connect.error.passwordFailed": "Не вдалося розблокувати сервер. Перевір пароль.", + "mobile.instances.addTitle": "Додати інстанс", + "mobile.instances.editTitle": "Редагувати інстанс", + "mobile.instances.edit": "Редагувати", + "mobile.instances.delete": "Видалити", + "mobile.instances.deleteAria": "Видалити {label}", + "mobile.instances.confirmDeleteAria": "Підтвердити видалення {label}", + "mobile.instances.cancelDeleteAria": "Залишити {label}", + "mobile.instances.cancelEdit": "Скасувати", + "mobile.instances.label.label": "Назва", + "mobile.instances.label.placeholder": "Необовʼязкова назва", + "mobile.instances.saveNew": "Зберегти інстанс", + "mobile.instances.saveEdit": "Зберегти зміни", "mobile.nav.changes": "Зміни", "mobile.nav.settings": "Налаштування", "mobile.surface.closeAria": "Закрити", @@ -43,6 +81,7 @@ export const dict: Record = { "mobile.menu.files": "Файли", "mobile.menu.changes": "Зміни", "mobile.menu.mcp": "MCP", + "mobile.menu.instances": "Інстанси", "mobile.menu.update": "Оновити", "mobile.menu.settings": "Налаштування", "mobile.sessions.newChatCta": "Новий чат у {project}", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 82b4dced..09ad5833 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -31,6 +31,44 @@ export const dict: Record = { 'layout.mainTab.terminal': '终端', 'layout.mainTab.context': '上下文', 'mobile.nav.aria': '移动导航', + 'mobile.connect.welcome.title': '连接到 OpenChamber', + 'mobile.connect.welcome.description': '添加服务器 URL 或扫描配对二维码即可开始使用移动应用。', + 'mobile.connect.url.label': '服务器 URL', + 'mobile.connect.url.placeholder': 'http://192.168.1.74:2606', + 'mobile.connect.token.label': '客户端令牌', + 'mobile.connect.token.placeholder': '粘贴访问令牌', + 'mobile.connect.token.hint': '仅当服务器需要令牌而非密码时才需要填写。', + 'mobile.connect.password.label': '密码', + 'mobile.connect.password.placeholder': 'OpenChamber 密码', + 'mobile.connect.connectButton': '连接', + 'mobile.connect.unlockButton': '解锁并连接', + 'mobile.connect.cancelPassword': '使用其他服务器', + 'mobile.connect.connecting': '连接中...', + 'mobile.connect.scanQr': '扫描二维码', + 'mobile.connect.advanced': '高级', + 'mobile.connect.scan.permissionDenied': '相机访问已关闭。请在“设置”中开启以扫描二维码。', + 'mobile.connect.scan.failed': '无法扫描该二维码。请重试或手动输入网址。', + 'mobile.connect.scan.invalid': '该二维码不是 OpenChamber 连接码。', + 'mobile.connect.scan.unsupported': '二维码扫描仅在已安装的移动应用中可用。', + 'mobile.connect.saved.title': '已保存的连接', + 'mobile.connect.saved.empty': '暂无已保存的连接。', + 'mobile.connect.error.urlRequired': '请输入服务器 URL。', + 'mobile.connect.error.invalidUrl': '该服务器 URL 无效。', + 'mobile.connect.error.unreachable': '无法连接到该 OpenChamber 服务器。', + 'mobile.connect.error.authRequired': '该服务器需要密码或客户端令牌。', + 'mobile.connect.error.passwordFailed': '无法解锁该服务器。请检查密码。', + 'mobile.instances.addTitle': '添加实例', + 'mobile.instances.editTitle': '编辑实例', + 'mobile.instances.edit': '编辑', + 'mobile.instances.delete': '删除', + 'mobile.instances.deleteAria': '删除 {label}', + 'mobile.instances.confirmDeleteAria': '确认删除 {label}', + 'mobile.instances.cancelDeleteAria': '保留 {label}', + 'mobile.instances.cancelEdit': '取消', + 'mobile.instances.label.label': '名称', + 'mobile.instances.label.placeholder': '可选显示名称', + 'mobile.instances.saveNew': '保存实例', + 'mobile.instances.saveEdit': '保存更改', 'mobile.nav.changes': '更改', 'mobile.nav.settings': '设置', 'mobile.surface.closeAria': '关闭', @@ -43,6 +81,7 @@ export const dict: Record = { 'mobile.menu.files': '文件', 'mobile.menu.changes': '更改', 'mobile.menu.mcp': 'MCP', + 'mobile.menu.instances': '实例', 'mobile.menu.update': '更新', 'mobile.menu.settings': '设置', 'mobile.sessions.newChatCta': '在 {project} 中新建会话', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 5b1d666c..34cdf134 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -31,6 +31,44 @@ export const dict: Record = { 'layout.mainTab.terminal': '終端機', 'layout.mainTab.context': '上下文', 'mobile.nav.aria': '行動導覽', + 'mobile.connect.welcome.title': '連線至 OpenChamber', + 'mobile.connect.welcome.description': '新增伺服器網址或掃描配對 QR 碼,即可開始使用行動應用程式。', + 'mobile.connect.url.label': '伺服器網址', + 'mobile.connect.url.placeholder': 'http://192.168.1.74:2606', + 'mobile.connect.token.label': '用戶端權杖', + 'mobile.connect.token.placeholder': '貼上存取權杖', + 'mobile.connect.token.hint': '僅當伺服器需要權杖而非密碼時才需要填寫。', + 'mobile.connect.password.label': '密碼', + 'mobile.connect.password.placeholder': 'OpenChamber 密碼', + 'mobile.connect.connectButton': '連線', + 'mobile.connect.unlockButton': '解鎖並連線', + 'mobile.connect.cancelPassword': '使用其他伺服器', + 'mobile.connect.connecting': '連線中...', + 'mobile.connect.scanQr': '掃描 QR code', + 'mobile.connect.advanced': '進階', + 'mobile.connect.scan.permissionDenied': '相機存取已關閉。請在「設定」中開啟以掃描 QR code。', + 'mobile.connect.scan.failed': '無法掃描該 QR code。請重試或手動輸入網址。', + 'mobile.connect.scan.invalid': '此 QR code 不是 OpenChamber 連線代碼。', + 'mobile.connect.scan.unsupported': 'QR code 掃描僅在已安裝的行動應用程式中可用。', + 'mobile.connect.saved.title': '已儲存的連線', + 'mobile.connect.saved.empty': '尚未儲存任何連線。', + 'mobile.connect.error.urlRequired': '請輸入伺服器網址。', + 'mobile.connect.error.invalidUrl': '該伺服器網址無效。', + 'mobile.connect.error.unreachable': '無法連線至該 OpenChamber 伺服器。', + 'mobile.connect.error.authRequired': '此伺服器需要密碼或用戶端權杖。', + 'mobile.connect.error.passwordFailed': '無法解鎖該伺服器。請檢查密碼。', + 'mobile.instances.addTitle': '新增執行個體', + 'mobile.instances.editTitle': '編輯執行個體', + 'mobile.instances.edit': '編輯', + 'mobile.instances.delete': '刪除', + 'mobile.instances.deleteAria': '刪除 {label}', + 'mobile.instances.confirmDeleteAria': '確認刪除 {label}', + 'mobile.instances.cancelDeleteAria': '保留 {label}', + 'mobile.instances.cancelEdit': '取消', + 'mobile.instances.label.label': '名稱', + 'mobile.instances.label.placeholder': '選填顯示名稱', + 'mobile.instances.saveNew': '儲存執行個體', + 'mobile.instances.saveEdit': '儲存變更', 'mobile.nav.changes': '變更', 'mobile.nav.settings': '設定', 'mobile.surface.closeAria': '關閉', @@ -43,6 +81,7 @@ export const dict: Record = { 'mobile.menu.files': '檔案', 'mobile.menu.changes': '變更', 'mobile.menu.mcp': 'MCP', + 'mobile.menu.instances': '執行個體', 'mobile.menu.update': '更新', 'mobile.menu.settings': '設定', 'mobile.sessions.newChatCta': '在 {project} 中新增聊天', diff --git a/packages/ui/src/lib/opencode/client.ts b/packages/ui/src/lib/opencode/client.ts index 216ef690..040c85d1 100644 --- a/packages/ui/src/lib/opencode/client.ts +++ b/packages/ui/src/lib/opencode/client.ts @@ -30,6 +30,7 @@ import { // Can be overridden with VITE_OPENCODE_URL for absolute URLs in special deployments const DEFAULT_BASE_URL = import.meta.env.VITE_OPENCODE_URL || "/api"; const CONFIG_CACHE_TTL_MS = 10_000; +const OPENCODE_HEALTH_TIMEOUT_MS = 4_000; /** * Render an SDK error payload into a short string for Error messages. @@ -157,6 +158,26 @@ const resolveRuntimeBaseUrl = (): string | null => { } }; +type AbortSignalConstructorWithTimeout = typeof AbortSignal & { + timeout?: (milliseconds: number) => AbortSignal; +}; + +const createTimeoutSignal = (timeoutMs: number): { signal: AbortSignal; cleanup: () => void } => { + const abortSignal = typeof AbortSignal !== 'undefined' + ? AbortSignal as AbortSignalConstructorWithTimeout + : undefined; + if (typeof abortSignal?.timeout === 'function') { + return { signal: abortSignal.timeout(timeoutMs), cleanup: () => undefined }; + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + return { + signal: controller.signal, + cleanup: () => clearTimeout(timeoutId), + }; +}; + const createRuntimeOpencodeClient = (config: { baseUrl: string; directory?: string }): OpencodeClient => { return createOpencodeClient({ ...config, @@ -1543,7 +1564,8 @@ class OpencodeService { ? '/api/opencode/health' : `${normalizedBase}/opencode/health`; markStartupTrace('opencodeClient.checkHealth:url', { baseUrl: this.baseUrl, healthUrl }); - const response = await runtimeFetch(healthUrl); + const timeout = createTimeoutSignal(OPENCODE_HEALTH_TIMEOUT_MS); + const response = await runtimeFetch(healthUrl, { signal: timeout.signal }).finally(timeout.cleanup); markStartupTrace('opencodeClient.checkHealth:response', { status: response.status }); if (!response.ok) { return false; diff --git a/packages/ui/src/lib/platform.ts b/packages/ui/src/lib/platform.ts new file mode 100644 index 00000000..73615a4d --- /dev/null +++ b/packages/ui/src/lib/platform.ts @@ -0,0 +1,26 @@ +import { isDesktopShell, isVSCodeRuntime } from '@/lib/desktop'; + +/** True when running inside the native Capacitor shell (iOS/Android app), not the web/PWA. */ +export const isCapacitorApp = (): boolean => { + if (typeof window === 'undefined') return false; + const capacitor = (window as typeof window & { Capacitor?: { isNativePlatform?: () => boolean } }).Capacitor; + return capacitor?.isNativePlatform?.() === true || window.location.protocol === 'capacitor:'; +}; + +export type ClientPlatform = 'ios' | 'android' | 'vscode' | 'desktop' | 'web'; + +/** + * The runtime surface this client is. Used by the push presence model: only 'ios'/'android' + * count as mobile (push recipients); everything else is an interactive surface that suppresses + * mobile push while visible. + */ +export const getClientPlatform = (): ClientPlatform => { + if (typeof window !== 'undefined') { + const capacitor = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor; + const native = capacitor?.getPlatform?.(); + if (native === 'ios' || native === 'android') return native; + } + if (isVSCodeRuntime()) return 'vscode'; + if (isDesktopShell()) return 'desktop'; + return 'web'; +}; diff --git a/packages/ui/src/styles/mobile.css b/packages/ui/src/styles/mobile.css index c00ea86a..11724eff 100644 --- a/packages/ui/src/styles/mobile.css +++ b/packages/ui/src/styles/mobile.css @@ -508,3 +508,88 @@ } } } + +/* Small app-wide bottom safe area for the native shell. The phone's rounded hardware + corners clip controls flush against the bottom edge, and the PWA's own safe-area + padding is gated behind display-mode: standalone — which the Capacitor WebView does + not match — so nothing reserves bottom room in the native app. Expose it as a token + so any native surface can consume it; the chat shell does so below. */ +:root.oc-capacitor-app { + --oc-app-bottom-safe: max(16px, calc(env(safe-area-inset-bottom, 0px) * 0.5)); +} + +/* Paint the document canvas with the theme background in the native app — the same + thing .desktop-runtime does for body/#root, which the Capacitor shell never got. + The status bar is overlaid (transparent), and in dark mode `color-scheme: dark` + makes the bare UA canvas dark, so any sliver not covered by content (notably the + area behind the status bar) bled through as a dark band at the top. It only showed + in dark mode, which is why it tracked the system theme. */ +:root.oc-capacitor-app, +:root.oc-capacitor-app body, +:root.oc-capacitor-app #root { + background: var(--background) !important; + background-color: var(--background) !important; +} + +/* Native (Capacitor) keyboard handling. + The Keyboard plugin runs in `resize: 'none'` mode so the WebView keeps its full + height; instead we shrink the app shell by the keyboard frame height, exposed as + --oc-keyboard-inset and set once from `keyboardWillShow` (see useNativeMobileChrome). + Scoped to .oc-capacitor-app so the browser PWA keeps its dvh / interactive-widget + behaviour untouched. + + `keyboardWillShow` fires at the start of the iOS keyboard animation, so the inset + is set once and the transition carries the rise. The duration/curve are tuned to + mimic the native iOS keyboard (≈0.25s, cubic-bezier(0.38, 0.7, 0.125, 1)) so our + layout and the keyboard move together. (visualViewport live-tracking would be exact + but doesn't report under WKWebView's `resize: 'none'`, so this is the best signal.) */ +:root.oc-capacitor-app .oc-mobile-app-shell { + height: calc(100dvh - var(--oc-keyboard-inset, 0px)); + /* Reserve the bottom safe area only while the keyboard is down — when it's up the + inset cancels it out (the home indicator is hidden and the composer should sit + flush above the keyboard). The shell keeps its own bg behind this padding. */ + padding-bottom: max(0px, calc(var(--oc-app-bottom-safe, 0px) - var(--oc-keyboard-inset, 0px))); + transition: height 0.25s cubic-bezier(0.38, 0.7, 0.125, 1), + padding-bottom 0.25s cubic-bezier(0.38, 0.7, 0.125, 1); +} + +/* Android resizes the window for the keyboard natively (no manual --oc-keyboard-inset), + so 100dvh changes instantly. Animating height against that instant resize makes the + header/content bounce on keyboard open — disable the transition on Android. */ +:root.oc-capacitor-app.oc-platform-android .oc-mobile-app-shell { + transition: none; +} + +/* Portal surfaces (bottom sheets, overlay panels) render at level, outside + the app shell, so they don't inherit the shell's keyboard inset. They're full- + height `fixed inset-0` scrims with a bottom-anchored (`mt-auto`) sheet, so raising + their bottom edge by the keyboard height shrinks scrim + sheet together and lifts + any input above the keyboard instead of hiding it underneath. The opacity term + preserves the scrim's enter fade (Tailwind's `transition-opacity` would otherwise + be overridden by this rule's `transition` shorthand). */ +:root.oc-capacitor-app .oc-keyboard-inset-surface { + bottom: var(--oc-keyboard-inset, 0px); + transition: bottom 0.25s cubic-bezier(0.38, 0.7, 0.125, 1), opacity 0.2s ease-out; +} + +/* Full-screen scroll views (e.g. the connect/login screen) live outside the app + shell, so shrink them by the keyboard height the same way the shell does. Capping + the height (instead of min-height: 100dvh) is what makes overflow-y-auto actually + scroll, so a field near the bottom lifts above the keyboard rather than staying + hidden behind it. min-height: 0 neutralises the Tailwind min-h-dvh baseline. */ +:root.oc-capacitor-app .oc-keyboard-fill-screen { + height: calc(100dvh - var(--oc-keyboard-inset, 0px)); + min-height: 0; + transition: height 0.25s cubic-bezier(0.38, 0.7, 0.125, 1); +} + +/* The composer keeps its 1rem bottom padding while the keyboard is down (breathing + room above the home indicator), but that gap looks artificial sitting above the + keyboard's accessory bar — so tighten it while the keyboard is open. Animated to + match the keyboard motion. */ +:root.oc-capacitor-app .oc-mobile-composer { + transition: padding-bottom 0.25s cubic-bezier(0.38, 0.7, 0.125, 1); +} +:root.oc-capacitor-app.oc-keyboard-open .oc-mobile-composer { + padding-bottom: 6px; +} diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index 9387eaf7..da34f3a4 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -8,6 +8,7 @@ import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" import { createEventPipeline } from "./event-pipeline" import { isVSCodeRuntime } from "@/lib/desktop" import { isMobileSurfaceRuntime } from "@/lib/runtimeSurface" +import { isCapacitorApp } from "@/lib/platform" import { reduceGlobalEvent, applyGlobalProject, applyDirectoryEvent } from "./event-reducer" import { useGlobalSyncStore } from "./global-sync-store" import { ChildStoreManager, type DirectoryStore } from "./child-store" @@ -1584,7 +1585,12 @@ export function SyncProvider(props: { directory: string children: React.ReactNode }) { - const messageStreamTransport = useConfigStore((state) => state.settingsMessageStreamTransport) + const storedMessageStreamTransport = useConfigStore((state) => state.settingsMessageStreamTransport) + // Capacitor apps are locked to SSE: native WebSocket streaming is unreliable there (on + // Android events only arrive once the run finishes), while SSE streams correctly. The Chat + // settings UI disables the other options on mobile, but force it here too so the effective + // transport can't drift. Remove this override (and the UI lock) to re-enable WS on mobile. + const messageStreamTransport: 'auto' | 'ws' | 'sse' = isCapacitorApp() ? 'sse' : storedMessageStreamTransport const childStoresRef = useRef(null) if (!childStoresRef.current) childStoresRef.current = new ChildStoreManager() const childStores = childStoresRef.current @@ -2053,7 +2059,7 @@ export function useDirectorySync(selector: (state: State) => T, directory?: s return useStore(store, selector) } -/** Get session messages for a specific session */ +/** Get session messages for a specific session */ export function useSessionMessages(sessionID: string, directory?: string) { const store = useDirectoryStore(directory) const getSnapshot = useCallback(() => { diff --git a/packages/web/bin/lib/commands-connect-url.js b/packages/web/bin/lib/commands-connect-url.js index b9ae0f50..c2b94705 100644 --- a/packages/web/bin/lib/commands-connect-url.js +++ b/packages/web/bin/lib/commands-connect-url.js @@ -35,6 +35,16 @@ async function resolveConnectUrlServerUrl(options) { } const bindHost = resolveConfiguredBindHost(hostOverride); + + // A host that's already a full http(s) URL is a public/server URL, not a bind + // address (e.g. `--host https://devchamber.example.com` for a remote deploy + // behind a reverse proxy). Use it directly instead of feeding it to + // buildLocalUrl, which would produce `http://https://...:port`. + const hostAsServerUrl = normalizeServerUrlForConnection(bindHost); + if (hostAsServerUrl) { + return { serverUrl: hostAsServerUrl, source: 'configured-host' }; + } + if (!isWildcardBindHost(bindHost)) { return { serverUrl: buildLocalUrl(options.port, '/', hostOverride).replace(/\/+$/, ''), diff --git a/packages/web/mobile.html b/packages/web/mobile.html index 18a298ac..907e8c41 100644 --- a/packages/web/mobile.html +++ b/packages/web/mobile.html @@ -4,6 +4,65 @@ OpenChamber Mobile + + + diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 21238b5e..28f6c2dd 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -9,6 +9,7 @@ import net from 'net'; import { fileURLToPath } from 'url'; import os from 'os'; import crypto from 'crypto'; +import http2 from 'node:http2'; import { createUiAuth } from './lib/ui-auth/ui-auth.js'; import { createTunnelAuth } from './lib/opencode/tunnel-auth.js'; import { createManagedTunnelConfigRuntime } from './lib/tunnels/managed-config.js'; @@ -79,6 +80,7 @@ import { registerNotificationRoutes } from './lib/notifications/routes.js'; import { createNotificationEmitterRuntime } from './lib/notifications/emitter-runtime.js'; import { createNotificationTriggerRuntime } from './lib/notifications/runtime.js'; import { createPushRuntime } from './lib/notifications/push-runtime.js'; +import { createApnsRuntime } from './lib/notifications/apns-runtime.js'; import { createNotificationTemplateRuntime } from './lib/notifications/template-runtime.js'; import { createGracefulShutdownRuntime } from './lib/opencode/shutdown-runtime.js'; import { createProjectConfigRuntime } from './lib/projects/project-config.js'; @@ -275,6 +277,7 @@ const OPENCHAMBER_DATA_DIR = process.env.OPENCHAMBER_DATA_DIR : path.join(os.homedir(), '.config', 'openchamber'); const SETTINGS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'settings.json'); const PUSH_SUBSCRIPTIONS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'push-subscriptions.json'); +const APNS_TOKENS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'apns-tokens.json'); const REMOTE_CLIENTS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'remote-clients.json'); const CLOUDFLARE_MANAGED_REMOTE_TUNNELS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'cloudflare-managed-remote-tunnels.json'); const CLOUDFLARE_LEGACY_NAMED_TUNNELS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'cloudflare-named-tunnels.json'); @@ -377,12 +380,34 @@ const getOrCreateVapidKeys = (...args) => pushRuntime.getOrCreateVapidKeys(...ar const addOrUpdatePushSubscription = (...args) => pushRuntime.addOrUpdatePushSubscription(...args); const removePushSubscription = (...args) => pushRuntime.removePushSubscription(...args); const sendPushToAllUiSessions = (...args) => pushRuntime.sendPushToAllUiSessions(...args); -const updateUiVisibility = (...args) => pushRuntime.updateUiVisibility(...args); +// Set once the notification trigger runtime exists (declared later). When a UI +// client reports it became visible, reset the native push badge set — the same +// moment the device zeroes its icon badge on becomeActive, keeping them in sync. +let clearPendingPushBadge = () => {}; +const updateUiVisibility = (token, visible, platform) => { + if (visible === true) clearPendingPushBadge(); + return pushRuntime.updateUiVisibility(token, visible, platform); +}; const isAnyUiVisible = (...args) => pushRuntime.isAnyUiVisible(...args); +const isAnyInteractiveClientVisible = (...args) => pushRuntime.isAnyInteractiveClientVisible(...args); const isUiVisible = (...args) => pushRuntime.isUiVisible(...args); const ensurePushInitialized = (...args) => pushRuntime.ensurePushInitialized(...args); const setPushInitialized = (...args) => pushRuntime.setPushInitialized(...args); +const apnsRuntime = createApnsRuntime({ + fsPromises, + path, + crypto, + http2, + APNS_TOKENS_FILE_PATH, + readSettingsFromDiskMigrated, + writeSettingsToDisk, +}); + +const addOrUpdateApnsToken = (...args) => apnsRuntime.addOrUpdateApnsToken(...args); +const removeApnsToken = (...args) => apnsRuntime.removeApnsToken(...args); +const sendApnsToAllUiSessions = (...args) => apnsRuntime.sendApnsToAllUiSessions(...args); + const TERMINAL_INPUT_WS_MAX_REBINDS_PER_WINDOW = 128; const TERMINAL_INPUT_WS_REBIND_WINDOW_MS = 60 * 1000; const TERMINAL_INPUT_WS_HEARTBEAT_INTERVAL_MS = 15 * 1000; @@ -676,12 +701,15 @@ const notificationTriggerRuntime = createNotificationTriggerRuntime({ emitDesktopNotification, broadcastUiNotification, sendPushToAllUiSessions, + sendApnsToAllUiSessions, + isAnyInteractiveClientVisible, buildOpenCodeUrl, getOpenCodeAuthHeaders, }); const maybeSendPushForTrigger = (...args) => notificationTriggerRuntime.maybeSendPushForTrigger(...args); const setAutoAcceptSession = (...args) => notificationTriggerRuntime.setAutoAcceptSession(...args); +clearPendingPushBadge = () => notificationTriggerRuntime.clearPendingPushBadge(); const globalMessageStreamHub = createGlobalMessageStreamHub({ buildOpenCodeUrl, @@ -1103,7 +1131,13 @@ async function main(options = {}) { const app = express(); const serverStartedAt = new Date().toISOString(); - const packagedClientOrigins = new Set(['openchamber-ui://app']); + const packagedClientOrigins = new Set([ + 'openchamber-ui://app', + 'capacitor://localhost', + 'http://localhost', + 'https://localhost', + ]); + const isLocalDevClientOrigin = (origin) => /^https?:\/\/(localhost|127\.0\.0\.1):\d+$/.test(origin); app.set('trust proxy', true); // Keep self-hosted instances out of search engines. The app shell is served // publicly (it loads before prompting for the UI password), so without this @@ -1118,7 +1152,7 @@ async function main(options = {}) { }); app.use((req, res, next) => { const origin = typeof req.headers.origin === 'string' ? req.headers.origin : ''; - if (packagedClientOrigins.has(origin)) { + if (packagedClientOrigins.has(origin) || isLocalDevClientOrigin(origin)) { res.setHeader('Access-Control-Allow-Origin', origin); res.setHeader('Access-Control-Allow-Credentials', 'true'); res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS'); @@ -1193,7 +1227,10 @@ async function main(options = {}) { writeSettingsToDisk, addOrUpdatePushSubscription, removePushSubscription, + addOrUpdateApnsToken, + removeApnsToken, updateUiVisibility, + clearPendingPushBadge: () => clearPendingPushBadge(), isUiVisible, getUiNotificationClients: () => uiNotificationClients, writeSseEvent, diff --git a/packages/web/server/lib/notifications/APNS.md b/packages/web/server/lib/notifications/APNS.md new file mode 100644 index 00000000..61821a3e --- /dev/null +++ b/packages/web/server/lib/notifications/APNS.md @@ -0,0 +1,131 @@ +# APNs remote push — signed relay mode + +Native iOS background push (notifications even when the app is **suspended or killed**) is +delivered via APNs through a **central relay**, so no user configures an Apple key. Each server +signs its relay requests with an auto-generated keypair, and tokens are bound to the server that +registered them — so a leaked device token alone can't be used to push. + +## How it works + +1. The app registers its APNs device token with **its own server** (`POST /api/push/apns-token`, + `useNativePushRegistration`). PWA/desktop never register — only the native Capacitor app. +2. The server **binds the token on the relay**: it POSTs `{ token, publicKeyJwk, ts, sig }` to + `POST /v1/push/register-token`, signed with its auto-generated ECDSA P-256 key + (`getOrCreateRelayKeypair`, persisted in settings like the VAPID keys). The relay records + `token → serverId` where `serverId = SHA-256(publicKey)`. +3. On a trigger (ready/error/question/permission), the server composes **generic, content-free** + text — a fixed scenario title ("Agent response is ready" / "Agent needs your input" / "Agent + needs permission" / "Agent hit an error") + the **session name** as the body, no model/project/ + message content — plus a **`badge`** count (see below) — and POSTs `{ tokens, title, body, + badge, env, data:{sessionId}, publicKeyJwk, ts, sig }` to `POST /v1/push/send` + (`apns-runtime.js` → `sendViaRelay`). It does **not** gate on UI visibility (see below). +4. The **relay** (`openchamber-website/apps/api`, Cloudflare Worker) verifies the signature + + `ts` freshness, derives `serverId`, and only delivers to tokens bound to that server. It holds + the single project APNs `.p8` key, signs an ES256 JWT with `crypto.subtle`, and sends each + token to APNs over HTTP/2, returning per-token results; the server drops tokens flagged `drop` + (410 / BadDeviceToken). The relay stores no secret — only `token → serverId` hashes. +5. Tapping a push deep-links to its session via the forwarded `sessionId`. + +## Foreground suppression + +APNs is **not** gated on UI visibility. A backgrounded WKWebView can't reliably report "hidden" +before iOS suspends it, so a server-side visibility gate dropped background push for short +responses. Instead the server always sends, and **iOS** suppresses the foreground banner +(`PushNotifications.presentationOptions: []` in `capacitor.config`) — so there is no notification +while the app is active, with no race. APNs is the native app's **only** channel; local +notifications were removed (a WKWebView can't tell foreground from background — `document.hasFocus()` +is unreliable — so they leaked while the app was open). Cloudflare is touched only when a native +app with notifications on has a registered token and a trigger fires. + +## App-icon badge + +Each push carries an **absolute** `aps.badge` = the number of **distinct collapse-ids (`tag`) +pushed since the app was last foregrounded**. It mirrors the lock-screen banner stack. + +The count is a `Set` (`pendingPushTags`) in the trigger runtime (`runtime.js`): +`toApnsGenericPayload` adds the push `tag` and returns the set size as the badge. We key by **`tag`, +not sessionId**, because the tag *is* the banner identity — iOS uses it as `apns-collapse-id`, so +same-tag pushes replace one banner while different tags are distinct banners. One session can raise +several banners (`ready-`, `question-`, `permission-` are different tags), so +counting sessionIds both over- and under-counts the stack; counting tags matches it. + +It is deliberately **not** derived from the live attention snapshot (`needsAttention`/`isViewed`): +that machinery drives in-app indicators on *connected* clients, where a backgrounded client stays +"viewing" and `needsAttention` is set by a separate `session.status` event that races the push +trigger. The set self-clears via `clearPendingPushBadge` on any signal that the user is engaging +with the app: the visibility beacon (`updateUiVisibility` wrapper, `visible:true`), **plus** opening +a session (`POST /api/sessions/:id/view`) and sending a message (`POST /api/sessions/:id/ +message-sent`). The latter two need no auth and fire reliably on the native app when it foregrounds, +so they are the dependable reset — the visibility beacon alone proved unreliable in WKWebView. This +mirrors the device zeroing its icon badge on `sceneDidBecomeActive` (`AppDelegate.swift`), keeping +server and device in sync. + +The value flows `runtime.js` (`toApnsGenericPayload`) → `apns-runtime.js` (`sendViaRelay` body / +direct-mode `aps.badge`) → relay (`pushSendSchema.badge` → `aps.badge`). It is **not** signed (like +`body`/`data`); the relay still only delivers to bound tokens. The set is server-global, so every +device token of a server sees the same badge. + +## Modes + +- **Relay (default):** server has no Apple key; `OPENCHAMBER_PUSH_RELAY_URL` defaults to + `https://api.openchamber.dev/v1/push/send` (register URL is derived as `…/register-token`). +- **Direct (fallback):** set `OPENCHAMBER_PUSH_RELAY_DISABLED=true` + `OPENCHAMBER_APNS_KEY_ID/ + TEAM_ID/P8` to sign+send from the server itself (HTTP/2 + ES256 JWT); no relay binding needed. + +## Config + +Server (`apns-runtime.js`): +- `OPENCHAMBER_PUSH_RELAY_URL` (default the public relay), `OPENCHAMBER_APNS_ENVIRONMENT` + (`sandbox` default / `production`). The signing keypair is auto-generated — nothing to set. +- Direct fallback: `OPENCHAMBER_APNS_KEY_ID`, `OPENCHAMBER_APNS_TEAM_ID`, `OPENCHAMBER_APNS_P8` + (or `_P8_PATH`), `OPENCHAMBER_APNS_BUNDLE_ID`, `OPENCHAMBER_PUSH_RELAY_DISABLED=true`. + +Relay (Cloudflare Worker secrets via `wrangler secret put` / GitHub Actions): `APNS_P8`, +`APNS_KEY_ID`, `APNS_TEAM_ID`, optional `APNS_BUNDLE_ID` / `APNS_DEFAULT_ENV`. The `push_tokens` +binding table is created by `migrations/0002_push_tokens.sql` (applied on deploy). + +## Apple setup (one-time) + +1. Apple **Keys** (not Certificates) → create an **APNs Auth Key** (`.p8`) → Key ID + Team ID; + enable **Push Notifications** on App ID `com.openchamber.app`. +2. In the **openchamber-website** repo → Actions secrets: `APNS_P8` (PEM), `APNS_KEY_ID`, + `APNS_TEAM_ID`. Push to `main` → relay deploys, secrets sync, D1 migrations apply. +3. Xcode: confirm the Push Notifications capability; Clean Build Folder; run on device. + +## Security posture + +- The device token is a per-install secret, but no longer the *only* defence: every relay request + is signed by the server's private key, and the relay only delivers to a token from its bound + `serverId`. A leaked token alone is useless — an attacker has neither the private key nor a + matching binding. +- `serverId` self-certifies (`SHA-256(publicKey)`), so the relay holds no secret; a D1 leak + exposes only `token → serverId` hashes. The signed `ts` (±5 min window) blocks replay. +- Residual: trust-on-first-bind (whoever registers a token first owns it) — acceptable, since + registering already requires possessing the token. Cloudflare rate limiting is defence-in-depth. + +## Data confidentiality (what the relay / Apple can see) + +The push payload is **not** application-encrypted, so there is no decryption step. The text is +sent in plaintext, protected only by **TLS in transit** (HTTPS to the relay, TLS from the relay +to APNs). The request **signature is authentication, not encryption** — the relay *verifies* it +(valid / invalid), it does not hide anything. + +Who can read the alert text: + +- **Network hops:** nothing (TLS). +- **The relay (Cloudflare):** the generic title + body (session name), the device token, and + `sessionId`. It stores only `token → serverId` hashes (no text, no payload). +- **Apple APNs:** the alert text too — APNs always reads the alert payload of an `alert` push. +- **The device:** displays it. + +This is acceptable **because the text is deliberately content-free**: a fixed scenario title + +the session name only — no model, project, or message content (`runtime.js` → +`toApnsGenericPayload`). The session name is the single semi-personal field that crosses the +relay/Apple. To hide even that from Apple would require an end-to-end **encrypted payload** +(`mutable-content` + a Notification Service Extension that decrypts on-device with a key never +sent to the relay) — not implemented, and unnecessary for generic text. + +## Android (FCM) note + +The Android equivalent is **FCM** (not implemented): the same relay would forward to FCM with a +server key, and the client would register an FCM token (same store/routes + signing). diff --git a/packages/web/server/lib/notifications/DOCUMENTATION.md b/packages/web/server/lib/notifications/DOCUMENTATION.md index 01ff1f6f..bf736a1e 100644 --- a/packages/web/server/lib/notifications/DOCUMENTATION.md +++ b/packages/web/server/lib/notifications/DOCUMENTATION.md @@ -7,6 +7,7 @@ This module provides notification message preparation utilities for the web serv - `packages/web/server/lib/notifications/index.js`: public entrypoint imported by `packages/web/server/index.js`. - `packages/web/server/lib/notifications/routes.js`: route registration for push, visibility, and session status/attention endpoints. - `packages/web/server/lib/notifications/push-runtime.js`: push subscription persistence, VAPID initialization, and UI visibility runtime. +- `packages/web/server/lib/notifications/apns-runtime.js`: native iOS APNs device-token persistence + delivery. Two modes: **relay** (default — sign + POST tokens + generic text to the central Cloudflare relay `https://api.openchamber.dev/v1/push/send`, which holds the single project APNs key) and **direct** (fallback — sign ES256 JWT with Node crypto + HTTP/2, when `OPENCHAMBER_PUSH_RELAY_DISABLED=true`). Each server has an auto-generated ECDSA P-256 keypair (`getOrCreateRelayKeypair`, persisted in settings); it binds tokens on the relay (`/v1/push/register-token`) and signs every relay request, so the relay only delivers to tokens bound to that server. APNs is the native app's sole notification channel (no local notifications) and is NOT gated on UI visibility — iOS suppresses the foreground banner instead. Mobile push carries only generic text (scenario title + session name) — see `APNS.md`. - `packages/web/server/lib/notifications/emitter-runtime.js`: desktop/stdout + UI SSE notification emission runtime. - `packages/web/server/lib/notifications/runtime.js`: trigger runtime for OpenCode event-driven notification fanout. - `packages/web/server/lib/notifications/template-runtime.js`: notification template variables and session text/title enrichment runtime. Zen-model helpers are retained as compatibility stubs only. @@ -24,6 +25,8 @@ This module provides notification message preparation utilities for the web serv - `GET /api/push/vapid-public-key` - `POST /api/push/subscribe` - `DELETE /api/push/subscribe` + - `POST /api/push/apns-token` (native iOS APNs device-token registration) + - `DELETE /api/push/apns-token` - `POST /api/push/visibility` - `GET /api/push/visibility` - `GET /api/notifications/stream` @@ -61,6 +64,16 @@ This module provides notification message preparation utilities for the web serv - `isAnyUiVisible()` - `isUiVisible(token)` +### APNs runtime API (apns-runtime.js) +- `createApnsRuntime(dependencies)`: creates runtime for native iOS APNs push and device-token state. Dependencies: `fsPromises`, `path`, `crypto`, `http2`, `APNS_TOKENS_FILE_PATH`, `readSettingsFromDiskMigrated`, `writeSettingsToDisk` (persists the auto-generated relay signing keypair). +- Returned API: + - `addOrUpdateApnsToken(uiSessionToken, deviceToken, userAgent)` — also binds a newly-seen token on the relay (signed `/v1/push/register-token`). + - `removeApnsToken(uiSessionToken, deviceToken)` + - `removeApnsTokenFromAllSessions(deviceToken)` + - `sendApnsToAllUiSessions(payload)` — signs + sends to all registered tokens (no UI-visibility gate; iOS suppresses the foreground banner). No-ops with a single warning when APNs is unconfigured. Drops tokens on `410` / `BadDeviceToken` / `Unregistered`. + - `resolveApnsConfig()` +- Configuration (env first, then `settings.apnsConfig`): `OPENCHAMBER_APNS_KEY_ID`, `OPENCHAMBER_APNS_TEAM_ID`, `OPENCHAMBER_APNS_P8` (PEM contents; literal `\n` accepted) or `OPENCHAMBER_APNS_P8_PATH`, `OPENCHAMBER_APNS_BUNDLE_ID` (default `com.openchamber.app`), `OPENCHAMBER_APNS_ENVIRONMENT` (`sandbox` default, or `production`). + ### Emitter runtime API (emitter-runtime.js) - `createNotificationEmitterRuntime(dependencies)`: creates runtime for unified notification emission channels. - Returned API: diff --git a/packages/web/server/lib/notifications/apns-runtime.js b/packages/web/server/lib/notifications/apns-runtime.js new file mode 100644 index 00000000..4f3040e9 --- /dev/null +++ b/packages/web/server/lib/notifications/apns-runtime.js @@ -0,0 +1,512 @@ +// APNs (Apple Push Notification service) runtime for the native iOS mobile app. +// +// Device tokens are persisted per UI session (mirrors push-runtime.js). Delivery has two +// modes, chosen at send time: +// - Relay (default): POST tokens + generic text to the central Cloudflare relay, which +// holds the single project APNs key and signs+sends — so users configure nothing. +// - Direct (fallback): sign an ES256 JWT with Node crypto and send over HTTP/2 ourselves, +// for self-hosters who set OPENCHAMBER_APNS_* and OPENCHAMBER_PUSH_RELAY_DISABLED=true. +// Wired into the same trigger fanout as web push (see runtime.js); the relay carries only +// generic, model-based text (no session content) — see APNS.md. + +const APNS_TOKENS_VERSION = 1; +const APNS_HOST_PRODUCTION = 'https://api.push.apple.com'; +const APNS_HOST_SANDBOX = 'https://api.sandbox.push.apple.com'; +// APNs rejects auth tokens older than 1h; refresh well inside that window. +const JWT_TTL_MS = 50 * 60 * 1000; +const DEFAULT_BUNDLE_ID = 'com.openchamber.app'; +const DEFAULT_RELAY_URL = 'https://api.openchamber.dev/v1/push/send'; +const MAX_TOKENS_PER_SESSION = 10; +// APNs reasons that mean the token is permanently invalid → drop it. +const DEAD_TOKEN_REASONS = new Set(['BadDeviceToken', 'Unregistered', 'DeviceTokenNotForTopic']); + +const trimmedEnv = (name) => { + const value = process.env[name]; + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; +}; + +// Env vars commonly store the .p8 with literal "\n" sequences; restore real newlines. +const normalizePem = (value) => (typeof value === 'string' ? value.replace(/\\n/g, '\n').trim() : ''); + +export const createApnsRuntime = (deps) => { + const { + fsPromises, + path, + crypto, + http2, + APNS_TOKENS_FILE_PATH, + readSettingsFromDiskMigrated, + writeSettingsToDisk, + } = deps; + + let persistLock = Promise.resolve(); + let cachedJwt = null; // { token, issuedAtMs, keyId } + let cachedRelayKey = null; // { privateKey, publicJwk } + let warnedUnconfigured = false; + + // --------------------------------------------------------------------------- + // Per-server relay signing identity (ECDSA P-256). Auto-generated + persisted in settings + // (mirrors getOrCreateVapidKeys). The relay derives serverId = SHA-256(publicKey), verifies + // each request's signature, and only delivers to tokens this server registered — so a leaked + // device token alone can't be used to push. Zero-config: the keypair generates on first use. + // --------------------------------------------------------------------------- + + const getOrCreateRelayKeypair = async () => { + if (cachedRelayKey) return cachedRelayKey; + const settings = await readSettingsFromDiskMigrated(); + const existing = settings?.relaySigningKey; + if (existing && existing.privateJwk && existing.publicJwk) { + cachedRelayKey = { + privateKey: crypto.createPrivateKey({ key: existing.privateJwk, format: 'jwk' }), + publicJwk: existing.publicJwk, + }; + return cachedRelayKey; + } + const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' }); + const privateJwk = privateKey.export({ format: 'jwk' }); + const publicJwk = publicKey.export({ format: 'jwk' }); + await writeSettingsToDisk({ ...settings, relaySigningKey: { privateJwk, publicJwk } }); + cachedRelayKey = { privateKey, publicJwk }; + return cachedRelayKey; + }; + + const signRelayMessage = (privateKey, message) => + crypto.sign('SHA256', Buffer.from(message), { key: privateKey, dsaEncoding: 'ieee-p1363' }).toString('base64url'); + + // Trim to the 4 fields the relay's schema accepts (and that feed the serverId hash). + const relayPublicJwk = (publicJwk) => ({ + kty: publicJwk.kty, + crv: publicJwk.crv, + x: publicJwk.x, + y: publicJwk.y, + }); + + const registerTokenWithRelay = async (token, platform = 'ios') => { + const relay = resolveRelayConfig(); + if (!relay) return; // direct mode — no relay binding needed + try { + const { privateKey, publicJwk } = await getOrCreateRelayKeypair(); + const ts = Date.now(); + // platform is part of the signed message so it can't be tampered en route. + const sig = signRelayMessage(privateKey, `${ts}.${token}.${platform}`); + const res = await fetch(relay.registerUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ token, platform, publicKeyJwk: relayPublicJwk(publicJwk), ts, sig }), + }); + if (!res.ok) console.warn(`[Push relay] register-token failed status=${res.status}`); + } catch (error) { + console.warn('[Push relay] register-token request failed:', error?.message ?? error); + } + }; + + // --------------------------------------------------------------------------- + // Token persistence (same shape + write-lock pattern as push-runtime.js) + // --------------------------------------------------------------------------- + + const emptyStore = () => ({ version: APNS_TOKENS_VERSION, tokensBySession: {} }); + + const readTokensFromDisk = async () => { + try { + const raw = await fsPromises.readFile(APNS_TOKENS_FILE_PATH, 'utf8'); + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object' || parsed.version !== APNS_TOKENS_VERSION) { + return emptyStore(); + } + const tokensBySession = + parsed.tokensBySession && typeof parsed.tokensBySession === 'object' ? parsed.tokensBySession : {}; + return { version: APNS_TOKENS_VERSION, tokensBySession }; + } catch (error) { + if (error && typeof error === 'object' && error.code === 'ENOENT') { + return emptyStore(); + } + console.warn('Failed to read APNs tokens file:', error); + return emptyStore(); + } + }; + + const writeTokensToDisk = async (data) => { + await fsPromises.mkdir(path.dirname(APNS_TOKENS_FILE_PATH), { recursive: true }); + await fsPromises.writeFile(APNS_TOKENS_FILE_PATH, JSON.stringify(data, null, 2), 'utf8'); + }; + + const persistTokenUpdate = async (mutate) => { + persistLock = persistLock.then(async () => { + const current = await readTokensFromDisk(); + const next = mutate({ version: APNS_TOKENS_VERSION, tokensBySession: current.tokensBySession || {} }); + await writeTokensToDisk(next); + return next; + }); + return persistLock; + }; + + const normalizeTokens = (record) => { + if (!Array.isArray(record)) return []; + return record + .map((entry) => { + if (!entry || typeof entry !== 'object') return null; + const deviceToken = entry.deviceToken; + if (typeof deviceToken !== 'string' || deviceToken.trim().length === 0) return null; + return { + deviceToken: deviceToken.trim(), + createdAt: typeof entry.createdAt === 'number' ? entry.createdAt : null, + lastSeenAt: typeof entry.lastSeenAt === 'number' ? entry.lastSeenAt : null, + userAgent: typeof entry.userAgent === 'string' ? entry.userAgent : undefined, + // 'ios' (APNs) or 'android' (FCM). Older entries without one are APNs by default. + platform: entry.platform === 'android' ? 'android' : 'ios', + }; + }) + .filter(Boolean); + }; + + // Normalize an incoming platform hint to the two we support; default to APNs/iOS since that + // was the only registrant before Android/FCM existed. + const normalizePlatform = (platform) => (platform === 'android' ? 'android' : 'ios'); + + const addOrUpdateApnsToken = async (uiSessionToken, deviceToken, userAgent, platform) => { + if (!uiSessionToken || typeof deviceToken !== 'string' || deviceToken.trim().length === 0) return; + const token = deviceToken.trim(); + const tokenPlatform = normalizePlatform(platform); + const now = Date.now(); + + await persistTokenUpdate((current) => { + const tokensBySession = { ...(current.tokensBySession || {}) }; + const existing = normalizeTokens(tokensBySession[uiSessionToken]); + const filtered = existing.filter((entry) => entry.deviceToken !== token); + filtered.unshift({ + deviceToken: token, + createdAt: now, + lastSeenAt: now, + userAgent: typeof userAgent === 'string' && userAgent.length > 0 ? userAgent : undefined, + platform: tokenPlatform, + }); + tokensBySession[uiSessionToken] = filtered.slice(0, MAX_TOKENS_PER_SESSION); + return { version: APNS_TOKENS_VERSION, tokensBySession }; + }); + + // (Re)bind this token to our server on the relay so only we can push to it. The device + // re-sends its token on each launch; this is an idempotent upsert relay-side, and binding + // every time (not just for new tokens) keeps existing tokens bound after a relay/server + // upgrade rather than silently going unbound. Platform is bound too so the relay routes + // it to APNs vs FCM. + await registerTokenWithRelay(token, tokenPlatform); + }; + + const removeApnsToken = async (uiSessionToken, deviceToken) => { + if (!uiSessionToken || !deviceToken) return; + await persistTokenUpdate((current) => { + const tokensBySession = { ...(current.tokensBySession || {}) }; + const filtered = normalizeTokens(tokensBySession[uiSessionToken]).filter( + (entry) => entry.deviceToken !== deviceToken, + ); + if (filtered.length === 0) delete tokensBySession[uiSessionToken]; + else tokensBySession[uiSessionToken] = filtered; + return { version: APNS_TOKENS_VERSION, tokensBySession }; + }); + }; + + const removeApnsTokenFromAllSessions = async (deviceToken) => { + if (!deviceToken) return; + await persistTokenUpdate((current) => { + const tokensBySession = { ...(current.tokensBySession || {}) }; + for (const [session, entries] of Object.entries(tokensBySession)) { + const filtered = normalizeTokens(entries).filter((entry) => entry.deviceToken !== deviceToken); + if (filtered.length === 0) delete tokensBySession[session]; + else tokensBySession[session] = filtered; + } + return { version: APNS_TOKENS_VERSION, tokensBySession }; + }); + }; + + // --------------------------------------------------------------------------- + // Config (env first, then settings.apnsConfig) — mirrors resolveVapidSubject + // --------------------------------------------------------------------------- + + const resolveApnsConfig = async () => { + let keyId = trimmedEnv('OPENCHAMBER_APNS_KEY_ID'); + let teamId = trimmedEnv('OPENCHAMBER_APNS_TEAM_ID'); + let bundleId = trimmedEnv('OPENCHAMBER_APNS_BUNDLE_ID'); + let environment = (trimmedEnv('OPENCHAMBER_APNS_ENVIRONMENT') || '').toLowerCase(); + let p8 = normalizePem(process.env.OPENCHAMBER_APNS_P8 || ''); + + const p8Path = trimmedEnv('OPENCHAMBER_APNS_P8_PATH'); + if (!p8 && p8Path) { + try { + p8 = (await fsPromises.readFile(p8Path, 'utf8')).trim(); + } catch (error) { + console.warn('[APNs] Failed to read OPENCHAMBER_APNS_P8_PATH:', error?.message ?? error); + } + } + + if (!keyId || !teamId || !p8) { + try { + const settings = await readSettingsFromDiskMigrated(); + const stored = settings?.apnsConfig; + if (stored && typeof stored === 'object') { + keyId = keyId || (typeof stored.keyId === 'string' ? stored.keyId.trim() : null); + teamId = teamId || (typeof stored.teamId === 'string' ? stored.teamId.trim() : null); + bundleId = bundleId || (typeof stored.bundleId === 'string' ? stored.bundleId.trim() : null); + environment = environment || (typeof stored.environment === 'string' ? stored.environment.toLowerCase() : ''); + if (!p8 && typeof stored.p8 === 'string') p8 = normalizePem(stored.p8); + } + } catch { + // settings unavailable — fall through to the unconfigured result + } + } + + if (!keyId || !teamId || !p8) return null; + + return { + keyId, + teamId, + p8, + bundleId: bundleId || DEFAULT_BUNDLE_ID, + environment: environment === 'production' ? 'production' : 'sandbox', + }; + }; + + // --------------------------------------------------------------------------- + // JWT (ES256, JOSE/raw signature) + HTTP/2 send + // --------------------------------------------------------------------------- + + const signApnsJwt = (config) => { + const header = Buffer.from(JSON.stringify({ alg: 'ES256', kid: config.keyId })).toString('base64url'); + const claims = Buffer.from( + JSON.stringify({ iss: config.teamId, iat: Math.floor(Date.now() / 1000) }), + ).toString('base64url'); + const signingInput = `${header}.${claims}`; + const signature = crypto + .sign('sha256', Buffer.from(signingInput), { key: config.p8, dsaEncoding: 'ieee-p1363' }) + .toString('base64url'); + return `${signingInput}.${signature}`; + }; + + const getJwt = (config) => { + const now = Date.now(); + if (cachedJwt && cachedJwt.keyId === config.keyId && now - cachedJwt.issuedAtMs < JWT_TTL_MS) { + return cachedJwt.token; + } + const token = signApnsJwt(config); + cachedJwt = { token, issuedAtMs: now, keyId: config.keyId }; + return token; + }; + + const buildBody = (payload) => { + const data = payload && typeof payload.data === 'object' && payload.data ? payload.data : {}; + return JSON.stringify({ + aps: { + alert: { + title: typeof payload?.title === 'string' ? payload.title : undefined, + body: typeof payload?.body === 'string' ? payload.body : undefined, + }, + badge: Number.isFinite(payload?.badge) && payload.badge >= 0 ? Math.trunc(payload.badge) : undefined, + sound: 'default', + 'thread-id': typeof payload?.tag === 'string' ? payload.tag : undefined, + // Wakes the Notification Service Extension so it can refresh the home/lock-screen + // widgets (attention count + unread dot) from the push, even when the app is closed. + // No extra network call — just an extra key on the push we already send. + 'mutable-content': 1, + }, + ...data, + }); + }; + + const sendOne = (client, deviceToken, body, jwt, config) => + new Promise((resolve) => { + const headers = { + ':method': 'POST', + ':path': `/3/device/${deviceToken}`, + authorization: `bearer ${jwt}`, + 'apns-topic': config.bundleId, + 'apns-push-type': 'alert', + 'apns-priority': '10', + }; + // collapse-id dedups like web-push tags; APNs caps it at 64 bytes. + const collapseId = typeof config.tag === 'string' ? config.tag.slice(0, 64) : undefined; + if (collapseId) headers['apns-collapse-id'] = collapseId; + + let req; + try { + req = client.request(headers); + } catch (error) { + console.warn('[APNs] request open failed:', error?.message ?? error); + resolve(); + return; + } + + let status = 0; + let responseBody = ''; + req.on('response', (resHeaders) => { + status = Number(resHeaders[':status']) || 0; + }); + req.setEncoding('utf8'); + req.on('data', (chunk) => { + responseBody += chunk; + }); + req.on('end', async () => { + if (status === 200) { + resolve(); + return; + } + let reason = ''; + try { + reason = JSON.parse(responseBody)?.reason || ''; + } catch { + // non-JSON error body + } + if (status === 410 || DEAD_TOKEN_REASONS.has(reason)) { + await removeApnsTokenFromAllSessions(deviceToken); + } else { + console.warn(`[APNs] push failed status=${status} reason=${reason || 'unknown'}`); + } + resolve(); + }); + req.on('error', (error) => { + console.warn('[APNs] request error:', error?.message ?? error); + resolve(); + }); + req.end(body); + }); + + // Relay mode (default): the single APNs key lives in the central Cloudflare relay, not on + // each user's server — so users configure nothing. The server just POSTs device tokens + + // generic text; the relay signs + sends and reports which tokens to drop. Direct mode (below) + // is the fallback for self-hosters who set OPENCHAMBER_APNS_* and disable the relay. + const resolveRelayConfig = () => { + if (trimmedEnv('OPENCHAMBER_PUSH_RELAY_DISABLED') === 'true') return null; + const url = trimmedEnv('OPENCHAMBER_PUSH_RELAY_URL') || DEFAULT_RELAY_URL; + return { + url, + registerUrl: url.replace(/\/send$/, '/register-token'), + environment: + (trimmedEnv('OPENCHAMBER_APNS_ENVIRONMENT') || 'sandbox').toLowerCase() === 'production' + ? 'production' + : 'sandbox', + }; + }; + + const sendViaRelay = async (deviceTokens, payload, relay) => { + const tokens = deviceTokens.slice(0, 100); + const title = typeof payload?.title === 'string' && payload.title.length > 0 ? payload.title : 'OpenChamber'; + const { privateKey, publicJwk } = await getOrCreateRelayKeypair(); + const ts = Date.now(); + // Sign over the same canonical form the relay verifies: ts.sortedTokens.title. + const sig = signRelayMessage(privateKey, `${ts}.${[...tokens].sort().join(',')}.${title}`); + const requestBody = JSON.stringify({ + tokens, + title, + body: typeof payload?.body === 'string' ? payload.body : '', + badge: Number.isFinite(payload?.badge) && payload.badge >= 0 ? Math.trunc(payload.badge) : undefined, + collapseId: typeof payload?.tag === 'string' ? payload.tag.slice(0, 64) : undefined, + env: relay.environment, + data: payload?.data && typeof payload.data === 'object' ? payload.data : undefined, + publicKeyJwk: relayPublicJwk(publicJwk), + ts, + sig, + }); + try { + const res = await fetch(relay.url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: requestBody, + }); + if (!res.ok) { + console.warn(`[APNs relay] send failed status=${res.status}`); + return; + } + const data = await res.json().catch(() => null); + const results = Array.isArray(data?.results) ? data.results : []; + for (const result of results) { + if (result && result.drop === true && typeof result.token === 'string') { + await removeApnsTokenFromAllSessions(result.token); + } + } + } catch (error) { + console.warn('[APNs relay] request failed:', error?.message ?? error); + } + }; + + const sendViaDirectApns = async (deviceTokens, payload) => { + const config = await resolveApnsConfig(); + if (!config) { + if (!warnedUnconfigured) { + warnedUnconfigured = true; + console.warn( + '[APNs] Relay disabled and no direct config; set OPENCHAMBER_APNS_KEY_ID / OPENCHAMBER_APNS_TEAM_ID / OPENCHAMBER_APNS_P8 for direct send.', + ); + } + return; + } + + const host = config.environment === 'production' ? APNS_HOST_PRODUCTION : APNS_HOST_SANDBOX; + const jwt = getJwt(config); + const body = buildBody(payload); + const sendConfig = { ...config, tag: typeof payload?.tag === 'string' ? payload.tag : undefined }; + + let client; + try { + client = http2.connect(host); + } catch (error) { + console.warn('[APNs] connect failed:', error?.message ?? error); + return; + } + + await new Promise((resolve) => { + let settled = false; + const finish = () => { + if (settled) return; + settled = true; + try { + client.close(); + } catch { + // ignore close errors + } + resolve(); + }; + client.on('error', (error) => { + console.warn('[APNs] session error:', error?.message ?? error); + finish(); + }); + Promise.all( + deviceTokens.map((token) => sendOne(client, token, body, jwt, sendConfig)), + ).finally(finish); + }); + }; + + // NOT gated on UI visibility (unlike web push). A backgrounded WKWebView can't reliably + // report "hidden" before iOS suspends it, so a visibility gate wrongly suppressed + // background push for short responses. Instead we always send, and rely on iOS to NOT + // display the alert while the app is foreground (presentationOptions: [] in + // capacitor.config) — so there is no notification when the app is active, with no race. + const sendApnsToAllUiSessions = async (payload, _options = {}) => { + const store = await readTokensFromDisk(); + const deviceTokens = []; + const seen = new Set(); + for (const record of Object.values(store.tokensBySession || {})) { + for (const entry of normalizeTokens(record)) { + if (!seen.has(entry.deviceToken)) { + seen.add(entry.deviceToken); + deviceTokens.push(entry.deviceToken); + } + } + } + if (deviceTokens.length === 0) return; + + const relay = resolveRelayConfig(); + if (relay) { + await sendViaRelay(deviceTokens, payload, relay); + return; + } + await sendViaDirectApns(deviceTokens, payload); + }; + + return { + addOrUpdateApnsToken, + removeApnsToken, + removeApnsTokenFromAllSessions, + sendApnsToAllUiSessions, + resolveApnsConfig, + // exposed for tests + signApnsJwt, + }; +}; diff --git a/packages/web/server/lib/notifications/apns-runtime.test.js b/packages/web/server/lib/notifications/apns-runtime.test.js new file mode 100644 index 00000000..5605ddc7 --- /dev/null +++ b/packages/web/server/lib/notifications/apns-runtime.test.js @@ -0,0 +1,196 @@ +import crypto from 'node:crypto'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createApnsRuntime } from './apns-runtime.js'; + +// A real P-256 key so the ES256 signing path (direct mode) runs for real. +const { privateKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' }); +const P8 = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(); +const APNS_CONFIG = { keyId: 'KEY123', teamId: 'TEAM123', p8: P8, bundleId: 'com.openchamber.app', environment: 'sandbox' }; + +// In-memory fs so add-then-read reflects within a test. +const createMemoryFs = () => { + let content = null; + return { + mkdir: vi.fn(async () => {}), + readFile: vi.fn(async () => { + if (content == null) { + const err = new Error('ENOENT'); + err.code = 'ENOENT'; + throw err; + } + return content; + }), + writeFile: vi.fn(async (_path, data) => { + content = data; + }), + }; +}; + +const makeDeps = (overrides = {}) => { + // Stateful settings so the auto-generated relay signing keypair persists + reads back. + let settings = {}; + return { + fsPromises: createMemoryFs(), + path: { dirname: () => '/tmp' }, + crypto, + http2: { connect: vi.fn(() => { throw new Error('http2 must not be used in relay mode'); }) }, + APNS_TOKENS_FILE_PATH: '/tmp/apns-tokens.json', + readSettingsFromDiskMigrated: vi.fn(async () => settings), + writeSettingsToDisk: vi.fn(async (next) => { settings = next; }), + ...overrides, + }; +}; + +const jsonResponse = (data, status = 200) => + new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json' } }); + +// Mirror of the relay's verifier (crypto.subtle), to prove the server's signatures are valid. +const verifyRelaySignature = async (publicKeyJwk, message, sigB64Url) => { + const key = await crypto.subtle.importKey( + 'jwk', + { kty: publicKeyJwk.kty, crv: publicKeyJwk.crv, x: publicKeyJwk.x, y: publicKeyJwk.y }, + { name: 'ECDSA', namedCurve: 'P-256' }, + false, + ['verify'], + ); + return crypto.subtle.verify( + { name: 'ECDSA', hash: 'SHA-256' }, + key, + new Uint8Array(Buffer.from(sigB64Url, 'base64url')), + new TextEncoder().encode(message), + ); +}; + +const isRegister = ([url]) => String(url).endsWith('/register-token'); +const isSend = ([url]) => String(url) === 'https://relay.test/v1/push/send'; + +afterEach(() => { + vi.unstubAllGlobals(); + delete process.env.OPENCHAMBER_PUSH_RELAY_URL; + delete process.env.OPENCHAMBER_PUSH_RELAY_DISABLED; +}); + +describe('apns runtime relay mode (default)', () => { + it('registers tokens (signed) and posts signed generic text, dropping dead tokens', async () => { + const fetchMock = vi.fn(async (url) => + isRegister([url]) + ? jsonResponse({ ok: true }) + : jsonResponse({ + results: [ + { token: 'tokenA', ok: true, drop: false }, + { token: 'tokenDead', ok: false, drop: true }, + ], + }), + ); + vi.stubGlobal('fetch', fetchMock); + process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send'; + + const runtime = createApnsRuntime(makeDeps()); + await runtime.addOrUpdateApnsToken('s1', 'tokenA'); + await runtime.addOrUpdateApnsToken('s2', 'tokenDead'); + + // Each new token is bound on the relay with a signed register-token call. + const registerCalls = fetchMock.mock.calls.filter(isRegister); + expect(registerCalls).toHaveLength(2); + for (const [url, init] of registerCalls) { + expect(url).toBe('https://relay.test/v1/push/register-token'); + const body = JSON.parse(init.body); + expect(body.publicKeyJwk).toMatchObject({ kty: 'EC', crv: 'P-256' }); + expect(typeof body.ts).toBe('number'); + expect(body.platform).toBe('ios'); + expect(await verifyRelaySignature(body.publicKeyJwk, `${body.ts}.${body.token}.${body.platform}`, body.sig)).toBe(true); + } + + fetchMock.mockClear(); + await runtime.sendApnsToAllUiSessions( + { title: 'Agent response is ready', body: 'My session', badge: 3, tag: 'ready-x', data: { sessionId: 'sess1' } }, + {}, + ); + + const sendCall = fetchMock.mock.calls.find(isSend); + expect(sendCall).toBeTruthy(); + const sent = JSON.parse(sendCall[1].body); + expect(sendCall[1].headers.authorization).toBeUndefined(); + expect(new Set(sent.tokens)).toEqual(new Set(['tokenA', 'tokenDead'])); + expect(sent.title).toBe('Agent response is ready'); + expect(sent.body).toBe('My session'); + expect(sent.badge).toBe(3); + expect(sent.data).toEqual({ sessionId: 'sess1' }); + expect(sent.publicKeyJwk).toMatchObject({ kty: 'EC', crv: 'P-256' }); + const sendMessage = `${sent.ts}.${[...sent.tokens].sort().join(',')}.${sent.title}`; + expect(await verifyRelaySignature(sent.publicKeyJwk, sendMessage, sent.sig)).toBe(true); + + // tokenDead should have been dropped → next send targets only tokenA. + fetchMock.mockClear(); + await runtime.sendApnsToAllUiSessions({ title: 'x', body: 'y', tag: 't' }, {}); + expect(JSON.parse(fetchMock.mock.calls.find(isSend)[1].body).tokens).toEqual(['tokenA']); + }); + + it('reuses one persisted keypair (same serverId) across register + send', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] })); + vi.stubGlobal('fetch', fetchMock); + process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send'; + + const deps = makeDeps(); + const runtime = createApnsRuntime(deps); + await runtime.addOrUpdateApnsToken('s1', 'tokenA'); + await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b', tag: 'x' }, {}); + + const keys = fetchMock.mock.calls.map(([, init]) => JSON.parse(init.body).publicKeyJwk); + expect(keys.length).toBeGreaterThanOrEqual(2); + expect(keys.every((k) => k.x === keys[0].x && k.y === keys[0].y)).toBe(true); + // Keypair was generated + persisted exactly once. + expect(deps.writeSettingsToDisk).toHaveBeenCalledTimes(1); + }); + + it('no-ops (no relay call) when no tokens are registered', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const runtime = createApnsRuntime(makeDeps()); + await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b' }); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe('apns runtime direct fallback (relay disabled)', () => { + it('signs an ES256 JWT and sends over http2 when relay is disabled', async () => { + process.env.OPENCHAMBER_PUSH_RELAY_DISABLED = 'true'; + const targeted = []; + const http2 = { + connect: () => ({ + on: () => {}, + close: () => {}, + request: (headers) => { + targeted.push(String(headers[':path']).replace('/3/device/', '')); + const listeners = {}; + const req = { + on: (event, cb) => { listeners[event] = cb; return req; }, + setEncoding: () => req, + end: () => { + queueMicrotask(() => { + listeners.response?.({ ':status': '200' }); + listeners.end?.(); + }); + }, + }; + return req; + }, + }), + }; + const runtime = createApnsRuntime( + makeDeps({ http2, readSettingsFromDiskMigrated: vi.fn(async () => ({ apnsConfig: APNS_CONFIG })) }), + ); + await runtime.addOrUpdateApnsToken('s', 'tokenDirect'); + await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b', tag: 'ready-x' }); + expect(targeted).toEqual(['tokenDirect']); + }); + + it('signApnsJwt produces a 3-part ES256 token with the expected header/claims', () => { + const runtime = createApnsRuntime(makeDeps()); + const parts = runtime.signApnsJwt(APNS_CONFIG).split('.'); + expect(parts).toHaveLength(3); + expect(JSON.parse(Buffer.from(parts[0], 'base64url').toString())).toEqual({ alg: 'ES256', kid: 'KEY123' }); + expect(JSON.parse(Buffer.from(parts[1], 'base64url').toString()).iss).toBe('TEAM123'); + }); +}); diff --git a/packages/web/server/lib/notifications/push-runtime.js b/packages/web/server/lib/notifications/push-runtime.js index ab776a8d..01abcb08 100644 --- a/packages/web/server/lib/notifications/push-runtime.js +++ b/packages/web/server/lib/notifications/push-runtime.js @@ -115,12 +115,13 @@ export const createPushRuntime = (deps) => { p256dh, auth, createdAt: typeof entry.createdAt === 'number' ? entry.createdAt : null, + platform: typeof entry.platform === 'string' ? entry.platform : undefined, }; }) .filter(Boolean); }; - const addOrUpdatePushSubscription = async (uiSessionToken, subscription, userAgent) => { + const addOrUpdatePushSubscription = async (uiSessionToken, subscription, userAgent, platform) => { if (!uiSessionToken) { return; } @@ -135,6 +136,7 @@ export const createPushRuntime = (deps) => { const filtered = existing.filter((entry) => entry && typeof entry.endpoint === 'string' && entry.endpoint !== subscription.endpoint); + const previous = existing.find((entry) => entry && entry.endpoint === subscription.endpoint); filtered.unshift({ endpoint: subscription.endpoint, p256dh: subscription.p256dh, @@ -142,6 +144,13 @@ export const createPushRuntime = (deps) => { createdAt: now, lastSeenAt: now, userAgent: typeof userAgent === 'string' && userAgent.length > 0 ? userAgent : undefined, + // Platform lets the sender route mobile PWA push through the same presence gate as APNs. + platform: + typeof platform === 'string' && platform + ? platform + : typeof previous?.platform === 'string' + ? previous.platform + : undefined, }); subsBySession[uiSessionToken] = filtered.slice(0, 10); @@ -230,18 +239,32 @@ export const createPushRuntime = (deps) => { } await Promise.all(Array.from(subscriptionsByEndpoint.values()).map(async (sub) => { - if (requireNoSse && isAnyUiVisible()) { - return; + if (requireNoSse) { + // Mobile PWA subscriptions follow the same presence model as native push: suppress only + // when an interactive (desktop/web) client is visible. The phone PWA's own foreground is + // handled in the service worker (focused-client check), so it won't double-notify. + // Non-mobile (desktop/web) subscriptions keep the existing any-visible gate. + const suppressed = isMobilePlatform(sub.platform) ? isAnyInteractiveClientVisible() : isAnyUiVisible(); + if (suppressed) return; } await sendPushToSubscription(sub, payload); })); }; - const updateUiVisibility = (token, visible) => { + // A client is "mobile" if it reports a native mobile platform. Anything else (web, desktop, + // vscode, or an older client that doesn't report a platform) is treated as interactive — i.e. + // a surface where the user would actually see the in-app notification. + const MOBILE_PLATFORMS = new Set(['ios', 'android']); + const isMobilePlatform = (platform) => typeof platform === 'string' && MOBILE_PLATFORMS.has(platform); + + const updateUiVisibility = (token, visible, platform) => { if (!token) return; const now = Date.now(); const nextVisible = Boolean(visible); - uiVisibilityByToken.set(token, { visible: nextVisible, updatedAt: now }); + const existing = uiVisibilityByToken.get(token); + // Keep the last known platform if this beacon didn't carry one (e.g. a heartbeat). + const nextPlatform = typeof platform === 'string' && platform ? platform : existing?.platform; + uiVisibilityByToken.set(token, { visible: nextVisible, updatedAt: now, platform: nextPlatform }); }; const isAnyUiVisible = () => { @@ -255,6 +278,25 @@ export const createPushRuntime = (deps) => { return false; }; + // True when at least one NON-mobile client (desktop/web/vscode) is currently visible. Used to + // suppress native push to the phone: an active desktop already shows the notification, so the + // phone doesn't need it. Deliberately based on the desktop's visibility (reliable), never the + // phone's own (a backgrounded WKWebView can't report "hidden" before iOS suspends it). + const isAnyInteractiveClientVisible = () => { + const now = Date.now(); + pruneUiVisibility(now); + for (const state of uiVisibilityByToken.values()) { + if ( + state.visible === true && + now - state.updatedAt <= UI_VISIBILITY_TTL_MS && + !isMobilePlatform(state.platform) + ) { + return true; + } + } + return false; + }; + const isUiVisible = (token) => { const now = Date.now(); pruneUiVisibility(now); @@ -317,6 +359,7 @@ export const createPushRuntime = (deps) => { sendPushToAllUiSessions, updateUiVisibility, isAnyUiVisible, + isAnyInteractiveClientVisible, isUiVisible, ensurePushInitialized, setPushInitialized, diff --git a/packages/web/server/lib/notifications/push-runtime.test.js b/packages/web/server/lib/notifications/push-runtime.test.js index cfcb756e..20de23a8 100644 --- a/packages/web/server/lib/notifications/push-runtime.test.js +++ b/packages/web/server/lib/notifications/push-runtime.test.js @@ -42,4 +42,38 @@ describe('push runtime visibility tracking', () => { expect(runtime.isAnyUiVisible()).toBe(false); expect(runtime.isUiVisible('visible-client')).toBe(false); }); + + it('treats only mobile platforms as non-interactive for isAnyInteractiveClientVisible', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + + const runtime = createRuntime(); + + // Only the phone (foreground) is connected → no interactive client to absorb the notification. + runtime.updateUiVisibility('phone', true, 'ios'); + expect(runtime.isAnyUiVisible()).toBe(true); + expect(runtime.isAnyInteractiveClientVisible()).toBe(false); + + // A visible desktop counts as interactive → suppress mobile push. + runtime.updateUiVisibility('desktop', true, 'desktop'); + expect(runtime.isAnyInteractiveClientVisible()).toBe(true); + + // Desktop hidden again → back to mobile-only, push should flow to the phone. + runtime.updateUiVisibility('desktop', false, 'desktop'); + expect(runtime.isAnyInteractiveClientVisible()).toBe(false); + + // A client that never reported a platform is treated as interactive (conservative). + runtime.updateUiVisibility('legacy', true); + expect(runtime.isAnyInteractiveClientVisible()).toBe(true); + }); + + it('remembers the last platform when a heartbeat omits it', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + + const runtime = createRuntime(); + runtime.updateUiVisibility('phone', true, 'android'); + runtime.updateUiVisibility('phone', true); // heartbeat without platform + expect(runtime.isAnyInteractiveClientVisible()).toBe(false); + }); }); diff --git a/packages/web/server/lib/notifications/routes.js b/packages/web/server/lib/notifications/routes.js index 32ea30e4..4f291a28 100644 --- a/packages/web/server/lib/notifications/routes.js +++ b/packages/web/server/lib/notifications/routes.js @@ -35,7 +35,10 @@ export const registerNotificationRoutes = (app, dependencies) => { writeSettingsToDisk, addOrUpdatePushSubscription, removePushSubscription, + addOrUpdateApnsToken, + removeApnsToken, updateUiVisibility, + clearPendingPushBadge, isUiVisible, getUiNotificationClients, writeSseEvent, @@ -106,6 +109,7 @@ export const registerNotificationRoutes = (app, dependencies) => { } } + const platform = typeof req.body?.platform === 'string' ? req.body.platform : undefined; await addOrUpdatePushSubscription( uiToken, { @@ -113,7 +117,8 @@ export const registerNotificationRoutes = (app, dependencies) => { p256dh: keys.p256dh, auth: keys.auth, }, - req.headers['user-agent'] + req.headers['user-agent'], + platform ); return res.json({ ok: true }); @@ -138,6 +143,50 @@ export const registerNotificationRoutes = (app, dependencies) => { return res.json({ ok: true }); }); + // Native iOS APNs device token registration (mirrors /api/push/subscribe). The token + // is a hex APNs device token from @capacitor/push-notifications, scoped to the UI + // session like web-push subscriptions. + app.post('/api/push/apns-token', async (req, res) => { + await ensureSessionWatcher(); + + const uiToken = uiAuthController?.ensureSessionToken + ? await uiAuthController.ensureSessionToken(req, res) + : getUiSessionTokenFromRequest(req); + if (!uiToken) { + return res.status(401).json({ error: 'UI session missing' }); + } + + const deviceToken = typeof req.body?.token === 'string' ? req.body.token.trim() : ''; + if (!deviceToken) { + return res.status(400).json({ error: 'Invalid body' }); + } + + const platform = req.body?.platform === 'android' ? 'android' : 'ios'; + if (typeof addOrUpdateApnsToken === 'function') { + await addOrUpdateApnsToken(uiToken, deviceToken, req.headers['user-agent'], platform); + } + return res.json({ ok: true }); + }); + + app.delete('/api/push/apns-token', async (req, res) => { + const uiToken = uiAuthController?.ensureSessionToken + ? await uiAuthController.ensureSessionToken(req, res) + : getUiSessionTokenFromRequest(req); + if (!uiToken) { + return res.status(401).json({ error: 'UI session missing' }); + } + + const deviceToken = typeof req.body?.token === 'string' ? req.body.token.trim() : ''; + if (!deviceToken) { + return res.status(400).json({ error: 'Invalid body' }); + } + + if (typeof removeApnsToken === 'function') { + await removeApnsToken(uiToken, deviceToken); + } + return res.json({ ok: true }); + }); + app.post('/api/push/visibility', async (req, res) => { const uiToken = uiAuthController?.ensureSessionToken ? await uiAuthController.ensureSessionToken(req, res) @@ -146,8 +195,9 @@ export const registerNotificationRoutes = (app, dependencies) => { return res.status(401).json({ error: 'UI session missing' }); } - const visible = req.body && typeof req.body === 'object' ? req.body.visible : null; - updateUiVisibility(uiToken, visible === true); + const body = req.body && typeof req.body === 'object' ? req.body : {}; + const platform = typeof body.platform === 'string' ? body.platform : undefined; + updateUiVisibility(uiToken, body.visible === true, platform); return res.json({ ok: true }); }); @@ -301,6 +351,10 @@ export const registerNotificationRoutes = (app, dependencies) => { const clientId = req.headers['x-client-id'] || req.ip || 'anonymous'; markSessionViewed(sessionId, clientId); + // The user is engaging with the app, so the native push badge no longer + // applies — reset it here too (not only on the visibility beacon), since + // opening the app reliably marks the opened session viewed. + if (typeof clearPendingPushBadge === 'function') clearPendingPushBadge(); return res.json({ success: true, @@ -326,6 +380,9 @@ export const registerNotificationRoutes = (app, dependencies) => { const sessionId = req.params.id; markUserMessageSent(sessionId); + // Sending a message means the user is active in the app; reset the native + // push badge so it counts only notifications since this engagement. + if (typeof clearPendingPushBadge === 'function') clearPendingPushBadge(); return res.json({ success: true, diff --git a/packages/web/server/lib/notifications/runtime.js b/packages/web/server/lib/notifications/runtime.js index 5a2d8259..01e8a245 100644 --- a/packages/web/server/lib/notifications/runtime.js +++ b/packages/web/server/lib/notifications/runtime.js @@ -10,10 +10,84 @@ export const createNotificationTriggerRuntime = (deps) => { emitDesktopNotification, broadcastUiNotification, sendPushToAllUiSessions, + sendApnsToAllUiSessions, + isAnyInteractiveClientVisible, buildOpenCodeUrl, getOpenCodeAuthHeaders, } = deps; + // App-icon badge for native push: the set of DISTINCT collapse-ids (the push + // `tag`, e.g. `ready-` / `permission-`) we've sent since + // the app was last foregrounded. The badge is the absolute APNs `aps.badge`. + // + // We key by `tag`, not sessionId, because the tag IS the banner identity: iOS + // uses it as `apns-collapse-id`, so same-tag pushes REPLACE one banner while + // different tags are distinct banners. One session can raise several banners + // (ready + question + permission are different tags), so counting sessionIds + // both over- and under-counts the lock-screen stack; counting tags mirrors it. + // + // We deliberately do NOT derive this from the live attention snapshot + // (needsAttention/isViewed): that machinery is for in-app indicators on + // connected clients — a backgrounded client stays "viewing", and needsAttention + // is set by a separate session.status event that races the push trigger. The + // set is cleared when a UI client reports visible (`clearPendingPushBadge`), + // the same moment the device zeroes its icon badge on becomeActive. + const pendingPushTags = new Set(); + const clearPendingPushBadge = () => { + pendingPushTags.clear(); + }; + const trackPushAndCountBadge = (tag) => { + if (typeof tag === 'string' && tag.length > 0) { + pendingPushTags.add(tag); + } + return pendingPushTags.size; + }; + + // Generic notification for native push (per the mobile design): a fixed, scenario-based + // title + the session name as the body. No model/project/message content crosses the relay. + const APNS_TITLE_BY_TYPE = { + ready: 'Agent response is ready', + error: 'Agent hit an error', + question: 'Agent needs your input', + permission: 'Agent needs permission', + }; + + const toApnsGenericPayload = (payload) => { + const data = payload?.data && typeof payload.data === 'object' ? payload.data : {}; + const sessionName = typeof data.sessionName === 'string' && data.sessionName.trim().length > 0 + ? data.sessionName.trim() + : 'Session'; + return { + title: APNS_TITLE_BY_TYPE[data.type] || 'Agent update', + body: sessionName, + badge: trackPushAndCountBadge(typeof payload?.tag === 'string' ? payload.tag : undefined), + tag: payload?.tag, + // sessionId is forwarded so a tapped push can deep-link; it is an opaque id, not content. + data: typeof data.sessionId === 'string' ? { sessionId: data.sessionId } : undefined, + }; + }; + + // Fan a notification out to every delivery channel: browser web-push (full templated + // payload) and native iOS APNs (generic model-based text). Both share the dedup tag and + // `requireNoSse` focus gate; a failure in one channel must not block the other. + const fanoutPush = (payload, options) => { + // Presence-aware routing: if any interactive (non-mobile) client — desktop/web/vscode — is + // currently visible, it already shows the in-app notification, so skip the native push to the + // phone. Gated on the desktop's visibility (reliable), never the phone's own. When we skip we + // also skip toApnsGenericPayload, so the badge isn't incremented for an undelivered push. + const interactiveVisible = isAnyInteractiveClientVisible?.() === true; + return Promise.all([ + Promise.resolve(sendPushToAllUiSessions?.(payload, options)).catch((error) => { + console.warn('[Push] web-push fanout failed:', error?.message ?? error); + }), + interactiveVisible + ? Promise.resolve() + : Promise.resolve(sendApnsToAllUiSessions?.(toApnsGenericPayload(payload), options)).catch((error) => { + console.warn('[APNs] fanout failed:', error?.message ?? error); + }), + ]); + }; + let getIsWindowFocused = typeof deps.getIsWindowFocused === 'function' ? deps.getIsWindowFocused : null; @@ -240,6 +314,7 @@ export const createNotificationTriggerRuntime = (deps) => { let title = `${formatMode(info?.mode)} agent is ready`; let body = `${formatModelId(info?.modelID)} completed the task`; + let sessionName = ''; try { const templates = settings.notificationTemplates || {}; @@ -249,6 +324,7 @@ export const createNotificationTriggerRuntime = (deps) => { : (templates.completion || { title: '{agent_name} is ready', message: '{model_name} completed the task' }); const variables = await buildTemplateVariables(payload, sessionId); + sessionName = typeof variables.session_name === 'string' ? variables.session_name : sessionName; const messageId = info?.id; let lastMessage = extractLastMessageText(payload); @@ -283,7 +359,7 @@ export const createNotificationTriggerRuntime = (deps) => { broadcastUiNotification(notificationPayload, { desktopNotificationDelivered }); } - await sendPushToAllUiSessions( + await fanoutPush( { title, body, @@ -291,6 +367,7 @@ export const createNotificationTriggerRuntime = (deps) => { data: { url: buildSessionDeepLinkUrl(sessionId), sessionId, + sessionName, type: 'ready', }, }, @@ -308,9 +385,11 @@ export const createNotificationTriggerRuntime = (deps) => { let title = 'Tool error'; let body = 'An error occurred'; + let sessionName = ''; try { const variables = await buildTemplateVariables(payload, sessionId); + sessionName = typeof variables.session_name === 'string' ? variables.session_name : sessionName; const errorMessageId = info?.id; let lastMessage = extractLastMessageText(payload); if (!lastMessage) { @@ -345,7 +424,7 @@ export const createNotificationTriggerRuntime = (deps) => { broadcastUiNotification(notificationPayload, { desktopNotificationDelivered }); } - await sendPushToAllUiSessions( + await fanoutPush( { title, body, @@ -353,6 +432,7 @@ export const createNotificationTriggerRuntime = (deps) => { data: { url: buildSessionDeepLinkUrl(sessionId), sessionId, + sessionName, type: 'error', }, }, @@ -391,9 +471,11 @@ export const createNotificationTriggerRuntime = (deps) => { ? 'Switch to build mode' : header || 'Input needed'; let body = questionText || 'Agent is waiting for your response'; + let sessionName = ''; try { const variables = await buildTemplateVariables(payload, sessionId); + sessionName = typeof variables.session_name === 'string' ? variables.session_name : sessionName; variables.last_message = questionText || header || ''; const templates = settings.notificationTemplates || {}; @@ -421,7 +503,7 @@ export const createNotificationTriggerRuntime = (deps) => { broadcastUiNotification(notificationPayload, { desktopNotificationDelivered }); } - void sendPushToAllUiSessions( + void fanoutPush( { title, body, @@ -429,6 +511,7 @@ export const createNotificationTriggerRuntime = (deps) => { data: { url: buildSessionDeepLinkUrl(sessionId), sessionId, + sessionName, type: 'question', }, }, @@ -505,9 +588,11 @@ export const createNotificationTriggerRuntime = (deps) => { let title = 'Permission required'; let body = fallbackMessage; + let sessionName = ''; try { const variables = await buildTemplateVariables(payload, sessionId); + sessionName = typeof variables.session_name === 'string' ? variables.session_name : sessionName; variables.last_message = fallbackMessage; const templates = settings.notificationTemplates || {}; @@ -539,7 +624,7 @@ export const createNotificationTriggerRuntime = (deps) => { notifiedPermissionRequests.add(requestKey); } - void sendPushToAllUiSessions( + void fanoutPush( { title, body, @@ -547,6 +632,7 @@ export const createNotificationTriggerRuntime = (deps) => { data: { url: buildSessionDeepLinkUrl(sessionId), sessionId, + sessionName, type: 'permission', }, }, @@ -562,5 +648,6 @@ export const createNotificationTriggerRuntime = (deps) => { maybeSendPushForTrigger, setAutoAcceptSession, setGetIsWindowFocused, + clearPendingPushBadge, }; }; diff --git a/packages/web/server/lib/opencode/bootstrap-runtime.js b/packages/web/server/lib/opencode/bootstrap-runtime.js index 7b41d17c..49d43e98 100644 --- a/packages/web/server/lib/opencode/bootstrap-runtime.js +++ b/packages/web/server/lib/opencode/bootstrap-runtime.js @@ -32,7 +32,10 @@ export const createBootstrapRuntime = (dependencies) => { writeSettingsToDisk, addOrUpdatePushSubscription, removePushSubscription, + addOrUpdateApnsToken, + removeApnsToken, updateUiVisibility, + clearPendingPushBadge, isUiVisible, getUiNotificationClients, writeSseEvent, @@ -95,7 +98,10 @@ export const createBootstrapRuntime = (dependencies) => { writeSettingsToDisk, addOrUpdatePushSubscription, removePushSubscription, + addOrUpdateApnsToken, + removeApnsToken, updateUiVisibility, + clearPendingPushBadge, isUiVisible, getUiNotificationClients, writeSseEvent, diff --git a/packages/web/server/lib/security/request-security.js b/packages/web/server/lib/security/request-security.js index 183c3847..5fb85cde 100644 --- a/packages/web/server/lib/security/request-security.js +++ b/packages/web/server/lib/security/request-security.js @@ -1,6 +1,6 @@ export const createRequestSecurityRuntime = (deps) => { const { readSettingsFromDiskMigrated } = deps; - const packagedClientOrigins = new Set(['openchamber-ui://app']); + const packagedClientOrigins = new Set(['openchamber-ui://app', 'capacitor://localhost']); const getUiSessionTokenFromRequest = (req) => { const cookieHeader = req?.headers?.cookie; diff --git a/packages/web/server/lib/security/request-security.test.js b/packages/web/server/lib/security/request-security.test.js index a37cb057..031e8bef 100644 --- a/packages/web/server/lib/security/request-security.test.js +++ b/packages/web/server/lib/security/request-security.test.js @@ -6,7 +6,7 @@ const createRuntime = () => createRequestSecurityRuntime({ }); describe('request security runtime', () => { - test('allows packaged client origin for remote client transports', async () => { + test('allows packaged client origins for remote client transports', async () => { const runtime = createRuntime(); await expect(runtime.isRequestOriginAllowed({ @@ -16,5 +16,13 @@ describe('request security runtime', () => { }, socket: {}, })).resolves.toBe(true); + + await expect(runtime.isRequestOriginAllowed({ + headers: { + origin: 'capacitor://localhost', + host: '192.168.1.130:1202', + }, + socket: {}, + })).resolves.toBe(true); }); }); diff --git a/packages/web/src/api/push.ts b/packages/web/src/api/push.ts index 525e4f1f..d93047c2 100644 --- a/packages/web/src/api/push.ts +++ b/packages/web/src/api/push.ts @@ -1,4 +1,4 @@ -import type { PushAPI, PushSubscribePayload, PushUnsubscribePayload } from '@openchamber/ui/lib/api/types'; +import type { ApnsTokenPayload, PushAPI, PushSubscribePayload, PushUnsubscribePayload } from '@openchamber/ui/lib/api/types'; import { runtimeFetch } from '@openchamber/ui/lib/runtime-fetch'; const fetchJson = async (input: string | URL | Request, init?: RequestInit): Promise => { @@ -47,7 +47,7 @@ export const createWebPushAPI = (): PushAPI => ({ }); }, - async setVisibility(payload: { visible: boolean }) { + async setVisibility(payload: { visible: boolean; platform?: string }) { return fetchJson<{ ok: true }>('/api/push/visibility', { method: 'POST', headers: { @@ -57,4 +57,24 @@ export const createWebPushAPI = (): PushAPI => ({ keepalive: true, }); }, + + async registerApnsToken(payload: ApnsTokenPayload) { + return fetchJson<{ ok: true }>('/api/push/apns-token', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); + }, + + async unregisterApnsToken(payload: ApnsTokenPayload) { + return fetchJson<{ ok: true }>('/api/push/apns-token', { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); + }, }); From 73c9431883336b070d8c727137b808f1048b4226 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 1 Jul 2026 13:25:43 +0300 Subject: [PATCH 0068/1073] feat: add Clack-based local dev helper Adds a cross-platform oc-dev menu for web, mobile, Electron, VS Code, and release workflows Supports user-level config for remote deploys, iOS device preferences, and maintainer-only release tools Ports local deploy flows from Bash snippets to Node-native operations --- bun.lock | 1 + package.json | 2 + scripts/oc-dev.config.example.json | 28 ++ scripts/oc-dev.mjs | 606 +++++++++++++++++++++++++++++ 4 files changed, 637 insertions(+) create mode 100644 scripts/oc-dev.config.example.json create mode 100755 scripts/oc-dev.mjs diff --git a/bun.lock b/bun.lock index 72a8a905..52f0ff1d 100644 --- a/bun.lock +++ b/bun.lock @@ -68,6 +68,7 @@ "zustand": "^5.0.8", }, "devDependencies": { + "@clack/prompts": "^1.1.0", "@eslint/js": "^9.33.0", "@remixicon/react": "^4.7.0", "@tailwindcss/postcss": "^4.0.0", diff --git a/package.json b/package.json index 3d6f9a0c..6e9de3d3 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "license": "MIT", "scripts": { "dev": "node ./scripts/dev-web-hmr.mjs", + "oc-dev": "bun scripts/oc-dev.mjs", "build": "bun run --filter '*' build", "build:web": "bun run --cwd packages/web build", "build:ui": "bun run --cwd packages/ui build", @@ -148,6 +149,7 @@ "@codemirror/view": "6.39.13" }, "devDependencies": { + "@clack/prompts": "^1.1.0", "@eslint/js": "^9.33.0", "@tailwindcss/postcss": "^4.0.0", "@types/dom-speech-recognition": "^0.0.12", diff --git a/scripts/oc-dev.config.example.json b/scripts/oc-dev.config.example.json new file mode 100644 index 00000000..971c7f96 --- /dev/null +++ b/scripts/oc-dev.config.example.json @@ -0,0 +1,28 @@ +{ + "ios": { + "deviceName": "iPhone Example", + "useXcodeBeta": false, + "xcodeAppName": "Xcode" + }, + "features": { + "releaseTools": false + }, + "remoteDeployments": [ + { + "id": "example-api", + "label": "example API-only", + "host": "example-host", + "port": 3002, + "dir": "testing-dev", + "apiOnly": true + }, + { + "id": "example-ui", + "label": "example with UI", + "host": "example-host", + "port": 3002, + "dir": "testing-dev", + "apiOnly": false + } + ] +} diff --git a/scripts/oc-dev.mjs b/scripts/oc-dev.mjs new file mode 100755 index 00000000..b6c46dba --- /dev/null +++ b/scripts/oc-dev.mjs @@ -0,0 +1,606 @@ +#!/usr/bin/env node +/** + * OpenChamber local development helper. + * + * This script owns the interactive `bun run oc-dev` menu and the equivalent + * non-interactive commands for common local workflows: web deploys, mobile + * builds/device deploys, Electron, VS Code, and maintainer release tasks. + * + * Personal or machine-specific options are intentionally kept out of git. + * The only supported user config is: + * + * ~/.config/openchamber/oc-dev.json + * + * See `scripts/oc-dev.config.example.json` for the shape. The config can set + * local device/app preferences such as `ios.deviceName`, `ios.useXcodeBeta`, + * and `ios.xcodeAppName`, and can define `remoteDeployments`. Remote deploy + * menu entries are shown only when configured. Maintainer-only actions such as + * release creation are hidden unless `features.releaseTools` is true. + * + * Menus are platform-aware: macOS-only iOS/Xcode actions are hidden off macOS. + * Direct unsupported commands fail with a clear error instead of relying on + * prompts for safety. + */ +import { spawn, spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, unlinkSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { cancel, intro, isCancel, log, outro, select, text } from '@clack/prompts'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = path.resolve(__dirname, '..'); +const configPath = path.join(os.homedir(), '.config', 'openchamber', 'oc-dev.json'); + +const GLOBAL_PORT = '2606'; +const TESTING_PORT = '1202'; +const TESTING_DIR = 'testing-dev'; +const REMOTE_RUNTIME_ENV = 'PATH=$HOME/.opencode/bin:$HOME/.local/bin:$HOME/.bun/bin:$PATH; if [ -z "${OPENCODE_BINARY:-}" ]; then OPENCODE_CANDIDATE=$(command -v opencode 2>/dev/null || true); if [ -n "$OPENCODE_CANDIDATE" ]; then export OPENCODE_BINARY="$OPENCODE_CANDIDATE"; fi; fi'; + +const isTty = Boolean(process.stdout.isTTY) && Boolean(process.stdin.isTTY); +const isMac = process.platform === 'darwin'; + +function printHelp() { + console.log(`Usage: + bun run oc-dev [action] [options] + bun scripts/oc-dev.mjs [action] [options] + +Actions: + build-deploy-web Build web package and deploy + remote-deploy-web Deploy to configured remote target + start-web-dev Start web development loop + start-mobile-dev Start mobile app with dev server live reload + mobile-tools Mobile build/sync/deploy helper menu + start-electron-app Start Electron app in dev mode + build-electron-app Build Electron app artifacts + start-vscode-extension Build + launch VS Code extension host + install-vscode-extension-local Build, package, and install local VSIX + create-release Validate and bump release version + +Options: + -a, --action + --deployment-mode + --remote-id Remote deployment id from ${configPath} + --target Compatibility alias for remote deployment selection + --web-mode + --mobile-mode + --mobile-task + --vsix-cleanup + --version + -h, --help + +Mobile tasks: + build, sync, android-devices, android-deploy-usb, android-run, android-logcat, + ios-sim-build, ios-sim-run, ios-sim-serve, ios-sim-kill, ios-device-sync-debug +`); +} + +function parseArgs(argv) { + const options = {}; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + const readValue = () => { + const value = argv[index + 1]; + if (!value || value.startsWith('-')) throw new Error(`Missing value for ${arg}`); + index += 1; + return value; + }; + + switch (arg) { + case '-h': + case '--help': + options.help = true; + break; + case '-a': + case '--action': + options.action = readValue(); + break; + case '--deployment-mode': + options.deploymentMode = readValue(); + break; + case '--remote-id': + options.remoteId = readValue(); + break; + case '--target': + options.target = readValue(); + break; + case '--web-mode': + options.webMode = readValue(); + break; + case '--mobile-mode': + options.mobileMode = readValue(); + break; + case '--mobile-task': + options.mobileTask = readValue(); + break; + case '--vsix-cleanup': + options.vsixCleanup = readValue(); + break; + case '--version': + options.version = readValue(); + break; + default: + if (arg.startsWith('-')) throw new Error(`Unknown option: ${arg}`); + if (options.action) throw new Error(`Unexpected argument: ${arg}`); + options.action = arg; + break; + } + } + return options; +} + +function loadConfig() { + if (!existsSync(configPath)) return { remoteDeployments: [] }; + try { + const parsed = JSON.parse(readFileSync(configPath, 'utf8')); + return { + ...parsed, + remoteDeployments: Array.isArray(parsed.remoteDeployments) ? parsed.remoteDeployments : [], + }; + } catch (error) { + throw new Error(`Failed to read ${configPath}: ${error.message}`); + } +} + +function quote(value) { + return `'${String(value).replaceAll("'", "'\\''")}'`; +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd || repoRoot, + env: { ...process.env, ...(options.env || {}) }, + stdio: options.capture ? 'pipe' : 'inherit', + encoding: 'utf8', + shell: options.shell || false, + }); + if (result.status !== 0 && !options.allowFail) { + throw new Error(`${options.label || [command, ...args].join(' ')} failed`); + } + return result.stdout?.trim() || ''; +} + +function step(label, fn) { + log.step(label); + const result = fn(); + log.success(`${label} completed`); + return result; +} + +function normalizeAction(action = '') { + const normalized = action.toLowerCase(); + const aliases = { + 'deploy-web': 'build-deploy-web', + 'build/deploy-web': 'build-deploy-web', + 'web-dev': 'start-web-dev', + 'mobile-dev': 'start-mobile-dev', + 'ios-sim-dev': 'start-mobile-dev', + mobile: 'mobile-tools', + 'mobile-menu': 'mobile-tools', + 'remote-deploy-web': 'remote-deploy-web', + 'electron-dev': 'start-electron-app', + 'electron-build': 'build-electron-app', + 'vscode-dev': 'start-vscode-extension', + 'vscode-install-local': 'install-vscode-extension-local', + release: 'create-release', + }; + return aliases[normalized] || normalized; +} + +function ensurePromptable() { + if (!isTty) throw new Error('Missing required option and no TTY is available for prompting.'); +} + +async function chooseValue(current, choices, message) { + if (current) return current; + ensurePromptable(); + const value = await select({ message, options: choices }); + if (isCancel(value)) { + cancel('Operation cancelled.'); + process.exit(130); + } + return value; +} + +function detectLanIp() { + for (const addresses of Object.values(os.networkInterfaces())) { + for (const address of addresses || []) { + if (address.family === 'IPv4' && !address.internal) return address.address; + } + } + return ''; +} + +function removeFilesByPrefixSuffix(directory, prefix, suffix) { + if (!existsSync(directory)) return; + for (const entry of readdirSync(directory)) { + if (!entry.startsWith(prefix) || !entry.endsWith(suffix)) continue; + unlinkSync(path.join(directory, entry)); + } +} + +function latestFileByExtensions(directory, extensions) { + if (!existsSync(directory)) return ''; + return readdirSync(directory) + .filter((entry) => extensions.some((extension) => entry.endsWith(extension))) + .map((entry) => { + const filePath = path.join(directory, entry); + return { filePath, mtimeMs: statSync(filePath).mtimeMs }; + }) + .sort((left, right) => right.mtimeMs - left.mtimeMs)[0]?.filePath || ''; +} + +function resetDirectory(directory) { + mkdirSync(directory, { recursive: true }); + for (const entry of ['package.json', 'package-lock.json', 'pnpm-lock.yaml', 'bun.lockb']) { + rmSync(path.join(directory, entry), { force: true }); + } + rmSync(path.join(directory, 'node_modules'), { recursive: true, force: true }); +} + +function installedWebCli(directory) { + const cliPath = path.join(directory, 'node_modules', '@openchamber', 'web', 'bin', 'cli.js'); + return existsSync(cliPath) ? cliPath : ''; +} + +function stopInstalledInstance(directory, port) { + const cliPath = installedWebCli(directory); + if (!cliPath) return; + run('node', [cliPath, 'stop', '--port', port], { cwd: directory, allowFail: true, label: `stop instance on ${port}` }); +} + +function startInstalledInstance(directory, port) { + const cliPath = installedWebCli(directory); + if (!cliPath) throw new Error(`OpenChamber CLI was not installed in ${directory}`); + run('node', [cliPath, '--port', port], { + cwd: directory, + env: { + OPENCHAMBER_UI_PASSWORD: process.env.OPENCHAMBER_PASSWORD || '', + OPENCHAMBER_HOST: '0.0.0.0', + }, + label: `start instance on ${port}`, + }); +} + +function packageWeb() { + step('Building web bundle', () => run('bun', ['run', '--cwd', 'packages/web', 'build'])); + const packOutput = step('Creating web package archive', () => run('npm', ['pack', '--pack-destination', repoRoot], { cwd: path.join(repoRoot, 'packages/web'), capture: true })); + const packageName = packOutput.split('\n').find((line) => line.trim().endsWith('.tgz'))?.trim(); + if (!packageName) throw new Error('Archive creation failed: npm pack did not print a .tgz file.'); + return path.join(repoRoot, packageName); +} + +async function selectRemoteDeployment(config, options) { + if (options.remoteId) { + const remote = config.remoteDeployments.find((entry) => entry.id === options.remoteId); + if (!remote) throw new Error(`No remote deployment with id "${options.remoteId}" in ${configPath}`); + return remote; + } + + if (options.target) { + const normalizedTarget = options.target.toLowerCase(); + const apiOnly = ['test', 'testing', 'test-api', 'api', 'api-only'].includes(normalizedTarget); + const withUi = ['test-ui', 'ui', 'with-ui'].includes(normalizedTarget); + if (!apiOnly && !withUi) throw new Error('Invalid --target. Use test-api or test-ui.'); + const remote = config.remoteDeployments.find((entry) => Boolean(entry.apiOnly) === apiOnly || (!entry.apiOnly && withUi)); + if (remote) return remote; + } + + if (config.remoteDeployments.length === 0) { + throw new Error(`No remoteDeployments configured in ${configPath}`); + } + + return chooseValue( + '', + config.remoteDeployments.map((remote) => ({ value: remote.id, label: remote.label || remote.id, hint: `${remote.host}:${remote.port}` })), + 'Select remote deployment', + ).then((id) => config.remoteDeployments.find((entry) => entry.id === id)); +} + +async function deployWeb(options, config) { + const deploymentMode = (await chooseValue(options.deploymentMode, [ + { value: 'global', label: 'Global' }, + { value: 'testing', label: 'Testing' }, + ], 'Select installation mode')).toLowerCase(); + + if (!['global', 'testing'].includes(deploymentMode)) { + throw new Error('Invalid deployment mode. Use global or testing. Use remote-deploy-web for configured remote deployments.'); + } + + const packageFile = packageWeb(); + + if (deploymentMode === 'testing') { + const testingDir = path.join(os.homedir(), TESTING_DIR); + step(`Stopping testing instance on ${TESTING_PORT}`, () => stopInstalledInstance(testingDir, TESTING_PORT)); + step('Preparing testing install directory', () => { + resetDirectory(testingDir); + run('bun', ['init', '-y'], { cwd: testingDir }); + }); + step('Installing testing package', () => run('bun', ['add', packageFile], { cwd: testingDir })); + step(`Starting testing instance on ${TESTING_PORT}`, () => startInstalledInstance(testingDir, TESTING_PORT)); + return; + } + + step(`Stopping global instance on ${GLOBAL_PORT}`, () => run('openchamber', ['stop', '--port', GLOBAL_PORT], { allowFail: true, label: `stop global instance on ${GLOBAL_PORT}` })); + step('Removing old global package', () => { + run('bun', ['remove', '-g', '@openchamber/web'], { allowFail: true, label: 'remove @openchamber/web' }); + run('bun', ['remove', '-g', 'openchamber'], { allowFail: true, label: 'remove openchamber' }); + }); + step('Installing package globally', () => run('bun', ['add', '-g', packageFile])); + step(`Starting global instance on ${GLOBAL_PORT}`, () => run('openchamber', ['--port', GLOBAL_PORT], { env: { OPENCHAMBER_UI_PASSWORD: process.env.OPENCHAMBER_PASSWORD || '', OPENCHAMBER_HOST: '0.0.0.0' } })); +} + +async function deployRemoteWeb(options, config) { + const remote = await selectRemoteDeployment(config, options); + const packageFile = packageWeb(); + const host = remote.host; + const dir = remote.dir; + const port = String(remote.port); + const apiOnly = remote.apiOnly ? 'true' : 'false'; + const packageBase = path.basename(packageFile); + + if (!host || !dir || !port) throw new Error(`Remote deployment ${remote.id} must define host, dir, and port.`); + + step('Preparing remote directories', () => run('ssh', [host, `mkdir -p ~/${dir}/releases`])); + step(`Stopping remote instance on ${host}:${port}`, () => run('ssh', [host, `set -e; ${REMOTE_RUNTIME_ENV}; cd ~/${dir} 2>/dev/null || exit 0; PORT=${quote(port)}; TMPDIR=$(node -p "require('os').tmpdir()" 2>/dev/null || echo /tmp); PIDFILE="$TMPDIR/openchamber-${port}.pid"; INSTANCEFILE="$TMPDIR/openchamber-${port}.json"; if [ -f ./node_modules/@openchamber/web/bin/cli.js ]; then bun ./node_modules/@openchamber/web/bin/cli.js stop --port "$PORT" >/dev/null 2>&1 || node ./node_modules/@openchamber/web/bin/cli.js stop --port "$PORT" >/dev/null 2>&1 || true; fi; if command -v lsof >/dev/null 2>&1; then lsof -ti :"$PORT" | xargs -r kill >/dev/null 2>&1 || true; sleep 0.5; lsof -ti :"$PORT" | xargs -r kill -9 >/dev/null 2>&1 || true; fi; rm -f "$PIDFILE" "$INSTANCEFILE"`], { label: 'stop remote instance' })); + step('Copying package to remote', () => { + run('ssh', [host, `mkdir -p ~/${dir}/releases && rm -f ~/${dir}/releases/*.tgz`]); + run('scp', ['-q', packageFile, `${host}:~/${dir}/releases/${packageBase}`]); + }); + step('Resetting remote install state', () => run('ssh', [host, `cd ~/${dir} && rm -f package.json package-lock.json pnpm-lock.yaml bun.lockb && rm -rf node_modules`])); + step('Preparing remote package manifest', () => run('ssh', [host, `cd ~/${dir} && ${REMOTE_RUNTIME_ENV}; npm init -y >/dev/null 2>&1`])); + step('Installing remote package', () => run('ssh', [host, `cd ~/${dir} && ${REMOTE_RUNTIME_ENV}; npm install ./releases/${packageBase}`])); + step(`Starting remote instance on ${host}:${port}`, () => run('ssh', [host, `set -e; cd ~/${dir}; ${REMOTE_RUNTIME_ENV}; PASSWORD_VALUE=$(grep '^export OPENCHAMBER_UI_PASSWORD=' ~/.bashrc 2>/dev/null | sed -E 's/.*=["“]?([^"”]+)["”]?/\\1/' || true); if [ -n "$PASSWORD_VALUE" ]; then export OPENCHAMBER_UI_PASSWORD="$PASSWORD_VALUE"; fi; if [ ${quote(apiOnly)} = 'true' ]; then export OPENCHAMBER_API_ONLY=true; fi; OPENCHAMBER_HOST=0.0.0.0 node ./node_modules/@openchamber/web/bin/cli.js --port ${quote(port)} >/dev/null 2>&1; sleep 0.5; if command -v lsof >/dev/null 2>&1; then lsof -ti :${quote(port)} >/dev/null 2>&1 || exit 1; fi`])); + log.success(`Remote deployment ready: ${host}:${port}`); +} + +async function startWebDev(options) { + const mode = await chooseValue(options.webMode, [ + { value: 'hmr', label: 'Web HMR' }, + { value: 'hmr-lan', label: 'Web HMR LAN/mobile' }, + { value: 'full', label: 'Web prod-like' }, + ], 'Select web dev mode'); + + if (mode === 'hmr-lan') { + log.info('Starting web HMR LAN/mobile loop. Open the LAN URL printed after startup.'); + run('bun', ['run', 'dev:web:hmr'], { env: { OPENCHAMBER_HMR_HOST: '0.0.0.0' } }); + } else if (mode === 'full') { + run('bun', ['run', 'dev:web:full']); + } else { + run('bun', ['run', 'dev:web:hmr']); + } +} + +async function startMobileDev(options) { + const mobileModeChoices = [ + { value: 'ios-sim-local', label: 'iOS Simulator local' }, + { value: 'ios-sim-lan', label: 'iOS Simulator LAN' }, + { value: 'android-local', label: 'Android emulator local' }, + { value: 'android-lan', label: 'Android device LAN' }, + ].filter((choice) => isMac || !choice.value.startsWith('ios-')); + const mode = await chooseValue(options.mobileMode, mobileModeChoices, 'Select mobile dev mode'); + + if (mode.startsWith('ios-') && !isMac) { + throw new Error('iOS mobile dev actions require macOS and Xcode.'); + } + + const hmrPort = process.env.OPENCHAMBER_HMR_UI_PORT || '5180'; + let hmrBindHost = '127.0.0.1'; + let liveReloadHost = '127.0.0.1'; + let platform = 'ios'; + let extraArgs = []; + + if (mode === 'ios-sim-lan' || mode === 'android-lan') { + hmrBindHost = '0.0.0.0'; + liveReloadHost = detectLanIp(); + if (!liveReloadHost) throw new Error('Could not detect LAN IP.'); + } + if (mode.startsWith('android')) platform = 'android'; + if (mode === 'android-local') extraArgs = ['--forwardPorts', `${hmrPort}:${hmrPort}`]; + + log.step(`Starting mobile UI dev server on ${hmrBindHost}:${hmrPort}`); + const devServer = spawn('bun', ['x', 'vite', '--config', 'local-dev-mobile-vite.config.mjs', '--host', hmrBindHost, '--port', hmrPort, '--strictPort'], { + cwd: repoRoot, + stdio: 'inherit', + env: { ...process.env, OPENCHAMBER_DISABLE_PWA_DEV: '1' }, + }); + + const stopDevServer = () => { + if (!devServer.killed) devServer.kill('SIGTERM'); + }; + process.once('SIGINT', () => { + stopDevServer(); + process.exit(130); + }); + process.once('SIGTERM', () => { + stopDevServer(); + process.exit(143); + }); + + await new Promise((resolve) => setTimeout(resolve, 6000)); + run('node', ['scripts/with-mobile-env.mjs', `bunx cap run ${platform} --live-reload --host ${liveReloadHost} --port ${hmrPort} ${extraArgs.join(' ')}`], { cwd: path.join(repoRoot, 'packages/mobile') }); + log.info('Mobile UI dev server is still running. Press Ctrl+C to stop.'); + await new Promise((resolve) => devServer.on('exit', resolve)); +} + +async function mobileTools(options, config) { + const mobileTaskChoices = [ + { value: 'build', label: 'Build mobile web assets' }, + { value: 'sync', label: 'Sync native projects' }, + { value: 'android-devices', label: 'Android: list USB devices' }, + { value: 'android-deploy-usb', label: 'Android: rebuild + deploy to USB device' }, + { value: 'android-run', label: 'Android: install + launch existing APK' }, + { value: 'android-logcat', label: 'Android: logcat' }, + { value: 'ios-sim-build', label: 'iOS Simulator: build' }, + { value: 'ios-sim-run', label: 'iOS Simulator: install + launch' }, + { value: 'ios-sim-serve', label: 'iOS Simulator: browser preview' }, + { value: 'ios-sim-kill', label: 'iOS Simulator: stop browser preview' }, + { value: 'ios-device-sync-debug', label: 'iOS Device: sync + open debugger workspace' }, + ].filter((choice) => isMac || !choice.value.startsWith('ios-')); + const task = await chooseValue(options.mobileTask, mobileTaskChoices, 'Select mobile action'); + + if (task.startsWith('ios-') && !isMac) { + throw new Error('iOS mobile actions require macOS and Xcode.'); + } + + const mobileCwd = path.join(repoRoot, 'packages/mobile'); + const mobileRun = (label, script) => step(label, () => run('bun', ['run', script], { cwd: mobileCwd })); + switch (task) { + case 'build': return mobileRun('Building mobile web assets', 'build'); + case 'sync': return mobileRun('Syncing native projects', 'sync'); + case 'android-devices': return mobileRun('Listing Android USB devices', 'android:devices'); + case 'android-deploy-usb': + mobileRun('Building Android debug APK', 'build:android:debug'); + return mobileRun('Installing and launching Android app on USB device', 'android:run'); + case 'android-run': return mobileRun('Installing and launching Android app on USB device', 'android:run'); + case 'android-logcat': return mobileRun('Streaming Android app logs', 'android:logcat'); + case 'ios-sim-build': return mobileRun('Building iOS Simulator app', 'build:ios:simulator'); + case 'ios-sim-run': return mobileRun('Installing and launching iOS Simulator app', 'sim:run'); + case 'ios-sim-serve': return mobileRun('Starting iOS Simulator browser preview', 'sim:serve'); + case 'ios-sim-kill': return mobileRun('Stopping iOS Simulator browser preview', 'sim:kill'); + case 'ios-device-sync-debug': { + mobileRun('Syncing iOS native project', 'sync'); + const deviceName = process.env.IOS_DEVICE_NAME || config.ios?.deviceName || 'iPhone Bohdan'; + const xcodeAppName = process.env.XCODE_APP_NAME || config.ios?.xcodeAppName || (config.ios?.useXcodeBeta ? 'Xcode-beta' : 'Xcode'); + log.info(`Target physical device: ${deviceName}`); + log.warn('CLI can sync/build/install parts of iOS, but attaching Apple\'s debugger to a physical iPhone is still Xcode\'s job. Select the device in Xcode and press Run.'); + if (process.platform !== 'darwin') throw new Error('Opening Xcode requires macOS.'); + return step(`Opening iOS workspace in ${xcodeAppName}`, () => run('open', ['-a', xcodeAppName, path.join(mobileCwd, 'ios/App/App.xcworkspace')])); + } + default: + throw new Error(`Unknown mobile task: ${task}`); + } +} + +function startElectronApp() { + run('bun', ['run', 'electron:dev']); +} + +function buildElectronApp() { + run('bun', ['run', 'electron:build'], { env: { CSC_IDENTITY_AUTO_DISCOVERY: 'false' } }); + const distDir = path.join(repoRoot, 'packages/electron/dist'); + if (!existsSync(distDir) || !isMac) return; + const artifact = latestFileByExtensions(distDir, ['.dmg', '-mac.zip']); + if (artifact) run('open', [artifact]); +} + +function startVsCodeExtension() { + const vscodeDir = path.join(repoRoot, 'packages/vscode'); + removeFilesByPrefixSuffix(vscodeDir, 'openchamber-', '.vsix'); + step('Building VS Code extension', () => run('bun', ['run', 'vscode:build'])); + run('code', ['--extensionDevelopmentPath', vscodeDir]); +} + +async function installVsCodeExtensionLocal(options) { + const cleanup = await chooseValue(options.vsixCleanup, [ + { value: 'delete', label: 'Delete VSIX after install' }, + { value: 'keep', label: 'Keep VSIX after install' }, + ], 'Select VSIX cleanup mode'); + const vscodeDir = path.join(repoRoot, 'packages/vscode'); + step('Building VS Code extension', () => run('bun', ['run', '--cwd', 'packages/vscode', 'build'])); + removeFilesByPrefixSuffix(vscodeDir, 'openchamber-', '.vsix'); + step('Packaging VSIX', () => run('bunx', ['vsce', 'package', '--no-dependencies'], { cwd: vscodeDir })); + run('code', ['--uninstall-extension', 'fedaykindev.openchamber'], { label: 'uninstall old extension', allowFail: true }); + const vsix = latestFileByExtensions(vscodeDir, ['.vsix']); + if (!vsix) throw new Error('VSIX package was not created.'); + step('Installing VSIX locally', () => run('code', ['--install-extension', vsix])); + if (cleanup === 'delete') removeFilesByPrefixSuffix(vscodeDir, 'openchamber-', '.vsix'); +} + +async function createRelease(options) { + if (!options.config?.features?.releaseTools) { + throw new Error(`Release tools are disabled. Set features.releaseTools=true in ${configPath} to enable this maintainer task.`); + } + + let version = options.version; + if (!version) { + ensurePromptable(); + version = await text({ message: 'Enter release version', placeholder: '1.4.7' }); + if (isCancel(version)) { + cancel('Operation cancelled.'); + process.exit(130); + } + } + if (!/^\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$/.test(version)) throw new Error('Invalid version format. Use semver, e.g. 1.4.7 or 1.4.7-beta.1'); + step('Validating codebase', () => run('bun', ['run', 'release:prepare'])); + step(`Bumping version to ${version}`, () => run('node', ['scripts/bump-version.mjs', version])); + log.success(`Release v${version} prepared locally`); +} + +async function chooseAction(config) { + const options = [ + { value: 'build-deploy-web', label: 'Build/Deploy web' }, + { value: 'start-web-dev', label: 'Start web dev' }, + { value: 'start-mobile-dev', label: 'Start mobile dev' }, + { value: 'mobile-tools', label: 'Mobile tools' }, + { value: 'start-electron-app', label: 'Start Electron app' }, + { value: 'build-electron-app', label: 'Build Electron app' }, + { value: 'start-vscode-extension', label: 'Start VS Code extension' }, + { value: 'install-vscode-extension-local', label: 'Install VS Code extension locally' }, + ]; + + if (config.features?.releaseTools) { + options.push({ value: 'create-release', label: 'Create Release' }); + } + if (config.remoteDeployments.length > 0) { + options.splice(1, 0, { value: 'remote-deploy-web', label: 'Deploy configured remote web' }); + } + const action = await chooseValue('', options, 'Select OpenChamber dev action'); + return action; +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + printHelp(); + return; + } + + const config = loadConfig(); + const interactive = !options.action; + if (interactive) intro('OpenChamber dev'); + let action = normalizeAction(options.action || await chooseAction(config)); + + switch (action) { + case 'build-deploy-web': + await deployWeb(options, config); + break; + case 'remote-deploy-web': + await deployRemoteWeb(options, config); + break; + case 'start-web-dev': + await startWebDev(options); + break; + case 'start-mobile-dev': + await startMobileDev(options); + break; + case 'mobile-tools': + await mobileTools(options, config); + break; + case 'start-electron-app': + startElectronApp(); + break; + case 'build-electron-app': + buildElectronApp(); + break; + case 'start-vscode-extension': + startVsCodeExtension(); + break; + case 'install-vscode-extension-local': + await installVsCodeExtensionLocal(options); + break; + case 'create-release': + options.config = config; + await createRelease(options); + break; + default: + throw new Error(`Unknown action: ${action}`); + } + if (interactive) outro('Done'); +} + +main().catch((error) => { + log.error(error.message); + process.exit(1); +}); From b60a794e8036536670dacc53bb428a2f8b8b788b Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 1 Jul 2026 17:21:19 +0300 Subject: [PATCH 0069/1073] fix: recover chat state after idle reconnects Resyncs active sessions after hidden upstream stream reconnects Recovers orphaned streaming parts with active-session snapshots Adds coverage for event-stream reconnect behavior --- bun.lock | 22 +++-- package.json | 2 +- packages/ui/package.json | 2 +- packages/ui/src/sync/sync-context.tsx | 82 +++++++++++++------ packages/vscode/package.json | 2 +- packages/web/package.json | 2 +- .../lib/event-stream/global-ws-bridge.js | 11 +++ .../server/lib/event-stream/runtime.test.js | 4 +- 8 files changed, 86 insertions(+), 41 deletions(-) diff --git a/bun.lock b/bun.lock index 52f0ff1d..2a05722e 100644 --- a/bun.lock +++ b/bun.lock @@ -33,7 +33,7 @@ "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.17.9", + "@opencode-ai/sdk": "^1.17.12", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -100,7 +100,7 @@ }, "packages/electron": { "name": "@openchamber/electron", - "version": "1.13.4", + "version": "1.13.8", "dependencies": { "@openchamber/web": "workspace:*", "electron-context-menu": "^4.1.2", @@ -136,7 +136,7 @@ }, "packages/ui": { "name": "@openchamber/ui", - "version": "1.13.4", + "version": "1.13.8", "dependencies": { "@aparajita/capacitor-secure-storage": "^8.0.0", "@base-ui/react": "^1.4.0", @@ -173,7 +173,7 @@ "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", - "@opencode-ai/sdk": "^1.17.9", + "@opencode-ai/sdk": "^1.17.12", "@pierre/diffs": "1.3.0-beta.6", "@replit/codemirror-vim": "^6.3.0", "@simplewebauthn/browser": "13.3.0", @@ -242,10 +242,10 @@ }, "packages/vscode": { "name": "openchamber", - "version": "1.13.4", + "version": "1.13.8", "dependencies": { "@openchamber/ui": "workspace:*", - "@opencode-ai/sdk": "^1.17.9", + "@opencode-ai/sdk": "^1.17.12", "adm-zip": "^0.5.16", "jsonc-parser": "^3.3.1", "react": "^19.1.1", @@ -265,14 +265,14 @@ }, "packages/web": { "name": "@openchamber/web", - "version": "1.13.4", + "version": "1.13.8", "bin": { "openchamber": "./bin/cli.js", }, "dependencies": { "@clack/prompts": "^1.1.0", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.17.9", + "@opencode-ai/sdk": "^1.17.12", "@simplewebauthn/server": "13.3.1", "adm-zip": "^0.5.16", "better-sqlite3": "^12.10.0", @@ -1007,7 +1007,7 @@ "@openchamber/web": ["@openchamber/web@workspace:packages/web"], - "@opencode-ai/sdk": ["@opencode-ai/sdk@1.17.9", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-MHmXEpGPHkg14v1p+cUlIOUxd6DQdSElfau9nqY7tcDI0x5r4Y8D0dKXcyAh0Gc73ptaGW67Vg84nkcV6O27Pw=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.17.12", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-N8kazWO0ZLCHWYFuZQt1UJM+bWxY6g1auSG6SvD1+K3+W+nw2qIhDAUGNCD0KVW3bY2LCwvfWvpG2ZbVGCHC0Q=="], "@paralleldrive/cuid2": ["@paralleldrive/cuid2@2.3.1", "", { "dependencies": { "@noble/hashes": "^1.1.5" } }, "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw=="], @@ -3387,7 +3387,7 @@ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], @@ -3739,8 +3739,6 @@ "serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], - "serve-sim/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], - "sharp/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], diff --git a/package.json b/package.json index 6e9de3d3..7013f47c 100644 --- a/package.json +++ b/package.json @@ -110,7 +110,7 @@ "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.17.9", + "@opencode-ai/sdk": "^1.17.12", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/packages/ui/package.json b/packages/ui/package.json index 509eb743..e44a52e3 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -46,7 +46,7 @@ "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", - "@opencode-ai/sdk": "^1.17.9", + "@opencode-ai/sdk": "^1.17.12", "@pierre/diffs": "1.3.0-beta.6", "@replit/codemirror-vim": "^6.3.0", "@simplewebauthn/browser": "13.3.0", diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index da34f3a4..423ecf77 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -874,15 +874,20 @@ const childStoreHasSessionState = ( || Object.prototype.hasOwnProperty.call(state.session_status ?? {}, sessionID) } -const childStoreHasMessagePartState = ( - childStores: ChildStoreManager, - directory: string, - messageID: string, -): boolean => { +const childStoreHasMessagePartState = ( + childStores: ChildStoreManager, + directory: string, + messageID: string, +): boolean => { const store = childStores.getChild(directory) if (!store) return false - return Object.prototype.hasOwnProperty.call(store.getState().part, messageID) -} + return Object.prototype.hasOwnProperty.call(store.getState().part, messageID) +} + +const getActiveDirectoryFallback = (childStores: ChildStoreManager): string | null => { + if (!_activeDirectory || !_activeSession) return null + return childStores.getChild(_activeDirectory) ? _activeDirectory : null +} const resolveDirectoryFromRoutingIndex = ( routingIndex: EventRoutingIndex, @@ -927,12 +932,21 @@ const resolveDirectoryFromRoutingIndex = ( } // Scan child stores for a store that has parts for this message - for (const [dir, store] of childStores.children) { - if (Object.prototype.hasOwnProperty.call(store.getState().part, messageID)) { - return dir - } - } - } + for (const [dir, store] of childStores.children) { + if (Object.prototype.hasOwnProperty.call(store.getState().part, messageID)) { + return dir + } + } + + // Some reconnect/idle gaps can deliver part events before the matching + // message.updated event and without a sessionID. If the user is actively + // viewing a session, route the orphaned part event there so the reducer can + // trigger HTTP materialization instead of dropping it as a global event. + const activeDirectory = getActiveDirectoryFallback(childStores) + if (activeDirectory) { + return activeDirectory + } + } // Single-store fallback: if there's only one directory, use it if ( @@ -946,8 +960,25 @@ const resolveDirectoryFromRoutingIndex = ( } } - return normalizedDirectory -} + return normalizedDirectory +} + +const resolveMaterializationSessionID = ( + materializationSessionID: string | undefined, + messageID: string | undefined, + resolvedDirectory: string, + routingIndex: EventRoutingIndex, +): string | undefined => { + if (materializationSessionID) return materializationSessionID + if (messageID) { + const indexedSessionID = routingIndex.messageSessionById.get(messageID) + if (indexedSessionID) return indexedSessionID + } + if (resolvedDirectory && resolvedDirectory === _activeDirectory && _activeSession) { + return _activeSession + } + return undefined +} const updateRoutingIndexFromEvent = ( routingIndex: EventRoutingIndex, @@ -1559,14 +1590,19 @@ function handleEvent( } - // Snapshot materialization is driven by typed reducer outcomes, not by - // inferring meaning from a generic false/no-change result. - if (materializationResult) { - const materializationSessionID = materializationResult.sessionID ?? getSessionIdFromPayload(payload) ?? undefined - if (materializationSessionID) { - enqueueSessionMaterialization(resolvedDirectory, materializationSessionID, childStores) - } - } + // Snapshot materialization is driven by typed reducer outcomes, not by + // inferring meaning from a generic false/no-change result. + if (materializationResult) { + const materializationSessionID = resolveMaterializationSessionID( + materializationResult.sessionID ?? getSessionIdFromPayload(payload) ?? undefined, + materializationResult.messageID ?? getMessageIdFromPayload(payload) ?? undefined, + resolvedDirectory, + routingIndex, + ) + if (materializationSessionID) { + enqueueSessionMaterialization(resolvedDirectory, materializationSessionID, childStores) + } + } updateRoutingIndexFromEvent(routingIndex, resolvedDirectory, payload) } diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 9b520338..20f556a5 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -244,7 +244,7 @@ }, "dependencies": { "@openchamber/ui": "workspace:*", - "@opencode-ai/sdk": "^1.17.9", + "@opencode-ai/sdk": "^1.17.12", "adm-zip": "^0.5.16", "jsonc-parser": "^3.3.1", "react": "^19.1.1", diff --git a/packages/web/package.json b/packages/web/package.json index 3ac7bdb4..1fd0c48b 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -25,7 +25,7 @@ "dependencies": { "@clack/prompts": "^1.1.0", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.17.9", + "@opencode-ai/sdk": "^1.17.12", "@simplewebauthn/server": "13.3.1", "adm-zip": "^0.5.16", "better-sqlite3": "^12.10.0", diff --git a/packages/web/server/lib/event-stream/global-ws-bridge.js b/packages/web/server/lib/event-stream/global-ws-bridge.js index 9ab65c84..d26f7ca0 100644 --- a/packages/web/server/lib/event-stream/global-ws-bridge.js +++ b/packages/web/server/lib/event-stream/global-ws-bridge.js @@ -120,6 +120,17 @@ export function createGlobalMessageStreamWsBridge({ for (const socket of Array.from(clients)) { if (!readyClients.has(socket)) { markReady(socket, clientLastEventIds.get(socket) ?? ''); + continue; + } + + if (status.wasReady) { + const sent = sendMessageStreamWsFrame(socket, { + type: 'ready', + scope: 'global', + }); + if (!sent) { + removeClient(socket); + } } } return; diff --git a/packages/web/server/lib/event-stream/runtime.test.js b/packages/web/server/lib/event-stream/runtime.test.js index 19065a92..c8635112 100644 --- a/packages/web/server/lib/event-stream/runtime.test.js +++ b/packages/web/server/lib/event-stream/runtime.test.js @@ -435,7 +435,7 @@ describe('message stream websocket runtime', () => { return createSseResponse({ signal: options.signal, - holdOpen: false, + holdOpen: true, blocks: [ 'id: evt-2\ndata: {"type":"server.connected","properties":{}}\n\n', ], @@ -451,7 +451,7 @@ describe('message stream websocket runtime', () => { const readyFrames = socket.sent.filter((frame) => frame.type === 'ready'); const eventFrames = socket.sent.filter((frame) => frame.type === 'event' && frame.payload?.type === 'server.connected'); - expect(readyFrames).toHaveLength(1); + expect(readyFrames.length).toBeGreaterThanOrEqual(2); expect(eventFrames.length).toBeGreaterThanOrEqual(2); expect(fetchCalls.slice(0, 2)).toEqual([null, 'evt-1']); expect(triggerHealthCheckCalls).toBe(0); From 0a69c4ebabff4517c4a365dae766eac595d22125 Mon Sep 17 00:00:00 2001 From: Tom Rochette Date: Wed, 1 Jul 2026 11:01:02 -0400 Subject: [PATCH 0070/1073] feat(pr-review): add risk score to review comment output (#1943) --- .opencode/agent/pr-review.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.opencode/agent/pr-review.md b/.opencode/agent/pr-review.md index 07913c13..c7c1c924 100644 --- a/.opencode/agent/pr-review.md +++ b/.opencode/agent/pr-review.md @@ -136,6 +136,13 @@ Merge signal in plain English: safe to merge, safe after a small fix, or not saf Explain the reason in a short paragraph. If there are findings, name the files that need attention. +

Risk Score: X/5

+ +1 is low risk (isolated, reversible, well-contained change), 5 is high risk (touches security, data persistence, shared state, build/release, or broad cross-runtime contracts). + +Explain the score in a short paragraph: which risk dimensions apply (correctness, data loss, security/supply-chain, performance, cross-runtime parity) and what makes the change more or less risky. +
+

Findings

If there are findings, list them like this: From 4b1e05160f53e9a94d4740a10f5c8942c6e1ce84 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 1 Jul 2026 18:32:16 +0300 Subject: [PATCH 0071/1073] fix: recover mobile and sync state after resume Reconnect sync stream when native mobile app resumes Materialize incomplete sessions with explicit recovery reasons Add low-noise debug breadcrumb for scoped recovery --- packages/ui/src/apps/MobileApp.tsx | 8 + .../src/sync/__tests__/event-reducer.test.ts | 8 +- packages/ui/src/sync/debug.ts | 8 +- packages/ui/src/sync/event-reducer.ts | 21 ++- packages/ui/src/sync/sync-context.tsx | 142 +++++++++++------- .../server/lib/event-stream/DOCUMENTATION.md | 1 + 6 files changed, 123 insertions(+), 65 deletions(-) diff --git a/packages/ui/src/apps/MobileApp.tsx b/packages/ui/src/apps/MobileApp.tsx index 756e564c..aeeb2a49 100644 --- a/packages/ui/src/apps/MobileApp.tsx +++ b/packages/ui/src/apps/MobileApp.tsx @@ -282,6 +282,8 @@ const mobileInputKeyboardProps = { spellCheck: false, } as const; +const NATIVE_RESUME_SYNC_EVENT_THROTTLE_MS = 1_000; + const getProjectLabel = (path: string): string => { const normalized = normalizePath(path); if (!normalized) return ''; @@ -1946,9 +1948,15 @@ export function MobileApp({ apis }: MobileAppProps) { // exhausted the attempt (then the connect screen shows). const [autoConnectPhase, setAutoConnectPhase] = React.useState<'pending' | 'attempting' | 'done'>('pending'); const isNativeMobileApp = React.useMemo(() => isCapacitorMobileApp(), []); + const lastNativeResumeSyncEventAtRef = React.useRef(0); const handleNativeResume = React.useCallback(() => { if (!getRuntimeApiBaseUrl()) return; + const now = Date.now(); + if (now - lastNativeResumeSyncEventAtRef.current >= NATIVE_RESUME_SYNC_EVENT_THROTTLE_MS) { + lastNativeResumeSyncEventAtRef.current = now; + window.dispatchEvent(new Event('openchamber:system-resume')); + } void initializeApp(); void refreshGitHubAuthStatus(apis.github, { force: true }); if (providersCount === 0) void loadProviders({ source: 'mobileApp:nativeResume' }); diff --git a/packages/ui/src/sync/__tests__/event-reducer.test.ts b/packages/ui/src/sync/__tests__/event-reducer.test.ts index 0c624563..bfeb8438 100644 --- a/packages/ui/src/sync/__tests__/event-reducer.test.ts +++ b/packages/ui/src/sync/__tests__/event-reducer.test.ts @@ -61,7 +61,7 @@ describe("applyDirectoryEvent", () => { expect(result).toEqual({ changed: false, - materialization: { type: "incomplete-session-snapshot", messageID: "msg_1", partID: "prt_1" }, + materialization: { type: "incomplete-session-snapshot", reason: "orphan-delta", messageID: "msg_1", partID: "prt_1" }, }) }) @@ -73,7 +73,7 @@ describe("applyDirectoryEvent", () => { expect(result).toEqual({ changed: false, - materialization: { type: "incomplete-session-snapshot", messageID: "msg_1", partID: "prt_1" }, + materialization: { type: "incomplete-session-snapshot", reason: "missing-delta-part", messageID: "msg_1", partID: "prt_1" }, }) }) @@ -86,6 +86,7 @@ describe("applyDirectoryEvent", () => { changed: true, materialization: { type: "incomplete-session-snapshot", + reason: "missing-owning-message", sessionID: "ses_1", messageID: "msg_1", partID: "prt_1", @@ -102,6 +103,7 @@ describe("applyDirectoryEvent", () => { changed: true, materialization: { type: "incomplete-session-snapshot", + reason: "missing-owning-message", sessionID: "ses_1", messageID: "msg_1", partID: "prt_1", @@ -123,7 +125,7 @@ describe("applyDirectoryEvent", () => { expect(result).toEqual({ changed: false, - materialization: { type: "incomplete-session-snapshot", sessionID: "ses_1", messageID: "msg_1", partID: "prt_1" }, + materialization: { type: "incomplete-session-snapshot", reason: "orphan-delta", sessionID: "ses_1", messageID: "msg_1", partID: "prt_1" }, }) }) diff --git a/packages/ui/src/sync/debug.ts b/packages/ui/src/sync/debug.ts index b792b445..d6b1aad1 100644 --- a/packages/ui/src/sync/debug.ts +++ b/packages/ui/src/sync/debug.ts @@ -23,7 +23,7 @@ function isSyncDebugEnabled(): boolean { } return _enabled } -type SyncDebugCategory = "pipeline" | "reducer" | "dispatch" +type SyncDebugCategory = "pipeline" | "reducer" | "dispatch" | "recovery" function log(cat: SyncDebugCategory, ...args: unknown[]): void { if (!isSyncDebugEnabled()) return @@ -74,4 +74,10 @@ export const syncDebug = { eventApplied: (eventType: string, sessionID?: string, messageID?: string) => log("dispatch", "event → applied", { eventType, sessionID, messageID }), }, + + recovery: { + /** A scoped session snapshot fetch is starting because live state looked incomplete. */ + materializing: (details: { reason: string; directory: string; sessionID: string; messageID?: string; partID?: string }) => + log("recovery", "materializing session", details), + }, } as const diff --git a/packages/ui/src/sync/event-reducer.ts b/packages/ui/src/sync/event-reducer.ts index c925b3d0..b9120b03 100644 --- a/packages/ui/src/sync/event-reducer.ts +++ b/packages/ui/src/sync/event-reducer.ts @@ -151,10 +151,23 @@ export type GlobalEventResult = { project: Project } | null +export type SessionMaterializationReason = + | "missing-owning-message" + | "orphan-delta" + | "missing-delta-part" + | "empty-assistant-message" + | "child-session-idle" + | "child-session-discovered" + | "ensure-session-messages" + | "stream-reconnect" + | "transport-switch" + | "stale-status-resync" + export type DirectoryEventResult = boolean | { changed: boolean materialization: { type: "incomplete-session-snapshot" + reason: SessionMaterializationReason sessionID?: string messageID: string partID?: string @@ -356,7 +369,7 @@ export function applyDirectoryEvent( return missingOwningMessage ? { changed: true, - materialization: { type: "incomplete-session-snapshot", sessionID, messageID, partID: part.id }, + materialization: { type: "incomplete-session-snapshot", reason: "missing-owning-message", sessionID, messageID, partID: part.id }, } : true } @@ -390,7 +403,7 @@ export function applyDirectoryEvent( return missingOwningMessage ? { changed: true, - materialization: { type: "incomplete-session-snapshot", sessionID, messageID, partID: part.id }, + materialization: { type: "incomplete-session-snapshot", reason: "missing-owning-message", sessionID, messageID, partID: part.id }, } : true } @@ -426,7 +439,7 @@ export function applyDirectoryEvent( syncDebug.reducer.partDeltaNoParts(props.messageID, props.partID) return { changed: false, - materialization: { type: "incomplete-session-snapshot", sessionID: props.sessionID, messageID: props.messageID, partID: props.partID }, + materialization: { type: "incomplete-session-snapshot", reason: "orphan-delta", sessionID: props.sessionID, messageID: props.messageID, partID: props.partID }, } } const result = Binary.search(parts, props.partID, (p) => p.id) @@ -434,7 +447,7 @@ export function applyDirectoryEvent( syncDebug.reducer.partDeltaNotFound(props.messageID, props.partID) return { changed: false, - materialization: { type: "incomplete-session-snapshot", sessionID: props.sessionID, messageID: props.messageID, partID: props.partID }, + materialization: { type: "incomplete-session-snapshot", reason: "missing-delta-part", sessionID: props.sessionID, messageID: props.messageID, partID: props.partID }, } } const existing = parts[result.index] as Record diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index 423ecf77..4eab7650 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -9,7 +9,7 @@ import { createEventPipeline } from "./event-pipeline" import { isVSCodeRuntime } from "@/lib/desktop" import { isMobileSurfaceRuntime } from "@/lib/runtimeSurface" import { isCapacitorApp } from "@/lib/platform" -import { reduceGlobalEvent, applyGlobalProject, applyDirectoryEvent } from "./event-reducer" +import { reduceGlobalEvent, applyGlobalProject, applyDirectoryEvent, type SessionMaterializationReason } from "./event-reducer" import { useGlobalSyncStore } from "./global-sync-store" import { ChildStoreManager, type DirectoryStore } from "./child-store" import { @@ -194,24 +194,36 @@ function haveEquivalentSyncSnapshots(left: unknown, right: unknown): boolean { // Tracked per-directory, deduplicated, and auto-expiring. // --------------------------------------------------------------------------- -type PendingSessionMaterialization = { - sessionID: string - directory: string - enqueuedAt: number -} +type PendingSessionMaterialization = { + sessionID: string + directory: string + enqueuedAt: number + request: SessionMaterializationRequest +} + +type SessionMaterializationRequest = { + reason: SessionMaterializationReason + messageID?: string + partID?: string +} const SESSION_MATERIALIZATION_COOLDOWN_MS = 5_000 const pendingSessionMaterializations = new Map() // key: directory:sessionID const materializationKey = (directory: string, sessionID: string) => `${directory}:${sessionID}` -function enqueueSessionMaterialization(directory: string, sessionID: string, childStores: ChildStoreManager) { - if (!directory || directory === "global" || !sessionID) return - const k = materializationKey(directory, sessionID) - const existing = pendingSessionMaterializations.get(k) - if (existing && Date.now() - existing.enqueuedAt < SESSION_MATERIALIZATION_COOLDOWN_MS) return - - pendingSessionMaterializations.set(k, { sessionID, directory, enqueuedAt: Date.now() }) +function enqueueSessionMaterialization( + directory: string, + sessionID: string, + childStores: ChildStoreManager, + request: SessionMaterializationRequest, +) { + if (!directory || directory === "global" || !sessionID) return + const k = materializationKey(directory, sessionID) + const existing = pendingSessionMaterializations.get(k) + if (existing && Date.now() - existing.enqueuedAt < SESSION_MATERIALIZATION_COOLDOWN_MS) return + + pendingSessionMaterializations.set(k, { sessionID, directory, enqueuedAt: Date.now(), request }) // Defer to next microtask so we don't hold up the current event batch void Promise.resolve().then(async () => { @@ -220,8 +232,8 @@ function enqueueSessionMaterialization(directory: string, sessionID: string, chi pendingSessionMaterializations.delete(k) return } - try { - await materializeSessionFromServer(directory, sessionID, store) + try { + await materializeSessionFromServer(directory, sessionID, store, request) } catch { // Transient failure — next SSE event or reconnect will catch up. } finally { @@ -230,13 +242,20 @@ function enqueueSessionMaterialization(directory: string, sessionID: string, chi }) } -async function materializeSessionFromServer( - directory: string, - sessionID: string, - store: StoreApi, - options?: { isStale?: () => boolean }, -) { - const scopedClient = opencodeClient.getScopedSdkClient(directory) +async function materializeSessionFromServer( + directory: string, + sessionID: string, + store: StoreApi, + options?: SessionMaterializationRequest & { isStale?: () => boolean }, +) { + syncDebug.recovery.materializing({ + reason: options?.reason ?? "ensure-session-messages", + directory, + sessionID, + messageID: options?.messageID, + partID: options?.partID, + }) + const scopedClient = opencodeClient.getScopedSdkClient(directory) const result = await retry(async () => { const response = await scopedClient.session.messages({ sessionID, limit: SESSION_MATERIALIZATION_MESSAGE_LIMIT }) assertSdkSuccess(response, "session.messages") @@ -1216,29 +1235,31 @@ export async function resyncBlockingRequestsForDirectory( } } -async function resyncDirectoryAfterReconnect( - directory: string, - store: StoreApi, - routingIndex: EventRoutingIndex, -) { +async function resyncDirectoryAfterReconnect( + directory: string, + store: StoreApi, + routingIndex: EventRoutingIndex, + reason: SessionMaterializationReason, +) { const current = store.getState() const candidateSessionIds = getActiveSessionCandidateIds(directory, current) if (candidateSessionIds.length === 0) return await resyncDirectorySessionStatuses(directory, store, candidateSessionIds, "authoritative") - const scopedClient = opencodeClient.getScopedSdkClient(directory) - await Promise.all(candidateSessionIds.map(async (sessionId) => { - const [sessionResponse, messageResponse] = await Promise.all([ - retry(async () => { - const response = await scopedClient.session.get({ sessionID: sessionId }) + const scopedClient = opencodeClient.getScopedSdkClient(directory) + await Promise.all(candidateSessionIds.map(async (sessionId) => { + syncDebug.recovery.materializing({ reason, directory, sessionID: sessionId }) + const [sessionResponse, messageResponse] = await Promise.all([ + retry(async () => { + const response = await scopedClient.session.get({ sessionID: sessionId }) assertSdkSuccess(response, "session.get") return response - }).catch(() => null), - retry(async () => { - const response = await scopedClient.session.messages({ sessionID: sessionId, limit: RECONNECT_MESSAGE_LIMIT }) - assertSdkSuccess(response, "session.messages") - return response + }).catch(() => null), + retry(async () => { + const response = await scopedClient.session.messages({ sessionID: sessionId, limit: RECONNECT_MESSAGE_LIMIT }) + assertSdkSuccess(response, "session.messages") + return response }).catch(() => null), ]) const session = sessionResponse?.data @@ -1497,9 +1518,9 @@ function handleEvent( const parentID = idleSession ? (idleSession as Session & { parentID?: string | null }).parentID : null - if (parentID) { - enqueueSessionMaterialization(resolvedDirectory, parentID, childStores) - } + if (parentID) { + enqueueSessionMaterialization(resolvedDirectory, parentID, childStores, { reason: "child-session-idle" }) + } } } @@ -1578,10 +1599,13 @@ function handleEvent( // never arrived. Recover the session so the UI doesn't render a blank bubble. if (sessionID && messageID && payload.type === "message.updated") { const after = store.getState() - const info = (payload.properties as { info: Message }).info - if (info.role === "assistant" && (!after.part[messageID] || after.part[messageID].length === 0)) { - enqueueSessionMaterialization(resolvedDirectory, sessionID, childStores) - } + const info = (payload.properties as { info: Message }).info + if (info.role === "assistant" && (!after.part[messageID] || after.part[messageID].length === 0)) { + enqueueSessionMaterialization(resolvedDirectory, sessionID, childStores, { + reason: "empty-assistant-message", + messageID, + }) + } } } else { const sessionID = getSessionIdFromPayload(payload) ?? undefined @@ -1600,7 +1624,11 @@ function handleEvent( routingIndex, ) if (materializationSessionID) { - enqueueSessionMaterialization(resolvedDirectory, materializationSessionID, childStores) + enqueueSessionMaterialization(resolvedDirectory, materializationSessionID, childStores, { + reason: materializationResult.reason, + messageID: materializationResult.messageID, + partID: materializationResult.partID, + }) } } @@ -1650,7 +1678,7 @@ export function SyncProvider(props: { [childStores, props.sdk, props.directory], ) - const triggerDirectoryResync = useCallback((directory: string) => { + const triggerDirectoryResync = useCallback((directory: string, reason: SessionMaterializationReason) => { const store = childStores.children.get(directory) if (!store) return const resyncing = resyncingDirectoriesRef.current @@ -1658,7 +1686,7 @@ export function SyncProvider(props: { lastFullResyncAtByDirectoryRef.current.set(directory, Date.now()) resyncing.add(directory) - void resyncDirectoryAfterReconnect(directory, store, routingIndex) + void resyncDirectoryAfterReconnect(directory, store, routingIndex, reason) .catch(() => { // Transient failure — the watchdog, next SSE event, or reconnect will catch up. }) @@ -1845,7 +1873,7 @@ export function SyncProvider(props: { return } for (const dir of childStores.children.keys()) { - triggerDirectoryResync(dir) + triggerDirectoryResync(dir, "stream-reconnect") } }, onDisconnect: (reason) => { @@ -1865,7 +1893,7 @@ export function SyncProvider(props: { connectionPhase: "connected", }) for (const dir of childStores.children.keys()) { - triggerDirectoryResync(dir) + triggerDirectoryResync(dir, "transport-switch") } }, }) @@ -1919,11 +1947,11 @@ export function SyncProvider(props: { ) return { session: sessions, limit: Math.max(sessions.length, 50) } }) - // Trigger parent session materialization so the task tool part - // state (metadata, sessionId, output) is refreshed. - for (const pid of parentIdsForMaterialization) { - enqueueSessionMaterialization(directory, pid, childStores) - } + // Trigger parent session materialization so the task tool part + // state (metadata, sessionId, output) is refreshed. + for (const pid of parentIdsForMaterialization) { + enqueueSessionMaterialization(directory, pid, childStores, { reason: "child-session-discovered" }) + } } catch { // Best-effort — next tick will retry. } @@ -1945,7 +1973,7 @@ export function SyncProvider(props: { needsSnapshotAfterStatusPoll(before, sessionId, statuses[sessionId]) )) if (needsSnapshot) { - triggerDirectoryResync(directory) + triggerDirectoryResync(directory, "stale-status-resync") } } finally { polling.delete(directory) @@ -1977,7 +2005,7 @@ export function SyncProvider(props: { const lastFullResyncAt = lastFullResyncAtByDirectoryRef.current.get(directory) ?? 0 if (shouldTriggerStaleResync(lastStreamActivityAtRef.current, lastFullResyncAt, now)) { pipelineReconnectRef.current?.("active_stream_stale") - triggerDirectoryResync(directory) + triggerDirectoryResync(directory, "stale-status-resync") } // Discover child sessions created by other OpenCode instances @@ -2676,7 +2704,7 @@ export function useEnsureSessionMessages(sessionID: string, directory?: string) void (async () => { try { - await materializeSessionFromServer(resolvedDirectory, sessionID, store, { isStale }) + await materializeSessionFromServer(resolvedDirectory, sessionID, store, { reason: "ensure-session-messages", isStale }) } catch { // Transient failure — next navigation or reconnect will retry } finally { diff --git a/packages/web/server/lib/event-stream/DOCUMENTATION.md b/packages/web/server/lib/event-stream/DOCUMENTATION.md index f69938c6..2acc7263 100644 --- a/packages/web/server/lib/event-stream/DOCUMENTATION.md +++ b/packages/web/server/lib/event-stream/DOCUMENTATION.md @@ -42,6 +42,7 @@ This module contains the OpenChamber message-stream WebSocket protocol and runti - The global hub keeps a bounded replay buffer keyed by SSE `eventId` so reconnecting browser clients can receive buffered events after their requested `Last-Event-ID`. - Directory WS clients still attach one upstream `/event?directory=...` SSE reader per connection because directory streams are scoped. - If an upstream SSE stream stalls after the browser WS is already ready, the reader aborts that upstream fetch and reconnects upstream with `Last-Event-ID`, keeping the browser WS alive when recovery is fast. +- When the shared global upstream reconnects after it was previously ready, the global WS bridge sends a fresh `ready` frame to already-ready browser clients. The browser treats this as a reconnect edge and can run scoped state repair without requiring the browser WS to close. - Health checks are reserved for initial upstream connect failures and explicit upstream-unavailable responses, not for ordinary stall recovery on an already-established stream. - Global synthetic events such as `openchamber:session-status`, `openchamber:session-activity`, `openchamber:notification`, and `openchamber:heartbeat` are preserved on the WS path, but heartbeat frames are emitted only while an upstream SSE stream is actively attached. - Global UI broadcasts are fan-out capable across both SSE and WS clients. From bc4a7d358a75924c27777b984f522a9faa07846b Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 1 Jul 2026 19:07:20 +0300 Subject: [PATCH 0072/1073] feat(desktop): add keep-awake setting --- packages/electron/main.mjs | 45 ++++++++++- .../openchamber/DesktopNetworkSettings.tsx | 79 +++++++++++++++++++ packages/ui/src/lib/desktop.ts | 42 +++++++++- .../ui/src/lib/i18n/messages/en.settings.ts | 5 ++ .../ui/src/lib/i18n/messages/es.settings.ts | 5 ++ .../ui/src/lib/i18n/messages/fr.settings.ts | 5 ++ .../ui/src/lib/i18n/messages/ja.settings.ts | 5 ++ .../ui/src/lib/i18n/messages/ko.settings.ts | 5 ++ .../ui/src/lib/i18n/messages/pl.settings.ts | 5 ++ .../src/lib/i18n/messages/pt-BR.settings.ts | 5 ++ .../ui/src/lib/i18n/messages/uk.settings.ts | 5 ++ .../src/lib/i18n/messages/zh-CN.settings.ts | 5 ++ .../src/lib/i18n/messages/zh-TW.settings.ts | 5 ++ packages/ui/src/lib/settings/search.ts | 8 ++ .../server/lib/opencode/settings-helpers.js | 3 + .../lib/opencode/settings-helpers.test.js | 11 +++ 16 files changed, 236 insertions(+), 2 deletions(-) diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index 91ec2f78..826c4dcb 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -1,4 +1,4 @@ -import { app, BrowserWindow, dialog, ipcMain, Menu, nativeTheme, net as electronNet, Notification, powerMonitor, protocol, screen, session, shell, webContents } from 'electron'; +import { app, BrowserWindow, dialog, ipcMain, Menu, nativeTheme, net as electronNet, Notification, powerMonitor, powerSaveBlocker, protocol, screen, session, shell, webContents } from 'electron'; import contextMenu from 'electron-context-menu'; import log from 'electron-log/main.js'; import dgram from 'node:dgram'; @@ -193,6 +193,32 @@ const state = { sshLogs: new Map(), trayController: null, lastFocusedWindowId: null, + keepAwakeBlockerId: null, +}; + +const setDesktopKeepAwakeActive = (enabled) => { + const currentId = state.keepAwakeBlockerId; + const isActive = Number.isInteger(currentId) && powerSaveBlocker.isStarted(currentId); + + if (enabled) { + if (!isActive) { + state.keepAwakeBlockerId = powerSaveBlocker.start('prevent-app-suspension'); + } + return Number.isInteger(state.keepAwakeBlockerId) && powerSaveBlocker.isStarted(state.keepAwakeBlockerId); + } + + if (isActive) { + powerSaveBlocker.stop(currentId); + } + state.keepAwakeBlockerId = null; + return false; +}; + +const readDesktopKeepAwakeStatus = () => { + const enabled = readSettingsRoot().desktopKeepAwakeEnabled === true; + const currentId = state.keepAwakeBlockerId; + const active = Number.isInteger(currentId) && powerSaveBlocker.isStarted(currentId); + return { supported: true, enabled, active }; }; const quitRisk = { @@ -228,6 +254,7 @@ const quitConfirmationMessage = () => { const shutdownBackgroundServices = () => { if (state.backgroundShutdownComplete) return; state.backgroundShutdownComplete = true; + setDesktopKeepAwakeActive(false); if (state.installingUpdate) return; killSidecar(); setImmediate(() => { @@ -271,6 +298,8 @@ const prepareForQuit = ({ installingUpdate = false } = {}) => { } } + setDesktopKeepAwakeActive(false); + if (installingUpdate) { state.backgroundShutdownComplete = true; return; @@ -1141,6 +1170,7 @@ const spawnLocalServer = async () => { // so phones/tablets on the same Wi-Fi can reach the app. UI shows a clear // warning and persists the flag via /api/config/settings. const lanAccessEnabled = settings.desktopLanAccessEnabled === true; + setDesktopKeepAwakeActive(settings.desktopKeepAwakeEnabled === true); const desktopUiPassword = typeof settings.desktopUiPassword === 'string' ? settings.desktopUiPassword.trim() : ''; const lanAccessBlockedByMissingPassword = lanAccessEnabled && !desktopUiPassword; const effectiveLanAccessEnabled = lanAccessEnabled && !lanAccessBlockedByMissingPassword; @@ -3166,6 +3196,19 @@ const handleInvoke = async (browserWindow, command, args = {}) => { return { supported: true, enabled: settings.openAtLogin === true }; } + case 'desktop_get_keep_awake': { + return readDesktopKeepAwakeStatus(); + } + + case 'desktop_set_keep_awake': { + const enabled = args.enabled === true; + await mutateSettingsRoot((root) => { + root.desktopKeepAwakeEnabled = enabled; + }); + const active = setDesktopKeepAwakeActive(enabled); + return { supported: true, enabled, active }; + } + case 'desktop_browser_capture_page': { const wcId = Number.isFinite(args.webContentsId) ? Math.trunc(args.webContentsId) : null; if (wcId === null || wcId < 0) throw new Error('webContentsId is required'); diff --git a/packages/ui/src/components/sections/openchamber/DesktopNetworkSettings.tsx b/packages/ui/src/components/sections/openchamber/DesktopNetworkSettings.tsx index a2861c3c..661d492f 100644 --- a/packages/ui/src/components/sections/openchamber/DesktopNetworkSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/DesktopNetworkSettings.tsx @@ -5,10 +5,12 @@ import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; import { getDesktopLanAddress, + getDesktopKeepAwake, getDesktopLaunchAtLogin, isDesktopLocalOriginActive, isDesktopShell, restartDesktopApp, + setDesktopKeepAwake, setDesktopLaunchAtLogin, } from '@/lib/desktop'; import { useI18n } from '@/lib/i18n'; @@ -29,6 +31,9 @@ export const DesktopNetworkSettings: React.FC = () => { const [launchAtLoginSupported, setLaunchAtLoginSupported] = React.useState(false); const [launchAtLoginEnabled, setLaunchAtLoginEnabled] = React.useState(false); const [isSavingLaunchAtLogin, setIsSavingLaunchAtLogin] = React.useState(false); + const [keepAwakeSupported, setKeepAwakeSupported] = React.useState(false); + const [keepAwakeEnabled, setKeepAwakeEnabled] = React.useState(false); + const [isSavingKeepAwake, setIsSavingKeepAwake] = React.useState(false); const [error, setError] = React.useState(null); const [lanAddress, setLanAddress] = React.useState(null); @@ -107,6 +112,27 @@ export const DesktopNetworkSettings: React.FC = () => { }; }, [isLocalDesktop]); + React.useEffect(() => { + if (!isLocalDesktop) { + setKeepAwakeSupported(false); + return; + } + + let cancelled = false; + void (async () => { + const status = await getDesktopKeepAwake(); + if (cancelled) { + return; + } + setKeepAwakeSupported(status?.supported === true); + setKeepAwakeEnabled(status?.enabled === true); + })(); + + return () => { + cancelled = true; + }; + }, [isLocalDesktop]); + React.useEffect(() => { if (!isLocalDesktop || !draftValue) { setLanAddress(null); @@ -183,6 +209,30 @@ export const DesktopNetworkSettings: React.FC = () => { } }, [isSavingLaunchAtLogin, launchAtLoginEnabled, launchAtLoginSupported, t]); + const handleKeepAwakeToggle = React.useCallback(async () => { + if (!keepAwakeSupported || isSavingKeepAwake) { + return; + } + + const nextValue = !keepAwakeEnabled; + setKeepAwakeEnabled(nextValue); + setIsSavingKeepAwake(true); + setError(null); + + try { + const status = await setDesktopKeepAwake(nextValue); + if (!status?.supported) { + throw new Error(t('settings.openchamber.desktopNetwork.error.keepAwakeUnsupported')); + } + setKeepAwakeEnabled(status.enabled); + } catch (cause) { + setKeepAwakeEnabled(!nextValue); + setError(cause instanceof Error ? cause.message : t('settings.openchamber.desktopNetwork.error.keepAwakeSaveFailed')); + } finally { + setIsSavingKeepAwake(false); + } + }, [isSavingKeepAwake, keepAwakeEnabled, keepAwakeSupported, t]); + const handleSaveAndRestart = React.useCallback(async () => { if (!isDirty) { return; @@ -261,6 +311,35 @@ export const DesktopNetworkSettings: React.FC = () => {
) : null} + {keepAwakeSupported ? ( +
{ + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleKeepAwakeToggle(); + } + }} + > + +
+
{t('settings.openchamber.desktopNetwork.field.keepAwake')}
+
+ {t('settings.openchamber.desktopNetwork.field.keepAwakeDescription')} +
+
+
+ ) : null} +
diff --git a/packages/ui/src/apps/VSCodeApp.tsx b/packages/ui/src/apps/VSCodeApp.tsx index d0006eb2..579dcda1 100644 --- a/packages/ui/src/apps/VSCodeApp.tsx +++ b/packages/ui/src/apps/VSCodeApp.tsx @@ -107,7 +107,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) {
- +
@@ -125,7 +125,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) {
- +
diff --git a/packages/ui/src/components/icon/sprite.ts b/packages/ui/src/components/icon/sprite.ts index eb415ebc..60ca2686 100644 --- a/packages/ui/src/components/icon/sprite.ts +++ b/packages/ui/src/components/icon/sprite.ts @@ -10,6 +10,7 @@ export const iconSpriteData = { "alert": ``, "align-justify": ``, "apple": ``, + "apps-2-ai": ``, "archive": ``, "archive-stack": ``, "arrow-down": ``, @@ -157,7 +158,6 @@ export const iconSpriteData = { "macbook": ``, "menu-2": ``, "menu-fold-2": ``, - "menu": ``, "menu-search": ``, "mic": ``, "mic-off": ``, diff --git a/packages/ui/src/styles/mobile.css b/packages/ui/src/styles/mobile.css index 11724eff..8c2a3d29 100644 --- a/packages/ui/src/styles/mobile.css +++ b/packages/ui/src/styles/mobile.css @@ -248,6 +248,10 @@ :root.mobile-pointer:not(.desktop-runtime) .header-safe-area { padding-top: var(--oc-safe-area-top); } + + :root.oc-capacitor-app [data-sonner-toaster][data-y-position='top'] { + top: calc(env(safe-area-inset-top, 0px) + 16px) !important; + } } /* Phase 1: iOS PWA safe area handling - Enhanced positioning approach */ From 4087ee608242d64565dd35a36c89f89f1ad4e650 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 1 Jul 2026 23:44:10 +0300 Subject: [PATCH 0076/1073] fix: build VS Code extension correctly from oc-dev Run oc-dev with Node to avoid Bun setting NODE_ENV=development Keep local VSIX install flow aligned with the working shell script --- package.json | 2 +- scripts/oc-dev.mjs | 30 +++++++++++++++++++----------- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index 7013f47c..8604a6cd 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "license": "MIT", "scripts": { "dev": "node ./scripts/dev-web-hmr.mjs", - "oc-dev": "bun scripts/oc-dev.mjs", + "oc-dev": "node scripts/oc-dev.mjs", "build": "bun run --filter '*' build", "build:web": "bun run --cwd packages/web build", "build:ui": "bun run --cwd packages/ui build", diff --git a/scripts/oc-dev.mjs b/scripts/oc-dev.mjs index b6c46dba..94170fd2 100755 --- a/scripts/oc-dev.mjs +++ b/scripts/oc-dev.mjs @@ -44,7 +44,7 @@ const isMac = process.platform === 'darwin'; function printHelp() { console.log(`Usage: bun run oc-dev [action] [options] - bun scripts/oc-dev.mjs [action] [options] + node scripts/oc-dev.mjs [action] [options] Actions: build-deploy-web Build web package and deploy @@ -493,19 +493,27 @@ function startVsCodeExtension() { } async function installVsCodeExtensionLocal(options) { - const cleanup = await chooseValue(options.vsixCleanup, [ - { value: 'delete', label: 'Delete VSIX after install' }, - { value: 'keep', label: 'Keep VSIX after install' }, - ], 'Select VSIX cleanup mode'); + let cleanup = options.vsixCleanup; + if (!cleanup && isTty) { + cleanup = await chooseValue('', [ + { value: 'delete', label: 'Delete VSIX after install' }, + { value: 'keep', label: 'Keep VSIX after install' }, + ], 'Select VSIX cleanup mode'); + } + cleanup ||= 'delete'; + if (!['delete', 'keep'].includes(cleanup)) throw new Error('Invalid --vsix-cleanup. Use delete or keep.'); + const vscodeDir = path.join(repoRoot, 'packages/vscode'); step('Building VS Code extension', () => run('bun', ['run', '--cwd', 'packages/vscode', 'build'])); - removeFilesByPrefixSuffix(vscodeDir, 'openchamber-', '.vsix'); + step('Removing found VSIX package(s) before install flow', () => removeFilesByPrefixSuffix(vscodeDir, 'openchamber-', '.vsix')); step('Packaging VSIX', () => run('bunx', ['vsce', 'package', '--no-dependencies'], { cwd: vscodeDir })); - run('code', ['--uninstall-extension', 'fedaykindev.openchamber'], { label: 'uninstall old extension', allowFail: true }); - const vsix = latestFileByExtensions(vscodeDir, ['.vsix']); - if (!vsix) throw new Error('VSIX package was not created.'); - step('Installing VSIX locally', () => run('code', ['--install-extension', vsix])); - if (cleanup === 'delete') removeFilesByPrefixSuffix(vscodeDir, 'openchamber-', '.vsix'); + step('Installing VSIX locally', () => { + run('code', ['--uninstall-extension', 'fedaykindev.openchamber'], { label: 'uninstall old extension', allowFail: true }); + run('code --install-extension packages/vscode/openchamber-*.vsix', [], { shell: true, label: 'install VSIX' }); + }); + if (cleanup === 'delete') { + step('Removing local VSIX package(s) after install', () => removeFilesByPrefixSuffix(vscodeDir, 'openchamber-', '.vsix')); + } } async function createRelease(options) { From c9c178c0008e38bb08f8941dc8bde7b20b03dac5 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 1 Jul 2026 23:44:36 +0300 Subject: [PATCH 0077/1073] fix: clear stale busy state after session recovery Reconcile session status after materializing recovered messages Return composer from stop to send when the server reports idle --- packages/ui/src/sync/sync-context.tsx | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index 4eab7650..3e614210 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -248,6 +248,7 @@ async function materializeSessionFromServer( store: StoreApi, options?: SessionMaterializationRequest & { isStale?: () => boolean }, ) { + const statusBeforeMaterialization = store.getState().session_status?.[sessionID] syncDebug.recovery.materializing({ reason: options?.reason ?? "ensure-session-messages", directory, @@ -282,11 +283,15 @@ async function materializeSessionFromServer( info: stripMessageDiffSnapshots(record.info), parts: record.parts ?? [], })), - { skipPartTypes: RECONNECT_SKIP_PARTS }, - ) - return { message: materialized.message, part: materialized.part } - }) -} + { skipPartTypes: RECONNECT_SKIP_PARTS }, + ) + return { message: materialized.message, part: materialized.part } + }) + + if (statusBeforeMaterialization && statusBeforeMaterialization.type !== "idle" && !options?.isStale?.()) { + await resyncDirectorySessionStatuses(directory, store, [sessionID], "authoritative") + } +} // Module-level refs for notification viewed check. // Used to determine if user is currently viewing the session when a notification arrives. From 37a9179656fa8b5ce06d38c3ca3d3e5dda3a0ed7 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Thu, 2 Jul 2026 00:45:59 +0300 Subject: [PATCH 0078/1073] chore: remove bundled IBM Plex fonts --- bun.lock | 17 ---------------- package.json | 3 --- packages/electron/main.mjs | 2 +- packages/ui/package.json | 3 --- .../components/chat/MarkdownRendererImpl.tsx | 2 +- packages/ui/src/index.css | 12 +++++------ packages/ui/src/lib/fontOptions.ts | 20 ++++--------------- .../ui/src/lib/theme/themes/amoled-dark.json | 6 +++--- .../ui/src/lib/theme/themes/amoled-light.json | 6 +++--- .../ui/src/lib/theme/themes/aura-dark.json | 6 +++--- .../ui/src/lib/theme/themes/aura-light.json | 6 +++--- .../ui/src/lib/theme/themes/ayu-dark.json | 6 +++--- .../ui/src/lib/theme/themes/ayu-light.json | 6 +++--- .../src/lib/theme/themes/carbonfox-dark.json | 6 +++--- .../src/lib/theme/themes/carbonfox-light.json | 6 +++--- .../src/lib/theme/themes/catppuccin-dark.json | 6 +++--- .../lib/theme/themes/catppuccin-light.json | 6 +++--- .../ui/src/lib/theme/themes/cursor-dark.json | 6 +++--- .../ui/src/lib/theme/themes/cursor-light.json | 6 +++--- .../ui/src/lib/theme/themes/dracula-dark.json | 6 +++--- .../src/lib/theme/themes/dracula-light.json | 6 +++--- .../themes/fields-of-the-shire-dark.json | 6 +++--- .../themes/fields-of-the-shire-light.json | 6 +++--- .../ui/src/lib/theme/themes/flexoki-dark.json | 6 +++--- .../src/lib/theme/themes/flexoki-light.json | 6 +++--- .../ui/src/lib/theme/themes/github-dark.json | 6 +++--- .../ui/src/lib/theme/themes/github-light.json | 6 +++--- .../ui/src/lib/theme/themes/gruvbox-dark.json | 6 +++--- .../src/lib/theme/themes/gruvbox-light.json | 6 +++--- .../src/lib/theme/themes/jetbrains-dark.json | 6 +++--- .../src/lib/theme/themes/jetbrains-light.json | 6 +++--- .../src/lib/theme/themes/kanagawa-dark.json | 6 +++--- .../src/lib/theme/themes/kanagawa-light.json | 6 +++--- .../lib/theme/themes/lucent-orng-dark.json | 6 +++--- .../lib/theme/themes/lucent-orng-light.json | 6 +++--- .../ui/src/lib/theme/themes/mono-dark.json | 6 +++--- .../ui/src/lib/theme/themes/mono-light.json | 6 +++--- .../src/lib/theme/themes/mono-plus-dark.json | 6 +++--- .../src/lib/theme/themes/mono-plus-light.json | 6 +++--- .../ui/src/lib/theme/themes/monokai-dark.json | 6 +++--- .../src/lib/theme/themes/monokai-light.json | 6 +++--- .../src/lib/theme/themes/nightowl-dark.json | 6 +++--- .../src/lib/theme/themes/nightowl-light.json | 6 +++--- .../ui/src/lib/theme/themes/nord-dark.json | 6 +++--- .../ui/src/lib/theme/themes/nord-light.json | 6 +++--- .../ui/src/lib/theme/themes/oc-2-dark.json | 6 +++--- .../ui/src/lib/theme/themes/oc-2-light.json | 6 +++--- .../src/lib/theme/themes/onedarkpro-dark.json | 6 +++--- .../lib/theme/themes/onedarkpro-light.json | 6 +++--- .../ui/src/lib/theme/themes/orng-dark.json | 6 +++--- .../ui/src/lib/theme/themes/orng-light.json | 6 +++--- .../src/lib/theme/themes/rosepine-dark.json | 6 +++--- .../src/lib/theme/themes/rosepine-light.json | 6 +++--- .../lib/theme/themes/shadesofpurple-dark.json | 6 +++--- .../theme/themes/shadesofpurple-light.json | 6 +++--- .../src/lib/theme/themes/solarized-dark.json | 6 +++--- .../src/lib/theme/themes/solarized-light.json | 6 +++--- .../src/lib/theme/themes/tokyonight-dark.json | 6 +++--- .../lib/theme/themes/tokyonight-light.json | 6 +++--- .../ui/src/lib/theme/themes/vercel-dark.json | 6 +++--- .../ui/src/lib/theme/themes/vercel-light.json | 6 +++--- .../ui/src/lib/theme/themes/vesper-dark.json | 6 +++--- .../ui/src/lib/theme/themes/vesper-light.json | 6 +++--- .../lib/theme/themes/vitesse-dark-dark.json | 6 +++--- .../lib/theme/themes/vitesse-light-light.json | 6 +++--- .../ui/src/lib/theme/themes/zenburn-dark.json | 6 +++--- .../src/lib/theme/themes/zenburn-light.json | 6 +++--- packages/ui/src/styles/design-system.css | 4 ++-- packages/ui/src/styles/fonts.ts | 10 +--------- packages/web/package.json | 3 --- scripts/changelog-card/generate.mjs | 12 ++++------- scripts/port-opencode-theme.ts | 6 +++--- 72 files changed, 202 insertions(+), 252 deletions(-) diff --git a/bun.lock b/bun.lock index 2a05722e..f4626363 100644 --- a/bun.lock +++ b/bun.lock @@ -25,12 +25,9 @@ "@codemirror/search": "^6.6.0", "@codemirror/state": "^6.5.4", "@codemirror/view": "6.39.13", - "@fontsource/ibm-plex-mono": "^5.2.7", - "@fontsource/ibm-plex-sans": "^5.1.1", "@heroui/scroll-shadow": "^2.3.18", "@heroui/system": "^2.4.23", "@heroui/theme": "^2.4.23", - "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", "@octokit/rest": "^22.0.1", "@opencode-ai/sdk": "^1.17.12", @@ -169,9 +166,6 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "@fontsource/ibm-plex-mono": "^5.2.7", - "@fontsource/ibm-plex-sans": "^5.1.1", - "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", "@opencode-ai/sdk": "^1.17.12", "@pierre/diffs": "1.3.0-beta.6", @@ -298,9 +292,6 @@ "@codemirror/lang-cpp": "^6.0.3", "@codemirror/lang-go": "^6.0.1", "@eslint/js": "^9.33.0", - "@fontsource/ibm-plex-mono": "^5.2.7", - "@fontsource/ibm-plex-sans": "^5.1.1", - "@ibm/plex": "^6.4.1", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -773,10 +764,6 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], - "@fontsource/ibm-plex-mono": ["@fontsource/ibm-plex-mono@5.2.7", "", {}, "sha512-MKAb8qV+CaiMQn2B0dIi1OV3565NYzp3WN5b4oT6LTkk+F0jR6j0ZN+5BKJiIhffDC3rtBULsYZE65+0018z9w=="], - - "@fontsource/ibm-plex-sans": ["@fontsource/ibm-plex-sans@5.2.8", "", {}, "sha512-eztSXjDhPhcpxNIiGTgMebdLP9qS4rWkysuE1V7c+DjOR0qiezaiDaTwQE7bTnG5HxAY/8M43XKDvs3cYq6ZYQ=="], - "@formatjs/ecma402-abstract": ["@formatjs/ecma402-abstract@2.3.6", "", { "dependencies": { "@formatjs/fast-memoize": "2.2.7", "@formatjs/intl-localematcher": "0.6.2", "decimal.js": "^10.4.3", "tslib": "^2.8.0" } }, "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw=="], "@formatjs/fast-memoize": ["@formatjs/fast-memoize@2.2.7", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ=="], @@ -817,10 +804,6 @@ "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], - "@ibm/plex": ["@ibm/plex@6.4.1", "", { "dependencies": { "@ibm/telemetry-js": "^1.5.1" } }, "sha512-fnsipQywHt3zWvsnlyYKMikcVI7E2fEwpiPnIHFqlbByXVfQfANAAeJk1IV4mNnxhppUIDlhU0TzwYwL++Rn2g=="], - - "@ibm/telemetry-js": ["@ibm/telemetry-js@1.11.0", "", { "bin": { "ibmtelemetry": "dist/collect.js" } }, "sha512-RO/9j+URJnSfseWg9ZkEX9p+a3Ousd33DBU7rOafoZB08RqdzxFVYJ2/iM50dkBuD0o7WX7GYt1sLbNgCoE+pA=="], - "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.1" }, "os": "darwin", "cpu": "arm64" }, "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg=="], diff --git a/package.json b/package.json index 8604a6cd..bd58e5e7 100644 --- a/package.json +++ b/package.json @@ -102,12 +102,9 @@ "@codemirror/search": "^6.6.0", "@codemirror/state": "^6.5.4", "@codemirror/view": "6.39.13", - "@fontsource/ibm-plex-mono": "^5.2.7", - "@fontsource/ibm-plex-sans": "^5.1.1", "@heroui/scroll-shadow": "^2.3.18", "@heroui/system": "^2.4.23", "@heroui/theme": "^2.4.23", - "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", "@octokit/rest": "^22.0.1", "@opencode-ai/sdk": "^1.17.12", diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index 826c4dcb..ca188e7e 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -1434,7 +1434,7 @@ const buildStartupSplashHtml = () => { } body { margin: 0; - font-family: "IBM Plex Sans", sans-serif; + font-family: "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; display: grid; place-items: center; height: 100vh; diff --git a/packages/ui/package.json b/packages/ui/package.json index e44a52e3..daef0b33 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -42,9 +42,6 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "@fontsource/ibm-plex-mono": "^5.2.7", - "@fontsource/ibm-plex-sans": "^5.1.1", - "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", "@opencode-ai/sdk": "^1.17.12", "@pierre/diffs": "1.3.0-beta.6", diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx index 5b5252f2..db97312c 100644 --- a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx @@ -965,7 +965,7 @@ const mermaidColorsFromTheme = (theme: Theme) => ({ surface: theme.colors.surface.muted, border: theme.colors.interactive.border, transparent: true, - font: 'IBM Plex Sans, sans-serif', + font: 'system-ui, sans-serif', }); const useDecorateContext = ( diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index 92da1e46..0cbae94d 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -708,11 +708,11 @@ html:not(.dark) .chat-scroll { /* Pierre diff viewer styling */ .pierre-diff-wrapper { - --diffs-font-family: var(--font-mono, 'IBM Plex Mono', monospace); + --diffs-font-family: var(--font-mono, ui-monospace, monospace); --diffs-font-size: var(--text-code); --diffs-line-height: 24px; --diffs-tab-size: 2; - --diffs-header-font-family: var(--font-sans, 'IBM Plex Sans', sans-serif); + --diffs-header-font-family: var(--font-sans, system-ui, sans-serif); --diffs-min-number-column-width: 4ch; --diffs-gap-inline: 0; --diffs-gap-block: 0; @@ -923,7 +923,7 @@ html:not(.dark) .chat-scroll { } } -/* Text font: IBM Plex Sans */ +/* Text font */ .markdown-content { font-family: var(--font-sans); font-size: var(--text-markdown); @@ -1057,7 +1057,7 @@ html:not(.dark) .chat-scroll { margin-bottom: 0.25em; } -/* Code font: IBM Plex Mono */ +/* Code font */ .markdown-content code, .markdown-content pre { font-family: var(--font-mono); @@ -1331,7 +1331,7 @@ html:not(.dark) .chat-scroll { white-space: pre; width: max-content; min-width: 100%; - font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; color: var(--surface-foreground); background: transparent; } @@ -1388,7 +1388,7 @@ html:not(.dark) .chat-scroll { width: max-content; min-width: 100%; min-height: 100%; - font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; color: var(--surface-foreground); background: transparent; } diff --git a/packages/ui/src/lib/fontOptions.ts b/packages/ui/src/lib/fontOptions.ts index 42bb7651..6387698a 100644 --- a/packages/ui/src/lib/fontOptions.ts +++ b/packages/ui/src/lib/fontOptions.ts @@ -1,6 +1,6 @@ -export type UiFontOption = 'ibm-plex-sans' | 'inter' | 'geist-sans' | 'atkinson-hyperlegible' | 'source-sans-3' | 'roboto' | 'noto-sans' | 'dm-sans' | 'manrope' | 'system'; +export type UiFontOption = 'inter' | 'geist-sans' | 'atkinson-hyperlegible' | 'source-sans-3' | 'roboto' | 'noto-sans' | 'dm-sans' | 'manrope' | 'system'; -export type MonoFontOption = 'ibm-plex-mono' | 'jetbrains-mono' | 'fira-code' | 'geist-mono' | 'commit-mono' | 'source-code-pro' | 'cascadia-code' | 'roboto-mono' | 'iosevka' | 'system-mono'; +export type MonoFontOption = 'jetbrains-mono' | 'fira-code' | 'geist-mono' | 'commit-mono' | 'source-code-pro' | 'cascadia-code' | 'roboto-mono' | 'iosevka' | 'system-mono'; export interface FontFaceSource { family: string; @@ -19,12 +19,6 @@ export interface FontOptionDefinition { } export const UI_FONT_OPTIONS: FontOptionDefinition[] = [ - { - id: 'ibm-plex-sans', - label: 'IBM Plex Sans', - description: 'Humanist sans-serif for optimal readability in the interface.', - stack: '"IBM Plex Sans", "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif' - }, { id: 'inter', label: 'Inter', @@ -90,12 +84,6 @@ export const UI_FONT_OPTIONS: FontOptionDefinition[] = [ ]; export const CODE_FONT_OPTIONS: FontOptionDefinition[] = [ - { - id: 'ibm-plex-mono', - label: 'IBM Plex Mono', - description: 'Balanced monospace for code blocks and technical content.', - stack: '"IBM Plex Mono", "SFMono-Regular", "Menlo", monospace' - }, { id: 'jetbrains-mono', label: 'JetBrains Mono', @@ -166,8 +154,8 @@ const buildFontMap = (options: FontOptionDefinition[]) => export const UI_FONT_OPTION_MAP = buildFontMap(UI_FONT_OPTIONS); export const CODE_FONT_OPTION_MAP = buildFontMap(CODE_FONT_OPTIONS); -export const DEFAULT_UI_FONT: UiFontOption = 'ibm-plex-sans'; -export const DEFAULT_MONO_FONT: MonoFontOption = 'ibm-plex-mono'; +export const DEFAULT_UI_FONT: UiFontOption = 'system'; +export const DEFAULT_MONO_FONT: MonoFontOption = 'system-mono'; export const isUiFontOption = (value: unknown): value is UiFontOption => typeof value === 'string' && value in UI_FONT_OPTION_MAP; diff --git a/packages/ui/src/lib/theme/themes/amoled-dark.json b/packages/ui/src/lib/theme/themes/amoled-dark.json index 98822de5..43dc52ad 100644 --- a/packages/ui/src/lib/theme/themes/amoled-dark.json +++ b/packages/ui/src/lib/theme/themes/amoled-dark.json @@ -407,9 +407,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "spacing": { "xs": "0.25rem", diff --git a/packages/ui/src/lib/theme/themes/amoled-light.json b/packages/ui/src/lib/theme/themes/amoled-light.json index 91c33851..57a8b164 100644 --- a/packages/ui/src/lib/theme/themes/amoled-light.json +++ b/packages/ui/src/lib/theme/themes/amoled-light.json @@ -407,9 +407,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "spacing": { "xs": "0.25rem", diff --git a/packages/ui/src/lib/theme/themes/aura-dark.json b/packages/ui/src/lib/theme/themes/aura-dark.json index aed60c01..2a311cfc 100644 --- a/packages/ui/src/lib/theme/themes/aura-dark.json +++ b/packages/ui/src/lib/theme/themes/aura-dark.json @@ -165,9 +165,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/aura-light.json b/packages/ui/src/lib/theme/themes/aura-light.json index 7269ab91..4fec273e 100644 --- a/packages/ui/src/lib/theme/themes/aura-light.json +++ b/packages/ui/src/lib/theme/themes/aura-light.json @@ -165,9 +165,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/ayu-dark.json b/packages/ui/src/lib/theme/themes/ayu-dark.json index 84241e51..3b7c9e62 100644 --- a/packages/ui/src/lib/theme/themes/ayu-dark.json +++ b/packages/ui/src/lib/theme/themes/ayu-dark.json @@ -165,9 +165,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/ayu-light.json b/packages/ui/src/lib/theme/themes/ayu-light.json index 33a448ae..fcdbf7ec 100644 --- a/packages/ui/src/lib/theme/themes/ayu-light.json +++ b/packages/ui/src/lib/theme/themes/ayu-light.json @@ -165,9 +165,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/carbonfox-dark.json b/packages/ui/src/lib/theme/themes/carbonfox-dark.json index 6567344e..3eb08847 100644 --- a/packages/ui/src/lib/theme/themes/carbonfox-dark.json +++ b/packages/ui/src/lib/theme/themes/carbonfox-dark.json @@ -165,9 +165,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/carbonfox-light.json b/packages/ui/src/lib/theme/themes/carbonfox-light.json index 4d90d8d7..78f5c2bb 100644 --- a/packages/ui/src/lib/theme/themes/carbonfox-light.json +++ b/packages/ui/src/lib/theme/themes/carbonfox-light.json @@ -165,9 +165,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/catppuccin-dark.json b/packages/ui/src/lib/theme/themes/catppuccin-dark.json index 062304ee..74699dec 100644 --- a/packages/ui/src/lib/theme/themes/catppuccin-dark.json +++ b/packages/ui/src/lib/theme/themes/catppuccin-dark.json @@ -165,9 +165,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/catppuccin-light.json b/packages/ui/src/lib/theme/themes/catppuccin-light.json index 4a1b04ea..d2347581 100644 --- a/packages/ui/src/lib/theme/themes/catppuccin-light.json +++ b/packages/ui/src/lib/theme/themes/catppuccin-light.json @@ -165,9 +165,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/cursor-dark.json b/packages/ui/src/lib/theme/themes/cursor-dark.json index c1f2a541..5243be74 100644 --- a/packages/ui/src/lib/theme/themes/cursor-dark.json +++ b/packages/ui/src/lib/theme/themes/cursor-dark.json @@ -407,9 +407,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "spacing": { "xs": "0.25rem", diff --git a/packages/ui/src/lib/theme/themes/cursor-light.json b/packages/ui/src/lib/theme/themes/cursor-light.json index 87a8ebea..9729bc05 100644 --- a/packages/ui/src/lib/theme/themes/cursor-light.json +++ b/packages/ui/src/lib/theme/themes/cursor-light.json @@ -407,9 +407,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "spacing": { "xs": "0.25rem", diff --git a/packages/ui/src/lib/theme/themes/dracula-dark.json b/packages/ui/src/lib/theme/themes/dracula-dark.json index e318229c..1a936c20 100644 --- a/packages/ui/src/lib/theme/themes/dracula-dark.json +++ b/packages/ui/src/lib/theme/themes/dracula-dark.json @@ -165,9 +165,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/dracula-light.json b/packages/ui/src/lib/theme/themes/dracula-light.json index 33fd1357..b732244b 100644 --- a/packages/ui/src/lib/theme/themes/dracula-light.json +++ b/packages/ui/src/lib/theme/themes/dracula-light.json @@ -165,9 +165,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/fields-of-the-shire-dark.json b/packages/ui/src/lib/theme/themes/fields-of-the-shire-dark.json index e22a01d0..e3d8bfb6 100644 --- a/packages/ui/src/lib/theme/themes/fields-of-the-shire-dark.json +++ b/packages/ui/src/lib/theme/themes/fields-of-the-shire-dark.json @@ -168,9 +168,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/fields-of-the-shire-light.json b/packages/ui/src/lib/theme/themes/fields-of-the-shire-light.json index c40c55b3..e3dbf3f8 100644 --- a/packages/ui/src/lib/theme/themes/fields-of-the-shire-light.json +++ b/packages/ui/src/lib/theme/themes/fields-of-the-shire-light.json @@ -168,9 +168,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/flexoki-dark.json b/packages/ui/src/lib/theme/themes/flexoki-dark.json index b0d6e43c..65ccfe92 100644 --- a/packages/ui/src/lib/theme/themes/flexoki-dark.json +++ b/packages/ui/src/lib/theme/themes/flexoki-dark.json @@ -167,9 +167,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/flexoki-light.json b/packages/ui/src/lib/theme/themes/flexoki-light.json index b8b20188..5880bf74 100644 --- a/packages/ui/src/lib/theme/themes/flexoki-light.json +++ b/packages/ui/src/lib/theme/themes/flexoki-light.json @@ -167,9 +167,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/github-dark.json b/packages/ui/src/lib/theme/themes/github-dark.json index 20ccb2df..08418975 100644 --- a/packages/ui/src/lib/theme/themes/github-dark.json +++ b/packages/ui/src/lib/theme/themes/github-dark.json @@ -407,9 +407,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "spacing": { "xs": "0.25rem", diff --git a/packages/ui/src/lib/theme/themes/github-light.json b/packages/ui/src/lib/theme/themes/github-light.json index 1afb0660..765704f3 100644 --- a/packages/ui/src/lib/theme/themes/github-light.json +++ b/packages/ui/src/lib/theme/themes/github-light.json @@ -407,9 +407,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "spacing": { "xs": "0.25rem", diff --git a/packages/ui/src/lib/theme/themes/gruvbox-dark.json b/packages/ui/src/lib/theme/themes/gruvbox-dark.json index 83fea959..d4ab4883 100644 --- a/packages/ui/src/lib/theme/themes/gruvbox-dark.json +++ b/packages/ui/src/lib/theme/themes/gruvbox-dark.json @@ -165,9 +165,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/gruvbox-light.json b/packages/ui/src/lib/theme/themes/gruvbox-light.json index d9004272..ec019e9e 100644 --- a/packages/ui/src/lib/theme/themes/gruvbox-light.json +++ b/packages/ui/src/lib/theme/themes/gruvbox-light.json @@ -165,9 +165,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/jetbrains-dark.json b/packages/ui/src/lib/theme/themes/jetbrains-dark.json index 6bca8d0b..6aa036ad 100644 --- a/packages/ui/src/lib/theme/themes/jetbrains-dark.json +++ b/packages/ui/src/lib/theme/themes/jetbrains-dark.json @@ -169,9 +169,9 @@ }, "config": { "fonts": { - "sans": "\"JetBrains Mono\", \"IBM Plex Mono\", monospace", - "mono": "\"JetBrains Mono\", \"IBM Plex Mono\", monospace", - "heading": "\"JetBrains Mono\", \"IBM Plex Mono\", monospace" + "sans": "\"JetBrains Mono\", ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "\"JetBrains Mono\", ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "\"JetBrains Mono\", ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/jetbrains-light.json b/packages/ui/src/lib/theme/themes/jetbrains-light.json index 3c7d3f97..91e7cbd9 100644 --- a/packages/ui/src/lib/theme/themes/jetbrains-light.json +++ b/packages/ui/src/lib/theme/themes/jetbrains-light.json @@ -169,9 +169,9 @@ }, "config": { "fonts": { - "sans": "\"JetBrains Mono\", \"IBM Plex Mono\", monospace", - "mono": "\"JetBrains Mono\", \"IBM Plex Mono\", monospace", - "heading": "\"JetBrains Mono\", \"IBM Plex Mono\", monospace" + "sans": "\"JetBrains Mono\", ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "\"JetBrains Mono\", ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "\"JetBrains Mono\", ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/kanagawa-dark.json b/packages/ui/src/lib/theme/themes/kanagawa-dark.json index 668ff4f7..020a8e93 100644 --- a/packages/ui/src/lib/theme/themes/kanagawa-dark.json +++ b/packages/ui/src/lib/theme/themes/kanagawa-dark.json @@ -167,9 +167,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/kanagawa-light.json b/packages/ui/src/lib/theme/themes/kanagawa-light.json index d28e08a7..6e294288 100644 --- a/packages/ui/src/lib/theme/themes/kanagawa-light.json +++ b/packages/ui/src/lib/theme/themes/kanagawa-light.json @@ -167,9 +167,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/lucent-orng-dark.json b/packages/ui/src/lib/theme/themes/lucent-orng-dark.json index a9b287be..a33f2f15 100644 --- a/packages/ui/src/lib/theme/themes/lucent-orng-dark.json +++ b/packages/ui/src/lib/theme/themes/lucent-orng-dark.json @@ -407,9 +407,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "spacing": { "xs": "0.25rem", diff --git a/packages/ui/src/lib/theme/themes/lucent-orng-light.json b/packages/ui/src/lib/theme/themes/lucent-orng-light.json index 098a10ad..78b6b31a 100644 --- a/packages/ui/src/lib/theme/themes/lucent-orng-light.json +++ b/packages/ui/src/lib/theme/themes/lucent-orng-light.json @@ -407,9 +407,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "spacing": { "xs": "0.25rem", diff --git a/packages/ui/src/lib/theme/themes/mono-dark.json b/packages/ui/src/lib/theme/themes/mono-dark.json index c0b9a8e3..72eb6946 100644 --- a/packages/ui/src/lib/theme/themes/mono-dark.json +++ b/packages/ui/src/lib/theme/themes/mono-dark.json @@ -167,9 +167,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/mono-light.json b/packages/ui/src/lib/theme/themes/mono-light.json index f8e028e7..e1d2f03d 100644 --- a/packages/ui/src/lib/theme/themes/mono-light.json +++ b/packages/ui/src/lib/theme/themes/mono-light.json @@ -167,9 +167,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/mono-plus-dark.json b/packages/ui/src/lib/theme/themes/mono-plus-dark.json index f9220071..7aea4596 100644 --- a/packages/ui/src/lib/theme/themes/mono-plus-dark.json +++ b/packages/ui/src/lib/theme/themes/mono-plus-dark.json @@ -145,9 +145,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/mono-plus-light.json b/packages/ui/src/lib/theme/themes/mono-plus-light.json index 7b59e50f..95068223 100644 --- a/packages/ui/src/lib/theme/themes/mono-plus-light.json +++ b/packages/ui/src/lib/theme/themes/mono-plus-light.json @@ -145,9 +145,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/monokai-dark.json b/packages/ui/src/lib/theme/themes/monokai-dark.json index 620280f2..ee378f74 100644 --- a/packages/ui/src/lib/theme/themes/monokai-dark.json +++ b/packages/ui/src/lib/theme/themes/monokai-dark.json @@ -165,9 +165,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/monokai-light.json b/packages/ui/src/lib/theme/themes/monokai-light.json index bae45259..b8542307 100644 --- a/packages/ui/src/lib/theme/themes/monokai-light.json +++ b/packages/ui/src/lib/theme/themes/monokai-light.json @@ -165,9 +165,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/nightowl-dark.json b/packages/ui/src/lib/theme/themes/nightowl-dark.json index dc2c94c8..a18136bb 100644 --- a/packages/ui/src/lib/theme/themes/nightowl-dark.json +++ b/packages/ui/src/lib/theme/themes/nightowl-dark.json @@ -165,9 +165,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/nightowl-light.json b/packages/ui/src/lib/theme/themes/nightowl-light.json index 13d5a401..1929b477 100644 --- a/packages/ui/src/lib/theme/themes/nightowl-light.json +++ b/packages/ui/src/lib/theme/themes/nightowl-light.json @@ -165,9 +165,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/nord-dark.json b/packages/ui/src/lib/theme/themes/nord-dark.json index 453e50cd..47844869 100644 --- a/packages/ui/src/lib/theme/themes/nord-dark.json +++ b/packages/ui/src/lib/theme/themes/nord-dark.json @@ -165,9 +165,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/nord-light.json b/packages/ui/src/lib/theme/themes/nord-light.json index ae4afc28..2e5895e1 100644 --- a/packages/ui/src/lib/theme/themes/nord-light.json +++ b/packages/ui/src/lib/theme/themes/nord-light.json @@ -165,9 +165,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/oc-2-dark.json b/packages/ui/src/lib/theme/themes/oc-2-dark.json index 8342a69c..2d11712d 100644 --- a/packages/ui/src/lib/theme/themes/oc-2-dark.json +++ b/packages/ui/src/lib/theme/themes/oc-2-dark.json @@ -407,9 +407,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "spacing": { "xs": "0.25rem", diff --git a/packages/ui/src/lib/theme/themes/oc-2-light.json b/packages/ui/src/lib/theme/themes/oc-2-light.json index 860341d0..b43fbf21 100644 --- a/packages/ui/src/lib/theme/themes/oc-2-light.json +++ b/packages/ui/src/lib/theme/themes/oc-2-light.json @@ -407,9 +407,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "spacing": { "xs": "0.25rem", diff --git a/packages/ui/src/lib/theme/themes/onedarkpro-dark.json b/packages/ui/src/lib/theme/themes/onedarkpro-dark.json index 6f8c26c4..a101eaae 100644 --- a/packages/ui/src/lib/theme/themes/onedarkpro-dark.json +++ b/packages/ui/src/lib/theme/themes/onedarkpro-dark.json @@ -165,9 +165,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/onedarkpro-light.json b/packages/ui/src/lib/theme/themes/onedarkpro-light.json index ed67d61c..b3e09dee 100644 --- a/packages/ui/src/lib/theme/themes/onedarkpro-light.json +++ b/packages/ui/src/lib/theme/themes/onedarkpro-light.json @@ -165,9 +165,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/orng-dark.json b/packages/ui/src/lib/theme/themes/orng-dark.json index f2fc50f0..2e4f64c9 100644 --- a/packages/ui/src/lib/theme/themes/orng-dark.json +++ b/packages/ui/src/lib/theme/themes/orng-dark.json @@ -407,9 +407,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "spacing": { "xs": "0.25rem", diff --git a/packages/ui/src/lib/theme/themes/orng-light.json b/packages/ui/src/lib/theme/themes/orng-light.json index 3b00b86d..f9cd8c53 100644 --- a/packages/ui/src/lib/theme/themes/orng-light.json +++ b/packages/ui/src/lib/theme/themes/orng-light.json @@ -407,9 +407,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "spacing": { "xs": "0.25rem", diff --git a/packages/ui/src/lib/theme/themes/rosepine-dark.json b/packages/ui/src/lib/theme/themes/rosepine-dark.json index 4d4e852e..d88b3906 100644 --- a/packages/ui/src/lib/theme/themes/rosepine-dark.json +++ b/packages/ui/src/lib/theme/themes/rosepine-dark.json @@ -407,9 +407,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "spacing": { "xs": "0.25rem", diff --git a/packages/ui/src/lib/theme/themes/rosepine-light.json b/packages/ui/src/lib/theme/themes/rosepine-light.json index a9e35083..643d5b86 100644 --- a/packages/ui/src/lib/theme/themes/rosepine-light.json +++ b/packages/ui/src/lib/theme/themes/rosepine-light.json @@ -407,9 +407,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "spacing": { "xs": "0.25rem", diff --git a/packages/ui/src/lib/theme/themes/shadesofpurple-dark.json b/packages/ui/src/lib/theme/themes/shadesofpurple-dark.json index f33bbbee..405ffdd8 100644 --- a/packages/ui/src/lib/theme/themes/shadesofpurple-dark.json +++ b/packages/ui/src/lib/theme/themes/shadesofpurple-dark.json @@ -407,9 +407,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "spacing": { "xs": "0.25rem", diff --git a/packages/ui/src/lib/theme/themes/shadesofpurple-light.json b/packages/ui/src/lib/theme/themes/shadesofpurple-light.json index 579f459a..266876ab 100644 --- a/packages/ui/src/lib/theme/themes/shadesofpurple-light.json +++ b/packages/ui/src/lib/theme/themes/shadesofpurple-light.json @@ -407,9 +407,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "spacing": { "xs": "0.25rem", diff --git a/packages/ui/src/lib/theme/themes/solarized-dark.json b/packages/ui/src/lib/theme/themes/solarized-dark.json index 0bfd9240..7a572993 100644 --- a/packages/ui/src/lib/theme/themes/solarized-dark.json +++ b/packages/ui/src/lib/theme/themes/solarized-dark.json @@ -165,9 +165,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/solarized-light.json b/packages/ui/src/lib/theme/themes/solarized-light.json index 7f12f1e6..bbf1e33b 100644 --- a/packages/ui/src/lib/theme/themes/solarized-light.json +++ b/packages/ui/src/lib/theme/themes/solarized-light.json @@ -165,9 +165,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/tokyonight-dark.json b/packages/ui/src/lib/theme/themes/tokyonight-dark.json index 5dfab8d2..e639c7bd 100644 --- a/packages/ui/src/lib/theme/themes/tokyonight-dark.json +++ b/packages/ui/src/lib/theme/themes/tokyonight-dark.json @@ -165,9 +165,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/tokyonight-light.json b/packages/ui/src/lib/theme/themes/tokyonight-light.json index d3efeaea..8c50a90f 100644 --- a/packages/ui/src/lib/theme/themes/tokyonight-light.json +++ b/packages/ui/src/lib/theme/themes/tokyonight-light.json @@ -165,9 +165,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/vercel-dark.json b/packages/ui/src/lib/theme/themes/vercel-dark.json index 284920f3..cf961f87 100644 --- a/packages/ui/src/lib/theme/themes/vercel-dark.json +++ b/packages/ui/src/lib/theme/themes/vercel-dark.json @@ -407,9 +407,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "spacing": { "xs": "0.25rem", diff --git a/packages/ui/src/lib/theme/themes/vercel-light.json b/packages/ui/src/lib/theme/themes/vercel-light.json index 4bd61939..dc960e6e 100644 --- a/packages/ui/src/lib/theme/themes/vercel-light.json +++ b/packages/ui/src/lib/theme/themes/vercel-light.json @@ -407,9 +407,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "spacing": { "xs": "0.25rem", diff --git a/packages/ui/src/lib/theme/themes/vesper-dark.json b/packages/ui/src/lib/theme/themes/vesper-dark.json index 36d82259..b0383397 100644 --- a/packages/ui/src/lib/theme/themes/vesper-dark.json +++ b/packages/ui/src/lib/theme/themes/vesper-dark.json @@ -165,9 +165,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/vesper-light.json b/packages/ui/src/lib/theme/themes/vesper-light.json index 62a7ac97..b3f9882c 100644 --- a/packages/ui/src/lib/theme/themes/vesper-light.json +++ b/packages/ui/src/lib/theme/themes/vesper-light.json @@ -165,9 +165,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/vitesse-dark-dark.json b/packages/ui/src/lib/theme/themes/vitesse-dark-dark.json index 140ca107..0c481b9f 100644 --- a/packages/ui/src/lib/theme/themes/vitesse-dark-dark.json +++ b/packages/ui/src/lib/theme/themes/vitesse-dark-dark.json @@ -145,9 +145,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/vitesse-light-light.json b/packages/ui/src/lib/theme/themes/vitesse-light-light.json index 33406272..2efa19cd 100644 --- a/packages/ui/src/lib/theme/themes/vitesse-light-light.json +++ b/packages/ui/src/lib/theme/themes/vitesse-light-light.json @@ -145,9 +145,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/zenburn-dark.json b/packages/ui/src/lib/theme/themes/zenburn-dark.json index 5d38357f..76872d9b 100644 --- a/packages/ui/src/lib/theme/themes/zenburn-dark.json +++ b/packages/ui/src/lib/theme/themes/zenburn-dark.json @@ -407,9 +407,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "spacing": { "xs": "0.25rem", diff --git a/packages/ui/src/lib/theme/themes/zenburn-light.json b/packages/ui/src/lib/theme/themes/zenburn-light.json index b7cda392..b86cbd0a 100644 --- a/packages/ui/src/lib/theme/themes/zenburn-light.json +++ b/packages/ui/src/lib/theme/themes/zenburn-light.json @@ -407,9 +407,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "spacing": { "xs": "0.25rem", diff --git a/packages/ui/src/styles/design-system.css b/packages/ui/src/styles/design-system.css index 438eaa43..63a63bfb 100644 --- a/packages/ui/src/styles/design-system.css +++ b/packages/ui/src/styles/design-system.css @@ -343,9 +343,9 @@ --markdown-heading4-size: var(--text-markdown); --markdown-heading5-size: var(--text-markdown); --markdown-heading6-size: var(--text-markdown); - --font-sans: "IBM Plex Mono", "JetBrains Mono", "Fira Code", "SFMono-Regular", "Menlo", monospace; + --font-sans: "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; --font-heading: var(--font-sans); - --font-mono: "IBM Plex Mono", "JetBrains Mono", "Fira Code", "SFMono-Regular", "Menlo", monospace; + --font-mono: ui-monospace, "SFMono-Regular", "Menlo", "Cascadia Mono", "Segoe UI Mono", monospace; --font-family-sans: var(--font-sans); --font-family-mono: var(--font-mono); } diff --git a/packages/ui/src/styles/fonts.ts b/packages/ui/src/styles/fonts.ts index 2334b048..12f3cab8 100644 --- a/packages/ui/src/styles/fonts.ts +++ b/packages/ui/src/styles/fonts.ts @@ -1,9 +1 @@ - - -import '@fontsource/ibm-plex-sans/latin-400.css'; -import '@fontsource/ibm-plex-sans/latin-500.css'; -import '@fontsource/ibm-plex-sans/latin-600.css'; - -import '@fontsource/ibm-plex-mono/latin-400.css'; -import '@fontsource/ibm-plex-mono/latin-500.css'; -import '@fontsource/ibm-plex-mono/latin-600.css'; +// Default fonts use system stacks. Optional user-selected fonts are loaded on demand. diff --git a/packages/web/package.json b/packages/web/package.json index 1fd0c48b..19911bf9 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -51,9 +51,6 @@ "@codemirror/lang-cpp": "^6.0.3", "@codemirror/lang-go": "^6.0.1", "@eslint/js": "^9.33.0", - "@fontsource/ibm-plex-mono": "^5.2.7", - "@fontsource/ibm-plex-sans": "^5.1.1", - "@ibm/plex": "^6.4.1", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/scripts/changelog-card/generate.mjs b/scripts/changelog-card/generate.mjs index 4d6029ac..28479a6d 100644 --- a/scripts/changelog-card/generate.mjs +++ b/scripts/changelog-card/generate.mjs @@ -18,8 +18,8 @@ // The sentence wraps automatically; the version + sentence block is // bottom-anchored over a readability scrim so the glowing plate stays visible. // -// Fonts (IBM Plex Sans, Instrument Serif) are fetched once from Fontsource -// into ./.fonts (gitignored) and wired into fontconfig for Pango. +// Accent fonts are fetched once from Fontsource into ./.fonts (gitignored) +// and wired into fontconfig for Pango. Main text uses the system sans stack. import { mkdir, writeFile, readFile, access } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; @@ -30,10 +30,6 @@ const repoRoot = path.resolve(toolDir, '..', '..'); const fontsDir = path.join(toolDir, '.fonts'); const FONTS = [ - { - file: 'IBMPlexSans-SemiBold.ttf', - url: 'https://cdn.jsdelivr.net/fontsource/fonts/ibm-plex-sans@latest/latin-600-normal.ttf', - }, { file: 'InstrumentSerif-Italic.ttf', url: 'https://cdn.jsdelivr.net/fontsource/fonts/instrument-serif@latest/latin-400-italic.ttf', @@ -142,7 +138,7 @@ async function main() { const sentenceBuf = await sharp({ text: { text: toPangoMarkup(sentence), - font: 'IBM Plex Sans 92', + font: 'system-ui 92', rgba: true, width: wrapWidth, wrap: 'word', @@ -160,7 +156,7 @@ async function main() { text: `${escapeMarkup( title )}`, - font: 'IBM Plex Sans 64', + font: 'system-ui 64', rgba: true, align: 'left', }, diff --git a/scripts/port-opencode-theme.ts b/scripts/port-opencode-theme.ts index 78b6ccc1..ff25db95 100644 --- a/scripts/port-opencode-theme.ts +++ b/scripts/port-opencode-theme.ts @@ -120,9 +120,9 @@ const DEFAULT_OUT_DIR = path.join(REPO_ROOT, 'packages', 'ui', 'src', 'lib', 'th const DEFAULT_CONFIG = { fonts: { - sans: '"IBM Plex Mono", monospace', - mono: '"IBM Plex Mono", monospace', - heading: '"IBM Plex Mono", monospace', + sans: '"SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif', + mono: 'ui-monospace, "SFMono-Regular", "Menlo", "Cascadia Mono", "Segoe UI Mono", monospace', + heading: '"SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif', }, radius: { none: '0', From bd8ab070e741dcb2b236d0dff5c54b4a0bd4dd6c Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Thu, 2 Jul 2026 00:48:09 +0300 Subject: [PATCH 0079/1073] fix: reject OpenCode desktop app as CLI --- packages/vscode/src/opencode.ts | 45 +++++++++----- .../web/server/lib/opencode/env-runtime.js | 29 +++++++-- .../server/lib/opencode/env-runtime.test.js | 59 +++++++++++++++++++ 3 files changed, 113 insertions(+), 20 deletions(-) diff --git a/packages/vscode/src/opencode.ts b/packages/vscode/src/opencode.ts index 540a9aba..c7e5cd85 100644 --- a/packages/vscode/src/opencode.ts +++ b/packages/vscode/src/opencode.ts @@ -196,10 +196,30 @@ function isMacOpenCodeAppBundlePath(candidate: string): boolean { return process.platform === 'darwin' && /\/OpenCode\.app\/Contents\/MacOS\/(?:OpenCode|opencode-cli)$/i.test(candidate); } +function isWindowsOpenCodeDesktopAppPath(candidate: string): boolean { + if (process.platform !== 'win32' || typeof candidate !== 'string') { + return false; + } + const localAppData = typeof process.env.LOCALAPPDATA === 'string' && process.env.LOCALAPPDATA.trim() + ? path.resolve(process.env.LOCALAPPDATA).toLowerCase() + : ''; + if (!localAppData) { + return false; + } + const normalized = path.resolve(candidate).toLowerCase(); + return normalized.startsWith(`${localAppData}${path.sep}`) + && normalized.endsWith(`${path.sep}programs${path.sep}opencode${path.sep}opencode.exe`); +} + +function isKnownOpenCodeDesktopAppPath(candidate: string): boolean { + return isMacOpenCodeAppBundlePath(candidate) || isWindowsOpenCodeDesktopAppPath(candidate); +} + function createConfiguredOpencodeBinaryError(raw: string, normalized: string): Error { const messageSuffix = 'OpenChamber needs the standalone opencode CLI. Install it and set openchamber.opencodeBinary to the CLI path, for example ~/.opencode/bin/opencode, or leave the setting empty to use PATH lookup.'; - if (isMacOpenCodeAppBundlePath(raw) || isMacOpenCodeAppBundlePath(normalized)) { - return new Error(`Configured OpenCode binary points at the macOS desktop app bundle, not the CLI: ${normalized}. ${messageSuffix}`); + if (isKnownOpenCodeDesktopAppPath(raw) || isKnownOpenCodeDesktopAppPath(normalized)) { + const platformName = process.platform === 'win32' ? 'Windows desktop app install' : 'macOS desktop app bundle'; + return new Error(`Configured OpenCode binary points at the ${platformName}, not the CLI: ${normalized}. ${messageSuffix}`); } try { @@ -254,7 +274,7 @@ function validateConfiguredOpencodeBinaryForManagedStart(): string | null { return null; } - if (isExecutable(normalized) && !isMacOpenCodeAppBundlePath(normalized)) { + if (isExecutable(normalized) && !isKnownOpenCodeDesktopAppPath(normalized)) { return normalized; } @@ -271,7 +291,7 @@ function resolveOpencodeCliPath(): string | null { } })(); - if (configured && isExecutable(configured) && !isMacOpenCodeAppBundlePath(configured)) { + if (configured && isExecutable(configured) && !isKnownOpenCodeDesktopAppPath(configured)) { return configured; } @@ -288,7 +308,7 @@ function resolveOpencodeCliPath(): string | null { } })(); - if (sharedFromOpenChamber && isExecutable(sharedFromOpenChamber) && !isMacOpenCodeAppBundlePath(sharedFromOpenChamber)) { + if (sharedFromOpenChamber && isExecutable(sharedFromOpenChamber) && !isKnownOpenCodeDesktopAppPath(sharedFromOpenChamber)) { return sharedFromOpenChamber; } @@ -302,13 +322,13 @@ function resolveOpencodeCliPath(): string | null { .filter(Boolean); for (const candidate of explicit) { - if (isExecutable(candidate)) { + if (isExecutable(candidate) && !isKnownOpenCodeDesktopAppPath(candidate)) { return candidate; } } if (cachedDetectedOpencodeCliPath) { - if (isExecutable(cachedDetectedOpencodeCliPath)) { + if (isExecutable(cachedDetectedOpencodeCliPath) && !isKnownOpenCodeDesktopAppPath(cachedDetectedOpencodeCliPath)) { return cachedDetectedOpencodeCliPath; } cachedDetectedOpencodeCliPath = undefined; @@ -327,7 +347,6 @@ function resolveOpencodeCliPath(): string | null { const winFallbacks = (() => { const userProfile = process.env.USERPROFILE || home; const appData = process.env.APPDATA || path.join(userProfile, 'AppData', 'Roaming'); - const localAppData = process.env.LOCALAPPDATA || ''; const programData = process.env.ProgramData || 'C:\\ProgramData'; const npmDir = path.join(appData, 'npm'); @@ -344,14 +363,12 @@ function resolveOpencodeCliPath(): string | null { // Bun global install path.join(userProfile, '.bun', 'bin', 'opencode.exe'), path.join(userProfile, '.bun', 'bin', 'opencode.cmd'), - // Some installers use LocalAppData - localAppData ? path.join(localAppData, 'Programs', 'opencode', 'opencode.exe') : '', ].filter(Boolean); })(); if (process.platform !== 'win32') { const fromPath = findExecutableInPath('opencode'); - if (fromPath) { + if (fromPath && !isKnownOpenCodeDesktopAppPath(fromPath)) { cachedDetectedOpencodeCliPath = fromPath; return fromPath; } @@ -359,7 +376,7 @@ function resolveOpencodeCliPath(): string | null { const fallbacks = process.platform === 'win32' ? winFallbacks : unixFallbacks; for (const candidate of fallbacks) { - if (isExecutable(candidate)) { + if (isExecutable(candidate) && !isKnownOpenCodeDesktopAppPath(candidate)) { cachedDetectedOpencodeCliPath = candidate; return candidate; } @@ -367,7 +384,7 @@ function resolveOpencodeCliPath(): string | null { if (process.platform === 'win32') { const fromPath = findExecutableInPath('opencode'); - if (fromPath) { + if (fromPath && !isKnownOpenCodeDesktopAppPath(fromPath)) { cachedDetectedOpencodeCliPath = fromPath; return fromPath; } @@ -382,7 +399,7 @@ function resolveOpencodeCliPath(): string | null { .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean); - const found = lines.find((line) => isExecutable(line)); + const found = lines.find((line) => isExecutable(line) && !isKnownOpenCodeDesktopAppPath(line)); if (found) { cachedDetectedOpencodeCliPath = found; return found; diff --git a/packages/web/server/lib/opencode/env-runtime.js b/packages/web/server/lib/opencode/env-runtime.js index 998128d3..bee62da1 100644 --- a/packages/web/server/lib/opencode/env-runtime.js +++ b/packages/web/server/lib/opencode/env-runtime.js @@ -260,6 +260,20 @@ export const createOpenCodeEnvRuntime = (deps) => { return /(^|[\\/])wsl(\.exe)?$/i.test(trimmed); }; + const isWindowsOpenCodeDesktopAppPath = (candidate) => { + if (process.platform !== 'win32' || typeof candidate !== 'string') { + return false; + } + const normalized = path.resolve(candidate).toLowerCase(); + const localAppData = typeof process.env.LOCALAPPDATA === 'string' && process.env.LOCALAPPDATA.trim() + ? path.resolve(process.env.LOCALAPPDATA).toLowerCase() + : ''; + if (!localAppData || !normalized.startsWith(`${localAppData}${path.sep}`)) { + return false; + } + return normalized.endsWith(`${path.sep}programs${path.sep}opencode${path.sep}opencode.exe`); + }; + const clearWslOpencodeResolution = () => { state.useWslForOpencode = false; state.resolvedWslBinary = null; @@ -278,7 +292,7 @@ export const createOpenCodeEnvRuntime = (deps) => { .filter(Boolean); for (const candidate of explicit) { - if (isExecutable(candidate)) { + if (isExecutable(candidate) && !isWindowsOpenCodeDesktopAppPath(candidate)) { clearWslOpencodeResolution(); state.resolvedOpencodeBinarySource = 'env'; return candidate; @@ -319,7 +333,6 @@ export const createOpenCodeEnvRuntime = (deps) => { path.join(programData, 'chocolatey', 'bin', 'opencode.cmd'), path.join(userProfile, '.bun', 'bin', 'opencode.exe'), path.join(userProfile, '.bun', 'bin', 'opencode.cmd'), - localAppData ? path.join(localAppData, 'Programs', 'opencode', 'opencode.exe') : '', ].filter(Boolean); })(); @@ -344,7 +357,7 @@ export const createOpenCodeEnvRuntime = (deps) => { .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean); - const found = lines.find((line) => isExecutable(line)); + const found = lines.find((line) => isExecutable(line) && !isWindowsOpenCodeDesktopAppPath(line)); if (found) { clearWslOpencodeResolution(); state.resolvedOpencodeBinarySource = 'where'; @@ -824,13 +837,17 @@ export const createOpenCodeEnvRuntime = (deps) => { return /\/OpenCode\.app\/Contents\/MacOS\/(?:OpenCode|opencode-cli)$/i.test(candidate); }; + const isKnownOpenCodeDesktopAppPath = (candidate) => isMacOpenCodeAppBundlePath(candidate) + || isWindowsOpenCodeDesktopAppPath(candidate); + const createConfiguredOpencodeBinaryError = (raw, normalized) => { const configured = typeof raw === 'string' ? raw.trim() : ''; const candidate = typeof normalized === 'string' && normalized.trim().length > 0 ? normalized.trim() : configured; const messageSuffix = 'OpenChamber needs the standalone opencode CLI. Install it and set settings.opencodeBinary to the CLI path, for example ~/.opencode/bin/opencode, or leave the setting empty to use PATH lookup.'; const error = (() => { - if (isMacOpenCodeAppBundlePath(candidate) || isMacOpenCodeAppBundlePath(configured)) { - return new Error(`Configured OpenCode binary points at the macOS desktop app bundle, not the CLI: ${candidate}. ${messageSuffix}`); + if (isKnownOpenCodeDesktopAppPath(candidate) || isKnownOpenCodeDesktopAppPath(configured)) { + const platformName = process.platform === 'win32' ? 'Windows desktop app install' : 'macOS desktop app bundle'; + return new Error(`Configured OpenCode binary points at the ${platformName}, not the CLI: ${candidate}. ${messageSuffix}`); } try { @@ -927,7 +944,7 @@ export const createOpenCodeEnvRuntime = (deps) => { return null; } - if (normalized && isExecutable(normalized) && !isMacOpenCodeAppBundlePath(normalized)) { + if (normalized && isExecutable(normalized) && !isKnownOpenCodeDesktopAppPath(normalized)) { clearWslOpencodeResolution(); process.env.OPENCODE_BINARY = normalized; prependToPath(path.dirname(normalized)); diff --git a/packages/web/server/lib/opencode/env-runtime.test.js b/packages/web/server/lib/opencode/env-runtime.test.js index c0ad1e7f..0e432a11 100644 --- a/packages/web/server/lib/opencode/env-runtime.test.js +++ b/packages/web/server/lib/opencode/env-runtime.test.js @@ -7,6 +7,7 @@ import { createOpenCodeEnvRuntime } from './env-runtime.js'; const originalOpencodeBinary = process.env.OPENCODE_BINARY; const originalComSpec = process.env.ComSpec; const originalPath = process.env.PATH; +const originalLocalAppData = process.env.LOCALAPPDATA; const originalSystemRoot = process.env.SystemRoot; const originalWslBinary = process.env.WSL_BINARY; const originalOpenChamberWslBinary = process.env.OPENCHAMBER_WSL_BINARY; @@ -59,6 +60,12 @@ afterEach(() => { delete process.env.SystemRoot; } + if (typeof originalLocalAppData === 'string') { + process.env.LOCALAPPDATA = originalLocalAppData; + } else { + delete process.env.LOCALAPPDATA; + } + if (typeof originalWslBinary === 'string') { process.env.WSL_BINARY = originalWslBinary; } else { @@ -138,6 +145,58 @@ describe('OpenCode env runtime', () => { }); }); + it('rejects known Windows OpenCode desktop app install paths', async () => { + setPlatform('win32'); + const localAppData = createTempDir('openchamber-localappdata-'); + const desktopBinary = path.join(localAppData, 'Programs', 'OpenCode', 'OpenCode.exe'); + fs.mkdirSync(path.dirname(desktopBinary), { recursive: true }); + fs.writeFileSync(desktopBinary, ''); + process.env.LOCALAPPDATA = localAppData; + const { runtime } = createRuntime({ opencodeBinary: desktopBinary }); + + await expect(runtime.applyOpencodeBinaryFromSettings({ strict: true })).rejects.toMatchObject({ + code: 'OPENCODE_BINARY_INVALID', + message: expect.stringContaining('Windows desktop app install'), + }); + }); + + it('does not auto-detect the Windows OpenCode desktop app as a CLI', () => { + setPlatform('win32'); + const localAppData = createTempDir('openchamber-localappdata-'); + const desktopBinary = path.join(localAppData, 'Programs', 'OpenCode', 'OpenCode.exe'); + fs.mkdirSync(path.dirname(desktopBinary), { recursive: true }); + fs.writeFileSync(desktopBinary, ''); + process.env.LOCALAPPDATA = localAppData; + process.env.PATH = createTempDir('openchamber-empty-path-'); + process.env.SystemRoot = createTempDir('openchamber-empty-systemroot-'); + delete process.env.OPENCODE_BINARY; + const { runtime } = createRuntime({}, { + spawnSync: () => ({ status: 1, stdout: '', stderr: '' }), + }); + + expect(runtime.resolveOpencodeCliPath()).toBeNull(); + }); + + it('skips Windows OpenCode desktop app entries returned by where.exe', () => { + setPlatform('win32'); + const localAppData = createTempDir('openchamber-localappdata-'); + const desktopBinary = path.join(localAppData, 'Programs', 'OpenCode', 'OpenCode.exe'); + const cliBinary = path.join(createTempDir('openchamber-cli-'), 'opencode.exe'); + fs.mkdirSync(path.dirname(desktopBinary), { recursive: true }); + fs.writeFileSync(desktopBinary, ''); + fs.writeFileSync(cliBinary, ''); + process.env.LOCALAPPDATA = localAppData; + process.env.PATH = createTempDir('openchamber-empty-path-'); + process.env.SystemRoot = createTempDir('openchamber-empty-systemroot-'); + delete process.env.OPENCODE_BINARY; + const { runtime, state } = createRuntime({}, { + spawnSync: () => ({ status: 0, stdout: `${desktopBinary}\r\n${cliBinary}\r\n`, stderr: '' }), + }); + + expect(runtime.resolveOpencodeCliPath()).toBe(cliBinary); + expect(state.resolvedOpencodeBinarySource).toBe('where'); + }); + it('rejects WSL settings in strict mode', async () => { setPlatform('win32'); const dir = createTempDir('openchamber-no-wsl-'); From 33ecd628bdff7e6fb6f752df584f6791314e0672 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Thu, 2 Jul 2026 17:43:33 +0300 Subject: [PATCH 0080/1073] feat(desktop): bundle pinned OpenCode CLI Bundle the official OpenCode CLI into Electron desktop builds instead of relying on whichever opencode executable happens to be first on PATH. Pin @opencode-ai/sdk to an exact version and use that version as the source of truth for the downloaded CLI artifact. Add an Electron prepare script that maps the current platform/arch to the official OpenCode release artifact, downloads it from GitHub releases, caches the archive under packages/electron/.cache, stages the binary under resources/opencode-cli, verifies opencode --version, and skips work when the staged binary already matches. Prefer explicit OpenCode binary overrides first, then the bundled Electron CLI, then PATH/system installs. Keep rejecting the Windows OpenCode desktop app executable as a CLI candidate and add resolver tests for bundled priority, explicit override priority, resourcesPath lookup, and desktop-app rejection. Suppress OpenCode CLI update prompts when the active CLI source is bundled. The server now reports upgrade-status as unavailable for bundled CLI while still returning the current OpenCode version for About, and rejects direct upgrade attempts with a 409 instead of trying to mutate the bundled binary. Update desktop release, smoke, and manual macOS DMG workflows to prepare and verify the bundled CLI before packaging, verify the packaged app contains the expected CLI, cache downloads by OS/arch/OpenCode version, and align the Windows smoke runner with production windows-2022. Document desktop bundling behavior, ignore generated CLI/cache files, add oc-dev helpers, and keep Web/VS Code behavior dependent on installed OpenCode CLI rather than desktop bundled resources. --- .github/workflows/build-macos-arm64-dmg.yml | 17 ++ .github/workflows/release-desktop-smoke.yml | 48 ++++- .github/workflows/release.yml | 42 +++- README.md | 2 +- bun.lock | 8 +- package.json | 2 +- packages/electron/.gitignore | 3 + packages/electron/README.md | 21 +- packages/electron/package.json | 9 +- .../electron/resources/opencode-cli/.gitkeep | 0 .../electron/scripts/prepare-opencode-cli.mjs | 181 ++++++++++++++++++ .../electron/scripts/verify-opencode-cli.mjs | 107 +++++++++++ packages/ui/package.json | 2 +- packages/ui/src/sync/sync-context.tsx | 71 +++++-- packages/vscode/package.json | 2 +- packages/web/package.json | 2 +- .../web/server/lib/opencode/env-runtime.js | 34 ++++ .../server/lib/opencode/env-runtime.test.js | 74 +++++++ packages/web/server/lib/opencode/routes.js | 36 ++++ scripts/oc-dev.mjs | 13 ++ 20 files changed, 640 insertions(+), 34 deletions(-) create mode 100644 packages/electron/resources/opencode-cli/.gitkeep create mode 100644 packages/electron/scripts/prepare-opencode-cli.mjs create mode 100644 packages/electron/scripts/verify-opencode-cli.mjs diff --git a/.github/workflows/build-macos-arm64-dmg.yml b/.github/workflows/build-macos-arm64-dmg.yml index c819b9c9..19479538 100644 --- a/.github/workflows/build-macos-arm64-dmg.yml +++ b/.github/workflows/build-macos-arm64-dmg.yml @@ -37,6 +37,20 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - name: Get bundled OpenCode CLI version + id: opencode_cli_version + run: | + VERSION=$(node -p "require('./package.json').dependencies['@opencode-ai/sdk']") + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Cache bundled OpenCode CLI artifact + uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + with: + path: packages/electron/.cache/opencode-cli + key: opencode-cli-${{ runner.os }}-arm64-${{ steps.opencode_cli_version.outputs.version }} + restore-keys: | + opencode-cli-${{ runner.os }}-arm64- + - name: Install Apple Certificate env: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} @@ -68,9 +82,12 @@ jobs: ELECTRON_BUILDER_ARCH: arm64 run: | bun run build:web-assets + bun run prepare:opencode-cli + bun run verify:opencode-cli bun run bundle:main bun run rebuild:native ./node_modules/.bin/electron-builder --mac --arm64 --publish=never + bun run verify:opencode-cli:packaged - name: Prepare DMG artifact run: | diff --git a/.github/workflows/release-desktop-smoke.yml b/.github/workflows/release-desktop-smoke.yml index 022292a6..7686eb7c 100644 --- a/.github/workflows/release-desktop-smoke.yml +++ b/.github/workflows/release-desktop-smoke.yml @@ -70,6 +70,20 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - name: Get bundled OpenCode CLI version + id: opencode_cli_version + run: | + VERSION=$(node -p "require('./package.json').dependencies['@opencode-ai/sdk']") + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Cache bundled OpenCode CLI artifact + uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + with: + path: packages/electron/.cache/opencode-cli + key: opencode-cli-${{ runner.os }}-${{ matrix.arch }}-${{ steps.opencode_cli_version.outputs.version }} + restore-keys: | + opencode-cli-${{ runner.os }}-${{ matrix.arch }}- + - name: Install Apple Certificate env: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} @@ -101,12 +115,15 @@ jobs: ELECTRON_BUILDER_ARCH: ${{ matrix.arch }} run: | bun run build:web-assets + bun run prepare:opencode-cli + bun run verify:opencode-cli bun run bundle:main # npmRebuild=false in package.json, so electron-builder won't # recompile native deps on its own. Rebuild against the target # Electron ABI before packaging, matching the release workflow. bun run rebuild:native bunx electron-builder --mac --${{ matrix.arch }} --publish=never + bun run verify:opencode-cli:packaged - name: Verify signature + entitlements + notarization run: | @@ -165,7 +182,10 @@ jobs: build-windows-electron: if: ${{ inputs.build_windows }} name: Build Windows Electron (x64) - runs-on: windows-latest + # Match the production release workflow. windows-latest currently resolves + # to a runner with Visual Studio 18, which this Electron/node-gyp stack does + # not detect correctly. + runs-on: windows-2022 strategy: fail-fast: false matrix: @@ -191,10 +211,32 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - name: Get bundled OpenCode CLI version + id: opencode_cli_version + shell: bash + run: | + VERSION=$(node -p "require('./package.json').dependencies['@opencode-ai/sdk']") + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Cache bundled OpenCode CLI artifact + uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + with: + path: packages/electron/.cache/opencode-cli + key: opencode-cli-${{ runner.os }}-${{ matrix.arch }}-${{ steps.opencode_cli_version.outputs.version }} + restore-keys: | + opencode-cli-${{ runner.os }}-${{ matrix.arch }}- + - name: Build web assets working-directory: packages/electron run: bun run build:web-assets + - name: Prepare bundled OpenCode CLI + working-directory: packages/electron + shell: bash + run: | + bun run prepare:opencode-cli + bun run verify:opencode-cli + - name: Bundle main process working-directory: packages/electron run: bun run bundle:main @@ -210,7 +252,9 @@ jobs: - name: Build Windows app working-directory: packages/electron shell: bash - run: node ./scripts/package.mjs --win --${{ matrix.arch }} --publish=never + run: | + node ./scripts/package.mjs --win --${{ matrix.arch }} --publish=never + bun run verify:opencode-cli:packaged - name: Upload Windows installable artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 717be367..ef66d355 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -146,6 +146,20 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - name: Get bundled OpenCode CLI version + id: opencode_cli_version + run: | + VERSION=$(node -p "require('./package.json').dependencies['@opencode-ai/sdk']") + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Cache bundled OpenCode CLI artifact + uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + with: + path: packages/electron/.cache/opencode-cli + key: opencode-cli-${{ runner.os }}-${{ matrix.arch }}-${{ steps.opencode_cli_version.outputs.version }} + restore-keys: | + opencode-cli-${{ runner.os }}-${{ matrix.arch }}- + - name: Install Apple Certificate env: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} @@ -179,6 +193,8 @@ jobs: ELECTRON_BUILDER_ARCH: ${{ matrix.arch }} run: | bun run build:web-assets + bun run prepare:opencode-cli + bun run verify:opencode-cli bun run bundle:main # npmRebuild=false in package.json, so electron-builder won't # recompile native deps on its own — we must rebuild against the @@ -186,6 +202,7 @@ jobs: # node-pty/bun-pty crash on require inside the packaged app. bun run rebuild:native bunx electron-builder --mac --${{ matrix.arch }} --publish=never + bun run verify:opencode-cli:packaged - name: Verify signature + entitlements + notarization run: | @@ -275,10 +292,31 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - name: Get bundled OpenCode CLI version + id: opencode_cli_version + run: | + VERSION=$(node -p "require('./package.json').dependencies['@opencode-ai/sdk']") + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Cache bundled OpenCode CLI artifact + uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + with: + path: packages/electron/.cache/opencode-cli + key: opencode-cli-${{ runner.os }}-${{ matrix.arch }}-${{ steps.opencode_cli_version.outputs.version }} + restore-keys: | + opencode-cli-${{ runner.os }}-${{ matrix.arch }}- + - name: Build web assets working-directory: packages/electron run: bun run build:web-assets + - name: Prepare bundled OpenCode CLI + working-directory: packages/electron + shell: bash + run: | + bun run prepare:opencode-cli + bun run verify:opencode-cli + - name: Bundle main process working-directory: packages/electron run: bun run bundle:main @@ -294,7 +332,9 @@ jobs: - name: Build Windows app working-directory: packages/electron shell: bash - run: node ./scripts/package.mjs --win --${{ matrix.arch }} --publish=never + run: | + node ./scripts/package.mjs --win --${{ matrix.arch }} --publish=never + bun run verify:opencode-cli:packaged - name: Upload installer to release uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 diff --git a/README.md b/README.md index 08ada712..f01b25be 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ ## Quick Start -> **Prerequisite:** [OpenCode CLI](https://opencode.ai) installed. +> **Prerequisite:** Desktop bundles the matching OpenCode CLI. CLI/Web and VS Code use your installed [OpenCode CLI](https://opencode.ai). ### **Desktop (macOS + Windows)** Download from [Releases](https://github.com/btriapitsyn/openchamber/releases). diff --git a/bun.lock b/bun.lock index f4626363..e49df436 100644 --- a/bun.lock +++ b/bun.lock @@ -30,7 +30,7 @@ "@heroui/theme": "^2.4.23", "@lezer/highlight": "^1.2.3", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.17.12", + "@opencode-ai/sdk": "1.17.12", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -167,7 +167,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@lezer/highlight": "^1.2.3", - "@opencode-ai/sdk": "^1.17.12", + "@opencode-ai/sdk": "1.17.12", "@pierre/diffs": "1.3.0-beta.6", "@replit/codemirror-vim": "^6.3.0", "@simplewebauthn/browser": "13.3.0", @@ -239,7 +239,7 @@ "version": "1.13.8", "dependencies": { "@openchamber/ui": "workspace:*", - "@opencode-ai/sdk": "^1.17.12", + "@opencode-ai/sdk": "1.17.12", "adm-zip": "^0.5.16", "jsonc-parser": "^3.3.1", "react": "^19.1.1", @@ -266,7 +266,7 @@ "dependencies": { "@clack/prompts": "^1.1.0", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.17.12", + "@opencode-ai/sdk": "1.17.12", "@simplewebauthn/server": "13.3.1", "adm-zip": "^0.5.16", "better-sqlite3": "^12.10.0", diff --git a/package.json b/package.json index bd58e5e7..91851ef8 100644 --- a/package.json +++ b/package.json @@ -107,7 +107,7 @@ "@heroui/theme": "^2.4.23", "@lezer/highlight": "^1.2.3", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.17.12", + "@opencode-ai/sdk": "1.17.12", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/packages/electron/.gitignore b/packages/electron/.gitignore index 2f8dd8d3..9827bb50 100644 --- a/packages/electron/.gitignore +++ b/packages/electron/.gitignore @@ -8,6 +8,9 @@ dist-bundle/ # Generated packaging resources resources/web-dist/ resources/sidecar/ +resources/opencode-cli/* +!resources/opencode-cli/.gitkeep +.cache/ # OS-specific .DS_Store diff --git a/packages/electron/README.md b/packages/electron/README.md index d722c878..5939a916 100644 --- a/packages/electron/README.md +++ b/packages/electron/README.md @@ -21,6 +21,7 @@ The preload bridge exposes desktop-only APIs to the web UI through `window.__OPE | `ssh-manager.mjs` | SSH host import, connection lifecycle, tunnel/port forwarding helpers | | `scripts/electron-dev.mjs` | Desktop dev launcher with Vite HMR support | | `scripts/build-web-assets.mjs` | Builds `packages/web` and stages UI assets into `resources/web-dist` | +| `scripts/prepare-opencode-cli.mjs` | Downloads and stages the pinned OpenCode CLI into `resources/opencode-cli` | | `scripts/bundle-main.mjs` | Bundles Electron main code into `dist-bundle/main.mjs` for packaging | | `scripts/rebuild-native.mjs` | Rebuilds native modules against the Electron runtime | | `scripts/package.mjs` | Runs `electron-builder`, with unsigned Windows builds when signing env is missing | @@ -58,9 +59,10 @@ bun run electron:build That runs, in order: 1. `build:web-assets` to build the web UI and copy it into `packages/electron/resources/web-dist`. -2. `bundle:main` to create `packages/electron/dist-bundle/main.mjs`. -3. `rebuild:native` to rebuild native modules for Electron. -4. `package.mjs` to run `electron-builder`. +2. `prepare:opencode-cli` to download/cache the pinned OpenCode CLI and copy it into `packages/electron/resources/opencode-cli`. +3. `bundle:main` to create `packages/electron/dist-bundle/main.mjs`. +4. `rebuild:native` to rebuild native modules for Electron. +5. `package.mjs` to run `electron-builder`. Build output goes to `packages/electron/dist`. @@ -74,6 +76,18 @@ Windows packaging needs NSIS support through `electron-builder`. If no Windows s The package supports macOS and Windows desktop features. Some native discovery helpers are platform-specific. For example, app icon fetching and app filtering currently only work on macOS, while opening files in installed apps works on macOS and Windows. +## Bundled OpenCode CLI + +Packaged Desktop builds include the official OpenCode CLI that matches the pinned `@opencode-ai/sdk` version in the root `package.json`. `prepare:opencode-cli` downloads the platform-specific release artifact, caches it under `packages/electron/.cache/opencode-cli`, stages `opencode` or `opencode.exe` into `resources/opencode-cli`, and verifies `opencode --version` before packaging. Re-running the step is fast when the staged binary already matches the pinned version. + +Managed local Desktop startup prefers OpenCode binaries in this order: + +1. Explicit overrides: `settings.opencodeBinary`, `OPENCODE_BINARY`, `OPENCODE_PATH`, `OPENCHAMBER_OPENCODE_PATH`, or `OPENCHAMBER_OPENCODE_BIN`. +2. The bundled Desktop CLI in `process.resourcesPath/opencode-cli`. +3. System installs discovered from PATH and known npm/Bun/Scoop/Chocolatey locations. + +Use an explicit override when testing a different OpenCode CLI build or when a user needs to point Desktop at a custom binary. The configured path must point to the standalone CLI, not the OpenCode Desktop app executable. + ## Common Env Vars | Variable | Use | @@ -83,6 +97,7 @@ The package supports macOS and Windows desktop features. Some native discovery h | `OPENCHAMBER_HMR_UI_PORT` | Preferred Vite UI port for desktop dev, default `5173` | | `OPENCHAMBER_HMR_API_PORT` | Preferred API port for desktop dev, default `3901` | | `OPENCHAMBER_RUNTIME=desktop` | Set by Electron before starting the web server | +| `OPENCHAMBER_OPENCODE_CLI_VERSION` | Optional packaging override for the bundled OpenCode CLI version; defaults to the pinned root `@opencode-ai/sdk` version | | `OPENCHAMBER_DESKTOP_NOTIFY=true` | Enables desktop notification flow in the web server | | `OPENCHAMBER_SKIP_API_COMPRESSION=true` | Defaulted by Desktop to reduce local CPU overhead | | `OPENCODE_HOST` / `OPENCODE_PORT` / `OPENCODE_SKIP_START` | Connect Desktop to an external OpenCode server instead of starting one locally | diff --git a/packages/electron/package.json b/packages/electron/package.json index b7208b28..a1d9514e 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -27,10 +27,13 @@ "dev": "node ./scripts/electron-dev.mjs", "build:web-assets": "node ./scripts/build-web-assets.mjs", "build": "bun -e \"process.exit(0)\"", + "prepare:opencode-cli": "node ./scripts/prepare-opencode-cli.mjs", + "verify:opencode-cli": "node ./scripts/verify-opencode-cli.mjs --staged", + "verify:opencode-cli:packaged": "node ./scripts/verify-opencode-cli.mjs --packaged", "bundle:main": "bun ./scripts/bundle-main.mjs", "generate:macos-icon": "node ./scripts/generate-macos-icon-assets.cjs", "rebuild:native": "node ./scripts/rebuild-native.mjs", - "package": "bun run build:web-assets && bun run bundle:main && bun run rebuild:native && node ./scripts/package.mjs", + "package": "bun run build:web-assets && bun run prepare:opencode-cli && bun run bundle:main && bun run rebuild:native && node ./scripts/package.mjs", "finalize:latest-yml": "node ./scripts/finalize-latest-yml.mjs", "type-check": "node --check ./main.mjs && node --check ./preload.mjs", "lint": "node -e \"process.exit(0)\"" @@ -54,6 +57,10 @@ { "from": "resources/icons/tray", "to": "icons/tray" + }, + { + "from": "resources/opencode-cli", + "to": "opencode-cli" } ], "afterPack": "scripts/after-pack.cjs", diff --git a/packages/electron/resources/opencode-cli/.gitkeep b/packages/electron/resources/opencode-cli/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/packages/electron/scripts/prepare-opencode-cli.mjs b/packages/electron/scripts/prepare-opencode-cli.mjs new file mode 100644 index 00000000..d7f5ede5 --- /dev/null +++ b/packages/electron/scripts/prepare-opencode-cli.mjs @@ -0,0 +1,181 @@ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const electronRoot = path.resolve(__dirname, '..'); +const workspaceRoot = path.resolve(electronRoot, '../..'); +const outputDir = path.join(electronRoot, 'resources', 'opencode-cli'); +const cacheRoot = path.join(electronRoot, '.cache', 'opencode-cli'); +const rootPackagePath = path.join(workspaceRoot, 'package.json'); + +const run = (command, args, options = {}) => { + const result = spawnSync(command, args, { + encoding: 'utf8', + stdio: options.stdio || 'pipe', + windowsHide: true, + ...options, + }); + if (result.status !== 0) { + const stderr = result.stderr ? `\n${result.stderr.trim()}` : ''; + const stdout = result.stdout ? `\n${result.stdout.trim()}` : ''; + throw new Error(`Command failed: ${command} ${args.join(' ')}${stderr}${stdout}`); + } + return result; +}; + +const readPinnedSdkVersion = () => { + const pkg = JSON.parse(fs.readFileSync(rootPackagePath, 'utf8')); + const version = pkg.dependencies?.['@opencode-ai/sdk']; + if (typeof version !== 'string' || !version.trim()) { + throw new Error('Missing @opencode-ai/sdk dependency in root package.json'); + } + const trimmed = version.trim(); + if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(trimmed)) { + throw new Error(`@opencode-ai/sdk must be pinned to an exact version for desktop CLI bundling, got: ${trimmed}`); + } + return trimmed; +}; + +const artifactForCurrentPlatform = () => { + const { platform, arch } = process; + if (platform === 'darwin') { + if (arch === 'arm64') return { name: 'opencode-darwin-arm64.zip', binary: 'opencode' }; + if (arch === 'x64') return { name: 'opencode-darwin-x64-baseline.zip', binary: 'opencode' }; + } + if (platform === 'win32') { + if (arch === 'arm64') return { name: 'opencode-windows-arm64.zip', binary: 'opencode.exe' }; + if (arch === 'x64') return { name: 'opencode-windows-x64-baseline.zip', binary: 'opencode.exe' }; + } + if (platform === 'linux') { + if (arch === 'arm64') return { name: 'opencode-linux-arm64.tar.gz', binary: 'opencode' }; + if (arch === 'x64') return { name: 'opencode-linux-x64-baseline.tar.gz', binary: 'opencode' }; + } + throw new Error(`No OpenCode CLI artifact mapping for ${platform}/${arch}`); +}; + +const outputBinaryPath = (binaryName) => path.join(outputDir, binaryName); + +const readBinaryVersion = (binaryPath) => { + if (!fs.existsSync(binaryPath)) return null; + const result = spawnSync(binaryPath, ['--version'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 15000, + windowsHide: true, + }); + if (result.status !== 0) return null; + return (result.stdout || '').trim().split(/\s+/)[0] || null; +}; + +const ensureExecutable = (filePath) => { + if (process.platform !== 'win32') { + fs.chmodSync(filePath, 0o755); + } +}; + +const download = async (url, destination) => { + fs.mkdirSync(path.dirname(destination), { recursive: true }); + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to download ${url}: ${response.status} ${response.statusText}`); + } + const temp = `${destination}.tmp`; + fs.writeFileSync(temp, Buffer.from(await response.arrayBuffer())); + fs.renameSync(temp, destination); +}; + +const extractArchive = (archivePath, destination) => { + fs.rmSync(destination, { recursive: true, force: true }); + fs.mkdirSync(destination, { recursive: true }); + if (archivePath.endsWith('.zip')) { + if (process.platform === 'win32') { + run('powershell.exe', [ + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-Command', + `Expand-Archive -LiteralPath ${JSON.stringify(archivePath)} -DestinationPath ${JSON.stringify(destination)} -Force`, + ]); + return; + } + run('unzip', ['-q', archivePath, '-d', destination]); + return; + } + if (archivePath.endsWith('.tar.gz')) { + run('tar', ['-xzf', archivePath, '-C', destination]); + return; + } + throw new Error(`Unsupported OpenCode CLI archive: ${archivePath}`); +}; + +const findBinary = (root, binaryName) => { + const entries = fs.readdirSync(root, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(root, entry.name); + if (entry.isFile() && entry.name.toLowerCase() === binaryName.toLowerCase()) { + return fullPath; + } + if (entry.isDirectory()) { + const found = findBinary(fullPath, binaryName); + if (found) return found; + } + } + return null; +}; + +const main = async () => { + const version = process.env.OPENCHAMBER_OPENCODE_CLI_VERSION || readPinnedSdkVersion(); + if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) { + throw new Error(`Invalid OpenCode CLI version: ${version}`); + } + + const artifact = artifactForCurrentPlatform(); + const outputBinary = outputBinaryPath(artifact.binary); + const existingVersion = readBinaryVersion(outputBinary); + if (existingVersion === version) { + console.log(`[electron] bundled OpenCode CLI already prepared: ${outputBinary} (${version})`); + return; + } + + const cacheDir = path.join(cacheRoot, version, `${process.platform}-${process.arch}`); + const archivePath = path.join(cacheDir, artifact.name); + const url = `https://github.com/anomalyco/opencode/releases/download/v${version}/${artifact.name}`; + if (!fs.existsSync(archivePath)) { + console.log(`[electron] downloading OpenCode CLI ${version}: ${artifact.name}`); + await download(url, archivePath); + } else { + console.log(`[electron] using cached OpenCode CLI archive: ${archivePath}`); + } + + const extractDir = path.join(cacheDir, 'extract'); + extractArchive(archivePath, extractDir); + const extractedBinary = findBinary(extractDir, artifact.binary); + if (!extractedBinary) { + throw new Error(`Archive ${archivePath} did not contain ${artifact.binary}`); + } + + fs.mkdirSync(outputDir, { recursive: true }); + for (const entry of fs.readdirSync(outputDir)) { + if (entry === '.gitkeep') continue; + fs.rmSync(path.join(outputDir, entry), { recursive: true, force: true }); + } + fs.copyFileSync(extractedBinary, outputBinary); + ensureExecutable(outputBinary); + + const preparedVersion = readBinaryVersion(outputBinary); + if (preparedVersion !== version) { + throw new Error(`Prepared OpenCode CLI version mismatch: expected ${version}, got ${preparedVersion || 'unknown'}`); + } + + console.log(`[electron] prepared OpenCode CLI ${version}: ${outputBinary}`); +}; + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +}); diff --git a/packages/electron/scripts/verify-opencode-cli.mjs b/packages/electron/scripts/verify-opencode-cli.mjs new file mode 100644 index 00000000..5d9da4f1 --- /dev/null +++ b/packages/electron/scripts/verify-opencode-cli.mjs @@ -0,0 +1,107 @@ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const electronRoot = path.resolve(__dirname, '..'); +const workspaceRoot = path.resolve(electronRoot, '../..'); + +const readExpectedVersion = () => { + const pkg = JSON.parse(fs.readFileSync(path.join(workspaceRoot, 'package.json'), 'utf8')); + const version = pkg.dependencies?.['@opencode-ai/sdk']; + if (typeof version !== 'string' || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) { + throw new Error(`Expected root @opencode-ai/sdk to be pinned to an exact version, got: ${version || '(missing)'}`); + } + return version; +}; + +const binaryName = () => process.platform === 'win32' ? 'opencode.exe' : 'opencode'; + +const runVersion = (binaryPath) => { + const result = spawnSync(binaryPath, ['--version'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 15000, + windowsHide: true, + }); + if (result.status !== 0) { + const stderr = result.stderr ? `\n${result.stderr.trim()}` : ''; + const stdout = result.stdout ? `\n${result.stdout.trim()}` : ''; + throw new Error(`Failed to run bundled OpenCode CLI: ${binaryPath}${stderr}${stdout}`); + } + return (result.stdout || '').trim().split(/\s+/)[0] || ''; +}; + +const assertBinary = (binaryPath, expectedVersion) => { + if (!fs.existsSync(binaryPath)) { + throw new Error(`Bundled OpenCode CLI not found: ${binaryPath}`); + } + const stat = fs.statSync(binaryPath); + if (!stat.isFile()) { + throw new Error(`Bundled OpenCode CLI is not a file: ${binaryPath}`); + } + if (process.platform !== 'win32' && (stat.mode & 0o111) === 0) { + throw new Error(`Bundled OpenCode CLI is not executable: ${binaryPath}`); + } + const actualVersion = runVersion(binaryPath); + if (actualVersion !== expectedVersion) { + throw new Error(`Bundled OpenCode CLI version mismatch at ${binaryPath}: expected ${expectedVersion}, got ${actualVersion || '(empty)'}`); + } + console.log(`[electron] verified bundled OpenCode CLI ${actualVersion}: ${binaryPath}`); +}; + +const findPackagedBinaries = () => { + const distDir = path.join(electronRoot, 'dist'); + if (!fs.existsSync(distDir)) return []; + + const candidates = []; + const targetBinary = binaryName().toLowerCase(); + const visit = (dir) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + visit(fullPath); + continue; + } + if (!entry.isFile() || entry.name.toLowerCase() !== targetBinary) continue; + const parent = path.basename(path.dirname(fullPath)).toLowerCase(); + if (parent === 'opencode-cli') { + candidates.push(fullPath); + } + } + }; + visit(distDir); + return candidates; +}; + +const usage = () => { + console.error('Usage: node scripts/verify-opencode-cli.mjs --staged|--packaged'); + process.exit(2); +}; + +const main = () => { + const mode = process.argv[2]; + if (mode !== '--staged' && mode !== '--packaged') usage(); + + const expectedVersion = readExpectedVersion(); + if (mode === '--staged') { + assertBinary(path.join(electronRoot, 'resources', 'opencode-cli', binaryName()), expectedVersion); + return; + } + + const packagedBinaries = findPackagedBinaries(); + if (packagedBinaries.length === 0) { + throw new Error('No packaged OpenCode CLI found under packages/electron/dist'); + } + for (const packagedBinary of packagedBinaries) { + assertBinary(packagedBinary, expectedVersion); + } +}; + +try { + main(); +} catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +} diff --git a/packages/ui/package.json b/packages/ui/package.json index daef0b33..c68903e7 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -43,7 +43,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@lezer/highlight": "^1.2.3", - "@opencode-ai/sdk": "^1.17.12", + "@opencode-ai/sdk": "1.17.12", "@pierre/diffs": "1.3.0-beta.6", "@replit/codemirror-vim": "^6.3.0", "@simplewebauthn/browser": "13.3.0", diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index 3e614210..89ace18f 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -43,13 +43,14 @@ import * as sessionActions from "./session-actions" import { getSessionMaterializationStatus, materializeSessionSnapshots } from "./materialization" import { openSessionFromToast } from "./session-navigation" import { getRuntimeLiveStatusSeed, LIVE_STATUS_TTL_MS } from "./runtime-live-memory" -import { getRuntimeKey } from "@/lib/runtime-switch" -import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry" -import { setSessionPrefetch } from "./session-prefetch-cache" -import { listGlobalSessionPages } from "@/stores/globalSessions" -import { useGlobalSessionsStore } from "@/stores/useGlobalSessionsStore" -import { areRequestArraysReferentiallyEqual, collectScopedBlockingRequests } from "./scoped-blocking-requests" -import { EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT, buildUserMessageHistorySnapshot, type UserMessageHistorySnapshot } from "./user-message-history" +import { getRuntimeKey } from "@/lib/runtime-switch" +import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry" +import { setSessionPrefetch } from "./session-prefetch-cache" +import { listGlobalSessionPages } from "@/stores/globalSessions" +import { useGlobalSessionsStore } from "@/stores/useGlobalSessionsStore" +import { areRequestArraysReferentiallyEqual, collectScopedBlockingRequests } from "./scoped-blocking-requests" +import { EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT, buildUserMessageHistorySnapshot, type UserMessageHistorySnapshot } from "./user-message-history" +import { runtimeFetch } from "@/lib/runtime-fetch" // --------------------------------------------------------------------------- // Context @@ -1644,10 +1645,44 @@ function handleEvent( // Provider // --------------------------------------------------------------------------- -const dispatchOpenCodeUpdateAvailable = (payload: { version: string }) => { - if (typeof window === "undefined") return - window.dispatchEvent(new CustomEvent("openchamber:opencode-update-available", { detail: payload })) -} +const dispatchOpenCodeUpdateAvailable = (payload: { version: string }) => { + if (typeof window === "undefined") return + window.dispatchEvent(new CustomEvent("openchamber:opencode-update-available", { detail: payload })) +} + +let bundledOpenCodeRuntimeCache: { runtimeKey: string; promise: Promise } | null = null + +const isBundledOpenCodeRuntime = async () => { + const runtimeKey = getRuntimeKey() + if (!bundledOpenCodeRuntimeCache || bundledOpenCodeRuntimeCache.runtimeKey !== runtimeKey) { + bundledOpenCodeRuntimeCache = { + runtimeKey, + promise: runtimeFetch("/api/config/opencode-resolution", { signal: AbortSignal.timeout(4000) }) + .then(async (response) => { + if (response.ok) { + const resolution = await response.json() as { source?: unknown; detectedSourceNow?: unknown } + return resolution.source === "bundled" || resolution.detectedSourceNow === "bundled" + } + + const healthResponse = await runtimeFetch("/health", { signal: AbortSignal.timeout(4000) }) + if (!healthResponse.ok) return false + const health = await healthResponse.json() as { opencodeBinarySource?: unknown } + return health.opencodeBinarySource === "bundled" + }) + .catch(() => false), + } + } + return bundledOpenCodeRuntimeCache.promise +} + +const dispatchOpenCodeUpdateAvailableUnlessBundled = (payload: { version: string }) => { + if (typeof window === "undefined") return + void isBundledOpenCodeRuntime().then((isBundled) => { + if (!isBundled) { + dispatchOpenCodeUpdateAvailable(payload) + } + }) +} export function SyncProvider(props: { sdk: OpencodeClient @@ -1859,13 +1894,13 @@ export function SyncProvider(props: { lastStreamActivityAtRef.current = Date.now() dispatchVSCodeRuntimeNotificationEvent(directory, payload) if (payload.type === "installation.update-available") { - const version = typeof (payload.properties as { version?: unknown })?.version === "string" - ? (payload.properties as { version: string }).version - : "" - if (version) { - dispatchOpenCodeUpdateAvailable({ version }) - } - } + const version = typeof (payload.properties as { version?: unknown })?.version === "string" + ? (payload.properties as { version: string }).version + : "" + if (version) { + dispatchOpenCodeUpdateAvailableUnlessBundled({ version }) + } + } handleEvent(directory, payload, childStores, routingIndex) }, onReconnect: () => { diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 20f556a5..53165781 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -244,7 +244,7 @@ }, "dependencies": { "@openchamber/ui": "workspace:*", - "@opencode-ai/sdk": "^1.17.12", + "@opencode-ai/sdk": "1.17.12", "adm-zip": "^0.5.16", "jsonc-parser": "^3.3.1", "react": "^19.1.1", diff --git a/packages/web/package.json b/packages/web/package.json index 19911bf9..65b4438d 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -25,7 +25,7 @@ "dependencies": { "@clack/prompts": "^1.1.0", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.17.12", + "@opencode-ai/sdk": "1.17.12", "@simplewebauthn/server": "13.3.1", "adm-zip": "^0.5.16", "better-sqlite3": "^12.10.0", diff --git a/packages/web/server/lib/opencode/env-runtime.js b/packages/web/server/lib/opencode/env-runtime.js index bee62da1..fabfa0a4 100644 --- a/packages/web/server/lib/opencode/env-runtime.js +++ b/packages/web/server/lib/opencode/env-runtime.js @@ -274,6 +274,33 @@ export const createOpenCodeEnvRuntime = (deps) => { return normalized.endsWith(`${path.sep}programs${path.sep}opencode${path.sep}opencode.exe`); }; + const bundledOpenCodeCliCandidates = () => { + const names = process.platform === 'win32' ? ['opencode.exe'] : ['opencode']; + const roots = [ + process.env.OPENCHAMBER_BUNDLED_OPENCODE_CLI_DIR, + typeof process.resourcesPath === 'string' ? path.join(process.resourcesPath, 'opencode-cli') : null, + ] + .map((value) => (typeof value === 'string' ? value.trim() : '')) + .filter(Boolean); + + const candidates = []; + for (const root of roots) { + for (const name of names) { + candidates.push(path.join(root, name)); + } + } + return candidates; + }; + + const resolveBundledOpenCodeCliPath = () => { + for (const candidate of bundledOpenCodeCliCandidates()) { + if (isExecutable(candidate) && !isWindowsOpenCodeDesktopAppPath(candidate)) { + return candidate; + } + } + return null; + }; + const clearWslOpencodeResolution = () => { state.useWslForOpencode = false; state.resolvedWslBinary = null; @@ -299,6 +326,13 @@ export const createOpenCodeEnvRuntime = (deps) => { } } + const bundled = resolveBundledOpenCodeCliPath(); + if (bundled) { + clearWslOpencodeResolution(); + state.resolvedOpencodeBinarySource = 'bundled'; + return bundled; + } + const resolvedFromPath = searchPathFor('opencode'); if (resolvedFromPath) { clearWslOpencodeResolution(); diff --git a/packages/web/server/lib/opencode/env-runtime.test.js b/packages/web/server/lib/opencode/env-runtime.test.js index 0e432a11..f5c5c7c5 100644 --- a/packages/web/server/lib/opencode/env-runtime.test.js +++ b/packages/web/server/lib/opencode/env-runtime.test.js @@ -9,6 +9,8 @@ const originalComSpec = process.env.ComSpec; const originalPath = process.env.PATH; const originalLocalAppData = process.env.LOCALAPPDATA; const originalSystemRoot = process.env.SystemRoot; +const originalBundledOpencodeCliDir = process.env.OPENCHAMBER_BUNDLED_OPENCODE_CLI_DIR; +const originalResourcesPath = process.resourcesPath; const originalWslBinary = process.env.WSL_BINARY; const originalOpenChamberWslBinary = process.env.OPENCHAMBER_WSL_BINARY; const originalPlatform = process.platform; @@ -66,6 +68,17 @@ afterEach(() => { delete process.env.LOCALAPPDATA; } + if (typeof originalBundledOpencodeCliDir === 'string') { + process.env.OPENCHAMBER_BUNDLED_OPENCODE_CLI_DIR = originalBundledOpencodeCliDir; + } else { + delete process.env.OPENCHAMBER_BUNDLED_OPENCODE_CLI_DIR; + } + + Object.defineProperty(process, 'resourcesPath', { + configurable: true, + value: originalResourcesPath, + }); + if (typeof originalWslBinary === 'string') { process.env.WSL_BINARY = originalWslBinary; } else { @@ -136,6 +149,67 @@ describe('OpenCode env runtime', () => { expect(state.resolvedOpencodeBinarySource).toBe('settings'); }); + it('resolves bundled OpenCode CLI before PATH lookup', () => { + const bundledDir = createTempDir('openchamber-bundled-opencode-'); + const bundledBinary = path.join(bundledDir, process.platform === 'win32' ? 'opencode.exe' : 'opencode'); + const pathDir = createTempDir('openchamber-path-opencode-'); + const pathBinary = path.join(pathDir, process.platform === 'win32' ? 'opencode.exe' : 'opencode'); + fs.writeFileSync(bundledBinary, '#!/bin/sh\nexit 0\n'); + fs.writeFileSync(pathBinary, '#!/bin/sh\nexit 0\n'); + if (process.platform !== 'win32') { + fs.chmodSync(bundledBinary, 0o755); + fs.chmodSync(pathBinary, 0o755); + } + process.env.OPENCHAMBER_BUNDLED_OPENCODE_CLI_DIR = bundledDir; + process.env.PATH = pathDir; + delete process.env.OPENCODE_BINARY; + const { runtime, state } = createRuntime({}); + + expect(runtime.resolveOpencodeCliPath()).toBe(bundledBinary); + expect(state.resolvedOpencodeBinarySource).toBe('bundled'); + }); + + it('keeps explicit OpenCode binary ahead of bundled CLI', () => { + const bundledDir = createTempDir('openchamber-bundled-opencode-'); + const bundledBinary = path.join(bundledDir, process.platform === 'win32' ? 'opencode.exe' : 'opencode'); + const explicitDir = createTempDir('openchamber-explicit-opencode-'); + const explicitBinary = path.join(explicitDir, process.platform === 'win32' ? 'opencode.exe' : 'opencode'); + fs.writeFileSync(bundledBinary, '#!/bin/sh\nexit 0\n'); + fs.writeFileSync(explicitBinary, '#!/bin/sh\nexit 0\n'); + if (process.platform !== 'win32') { + fs.chmodSync(bundledBinary, 0o755); + fs.chmodSync(explicitBinary, 0o755); + } + process.env.OPENCHAMBER_BUNDLED_OPENCODE_CLI_DIR = bundledDir; + process.env.OPENCODE_BINARY = explicitBinary; + const { runtime, state } = createRuntime({}); + + expect(runtime.resolveOpencodeCliPath()).toBe(explicitBinary); + expect(state.resolvedOpencodeBinarySource).toBe('env'); + }); + + it('resolves bundled OpenCode CLI from Electron resourcesPath', () => { + const resourcesPath = createTempDir('openchamber-resources-'); + const bundledDir = path.join(resourcesPath, 'opencode-cli'); + const bundledBinary = path.join(bundledDir, process.platform === 'win32' ? 'opencode.exe' : 'opencode'); + fs.mkdirSync(bundledDir, { recursive: true }); + fs.writeFileSync(bundledBinary, '#!/bin/sh\nexit 0\n'); + if (process.platform !== 'win32') { + fs.chmodSync(bundledBinary, 0o755); + } + Object.defineProperty(process, 'resourcesPath', { + configurable: true, + value: resourcesPath, + }); + process.env.PATH = createTempDir('openchamber-empty-path-'); + delete process.env.OPENCHAMBER_BUNDLED_OPENCODE_CLI_DIR; + delete process.env.OPENCODE_BINARY; + const { runtime, state } = createRuntime({}); + + expect(runtime.resolveOpencodeCliPath()).toBe(bundledBinary); + expect(state.resolvedOpencodeBinarySource).toBe('bundled'); + }); + itIf(process.platform === 'darwin')('rejects known macOS OpenCode app bundle executable paths', async () => { const { runtime } = createRuntime({ opencodeBinary: '/Applications/OpenCode.app/Contents/MacOS/OpenCode' }); diff --git a/packages/web/server/lib/opencode/routes.js b/packages/web/server/lib/opencode/routes.js index f7c54db5..08cdb0ac 100644 --- a/packages/web/server/lib/opencode/routes.js +++ b/packages/web/server/lib/opencode/routes.js @@ -41,6 +41,25 @@ export const registerOpenCodeRoutes = (app, dependencies) => { return trimmed || null; }; + const isBundledOpenCodeBinaryActive = async () => { + const settings = await readSettingsFromDiskMigrated(); + const resolution = await getOpenCodeResolutionSnapshot(settings); + return resolution?.source === 'bundled' || resolution?.detectedSourceNow === 'bundled'; + }; + + const readOpenCodeCurrentVersion = async () => { + const healthResponse = await fetch(buildOpenCodeUrl('/global/health', ''), { + method: 'GET', + headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() }, + }); + const health = await healthResponse.json().catch(() => null); + if (!healthResponse.ok) { + return { ok: false, status: healthResponse.status, error: health?.error || healthResponse.statusText }; + } + const currentVersion = typeof health?.version === 'string' ? health.version.replace(/^v/, '') : null; + return { ok: true, currentVersion }; + }; + const parseVersionForComparison = (value) => { const normalized = String(value || '').replace(/^v/, '').split('+')[0]; const prereleaseIndex = normalized.indexOf('-'); @@ -136,6 +155,13 @@ export const registerOpenCodeRoutes = (app, dependencies) => { app.post('/api/opencode/upgrade', async (req, res) => { try { + if (await isBundledOpenCodeBinaryActive()) { + return res.status(409).json({ + success: false, + error: 'OpenCode is bundled with OpenChamber Desktop and cannot be upgraded separately.', + }); + } + const target = typeof req.body?.target === 'string' && req.body.target.trim().length > 0 ? req.body.target.trim() : undefined; @@ -180,6 +206,16 @@ export const registerOpenCodeRoutes = (app, dependencies) => { app.get('/api/opencode/upgrade-status', async (_req, res) => { try { + if (await isBundledOpenCodeBinaryActive()) { + const current = await readOpenCodeCurrentVersion().catch(() => ({ ok: false, currentVersion: null })); + return res.json({ + available: false, + currentVersion: current.ok ? current.currentVersion : null, + latestVersion: null, + source: 'bundled', + }); + } + const [healthResponse, latestVersion] = await Promise.all([ fetch(buildOpenCodeUrl('/global/health', ''), { method: 'GET', diff --git a/scripts/oc-dev.mjs b/scripts/oc-dev.mjs index 94170fd2..ade8433e 100755 --- a/scripts/oc-dev.mjs +++ b/scripts/oc-dev.mjs @@ -53,6 +53,7 @@ Actions: start-mobile-dev Start mobile app with dev server live reload mobile-tools Mobile build/sync/deploy helper menu start-electron-app Start Electron app in dev mode + prepare-opencode-cli Download/cache bundled OpenCode CLI for Electron build-electron-app Build Electron app artifacts start-vscode-extension Build + launch VS Code extension host install-vscode-extension-local Build, package, and install local VSIX @@ -180,6 +181,8 @@ function normalizeAction(action = '') { 'mobile-menu': 'mobile-tools', 'remote-deploy-web': 'remote-deploy-web', 'electron-dev': 'start-electron-app', + 'opencode-cli': 'prepare-opencode-cli', + 'electron-opencode-cli': 'prepare-opencode-cli', 'electron-build': 'build-electron-app', 'vscode-dev': 'start-vscode-extension', 'vscode-install-local': 'install-vscode-extension-local', @@ -474,10 +477,16 @@ async function mobileTools(options, config) { } function startElectronApp() { + prepareOpenCodeCli(); run('bun', ['run', 'electron:dev']); } +function prepareOpenCodeCli() { + step('Preparing bundled OpenCode CLI', () => run('bun', ['--filter', '@openchamber/electron', 'prepare:opencode-cli'])); +} + function buildElectronApp() { + prepareOpenCodeCli(); run('bun', ['run', 'electron:build'], { env: { CSC_IDENTITY_AUTO_DISCOVERY: 'false' } }); const distDir = path.join(repoRoot, 'packages/electron/dist'); if (!existsSync(distDir) || !isMac) return; @@ -543,6 +552,7 @@ async function chooseAction(config) { { value: 'start-mobile-dev', label: 'Start mobile dev' }, { value: 'mobile-tools', label: 'Mobile tools' }, { value: 'start-electron-app', label: 'Start Electron app' }, + { value: 'prepare-opencode-cli', label: 'Prepare bundled OpenCode CLI' }, { value: 'build-electron-app', label: 'Build Electron app' }, { value: 'start-vscode-extension', label: 'Start VS Code extension' }, { value: 'install-vscode-extension-local', label: 'Install VS Code extension locally' }, @@ -589,6 +599,9 @@ async function main() { case 'start-electron-app': startElectronApp(); break; + case 'prepare-opencode-cli': + prepareOpenCodeCli(); + break; case 'build-electron-app': buildElectronApp(); break; From be9911fac0cf69c692fb2f2b21529026f2c565dc Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Thu, 2 Jul 2026 19:00:58 +0300 Subject: [PATCH 0081/1073] release v1.13.9 --- CHANGELOG.md | 14 ++++++++++++-- package.json | 2 +- packages/electron/package.json | 2 +- packages/ui/package.json | 2 +- packages/vscode/CHANGELOG.md | 8 ++++++++ packages/vscode/package.json | 2 +- packages/web/package.json | 2 +- 7 files changed, 25 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73b75e4b..bf19462e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,18 @@ All notable changes to this project will be documented in this file. ## [Unreleased] -- Desktop: remote instances can now save additional request headers for proxy-auth setups such as Cloudflare Access, including for live updates and terminal streams. -- Desktop: SSH remote instances with a saved UI password no longer ask for that UI password again after the tunnel connects. +## [1.13.9] - 2026-07-02 + +- Mobile: added the native iOS and Android app projects ahead of the mobile app release, with continued polish for saved connections, password unlock, QR-code connection scanning, push notifications, iOS widgets, app resume, and native layout details. +- Desktop: the app can now use a bundled OpenCode CLI, or you can choose your own CLI path in settings. +- Desktop: added a Keep awake setting for the upcoming desktop app release to prevent the computer from sleeping while the app is running. +- Desktop: you can now specify optional custom headers when adding a remote OpenChamber instance to the desktop app, including for Cloudflare Access-style setups; settings and environment variables can still override them, and the bundled CLI can be replaced by setting a direct OpenCode CLI path. +- Desktop: SSH remote instances with a saved UI password now open directly after the tunnel connects instead of showing the unlock screen again. +- Chat: fixed edge cases where late-loading tool content, subagent content, or streaming Thinking blocks could pull the conversation away from the latest message or fight manual scrolling. +- Chat: embedded JSON examples in messages no longer render as generated-result cards. +- Sync: chat state now recovers after idle reconnects instead of leaving sessions stuck in a stale busy state. +- VSCode: clearing optional agent fields now removes them from agent config instead of saving `null` values. +- VSCode: the extension no longer picks OpenCode desktop app installs when looking for the standalone OpenCode CLI. ## [1.13.8] - 2026-06-29 diff --git a/package.json b/package.json index 91851ef8..c18f83a4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openchamber-monorepo", - "version": "1.13.8", + "version": "1.13.9", "description": "OpenChamber monorepo workspace for web, ui, and desktop runtimes", "private": true, "type": "module", diff --git a/packages/electron/package.json b/packages/electron/package.json index a1d9514e..96f490b0 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -1,6 +1,6 @@ { "name": "@openchamber/electron", - "version": "1.13.8", + "version": "1.13.9", "private": true, "description": "Electron desktop runtime for OpenChamber", "author": "OpenChamber", diff --git a/packages/ui/package.json b/packages/ui/package.json index c68903e7..f8ae5ec1 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@openchamber/ui", - "version": "1.13.8", + "version": "1.13.9", "private": true, "type": "module", "main": "src/main.tsx", diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index ee615726..b3a5631f 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -1,3 +1,11 @@ +## [1.13.9] - 2026-07-02 + +- Agents: clearing optional agent fields now removes them from agent config instead of saving `null` values. +- Startup: the extension no longer picks OpenCode desktop app installs when looking for the standalone OpenCode CLI. +- Chat: fixed edge cases where late-loading tool content, subagent content, or streaming Thinking blocks could pull the conversation away from the latest message or fight manual scrolling. +- Chat: embedded JSON examples in messages no longer render as generated-result cards. +- Sync: chat state now recovers after idle reconnects instead of leaving sessions stuck in a stale busy state. + ## [1.13.8] - 2026-06-29 - Chat: a new Follow-up behavior setting controls what happens when you press Enter on a message while the agent is still responding — Steer inserts it into the agent's current turn, or Queue holds it until the turn finishes. Replaces the previous queue-mode toggle (thanks to @bashrusakh). diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 53165781..0a9a8ea1 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -2,7 +2,7 @@ "name": "openchamber", "displayName": "OpenChamber", "description": "%extension.description%", - "version": "1.13.8", + "version": "1.13.9", "publisher": "fedaykindev", "private": true, "repository": { diff --git a/packages/web/package.json b/packages/web/package.json index 65b4438d..83bb0dfb 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@openchamber/web", - "version": "1.13.8", + "version": "1.13.9", "private": false, "type": "module", "main": "./server/index.js", From 01e0905e8b76f537db5ad4cb59beeb552608b0c3 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Thu, 2 Jul 2026 19:23:42 +0300 Subject: [PATCH 0082/1073] fix(ci): run OpenCode CLI version step in bash The release workflow reads the pinned @opencode-ai/sdk version with bash command-substitution syntax before caching the bundled OpenCode CLI artifact. On Windows jobs GitHub Actions defaults run steps to PowerShell, which treated VERSION= as a command and failed before the cache/build steps ran. Set shell: bash on the release workflow version-discovery steps so macOS and Windows use the same syntax. The smoke workflow already used bash for the Windows version step, which is why the smoke artifact could pass while the production Windows release job failed. --- .github/workflows/release.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ef66d355..68390b3f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -148,6 +148,7 @@ jobs: - name: Get bundled OpenCode CLI version id: opencode_cli_version + shell: bash run: | VERSION=$(node -p "require('./package.json').dependencies['@opencode-ai/sdk']") echo "version=$VERSION" >> "$GITHUB_OUTPUT" @@ -294,6 +295,7 @@ jobs: - name: Get bundled OpenCode CLI version id: opencode_cli_version + shell: bash run: | VERSION=$(node -p "require('./package.json').dependencies['@opencode-ai/sdk']") echo "version=$VERSION" >> "$GITHUB_OUTPUT" From 3bd785a10af1889afecf569dce520cd2f82fa238 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Thu, 2 Jul 2026 22:44:00 +0300 Subject: [PATCH 0083/1073] fix: prevent mobile session resync flicker Avoid unnecessary resync on clean initial stream connect Skip no-op message snapshot writes during recovery Only trigger mobile resume sync after real app resume --- packages/ui/src/apps/MobileApp.tsx | 15 ++++++- packages/ui/src/sync/session-actions.ts | 5 +++ packages/ui/src/sync/sync-context.tsx | 55 +++++++++++++++---------- 3 files changed, 52 insertions(+), 23 deletions(-) diff --git a/packages/ui/src/apps/MobileApp.tsx b/packages/ui/src/apps/MobileApp.tsx index 7e7c1ea9..825b523d 100644 --- a/packages/ui/src/apps/MobileApp.tsx +++ b/packages/ui/src/apps/MobileApp.tsx @@ -202,19 +202,30 @@ const useNativeMobileChrome = (): void => { }; const useNativeMobileLifecycle = (onResume: () => void): void => { + const wasInactiveRef = React.useRef(false); + React.useEffect(() => { if (!isCapacitorMobileApp()) return; let disposed = false; const cleanup: Array<() => void> = []; + const resumeAfterInactive = () => { + if (!wasInactiveRef.current) return; + wasInactiveRef.current = false; + onResume(); + }; void import('@capacitor/app').then(async ({ App }) => { if (disposed) return; const state = await App.addListener('appStateChange', ({ isActive }) => { document.documentElement.classList.toggle('oc-native-app-active', isActive); - if (isActive) onResume(); + if (!isActive) { + wasInactiveRef.current = true; + return; + } + resumeAfterInactive(); }); - const resume = await App.addListener('resume', onResume); + const resume = await App.addListener('resume', resumeAfterInactive); if (disposed) { void state.remove(); void resume.remove(); diff --git a/packages/ui/src/sync/session-actions.ts b/packages/ui/src/sync/session-actions.ts index 8d4fe796..e0dc66c5 100644 --- a/packages/ui/src/sync/session-actions.ts +++ b/packages/ui/src/sync/session-actions.ts @@ -1169,6 +1169,10 @@ export async function fetchMessagesForSession(sessionID: string, directory?: str // can't repopulate (and un-evict) a session already navigated away from. if (useSessionUIStore.getState().currentSessionId !== sessionID) return + const latestState = store.getState() + const latestStatus = getSessionMaterializationStatus(latestState, sessionID) + if (latestStatus.renderable && (latestState.message[sessionID]?.length ?? 0) >= records.length) return + store.setState((state) => { const materialized = materializeSessionSnapshots( state, @@ -1179,6 +1183,7 @@ export async function fetchMessagesForSession(sessionID: string, directory?: str })), { skipPartTypes: MESSAGE_REFETCH_SKIP_PARTS }, ) + if (!materialized.messagesChanged && !materialized.partsChanged) return state return { message: materialized.message, part: materialized.part } }) } catch { diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index 89ace18f..6d94b74b 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -277,15 +277,18 @@ async function materializeSessionFromServer( if (options?.isStale?.()) return store.setState((state: DirectoryStore) => { - const materialized = materializeSessionSnapshots( - state, - sessionID, + const materialized = materializeSessionSnapshots( + state, + sessionID, records.map((record: { info: Message; parts?: Part[] }) => ({ info: stripMessageDiffSnapshots(record.info), parts: record.parts ?? [], - })), + })), { skipPartTypes: RECONNECT_SKIP_PARTS }, ) + if (!materialized.messagesChanged && !materialized.partsChanged) { + return state + } return { message: materialized.message, part: materialized.part } }) @@ -1705,9 +1708,11 @@ export function SyncProvider(props: { const lastStatusPollAtByDirectoryRef = useRef(new Map()) const lastFullResyncAtByDirectoryRef = useRef(new Map()) const lastChildDiscoveryAtByDirectoryRef = useRef(new Map()) - const resyncingDirectoriesRef = useRef(new Set()) - const statusPollingDirectoriesRef = useRef(new Set()) - const pipelineReconnectRef = useRef<((reason?: string) => void) | null>(null) + const resyncingDirectoriesRef = useRef(new Set()) + const statusPollingDirectoriesRef = useRef(new Set()) + const pipelineReconnectRef = useRef<((reason?: string) => void) | null>(null) + const pipelineHasConnectedRef = useRef(false) + const pipelineDisconnectedBeforeFirstConnectRef = useRef(false) const system = useMemo( () => ({ @@ -1903,23 +1908,31 @@ export function SyncProvider(props: { } handleEvent(directory, payload, childStores, routingIndex) }, - onReconnect: () => { - useConfigStore.setState({ - isConnected: true, - hasEverConnected: true, - connectionPhase: "connected", - }) - if (isRecentBoot()) { - return - } + onReconnect: () => { + useConfigStore.setState({ + isConnected: true, + hasEverConnected: true, + connectionPhase: "connected", + }) + const isFirstConnect = !pipelineHasConnectedRef.current + pipelineHasConnectedRef.current = true + if (isFirstConnect && !pipelineDisconnectedBeforeFirstConnectRef.current) { + return + } + if (isRecentBoot()) { + return + } for (const dir of childStores.children.keys()) { triggerDirectoryResync(dir, "stream-reconnect") } - }, - onDisconnect: (reason) => { - const { hasEverConnected } = useConfigStore.getState() - useConfigStore.setState({ - isConnected: false, + }, + onDisconnect: (reason) => { + if (!pipelineHasConnectedRef.current) { + pipelineDisconnectedBeforeFirstConnectRef.current = true + } + const { hasEverConnected } = useConfigStore.getState() + useConfigStore.setState({ + isConnected: false, connectionPhase: hasEverConnected ? "reconnecting" : "connecting", lastDisconnectReason: reason, }) From d71aec54db73221b797e6dd0cd4f9a6f28bdd2a9 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 3 Jul 2026 01:34:04 +0300 Subject: [PATCH 0084/1073] fix: stabilize chat history prepend scroll preservation on mobile and desktop - Mobile: defeat iOS momentum scroll when compensating history prepend (overflow toggle + short rAF watchdog); disable history virtualization and post-paint background prepends; preload Markdown renderer and use plain-text Suspense fallback to avoid first-frame geometry shifts - Desktop: stop double-compensating prepends on the virtualized list - virtua shift owns the adjustment; remove sticky-anchor heuristics that misfired as failed restores - Sync: skip no-op store writes when messages/parts are unchanged --- packages/ui/src/apps/renderMobileApp.tsx | 2 + .../src/components/chat/MarkdownRenderer.tsx | 29 ++- .../ui/src/components/chat/MessageList.tsx | 27 ++- .../hooks/useChatTimelineController.test.ts | 31 ++- .../chat/hooks/useChatTimelineController.ts | 204 ++++++++++++++++-- .../components/chat/markdownRendererLoader.ts | 13 ++ packages/ui/src/sync/use-sync.ts | 20 +- 7 files changed, 286 insertions(+), 40 deletions(-) create mode 100644 packages/ui/src/components/chat/markdownRendererLoader.ts diff --git a/packages/ui/src/apps/renderMobileApp.tsx b/packages/ui/src/apps/renderMobileApp.tsx index 64f77954..3c2beeaf 100644 --- a/packages/ui/src/apps/renderMobileApp.tsx +++ b/packages/ui/src/apps/renderMobileApp.tsx @@ -16,6 +16,7 @@ import { initializeLocale, I18nProvider } from '@/lib/i18n'; import { initializeAppearancePreferences, syncDesktopSettings } from '@/lib/persistence'; import { startModelPrefsAutoSave } from '@/lib/modelPrefsAutoSave'; import { startTypographyWatcher } from '@/lib/typographyWatcher'; +import { preloadMarkdownRenderer } from '@/components/chat/markdownRendererLoader'; import { MobileApp } from './MobileApp'; const initializeSharedPreferences = () => { @@ -42,6 +43,7 @@ const initializeSharedPreferences = () => { }; export function renderMobileApp(apis: RuntimeAPIs) { + preloadMarkdownRenderer(); initializeSharedPreferences(); // Expose the widget snapshot builder so the native shell can read the session overview diff --git a/packages/ui/src/components/chat/MarkdownRenderer.tsx b/packages/ui/src/components/chat/MarkdownRenderer.tsx index 4d56736b..c294bd6f 100644 --- a/packages/ui/src/components/chat/MarkdownRenderer.tsx +++ b/packages/ui/src/components/chat/MarkdownRenderer.tsx @@ -1,5 +1,8 @@ import React from 'react'; import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery'; +import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface'; +import { cn } from '@/lib/utils'; +import { loadMarkdownRendererModule } from './markdownRendererLoader'; // Thin lazy wrapper around the MarkdownRenderer implementation. // The full implementation (marked + Shiki highlighting + KaTeX + morphdom @@ -7,23 +10,41 @@ import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery'; // initial bundle lean. const MarkdownRendererLazy = lazyWithChunkRecovery(() => - import('./MarkdownRendererImpl').then((m) => ({ default: m.MarkdownRenderer })) + loadMarkdownRendererModule().then((m) => ({ default: m.MarkdownRenderer })) ); const SimpleMarkdownRendererLazy = lazyWithChunkRecovery(() => - import('./MarkdownRendererImpl').then((m) => ({ default: m.SimpleMarkdownRenderer })) + loadMarkdownRendererModule().then((m) => ({ default: m.SimpleMarkdownRenderer })) ); const fallback =
; +const fallbackContentClassName = (variant: unknown): string => { + if (variant === 'tool') return 'markdown-content markdown-tool'; + if (variant === 'reasoning') return 'markdown-content markdown-reasoning'; + return 'markdown-content leading-relaxed'; +}; + +const MobileMarkdownFallback = (props: { content?: unknown; className?: unknown; variant?: unknown }) => { + if (!isMobileSurfaceRuntime() || typeof props.content !== 'string' || props.content.length === 0) { + return fallback; + } + + return ( +
+ {props.content} +
+ ); +}; + export const MarkdownRenderer: React.FC> = (props) => ( - + }> ); export const SimpleMarkdownRenderer: React.FC> = (props) => ( - + }> ); diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx index 653f370d..73bcea09 100644 --- a/packages/ui/src/components/chat/MessageList.tsx +++ b/packages/ui/src/components/chat/MessageList.tsx @@ -155,6 +155,21 @@ const getMessageParentId = (message: ChatMessageEntry): string | null => { return typeof parentID === 'string' && parentID.trim().length > 0 ? parentID : null; }; +const isInsideStuckSticky = (node: HTMLElement, container: HTMLElement, containerTop: number): boolean => { + if (typeof window === 'undefined') return false; + + let current: HTMLElement | null = node; + while (current && current !== container) { + const computed = window.getComputedStyle(current); + if (computed.position === 'sticky' && current.getBoundingClientRect().top <= containerTop + 1) { + return true; + } + current = current.parentElement; + } + + return false; +}; + const isUserShellMarkerMessage = (message: ChatMessageEntry | undefined): boolean => { if (!message) return false; if (resolveMessageRole(message) !== 'user') return false; @@ -373,6 +388,7 @@ export interface MessageListHandle { scrollToMessageId: (messageId: string, options?: { behavior?: ScrollBehavior }) => boolean; captureViewportAnchor: () => { messageId: string; offsetTop: number } | null; restoreViewportAnchor: (anchor: { messageId: string; offsetTop: number }) => boolean; + isHistoryVirtualized: () => boolean; scrollToBottom: () => void; } @@ -1262,7 +1278,10 @@ const MessageList = React.forwardRef(({ } const historyEntries = staticRenderEntries; - const shouldVirtualizeHistory = historyEntries.length >= MESSAGE_LIST_VIRTUALIZE_THRESHOLD; + // Virtua hides unmeasured items until ResizeObserver reports their height. + // Mobile momentum scrolling can outrun that measurement and expose blank + // reserved rows, so keep the constrained mobile history mounted normally. + const shouldVirtualizeHistory = !isMobileSurfaceRuntime() && historyEntries.length >= MESSAGE_LIST_VIRTUALIZE_THRESHOLD; const historyEntryKeys = React.useMemo(() => historyEntries.map((entry) => entry.key), [historyEntries]); const virtualCache = React.useMemo( () => (shouldVirtualizeHistory ? readTimelineCache(sessionKey, historyEntryKeys) : undefined), @@ -1468,6 +1487,8 @@ const MessageList = React.forwardRef(({ ); }, + isHistoryVirtualized: () => shouldVirtualizeHistory, + captureViewportAnchor: () => { const container = resolveScrollContainer(); if (!container) { @@ -1486,9 +1507,7 @@ const MessageList = React.forwardRef(({ return true; } - const computed = window.getComputedStyle(node); - const isStuckSticky = computed.position === 'sticky' && rect.top <= containerRect.top + 1; - return !isStuckSticky; + return !isInsideStuckSticky(node, container, containerRect.top); }) ?? nodes.find((node) => node.getBoundingClientRect().bottom > containerRect.top + 1); if (!firstVisible) { return null; diff --git a/packages/ui/src/components/chat/hooks/useChatTimelineController.test.ts b/packages/ui/src/components/chat/hooks/useChatTimelineController.test.ts index 7c45cd4f..4eb0a72e 100644 --- a/packages/ui/src/components/chat/hooks/useChatTimelineController.test.ts +++ b/packages/ui/src/components/chat/hooks/useChatTimelineController.test.ts @@ -1,6 +1,9 @@ import { describe, expect, test } from 'bun:test'; -import { shouldAutoLoadEarlierForUnderfilledPinnedViewport } from './useChatTimelineController'; +import { + isOlderHistoryPrependCommit, + shouldAutoLoadEarlierForUnderfilledPinnedViewport, +} from './useChatTimelineController'; const baseInput = { sessionId: 'ses_1', @@ -39,3 +42,29 @@ describe('shouldAutoLoadEarlierForUnderfilledPinnedViewport', () => { })).toBe(false); }); }); + +describe('isOlderHistoryPrependCommit', () => { + test('detects older messages inserted above the existing timeline', () => { + expect(isOlderHistoryPrependCommit({ + previousOldestId: 'msg_2', + previousNewestId: 'msg_4', + currentOldestId: 'msg_1', + currentNewestId: 'msg_4', + })).toBe(true); + }); + + test('does not treat appends or replacements as prepends', () => { + expect(isOlderHistoryPrependCommit({ + previousOldestId: 'msg_2', + previousNewestId: 'msg_4', + currentOldestId: 'msg_2', + currentNewestId: 'msg_5', + })).toBe(false); + expect(isOlderHistoryPrependCommit({ + previousOldestId: 'msg_2', + previousNewestId: 'msg_4', + currentOldestId: 'msg_1', + currentNewestId: 'msg_5', + })).toBe(false); + }); +}); diff --git a/packages/ui/src/components/chat/hooks/useChatTimelineController.ts b/packages/ui/src/components/chat/hooks/useChatTimelineController.ts index 13a0793c..528eafa4 100644 --- a/packages/ui/src/components/chat/hooks/useChatTimelineController.ts +++ b/packages/ui/src/components/chat/hooks/useChatTimelineController.ts @@ -126,6 +126,75 @@ export const shouldAutoLoadEarlierForUnderfilledPinnedViewport = (input: { return input.scrollHeight <= input.clientHeight + 1; }; +export const isOlderHistoryPrependCommit = (input: { + previousOldestId: string | null; + previousNewestId: string | null; + currentOldestId: string | null; + currentNewestId: string | null; +}): boolean => Boolean( + input.previousOldestId + && input.currentOldestId + && input.currentOldestId !== input.previousOldestId + && input.previousNewestId + && input.currentNewestId + && input.currentNewestId === input.previousNewestId, +); + +// iOS WKWebView ignores programmatic scrollTop writes while a touch drag or +// momentum (fling) scroll is active: the native scroll animation keeps running +// and overwrites the value on the next frame. The mobile history threshold is +// large enough that the prepend commit almost always lands mid-fling, so a +// plain `container.scrollTop = target` never sticks. Toggling overflow kills +// the native scroll synchronously (pre-paint, invisible inside a layout +// effect); a short post-paint watchdog re-asserts the target if residual +// momentum still drags the viewport upward. +const MOMENTUM_WATCHDOG_FRAMES = 20; +const MOMENTUM_WATCHDOG_TOLERANCE_PX = 4; + +const setScrollTopDefeatingMomentum = (container: HTMLElement, target: number) => { + const previousOverflow = container.style.overflow; + container.style.overflow = 'hidden'; + container.scrollTop = target; + void container.scrollHeight; + container.style.overflow = previousOverflow; + container.scrollTop = target; + + if (typeof window === 'undefined') return; + let cancelled = false; + let frames = 0; + const cancelOnUserTouch = () => { + cancelled = true; + }; + container.addEventListener('touchstart', cancelOnUserTouch, { passive: true, once: true }); + const watch = () => { + if (cancelled) return; + // Only correct upward drift (residual momentum). Downward movement or + // content growth above the viewport must not be fought here. + if (container.scrollTop < target - MOMENTUM_WATCHDOG_TOLERANCE_PX) { + container.scrollTop = target; + } + frames += 1; + if (frames < MOMENTUM_WATCHDOG_FRAMES) { + window.requestAnimationFrame(watch); + } else { + container.removeEventListener('touchstart', cancelOnUserTouch); + } + }; + window.requestAnimationFrame(watch); +}; + +const hasInsertedBeforeKnownOldest = ( + previousOldestId: string | null, + currentOldestId: string | null, + messages: ChatMessageEntry[], +): boolean => { + if (!previousOldestId || !currentOldestId || currentOldestId === previousOldestId) { + return false; + } + + return messages.some((message) => message.info.id === previousOldestId); +}; + export const useChatTimelineController = ({ sessionId, messages, @@ -339,9 +408,12 @@ export const useChatTimelineController = ({ // before triggering the state change. useLayoutEffect consumes it // after React commits new DOM — before the browser paints. const prePrependScrollRef = React.useRef<{ + sessionId: string | null; height: number; top: number; anchor: ViewportAnchor | null; + oldestId: string | null; + newestId: string | null; } | null>(null); const captureViewportAnchor = React.useCallback((): ViewportAnchor | null => { @@ -364,26 +436,47 @@ export const useChatTimelineController = ({ scrollHeight: number; } | null>(null); + React.useLayoutEffect(() => { + prePrependScrollRef.current = null; + prependTrackingRef.current = null; + }, [sessionId]); + React.useLayoutEffect(() => { const container = scrollRef.current; if (!container) return; - const snap = prePrependScrollRef.current; + let snap = prePrependScrollRef.current; const prev = prependTrackingRef.current; const currentOldestId = renderedMessages[0]?.info?.id ?? null; const currentNewestId = renderedMessages[renderedMessages.length - 1]?.info?.id ?? null; - // A prepend = content inserted ABOVE the viewport: the oldest message id - // changed while the newest stayed the same. This distinguishes a history - // load from a bottom append, a streaming part growing, or a session switch. - const isPrepend = Boolean( - prev - && prev.oldestId - && currentOldestId - && currentOldestId !== prev.oldestId - && prev.newestId - && currentNewestId - && currentNewestId === prev.newestId, - ); + // A prepend = content inserted ABOVE the viewport: either the newest + // stayed fixed, or the old first message still exists below a new first + // message. The latter keeps preservation alive if a tail append lands in + // the same commit as the history page. + const isPrepend = prev + ? isOlderHistoryPrependCommit({ + previousOldestId: prev.oldestId, + previousNewestId: prev.newestId, + currentOldestId, + currentNewestId, + }) || hasInsertedBeforeKnownOldest(prev.oldestId, currentOldestId, renderedMessages) + : false; + + if (snap && snap.sessionId !== sessionIdRef.current) { + prePrependScrollRef.current = null; + snap = null; + } + + const isSnapshotPrepend = snap + ? isOlderHistoryPrependCommit({ + previousOldestId: snap.oldestId, + previousNewestId: snap.newestId, + currentOldestId, + currentNewestId, + }) || hasInsertedBeforeKnownOldest(snap.oldestId, currentOldestId, renderedMessages) + : false; + const didPrepend = isPrepend || isSnapshotPrepend; + const shouldConsumeSnapshot = Boolean(snap && (isPrepend || isSnapshotPrepend)); const updateTracking = () => { prependTrackingRef.current = { @@ -393,6 +486,22 @@ export const useChatTimelineController = ({ }; }; + const refreshPendingSnapshot = () => { + const pending = prePrependScrollRef.current; + if (!pending) { + return; + } + + prePrependScrollRef.current = { + ...pending, + height: container.scrollHeight, + top: container.scrollTop, + anchor: captureViewportAnchor(), + oldestId: currentOldestId, + newestId: currentNewestId, + }; + }; + if (isPinnedRef.current) { // Bottom-pinned. Only content inserted ABOVE (a prepend / history load) // needs an explicit re-pin: with overflow-anchor:none the browser leaves @@ -407,38 +516,74 @@ export const useChatTimelineController = ({ // best, and the source of the old up/down jiggle on send / from the // queue / while streaming. So for an append we do nothing and let // auto-follow own it. - if (snap || isPrepend) { + if (didPrepend) { prePrependScrollRef.current = null; goToBottom('instant'); + } else if (snap) { + refreshPendingSnapshot(); } updateTracking(); return; } - if (snap) { + // When the history list is virtualized, virtua runs with `shift` during + // history loads and compensates the prepend internally. Manual + // height-delta compensation on top of that applies the same delta twice + // and throws the viewport far downward. Anchor restore stays allowed — + // it corrects to an absolute element position, so it cannot double up. + const historyVirtualized = messageListRef.current?.isHistoryVirtualized() ?? false; + + if (snap && shouldConsumeSnapshot) { prePrependScrollRef.current = null; + const heightDelta = container.scrollHeight - snap.height; + const applyHeightDelta = (): boolean => { + if (historyVirtualized || heightDelta <= 0) { + return false; + } + container.scrollTop = snap.top + heightDelta; + return true; + }; + + if (isMobileSurfaceRuntime() && heightDelta > 0) { + setScrollTopDefeatingMomentum(container, snap.top + heightDelta); + updateTracking(); + return; + } + // When a viewport anchor is available, delegate to MessageList // restoreViewportAnchor which falls back to virtualizer-aware // scrollHistoryIndexIntoView when the element is not in the DOM. + // Note: an unchanged scrollTop after restore is NOT a failure here — + // the virtualized desktop list runs with virtua `shift`, which + // compensates the prepend internally, so staying near snap.top is + // the correct outcome. if (!(snap.anchor && restoreViewportAnchor(snap.anchor))) { // Fallback: height-delta compensation - const delta = container.scrollHeight - snap.height; - if (delta > 0) { - container.scrollTop = snap.top + delta; - } + applyHeightDelta(); } - } else if (isPrepend && prev) { + } else if (isPrepend && prev && !historyVirtualized) { // Released viewport: preserve the read position by compensating for the // exact height the prepend added above, with no intermediate frame for - // auto-follow to fight. + // auto-follow to fight. Virtualized lists skip this — virtua `shift` + // already compensated the prepend. const delta = container.scrollHeight - prev.scrollHeight; if (delta > 0) { - container.scrollTop = container.scrollTop + delta; + const target = container.scrollTop + delta; + if (isMobileSurfaceRuntime()) { + setScrollTopDefeatingMomentum(container, target); + } else { + container.scrollTop = target; + } } + } else if (snap) { + // setIsLoadingOlder/historyMeta can commit before the server page + // arrives. Keep the snapshot armed, but refresh it so later fallback + // compensation only accounts for rows actually prepended above. + refreshPendingSnapshot(); } updateTracking(); - }, [renderedMessages, scrollRef, restoreViewportAnchor, goToBottom]); + }, [captureViewportAnchor, messageListRef, renderedMessages, scrollRef, restoreViewportAnchor, goToBottom]); const revealBufferedTurns = React.useCallback(async (): Promise => false, []); @@ -462,9 +607,12 @@ export const useChatTimelineController = ({ // compensate synchronously when React commits the new messages. if (input.preserveViewport && container) { prePrependScrollRef.current = { + sessionId: sessionIdRef.current, height: container.scrollHeight, top: container.scrollTop, anchor: captureViewportAnchor(), + oldestId: beforeOldestMessageId, + newestId: beforeMessages[beforeMessages.length - 1]?.info?.id ?? null, }; } @@ -474,6 +622,7 @@ export const useChatTimelineController = ({ try { const targetSessionId = sessionIdRef.current; if (!targetSessionId) { + prePrependScrollRef.current = null; return false; } @@ -485,6 +634,7 @@ export const useChatTimelineController = ({ while (true) { await loadMoreMessages(targetSessionId, 'up'); if (sessionIdRef.current !== targetSessionId) { + prePrependScrollRef.current = null; return false; } @@ -506,6 +656,7 @@ export const useChatTimelineController = ({ return true; } if (!messageGrowth) { + prePrependScrollRef.current = null; return false; } if (!historySignalsRef.current.hasMoreAboveTurns) { @@ -516,6 +667,9 @@ export const useChatTimelineController = ({ loadedOldestMessageId = afterOldestMessageId; loadedLimit = afterLimit; } + } catch (error) { + prePrependScrollRef.current = null; + throw error; } finally { setIsLoadingOlder(false); settleHistoryInteraction(); @@ -547,6 +701,10 @@ export const useChatTimelineController = ({ }, [loadEarlier, scrollRef]); const loadEarlierIfPinnedViewportUnderfilled = React.useCallback(() => { + // On mobile the initial page is intentionally smaller. Auto-prepending + // older rows after first paint shifts the narrow timeline; let explicit + // upward scroll request history instead. + if (isMobileSurfaceRuntime()) return; if (historyInteractionRef.current) return; const container = scrollRef.current; if (!container) return; diff --git a/packages/ui/src/components/chat/markdownRendererLoader.ts b/packages/ui/src/components/chat/markdownRendererLoader.ts new file mode 100644 index 00000000..986fbedc --- /dev/null +++ b/packages/ui/src/components/chat/markdownRendererLoader.ts @@ -0,0 +1,13 @@ +let markdownRendererModulePromise: Promise | null = null; + +export const loadMarkdownRendererModule = () => { + markdownRendererModulePromise ??= import('./MarkdownRendererImpl').catch((error) => { + markdownRendererModulePromise = null; + throw error; + }); + return markdownRendererModulePromise; +}; + +export const preloadMarkdownRenderer = () => { + void loadMarkdownRendererModule().catch(() => undefined); +}; diff --git a/packages/ui/src/sync/use-sync.ts b/packages/ui/src/sync/use-sync.ts index b0d646d6..6271ffde 100644 --- a/packages/ui/src/sync/use-sync.ts +++ b/packages/ui/src/sync/use-sync.ts @@ -399,7 +399,12 @@ export function useSync() { complete: merged.complete, loading: false, }) - store.setState({ message: materialized.message, part: materialized.part }) + if (materialized.messagesChanged || materialized.partsChanged) { + store.setState({ + ...(materialized.messagesChanged ? { message: materialized.message } : {}), + ...(materialized.partsChanged ? { part: materialized.part } : {}), + }) + } setSessionPrefetch({ directory, sessionID, @@ -498,13 +503,12 @@ export function useSync() { shouldLoadMessages ? loadMessages(sessionID, { isStale }) : Promise.resolve(), ]) - // Progressive mount: after the initial page resolves, if the session - // isn't stale and the server indicated more messages, dispatch a - // second fetch to prepend older history. The user sees the first page - // immediately; the rest arrive shortly after. This gives the scroll - // container headroom above the viewport so the "load older on - // scroll-up" trigger fires before the user hits the absolute top. - if (!isStale()) { + // Progressive mount on desktop: after the initial page resolves, if the + // session isn't stale and the server indicated more messages, dispatch a + // second fetch to prepend older history. Mobile avoids this background + // prepend because adding rows after first paint on a narrow viewport can + // visibly shift the timeline; user scroll still loads older history. + if (!isStale() && !isMobileSurfaceRuntime()) { const currentMeta = getMetaFor(sessionID) if (currentMeta.cursor && !currentMeta.complete) { loadMessages(sessionID, { before: currentMeta.cursor, mode: "prepend", isStale }) From 2bce38cfbb57df2bfe86593c40e672130b60704b Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 3 Jul 2026 13:49:53 +0300 Subject: [PATCH 0085/1073] feat(chat): migrate history list to @tanstack/react-virtual with deterministic mobile history loading - Replace virtua with @tanstack/react-virtual for chat history on all surfaces: bottom anchoring (anchorTo: end), key-stable prepend preservation, and native iOS touch/momentum deferral live in the core - Patch virtual-core to clamp the render range to real scroll bounds during transient adjustments - Rows render in normal flow inside a translated wrapper so sticky user headers keep working; measurement snapshots cached per session - Pre-write container height in scrollToFn so the browser cannot clamp anchor corrections to the stale height; hold the prepend anchor for up to 180 frames on mobile while fresh rows settle (cancelled by user input; desktop relies on core anchoring alone) - Adaptive row-size estimate from per-session measured averages; disable reveal fade-in for virtualized history rows - Mobile loads older history only through an explicit localized top button: no scroll-position trigger and no post-mount background prepend, so every insert happens from a resting state; a quiet-window hold defers any stray prepend commit while a touch gesture is active - Desktop/VS Code keep the seamless scroll-up trigger and progressive background prepend --- bun.lock | 16 +- package.json | 3 + packages/ui/package.json | 1 + .../ui/src/components/chat/ChatContainer.tsx | 35 +- .../ui/src/components/chat/MessageList.tsx | 443 +++++++++++++----- .../chat/hooks/useChatTimelineController.ts | 45 +- packages/ui/src/lib/i18n/messages/en.ts | 1 + packages/ui/src/lib/i18n/messages/es.ts | 1 + packages/ui/src/lib/i18n/messages/fr.ts | 1 + packages/ui/src/lib/i18n/messages/ja.ts | 1 + packages/ui/src/lib/i18n/messages/ko.ts | 1 + packages/ui/src/lib/i18n/messages/pl.ts | 1 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 1 + packages/ui/src/lib/i18n/messages/uk.ts | 1 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 1 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 1 + packages/ui/src/sync/use-sync.ts | 14 +- patches/@tanstack%2Fvirtual-core@3.17.3.patch | 36 ++ 18 files changed, 458 insertions(+), 145 deletions(-) create mode 100644 patches/@tanstack%2Fvirtual-core@3.17.3.patch diff --git a/bun.lock b/bun.lock index e49df436..37335213 100644 --- a/bun.lock +++ b/bun.lock @@ -97,7 +97,7 @@ }, "packages/electron": { "name": "@openchamber/electron", - "version": "1.13.8", + "version": "1.13.9", "dependencies": { "@openchamber/web": "workspace:*", "electron-context-menu": "^4.1.2", @@ -133,7 +133,7 @@ }, "packages/ui": { "name": "@openchamber/ui", - "version": "1.13.8", + "version": "1.13.9", "dependencies": { "@aparajita/capacitor-secure-storage": "^8.0.0", "@base-ui/react": "^1.4.0", @@ -171,6 +171,7 @@ "@pierre/diffs": "1.3.0-beta.6", "@replit/codemirror-vim": "^6.3.0", "@simplewebauthn/browser": "13.3.0", + "@tanstack/react-virtual": "3.14.5", "@xenova/transformers": "^2.17.2", "@zumer/snapdom": "^2.12.0", "beautiful-mermaid": "^1.1.3", @@ -236,7 +237,7 @@ }, "packages/vscode": { "name": "openchamber", - "version": "1.13.8", + "version": "1.13.9", "dependencies": { "@openchamber/ui": "workspace:*", "@opencode-ai/sdk": "1.17.12", @@ -259,7 +260,7 @@ }, "packages/web": { "name": "@openchamber/web", - "version": "1.13.8", + "version": "1.13.9", "bin": { "openchamber": "./bin/cli.js", }, @@ -345,6 +346,9 @@ }, }, }, + "patchedDependencies": { + "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch", + }, "overrides": { "@codemirror/language": "6.12.2", "@codemirror/view": "6.39.13", @@ -1296,6 +1300,10 @@ "@tailwindcss/postcss": ["@tailwindcss/postcss@4.2.1", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "postcss": "^8.5.6", "tailwindcss": "4.2.1" } }, "sha512-OEwGIBnXnj7zJeonOh6ZG9woofIjGrd2BORfvE5p9USYKDCZoQmfqLcfNiRWoJlRWLdNPn2IgVZuWAOM4iTYMw=="], + "@tanstack/react-virtual": ["@tanstack/react-virtual@3.14.5", "", { "dependencies": { "@tanstack/virtual-core": "3.17.3" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-4EKRXh7zBLkbKbFmG3AUVkircuHd+7OdT1pocJSepxtfBd3qnrJgJ5rtPkRYyo9fmyVb2+pI2xPy5oYvMLQy6A=="], + + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.3", "", {}, "sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw=="], + "@textlint/ast-node-types": ["@textlint/ast-node-types@15.5.2", "", {}, "sha512-fCaOxoup5LIyBEo7R1oYWE7V4bSX0KQeHh66twon9e9usaLE3ijgF8QjYsR6joCssdeCHVd0wHm7ppsEyTr6vg=="], "@textlint/linter-formatter": ["@textlint/linter-formatter@15.5.2", "", { "dependencies": { "@azu/format-text": "^1.0.2", "@azu/style-format": "^1.0.1", "@textlint/module-interop": "15.5.2", "@textlint/resolver": "15.5.2", "@textlint/types": "15.5.2", "chalk": "^4.1.2", "debug": "^4.4.3", "js-yaml": "^4.1.1", "lodash": "^4.17.23", "pluralize": "^2.0.0", "string-width": "^4.2.3", "strip-ansi": "^6.0.1", "table": "^6.9.0", "text-table": "^0.2.0" } }, "sha512-jAw7jWM8+wU9cG6Uu31jGyD1B+PAVePCvnPKC/oov+2iBPKk3ao30zc/Itmi7FvXo4oPaL9PmzPPQhyniPVgVg=="], diff --git a/package.json b/package.json index c18f83a4..8f83f714 100644 --- a/package.json +++ b/package.json @@ -174,5 +174,8 @@ "typescript": "~5.9.0", "typescript-eslint": "^8.39.1", "vite": "^7.1.2" + }, + "patchedDependencies": { + "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch" } } diff --git a/packages/ui/package.json b/packages/ui/package.json index f8ae5ec1..862ffbb1 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -47,6 +47,7 @@ "@pierre/diffs": "1.3.0-beta.6", "@replit/codemirror-vim": "^6.3.0", "@simplewebauthn/browser": "13.3.0", + "@tanstack/react-virtual": "3.14.5", "@xenova/transformers": "^2.17.2", "@zumer/snapdom": "^2.12.0", "beautiful-mermaid": "^1.1.3", diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index b73c52ee..0a25eb42 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -46,6 +46,7 @@ import { getSessionPrefetch, subscribeSessionPrefetch } from '@/sync/session-pre import { getSessionMaterializationStatus } from '@/sync/materialization'; import { usePlanDetection } from '@/hooks/usePlanDetection'; import { useI18n } from '@/lib/i18n'; +import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface'; import { isVSCodeRuntime } from '@/lib/desktop'; const EMPTY_MESSAGES: Array<{ info: Message; parts: Part[] }> = []; @@ -157,6 +158,8 @@ type ChatViewportProps = { sessionQuestions: QuestionRequest[]; sessionPermissions: PermissionRequest[]; isProgrammaticFollowActive: boolean; + showLoadOlderButton: boolean; + onLoadOlder: () => void; }; const ChatViewport = React.memo(({ @@ -181,7 +184,10 @@ const ChatViewport = React.memo(({ sessionQuestions, sessionPermissions, isProgrammaticFollowActive, + showLoadOlderButton, + onLoadOlder, }: ChatViewportProps) => { + const { t } = useI18n(); const focusScrollContainer = React.useCallback((event: React.MouseEvent) => { if (event.defaultPrevented || shouldIgnoreChatNavigationTarget(event.target)) { return; @@ -218,6 +224,21 @@ const ChatViewport = React.memo(({ data-scrollbar="chat" >
+ {showLoadOlderButton && ( +
+ +
+ )} = ({ autoOpenDraft = tr const resumeToLatestInstant = React.useCallback(() => { goToBottom('instant'); }, [goToBottom]); + // Mobile loads older history via an explicit top button instead of a + // scroll-position trigger (see handleHistoryScroll in the controller). + const showLoadOlderButton = isMobileSurfaceRuntime() + && timelineController.historySignals.canLoadEarlier; + const timelineLoadEarlier = timelineController.loadEarlier; + const handleLoadOlderClick = React.useCallback(() => { + void timelineLoadEarlier({ userInitiated: true }); + }, [timelineLoadEarlier]); React.useEffect(() => { activeTurnChangeRef.current = timelineController.handleActiveTurnChange; @@ -918,6 +949,8 @@ export const ChatContainer: React.FC = ({ autoOpenDraft = tr sessionQuestions={sessionQuestions} sessionPermissions={sessionPermissions} isProgrammaticFollowActive={isFollowingProgrammatically} + showLoadOlderButton={showLoadOlderButton} + onLoadOlder={handleLoadOlderClick} />
(); -const MESSAGE_LIST_BUFFER_SIZE = 900; -// Touch surfaces fling-scroll natively and dispatch scroll events less often -// than the virtualizer can repaint, so a desktop-sized buffer leaves blank gaps -// during momentum that only fill once measurement catches up. A larger overscan -// keeps more rows mounted around the viewport so fast flings stay populated. -const MOBILE_MESSAGE_LIST_BUFFER_SIZE = 2400; -const resolveMessageListBufferSize = (): number => ( - isMobileSurfaceRuntime() ? MOBILE_MESSAGE_LIST_BUFFER_SIZE : MESSAGE_LIST_BUFFER_SIZE -); const TIMELINE_CACHE_LIMIT = 16; const sameKeys = (a: readonly string[] | undefined, b: readonly string[] | undefined): boolean => { @@ -42,28 +33,81 @@ const sameKeys = (a: readonly string[] | undefined, b: readonly string[] | undef return a.every((key, index) => key === b[index]); }; -const timelineCache = new Map(); +// --- History virtualization (@tanstack/react-virtual) ---------------------- +// The history list virtualizes with @tanstack/react-virtual on all surfaces: +// its core has bottom anchoring (anchorTo: 'end'), key-stable prepend +// preservation, and native iOS touch/momentum deferral for scroll +// adjustments — the failure modes that historically forced virtua off on +// mobile and required manual prepend compensation on desktop. +type TanstackVirtualizerInstance = ReactVirtualizer; +type HistoryEngine = 'none' | 'tanstack'; -const readTimelineCache = (sessionKey: string, keys: readonly string[]): CacheSnapshot | undefined => { - const entry = timelineCache.get(sessionKey); +const TANSTACK_ESTIMATED_ENTRY_SIZE = 320; +const TANSTACK_OVERSCAN = 8; +// Touch flings cover more distance between paints than desktop wheels; a +// larger window keeps fast mobile scrolling over mounted rows. +const TANSTACK_MOBILE_OVERSCAN = 16; +const resolveTanstackOverscan = (): number => ( + isMobileSurfaceRuntime() ? TANSTACK_MOBILE_OVERSCAN : TANSTACK_OVERSCAN +); +// Post-prepend anchor hold (upstream parity): measurements of freshly +// prepended rows settle over multiple frames, so a single restore can be +// invalidated by the next measurement pass. Re-assert the anchor until it +// holds still for STABLE_FRAMES consecutive frames, giving up at MAX_FRAMES. +const ANCHOR_HOLD_STABLE_FRAMES = 30; +const ANCHOR_HOLD_MAX_FRAMES = 180; +// Adaptive estimate bounds: only trust the session average once a few rows +// are measured, and keep it inside sane turn-height bounds. +const TANSTACK_ESTIMATE_MIN_SAMPLES = 5; +const TANSTACK_ESTIMATE_MIN = 120; +const TANSTACK_ESTIMATE_MAX = 1200; + +// Quiet-window prepend on mobile: while a touch drag or momentum scroll is +// active, iOS owns the scroll position and ANY geometry change above the +// viewport races against the native animation — a race that compensation +// logic can only lose sometimes. So freshly loaded older history is held +// (data already fetched, store already updated) and inserted into the +// rendered list only once the gesture goes quiet. Safety valves: flush when +// the user gets close to the top (a blank top is worse than a small hop) or +// after MAX_HOLD_MS. +const HISTORY_PREPEND_QUIET_MS = 160; +const HISTORY_PREPEND_MAX_HOLD_MS = 1500; +const HISTORY_PREPEND_NEAR_TOP_VIEWPORTS = 1.5; +const HISTORY_PREPEND_MONITOR_INTERVAL_MS = 90; + +// A commit is a deferable prepend when older entries were inserted strictly +// above the known content: the previous first key still exists deeper in the +// list and the tail is unchanged. Anything else renders immediately. +const isPrependAboveCommit = (previous: RenderEntry[], next: RenderEntry[]): boolean => { + if (previous.length === 0 || next.length <= previous.length) return false; + if (previous[previous.length - 1]?.key !== next[next.length - 1]?.key) return false; + const previousFirstKey = previous[0]?.key; + const insertedIndex = next.findIndex((entry) => entry.key === previousFirstKey); + return insertedIndex > 0; +}; + +const tanstackTimelineCache = new Map(); + +const readTanstackTimelineCache = (sessionKey: string, keys: readonly string[]): VirtualItem[] | undefined => { + const entry = tanstackTimelineCache.get(sessionKey); if (!entry) return undefined; - if (sameKeys(entry.keys, keys)) return entry.cache; - timelineCache.delete(sessionKey); + if (sameKeys(entry.keys, keys)) return entry.items; + tanstackTimelineCache.delete(sessionKey); return undefined; }; -const writeTimelineCache = ( +const writeTanstackTimelineCache = ( sessionKey: string, keys: readonly string[], - handle: VirtualizerHandle | null | undefined, + virtualizer: TanstackVirtualizerInstance | null | undefined, ): void => { - if (!handle || keys.length === 0) return; - timelineCache.delete(sessionKey); - timelineCache.set(sessionKey, { keys: keys.slice(), cache: handle.cache }); - while (timelineCache.size > TIMELINE_CACHE_LIMIT) { - const oldest = timelineCache.keys().next().value; + if (!virtualizer || keys.length === 0) return; + tanstackTimelineCache.delete(sessionKey); + tanstackTimelineCache.set(sessionKey, { keys: keys.slice(), items: virtualizer.takeSnapshot() }); + while (tanstackTimelineCache.size > TIMELINE_CACHE_LIMIT) { + const oldest = tanstackTimelineCache.keys().next().value; if (typeof oldest !== 'string') break; - timelineCache.delete(oldest); + tanstackTimelineCache.delete(oldest); } }; @@ -388,6 +432,7 @@ export interface MessageListHandle { scrollToMessageId: (messageId: string, options?: { behavior?: ScrollBehavior }) => boolean; captureViewportAnchor: () => { messageId: string; offsetTop: number } | null; restoreViewportAnchor: (anchor: { messageId: string; offsetTop: number }) => boolean; + holdViewportAnchor: (anchor: { messageId: string; offsetTop: number }) => void; isHistoryVirtualized: () => boolean; scrollToBottom: () => void; } @@ -930,13 +975,11 @@ MessageListEntry.displayName = 'MessageListEntry'; // Inner component that renders staged turn entries. type StaticHistoryListProps = { entries: RenderEntry[]; - shouldVirtualize: boolean; + engine: HistoryEngine; contentRef: React.RefObject; scrollRef?: React.RefObject; - virtualizerRef: React.Ref; + registerTanstackVirtualizer?: (virtualizer: TanstackVirtualizerInstance | null) => void; virtualizerKey: string; - virtualCache?: CacheSnapshot; - shift: boolean; onMessageContentChange: (reason?: ContentChangeReason) => void; getAnimationHandlers: (messageId: string) => AnimationHandlers; scrollToBottom?: () => void; @@ -950,7 +993,151 @@ type StaticHistoryListProps = { reviewTransferDirection?: ReviewTransferDirection | null; }; -const StaticHistoryList = React.memo(({ entries, shouldVirtualize, contentRef, scrollRef, virtualizerRef, virtualizerKey, virtualCache, shift, onMessageContentChange, getAnimationHandlers, scrollToBottom, stickyUserHeader, defaultActivityExpanded, turnUiStates, onToggleTurnGroup, chatRenderMode, shouldAnimateUserMessage, onUserAnimationConsumed, reviewTransferDirection }: StaticHistoryListProps) => { +const StaticHistoryList = React.memo(({ entries, engine, contentRef, scrollRef, registerTanstackVirtualizer, virtualizerKey, onMessageContentChange, getAnimationHandlers, scrollToBottom, stickyUserHeader, defaultActivityExpanded, turnUiStates, onToggleTurnGroup, chatRenderMode, shouldAnimateUserMessage, onUserAnimationConsumed, reviewTransferDirection }: StaticHistoryListProps) => { + const isTanstack = engine === 'tanstack'; + + // --- Quiet-window prepend (mobile) -------------------------------------- + // Gesture tracking for the deferred-prepend decision. Refs only: reading + // them never re-renders, and the render-phase reconcile below needs them. + const touchActiveRef = React.useRef(false); + const lastScrollAtRef = React.useRef(0); + const holdSinceRef = React.useRef(null); + const deferPrepends = isTanstack && isMobileSurfaceRuntime(); + + React.useEffect(() => { + if (!deferPrepends) return; + const element = scrollRef?.current; + if (!element) return; + const onTouchStart = () => { touchActiveRef.current = true; }; + const onTouchEnd = () => { touchActiveRef.current = false; }; + const onScroll = () => { lastScrollAtRef.current = performance.now(); }; + element.addEventListener('touchstart', onTouchStart, { passive: true }); + element.addEventListener('touchend', onTouchEnd, { passive: true }); + element.addEventListener('touchcancel', onTouchEnd, { passive: true }); + element.addEventListener('scroll', onScroll, { passive: true }); + return () => { + element.removeEventListener('touchstart', onTouchStart); + element.removeEventListener('touchend', onTouchEnd); + element.removeEventListener('touchcancel', onTouchEnd); + element.removeEventListener('scroll', onScroll); + }; + }, [deferPrepends, scrollRef]); + + const isGestureActive = React.useCallback(() => ( + touchActiveRef.current + || performance.now() - lastScrollAtRef.current < HISTORY_PREPEND_QUIET_MS + ), []); + + const isNearTop = React.useCallback(() => { + const element = scrollRef?.current; + if (!element) return true; + return element.scrollTop < element.clientHeight * HISTORY_PREPEND_NEAR_TOP_VIEWPORTS; + }, [scrollRef]); + + const [displayEntries, setDisplayEntries] = React.useState(entries); + // Render-phase reconcile (official derived-state pattern): adopt the new + // entries immediately unless this commit is a pure prepend-above landing + // in the middle of an active touch gesture — those wait for quiet. + let renderEntries = displayEntries; + if (entries !== displayEntries) { + const shouldHold = deferPrepends + && isPrependAboveCommit(displayEntries, entries) + && isGestureActive() + && !isNearTop() + && (holdSinceRef.current === null + || performance.now() - holdSinceRef.current < HISTORY_PREPEND_MAX_HOLD_MS); + if (shouldHold) { + if (holdSinceRef.current === null) holdSinceRef.current = performance.now(); + } else { + holdSinceRef.current = null; + setDisplayEntries(entries); + renderEntries = entries; + } + } else if (holdSinceRef.current !== null) { + holdSinceRef.current = null; + } + + // While a prepend is held, poll for the quiet window (touch/momentum have + // no completion event we can await) and flush by re-rendering. + const [, forceFlushTick] = React.useReducer((tick: number) => tick + 1, 0); + React.useEffect(() => { + if (!deferPrepends) return; + const timer = window.setInterval(() => { + if (holdSinceRef.current === null) return; + const expired = performance.now() - holdSinceRef.current >= HISTORY_PREPEND_MAX_HOLD_MS; + if (!isGestureActive() || isNearTop() || expired) { + forceFlushTick(); + } + }, HISTORY_PREPEND_MONITOR_INTERVAL_MS); + return () => window.clearInterval(timer); + }, [deferPrepends, isGestureActive, isNearTop]); + + const entriesRef = React.useRef(renderEntries); + entriesRef.current = renderEntries; + // Initial-only read: measurement cache restore is a mount-time concern; + // afterwards the live virtualizer owns measurements. + const [initialMeasurements] = React.useState(() => ( + isTanstack + ? readTanstackTimelineCache(virtualizerKey, entries.map((entry) => entry.key)) + : undefined + )); + + const sizeContainerRef = React.useRef(null); + // Adaptive estimate: rows this session has actually measured are a far + // better predictor for the still-unmeasured ones than a fixed constant. + // Smaller estimate error → smaller anchor corrections when prepended rows + // measure in → less visible drift. The ref keeps estimateSize's identity + // stable so updating the average never triggers a global remeasure. + const estimatedEntrySizeRef = React.useRef(TANSTACK_ESTIMATED_ENTRY_SIZE); + const tanstackVirtualizer = useTanstackVirtualizer({ + count: renderEntries.length, + enabled: isTanstack, + getScrollElement: () => scrollRef?.current ?? null, + estimateSize: () => estimatedEntrySizeRef.current, + overscan: resolveTanstackOverscan(), + scrollToFn: (offset, options, instance) => { + // Expose the new total height before core writes an anchor + // correction so the browser does not clamp the offset to the old + // height (upstream parity). + const sizeElement = sizeContainerRef.current; + if (sizeElement) sizeElement.style.height = `${instance.getTotalSize()}px`; + elementScroll(offset, options, instance); + }, + getItemKey: (index) => entriesRef.current[index]?.key ?? `index:${index}`, + // Bottom-anchored chat semantics: prepending older entries above the + // viewport must not move what the user is reading, and iOS-specific + // touch/momentum deferral for those adjustments lives in the core. + anchorTo: 'end', + initialOffset: () => Number.MAX_SAFE_INTEGER, + initialMeasurementsCache: initialMeasurements, + }); + + React.useEffect(() => { + if (!isTanstack) return; + const sizes = tanstackVirtualizer.itemSizeCache; + if (sizes.size >= TANSTACK_ESTIMATE_MIN_SAMPLES) { + let total = 0; + for (const size of sizes.values()) total += size; + estimatedEntrySizeRef.current = Math.min( + TANSTACK_ESTIMATE_MAX, + Math.max(TANSTACK_ESTIMATE_MIN, Math.round(total / sizes.size)), + ); + } + }); + + React.useEffect(() => { + if (!isTanstack) return; + registerTanstackVirtualizer?.(tanstackVirtualizer); + return () => { + writeTanstackTimelineCache( + virtualizerKey, + entriesRef.current.map((entry) => entry.key), + tanstackVirtualizer, + ); + registerTanstackVirtualizer?.(null); + }; + }, [isTanstack, registerTanstackVirtualizer, tanstackVirtualizer, virtualizerKey]); + const renderEntry = React.useCallback((entry: RenderEntry) => { return ( - {entries.map((entry) => ( + {renderEntries.map((entry) => (
- {(entry) => ( -
- {renderEntry(entry)} + if (engine === 'tanstack') { + const virtualItems = tanstackVirtualizer.getVirtualItems(); + const startOffset = virtualItems[0]?.start ?? 0; + // Rendered rows stay in normal flow inside a single translated wrapper + // (not per-row absolute positioning) so per-turn sticky user headers + // keep working against the scroll container. + return ( +
+
+ {virtualItems.map((item) => { + const entry = renderEntries[item.index]; + if (!entry) return null; + return ( +
+ {renderEntry(entry)} +
+ ); + })}
- )} - - ); +
+ ); + } + + return null; }); StaticHistoryList.displayName = 'StaticHistoryList'; @@ -1078,9 +1277,8 @@ const StreamingTailContent: React.FC<{ StreamingTailContent.displayName = 'StreamingTailContent'; -const MessageList = React.forwardRef(({ +const MessageList = React.forwardRef(({ sessionKey, - disableStaging = false, messages, sessionIsWorking = false, activeStreamingMessageId = null, @@ -1088,7 +1286,6 @@ const MessageList = React.forwardRef(({ retryOverlay = null, onMessageContentChange, getAnimationHandlers, - isLoadingOlder, scrollToBottom, scrollRef, directory, @@ -1176,7 +1373,6 @@ const MessageList = React.forwardRef(({ }), [messages]); const historyContentRef = React.useRef(null); - const historyVirtualizerRef = React.useRef(null); const resolveScrollContainer = React.useCallback((): HTMLDivElement | null => { if (scrollRef?.current) { return scrollRef.current; @@ -1278,41 +1474,14 @@ const MessageList = React.forwardRef(({ } const historyEntries = staticRenderEntries; - // Virtua hides unmeasured items until ResizeObserver reports their height. - // Mobile momentum scrolling can outrun that measurement and expose blank - // reserved rows, so keep the constrained mobile history mounted normally. - const shouldVirtualizeHistory = !isMobileSurfaceRuntime() && historyEntries.length >= MESSAGE_LIST_VIRTUALIZE_THRESHOLD; - const historyEntryKeys = React.useMemo(() => historyEntries.map((entry) => entry.key), [historyEntries]); - const virtualCache = React.useMemo( - () => (shouldVirtualizeHistory ? readTimelineCache(sessionKey, historyEntryKeys) : undefined), - [historyEntryKeys, sessionKey, shouldVirtualizeHistory], - ); - const virtualCacheSessionRef = React.useRef(sessionKey); - const virtualCacheKeysRef = React.useRef(historyEntryKeys); - const setHistoryVirtualizer = React.useCallback((handle: VirtualizerHandle | null) => { - if (!handle) { - writeTimelineCache( - virtualCacheSessionRef.current, - virtualCacheKeysRef.current, - historyVirtualizerRef.current, - ); - historyVirtualizerRef.current = null; - return; - } - - historyVirtualizerRef.current = handle; - }, []); - - React.useEffect(() => { - virtualCacheSessionRef.current = sessionKey; - virtualCacheKeysRef.current = historyEntryKeys; - }, [historyEntryKeys, sessionKey]); - - React.useEffect(() => { - const virtualizerForCleanup = historyVirtualizerRef.current; - return () => { - writeTimelineCache(virtualCacheSessionRef.current, virtualCacheKeysRef.current, virtualizerForCleanup); - }; + // All surfaces virtualize with @tanstack/react-virtual (see the engine + // note at the top of the file). An unvirtualized list is kept only for + // tiny histories where windowing overhead is not worth it. + const shouldVirtualizeHistory = historyEntries.length >= MESSAGE_LIST_VIRTUALIZE_THRESHOLD; + const historyEngine: HistoryEngine = shouldVirtualizeHistory ? 'tanstack' : 'none'; + const tanstackVirtualizerRef = React.useRef(null); + const registerTanstackVirtualizer = React.useCallback((virtualizer: TanstackVirtualizerInstance | null) => { + tanstackVirtualizerRef.current = virtualizer; }, []); const allEntries = React.useMemo(() => { @@ -1410,16 +1579,20 @@ const MessageList = React.forwardRef(({ }, [resolveScrollContainer]); const scrollHistoryIndexIntoView = React.useCallback((index: number, behavior: ScrollBehavior = 'auto') => { - if (!shouldVirtualizeHistory || index < 0 || index >= historyEntries.length) { + if (index < 0 || index >= historyEntries.length) { return false; } - const virtualizer = historyVirtualizerRef.current; + if (!shouldVirtualizeHistory) { + return false; + } + + const virtualizer = tanstackVirtualizerRef.current; if (!virtualizer) { return false; } - virtualizer.scrollToIndex(index, { align: 'start', smooth: behavior === 'smooth' }); + virtualizer.scrollToIndex(index, { align: 'start', behavior: behavior === 'smooth' ? 'smooth' : 'auto' }); return true; }, [historyEntries.length, shouldVirtualizeHistory]); @@ -1487,6 +1660,47 @@ const MessageList = React.forwardRef(({ ); }, + holdViewportAnchor: (anchor) => { + const container = resolveScrollContainer(); + if (!container || typeof window === 'undefined') { + return; + } + + let frames = 0; + let stable = 0; + let cancelled = false; + const cancelOnUserInput = () => { + cancelled = true; + container.removeEventListener('touchstart', cancelOnUserInput); + container.removeEventListener('wheel', cancelOnUserInput); + }; + container.addEventListener('touchstart', cancelOnUserInput, { passive: true }); + container.addEventListener('wheel', cancelOnUserInput, { passive: true }); + const step = () => { + if (cancelled) return; + const element = findMessageElement(anchor.messageId); + if (element) { + const delta = element.getBoundingClientRect().top + - container.getBoundingClientRect().top + - anchor.offsetTop; + if (Math.abs(delta) > 0.5) { + container.scrollTop += delta; + stable = 0; + } else { + stable += 1; + } + } + frames += 1; + if (stable >= ANCHOR_HOLD_STABLE_FRAMES || frames >= ANCHOR_HOLD_MAX_FRAMES) { + container.removeEventListener('touchstart', cancelOnUserInput); + container.removeEventListener('wheel', cancelOnUserInput); + return; + } + window.requestAnimationFrame(step); + }; + window.requestAnimationFrame(step); + }, + isHistoryVirtualized: () => shouldVirtualizeHistory, captureViewportAnchor: () => { @@ -1559,8 +1773,8 @@ const MessageList = React.forwardRef(({ }, scrollToBottom: () => { - if (shouldVirtualizeHistory && historyEntries.length > 0) { - historyVirtualizerRef.current?.scrollToIndex(historyEntries.length - 1, { align: 'end' }); + if (shouldVirtualizeHistory && historyEntries.length > 0 && tanstackVirtualizerRef.current) { + tanstackVirtualizerRef.current.scrollToEnd(); return; } const container = resolveScrollContainer(); @@ -1589,27 +1803,32 @@ const MessageList = React.forwardRef(({
- + {/* Virtualized history rows unmount/remount during scroll; + re-running the reveal fade on every remount reads as + blinking. History content is never "new", so fade-in + is disabled there — the streaming tail keeps it. */} + + + {trailingStreamingEntry ? ( { - if (!isMobileSurfaceRuntime()) { - return HISTORY_SCROLL_THRESHOLD - } - return Math.max( - MOBILE_HISTORY_SCROLL_THRESHOLD_MIN, - clientHeight * MOBILE_HISTORY_SCROLL_VIEWPORT_FACTOR, - ) -} const VSCODE_TURN_MODEL_CACHE_MAX = 4 const VSCODE_TURN_MODEL_CACHE_MAX_MESSAGES = 30 const MOBILE_TURN_MODEL_CACHE_MAX = 4 @@ -544,7 +526,10 @@ export const useChatTimelineController = ({ return true; }; - if (isMobileSurfaceRuntime() && heightDelta > 0) { + // Non-virtualized mobile list only: fight iOS momentum manually. + // The virtualized mobile list (tanstack) defers prepend adjustments + // through touch/momentum in core, so manual writes would double up. + if (isMobileSurfaceRuntime() && !historyVirtualized && heightDelta > 0) { setScrollTopDefeatingMomentum(container, snap.top + heightDelta); updateTracking(); return; @@ -554,13 +539,21 @@ export const useChatTimelineController = ({ // restoreViewportAnchor which falls back to virtualizer-aware // scrollHistoryIndexIntoView when the element is not in the DOM. // Note: an unchanged scrollTop after restore is NOT a failure here — - // the virtualized desktop list runs with virtua `shift`, which - // compensates the prepend internally, so staying near snap.top is - // the correct outcome. + // the virtualized list compensates the prepend internally, so + // staying near snap.top is the correct outcome. if (!(snap.anchor && restoreViewportAnchor(snap.anchor))) { // Fallback: height-delta compensation applyHeightDelta(); } + if (historyVirtualized && snap.anchor && isMobileSurfaceRuntime()) { + // Mobile only: freshly prepended rows keep re-measuring for a + // few frames and each pass can shift content, so hold the + // anchor until it settles. Desktop must NOT run this — wheel + // scrolling during the hold would fight the re-assertions and + // read as a frozen scroll; the virtualizer's own anchoring is + // enough there. + messageListRef.current?.holdViewportAnchor(snap.anchor); + } } else if (isPrepend && prev && !historyVirtualized) { // Released viewport: preserve the read position by compensating for the // exact height the prepend added above, with no intermediate frame for @@ -690,10 +683,16 @@ export const useChatTimelineController = ({ }, [beginHistoryInteraction, fetchOlderHistory, releaseAutoFollow, settleHistoryInteraction]); const handleHistoryScroll = React.useCallback(() => { + // Mobile never loads history from scroll position: any prepend racing + // an active touch gesture can be hijacked by the native scroll + // animation. The user scrolls to the natural top and taps an explicit + // "load older" button instead — the insert then happens from a resting + // state, which is fully deterministic. + if (isMobileSurfaceRuntime()) return; const container = scrollRef.current; if (!container) return; if (isPinnedRef.current) return; - if (container.scrollTop >= resolveHistoryScrollThreshold(container.clientHeight)) return; + if (container.scrollTop >= HISTORY_SCROLL_THRESHOLD) return; if (!historySignalsRef.current.canLoadEarlier) return; if (isLoadingOlderRef.current || pendingRevealWorkRef.current) return; diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 18375b2b..65f299cc 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -1279,6 +1279,7 @@ export const dict = { 'diffView.reviewDialog.actions.starting': 'Starting...', 'diffView.reviewDialog.toast.noSessionDirectory': 'Session directory is unavailable', 'diffView.reviewDialog.toast.startFailed': 'Failed to start review flow', + 'chat.history.loadOlder': 'Load older messages', 'chat.autoReview.title': 'Code review loop is running', 'chat.autoReview.status.waitingForReviewer': 'Waiting for reviewer', 'chat.autoReview.status.waitingForImplementer': 'Waiting for implementer', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index d1a93005..31f6311a 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -1245,6 +1245,7 @@ export const dict: Record = { 'diffView.reviewDialog.actions.starting': 'Iniciando...', 'diffView.reviewDialog.toast.noSessionDirectory': 'El directorio de la sesión no está disponible', 'diffView.reviewDialog.toast.startFailed': 'No se pudo iniciar el flujo de revisión', + 'chat.history.loadOlder': 'Cargar mensajes anteriores', 'chat.autoReview.title': 'El ciclo de revisión de código está en curso', 'chat.autoReview.status.waitingForReviewer': 'Esperando al revisor', 'chat.autoReview.status.waitingForImplementer': 'Esperando al implementador', diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index c4eb7f12..ac7833a2 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -1113,6 +1113,7 @@ export const dict = { 'diffView.reviewDialog.actions.starting': 'Démarrage...', 'diffView.reviewDialog.toast.noSessionDirectory': 'Le dossier de session est indisponible', 'diffView.reviewDialog.toast.startFailed': 'Impossible de démarrer le flux de revue', + 'chat.history.loadOlder': 'Charger les messages précédents', 'chat.autoReview.title': 'La boucle de revue de code est en cours', 'chat.autoReview.status.waitingForReviewer': 'En attente du reviewer', 'chat.autoReview.status.waitingForImplementer': 'En attente de l’implémenteur', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 306ae0fe..e1095738 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -1284,6 +1284,7 @@ export const dict: Record = { 'diffView.hunk.discardTitle': 'ハンク{index}を破棄', 'diffView.hunk.unavailable': 'このハンクはもう利用できません。差分を更新してからもう一度お試しください。', 'diffView.hunk.unsupported': '個別のハンクのステージングはこのランタイムではサポートされていません。', + 'chat.history.loadOlder': '以前のメッセージを読み込む', 'chat.autoReview.title': 'コードレビューループが実行中です', 'chat.autoReview.status.waitingForReviewer': 'レビュアーを待機中', 'chat.autoReview.status.waitingForImplementer': '実装者を待機中', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index d329885d..440c2b84 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1282,6 +1282,7 @@ export const dict: Record = { 'diffView.reviewDialog.actions.starting': '시작 중...', 'diffView.reviewDialog.toast.noSessionDirectory': '세션 디렉터리를 사용할 수 없습니다', 'diffView.reviewDialog.toast.startFailed': '리뷰 흐름을 시작하지 못했습니다', + 'chat.history.loadOlder': '이전 메시지 불러오기', 'chat.autoReview.title': '코드 리뷰 루프 실행 중', 'chat.autoReview.status.waitingForReviewer': '리뷰어를 기다리는 중', 'chat.autoReview.status.waitingForImplementer': '구현 에이전트를 기다리는 중', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 30cd07a1..e4c6cb2d 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -1481,6 +1481,7 @@ export const dict: Record = { 'diffView.reviewDialog.actions.starting': 'Uruchamianie...', 'diffView.reviewDialog.toast.noSessionDirectory': 'Katalog sesji jest niedostępny', 'diffView.reviewDialog.toast.startFailed': 'Nie udało się uruchomić flow review', + 'chat.history.loadOlder': 'Wczytaj starsze wiadomości', 'chat.autoReview.title': 'Pętla code review trwa', 'chat.autoReview.status.waitingForReviewer': 'Oczekiwanie na reviewera', 'chat.autoReview.status.waitingForImplementer': 'Oczekiwanie na implementatora', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index bcbf1aed..e48993b2 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1245,6 +1245,7 @@ export const dict: Record = { 'diffView.reviewDialog.actions.starting': 'Iniciando...', 'diffView.reviewDialog.toast.noSessionDirectory': 'O diretório da sessão está indisponível', 'diffView.reviewDialog.toast.startFailed': 'Falha ao iniciar o fluxo de revisão', + 'chat.history.loadOlder': 'Carregar mensagens anteriores', 'chat.autoReview.title': 'O ciclo de revisão de código está em andamento', 'chat.autoReview.status.waitingForReviewer': 'Aguardando o revisor', 'chat.autoReview.status.waitingForImplementer': 'Aguardando o implementador', diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 38414a0a..9e30739a 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1245,6 +1245,7 @@ export const dict: Record = { 'diffView.reviewDialog.actions.starting': 'Запуск...', 'diffView.reviewDialog.toast.noSessionDirectory': 'Директорія сесії недоступна', 'diffView.reviewDialog.toast.startFailed': 'Не вдалося запустити review flow', + 'chat.history.loadOlder': 'Завантажити ще', 'chat.autoReview.title': 'Цикл код-ревʼю триває', 'chat.autoReview.status.waitingForReviewer': 'Очікуємо ревʼювера', 'chat.autoReview.status.waitingForImplementer': 'Очікуємо імплементатора', diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 09ad5833..9956d237 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1245,6 +1245,7 @@ export const dict: Record = { 'diffView.reviewDialog.actions.starting': 'Starting...', 'diffView.reviewDialog.toast.noSessionDirectory': 'Session directory is unavailable', 'diffView.reviewDialog.toast.startFailed': 'Failed to start review flow', + 'chat.history.loadOlder': '加载更早的消息', 'chat.autoReview.title': '代码审查循环正在运行', 'chat.autoReview.status.waitingForReviewer': '等待审查者', 'chat.autoReview.status.waitingForImplementer': '等待实现者', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 34cdf134..68633d5c 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -1255,6 +1255,7 @@ export const dict: Record = { 'diffView.reviewDialog.actions.starting': 'Starting...', 'diffView.reviewDialog.toast.noSessionDirectory': 'Session directory is unavailable', 'diffView.reviewDialog.toast.startFailed': 'Failed to start review flow', + 'chat.history.loadOlder': '載入更早的訊息', 'chat.autoReview.title': '程式碼審查循環執行中', 'chat.autoReview.status.waitingForReviewer': '等待審查者', 'chat.autoReview.status.waitingForImplementer': '等待實作者', diff --git a/packages/ui/src/sync/use-sync.ts b/packages/ui/src/sync/use-sync.ts index 6271ffde..4f741d68 100644 --- a/packages/ui/src/sync/use-sync.ts +++ b/packages/ui/src/sync/use-sync.ts @@ -503,11 +503,15 @@ export function useSync() { shouldLoadMessages ? loadMessages(sessionID, { isStale }) : Promise.resolve(), ]) - // Progressive mount on desktop: after the initial page resolves, if the - // session isn't stale and the server indicated more messages, dispatch a - // second fetch to prepend older history. Mobile avoids this background - // prepend because adding rows after first paint on a narrow viewport can - // visibly shift the timeline; user scroll still loads older history. + // Progressive mount (desktop/VS Code): after the initial page + // resolves, if the session isn't stale and the server indicated more + // messages, dispatch a second fetch to prepend older history — it + // gives the scroll container headroom so the scroll-up trigger fires + // seamlessly. Mobile deliberately opts out: it has no scroll-position + // trigger at all — ALL older history loads happen through the + // explicit "load older" button at the top, so every prepend lands + // from a resting state the user initiated. (The initial page itself, + // including the turn-boundary extension, is unaffected.) if (!isStale() && !isMobileSurfaceRuntime()) { const currentMeta = getMetaFor(sessionID) if (currentMeta.cursor && !currentMeta.complete) { diff --git a/patches/@tanstack%2Fvirtual-core@3.17.3.patch b/patches/@tanstack%2Fvirtual-core@3.17.3.patch new file mode 100644 index 00000000..86e09c7b --- /dev/null +++ b/patches/@tanstack%2Fvirtual-core@3.17.3.patch @@ -0,0 +1,36 @@ +diff --git a/dist/cjs/index.cjs b/dist/cjs/index.cjs +index 52ae6ca12f8d1c650ee7f1bd55573ee7d4f8b65f..bcee09df7377c37ffb220606b741c9ff434b3470 100644 +--- a/dist/cjs/index.cjs ++++ b/dist/cjs/index.cjs +@@ -723,10 +723,12 @@ class Virtualizer { + this.range = null; + return null; + } ++ const maxScrollOffset = Math.max(this.getTotalSize() - outerSize, 0); ++ const effectiveScrollOffset = Math.min(Math.max(scrollOffset, 0), maxScrollOffset); + this.range = calculateRangeImpl( + measurements, + outerSize, +- scrollOffset, ++ effectiveScrollOffset, + lanes, + // Pass the typed array so binary search + forward-walk can read + // start/end directly from Float64Array, skipping the Proxy traps. +diff --git a/dist/esm/index.js b/dist/esm/index.js +index 3032c0ca457582be3f47923cba1f7d92c848745c..90b574881a073aabac99c075f7eab0a8f363fff6 100644 +--- a/dist/esm/index.js ++++ b/dist/esm/index.js +@@ -721,10 +721,12 @@ class Virtualizer { + this.range = null; + return null; + } ++ const maxScrollOffset = Math.max(this.getTotalSize() - outerSize, 0); ++ const effectiveScrollOffset = Math.min(Math.max(scrollOffset, 0), maxScrollOffset); + this.range = calculateRangeImpl( + measurements, + outerSize, +- scrollOffset, ++ effectiveScrollOffset, + lanes, + // Pass the typed array so binary search + forward-walk can read + // start/end directly from Float64Array, skipping the Proxy traps. From 3f5151d424d9e7c6a239c598a3bd41787bb971e3 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 3 Jul 2026 18:44:10 +0300 Subject: [PATCH 0086/1073] feat(ui): unify list virtualization on @tanstack/react-virtual and polish scroll behavior - Migrate sidebar session groups, git changes panel, virtualized code blocks, and JSON tree viewer from virtua to @tanstack/react-virtual; virtua remains only inside the Pierre diff viewer integration - Sidebar: preserve scroll position when virtualization enables mid-session (enable only once the ancestor scroll element is resolved, seed initial offset from its live scrollTop, render plain rows for the single pre-paint frame); disable native scroll anchoring on the sessions scroller; keep row spacing identical between plain and virtualized modes; absolute row positioning so variable-height rows cannot drift past the container - Chat: expand tool/thinking blocks downward by only adjusting scroll for rows growing above the viewport; raise the desktop history-load lead to 1.5 viewports so prepends land above the visible area - Git changes: compute the prefetch window from the first visible row, skipping overscan rows above the viewport - Sidebar rows: make the whole highlighted row area clickable, guarded against double-firing from interactive children --- .../ui/src/components/chat/MessageList.tsx | 17 +++- .../chat/hooks/useChatTimelineController.ts | 14 ++- .../message/parts/VirtualizedCodeBlock.tsx | 49 ++++++---- .../session/sidebar/SessionGroupSection.tsx | 93 ++++++++++++++++--- .../session/sidebar/SessionNodeItem.tsx | 15 ++- .../session/sidebar/SidebarProjectsList.tsx | 7 +- .../ui/src/components/ui/JsonTreeViewer.tsx | 45 +++++---- .../src/components/views/git/ChangesPanel.tsx | 81 +++++++++------- 8 files changed, 234 insertions(+), 87 deletions(-) diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx index bc45683d..dbac2f50 100644 --- a/packages/ui/src/components/chat/MessageList.tsx +++ b/packages/ui/src/components/chat/MessageList.tsx @@ -50,7 +50,7 @@ const TANSTACK_MOBILE_OVERSCAN = 16; const resolveTanstackOverscan = (): number => ( isMobileSurfaceRuntime() ? TANSTACK_MOBILE_OVERSCAN : TANSTACK_OVERSCAN ); -// Post-prepend anchor hold (upstream parity): measurements of freshly +// Post-prepend anchor hold: measurements of freshly // prepended rows settle over multiple frames, so a single restore can be // invalidated by the next measurement pass. Re-assert the anchor until it // holds still for STABLE_FRAMES consecutive frames, giving up at MAX_FRAMES. @@ -61,6 +61,8 @@ const ANCHOR_HOLD_MAX_FRAMES = 180; const TANSTACK_ESTIMATE_MIN_SAMPLES = 5; const TANSTACK_ESTIMATE_MIN = 120; const TANSTACK_ESTIMATE_MAX = 1200; +// "At bottom" tolerance for resize-adjustment decisions. +const TANSTACK_AT_END_THRESHOLD_PX = 80; // Quiet-window prepend on mobile: while a touch drag or momentum scroll is // active, iOS owns the scroll position and ANY geometry change above the @@ -1098,7 +1100,7 @@ const StaticHistoryList = React.memo(({ entries, engine, contentRef, scrollRef, scrollToFn: (offset, options, instance) => { // Expose the new total height before core writes an anchor // correction so the browser does not clamp the offset to the old - // height (upstream parity). + // height. const sizeElement = sizeContainerRef.current; if (sizeElement) sizeElement.style.height = `${instance.getTotalSize()}px`; elementScroll(offset, options, instance); @@ -1111,6 +1113,17 @@ const StaticHistoryList = React.memo(({ entries, engine, contentRef, scrollRef, initialOffset: () => Number.MAX_SAFE_INTEGER, initialMeasurementsCache: initialMeasurements, }); + // Only compensate scroll for rows growing ABOVE the viewport (history + // remeasures, prepended pages). A row growing inside the viewport — + // expanding a tool call or thinking block — must grow DOWNWARD naturally; + // the end-anchored default made it expand upward. At the bottom, + // app-level auto-follow owns pinning, so skip there too instead of + // double-writing. (This is an instance field, not a constructor option.) + tanstackVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => { + if (instance.isAtEnd(TANSTACK_AT_END_THRESHOLD_PX)) return false; + const firstVisibleIndex = instance.range?.startIndex; + return firstVisibleIndex !== undefined && item.index < firstVisibleIndex; + }; React.useEffect(() => { if (!isTanstack) return; diff --git a/packages/ui/src/components/chat/hooks/useChatTimelineController.ts b/packages/ui/src/components/chat/hooks/useChatTimelineController.ts index 8112d62b..2926ca66 100644 --- a/packages/ui/src/components/chat/hooks/useChatTimelineController.ts +++ b/packages/ui/src/components/chat/hooks/useChatTimelineController.ts @@ -59,7 +59,17 @@ export interface UseChatTimelineControllerResult { } const TURN_MODEL_CACHE_MAX = 30 -const HISTORY_SCROLL_THRESHOLD = 200 +// Desktop load-older lead distance. Trigger well before the top: the fetch +// then completes and the prepend lands ABOVE the viewport, where key-anchored +// compensation is exact and invisible. A short lead (the old 200px) let the +// user reach the estimated-height region near the absolute top mid-fetch, +// where the post-insert restore is least precise and reads as a small jump. +const HISTORY_SCROLL_THRESHOLD_MIN_PX = 1200 +const HISTORY_SCROLL_VIEWPORT_FACTOR = 1.5 +const resolveHistoryScrollThreshold = (clientHeight: number): number => Math.max( + HISTORY_SCROLL_THRESHOLD_MIN_PX, + clientHeight * HISTORY_SCROLL_VIEWPORT_FACTOR, +) const VSCODE_TURN_MODEL_CACHE_MAX = 4 const VSCODE_TURN_MODEL_CACHE_MAX_MESSAGES = 30 const MOBILE_TURN_MODEL_CACHE_MAX = 4 @@ -692,7 +702,7 @@ export const useChatTimelineController = ({ const container = scrollRef.current; if (!container) return; if (isPinnedRef.current) return; - if (container.scrollTop >= HISTORY_SCROLL_THRESHOLD) return; + if (container.scrollTop >= resolveHistoryScrollThreshold(container.clientHeight)) return; if (!historySignalsRef.current.canLoadEarlier) return; if (isLoadingOlderRef.current || pendingRevealWorkRef.current) return; diff --git a/packages/ui/src/components/chat/message/parts/VirtualizedCodeBlock.tsx b/packages/ui/src/components/chat/message/parts/VirtualizedCodeBlock.tsx index 028f889a..11e7763f 100644 --- a/packages/ui/src/components/chat/message/parts/VirtualizedCodeBlock.tsx +++ b/packages/ui/src/components/chat/message/parts/VirtualizedCodeBlock.tsx @@ -3,7 +3,7 @@ * * Renders large code/read outputs without mounting one highlighter per line: * 1. ONE worker tokenization of the whole block (off the main thread) - * 2. virtua to only render visible rows + * 2. @tanstack/react-virtual to only render visible rows * * Tokenizing the whole block at once also preserves cross-line syntax context * (multi-line strings/comments) that per-line highlighting loses. Colors resolve @@ -11,7 +11,7 @@ */ import React from 'react'; -import { Virtualizer } from 'virtua'; +import { useVirtualizer } from '@tanstack/react-virtual'; import { useThemeSystem } from '@/contexts/useThemeSystem'; import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownTheme'; import { useWorkerHighlightedLines } from '@/components/code/useWorkerHighlightedLines'; @@ -114,28 +114,41 @@ const VirtualizedRows: React.FC = React.memo(({ const parentRef = React.useRef(null); const viewportHeight = `min(${lines.length * ROW_HEIGHT}px, ${maxHeight})`; + const virtualizer = useVirtualizer({ + count: lines.length, + getScrollElement: () => parentRef.current, + estimateSize: () => ROW_HEIGHT, + overscan: 20, + }); + const virtualItems = virtualizer.getVirtualItems(); + return (
- - {(line, index) => ( - - )} - +
+ {virtualItems.map((item) => { + const line = lines[item.index]; + if (!line) return null; + return ( +
+ +
+ ); + })} +
); }); diff --git a/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx b/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx index 1e854bca..3eb58a2b 100644 --- a/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx +++ b/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Virtualizer } from 'virtua'; +import { useVirtualizer } from '@tanstack/react-virtual'; import type { Session } from '@opencode-ai/sdk/v2'; // Archived buckets routinely grow into the hundreds/thousands; virtualize @@ -581,7 +581,6 @@ function SessionGroupSectionBase(props: Props): React.ReactNode { }); const archivedVirtualContainerRef = React.useRef(null); - const archivedScrollRef = React.useRef(null); const [archivedScrollEl, setArchivedScrollEl] = React.useState(null); // Offset of the virtual container from the scroll element's content origin. // virtua reads startMargin from Virtualizer options and uses it @@ -617,7 +616,6 @@ function SessionGroupSectionBase(props: Props): React.ReactNode { React.useLayoutEffect(() => { if (!shouldVirtualize) { if (archivedScrollEl !== null) setArchivedScrollEl(null); - archivedScrollRef.current = null; if (archivedScrollMargin !== 0) setArchivedScrollMargin(0); return; } @@ -632,7 +630,6 @@ function SessionGroupSectionBase(props: Props): React.ReactNode { if (providedScrollEl && providedScrollEl.contains(container)) { scrollEl = providedScrollEl; if (scrollEl !== archivedScrollEl) { - archivedScrollRef.current = scrollEl; setArchivedScrollEl(scrollEl); return; } @@ -649,7 +646,6 @@ function SessionGroupSectionBase(props: Props): React.ReactNode { el = el.parentElement; } if (scrollEl !== archivedScrollEl) { - archivedScrollRef.current = scrollEl; setArchivedScrollEl(scrollEl); return; } @@ -661,6 +657,31 @@ function SessionGroupSectionBase(props: Props): React.ReactNode { setArchivedScrollMargin((prev) => (Math.abs(prev - offset) < 1 ? prev : offset)); }); + // The scroll element is an ANCESTOR of this section (the sidebar's + // ScrollableOverlay), so scrollMargin translates its scrollTop into + // container-relative coordinates — the tanstack equivalent of virtua's + // startMargin this replaces. + // Enable ONLY once the ancestor scroll element is resolved. While the + // virtualizer is disabled the core resets its cached scroll offset, so the + // first enabled read takes initialOffset() from the LIVE scrollTop below — + // making the core's attach-time scrollTo target the current position (a + // visual no-op) instead of a stale 0 that reset the sidebar to the top. + // The core only learns the offset from scroll events after that, so this + // initial seeding is what makes the first render window correct too. + const virtualizerReady = shouldVirtualize && archivedScrollEl !== null; + const sessionVirtualizer = useVirtualizer({ + count: visibleSessions.length, + enabled: virtualizerReady, + getScrollElement: () => archivedScrollEl, + initialOffset: () => archivedScrollEl?.scrollTop ?? 0, + estimateSize: () => ARCHIVED_ROW_ESTIMATE_PX, + // Expanded parents render children inline and dwarf the row estimate; + // widen the window so their extra height stays covered. + overscan: hasExpandedParent ? 20 : 8, + scrollMargin: archivedScrollMargin, + getItemKey: (index) => visibleSessions[index]?.session.id ?? index, + }); + // Hooks below MUST stay above the search-empty early-return so they // fire in the same order every render — rules-of-hooks. const collectGroupSessions = React.useCallback((nodes: SessionNode[]): Session[] => { @@ -913,21 +934,63 @@ function SessionGroupSectionBase(props: Props): React.ReactNode { {renderFolderItems()} {shouldVirtualize ? (
- - {(node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', { + {!virtualizerReady ? ( + // At most one pre-paint frame: this wrapper must exist for the + // layout effect to resolve the ancestor scroll element, which + // re-renders synchronously before paint. Rendering the plain rows + // meanwhile keeps the container's height real so the scroller + // never collapses/clamps during the flip. + visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', { subtreeContainsActive, subtreeContainsEditing, menuOpenSessionId, nodeStructureKey: resolveNodeStructureKey(node), childRenderExtrasFor, - }) as React.ReactElement} - + })) + ) : ( +
+ {/* Absolutely positioned rows (canonical tanstack layout): with + variable-height rows, flow-stacking can drift from the computed + total height until measurements settle and overlap the content + below the group. Per-item offsets cannot drift. item.start + includes scrollMargin (ancestor-scroll offset), so subtract it. */} + {sessionVirtualizer.getVirtualItems().map((item) => { + const node = visibleSessions[item.index]; + if (!node) return null; + return ( +
+ {renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', { + subtreeContainsActive, + subtreeContainsEditing, + menuOpenSessionId, + nodeStructureKey: resolveNodeStructureKey(node), + childRenderExtrasFor, + })} +
+ ); + })} +
+ )}
) : ( visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', { diff --git a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx index 89dc1620..3fad43f6 100644 --- a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx +++ b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx @@ -747,6 +747,18 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { handleSessionSelect(session.id, sessionDirectory, projectId); }; + // The selection/active highlight covers the WHOLE row box (gutter, edge + // paddings), while the primary click target is the inner title button. + // Make the rest of the highlighted box clickable too — but only for clicks + // that did not originate from an interactive child (title button, chevron, + // action menu), so nothing double-fires. + const handleRowBackgroundClick = (event: React.MouseEvent) => { + if (event.defaultPrevented) return; + const target = event.target as HTMLElement | null; + if (target?.closest('button, a, input, [role="menuitem"], [role="menu"]')) return; + handleRowSelect(event as unknown as React.MouseEvent); + }; + const handleRowMouseDown = (event: React.MouseEvent) => { if (event.button === 2 || (event.button === 0 && event.ctrlKey && !selectionModeEnabled)) { suppressNextSelectRef.current = true; @@ -974,8 +986,9 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { data-session-row={session.id} data-session-scope={sessionDirectory ?? ''} data-session-archived={archivedBucket ? '1' : '0'} + onClick={handleRowBackgroundClick} className={cn( - 'group relative my-0.5 flex items-center rounded-md py-1 pr-1.5', + 'group relative my-0.5 flex cursor-pointer items-center rounded-md py-1 pr-1.5', // Pull the row box left into the container gutter so the // selection highlight covers the chevron/status markers // (which sit in that gutter), then re-pad so the title text diff --git a/packages/ui/src/components/session/sidebar/SidebarProjectsList.tsx b/packages/ui/src/components/session/sidebar/SidebarProjectsList.tsx index c6b94eef..849987b0 100644 --- a/packages/ui/src/components/session/sidebar/SidebarProjectsList.tsx +++ b/packages/ui/src/components/session/sidebar/SidebarProjectsList.tsx @@ -142,7 +142,12 @@ export function SidebarProjectsList(props: Props): React.ReactNode { } return ( - + // [overflow-anchor:none] — the browser's native scroll anchoring otherwise + // latches onto content BELOW a growing session group (e.g. the "Show more" + // button) and holds it in place, which makes newly revealed sessions look + // like they insert upward. With anchoring off, scrollTop stays put and new + // rows appear below naturally. + {props.topContent} {props.showOnlyMainWorkspace ? (
diff --git a/packages/ui/src/components/ui/JsonTreeViewer.tsx b/packages/ui/src/components/ui/JsonTreeViewer.tsx index 9110e5af..3712328d 100644 --- a/packages/ui/src/components/ui/JsonTreeViewer.tsx +++ b/packages/ui/src/components/ui/JsonTreeViewer.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Virtualizer } from 'virtua'; +import { useVirtualizer } from '@tanstack/react-virtual'; import { parseJsonToTree, @@ -203,6 +203,14 @@ const JsonTreeViewer = React.forwardRef<{ expandAll: () => void; collapseAll: () const shouldVirtualize = flatNodes.length > VIRTUALIZE_THRESHOLD; const parentRef = React.useRef(null); + const virtualizer = useVirtualizer({ + count: flatNodes.length, + enabled: shouldVirtualize, + getScrollElement: () => parentRef.current, + estimateSize: () => ROW_HEIGHT, + overscan: 20, + getItemKey: (index) => flatNodes[index]?.node.id ?? index, + }); const handleToggle = React.useCallback((id: string) => { setCollapsedPaths((prev) => { @@ -238,21 +246,26 @@ const JsonTreeViewer = React.forwardRef<{ expandAll: () => void; collapseAll: () className={className} style={{ maxHeight, overflow: 'auto' }} > - - {(flatNode) => ( - - )} - +
+ {virtualizer.getVirtualItems().map((item) => { + const flatNode = flatNodes[item.index]; + if (!flatNode) return null; + return ( +
+ +
+ ); + })} +
); } diff --git a/packages/ui/src/components/views/git/ChangesPanel.tsx b/packages/ui/src/components/views/git/ChangesPanel.tsx index 51a549d3..23a48105 100644 --- a/packages/ui/src/components/views/git/ChangesPanel.tsx +++ b/packages/ui/src/components/views/git/ChangesPanel.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Virtualizer, type VirtualizerHandle } from 'virtua'; +import { useVirtualizer } from '@tanstack/react-virtual'; import { Button } from '@/components/ui/button'; import { Dialog, @@ -195,16 +195,26 @@ export const ChangesPanel: React.FC = ({ const rowCount = rows.length; const shouldVirtualize = rowCount >= CHANGE_LIST_VIRTUALIZE_THRESHOLD; - const rowVirtualizerRef = React.useRef(null); - const [visibleStartIndex, setVisibleStartIndex] = React.useState(0); - - const updateVisibleStartIndex = React.useCallback((offset: number) => { - const virtualizer = rowVirtualizerRef.current; - const next = virtualizer - ? virtualizer.findItemIndex(offset) - : Math.floor(offset / CHANGE_ROW_ESTIMATE_PX); - setVisibleStartIndex((previous) => (previous === next ? previous : next)); - }, []); + const rowVirtualizer = useVirtualizer({ + count: rowCount, + enabled: shouldVirtualize, + getScrollElement: () => scrollRef.current, + estimateSize: () => CHANGE_ROW_ESTIMATE_PX, + overscan: 12, + getItemKey: (index) => rows[index]?.key ?? index, + }); + const virtualRows = rowVirtualizer.getVirtualItems(); + // First VISIBLE row index drives the visible-path prefetch window (the + // virtua findItemIndex/onScroll pair this replaces). virtualRows starts at + // the overscan boundary — up to `overscan` rows above the viewport — so + // skip rows that end above the current scroll offset; otherwise the + // prefetch budget leaks to offscreen files above the viewport. + const visibleStartIndex = React.useMemo(() => { + if (!shouldVirtualize) return 0; + const scrollTop = scrollRef.current?.scrollTop ?? 0; + const firstVisible = virtualRows.find((item) => item.end > scrollTop); + return firstVisible?.index ?? 0; + }, [shouldVirtualize, virtualRows, scrollRef]); React.useEffect(() => { if (!onVisiblePathsChange) { @@ -481,27 +491,34 @@ export const ChangesPanel: React.FC = ({ className="overlay-scrollbar-target overlay-scrollbar-container min-h-0 w-full flex-1 overflow-x-hidden overflow-y-auto" > {shouldVirtualize ? ( - - {(row, index) => ( -
- {renderRow(row, index === 0)} -
- )} -
+
+ {/* Absolutely positioned rows: variable-height rows can drift from + the computed total height under flow stacking until measured. */} + {virtualRows.map((item) => { + const row = rows[item.index]; + if (!row) return null; + return ( +
+ {renderRow(row, item.index === 0)} +
+ ); + })} +
) : (
{rows.map((row, index) => ( From de1b85ac566db954472131b5b2bea3792907bd77 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sat, 4 Jul 2026 02:48:07 +0300 Subject: [PATCH 0087/1073] feat(voice): first-class voice input and local TTS across web, desktop, and mobile (#2018) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete rebuild of voice input on a server-authoritative streaming architecture, replacing the legacy Web Speech / whole-blob / WASM engines and the dead voice-agent layer (~4k lines removed). Speech-to-text (dictation): - Client streams 16 kHz mono PCM16 chunks over /api/dictation/ws with seq/ack ordering; buffered audio is retained and replayed on reconnect - Server transcribes and streams live partial transcripts back; segments auto-commit every ~15s with silence suppression and adaptive finalization timeouts - Local provider (default, zero config): sherpa-onnx models in a forked worker process — auto-download with progress, staged extraction with verification, corrupt-model auto-recovery, idle shutdown after 5 min - Model catalog with settings picker (accuracy/speed ratings, sizes, download/delete): Parakeet TDT v2 (English) and v3 (25 European languages, auto-detected), Whisper base and tiny (multilingual, light) - OpenAI-compatible provider for any Whisper endpoint - Composer overlay with live transcript, volume meter, timer, and cancel / insert / insert-and-send actions; failed transcriptions keep their audio for retry or accepting the partial text as-is - Configurable keyboard shortcut (default mod+alt+v) toggles dictation; Enter confirms and Escape cancels while recording - Overlay is pixel-aligned with the composer (measured footer height, matching paddings/typography/gaps) — no layout shift when toggling Text-to-speech: - Local Kokoro provider (English, 11 voices) synthesized in the same worker via /api/dictation/tts/speak, managed by the shared model pipeline; sentence-pipelined playback keeps time-to-first-audio at ~1 sentence regardless of message length, and stop cancels in-flight synthesis - Sanitizer keeps inline-code content (strips backticks only), reads interword slashes aloud, and removes only absolute file paths Settings: - Voice page unified: a single read-aloud toggle owns all playback options (the confusing "Enable Voice Mode" is gone); a new "Enable voice input" toggle (default on, persisted to settings.json) hides the composer mic entirely when disabled Mobile and transport: - iOS/Android microphone permissions added (dictation was previously impossible on mobile) - Fixed Android WebSocket upgrades: the Capacitor WebView origin (https://localhost) was missing from the packaged-client allowlist, 403-ing every WS connection — root cause of the old mobile SSE lock, which is now removed for all transports Security and conventions: - All HTTP routes sit behind the global /api auth gate; the WS upgrade explicitly validates the UI session and origin, with oc_url_token narrowly allowlisted and covered by tests; the dictation socket mints a fresh URL token before connecting - Routes register before the generic OpenCode proxy; the client goes through runtimeFetch/getRuntimeUrlResolver, and runtime switches reset the dictation socket - VS Code deliberately reports dictation as unavailable (no server process in that runtime) CI: workflow Node bumped 20 -> 22 to match the repo engines and fix better-sqlite3 installs broken by node-gyp@latest on Node 20. New dependency: sherpa-onnx-node (prebuilt N-API; macOS/Linux x64+arm64, Windows x64 — Windows-on-ARM falls back to the OpenAI-compatible provider) --- .github/workflows/build-macos-arm64-dmg.yml | 2 +- .github/workflows/oc-review.yml | 2 +- .github/workflows/release-desktop-smoke.yml | 4 +- .github/workflows/release.yml | 8 +- .github/workflows/vscode-extension.yml | 2 +- .gitignore | 1 + bun.lock | 15 + .../android/app/src/main/AndroidManifest.xml | 6 + packages/mobile/ios/App/App/Info.plist | 2 + packages/ui/src/App.tsx | 7 +- packages/ui/src/components/chat/ChatInput.tsx | 57 +- .../dictation/ComposerDictation.tsx | 439 ++ .../openchamber/OpenChamberVisualSettings.tsx | 7 +- .../sections/openchamber/VoiceSettings.tsx | 732 ++- .../ui/src/components/views/SettingsView.tsx | 2 +- .../components/voice/BrowserVoiceButton.tsx | 392 -- .../ui/src/components/voice/VoiceProvider.tsx | 30 - .../components/voice/VoiceStatusIndicator.tsx | 139 - packages/ui/src/components/voice/index.ts | 2 - packages/ui/src/hooks/useBrowserVoice.ts | 1007 ---- packages/ui/src/hooks/useDictation.ts | 408 ++ packages/ui/src/hooks/useKeyboardShortcuts.ts | 12 + packages/ui/src/hooks/useLocalTTS.ts | 266 + packages/ui/src/hooks/useMessageTTS.ts | 15 +- packages/ui/src/hooks/useVoiceContext.ts | 57 - packages/ui/src/lib/desktop.ts | 8 +- .../ui/src/lib/dictation/dictation-client.ts | 447 ++ .../lib/dictation/dictation-stream-sender.ts | 240 + .../dictation/use-dictation-audio-source.ts | 301 + .../ui/src/lib/i18n/messages/en.settings.ts | 23 +- packages/ui/src/lib/i18n/messages/en.ts | 12 + .../ui/src/lib/i18n/messages/es.settings.ts | 23 +- packages/ui/src/lib/i18n/messages/es.ts | 12 + .../ui/src/lib/i18n/messages/fr.settings.ts | 23 +- packages/ui/src/lib/i18n/messages/fr.ts | 12 + .../ui/src/lib/i18n/messages/ja.settings.ts | 23 +- packages/ui/src/lib/i18n/messages/ja.ts | 12 + .../ui/src/lib/i18n/messages/ko.settings.ts | 23 +- packages/ui/src/lib/i18n/messages/ko.ts | 12 + .../ui/src/lib/i18n/messages/pl.settings.ts | 23 +- packages/ui/src/lib/i18n/messages/pl.ts | 12 + .../src/lib/i18n/messages/pt-BR.settings.ts | 23 +- packages/ui/src/lib/i18n/messages/pt-BR.ts | 12 + .../ui/src/lib/i18n/messages/uk.settings.ts | 23 +- packages/ui/src/lib/i18n/messages/uk.ts | 12 + .../src/lib/i18n/messages/zh-CN.settings.ts | 23 +- packages/ui/src/lib/i18n/messages/zh-CN.ts | 12 + .../src/lib/i18n/messages/zh-TW.settings.ts | 23 +- packages/ui/src/lib/i18n/messages/zh-TW.ts | 12 + packages/ui/src/lib/persistence.ts | 56 +- packages/ui/src/lib/settings/search.ts | 14 +- packages/ui/src/lib/shortcuts.ts | 7 + .../ui/src/lib/voice/audioStreamService.ts | 397 -- .../ui/src/lib/voice/contextFormatters.ts | 131 - packages/ui/src/lib/voice/index.ts | 17 - packages/ui/src/lib/voice/summarize.ts | 14 +- packages/ui/src/lib/voice/voiceConfig.ts | 32 - packages/ui/src/lib/voice/voiceHooks.ts | 125 - packages/ui/src/lib/voice/voiceSession.ts | 28 - packages/ui/src/lib/voice/wasmSttService.ts | 555 -- packages/ui/src/lib/voice/wasmSttWorker.ts | 92 - packages/ui/src/stores/useConfigStore.ts | 209 +- packages/ui/src/sync/session-ui-store.ts | 1 - packages/ui/src/sync/sync-context.tsx | 5064 ++++++++--------- packages/ui/src/sync/voice-store.ts | 7 - packages/vscode/webview/main.tsx | 17 + packages/web/.gitignore | 1 + packages/web/package.json | 1 + packages/web/server/index.js | 10 + .../web/server/lib/dictation/DOCUMENTATION.md | 63 + packages/web/server/lib/dictation/audio.js | 195 + .../lib/dictation/local/model-catalog.js | 141 + .../lib/dictation/local/model-downloader.js | 163 + .../lib/dictation/local/sherpa-loader.js | 136 + .../lib/dictation/local/sherpa-recognizer.js | 277 + .../server/lib/dictation/local/sherpa-tts.js | 110 + .../lib/dictation/local/worker-client.js | 352 ++ .../lib/dictation/local/worker-process.js | 197 + .../dictation/openai-compatible-session.js | 98 + packages/web/server/lib/dictation/runtime.js | 278 + packages/web/server/lib/dictation/service.js | 302 + .../server/lib/dictation/stream-manager.js | 461 ++ .../lib/dictation/stream-manager.test.js | 194 + .../server/lib/opencode/settings-helpers.js | 27 +- .../lib/opencode/startup-pipeline-runtime.js | 13 + .../server/lib/security/request-security.js | 12 +- .../lib/security/request-security.test.js | 21 + packages/web/server/lib/ui-auth/ui-auth.js | 1 + .../web/server/lib/ui-auth/ui-auth.test.js | 22 + 89 files changed, 8740 insertions(+), 6061 deletions(-) create mode 100644 packages/ui/src/components/dictation/ComposerDictation.tsx delete mode 100644 packages/ui/src/components/voice/BrowserVoiceButton.tsx delete mode 100644 packages/ui/src/components/voice/VoiceProvider.tsx delete mode 100644 packages/ui/src/components/voice/VoiceStatusIndicator.tsx delete mode 100644 packages/ui/src/components/voice/index.ts delete mode 100644 packages/ui/src/hooks/useBrowserVoice.ts create mode 100644 packages/ui/src/hooks/useDictation.ts create mode 100644 packages/ui/src/hooks/useLocalTTS.ts delete mode 100644 packages/ui/src/hooks/useVoiceContext.ts create mode 100644 packages/ui/src/lib/dictation/dictation-client.ts create mode 100644 packages/ui/src/lib/dictation/dictation-stream-sender.ts create mode 100644 packages/ui/src/lib/dictation/use-dictation-audio-source.ts delete mode 100644 packages/ui/src/lib/voice/audioStreamService.ts delete mode 100644 packages/ui/src/lib/voice/contextFormatters.ts delete mode 100644 packages/ui/src/lib/voice/index.ts delete mode 100644 packages/ui/src/lib/voice/voiceConfig.ts delete mode 100644 packages/ui/src/lib/voice/voiceHooks.ts delete mode 100644 packages/ui/src/lib/voice/voiceSession.ts delete mode 100644 packages/ui/src/lib/voice/wasmSttService.ts delete mode 100644 packages/ui/src/lib/voice/wasmSttWorker.ts delete mode 100644 packages/ui/src/sync/voice-store.ts create mode 100644 packages/web/server/lib/dictation/DOCUMENTATION.md create mode 100644 packages/web/server/lib/dictation/audio.js create mode 100644 packages/web/server/lib/dictation/local/model-catalog.js create mode 100644 packages/web/server/lib/dictation/local/model-downloader.js create mode 100644 packages/web/server/lib/dictation/local/sherpa-loader.js create mode 100644 packages/web/server/lib/dictation/local/sherpa-recognizer.js create mode 100644 packages/web/server/lib/dictation/local/sherpa-tts.js create mode 100644 packages/web/server/lib/dictation/local/worker-client.js create mode 100644 packages/web/server/lib/dictation/local/worker-process.js create mode 100644 packages/web/server/lib/dictation/openai-compatible-session.js create mode 100644 packages/web/server/lib/dictation/runtime.js create mode 100644 packages/web/server/lib/dictation/service.js create mode 100644 packages/web/server/lib/dictation/stream-manager.js create mode 100644 packages/web/server/lib/dictation/stream-manager.test.js diff --git a/.github/workflows/build-macos-arm64-dmg.yml b/.github/workflows/build-macos-arm64-dmg.yml index 19479538..a96c1da8 100644 --- a/.github/workflows/build-macos-arm64-dmg.yml +++ b/.github/workflows/build-macos-arm64-dmg.yml @@ -32,7 +32,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: "20" + node-version: "22" - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.github/workflows/oc-review.yml b/.github/workflows/oc-review.yml index 65e49094..655b6665 100644 --- a/.github/workflows/oc-review.yml +++ b/.github/workflows/oc-review.yml @@ -18,7 +18,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '20' + node-version: '22' - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.github/workflows/release-desktop-smoke.yml b/.github/workflows/release-desktop-smoke.yml index 7686eb7c..e81407d8 100644 --- a/.github/workflows/release-desktop-smoke.yml +++ b/.github/workflows/release-desktop-smoke.yml @@ -65,7 +65,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '20' + node-version: '22' - name: Install dependencies run: bun install --frozen-lockfile @@ -206,7 +206,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '20' + node-version: '22' - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 68390b3f..30148426 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -90,7 +90,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '20' + node-version: '22' registry-url: 'https://registry.npmjs.org' - name: Install dependencies @@ -141,7 +141,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '20' + node-version: '22' - name: Install dependencies run: bun install --frozen-lockfile @@ -288,7 +288,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '20' + node-version: '22' - name: Install dependencies run: bun install --frozen-lockfile @@ -365,7 +365,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '20' + node-version: '22' - name: Download per-arch latest-mac.yml uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 diff --git a/.github/workflows/vscode-extension.yml b/.github/workflows/vscode-extension.yml index 566549c1..ef9ac3a2 100644 --- a/.github/workflows/vscode-extension.yml +++ b/.github/workflows/vscode-extension.yml @@ -25,7 +25,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '20' + node-version: '22' - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.gitignore b/.gitignore index 305aec4c..8a344a6b 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,4 @@ data/ workspaces/ *.pid .worktrees/ +test-results/ diff --git a/bun.lock b/bun.lock index 37335213..1e4d7467 100644 --- a/bun.lock +++ b/bun.lock @@ -283,6 +283,7 @@ "openai": "^4.79.0", "qrcode-terminal": "^0.12.0", "reflect-metadata": "^0.2.2", + "sherpa-onnx-node": "1.12.28", "simple-git": "^3.28.0", "web-push": "^3.6.7", "ws": "^8.18.3", @@ -2980,6 +2981,20 @@ "shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="], + "sherpa-onnx-darwin-arm64": ["sherpa-onnx-darwin-arm64@1.13.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9x86Cbf+BDFONdtCPM3cnjvtAW0ER8tMaHK5pVfz+SHPt8GeuwRXaiR/BzcByFBUyxCgmceO09/WMZOCi44P/g=="], + + "sherpa-onnx-darwin-x64": ["sherpa-onnx-darwin-x64@1.13.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-TVQ35g7JIpDPB1lUDdcog+JtI0cI45ZzOnvHXm0DtWs/dgxnJXtWMY3uLRtBbLnysV9j5ljffwZ1IX9VDHsCzQ=="], + + "sherpa-onnx-linux-arm64": ["sherpa-onnx-linux-arm64@1.13.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-uDtZkkoP6QQ/3DHOscCpEZ2WpaiHUQsDpbyYaHURrJ7DbsjqGnS6G8l+R589Ro5Bf282QElzBy3okwxXbt3Kxw=="], + + "sherpa-onnx-linux-x64": ["sherpa-onnx-linux-x64@1.13.3", "", { "os": "linux", "cpu": "x64" }, "sha512-OFVK0GYwKwKNsjxbPmfcLQm/dfA0IwAoiIQJ96s+eFYcDqhlapcY06ocdb7SNluGBcM7xgU5jEW2QXBkMIOEvQ=="], + + "sherpa-onnx-node": ["sherpa-onnx-node@1.12.28", "", { "optionalDependencies": { "sherpa-onnx-darwin-arm64": "^1.12.28", "sherpa-onnx-darwin-x64": "^1.12.28", "sherpa-onnx-linux-arm64": "^1.12.28", "sherpa-onnx-linux-x64": "^1.12.28", "sherpa-onnx-win-ia32": "^1.12.28", "sherpa-onnx-win-x64": "^1.12.28" } }, "sha512-EHSB3EG6hKyXaTNh6GU/bwh6i3dncCH6ZCU2mScNzkxRbVStZ7QmNj0Oo4E9XrGGo9jX9pKg9MPHEPyjdK+ApA=="], + + "sherpa-onnx-win-ia32": ["sherpa-onnx-win-ia32@1.13.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-VDZh1M7Ccx/bkP3WwBCFoJzwAwq+b5nR1KRkYRz5p1w5bfhzfa3ACBGr7vpUt5AGUge4qSLe0MSKXyKtSmy1uA=="], + + "sherpa-onnx-win-x64": ["sherpa-onnx-win-x64@1.13.3", "", { "os": "win32", "cpu": "x64" }, "sha512-ZQzcSmFvZK4jzmtWckqxocDUuEjYnBV2MHrDD21HPTeUMfGdE9yfvuSPpesIVfdzKbQzIQY42RAcfZEGWu0FbQ=="], + "shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="], "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], diff --git a/packages/mobile/android/app/src/main/AndroidManifest.xml b/packages/mobile/android/app/src/main/AndroidManifest.xml index 0229e443..9ef6ac5c 100644 --- a/packages/mobile/android/app/src/main/AndroidManifest.xml +++ b/packages/mobile/android/app/src/main/AndroidManifest.xml @@ -53,4 +53,10 @@ + + + + diff --git a/packages/mobile/ios/App/App/Info.plist b/packages/mobile/ios/App/App/Info.plist index 3ffde0d6..2d96c874 100644 --- a/packages/mobile/ios/App/App/Info.plist +++ b/packages/mobile/ios/App/App/Info.plist @@ -35,6 +35,8 @@ OpenChamber connects to OpenChamber servers on your local network. NSCameraUsageDescription OpenChamber uses the camera to scan a server's pairing QR code. + NSMicrophoneUsageDescription + OpenChamber uses the microphone for voice dictation in the chat composer. CFBundleURLTypes diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 842e8372..9159a9e0 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -44,7 +44,6 @@ import { ConfigUpdateOverlay } from '@/components/ui/ConfigUpdateOverlay'; import { AboutDialog } from '@/components/ui/AboutDialog'; import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider'; import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; -import { VoiceProvider } from '@/components/voice'; import { useUIStore } from '@/stores/useUIStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore'; @@ -928,8 +927,8 @@ function App({ apis }: AppProps) { } // Always mount the full provider tree to avoid remounts when isInitialized - // flips from false → true. FireworksProvider and VoiceProvider are lightweight - // shells; their heavy children are only activated when actually needed. + // flips from false → true. FireworksProvider is a lightweight shell; its + // heavy children are only activated when actually needed. const isBootShell = !isInitialized && !isDesktopRuntime; return ( @@ -937,7 +936,6 @@ function App({ apis }: AppProps) { -
@@ -955,7 +953,6 @@ function App({ apis }: AppProps) { )}
-
diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index be6bd133..3d32a657 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Textarea } from '@/components/ui/textarea'; -import { BrowserVoiceButton } from '@/components/voice'; +import { ComposerDictation } from '@/components/dictation/ComposerDictation'; // sessionStore removed — currentSessionId comes from useSessionUIStore import { useConfigStore } from '@/stores/useConfigStore'; import { useUIStore } from '@/stores/useUIStore'; @@ -381,7 +381,7 @@ const getProjectIconColor = (projectColor?: string | null): string | undefined = }; const MemoModelControls = React.memo(ModelControls); -const MemoBrowserVoiceButton = React.memo(BrowserVoiceButton); +const MemoComposerDictation = React.memo(ComposerDictation); const MemoMobileAgentButton = React.memo(MobileAgentButton); const MemoMobileModelButton = React.memo(MobileModelButton); const MemoStatusRow = React.memo(StatusRow); @@ -2318,6 +2318,33 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo void handleSubmitRef.current(); }, []); + // Dictation: insert the transcript inline; optionally submit immediately. + // getCurrentInputSnapshot reads textareaRef.current.value first, so setting + // it synchronously lets handleSubmit pick up the text in the same tick. + const handleDictationInsert = React.useCallback((text: string) => { + setMessage((prev) => { + const next = appendInlineText(prev, text); + const textarea = textareaRef.current; + if (textarea) { + textarea.value = next; + } + return next; + }); + setTimeout(() => { + textareaRef.current?.focus(); + }, 0); + }, []); + + const handleDictationInsertAndSend = React.useCallback((text: string) => { + const textarea = textareaRef.current; + const next = appendInlineText(textarea?.value ?? messageRef.current, text); + if (textarea) { + textarea.value = next; + } + setMessage(next); + void handleSubmitRef.current(); + }, []); + // Preset chips rendered outside this component (e.g. under the welcome // message on narrow surfaces) request a submit via the input store; consume // it here so it routes through the same command-aware submit path. @@ -4420,6 +4447,9 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo : undefined} /> )} + {/* Positioning context for the dictation overlay: covers the + text area + footer exactly, excluding MobileSessionStatusBar. */} +
@@ -4559,7 +4589,16 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo />
- + = ({ onOpenSettings, scrollTo
- + = ({ onOpenSettings, scrollTo )}
+
{/* Mobile session panel: slide-up overlay toggled by MobileSessionPanelTrigger. */} {isMobile && } diff --git a/packages/ui/src/components/dictation/ComposerDictation.tsx b/packages/ui/src/components/dictation/ComposerDictation.tsx new file mode 100644 index 00000000..7f3fd913 --- /dev/null +++ b/packages/ui/src/components/dictation/ComposerDictation.tsx @@ -0,0 +1,439 @@ +/** + * Composer dictation controls: a mic button for the composer footer plus a + * full-composer overlay while dictation is active (recording, transcribing, + * or failed). The overlay mirrors the composer's own layout — the transcript + * area uses the same paddings/typography as the textarea and the action row + * reuses the footer icon-button styling — so toggling dictation causes no + * vertical shift. + */ + +import React from 'react'; + +import { Icon } from '@/components/icon/Icon'; +import { useI18n } from '@/lib/i18n'; +import { useThemeSystem } from '@/contexts/useThemeSystem'; +import { cn } from '@/lib/utils'; +import { runtimeFetch } from '@/lib/runtime-fetch'; +import { useDictation } from '@/hooks/useDictation'; +import { isDictationCaptureSupported } from '@/lib/dictation/use-dictation-audio-source'; +import { isVSCodeRuntime } from '@/lib/desktop'; +import { useConfigStore } from '@/stores/useConfigStore'; +import { useUIStore } from '@/stores/useUIStore'; +import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts'; + +interface ComposerDictationProps { + radius?: number | string; + isMobile: boolean; + footerIconButtonClass: string; + footerPaddingClass: string; + iconSizeClass: string; + sendIconSizeClass: string; + disabled?: boolean; + onInsert: (text: string) => void; + onInsertAndSend: (text: string) => void; +} + +const formatDuration = (seconds: number): string => { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}:${String(secs).padStart(2, '0')}`; +}; + +const VolumeMeter: React.FC<{ volume: number }> = ({ volume }) => { + const { currentTheme } = useThemeSystem(); + return ( + )} - {(shouldShow('collapsibleUserMessages') || shouldShow('stickyUserHeader') || shouldShow('wideChatLayout') || shouldShow('splitAssistantMessageActions') || shouldShow('dotfiles') || shouldShow('fileViewerPreview') || shouldShow('persistDraft') || shouldShow('showToolFileIcons') || shouldShow('showTurnChangedFiles') || (!isMobile && shouldShow('inputSpellcheck')) || shouldShow('reasoning')) && ( + {(shouldShow('sessionAssist') || shouldShow('collapsibleUserMessages') || shouldShow('stickyUserHeader') || shouldShow('wideChatLayout') || shouldShow('splitAssistantMessageActions') || shouldShow('dotfiles') || shouldShow('fileViewerPreview') || shouldShow('persistDraft') || shouldShow('showToolFileIcons') || shouldShow('showTurnChangedFiles') || (!isMobile && shouldShow('inputSpellcheck')) || shouldShow('reasoning')) && (
+ {shouldShow('sessionAssist') && ( +
setSessionAssistEnabled(!sessionAssistEnabled)} + onKeyDown={(event) => { + if (event.key === ' ' || event.key === 'Enter') { + event.preventDefault(); + setSessionAssistEnabled(!sessionAssistEnabled); + } + }} + > + + {t('settings.openchamber.visual.field.sessionAssist')} +
+ )} {shouldShow('reasoning') && (
{ > {t('settings.voice.page.field.ttsInputModeRaw')} +
diff --git a/packages/ui/src/hooks/useMessageTTS.ts b/packages/ui/src/hooks/useMessageTTS.ts index ebe83309..b6b4037d 100644 --- a/packages/ui/src/hooks/useMessageTTS.ts +++ b/packages/ui/src/hooks/useMessageTTS.ts @@ -12,6 +12,36 @@ import { useSayTTS } from './useSayTTS'; import { useLocalTTS } from './useLocalTTS'; import { browserVoiceService } from '@/lib/voice/browserVoiceService'; import { sanitizeForTTS } from '@/lib/voice/summarize'; +import { runtimeFetch } from '@/lib/runtime-fetch'; + +// Below this length the reply is comfortable to listen to as-is; summarizing +// would only add latency. +const TTS_SUMMARIZE_MIN_CHARS = 600; + +const SUMMARIZE_SYSTEM_PROMPT = 'Summarize the assistant reply for text-to-speech listening. Reply with 2-4 sentences of plain spoken prose in the same language as the reply. No markdown, no lists, no code — mention code changes briefly in words instead.'; + +async function summarizeForSpeech( + text: string, + preferred: { providerID?: string; modelID?: string }, +): Promise { + try { + const response = await runtimeFetch('/api/small-model/generate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + prompt: text, + system: SUMMARIZE_SYSTEM_PROMPT, + ...(preferred.providerID ? { preferredProviderID: preferred.providerID } : {}), + ...(preferred.modelID ? { preferredModelID: preferred.modelID } : {}), + }), + }); + if (!response.ok) return null; + const payload = await response.json().catch(() => null) as { text?: unknown } | null; + return typeof payload?.text === 'string' && payload.text.trim() ? payload.text.trim() : null; + } catch { + return null; + } +} export interface UseMessageTTSReturn { /** Whether TTS is currently playing for this message */ @@ -69,9 +99,24 @@ export function useMessageTTS(): UseMessageTTSReturn { setIsPlaying(true); try { + // Summarized mode: replace long replies with a short spoken-prose + // summary from the small model; fall back to the sanitized + // original when summarization is unavailable. + let sourceText = text; + if (ttsInputMode === 'summarized' && text.length >= TTS_SUMMARIZE_MIN_CHARS) { + const { currentProviderId, currentModelId } = useConfigStore.getState(); + const summary = await summarizeForSpeech(text, { + providerID: currentProviderId || undefined, + modelID: currentModelId || undefined, + }); + if (summary) { + sourceText = summary; + } + } + const shouldUseRaw = ttsInputMode === 'raw' && isServerProvider; - const sanitizedText = sanitizeForTTS(text); - const textToSpeak = shouldUseRaw ? text : sanitizedText; + const sanitizedText = sanitizeForTTS(sourceText); + const textToSpeak = shouldUseRaw ? sourceText : sanitizedText; if (isServerProvider && isServerTTSAvailable) { const voice = voiceProvider === 'openai-compatible' ? openaiCompatibleVoice : openaiVoice; diff --git a/packages/ui/src/hooks/useSessionAssist.ts b/packages/ui/src/hooks/useSessionAssist.ts new file mode 100644 index 00000000..889c0b42 --- /dev/null +++ b/packages/ui/src/hooks/useSessionAssist.ts @@ -0,0 +1,94 @@ +import React from 'react'; +import { useDirectoryStore, useSession, useSessionStatus } from '@/sync/sync-context'; +import { getSessionAssist, type SessionAssistPayload } from '@/lib/sessionAssistMetadata'; + +// How long the chat must sit untouched before the recap becomes visible. +// The suggestion has no such delay — it shows as soon as it arrives. +export const RECAP_VISIBILITY_DELAY_MS = 5 * 60 * 1000; + +interface LastMessageSnapshot { + id: string; + role: string; + timestamp: number; +} + +/** Narrow subscription to the last message of a session (id/role/time only). */ +function useLastMessageSnapshot(sessionId: string): LastMessageSnapshot | null { + const store = useDirectoryStore(); + const cacheRef = React.useRef(null); + + const getSnapshot = React.useCallback((): LastMessageSnapshot | null => { + if (!sessionId) return null; + const messages = store.getState().message[sessionId]; + const last = messages && messages.length > 0 ? messages[messages.length - 1] : null; + const info = last as { id?: string; role?: string; time?: { completed?: number; created?: number } } | null; + if (!info?.id) { + cacheRef.current = null; + return null; + } + const next: LastMessageSnapshot = { + id: info.id, + role: typeof info.role === 'string' ? info.role : '', + timestamp: info.time?.completed ?? info.time?.created ?? 0, + }; + const cached = cacheRef.current; + if (cached && cached.id === next.id && cached.role === next.role && cached.timestamp === next.timestamp) { + return cached; + } + cacheRef.current = next; + return next; + }, [sessionId, store]); + + const subscribe = React.useCallback((notify: () => void) => { + if (!sessionId) return () => undefined; + return store.subscribe(notify); + }, [sessionId, store]); + + return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} + +export interface SessionAssistState { + /** Valid (fresh) assist payload, or null. */ + assist: SessionAssistPayload | null; + /** Recap text, only when the 5-minute quiet window has elapsed. */ + visibleRecap: string | null; + /** Suggestion text — fresh payload, session idle; caller still gates on input emptiness. */ + suggestion: string | null; +} + +export function useSessionAssistState(sessionId: string): SessionAssistState { + const session = useSession(sessionId); + const status = useSessionStatus(sessionId); + const lastMessage = useLastMessageSnapshot(sessionId); + + const isIdle = !status || status.type === 'idle'; + const payload = getSessionAssist(session); + + // Fresh = the payload's target message is still the session's last message. + const assist = payload + && lastMessage + && lastMessage.role === 'assistant' + && lastMessage.id === payload.forMessageID + && isIdle + ? payload + : null; + + // Recap waits out the quiet window; re-render once when the boundary passes. + const lastTimestamp = lastMessage?.timestamp ?? 0; + const [, forceTick] = React.useReducer((tick: number) => tick + 1, 0); + const quietElapsed = assist ? Date.now() - lastTimestamp >= RECAP_VISIBILITY_DELAY_MS : false; + + React.useEffect(() => { + if (!assist || quietElapsed || !lastTimestamp) return undefined; + const remaining = RECAP_VISIBILITY_DELAY_MS - (Date.now() - lastTimestamp); + if (remaining <= 0) return undefined; + const timer = setTimeout(forceTick, remaining + 250); + return () => clearTimeout(timer); + }, [assist, quietElapsed, lastTimestamp]); + + return { + assist, + visibleRecap: assist && assist.recap && quietElapsed ? assist.recap : null, + suggestion: assist && assist.suggestion ? assist.suggestion : null, + }; +} diff --git a/packages/ui/src/lib/appearanceAutoSave.ts b/packages/ui/src/lib/appearanceAutoSave.ts index 8e4146ab..ab083612 100644 --- a/packages/ui/src/lib/appearanceAutoSave.ts +++ b/packages/ui/src/lib/appearanceAutoSave.ts @@ -6,6 +6,7 @@ import type { MobileKeyboardMode } from '@/lib/mobileKeyboardMode'; type AppearanceSlice = { showReasoningTraces: boolean; + sessionAssistEnabled: boolean; collapsibleThinkingBlocks: boolean; showDeletionDialog: boolean; nativeNotificationsEnabled: boolean; @@ -50,6 +51,7 @@ export const startAppearanceAutoSave = (): void => { let previous: AppearanceSlice = { showReasoningTraces: useUIStore.getState().showReasoningTraces, + sessionAssistEnabled: useUIStore.getState().sessionAssistEnabled, collapsibleThinkingBlocks: useUIStore.getState().collapsibleThinkingBlocks, showDeletionDialog: useUIStore.getState().showDeletionDialog, nativeNotificationsEnabled: useUIStore.getState().nativeNotificationsEnabled, @@ -101,6 +103,7 @@ export const startAppearanceAutoSave = (): void => { useUIStore.subscribe((state) => { const current: AppearanceSlice = { showReasoningTraces: state.showReasoningTraces, + sessionAssistEnabled: state.sessionAssistEnabled, collapsibleThinkingBlocks: state.collapsibleThinkingBlocks, showDeletionDialog: state.showDeletionDialog, nativeNotificationsEnabled: state.nativeNotificationsEnabled, @@ -134,6 +137,9 @@ export const startAppearanceAutoSave = (): void => { if (current.showReasoningTraces !== previous.showReasoningTraces) { diff.showReasoningTraces = current.showReasoningTraces; } + if (current.sessionAssistEnabled !== previous.sessionAssistEnabled) { + diff.sessionAssistEnabled = current.sessionAssistEnabled; + } if (current.collapsibleThinkingBlocks !== previous.collapsibleThinkingBlocks) { diff.collapsibleThinkingBlocks = current.collapsibleThinkingBlocks; } diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index 8c3329fd..fd2de600 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -113,6 +113,9 @@ export type DesktopSettings = { defaultModel?: string; // format: "provider/model" defaultVariant?: string; defaultAgent?: string; + smallModelUseDefault?: boolean; + sessionAssistEnabled?: boolean; + smallModelOverride?: string; // format: "provider/model" defaultGitIdentityId?: string; // ''/undefined = unset, 'global' or profile id openInAppId?: string; autoCreateWorktree?: boolean; diff --git a/packages/ui/src/lib/gitApi.ts b/packages/ui/src/lib/gitApi.ts index a177b7c3..bfa787de 100644 --- a/packages/ui/src/lib/gitApi.ts +++ b/packages/ui/src/lib/gitApi.ts @@ -2,6 +2,7 @@ import * as gitHttp from './gitApiHttp'; import { opencodeClient } from './opencode/client'; import { renderMagicPrompt } from './magicPrompts'; +import { runtimeFetch } from './runtime-fetch'; import { materializeOpenDraftSession, useSessionUIStore } from '@/sync/session-ui-store'; import { useSelectionStore } from '@/sync/selection-store'; import { useConfigStore } from '@/stores/useConfigStore'; @@ -210,6 +211,71 @@ export async function deleteRemoteBranch(directory: string, payload: import('./a return gitHttp.deleteRemoteBranch(directory, payload); } +const COMMIT_DIFF_FILE_LIMIT = 30; +const COMMIT_DIFF_TOTAL_CHAR_LIMIT = 120_000; + +const collectSelectedFileDiffs = async (directory: string, files: string[]): Promise => { + const limited = files.slice(0, COMMIT_DIFF_FILE_LIMIT); + const chunks = await Promise.all(limited.map(async (path) => { + try { + const [staged, unstaged] = await Promise.all([ + gitHttp.getGitDiff(directory, { path, staged: true }).catch(() => null), + gitHttp.getGitDiff(directory, { path, staged: false }).catch(() => null), + ]); + const text = [staged?.diff, unstaged?.diff] + .filter((diff): diff is string => typeof diff === 'string' && diff.trim().length > 0) + .join('\n'); + return text ? text : `--- ${path} (no textual diff available)`; + } catch { + return `--- ${path} (diff unavailable)`; + } + })); + + let total = ''; + for (const chunk of chunks) { + if (total.length + chunk.length > COMMIT_DIFF_TOTAL_CHAR_LIMIT) { + total += '\n[remaining diffs truncated]'; + break; + } + total += (total ? '\n\n' : '') + chunk; + } + if (files.length > limited.length) { + total += `\n[${files.length - limited.length} more selected files omitted]`; + } + return total; +}; + +const parseCommitStructured = (structured: Record | null): { subject: string; highlights: string[] } => { + const subject = typeof structured?.subject === 'string' ? structured.subject.trim() : ''; + const highlights = Array.isArray(structured?.highlights) + ? structured.highlights.filter((item) => typeof item === 'string').map((item) => item.trim()).filter(Boolean).slice(0, 3) + : []; + if (!subject) { + throw new Error('Structured output missing subject'); + } + return { subject, highlights }; +}; + +// Legacy transport: run the structured generation inside the active chat +// session. Kept as the fallback for setups with no direct provider login +// (vanilla installs on OpenCode's free models), where the small-model +// endpoint has nothing to call but the session itself still works. +async function generateCommitMessageViaSession( + directory: string, + visiblePrompt: string, + hiddenPrompt: string, +): Promise<{ message: import('./api/types').GeneratedCommitMessage }> { + const generationSession = await resolveGenerationSessionContext(); + const structured = await runStructuredGenerationInActiveSession({ + directory, + visiblePrompt, + hiddenPrompt, + generationSession, + kind: 'commit', + }); + return { message: parseCommitStructured(structured) }; +} + export async function generateCommitMessage( directory: string, files: string[], @@ -217,17 +283,12 @@ export async function generateCommitMessage( ): Promise<{ message: import('./api/types').GeneratedCommitMessage }> { const startedAt = Date.now(); void options; - const generationSession = await resolveGenerationSessionContext(); console.info('[git-generation][browser] request', { - transport: 'session', + transport: 'small-model', kind: 'commit', directory, selectedFiles: files.length, - sessionId: generationSession.sessionId, - providerId: generationSession.providerID, - modelId: generationSession.modelID, - agent: generationSession.agent, }); const visiblePrompt = await renderMagicPrompt('git.commit.generate.visible'); @@ -236,26 +297,44 @@ export async function generateCommitMessage( }); try { - const structured = await runStructuredGenerationInActiveSession({ - directory, - visiblePrompt, - hiddenPrompt, - generationSession, - kind: 'commit', + const diffs = await collectSelectedFileDiffs(directory, files); + const { currentProviderId, currentModelId } = useConfigStore.getState(); + const response = await runtimeFetch('/api/small-model/generate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + system: visiblePrompt, + prompt: `${hiddenPrompt}\n\nDiffs of the selected files:\n${diffs}`, + directory, + ...(currentProviderId ? { preferredProviderID: currentProviderId } : {}), + ...(currentModelId ? { preferredModelID: currentModelId } : {}), + }), }); - const subject = typeof structured.subject === 'string' ? structured.subject.trim() : ''; - const highlights = Array.isArray(structured.highlights) - ? structured.highlights.filter((item) => typeof item === 'string').map((item) => item.trim()).filter(Boolean).slice(0, 3) - : []; - - if (!subject) { - throw new Error('Structured output missing subject'); + if (response.status === 404) { + // No authenticated provider has a small model — fall back to the + // session transport so free-model-only setups keep a working button. + console.info('[git-generation][browser] small model unavailable, falling back to session transport'); + const result = await generateCommitMessageViaSession(directory, visiblePrompt, hiddenPrompt); + console.info('[git-generation][browser] success', { + transport: 'session-fallback', + kind: 'commit', + elapsedMs: Date.now() - startedAt, + subjectLength: result.message.subject.length, + highlightsCount: result.message.highlights.length, + }); + return result; } - const result = { message: { subject, highlights } }; + const payload = await response.json().catch(() => null) as { text?: unknown; error?: unknown } | null; + if (!response.ok || typeof payload?.text !== 'string') { + const message = typeof payload?.error === 'string' ? payload.error : `HTTP ${response.status}`; + throw new Error(message); + } + + const result = { message: parseCommitStructured(extractJsonObject(payload.text)) }; console.info('[git-generation][browser] success', { - transport: 'session', + transport: 'small-model', kind: 'commit', elapsedMs: Date.now() - startedAt, subjectLength: result.message.subject.length, @@ -264,7 +343,7 @@ export async function generateCommitMessage( return result; } catch (error) { console.error('[git-generation][browser] failed', { - transport: 'session', + transport: 'small-model', kind: 'commit', elapsedMs: Date.now() - startedAt, message: error instanceof Error ? error.message : String(error), @@ -279,18 +358,19 @@ export async function generatePullRequestDescription( payload: { base: string; head: string; context?: string; zenModel?: string; providerId?: string; modelId?: string } ): Promise { const startedAt = Date.now(); - const generationSession = await resolveGenerationSessionContext(); const commitLog = await getGitLog(directory, { from: payload.base, to: payload.head, maxCount: 50, }); + const COMMIT_BODY_CHAR_LIMIT = 2_000; const commits = (Array.isArray(commitLog?.all) ? commitLog.all : []) .filter((entry) => typeof entry?.hash === 'string' && entry.hash.length > 0) .map((entry) => ({ hash: entry.hash, subject: typeof entry.message === 'string' ? entry.message.trim() : '', + body: typeof entry.body === 'string' ? entry.body.trim().slice(0, COMMIT_BODY_CHAR_LIMIT) : '', })); if (commits.length === 0) { @@ -317,13 +397,9 @@ export async function generatePullRequestDescription( const changedFiles = Array.from(filesSet).sort().slice(0, 300); console.info('[git-generation][browser] request', { - transport: 'session', + transport: 'small-model', kind: 'pr', directory, - sessionId: generationSession.sessionId, - providerId: generationSession.providerID, - modelId: generationSession.modelID, - agent: generationSession.agent, base: payload.base, head: payload.head, commits: commits.length, @@ -334,26 +410,67 @@ export async function generatePullRequestDescription( const hiddenPrompt = await renderMagicPrompt('git.pr.generate.instructions', { base_branch: payload.base, head_branch: payload.head, - commits: commits.map((commit) => `- ${commit.hash.slice(0, 7)} ${commit.subject || '(no subject)'}`).join('\n'), + commits: commits.map((commit) => { + const line = `- ${commit.hash.slice(0, 7)} ${commit.subject || '(no subject)'}`; + if (!commit.body) return line; + const indentedBody = commit.body.split('\n').map((bodyLine) => ` ${bodyLine}`).join('\n'); + return `${line}\n${indentedBody}`; + }).join('\n'), changed_files: changedFiles.length > 0 ? changedFiles.map((file) => `- ${file}`).join('\n') : '- none detected', additional_context_block: payload.context?.trim() ? `\nAdditional context:\n${payload.context.trim()}` : '', }); + const parsePrStructured = (structured: Record | null) => ({ + title: typeof structured?.title === 'string' ? structured.title.trim() : '', + body: typeof structured?.body === 'string' ? structured.body.trim() : '', + }); + try { - const structured = await runStructuredGenerationInActiveSession({ - directory, - visiblePrompt, - hiddenPrompt, - generationSession, - kind: 'pr', + const { currentProviderId, currentModelId } = useConfigStore.getState(); + const response = await runtimeFetch('/api/small-model/generate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + system: visiblePrompt, + prompt: hiddenPrompt, + directory, + ...(currentProviderId ? { preferredProviderID: currentProviderId } : {}), + ...(currentModelId ? { preferredModelID: currentModelId } : {}), + }), }); - const result = { - title: typeof structured.title === 'string' ? structured.title.trim() : '', - body: typeof structured.body === 'string' ? structured.body.trim() : '', - }; + if (response.status === 404) { + // No authenticated provider has a small model — fall back to the + // session transport so free-model-only setups keep working. + console.info('[git-generation][browser] small model unavailable, falling back to session transport'); + const generationSession = await resolveGenerationSessionContext(); + const structured = await runStructuredGenerationInActiveSession({ + directory, + visiblePrompt, + hiddenPrompt, + generationSession, + kind: 'pr', + }); + const result = parsePrStructured(structured); + console.info('[git-generation][browser] success', { + transport: 'session-fallback', + kind: 'pr', + elapsedMs: Date.now() - startedAt, + titleLength: result.title.length, + bodyLength: result.body.length, + }); + return result; + } + + const payload = await response.json().catch(() => null) as { text?: unknown; error?: unknown } | null; + if (!response.ok || typeof payload?.text !== 'string') { + const message = typeof payload?.error === 'string' ? payload.error : `HTTP ${response.status}`; + throw new Error(message); + } + + const result = parsePrStructured(extractJsonObject(payload.text)); console.info('[git-generation][browser] success', { - transport: 'session', + transport: 'small-model', kind: 'pr', elapsedMs: Date.now() - startedAt, titleLength: result.title.length, @@ -362,7 +479,7 @@ export async function generatePullRequestDescription( return result; } catch (error) { console.error('[git-generation][browser] failed', { - transport: 'session', + transport: 'small-model', kind: 'pr', elapsedMs: Date.now() - startedAt, message: error instanceof Error ? error.message : String(error), diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index c659fac1..9deabdc1 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -1397,6 +1397,11 @@ export const settingsDict = { 'settings.openchamber.defaults.field.defaultAgent': 'Default Agent', 'settings.openchamber.defaults.field.showDeletionDialogAria': 'Show deletion dialog', 'settings.openchamber.defaults.field.showDeletionDialog': 'Show Deletion Dialog', + 'settings.openchamber.defaults.smallModel.title': 'Small Model', + 'settings.openchamber.defaults.smallModel.description': 'A cheap model for quick utility tasks like short recaps and summaries.', + 'settings.openchamber.defaults.smallModel.useDefault': 'Use default small model', + 'settings.openchamber.defaults.smallModel.useDefaultAria': 'Use default small model', + 'settings.openchamber.defaults.smallModel.overrideModel': 'Override model', 'settings.openchamber.defaults.field.openFilesPreviewAria': 'Open files in preview mode', 'settings.openchamber.defaults.field.openFilesPreview': 'Open files in preview mode', 'settings.openchamber.defaults.option.default': 'Default', @@ -1601,6 +1606,7 @@ export const settingsDict = { 'settings.voice.page.field.ttsInputMode': 'TTS Input Mode', 'settings.voice.page.field.ttsInputModeSanitized': 'Sanitized', 'settings.voice.page.field.ttsInputModeRaw': 'Raw Markdown', + 'settings.voice.page.field.ttsInputModeSummarized': 'summarized', 'settings.openchamber.visual.section.colorMode': 'Color Mode', 'settings.openchamber.visual.section.mobileLayout': 'Mobile Layout', 'settings.openchamber.visual.option.mobileLayout.default': 'Old', @@ -1684,6 +1690,8 @@ export const settingsDict = { 'settings.openchamber.visual.field.userMessageRenderingAria': 'User message rendering: {option}', 'settings.openchamber.visual.field.mermaidRenderingAria': 'Mermaid rendering: {option}', 'settings.openchamber.visual.field.diffLayoutAria': 'Diff layout: {option}', + 'settings.openchamber.visual.field.sessionAssist': 'Generate Session Recap & Suggestion', + 'settings.openchamber.visual.field.sessionAssistAria': 'Generate a recap and a suggested reply after the agent finishes', 'settings.openchamber.visual.field.showReasoningTracesAria': 'Show reasoning traces', 'settings.openchamber.visual.field.showReasoningTraces': 'Show Reasoning Traces', 'settings.openchamber.visual.field.collapsibleThinkingBlocksAria': 'Enable collapsible reasoning blocks', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 01abb9d4..ecc04004 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -1387,6 +1387,10 @@ export const dict = { 'header.actions.toggleChangesPanelAria': 'Toggle changes panel', 'header.actions.planWithShortcut': 'Plan ({shortcut})', 'header.actions.terminalPanelWithShortcut': 'Terminal panel ({shortcut})', + 'chat.recap.aria': 'Session recap', + 'chat.recap.label': 'Recap:', + 'chat.suggestion.applyAria': 'Use suggested message', + 'chat.suggestion.dismissAria': 'Dismiss suggestion', 'header.actions.toggleTerminalPanelAria': 'Toggle terminal panel', 'terminalView.stream.processExitedMessage': '\r\n[Process exited{exitCodeSegment}{signalSegment}]\r\n', 'terminalView.stream.processExitedWithCode': ' with code {exitCode}', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index 94ae6b59..ad6cb599 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -1363,6 +1363,11 @@ export const settingsDict = { "settings.openchamber.defaults.field.thinkingPlaceholder": "Pensando", "settings.openchamber.defaults.field.defaultAgent": "Agente por defecto", "settings.openchamber.defaults.field.showDeletionDialogAria": "Mostrar diálogo de eliminación", + "settings.openchamber.defaults.smallModel.title": "Modelo pequeño", + "settings.openchamber.defaults.smallModel.description": "Un modelo económico para tareas utilitarias rápidas, como recapitulaciones y resúmenes breves.", + "settings.openchamber.defaults.smallModel.useDefault": "Usar el modelo pequeño predeterminado", + "settings.openchamber.defaults.smallModel.useDefaultAria": "Usar el modelo pequeño predeterminado", + "settings.openchamber.defaults.smallModel.overrideModel": "Modelo de anulación", "settings.openchamber.defaults.field.showDeletionDialog": "Mostrar diálogo de eliminación", "settings.openchamber.defaults.field.openFilesPreviewAria": "Abrir archivos en modo vista previa", "settings.openchamber.defaults.field.openFilesPreview": "Abrir archivos en modo vista previa", @@ -1568,6 +1573,7 @@ export const settingsDict = { "settings.voice.page.field.ttsInputMode": "Modo de entrada TTS", "settings.voice.page.field.ttsInputModeSanitized": "Texto limpio", "settings.voice.page.field.ttsInputModeRaw": "Markdown sin procesar", + "settings.voice.page.field.ttsInputModeSummarized": "resumido", "settings.openchamber.visual.section.colorMode": "Modo de color", "settings.openchamber.visual.section.mobileLayout": "Diseño móvil", "settings.openchamber.visual.option.mobileLayout.default": "Anterior", @@ -1651,6 +1657,8 @@ export const settingsDict = { "settings.openchamber.visual.field.userMessageRenderingAria": "Renderizado de mensajes del usuario: {option}", "settings.openchamber.visual.field.mermaidRenderingAria": "Renderizado de Mermaid: {option}", "settings.openchamber.visual.field.diffLayoutAria": "Diseño de comparación: {option}", + "settings.openchamber.visual.field.sessionAssist": "Generar resumen y sugerencia de sesión", + "settings.openchamber.visual.field.sessionAssistAria": "Generar un resumen y una respuesta sugerida cuando el agente termina", "settings.openchamber.visual.field.showReasoningTracesAria": "Mostrar rastros de razonamiento", "settings.openchamber.visual.field.showReasoningTraces": "Mostrar trazas de razonamiento", "settings.openchamber.visual.field.collapsibleThinkingBlocksAria": "Habilitar bloques de razonamiento colapsables", diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index fee8511f..5c412588 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -1365,6 +1365,10 @@ export const dict: Record = { "header.actions.toggleChangesPanelAria": "Alternar panel de cambios", "header.actions.planWithShortcut": "Plan ({shortcut})", "header.actions.terminalPanelWithShortcut": "Panel de terminal ({shortcut})", + "chat.recap.aria": "Resumen de la sesión", + "chat.recap.label": "Resumen:", + "chat.suggestion.applyAria": "Usar mensaje sugerido", + "chat.suggestion.dismissAria": "Descartar sugerencia", "header.actions.toggleTerminalPanelAria": "Mostrar u ocultar panel de terminal", "terminalView.stream.processExitedMessage": "\r\n[Proceso terminado{exitCodeSegment}{signalSegment}]\r\n", "terminalView.stream.processExitedWithCode": " con código {exitCode}", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index b3cb5487..c3722240 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -1346,6 +1346,11 @@ export const settingsDict = { 'settings.openchamber.defaults.field.thinkingPlaceholder': 'Pensée', 'settings.openchamber.defaults.field.defaultAgent': 'Agent par défaut', 'settings.openchamber.defaults.field.showDeletionDialogAria': 'Afficher la boîte de dialogue de suppression', + 'settings.openchamber.defaults.smallModel.title': 'Petit modèle', + 'settings.openchamber.defaults.smallModel.description': 'Un modèle économique pour les tâches utilitaires rapides, comme les récapitulatifs et résumés courts.', + 'settings.openchamber.defaults.smallModel.useDefault': 'Utiliser le petit modèle par défaut', + 'settings.openchamber.defaults.smallModel.useDefaultAria': 'Utiliser le petit modèle par défaut', + 'settings.openchamber.defaults.smallModel.overrideModel': 'Modèle de remplacement', 'settings.openchamber.defaults.field.showDeletionDialog': 'Afficher la boîte de dialogue de suppression', 'settings.openchamber.defaults.field.openFilesPreviewAria': 'Ouvrir les fichiers en mode aperçu', 'settings.openchamber.defaults.field.openFilesPreview': 'Ouvrir les fichiers en mode aperçu', @@ -1619,6 +1624,8 @@ export const settingsDict = { 'settings.openchamber.visual.field.userMessageRenderingAria': 'Rendu du message utilisateur : {option}', 'settings.openchamber.visual.field.mermaidRenderingAria': 'Rendu Mermaid : {option}', 'settings.openchamber.visual.field.diffLayoutAria': 'Disposition des différences : {option}', + 'settings.openchamber.visual.field.sessionAssist': 'Générer le récapitulatif et la suggestion de session', + 'settings.openchamber.visual.field.sessionAssistAria': "Générer un récapitulatif et une réponse suggérée quand l'agent termine", 'settings.openchamber.visual.field.showReasoningTracesAria': 'Afficher les traces de raisonnement', 'settings.openchamber.visual.field.showReasoningTraces': 'Afficher les traces de raisonnement', 'settings.openchamber.visual.field.collapsibleThinkingBlocksAria': 'Activer les blocs de raisonnement pliables', @@ -1785,6 +1792,7 @@ export const settingsDict = { 'settings.voice.page.field.ttsInputMode': 'Mode d’entrée TTS', 'settings.voice.page.field.ttsInputModeSanitized': 'Nettoyé', 'settings.voice.page.field.ttsInputModeRaw': 'Markdown brut', + 'settings.voice.page.field.ttsInputModeSummarized': 'résumé', 'settings.openchamber.visual.section.mobileLayout': 'Mise en page mobile', 'settings.openchamber.visual.option.mobileLayout.default': 'Ancienne', 'settings.openchamber.visual.option.mobileLayout.new': 'Nouvelle', diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 712b9c5a..efd36d71 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -1214,6 +1214,10 @@ export const dict = { "header.actions.toggleChangesPanelAria": "Basculer le panneau des changements", 'header.actions.planWithShortcut': 'Forfait ({shortcut})', 'header.actions.terminalPanelWithShortcut': 'Panneau à bornes ({shortcut})', + 'chat.recap.aria': 'Récapitulatif de la session', + 'chat.recap.label': 'Récap :', + 'chat.suggestion.applyAria': 'Utiliser le message suggéré', + 'chat.suggestion.dismissAria': 'Ignorer la suggestion', 'header.actions.toggleTerminalPanelAria': 'Basculer le panneau à bornes', 'terminalView.stream.processExitedMessage': '[Processus terminé{exitCodeSegment}{signalSegment}]', 'terminalView.stream.processExitedWithCode': 'avec le code {exitCode}', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index 3e7a1417..e4ea9ca1 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -1396,6 +1396,11 @@ export const settingsDict = { 'settings.openchamber.defaults.field.thinkingPlaceholder': '思考', 'settings.openchamber.defaults.field.defaultAgent': 'デフォルト Agent', 'settings.openchamber.defaults.field.showDeletionDialogAria': '削除ダイアログを表示', + 'settings.openchamber.defaults.smallModel.title': '小型モデル', + 'settings.openchamber.defaults.smallModel.description': '短い要約やまとめなどの軽いユーティリティタスク用の低コストモデルです。', + 'settings.openchamber.defaults.smallModel.useDefault': 'デフォルトの小型モデルを使用', + 'settings.openchamber.defaults.smallModel.useDefaultAria': 'デフォルトの小型モデルを使用', + 'settings.openchamber.defaults.smallModel.overrideModel': '上書きモデル', 'settings.openchamber.defaults.field.showDeletionDialog': '削除ダイアログを表示', 'settings.openchamber.defaults.field.openFilesPreviewAria': 'ファイルをプレビューモードで開く', 'settings.openchamber.defaults.field.openFilesPreview': 'ファイルをプレビューモードで開く', @@ -1601,6 +1606,7 @@ export const settingsDict = { 'settings.voice.page.field.ttsInputMode': 'TTS 入力モード', 'settings.voice.page.field.ttsInputModeSanitized': 'サニタイズ', 'settings.voice.page.field.ttsInputModeRaw': '生 Markdown', + 'settings.voice.page.field.ttsInputModeSummarized': '要約', 'settings.openchamber.visual.section.colorMode': 'カラーモード', 'settings.openchamber.visual.section.mobileLayout': 'モバイルレイアウト', 'settings.openchamber.visual.option.mobileLayout.default': '旧', @@ -1684,6 +1690,8 @@ export const settingsDict = { 'settings.openchamber.visual.field.userMessageRenderingAria': 'ユーザーメッセージ表示: {option}', 'settings.openchamber.visual.field.mermaidRenderingAria': 'Mermaid 表示: {option}', 'settings.openchamber.visual.field.diffLayoutAria': 'Diff レイアウト: {option}', + 'settings.openchamber.visual.field.sessionAssist': 'セッションの要約と提案を生成', + 'settings.openchamber.visual.field.sessionAssistAria': 'エージェントの完了後に要約と返信の提案を生成します', 'settings.openchamber.visual.field.showReasoningTracesAria': '推論トレースを表示', 'settings.openchamber.visual.field.showReasoningTraces': '推論トレースを表示', 'settings.openchamber.visual.field.collapsibleThinkingBlocksAria': '折りたたみ可能な推論ブロックを有効化', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 09520449..b48f61f3 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -1383,6 +1383,10 @@ export const dict: Record = { 'header.actions.toggleChangesPanelAria': '変更パネルの切り替え', 'header.actions.planWithShortcut': '計画({shortcut})', 'header.actions.terminalPanelWithShortcut': 'ターミナルパネル({shortcut})', + 'chat.recap.aria': 'セッションの要約', + 'chat.recap.label': '要約:', + 'chat.suggestion.applyAria': '提案されたメッセージを使用', + 'chat.suggestion.dismissAria': '提案を閉じる', 'header.actions.toggleTerminalPanelAria': 'ターミナルパネルの切り替え', 'terminalView.stream.processExitedMessage': '\r\n[プロセスが終了しました{exitCodeSegment}{signalSegment}]\r\n', 'terminalView.stream.processExitedWithCode': ' コード {exitCode}', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index aa9b90be..3a24a088 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -1363,6 +1363,11 @@ export const settingsDict = { 'settings.openchamber.defaults.field.thinkingPlaceholder': 'Thinking', 'settings.openchamber.defaults.field.defaultAgent': '기본 에이전트', 'settings.openchamber.defaults.field.showDeletionDialogAria': '삭제 확인 대화상자 표시', + 'settings.openchamber.defaults.smallModel.title': '소형 모델', + 'settings.openchamber.defaults.smallModel.description': '짧은 요약 등 가벼운 유틸리티 작업을 위한 저렴한 모델입니다.', + 'settings.openchamber.defaults.smallModel.useDefault': '기본 소형 모델 사용', + 'settings.openchamber.defaults.smallModel.useDefaultAria': '기본 소형 모델 사용', + 'settings.openchamber.defaults.smallModel.overrideModel': '재정의 모델', 'settings.openchamber.defaults.field.showDeletionDialog': '삭제 확인 대화상자 표시', 'settings.openchamber.defaults.field.openFilesPreviewAria': '파일을 미리보기 모드로 열기', 'settings.openchamber.defaults.field.openFilesPreview': '파일을 미리보기 모드로 열기', @@ -1568,6 +1573,7 @@ export const settingsDict = { 'settings.voice.page.field.ttsInputMode': 'TTS 입력 모드', 'settings.voice.page.field.ttsInputModeSanitized': '정제된 텍스트', 'settings.voice.page.field.ttsInputModeRaw': '원본 Markdown', + 'settings.voice.page.field.ttsInputModeSummarized': '요약', 'settings.openchamber.visual.section.colorMode': '색상 모드', 'settings.openchamber.visual.section.mobileLayout': '모바일 레이아웃', 'settings.openchamber.visual.option.mobileLayout.default': '이전', @@ -1651,6 +1657,8 @@ export const settingsDict = { 'settings.openchamber.visual.field.userMessageRenderingAria': '사용자 메시지 렌더링: {option}', 'settings.openchamber.visual.field.mermaidRenderingAria': 'Mermaid 렌더링: {option}', 'settings.openchamber.visual.field.diffLayoutAria': 'Diff 레이아웃: {option}', + 'settings.openchamber.visual.field.sessionAssist': '세션 요약 및 제안 생성', + 'settings.openchamber.visual.field.sessionAssistAria': '에이전트가 완료되면 요약과 제안 답장을 생성합니다', 'settings.openchamber.visual.field.showReasoningTracesAria': 'Reasoning trace 표시', 'settings.openchamber.visual.field.showReasoningTraces': 'Reasoning Trace 표시', 'settings.openchamber.visual.field.collapsibleThinkingBlocksAria': '접을 수 있는 추론 블록 활성화', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index cc1d22ab..91b83456 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1389,6 +1389,10 @@ export const dict: Record = { "header.actions.toggleChangesPanelAria": "변경 패널 전환", 'header.actions.planWithShortcut': '플랜 ({shortcut})', 'header.actions.terminalPanelWithShortcut': '터미널 패널 ({shortcut})', + 'chat.recap.aria': '세션 요약', + 'chat.recap.label': '요약:', + 'chat.suggestion.applyAria': '제안된 메시지 사용', + 'chat.suggestion.dismissAria': '제안 닫기', 'header.actions.toggleTerminalPanelAria': '토글 터미널 패널', 'terminalView.stream.processExitedMessage': '\r\n[Process exited{exitCodeSegment}{signalSegment}]\r\n', 'terminalView.stream.processExitedWithCode': ', 종료 코드 {exitCode}', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index 86c2caed..75a2f201 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -678,6 +678,11 @@ export const settingsDict = { 'settings.openchamber.defaults.field.openFilesPreviewAria': 'Otwieraj pliki w trybie podglądu', 'settings.openchamber.defaults.field.showDeletionDialog': 'Pokaż dialog usuwania', 'settings.openchamber.defaults.field.showDeletionDialogAria': 'Pokaż dialog usuwania', + 'settings.openchamber.defaults.smallModel.title': 'Mały model', + 'settings.openchamber.defaults.smallModel.description': 'Tani model do szybkich zadań pomocniczych, takich jak krótkie podsumowania.', + 'settings.openchamber.defaults.smallModel.useDefault': 'Używaj domyślnego małego modelu', + 'settings.openchamber.defaults.smallModel.useDefaultAria': 'Używaj domyślnego małego modelu', + 'settings.openchamber.defaults.smallModel.overrideModel': 'Model zastępczy', 'settings.openchamber.defaults.field.thinkingPlaceholder': 'Myślenie', 'settings.openchamber.defaults.option.default': 'Domyślne', 'settings.openchamber.defaults.option.defaultLowercase': 'domyślne', @@ -969,6 +974,8 @@ export const settingsDict = { 'settings.openchamber.visual.field.showDotfilesAria': 'Pokaż pliki ukryte', 'settings.openchamber.visual.field.showExpandedBashToolsAria': 'Pokaż rozwinięte narzędzia bash', 'settings.openchamber.visual.field.showExpandedEditToolsAria': 'Pokaż rozwinięte narzędzia edycji', + 'settings.openchamber.visual.field.sessionAssist': 'Generuj podsumowanie i sugestię sesji', + 'settings.openchamber.visual.field.sessionAssistAria': 'Generuj podsumowanie i sugerowaną odpowiedź po zakończeniu pracy agenta', 'settings.openchamber.visual.field.showReasoningTraces': 'Pokaż ślady rozumowania', 'settings.openchamber.visual.field.showReasoningTracesAria': 'Pokaż ślady rozumowania', 'settings.openchamber.visual.field.collapsibleThinkingBlocks': 'Włącz zwijalne bloki rozumowania', @@ -1819,6 +1826,7 @@ export const settingsDict = { 'settings.voice.page.field.ttsInputMode': 'Tryb wejścia TTS', 'settings.voice.page.field.ttsInputModeSanitized': 'Oczyszczony tekst', 'settings.voice.page.field.ttsInputModeRaw': 'Surowy Markdown', + 'settings.voice.page.field.ttsInputModeSummarized': 'streszczony', 'settings.window.description': 'Okno ustawień OpenChamber.', 'settings.openchamber.visual.section.followUpBehavior': 'Follow-up behavior', 'settings.openchamber.visual.section.followUpBehaviorAria': 'Follow-up behavior', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index aa36d918..2ecc05c4 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -2061,6 +2061,10 @@ export const dict: Record = { 'header.actions.planWithShortcut': 'Plan ({shortcut})', 'header.actions.rightSidebarWithShortcut': 'Prawy panel boczny ({shortcut})', 'header.actions.terminalPanelWithShortcut': 'Panel terminala ({shortcut})', + 'chat.recap.aria': 'Podsumowanie sesji', + 'chat.recap.label': 'Podsumowanie:', + 'chat.suggestion.applyAria': 'Użyj sugerowanej wiadomości', + 'chat.suggestion.dismissAria': 'Odrzuć sugestię', 'header.actions.toggleRightSidebarAria': 'Przełącz prawy panel boczny', 'header.actions.toggleTerminalPanelAria': 'Przełącz panel terminala', 'header.changes.availableAria': 'Dostępne zmiany', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index 4236c670..453bfb72 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -1363,6 +1363,11 @@ export const settingsDict = { "settings.openchamber.defaults.field.thinkingPlaceholder": "Pensando", "settings.openchamber.defaults.field.defaultAgent": "Agente por padrão", "settings.openchamber.defaults.field.showDeletionDialogAria": "Mostrar diálogo de eliminación", + "settings.openchamber.defaults.smallModel.title": "Modelo pequeno", + "settings.openchamber.defaults.smallModel.description": "Um modelo barato para tarefas utilitárias rápidas, como recapitulações e resumos curtos.", + "settings.openchamber.defaults.smallModel.useDefault": "Usar o modelo pequeno padrão", + "settings.openchamber.defaults.smallModel.useDefaultAria": "Usar o modelo pequeno padrão", + "settings.openchamber.defaults.smallModel.overrideModel": "Modelo de substituição", "settings.openchamber.defaults.field.showDeletionDialog": "Mostrar diálogo de eliminación", "settings.openchamber.defaults.field.openFilesPreviewAria": "Abrir arquivos em modo prévia", "settings.openchamber.defaults.field.openFilesPreview": "Abrir arquivos em modo prévia", @@ -1568,6 +1573,7 @@ export const settingsDict = { "settings.voice.page.field.ttsInputMode": "Modo de entrada TTS", "settings.voice.page.field.ttsInputModeSanitized": "Texto limpo", "settings.voice.page.field.ttsInputModeRaw": "Markdown bruto", + "settings.voice.page.field.ttsInputModeSummarized": "resumido", "settings.openchamber.visual.section.colorMode": "Modo de cor", "settings.openchamber.visual.section.mobileLayout": "Layout móvel", "settings.openchamber.visual.option.mobileLayout.default": "Anterior", @@ -1651,6 +1657,8 @@ export const settingsDict = { "settings.openchamber.visual.field.userMessageRenderingAria": "Renderizado de mensagens do usuário: {option}", "settings.openchamber.visual.field.mermaidRenderingAria": "Renderizado de Mermaid: {option}", "settings.openchamber.visual.field.diffLayoutAria": "Layout de comparação: {option}", + "settings.openchamber.visual.field.sessionAssist": "Gerar resumo e sugestão da sessão", + "settings.openchamber.visual.field.sessionAssistAria": "Gerar um resumo e uma resposta sugerida quando o agente termina", "settings.openchamber.visual.field.showReasoningTracesAria": "Mostrar rastros de raciocínio", "settings.openchamber.visual.field.showReasoningTraces": "Mostrar rastros de raciocínio", "settings.openchamber.visual.field.collapsibleThinkingBlocksAria": "Habilitar blocos de raciocínio recolhíveis", diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 4ff09980..00950b28 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1365,6 +1365,10 @@ export const dict: Record = { "header.actions.toggleChangesPanelAria": "Alternar painel de alterações", "header.actions.planWithShortcut": "Plano ({shortcut})", "header.actions.terminalPanelWithShortcut": "Painel de terminal ({shortcut})", + "chat.recap.aria": "Resumo da sessão", + "chat.recap.label": "Resumo:", + "chat.suggestion.applyAria": "Usar mensagem sugerida", + "chat.suggestion.dismissAria": "Dispensar sugestão", "header.actions.toggleTerminalPanelAria": "Mostrar ou ocultar painel de terminal", "terminalView.stream.processExitedMessage": "\r\n[Processo encerrado{exitCodeSegment}{signalSegment}]\r\n", "terminalView.stream.processExitedWithCode": " com código {exitCode}", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index 84af19b1..7b030311 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -1363,6 +1363,11 @@ export const settingsDict = { "settings.openchamber.defaults.field.thinkingPlaceholder": "Мислення", "settings.openchamber.defaults.field.defaultAgent": "Агент за замовчуванням", "settings.openchamber.defaults.field.showDeletionDialogAria": "Показати діалогове вікно видалення", + "settings.openchamber.defaults.smallModel.title": "Мала модель", + "settings.openchamber.defaults.smallModel.description": "Дешева модель для швидких службових задач — коротких підсумків і резюме.", + "settings.openchamber.defaults.smallModel.useDefault": "Використовувати типову малу модель", + "settings.openchamber.defaults.smallModel.useDefaultAria": "Використовувати типову малу модель", + "settings.openchamber.defaults.smallModel.overrideModel": "Модель заміни", "settings.openchamber.defaults.field.showDeletionDialog": "Показати діалогове вікно видалення", "settings.openchamber.defaults.field.openFilesPreviewAria": "Відкривати файли в режимі попереднього перегляду", "settings.openchamber.defaults.field.openFilesPreview": "Відкривати файли в режимі попереднього перегляду", @@ -1568,6 +1573,7 @@ export const settingsDict = { "settings.voice.page.field.ttsInputMode": "Режим вводу TTS", "settings.voice.page.field.ttsInputModeSanitized": "Очищений текст", "settings.voice.page.field.ttsInputModeRaw": "Сирий Markdown", + "settings.voice.page.field.ttsInputModeSummarized": "скорочений", "settings.openchamber.visual.section.colorMode": "Режим теми", "settings.openchamber.visual.section.mobileLayout": "Мобільний макет", "settings.openchamber.visual.option.mobileLayout.default": "Попередній", @@ -1651,6 +1657,8 @@ export const settingsDict = { "settings.openchamber.visual.field.userMessageRenderingAria": "Відображення повідомлень користувача: {option}", "settings.openchamber.visual.field.mermaidRenderingAria": "Візуалізація Mermaid: {option}", "settings.openchamber.visual.field.diffLayoutAria": "Компонування diff: {option}", + "settings.openchamber.visual.field.sessionAssist": "Генерувати підсумок і пропозицію для сесії", + "settings.openchamber.visual.field.sessionAssistAria": "Генерувати підсумок і запропоновану відповідь після завершення роботи агента", "settings.openchamber.visual.field.showReasoningTracesAria": "Показати сліди міркувань", "settings.openchamber.visual.field.showReasoningTraces": "Показати сліди міркувань", "settings.openchamber.visual.field.collapsibleThinkingBlocksAria": "Увімкнути згортальні блоки міркувань", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index d1d37f84..2553e302 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1365,6 +1365,10 @@ export const dict: Record = { "header.actions.toggleChangesPanelAria": "Перемкнути панель змін", "header.actions.planWithShortcut": "План ({shortcut})", "header.actions.terminalPanelWithShortcut": "Термінальна панель ({shortcut})", + "chat.recap.aria": "Підсумок сесії", + "chat.recap.label": "Підсумок:", + "chat.suggestion.applyAria": "Використати запропоноване повідомлення", + "chat.suggestion.dismissAria": "Прибрати пропозицію", "header.actions.toggleTerminalPanelAria": "Перемкнути панель терміналу", "terminalView.stream.processExitedMessage": "\r\n[Process exited{exitCodeSegment}{signalSegment}]\r\n", "terminalView.stream.processExitedWithCode": " з кодом {exitCode}", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index b4684193..defb89ce 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -1363,6 +1363,11 @@ export const settingsDict = { 'settings.openchamber.defaults.field.thinkingPlaceholder': '思考模式', 'settings.openchamber.defaults.field.defaultAgent': '默认智能体', 'settings.openchamber.defaults.field.showDeletionDialogAria': '显示删除对话框', + 'settings.openchamber.defaults.smallModel.title': '小模型', + 'settings.openchamber.defaults.smallModel.description': '用于快速实用任务(如简短回顾和摘要)的廉价模型。', + 'settings.openchamber.defaults.smallModel.useDefault': '使用默认小模型', + 'settings.openchamber.defaults.smallModel.useDefaultAria': '使用默认小模型', + 'settings.openchamber.defaults.smallModel.overrideModel': '覆盖模型', 'settings.openchamber.defaults.field.showDeletionDialog': '显示删除对话框', 'settings.openchamber.defaults.field.openFilesPreviewAria': '以预览模式打开文件', 'settings.openchamber.defaults.field.openFilesPreview': '以预览模式打开文件', @@ -1568,6 +1573,7 @@ export const settingsDict = { 'settings.voice.page.field.ttsInputMode': 'TTS 输入模式', 'settings.voice.page.field.ttsInputModeSanitized': '清理后文本', 'settings.voice.page.field.ttsInputModeRaw': '原始 Markdown', + 'settings.voice.page.field.ttsInputModeSummarized': '摘要', 'settings.openchamber.visual.section.colorMode': '颜色模式', 'settings.openchamber.visual.section.mobileLayout': '移动端布局', 'settings.openchamber.visual.option.mobileLayout.default': '旧版', @@ -1651,6 +1657,8 @@ export const settingsDict = { 'settings.openchamber.visual.field.userMessageRenderingAria': '用户消息渲染:{option}', 'settings.openchamber.visual.field.mermaidRenderingAria': 'Mermaid 渲染:{option}', 'settings.openchamber.visual.field.diffLayoutAria': '差异布局:{option}', + 'settings.openchamber.visual.field.sessionAssist': '生成会话回顾与建议', + 'settings.openchamber.visual.field.sessionAssistAria': '代理完成后生成回顾和建议回复', 'settings.openchamber.visual.field.showReasoningTracesAria': '显示推理轨迹', 'settings.openchamber.visual.field.showReasoningTraces': '显示推理轨迹', 'settings.openchamber.visual.field.collapsibleThinkingBlocksAria': '启用可折叠推理块', diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 2b81880c..eee31d39 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1353,6 +1353,10 @@ export const dict: Record = { "header.actions.toggleChangesPanelAria": "切换更改面板", 'header.actions.planWithShortcut': '计划({shortcut})', 'header.actions.terminalPanelWithShortcut': '终端面板({shortcut})', + 'chat.recap.aria': '会话回顾', + 'chat.recap.label': '回顾:', + 'chat.suggestion.applyAria': '使用建议的消息', + 'chat.suggestion.dismissAria': '关闭建议', 'header.actions.toggleTerminalPanelAria': '切换终端面板', 'terminalView.stream.processExitedMessage': '\r\n[进程已退出{exitCodeSegment}{signalSegment}]\r\n', 'terminalView.stream.processExitedWithCode': ',退出码 {exitCode}', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index 04794440..0be05ab2 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -1279,6 +1279,11 @@ 'settings.openchamber.defaults.field.thinkingPlaceholder': '思考模式', 'settings.openchamber.defaults.field.defaultAgent': '預設 Agent', 'settings.openchamber.defaults.field.showDeletionDialogAria': '顯示刪除對話方塊', + 'settings.openchamber.defaults.smallModel.title': '小模型', + 'settings.openchamber.defaults.smallModel.description': '用於快速實用任務(如簡短回顧與摘要)的廉價模型。', + 'settings.openchamber.defaults.smallModel.useDefault': '使用預設小模型', + 'settings.openchamber.defaults.smallModel.useDefaultAria': '使用預設小模型', + 'settings.openchamber.defaults.smallModel.overrideModel': '覆寫模型', 'settings.openchamber.defaults.field.showDeletionDialog': '顯示刪除對話方塊', 'settings.openchamber.defaults.field.openFilesPreviewAria': '以預覽模式開啟檔案', 'settings.openchamber.defaults.field.openFilesPreview': '以預覽模式開啟檔案', @@ -1484,6 +1489,7 @@ 'settings.voice.page.field.ttsInputMode': 'TTS 輸入模式', 'settings.voice.page.field.ttsInputModeSanitized': '清理後文字', 'settings.voice.page.field.ttsInputModeRaw': '原始 Markdown', + 'settings.voice.page.field.ttsInputModeSummarized': '摘要', 'settings.openchamber.visual.section.colorMode': '顏色模式', 'settings.openchamber.visual.section.localization': '在地化', 'settings.openchamber.visual.section.spacingAndLayout': '間距與佈局', @@ -1567,6 +1573,8 @@ 'settings.openchamber.visual.field.userMessageRenderingAria': '使用者訊息渲染:{option}', 'settings.openchamber.visual.field.mermaidRenderingAria': 'Mermaid 渲染:{option}', 'settings.openchamber.visual.field.diffLayoutAria': '差異佈局:{option}', + 'settings.openchamber.visual.field.sessionAssist': '產生工作階段回顧與建議', + 'settings.openchamber.visual.field.sessionAssistAria': '代理完成後產生回顧與建議回覆', 'settings.openchamber.visual.field.showReasoningTracesAria': '顯示推理軌跡', 'settings.openchamber.visual.field.showReasoningTraces': '顯示推理軌跡', 'settings.openchamber.visual.field.collapsibleThinkingBlocksAria': '啟用可摺疊推理區塊', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index ab424dd2..b6cac65f 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -1357,6 +1357,10 @@ export const dict: Record = { "header.actions.toggleChangesPanelAria": "切換變更面板", 'header.actions.planWithShortcut': '計畫({shortcut})', 'header.actions.terminalPanelWithShortcut': '終端機面板({shortcut})', + 'chat.recap.aria': '工作階段回顧', + 'chat.recap.label': '回顧:', + 'chat.suggestion.applyAria': '使用建議的訊息', + 'chat.suggestion.dismissAria': '關閉建議', 'header.actions.toggleTerminalPanelAria': '切換終端機面板', 'terminalView.stream.processExitedMessage': '\r\n[處理程序已結束{exitCodeSegment}{signalSegment}]\r\n', 'terminalView.stream.processExitedWithCode': ',結束代碼 {exitCode}', diff --git a/packages/ui/src/lib/magicPrompts.ts b/packages/ui/src/lib/magicPrompts.ts index 6d12c6e0..d9c89801 100644 --- a/packages/ui/src/lib/magicPrompts.ts +++ b/packages/ui/src/lib/magicPrompts.ts @@ -70,7 +70,7 @@ const MAGIC_PROMPT_DEFINITIONS: readonly MagicPromptDefinition[] = [ title: 'Commit Generation Visible Prompt', group: 'Git', description: 'Visible user message for commit message generation.', - template: 'You are generating a Conventional Commits subject line using session context and selected file paths.', + template: 'You are generating a Conventional Commits subject line from the diffs of the selected files.', }, { id: 'git.commit.generate.instructions', diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index a61317ad..d7ccf5d0 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -423,6 +423,9 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => { if (typeof settings.showReasoningTraces === 'boolean' && settings.showReasoningTraces !== store.showReasoningTraces) { store.setShowReasoningTraces(settings.showReasoningTraces); } + if (typeof settings.sessionAssistEnabled === 'boolean' && settings.sessionAssistEnabled !== store.sessionAssistEnabled) { + store.setSessionAssistEnabled(settings.sessionAssistEnabled); + } if (typeof settings.collapsibleThinkingBlocks === 'boolean' && settings.collapsibleThinkingBlocks !== store.collapsibleThinkingBlocks) { store.setCollapsibleThinkingBlocks(settings.collapsibleThinkingBlocks); } @@ -765,6 +768,9 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => { if (typeof candidate.showReasoningTraces === 'boolean') { result.showReasoningTraces = candidate.showReasoningTraces; } + if (typeof candidate.sessionAssistEnabled === 'boolean') { + result.sessionAssistEnabled = candidate.sessionAssistEnabled; + } if (typeof candidate.collapsibleThinkingBlocks === 'boolean') { result.collapsibleThinkingBlocks = candidate.collapsibleThinkingBlocks; } @@ -832,6 +838,12 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => { if (typeof candidate.defaultAgent === 'string' && candidate.defaultAgent.length > 0) { result.defaultAgent = candidate.defaultAgent; } + if (typeof candidate.smallModelUseDefault === 'boolean') { + result.smallModelUseDefault = candidate.smallModelUseDefault; + } + if (typeof candidate.smallModelOverride === 'string' && candidate.smallModelOverride.length > 0) { + result.smallModelOverride = candidate.smallModelOverride; + } if (typeof candidate.autoCreateWorktree === 'boolean') { result.autoCreateWorktree = candidate.autoCreateWorktree; } diff --git a/packages/ui/src/lib/sessionAssistMetadata.ts b/packages/ui/src/lib/sessionAssistMetadata.ts new file mode 100644 index 00000000..51bb9549 --- /dev/null +++ b/packages/ui/src/lib/sessionAssistMetadata.ts @@ -0,0 +1,36 @@ +import type { Session } from '@opencode-ai/sdk/v2'; + +// Recap + suggested follow-up generated by the server's session-assist +// watcher, stored under session.metadata.openchamber.assist. Freshness is +// encoded in forMessageID: the payload is only valid while that message is +// still the session's last assistant message. +export interface SessionAssistPayload { + recap: string; + suggestion: string; + forMessageID: string; + generatedAt: number; +} + +const isRecord = (value: unknown): value is Record => + Boolean(value) && typeof value === 'object' && !Array.isArray(value); + +export function getSessionAssist(session: Session | null | undefined): SessionAssistPayload | null { + const metadata = (session as { metadata?: unknown } | null | undefined)?.metadata; + if (!isRecord(metadata)) return null; + const namespace = metadata.openchamber; + if (!isRecord(namespace)) return null; + const assist = namespace.assist; + if (!isRecord(assist)) return null; + + const recap = typeof assist.recap === 'string' ? assist.recap.trim() : ''; + const suggestion = typeof assist.suggestion === 'string' ? assist.suggestion.trim() : ''; + const forMessageID = typeof assist.forMessageID === 'string' ? assist.forMessageID : ''; + if (!forMessageID || (!recap && !suggestion)) return null; + + return { + recap, + suggestion, + forMessageID, + generatedAt: typeof assist.generatedAt === 'number' ? assist.generatedAt : 0, + }; +} diff --git a/packages/ui/src/lib/settings/search.ts b/packages/ui/src/lib/settings/search.ts index b9047657..82149835 100644 --- a/packages/ui/src/lib/settings/search.ts +++ b/packages/ui/src/lib/settings/search.ts @@ -166,6 +166,12 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ titleKey: 'settings.openchamber.visual.section.messageStreamTransport', keywords: ['streaming', 'sse', 'websocket'], }, + { + id: 'chat.session-assist', + page: 'chat', + titleKey: 'settings.openchamber.visual.field.sessionAssist', + keywords: ['recap', 'suggestion', 'assist', 'small model', 'summary'], + }, { id: 'chat.reasoning-traces', page: 'chat', @@ -260,6 +266,13 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ titleKey: 'settings.openchamber.defaults.field.showDeletionDialog', keywords: ['delete', 'confirmation'], }, + { + id: 'sessions.small-model', + page: 'sessions', + titleKey: 'settings.openchamber.defaults.smallModel.title', + descriptionKey: 'settings.openchamber.defaults.smallModel.description', + keywords: ['small model', 'utility', 'summary', 'recap', 'cheap', 'override'], + }, { id: 'sessions.auto-cleanup', page: 'sessions', diff --git a/packages/ui/src/lib/smallModel.ts b/packages/ui/src/lib/smallModel.ts new file mode 100644 index 00000000..67ecd546 --- /dev/null +++ b/packages/ui/src/lib/smallModel.ts @@ -0,0 +1,57 @@ +import { runtimeFetch } from '@/lib/runtime-fetch'; +import { useConfigStore } from '@/stores/useConfigStore'; +import { getSessionLastAssistantModel } from '@/sync/session-actions'; + +// Selections shorter than this are already note-sized — summarizing them +// would only add latency and risk losing the exact wording. +const NOTES_SUMMARIZE_MIN_CHARS = 280; + +const NOTES_SYSTEM_PROMPT = [ + 'You distill a text selection from a coding-agent conversation into a project note.', + 'Return ONLY the note text — no preamble, no surrounding quotes, no headers.', + 'Write 1-3 tight sentences that capture the essence worth remembering later: facts, decisions, constraints, root causes, gotchas, next steps.', + 'Preserve exact identifiers verbatim — file paths, function names, commands, flags, versions — in backticks.', + 'Drop filler, hedging, greetings, and step-by-step narration.', + 'Write the note in the same language as the selection. Ignore any other language preferences or personalization — only the selection text decides the language.', +].join('\n'); + +/** + * Distills a chat selection into a compact note via the small model. Falls + * back to the original text on any failure or when no small model is + * available within the session's provider (explicit settings/config picks + * are still honored server-side). + */ +export async function summarizeSelectionForNotes(text: string, sessionId?: string | null): Promise { + const trimmed = text.trim(); + if (trimmed.length < NOTES_SUMMARIZE_MIN_CHARS) { + return trimmed; + } + + try { + // The selection's session provider is authoritative — the text came from + // that conversation. The composer picker only serves as a fallback. + const sessionModel = sessionId ? getSessionLastAssistantModel(sessionId) : null; + const { currentProviderId, currentModelId } = useConfigStore.getState(); + const preferredProviderID = sessionModel?.providerID || currentProviderId || ''; + const preferredModelID = sessionModel?.modelID || currentModelId || ''; + const response = await runtimeFetch('/api/small-model/generate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + prompt: trimmed, + system: NOTES_SYSTEM_PROMPT, + restrictToPreferredProvider: true, + ...(preferredProviderID ? { preferredProviderID } : {}), + ...(preferredModelID ? { preferredModelID } : {}), + }), + }); + if (!response.ok) { + return trimmed; + } + const payload = await response.json().catch(() => null) as { text?: unknown } | null; + const summary = typeof payload?.text === 'string' ? payload.text.trim() : ''; + return summary || trimmed; + } catch { + return trimmed; + } +} diff --git a/packages/ui/src/stores/useConfigStore.ts b/packages/ui/src/stores/useConfigStore.ts index 9dc1fc75..0298d6bb 100644 --- a/packages/ui/src/stores/useConfigStore.ts +++ b/packages/ui/src/stores/useConfigStore.ts @@ -1004,7 +1004,7 @@ interface ConfigStore { sttLocalModel: string; sttLanguage: string; showMessageTTSButtons: boolean; - ttsInputMode: 'sanitized' | 'raw'; + ttsInputMode: 'sanitized' | 'raw' | 'summarized'; // Summarization settings summarizeMessageTTS: boolean; summarizeVoiceConversation: boolean; @@ -1030,7 +1030,7 @@ interface ConfigStore { setSttLocalModel: (model: string) => void; setSttLanguage: (lang: string) => void; setShowMessageTTSButtons: (show: boolean) => void; - setTtsInputMode: (mode: 'sanitized' | 'raw') => void; + setTtsInputMode: (mode: 'sanitized' | 'raw' | 'summarized') => void; setSummarizeMessageTTS: (enabled: boolean) => void; setSummarizeVoiceConversation: (enabled: boolean) => void; setSummarizeCharacterThreshold: (threshold: number) => void; @@ -1299,6 +1299,7 @@ export const useConfigStore = create()( if (typeof window !== 'undefined') { const saved = localStorage.getItem('ttsInputMode'); if (saved === 'raw') return 'raw' as const; + if (saved === 'summarized') return 'summarized' as const; } return 'sanitized' as const; })(), @@ -2925,7 +2926,7 @@ export const useConfigStore = create()( } }, - setTtsInputMode: (mode: 'sanitized' | 'raw') => { + setTtsInputMode: (mode: 'sanitized' | 'raw' | 'summarized') => { set({ ttsInputMode: mode }); if (typeof window !== 'undefined') { localStorage.setItem('ttsInputMode', mode); diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index b8abc30c..1daf530a 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -560,6 +560,7 @@ interface UIStore { eventStreamStatus: EventStreamStatus; eventStreamHint: string | null; showReasoningTraces: boolean; + sessionAssistEnabled: boolean; collapsibleThinkingBlocks: boolean; groupReasoningBlocks: boolean; chatRenderMode: ChatRenderMode; @@ -708,6 +709,7 @@ interface UIStore { setSettingsRemoteInstancesSelectedId: (instanceId: string | null) => void; setEventStreamStatus: (status: EventStreamStatus, hint?: string | null) => void; setShowReasoningTraces: (value: boolean) => void; + setSessionAssistEnabled: (value: boolean) => void; setCollapsibleThinkingBlocks: (value: boolean) => void; setChatRenderMode: (value: ChatRenderMode) => void; setActivityRenderMode: (value: ActivityRenderMode) => void; @@ -851,6 +853,7 @@ export const useUIStore = create()( eventStreamStatus: 'idle', eventStreamHint: null, showReasoningTraces: true, + sessionAssistEnabled: true, collapsibleThinkingBlocks: true, groupReasoningBlocks: true, chatRenderMode: 'live', @@ -1543,6 +1546,10 @@ export const useUIStore = create()( set({ showReasoningTraces: value }); }, + setSessionAssistEnabled: (value) => { + set({ sessionAssistEnabled: value }); + }, + setCollapsibleThinkingBlocks: (value) => { set({ collapsibleThinkingBlocks: value }); }, @@ -2227,6 +2234,7 @@ export const useUIStore = create()( isSessionCreateDialogOpen: state.isSessionCreateDialogOpen, // Note: isSettingsDialogOpen intentionally NOT persisted showReasoningTraces: state.showReasoningTraces, + sessionAssistEnabled: state.sessionAssistEnabled, collapsibleThinkingBlocks: state.collapsibleThinkingBlocks, chatRenderMode: state.chatRenderMode, activityRenderMode: state.activityRenderMode, diff --git a/packages/ui/src/sync/session-actions.ts b/packages/ui/src/sync/session-actions.ts index 6195f127..90a4e89d 100644 --- a/packages/ui/src/sync/session-actions.ts +++ b/packages/ui/src/sync/session-actions.ts @@ -131,6 +131,29 @@ function dirStoreForSession(sessionId: string): { store: DirectoryStoreApi; dire return { store: dirStore(), directory: dir() } } +/** + * Provider/model of the session's last assistant message — the authoritative + * "session provider" for utility calls (notes distillation etc.), independent + * of what the composer picker currently points at. + */ +export function getSessionLastAssistantModel(sessionId: string): { providerID: string; modelID: string } | null { + try { + const { store } = dirStoreForSession(sessionId) + const messages = store.getState().message[sessionId] + if (!messages) return null + for (let i = messages.length - 1; i >= 0; i -= 1) { + const info = messages[i] as { role?: string; providerID?: string; modelID?: string } + if (info?.role === "assistant" && typeof info.providerID === "string" && info.providerID + && typeof info.modelID === "string" && info.modelID) { + return { providerID: info.providerID, modelID: info.modelID } + } + } + return null + } catch { + return null + } +} + function updateLiveSession(session: Session, directory?: string): void { const stores = _childStores if (!stores) return diff --git a/packages/vscode/src/bridge-settings-runtime.ts b/packages/vscode/src/bridge-settings-runtime.ts index aa4049ab..86b80022 100644 --- a/packages/vscode/src/bridge-settings-runtime.ts +++ b/packages/vscode/src/bridge-settings-runtime.ts @@ -291,7 +291,7 @@ export const persistSettings = async (changes: Record, ctx?: Br const keysToClear = new Set(); - for (const key of ['defaultModel', 'defaultVariant', 'defaultAgent', 'defaultGitIdentityId', 'opencodeBinary']) { + for (const key of ['defaultModel', 'defaultVariant', 'defaultAgent', 'defaultGitIdentityId', 'opencodeBinary', 'smallModelOverride']) { const value = restChanges[key]; if (typeof value === 'string' && value.trim().length === 0) { keysToClear.add(key); @@ -299,6 +299,14 @@ export const persistSettings = async (changes: Record, ctx?: Br } } + if ('smallModelUseDefault' in restChanges && typeof restChanges.smallModelUseDefault !== 'boolean') { + delete restChanges.smallModelUseDefault; + } + + if ('sessionAssistEnabled' in restChanges && typeof restChanges.sessionAssistEnabled !== 'boolean') { + delete restChanges.sessionAssistEnabled; + } + if (typeof restChanges.usageAutoRefresh !== 'boolean') { delete restChanges.usageAutoRefresh; } diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 58e12a36..fea5d94b 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -72,6 +72,7 @@ import { createOpenCodeResolutionRuntime } from './lib/opencode/opencode-resolut import { createBootstrapRuntime } from './lib/opencode/bootstrap-runtime.js'; import { createSessionRuntime } from './lib/opencode/session-runtime.js'; import { createOpenCodeWatcherRuntime } from './lib/opencode/watcher.js'; +import { createSessionAssistRuntime } from './lib/session-assist/runtime.js'; import { createScheduledTasksRuntime } from './lib/scheduled-tasks/runtime.js'; import { createServerStartupRuntime } from './lib/opencode/server-startup-runtime.js'; import { createTunnelWiringRuntime } from './lib/opencode/tunnel-wiring-runtime.js'; @@ -713,6 +714,12 @@ const maybeSendPushForTrigger = (...args) => notificationTriggerRuntime.maybeSen const setAutoAcceptSession = (...args) => notificationTriggerRuntime.setAutoAcceptSession(...args); clearPendingPushBadge = () => notificationTriggerRuntime.clearPendingPushBadge(); +const sessionAssistRuntime = createSessionAssistRuntime({ + buildOpenCodeUrl, + getOpenCodeAuthHeaders, + getSmallModelService: async () => import('./lib/small-model/index.js'), +}); + const globalMessageStreamHub = createGlobalMessageStreamHub({ buildOpenCodeUrl, getOpenCodeAuthHeaders, @@ -732,6 +739,19 @@ const openCodeWatcherRuntime = createOpenCodeWatcherRuntime({ }, }); +// Session-assist subscribes to the hub directly: it needs the envelope's +// directory to route its own OpenCode calls to the right instance. +console.log('[session-assist] listening for session events'); +globalMessageStreamHub.subscribeEvent((event) => { + const raw = event?.payload; + const payload = raw?.payload && typeof raw.payload === 'object' ? raw.payload : raw; + if (!payload || typeof payload !== 'object') return; + const directory = typeof event?.directory === 'string' && event.directory && event.directory !== 'global' + ? event.directory + : ''; + sessionAssistRuntime.processPayload(payload, directory); +}); + const processForwardedEventPayload = (payload, emitSyntheticEvent) => { if (!payload || typeof payload !== 'object' || typeof emitSyntheticEvent !== 'function') { return; @@ -1014,11 +1034,12 @@ const bootstrapOpenCodeAtStartup = async (...args) => { if (openCodeLifecycleState.openCodeProcess && !openCodeLifecycleState.isExternalOpenCode) { startHealthMonitoring(); } - if (ENV_DESKTOP_NOTIFY) { - void ensureGlobalWatcherStarted().catch((error) => { - console.warn(`Global event watcher startup failed: ${error?.message || error}`); - }); - } + // The global watcher used to start only for desktop notifications; the + // session-assist runtime also rides its event hub, so it now starts + // unconditionally once OpenCode is up. + void ensureGlobalWatcherStarted().catch((error) => { + console.warn(`Global event watcher startup failed: ${error?.message || error}`); + }); }; const killProcessOnPort = (...args) => openCodeLifecycleRuntime.killProcessOnPort(...args); const waitForPortRelease = (...args) => openCodeLifecycleRuntime.waitForPortRelease(...args); @@ -1037,6 +1058,7 @@ const gracefulShutdownRuntime = createGracefulShutdownRuntime({ }, syncToHmrState, openCodeWatcherRuntime, + sessionAssistRuntime, sessionRuntime, getHealthCheckInterval: () => healthCheckInterval, clearHealthCheckInterval: (value) => clearInterval(value), diff --git a/packages/web/server/lib/opencode/core-routes.js b/packages/web/server/lib/opencode/core-routes.js index 697fe528..1c203ed1 100644 --- a/packages/web/server/lib/opencode/core-routes.js +++ b/packages/web/server/lib/opencode/core-routes.js @@ -759,6 +759,7 @@ export const registerCommonRequestMiddleware = (app, dependencies) => { req.path.startsWith('/api/push') || req.path.startsWith('/api/notifications') || req.path.startsWith('/api/session-folders') || + req.path.startsWith('/api/small-model') || req.path.startsWith('/api/text') || req.path.startsWith('/api/voice') || req.path.startsWith('/api/tts') || diff --git a/packages/web/server/lib/opencode/feature-routes-runtime.js b/packages/web/server/lib/opencode/feature-routes-runtime.js index 25de723f..28147717 100644 --- a/packages/web/server/lib/opencode/feature-routes-runtime.js +++ b/packages/web/server/lib/opencode/feature-routes-runtime.js @@ -1,5 +1,6 @@ import { registerFsRoutes } from '../fs/routes.js'; import { registerQuotaRoutes } from '../quota/routes.js'; +import { registerSmallModelRoutes } from '../small-model/routes.js'; import { registerGitHubRoutes } from '../github/routes.js'; import { registerGitRoutes } from '../git/routes.js'; import { registerMagicPromptRoutes } from '../magic-prompts/routes.js'; @@ -54,6 +55,14 @@ export const createFeatureRoutesRuntime = (dependencies) => { return quotaProviders; }; + let smallModelService = null; + const getSmallModelService = async () => { + if (!smallModelService) { + smallModelService = await import('../small-model/index.js'); + } + return smallModelService; + }; + const registerRoutes = async (app, routeDependencies) => { const { crypto, @@ -226,6 +235,7 @@ export const createFeatureRoutesRuntime = (dependencies) => { }); registerQuotaRoutes(app, { getQuotaProviders }); + registerSmallModelRoutes(app, { getSmallModelService }); registerGitHubRoutes(app); registerGitRoutes(app); registerMagicPromptRoutes(app, { diff --git a/packages/web/server/lib/opencode/models-metadata.js b/packages/web/server/lib/opencode/models-metadata.js new file mode 100644 index 00000000..0d37a629 --- /dev/null +++ b/packages/web/server/lib/opencode/models-metadata.js @@ -0,0 +1,61 @@ +const MODELS_DEV_API_URL = 'https://models.dev/api.json'; +const DEFAULT_TTL_MS = 10 * 60 * 1000; +const DEFAULT_TIMEOUT_MS = 8000; + +// Shared in-process cache of the models.dev catalog. Used by the +// /api/openchamber/models-metadata route and the small-model resolver so the +// server fetches the catalog once, not per consumer. +let cachedMetadata = null; +let cachedAt = 0; +let inflight = null; + +const fetchCatalog = async (url, timeoutMs) => { + const response = await fetch(url, { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(timeoutMs), + }); + if (!response.ok) { + throw new Error(`models.dev responded with status ${response.status}`); + } + const metadata = await response.json(); + if (!metadata || typeof metadata !== 'object') { + throw new Error('models.dev returned an unexpected payload'); + } + return metadata; +}; + +/** + * Returns the models.dev catalog, serving the in-memory copy while fresh. + * On fetch failure a stale cached copy is returned when available; otherwise + * the error propagates. + */ +export async function getModelsMetadata({ + url = MODELS_DEV_API_URL, + ttlMs = DEFAULT_TTL_MS, + timeoutMs = DEFAULT_TIMEOUT_MS, +} = {}) { + const now = Date.now(); + if (cachedMetadata && now - cachedAt < ttlMs) { + return { metadata: cachedMetadata, fromCache: true }; + } + + if (!inflight) { + inflight = fetchCatalog(url, timeoutMs).finally(() => { + inflight = null; + }); + } + + try { + const metadata = await inflight; + cachedMetadata = metadata; + cachedAt = Date.now(); + return { metadata, fromCache: false }; + } catch (error) { + if (cachedMetadata) { + return { metadata: cachedMetadata, fromCache: true, stale: true }; + } + throw error; + } +} + +export { MODELS_DEV_API_URL }; diff --git a/packages/web/server/lib/opencode/openchamber-routes.js b/packages/web/server/lib/opencode/openchamber-routes.js index 08b22790..21694abb 100644 --- a/packages/web/server/lib/opencode/openchamber-routes.js +++ b/packages/web/server/lib/opencode/openchamber-routes.js @@ -13,9 +13,6 @@ export const registerOpenChamberRoutes = (app, dependencies) => { getCachedZenModels, } = dependencies; - let cachedModelsMetadata = null; - let cachedModelsMetadataTimestamp = 0; - app.get('/api/openchamber/update-check', async (req, res) => { try { const { checkForUpdates } = await import('../package-manager.js'); @@ -254,48 +251,18 @@ export const registerOpenChamberRoutes = (app, dependencies) => { }); app.get('/api/openchamber/models-metadata', async (_req, res) => { - const now = Date.now(); - - if (cachedModelsMetadata && now - cachedModelsMetadataTimestamp < modelsMetadataCacheTtl) { - res.setHeader('Cache-Control', 'public, max-age=60'); - return res.json(cachedModelsMetadata); - } - - const controller = typeof AbortController !== 'undefined' ? new AbortController() : null; - const timeout = controller ? setTimeout(() => controller.abort(), 8000) : null; - try { - const response = await fetch(modelsDevApiUrl, { - signal: controller?.signal, - headers: { - Accept: 'application/json' - } + const { getModelsMetadata } = await import('./models-metadata.js'); + const { metadata, fromCache, stale } = await getModelsMetadata({ + url: modelsDevApiUrl, + ttlMs: modelsMetadataCacheTtl, }); - - if (!response.ok) { - throw new Error(`models.dev responded with status ${response.status}`); - } - - const metadata = await response.json(); - cachedModelsMetadata = metadata; - cachedModelsMetadataTimestamp = Date.now(); - - res.setHeader('Cache-Control', 'public, max-age=300'); + res.setHeader('Cache-Control', fromCache && !stale ? 'public, max-age=60' : 'public, max-age=300'); res.json(metadata); } catch (error) { console.warn('Failed to fetch models.dev metadata via server:', error); - - if (cachedModelsMetadata) { - res.setHeader('Cache-Control', 'public, max-age=60'); - res.json(cachedModelsMetadata); - } else { - const statusCode = error?.name === 'AbortError' ? 504 : 502; - res.status(statusCode).json({ error: 'Failed to retrieve model metadata' }); - } - } finally { - if (timeout) { - clearTimeout(timeout); - } + const statusCode = error?.name === 'TimeoutError' || error?.name === 'AbortError' ? 504 : 502; + res.status(statusCode).json({ error: 'Failed to retrieve model metadata' }); } }); diff --git a/packages/web/server/lib/opencode/settings-helpers.js b/packages/web/server/lib/opencode/settings-helpers.js index 087b7f8a..e868449e 100644 --- a/packages/web/server/lib/opencode/settings-helpers.js +++ b/packages/web/server/lib/opencode/settings-helpers.js @@ -245,6 +245,9 @@ export const createSettingsHelpers = (dependencies) => { if (typeof candidate.showReasoningTraces === 'boolean') { result.showReasoningTraces = candidate.showReasoningTraces; } + if (typeof candidate.sessionAssistEnabled === 'boolean') { + result.sessionAssistEnabled = candidate.sessionAssistEnabled; + } if (typeof candidate.collapsibleThinkingBlocks === 'boolean') { result.collapsibleThinkingBlocks = candidate.collapsibleThinkingBlocks; } @@ -374,6 +377,13 @@ export const createSettingsHelpers = (dependencies) => { const trimmed = candidate.defaultAgent.trim(); result.defaultAgent = trimmed.length > 0 ? trimmed : undefined; } + if (typeof candidate.smallModelUseDefault === 'boolean') { + result.smallModelUseDefault = candidate.smallModelUseDefault; + } + if (typeof candidate.smallModelOverride === 'string') { + const trimmed = candidate.smallModelOverride.trim(); + result.smallModelOverride = trimmed.length > 0 ? trimmed : undefined; + } if (typeof candidate.defaultGitIdentityId === 'string') { const trimmed = candidate.defaultGitIdentityId.trim(); result.defaultGitIdentityId = trimmed.length > 0 ? trimmed : undefined; diff --git a/packages/web/server/lib/opencode/shutdown-runtime.js b/packages/web/server/lib/opencode/shutdown-runtime.js index 6f568649..acb6e48b 100644 --- a/packages/web/server/lib/opencode/shutdown-runtime.js +++ b/packages/web/server/lib/opencode/shutdown-runtime.js @@ -8,6 +8,7 @@ export const createGracefulShutdownRuntime = (dependencies) => { syncToHmrState, openCodeWatcherRuntime, sessionRuntime, + sessionAssistRuntime, scheduledTasksRuntime, getHealthCheckInterval, clearHealthCheckInterval, @@ -41,6 +42,7 @@ export const createGracefulShutdownRuntime = (dependencies) => { openCodeWatcherRuntime.stop(); sessionRuntime.dispose(); + sessionAssistRuntime?.stop?.(); scheduledTasksRuntime?.stop?.(); const healthCheckInterval = getHealthCheckInterval(); diff --git a/packages/web/server/lib/session-assist/DOCUMENTATION.md b/packages/web/server/lib/session-assist/DOCUMENTATION.md new file mode 100644 index 00000000..72f2bcf1 --- /dev/null +++ b/packages/web/server/lib/session-assist/DOCUMENTATION.md @@ -0,0 +1,64 @@ +# Session Assist + +Server-side watcher that generates a short recap of the agent's last reply +and one suggested user follow-up with the small model +(`lib/small-model`), storing both on the session's metadata under +`metadata.openchamber.assist`. + +## Flow + +1. `createSessionAssistRuntime` is a consumer of the server's global SSE + fan-out (`index.js` → `onPayload`), riding the same upstream connection as + notifications. Purely event-driven — dormant sessions never generate + anything, there is no backfill and no session scanning. +2. `session.status: idle` arms a 60-second per-session timer; any `busy`/ + `retry` status or a user `message.updated` clears it (the "1 minute of + quiet" rule). +3. On fire: fetch the session (skip sub-agent sessions with `parentID`), + take the LAST exchange only — the final assistant reply plus the user + message it answered (assistant `parentID` → user id) — and call + `generateSmallModelText` with the + session's own provider/model taken from the last assistant message — so + the utility call spends the same subscription as the conversation. + `restrictToPreferredProvider` forbids the resolver's global fallback: + conversation content never goes to a provider the user didn't pick for + the session, unless the small model was chosen explicitly (settings + override or opencode config). A resolver 404 is silently skipped. +4. The `{recap, suggestion}` JSON is clamped and PATCHed onto the session + metadata together with `forMessageID` (the last assistant message id) and + `generatedAt`. Before writing, the session tail is re-checked (a stale + result is dropped) and the metadata is merged from a fresh session read so + concurrent metadata writes made during generation are preserved. + +## Settings gate + +`sessionAssistEnabled` in OpenChamber settings (Settings → Chat, default on) +is a hard generation switch checked at fire time: when off, no small-model +calls run and nothing is written. Existing payloads keep rendering and can +still be dismissed — the switch is about generation, not visibility. + +## Freshness contract (no clearing writes) + +Clients do not need the payload to be deleted: they render it only while +`assist.forMessageID` still equals the session's last assistant message id +(and the session is idle). Any new message invalidates the payload +everywhere instantly and offline; the next idle cycle overwrites it. + +## UI consumers (packages/ui) + +- `lib/sessionAssistMetadata.ts` — payload parsing. +- `hooks/useSessionAssist.ts` — freshness gating + the 5-minute quiet window + for the recap (single timeout to the boundary, no polling). +- `components/chat/SessionRecapSpacer.tsx` — renders the recap inside the + fixed-height reserved gap under the last message (height never changes). +- `components/chat/SessionSuggestionChip.tsx` — one tappable suggestion chip + near the composer (desktop chips row + above the mobile pill); hidden as + soon as the composer has any content. Tap fills the input, never sends. + +## Limitations + +- The watcher lives in the web server, so VS Code (extension-only, no web + server) does not generate assists; it still renders payloads produced by a + web/desktop instance of the same OpenCode server via `session.updated`. +- Metadata payloads ride every `session.updated` event — keep the clamps + (`RECAP_CHAR_LIMIT`, `SUGGESTION_CHAR_LIMIT`) small. diff --git a/packages/web/server/lib/session-assist/runtime.js b/packages/web/server/lib/session-assist/runtime.js new file mode 100644 index 00000000..5843c289 --- /dev/null +++ b/packages/web/server/lib/session-assist/runtime.js @@ -0,0 +1,349 @@ +// Session assist: after a session goes idle and stays quiet, generate a short +// recap of the agent's last reply plus one suggested user follow-up with the +// small model, and store both on the session's metadata +// (metadata.openchamber.assist). Clients decide visibility from +// assist.forMessageID — a new message makes the payload stale everywhere +// without any extra writes. +// +// Purely event-driven: only sessions that transition busy→idle while the +// server is running ever generate anything. No backfill, no session scans. + +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +const OPENCHAMBER_SETTINGS_FILE = path.join( + process.env.OPENCHAMBER_DATA_DIR + ? path.resolve(process.env.OPENCHAMBER_DATA_DIR) + : path.join(os.homedir(), '.config', 'openchamber'), + 'settings.json', +); + +// The Chat setting is a hard generation switch (default on): when off, no +// small-model calls and no metadata writes happen at all. Existing payloads +// stay untouched — clients keep showing them and dismissal still works. +const isSessionAssistEnabled = () => { + try { + const raw = fs.readFileSync(OPENCHAMBER_SETTINGS_FILE, 'utf8'); + const settings = JSON.parse(raw); + return settings?.sessionAssistEnabled !== false; + } catch { + return true; + } +}; + +const IDLE_QUIET_MS = 60_000; +const TRANSCRIPT_MESSAGE_LIMIT = 12; +const TRANSCRIPT_PART_CHAR_LIMIT = 6_000; +const RECAP_CHAR_LIMIT = 320; +const SUGGESTION_CHAR_LIMIT = 500; +const FETCH_TIMEOUT_MS = 5_000; + +const ASSIST_SYSTEM_PROMPT = [ + 'You assist a user who chats with a coding agent. Based on the conversation transcript, return exactly one JSON object and nothing else — no prose, no markdown, no code fences.', + 'Shape: {"recap": string, "suggestion": string}', + 'recap: at most 20 words. State the substance directly — the facts, result, or conclusion, plus the next move if there is one. NEVER narrate ("The assistant explained…", "The agent did…") — write the content itself, like a note the user jotted down.', + 'suggestion: the next message to send in this conversation, addressed TO the agent — a concise instruction or question that moves the work forward, e.g. "Run the tests and fix failures" / "Commit this". Imperative or question form. Never explain, never offer help, never say "you can".', + 'Both values MUST be written in the same language as the conversation text itself. Ignore any other language preferences or personalization you may have — only the conversation text decides the language.', + 'Use double quotes for JSON strings, no trailing commas.', +].join('\n'); + +const extractJsonObject = (value) => { + const text = String(value ?? '').trim(); + const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i); + const candidate = (fenced?.[1] ?? text).trim(); + const start = candidate.indexOf('{'); + if (start < 0) return null; + for (let end = candidate.length; end > start; end -= 1) { + if (candidate[end - 1] !== '}') continue; + try { + const parsed = JSON.parse(candidate.slice(start, end)); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed; + } + } catch { + // keep scanning — models wrap JSON in prose sometimes + } + } + return null; +}; + +const extractSessionStatus = (payload) => { + if (!payload || payload.type !== 'session.status') return null; + const properties = payload.properties && typeof payload.properties === 'object' ? payload.properties : {}; + const status = properties.status && typeof properties.status === 'object' ? properties.status : {}; + const info = properties.info && typeof properties.info === 'object' ? properties.info : {}; + const sessionId = typeof properties.sessionID === 'string' ? properties.sessionID.trim() : ''; + const type = typeof status.type === 'string' + ? status.type.trim() + : (typeof info.type === 'string' ? info.type.trim() : ''); + if (!sessionId || !type) return null; + const directory = typeof properties.directory === 'string' && properties.directory + ? properties.directory + : (typeof info.directory === 'string' ? info.directory : ''); + return { sessionId, type, directory }; +}; + +const extractUserMessage = (payload) => { + if (!payload || payload.type !== 'message.updated') return null; + const info = payload.properties?.info; + if (!info || typeof info !== 'object' || info.role !== 'user') return null; + if (typeof info.sessionID !== 'string' || !info.sessionID) return null; + return { + sessionId: info.sessionID, + createdAt: typeof info.time?.created === 'number' ? info.time.created : 0, + }; +}; + +const messagePartsToText = (message) => { + const parts = Array.isArray(message?.parts) ? message.parts : []; + return parts + .map((part) => (part?.type === 'text' && typeof part.text === 'string' ? part.text : '')) + .filter(Boolean) + .join('\n') + .slice(0, TRANSCRIPT_PART_CHAR_LIMIT); +}; + +export const createSessionAssistRuntime = ({ + buildOpenCodeUrl, + getOpenCodeAuthHeaders, + getSmallModelService, + quietMs = IDLE_QUIET_MS, +}) => { + const timers = new Map(); + const inflight = new Set(); + let stopped = false; + + const clearTimer = (sessionId) => { + const existing = timers.get(sessionId); + if (existing) { + clearTimeout(existing.timer); + timers.delete(sessionId); + } + }; + + const openCodeFetch = async (path, { directory, method = 'GET', body } = {}) => { + const base = buildOpenCodeUrl(path, ''); + const url = directory ? `${base}?directory=${encodeURIComponent(directory)}` : base; + const response = await fetch(url, { + method, + headers: { + Accept: 'application/json', + ...(body ? { 'Content-Type': 'application/json' } : {}), + ...getOpenCodeAuthHeaders(), + }, + ...(body ? { body: JSON.stringify(body) } : {}), + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + if (!response.ok) { + throw new Error(`OpenCode ${method} ${path} failed with ${response.status}`); + } + return response.json().catch(() => null); + }; + + const fetchRecentMessages = async (sessionId, directory) => { + const base = buildOpenCodeUrl(`/session/${encodeURIComponent(sessionId)}/message`, ''); + const params = new URLSearchParams({ limit: String(TRANSCRIPT_MESSAGE_LIMIT) }); + if (directory) params.set('directory', directory); + const response = await fetch(`${base}?${params.toString()}`, { + method: 'GET', + headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() }, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + if (!response.ok) return null; + const messages = await response.json().catch(() => null); + return Array.isArray(messages) ? messages : null; + }; + + const generateAssist = async (sessionId, directory) => { + if (!isSessionAssistEnabled()) return; + const session = await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, { directory }) + .catch((error) => { + console.warn(`[session-assist] session fetch failed: ${error?.message || error}`); + return null; + }); + if (!session || typeof session !== 'object') return; + // Sub-agent/task sessions never surface in chat — skip them. + if (typeof session.parentID === 'string' && session.parentID) return; + + const messages = await fetchRecentMessages(sessionId, directory); + if (!messages || messages.length === 0) { + console.warn('[session-assist] no messages fetched'); + return; + } + + let lastAssistant = null; + for (let i = messages.length - 1; i >= 0; i -= 1) { + const info = messages[i]?.info; + if (info?.role === 'assistant') { + lastAssistant = messages[i]; + break; + } + } + const lastAssistantInfo = lastAssistant?.info; + if (!lastAssistantInfo?.id) return; + + // Only the last exchange: the assistant reply plus the user message it + // answered (assistant info.parentID → user info.id). Everything else is + // token waste for a one-line recap and a single suggestion. + const parentUserMessage = typeof lastAssistantInfo.parentID === 'string' && lastAssistantInfo.parentID + ? messages.find((message) => message?.info?.id === lastAssistantInfo.parentID && message?.info?.role === 'user') + : null; + const userText = parentUserMessage ? messagePartsToText(parentUserMessage) : ''; + const assistantText = messagePartsToText(lastAssistant); + const transcript = [ + userText ? `User:\n${userText}` : '', + assistantText ? `Assistant:\n${assistantText}` : '', + ].filter(Boolean).join('\n\n'); + if (!transcript) return; + + const { generateSmallModelText } = await getSmallModelService(); + // Instruct the language by example, not by description — account-side + // personalization (e.g. the ChatGPT backend knowing the user's locale) + // otherwise leaks a different language into the output. + const languageSample = (userText || assistantText).slice(0, 200).replace(/\s+/g, ' ').trim(); + let generated; + try { + generated = await generateSmallModelText({ + // Background feature: conversation content must never leave the + // session's own provider unless the user explicitly picked a small + // model (settings override / opencode config). + restrictToPreferredProvider: true, + prompt: `The latest exchange in the conversation:\n\n${transcript}\n\nWrite recap and suggestion in the SAME language as this sample from the conversation: "${languageSample}"`, + system: ASSIST_SYSTEM_PROMPT, + directory, + preferredProviderID: typeof lastAssistantInfo.providerID === 'string' ? lastAssistantInfo.providerID : undefined, + preferredModelID: typeof lastAssistantInfo.modelID === 'string' ? lastAssistantInfo.modelID : undefined, + }); + } catch (error) { + // No authenticated provider (404) or a transient model failure — this is + // background sugar, never retry loops or logs spam. + if (Number(error?.statusCode) !== 404) { + console.warn('[session-assist] generation failed:', error?.message || error); + } + return; + } + + const structured = extractJsonObject(generated?.text); + let recap = typeof structured?.recap === 'string' ? structured.recap.trim().slice(0, RECAP_CHAR_LIMIT) : ''; + let suggestion = typeof structured?.suggestion === 'string' ? structured.suggestion.trim().slice(0, SUGGESTION_CHAR_LIMIT) : ''; + + // Hard guard against language hallucination: if the conversation contains + // no Cyrillic/CJK at all, the output must not either (and drop per-field, + // so one hallucinated field doesn't kill the other). + const hasCyrillic = (text) => /[\u0400-\u04FF]/.test(text); + const hasCjk = (text) => /[\u3040-\u30FF\u4E00-\u9FFF\uAC00-\uD7AF]/.test(text); + const inputText = `${userText}\n${assistantText}`; + const scriptMismatch = (text) => (hasCyrillic(text) && !hasCyrillic(inputText)) + || (hasCjk(text) && !hasCjk(inputText)); + if (recap && scriptMismatch(recap)) { + console.warn('[session-assist] dropped recap: language mismatch with conversation'); + recap = ''; + } + if (suggestion && scriptMismatch(suggestion)) { + console.warn('[session-assist] dropped suggestion: language mismatch with conversation'); + suggestion = ''; + } + if (!recap && !suggestion) return; + + // The session may have moved on while we generated — a stale patch would + // flash outdated content, so re-check the tail before writing. + const latest = await fetchRecentMessages(sessionId, directory); + const latestAssistantId = (() => { + if (!latest) return null; + for (let i = latest.length - 1; i >= 0; i -= 1) { + const info = latest[i]?.info; + if (info?.role === 'assistant') return info.id; + if (info?.role === 'user') return null; + } + return null; + })(); + if (latestAssistantId !== lastAssistantInfo.id) { + console.log('[session-assist] tail moved on, dropping result'); + return; + } + + // Merge from a FRESH read: generation takes tens of seconds, and merging + // from the session snapshot fetched before it would clobber any metadata + // written meanwhile (suggestion dismissals, review links, …). + const freshSession = await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, { directory }) + .catch(() => null); + const currentMetadata = freshSession?.metadata && typeof freshSession.metadata === 'object' + ? freshSession.metadata + : (session.metadata && typeof session.metadata === 'object' ? session.metadata : {}); + const currentNamespace = currentMetadata.openchamber && typeof currentMetadata.openchamber === 'object' + ? currentMetadata.openchamber + : {}; + + console.log(`[session-assist] generated for ${sessionId} via ${generated.providerID}/${generated.modelID}`); + await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, { + directory, + method: 'PATCH', + body: { + metadata: { + ...currentMetadata, + openchamber: { + ...currentNamespace, + assist: { + recap, + suggestion, + forMessageID: lastAssistantInfo.id, + generatedAt: Date.now(), + }, + }, + }, + }, + }); + }; + + const armTimer = (sessionId, directory) => { + clearTimer(sessionId); + const timer = setTimeout(() => { + timers.delete(sessionId); + if (stopped || inflight.has(sessionId)) return; + inflight.add(sessionId); + generateAssist(sessionId, directory) + .catch((error) => { + console.warn('[session-assist] failed:', error?.message || error); + }) + .finally(() => { + inflight.delete(sessionId); + }); + }, quietMs); + if (typeof timer?.unref === 'function') timer.unref(); + timers.set(sessionId, { timer, armedAt: Date.now() }); + }; + + const processPayload = (payload, directoryHint = '') => { + if (stopped) return; + const status = extractSessionStatus(payload); + if (status) { + if (status.type === 'idle') { + armTimer(status.sessionId, status.directory || directoryHint); + } else { + clearTimer(status.sessionId); + } + return; + } + const userMessage = extractUserMessage(payload); + if (userMessage) { + // OpenCode re-emits message.updated for OLD user messages after the + // session settles (post-completion metadata patches). Only a message + // created after the timer was armed means the user actually moved on. + const armed = timers.get(userMessage.sessionId); + if (armed && userMessage.createdAt >= armed.armedAt) { + clearTimer(userMessage.sessionId); + } + } + }; + + const stop = () => { + stopped = true; + for (const { timer } of timers.values()) { + clearTimeout(timer); + } + timers.clear(); + }; + + return { processPayload, stop }; +}; diff --git a/packages/web/server/lib/small-model/DOCUMENTATION.md b/packages/web/server/lib/small-model/DOCUMENTATION.md new file mode 100644 index 00000000..c757d07b --- /dev/null +++ b/packages/web/server/lib/small-model/DOCUMENTATION.md @@ -0,0 +1,78 @@ +# Small Model + +Server-side direct LLM calls that reuse the user's existing OpenCode provider +logins (`~/.local/share/opencode/auth.json`). OpenCode uses a "small model" +internally (titles, summaries) but does not expose it through the SDK or +plugins — this module replicates that mechanism as an OpenChamber runtime API. + +## Security boundary + +Credentials never leave the server process. The client sends only a prompt; +auth resolution, OAuth refresh, and provider dispatch all happen server-side. +Routes live under `/api/*` and are gated by the ui-auth middleware like every +other runtime API. + +## Files + +- `index.js` — orchestration: `generateSmallModelText()` / `describeSmallModel()`. +- `resolve.js` — model selection, mirroring OpenCode's `getSmallModel` chain: + 0. OpenChamber's own settings override (Settings → Sessions → Small Model): + when `smallModelUseDefault` is `false`, `smallModelOverride` + (`provider/model`) outranks everything below. Sanitized in + `settings-helpers.js` (server), `persistence.ts` (client), and + `bridge-settings-runtime.ts` (VS Code). + 1. `small_model` from the merged OpenCode config layers (`provider/model`). + 2. Family-priority scan (`gemini-flash` → `gpt-nano` → `claude-haiku`) + **within the session's provider first** (`preferredProviderID`, like + OpenCode resolves within the current provider), then over the other + providers with a usable auth entry, newest `release_date` first. + 3. GitHub Copilot hidden utility models (`gpt-*-nano/mini`) — these never + appear in the catalog, so they participate as the `gpt-nano` family entry + and as a final utility fallback. + 4. Last resort: the session's own model (`preferredModelID`) when no small + model resolves anywhere — costlier, but always valid. +- Input clamp: the prompt is truncated to the resolved model's catalog + `limit.context` (minus an output reserve, ~4 chars/token estimate; + conservative default when the model is not in the catalog). Truncation is + reported as `inputTruncated: true` in the response. +- `call.js` — wire formats and per-provider auth, replicating OpenCode's + plugin auth loaders: + - **GitHub Copilot**: OpenAI-compatible `/chat/completions` on + `https://api.githubcopilot.com` (or `copilot-api.`) with the + stored device-OAuth token as the bearer — no token exchange, no expiry. + - **OpenAI OAuth (ChatGPT plan)**: streaming Responses API on + `https://chatgpt.com/backend-api/codex/responses` with + `ChatGPT-Account-Id`; expired tokens are refreshed against + `auth.openai.com` (single-flight) and written back to `auth.json`. + - **Anthropic** (`type: api`): `/v1/messages` with `x-api-key`. + - **Google** (`type: api`): `generateContent` with `x-goog-api-key`. + - Everything else: OpenAI-compatible `/chat/completions` against the + provider's models.dev base URL with `Authorization: Bearer `. +- `catalog.js` — models.dev catalog via the shared in-process cache + (`../opencode/models-metadata.js`, also serving + `/api/openchamber/models-metadata`). +- `routes.js` — `GET /api/small-model` (resolution preview) and + `POST /api/small-model/generate` (`{ prompt, system?, maxOutputTokens?, + model?, directory? }` → `{ text, providerID, modelID, source }`). + +## Registration + +Mounted lazily from `feature-routes-runtime.js` (same pattern as quota): the +module is imported on first request, not at server startup. + +## Known limitations + +- OpenCode's free models (`opencode/big-pickle`, `*-free`) work without a + token only through OpenCode's own server — direct calls are rejected, and + piggybacking on their subsidized infra is out of bounds by design. Every + resolution step therefore requires a usable auth entry for the provider: + a session on an unauthenticated `opencode` provider falls through to the + global scan (or a clean 404 on a vanilla setup with no logins). + +- Anthropic OAuth (Claude Pro/Max) entries are not supported — OpenCode itself + keeps those outside `auth.json` in this generation; only `type: api` keys + work for Anthropic. +- Amazon Bedrock, GitLab, Azure and other credential-chain providers are out + of scope; they need more than a key/token (regions, resource names). +- Responses from the codex backend are collected from the SSE stream; the + endpoint itself is non-streaming by design (small utility calls). diff --git a/packages/web/server/lib/small-model/call.js b/packages/web/server/lib/small-model/call.js new file mode 100644 index 00000000..8fd81166 --- /dev/null +++ b/packages/web/server/lib/small-model/call.js @@ -0,0 +1,380 @@ +import { readAuthFile, writeAuthFile } from '../opencode/auth.js'; +import { getCatalogProvider } from './catalog.js'; +import { getAuthEntryForProvider } from './resolve.js'; + +// Direct, non-streaming text generation against the provider APIs, replicating +// how OpenCode authenticates each of them (see the plugin auth loaders in the +// opencode repo). auth.json credentials never leave this process. + +const REQUEST_TIMEOUT_MS = 60_000; +// Generous default: thinking models that can't be switched off (DeepSeek, +// Qwen, …) spend part of this budget on reasoning before the actual answer. +const DEFAULT_MAX_OUTPUT_TOKENS = 4_000; + +const USER_AGENT = 'opencode/1.0 openchamber'; + +const CODEX_TOKEN_URL = 'https://auth.openai.com/oauth/token'; +const CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'; +const CODEX_RESPONSES_URL = 'https://chatgpt.com/backend-api/codex/responses'; + +const httpError = async (response, provider) => { + const body = await response.text().catch(() => ''); + const snippet = body ? `: ${body.slice(0, 300)}` : ''; + return new Error(`${provider} request failed with ${response.status}${snippet}`); +}; + +// --------------------------------------------------------------------------- +// OpenAI OAuth (ChatGPT plan / codex) token refresh — single-flight, with the +// refreshed token written back to auth.json exactly like OpenCode does. +// --------------------------------------------------------------------------- + +let openaiRefreshPromise = null; + +const decodeJwtClaims = (token) => { + try { + const payload = token.split('.')[1]; + return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); + } catch { + return null; + } +}; + +const extractChatgptAccountId = (accessToken) => { + const claims = decodeJwtClaims(accessToken); + const auth = claims?.['https://api.openai.com/auth']; + const value = auth?.chatgpt_account_id; + return typeof value === 'string' && value ? value : null; +}; + +const refreshOpenaiOauth = async (entry) => { + if (!openaiRefreshPromise) { + openaiRefreshPromise = (async () => { + const response = await fetch(CODEX_TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'refresh_token', + refresh_token: entry.refresh, + client_id: CODEX_CLIENT_ID, + }), + signal: AbortSignal.timeout(30_000), + }); + if (!response.ok) { + throw await httpError(response, 'OpenAI token refresh'); + } + const payload = await response.json(); + const access = typeof payload?.access_token === 'string' ? payload.access_token : ''; + if (!access) { + throw new Error('OpenAI token refresh returned no access token'); + } + const refreshed = { + ...entry, + type: 'oauth', + access, + refresh: typeof payload?.refresh_token === 'string' && payload.refresh_token + ? payload.refresh_token + : entry.refresh, + expires: Date.now() + (Number(payload?.expires_in) > 0 ? Number(payload.expires_in) : 3600) * 1000, + }; + const auth = readAuthFile(); + auth.openai = refreshed; + writeAuthFile(auth); + return refreshed; + })().finally(() => { + openaiRefreshPromise = null; + }); + } + return openaiRefreshPromise; +}; + +const ensureFreshOpenaiOauth = async (entry) => { + if (entry.access && Number(entry.expires) > Date.now()) { + return entry; + } + if (!entry.refresh) { + throw new Error('OpenAI OAuth entry has no refresh token'); + } + return refreshOpenaiOauth(entry); +}; + +// --------------------------------------------------------------------------- +// Wire formats +// --------------------------------------------------------------------------- + +const callOpenaiCompatible = async ({ baseURL, headers, modelID, prompt, system, maxOutputTokens, providerLabel, extraBody }) => { + const trimmedBase = baseURL.replace(/\/+$/, ''); + const response = await fetch(`${trimmedBase}/chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + ...headers, + }, + body: JSON.stringify({ + model: modelID, + messages: [ + ...(system ? [{ role: 'system', content: system }] : []), + { role: 'user', content: prompt }, + ], + max_tokens: maxOutputTokens, + stream: false, + ...(extraBody || {}), + }), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response.ok) { + throw await httpError(response, providerLabel); + } + const payload = await response.json(); + const message = payload?.choices?.[0]?.message; + + // Providers disagree on the content shape: plain string, an array of + // typed parts, or (thinking models) an empty content with the budget spent + // on reasoning_content. + let text = ''; + if (typeof message?.content === 'string') { + text = message.content; + } else if (Array.isArray(message?.content)) { + text = message.content + .map((part) => (typeof part?.text === 'string' ? part.text : '')) + .join(''); + } + if (!text.trim() && typeof message?.reasoning_content === 'string' && message.reasoning_content.trim()) { + const finishReason = payload?.choices?.[0]?.finish_reason; + throw new Error( + `${providerLabel} spent the output budget on reasoning and returned no answer` + + (finishReason ? ` (finish_reason: ${finishReason})` : ''), + ); + } + if (!text.trim()) { + throw new Error(`${providerLabel} returned no message content`); + } + return text; +}; + +const callAnthropic = async ({ apiKey, modelID, prompt, system, maxOutputTokens }) => { + const response = await fetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model: modelID, + max_tokens: maxOutputTokens, + ...(system ? { system } : {}), + messages: [{ role: 'user', content: prompt }], + }), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response.ok) { + throw await httpError(response, 'Anthropic'); + } + const payload = await response.json(); + const text = (payload?.content || []) + .filter((part) => part?.type === 'text' && typeof part.text === 'string') + .map((part) => part.text) + .join(''); + if (!text) { + throw new Error('Anthropic returned no text content'); + } + return text; +}; + +const callGoogle = async ({ apiKey, modelID, prompt, system, maxOutputTokens }) => { + const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(modelID)}:generateContent`; + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'x-goog-api-key': apiKey, + }, + body: JSON.stringify({ + contents: [{ role: 'user', parts: [{ text: prompt }] }], + ...(system ? { systemInstruction: { parts: [{ text: system }] } } : {}), + // thinkingBudget 0 switches Gemini Flash thinking off; Flash is the only + // family the small-model resolver picks for Google. + generationConfig: { maxOutputTokens, thinkingConfig: { thinkingBudget: 0 } }, + }), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response.ok) { + throw await httpError(response, 'Google'); + } + const payload = await response.json(); + const text = (payload?.candidates?.[0]?.content?.parts || []) + .map((part) => (typeof part?.text === 'string' ? part.text : '')) + .join(''); + if (!text) { + throw new Error('Google returned no text content'); + } + return text; +}; + +// ChatGPT-plan traffic goes to the codex backend, which only speaks the +// streaming Responses API — collect the output_text deltas from the SSE body. +const callCodexResponses = async ({ accessToken, accountId, modelID, prompt, system }) => { + const response = await fetch(CODEX_RESPONSES_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'text/event-stream', + Authorization: `Bearer ${accessToken}`, + ...(accountId ? { 'ChatGPT-Account-Id': accountId } : {}), + originator: 'opencode', + 'User-Agent': USER_AGENT, + }, + body: JSON.stringify({ + model: modelID, + ...(system ? { instructions: system } : {}), + input: [ + { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: prompt }], + }, + ], + // The codex backend rejects max_output_tokens (OpenCode forces it to + // undefined for this provider too). + stream: true, + store: false, + }), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response.ok) { + throw await httpError(response, 'OpenAI (ChatGPT plan)'); + } + + const raw = await response.text(); + let text = ''; + let completedText = ''; + for (const line of raw.split('\n')) { + if (!line.startsWith('data:')) continue; + const data = line.slice(5).trim(); + if (!data || data === '[DONE]') continue; + let event; + try { + event = JSON.parse(data); + } catch { + continue; + } + if (event?.type === 'response.output_text.delta' && typeof event.delta === 'string') { + text += event.delta; + } + if (event?.type === 'response.output_text.done' && typeof event.text === 'string') { + completedText = event.text; + } + if (event?.type === 'response.failed' || event?.type === 'error') { + const message = event?.response?.error?.message || event?.message || 'response failed'; + throw new Error(`OpenAI (ChatGPT plan) stream error: ${message}`); + } + } + const result = completedText || text; + if (!result) { + throw new Error('OpenAI (ChatGPT plan) returned no text output'); + } + return result; +}; + +// --------------------------------------------------------------------------- +// Dispatch +// --------------------------------------------------------------------------- + +export async function callSmallModel({ auth, catalog, providerID, modelID, prompt, system, maxOutputTokens }) { + const tokens = Number(maxOutputTokens) > 0 ? Number(maxOutputTokens) : DEFAULT_MAX_OUTPUT_TOKENS; + const entry = getAuthEntryForProvider(auth, providerID); + if (!entry) { + throw new Error(`No OpenCode login found for provider "${providerID}"`); + } + + if (providerID === 'github-copilot') { + // OpenCode uses the stored device-OAuth token directly as the bearer — + // access === refresh, no exchange, no expiry. + const token = entry.refresh || entry.access || entry.key; + if (!token) { + throw new Error('GitHub Copilot login has no token'); + } + const baseURL = entry.enterpriseUrl + ? `https://copilot-api.${String(entry.enterpriseUrl).replace(/^https?:\/\//, '').replace(/\/+$/, '')}` + : 'https://api.githubcopilot.com'; + return callOpenaiCompatible({ + baseURL, + headers: { + Authorization: `Bearer ${token}`, + 'User-Agent': USER_AGENT, + 'Openai-Intent': 'conversation-edits', + 'x-initiator': 'agent', + 'X-GitHub-Api-Version': '2026-06-01', + }, + modelID, + prompt, + system, + maxOutputTokens: tokens, + providerLabel: 'GitHub Copilot', + }); + } + + if (providerID === 'openai' && entry.type === 'oauth') { + const fresh = await ensureFreshOpenaiOauth(entry); + return callCodexResponses({ + accessToken: fresh.access, + accountId: fresh.accountId || extractChatgptAccountId(fresh.access), + modelID, + prompt, + system, + }); + } + + const apiKey = entry.type === 'api' ? entry.key + : entry.type === 'wellknown' ? entry.token + : entry.access; + if (!apiKey) { + throw new Error(`OpenCode login for "${providerID}" has no usable credential`); + } + + if (providerID === 'anthropic') { + return callAnthropic({ apiKey, modelID, prompt, system, maxOutputTokens: tokens }); + } + if (providerID === 'google') { + return callGoogle({ apiKey, modelID, prompt, system, maxOutputTokens: tokens }); + } + + // Everything else: OpenAI-compatible chat completions against the catalog's + // base URL for that provider (openai itself included). + const provider = getCatalogProvider(catalog, providerID); + const baseURL = providerID === 'openai' + ? 'https://api.openai.com/v1' + : typeof provider?.api === 'string' && provider.api + ? provider.api + : null; + if (!baseURL) { + throw new Error(`Provider "${providerID}" has no known API base URL`); + } + + // Thinking models burn the output budget on reasoning and leave content + // empty — disable thinking where a wire-format switch exists (mirrors + // OpenCode's smallOptions/variants special cases). There is NO universal + // parameter: unknown body fields 400 on some providers, so this stays an + // explicit allowlist. Models without a switch (DeepSeek, Qwen, Kimi, …) + // just get the generous output budget. + const lowerModel = modelID.toLowerCase(); + const supportsThinkingToggle = providerID.includes('zai') + || providerID.includes('zhipu') + || lowerModel.includes('glm') + || lowerModel.includes('minimax-m3'); + const extraBody = supportsThinkingToggle ? { thinking: { type: 'disabled' } } : undefined; + + return callOpenaiCompatible({ + baseURL, + headers: { Authorization: `Bearer ${apiKey}` }, + modelID, + prompt, + system, + maxOutputTokens: tokens, + providerLabel: provider?.name || providerID, + extraBody, + }); +} diff --git a/packages/web/server/lib/small-model/catalog.js b/packages/web/server/lib/small-model/catalog.js new file mode 100644 index 00000000..abfba4c4 --- /dev/null +++ b/packages/web/server/lib/small-model/catalog.js @@ -0,0 +1,13 @@ +import { getModelsMetadata } from '../opencode/models-metadata.js'; + +// The models.dev catalog is shared with the /api/openchamber/models-metadata +// route through one in-process cache — no extra fetches, no cache files. +export async function getModelCatalog() { + const { metadata } = await getModelsMetadata(); + return metadata; +} + +export function getCatalogProvider(catalog, providerID) { + const entry = catalog?.[providerID]; + return entry && typeof entry === 'object' ? entry : null; +} diff --git a/packages/web/server/lib/small-model/index.js b/packages/web/server/lib/small-model/index.js new file mode 100644 index 00000000..43457c32 --- /dev/null +++ b/packages/web/server/lib/small-model/index.js @@ -0,0 +1,167 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { readAuthFile } from '../opencode/auth.js'; +import { readConfigLayers } from '../opencode/shared.js'; +import { getModelCatalog } from './catalog.js'; +import { resolveSmallModel, parseModelRef, isUsableAuthEntry, getAuthEntryForProvider } from './resolve.js'; +import { callSmallModel } from './call.js'; + +const OPENCHAMBER_SETTINGS_FILE = path.join( + process.env.OPENCHAMBER_DATA_DIR + ? path.resolve(process.env.OPENCHAMBER_DATA_DIR) + : path.join(os.homedir(), '.config', 'openchamber'), + 'settings.json', +); + +// OpenChamber's own settings: when the user unchecks "use default small model" +// their explicit override outranks every other resolution step. +const readSmallModelSettingsOverride = () => { + try { + const raw = fs.readFileSync(OPENCHAMBER_SETTINGS_FILE, 'utf8'); + const settings = JSON.parse(raw); + if (!settings || typeof settings !== 'object') return null; + if (settings.smallModelUseDefault !== false) return null; + const override = typeof settings.smallModelOverride === 'string' ? settings.smallModelOverride.trim() : ''; + return override || null; + } catch { + return null; + } +}; + +// Rough safety clamp so a huge input never blows the model's context window. +// Token estimate is ~4 chars/token; when the catalog has no limit for the +// model (Copilot/codex utility models are not listed) a conservative default +// applies. +const DEFAULT_CONTEXT_TOKENS = 64_000; +const OUTPUT_RESERVE_TOKENS = 4_000; + +const clampPromptToModelLimit = ({ prompt, catalog, providerID, modelID }) => { + const limit = catalog?.[providerID]?.models?.[modelID]?.limit; + const contextTokens = Number(limit?.context) > 0 ? Number(limit.context) : DEFAULT_CONTEXT_TOKENS; + const inputBudgetTokens = Math.max(1_000, contextTokens - OUTPUT_RESERVE_TOKENS); + const maxChars = inputBudgetTokens * 4; + if (prompt.length <= maxChars) { + return { prompt, truncated: false }; + } + return { prompt: `${prompt.slice(0, maxChars)}…`, truncated: true }; +}; + +const readConfiguredSmallModel = (workingDirectory) => { + try { + const { mergedConfig } = readConfigLayers(workingDirectory); + const value = mergedConfig?.small_model; + return typeof value === 'string' ? value : null; + } catch { + return null; + } +}; + +/** + * Generates text with the user's small model, resolved and authenticated + * entirely server-side from the OpenCode config and auth store. + */ +export async function generateSmallModelText({ prompt, system, maxOutputTokens, model, directory, preferredProviderID, preferredModelID, restrictToPreferredProvider = false }) { + if (typeof prompt !== 'string' || !prompt.trim()) { + throw Object.assign(new Error('prompt is required'), { statusCode: 400 }); + } + + const auth = readAuthFile(); + const catalog = await getModelCatalog().catch(() => ({})); + + const explicit = parseModelRef(model); + const resolved = explicit + ? { ...explicit, source: 'request' } + : resolveSmallModel({ + auth, + catalog, + settingsSmallModel: readSmallModelSettingsOverride(), + configSmallModel: readConfiguredSmallModel(directory), + preferredProviderID, + preferredModelID, + }); + + if (!resolved) { + throw Object.assign( + new Error('No small model available — no authenticated provider has a suitable model'), + { statusCode: 404 }, + ); + } + + // Callers with a session context can forbid silently switching providers: + // an explicit user choice (settings override, opencode config, request + // model) is always allowed, anything else must stay on the session's + // provider. + if (restrictToPreferredProvider + && !['settings', 'config', 'request'].includes(resolved.source) + && resolved.providerID !== preferredProviderID) { + throw Object.assign( + new Error('No small model available within the session provider'), + { statusCode: 404 }, + ); + } + + const clamped = clampPromptToModelLimit({ + prompt: prompt.trim(), + catalog, + providerID: resolved.providerID, + modelID: resolved.modelID, + }); + + const text = await callSmallModel({ + auth, + catalog, + providerID: resolved.providerID, + modelID: resolved.modelID, + prompt: clamped.prompt, + system: typeof system === 'string' && system.trim() ? system.trim() : undefined, + maxOutputTokens, + }); + + return { + text: text.trim(), + providerID: resolved.providerID, + modelID: resolved.modelID, + source: resolved.source, + ...(clamped.truncated ? { inputTruncated: true } : {}), + }; +} + +/** + * Provider ids with a usable OpenCode login — the set the small model can + * actually call. Used by the settings override picker to hide providers that + * would only ever fail (e.g. opencode free models without a token). + */ +export function listAuthenticatedProviders() { + try { + const auth = readAuthFile(); + const ids = new Set( + Object.keys(auth || {}).filter((providerID) => isUsableAuthEntry(auth[providerID])), + ); + // The catalog id is github-copilot while legacy auth entries may sit + // under the copilot alias. + if (isUsableAuthEntry(getAuthEntryForProvider(auth, 'github-copilot'))) { + ids.add('github-copilot'); + } + return Array.from(ids); + } catch { + return []; + } +} + +/** + * Reports which model would be used, without calling it. + */ +export async function describeSmallModel({ directory, preferredProviderID, preferredModelID } = {}) { + const auth = readAuthFile(); + const catalog = await getModelCatalog().catch(() => ({})); + const resolved = resolveSmallModel({ + auth, + catalog, + settingsSmallModel: readSmallModelSettingsOverride(), + configSmallModel: readConfiguredSmallModel(directory), + preferredProviderID, + preferredModelID, + }); + return resolved; +} diff --git a/packages/web/server/lib/small-model/resolve.js b/packages/web/server/lib/small-model/resolve.js new file mode 100644 index 00000000..4d4a2520 --- /dev/null +++ b/packages/web/server/lib/small-model/resolve.js @@ -0,0 +1,131 @@ +import { getCatalogProvider } from './catalog.js'; + +// Mirrors OpenCode's getSmallModel fallback chain: +// 1. `small_model` from the merged config layers ("provider/model"). +// 2. GitHub Copilot's hidden utility models when Copilot is logged in. +// 3. Family-priority scan of the authenticated providers' catalog models. +const FAMILY_PRIORITY = ['gemini-flash', 'gpt-nano', 'claude-haiku']; +const COPILOT_UTILITY_MODELS = ['gpt-5.4-nano', 'gpt-4.1', 'gpt-4o', 'gpt-4o-mini']; +// The ChatGPT-plan codex backend only accepts a small allowlist of models +// (nano/API-key models are rejected with 400) — this is its cheapest one. +const OPENAI_OAUTH_SMALL_MODEL = 'gpt-5.4-mini'; + +const AUTH_PROVIDER_ALIASES = { + 'github-copilot': ['github-copilot', 'copilot'], +}; + +export function getAuthEntryForProvider(auth, providerID) { + const aliases = AUTH_PROVIDER_ALIASES[providerID] || [providerID]; + for (const alias of aliases) { + const entry = auth?.[alias]; + if (entry && typeof entry === 'object') { + return entry; + } + } + return null; +} + +export function isUsableAuthEntry(entry) { + if (!entry || typeof entry !== 'object') return false; + if (entry.type === 'api') return typeof entry.key === 'string' && entry.key.length > 0; + if (entry.type === 'oauth') { + return (typeof entry.access === 'string' && entry.access.length > 0) + || (typeof entry.refresh === 'string' && entry.refresh.length > 0); + } + if (entry.type === 'wellknown') return typeof entry.token === 'string' && entry.token.length > 0; + return false; +} + +export function parseModelRef(value) { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + const slash = trimmed.indexOf('/'); + if (slash <= 0 || slash === trimmed.length - 1) return null; + return { + providerID: trimmed.slice(0, slash), + modelID: trimmed.slice(slash + 1), + }; +} + +const pickByFamily = (models, family) => { + const matches = Object.values(models) + .filter((model) => model && typeof model === 'object' && model.family === family); + if (matches.length === 0) return null; + matches.sort((a, b) => String(b.release_date || '').localeCompare(String(a.release_date || ''))); + return matches[0]; +}; + +// Small-model candidates within ONE provider, by family priority. Copilot and +// ChatGPT-plan OpenAI have fixed small models that never appear in the +// catalog; everyone else is scanned through the catalog families. +const pickWithinProvider = (providerID, auth, catalog, family) => { + if (providerID === 'openai' && auth.openai?.type === 'oauth') { + return family === 'gpt-nano' + ? { providerID, modelID: OPENAI_OAUTH_SMALL_MODEL, source: 'codex-small' } + : null; + } + if (providerID === 'github-copilot') { + return family === 'gpt-nano' + ? { providerID, modelID: COPILOT_UTILITY_MODELS[0], source: 'copilot-utility' } + : null; + } + const provider = getCatalogProvider(catalog, providerID); + if (!provider || !provider.models || typeof provider.models !== 'object') return null; + const model = pickByFamily(provider.models, family); + return model?.id ? { providerID, modelID: model.id, source: 'family-scan' } : null; +}; + +export function resolveSmallModel({ auth, catalog, settingsSmallModel, configSmallModel, preferredProviderID, preferredModelID }) { + // OpenChamber's own setting (Settings → Sessions → Small Model override) + // outranks everything, including the OpenCode config. + const fromSettings = parseModelRef(settingsSmallModel); + if (fromSettings) { + return { ...fromSettings, source: 'settings' }; + } + + const explicit = parseModelRef(configSmallModel); + if (explicit) { + return { ...explicit, source: 'config' }; + } + + // Like OpenCode: when the caller has a session context, the utility call + // stays on the session's provider. Scan its families for a small model, + // otherwise run on the session's own model — never silently switch to a + // different provider's subscription. + const preferred = typeof preferredProviderID === 'string' && preferredProviderID + ? preferredProviderID + : null; + if (preferred && isUsableAuthEntry(getAuthEntryForProvider(auth, preferred))) { + for (const family of FAMILY_PRIORITY) { + const match = pickWithinProvider(preferred, auth, catalog, family); + if (match) return match; + } + if (typeof preferredModelID === 'string' && preferredModelID) { + return { providerID: preferred, modelID: preferredModelID, source: 'session-model' }; + } + } + + // No session context (or its provider has no usable login): scan all + // authenticated providers by family priority. + const authedProviders = Object.keys(auth || {}).filter((providerID) => + providerID !== preferred && isUsableAuthEntry(auth[providerID])); + + for (const family of FAMILY_PRIORITY) { + for (const providerID of authedProviders) { + const match = pickWithinProvider(providerID, auth, catalog, family); + if (match) return match; + } + } + + // Copilot's utility fallback for legacy auth aliases the loop above missed. + const copilotEntry = getAuthEntryForProvider(auth, 'github-copilot'); + if (isUsableAuthEntry(copilotEntry)) { + return { + providerID: 'github-copilot', + modelID: COPILOT_UTILITY_MODELS[0], + source: 'copilot-utility', + }; + } + + return null; +} diff --git a/packages/web/server/lib/small-model/resolve.test.js b/packages/web/server/lib/small-model/resolve.test.js new file mode 100644 index 00000000..5e5d1299 --- /dev/null +++ b/packages/web/server/lib/small-model/resolve.test.js @@ -0,0 +1,197 @@ +import { describe, it, expect } from 'bun:test'; +import { resolveSmallModel, parseModelRef, isUsableAuthEntry } from './resolve.js'; + +const catalog = { + google: { + id: 'google', + models: { + 'gemini-2.5-flash': { id: 'gemini-2.5-flash', family: 'gemini-flash', release_date: '2025-06-01' }, + 'gemini-2.0-flash': { id: 'gemini-2.0-flash', family: 'gemini-flash', release_date: '2024-12-01' }, + 'gemini-2.5-pro': { id: 'gemini-2.5-pro', family: 'gemini-pro', release_date: '2025-06-01' }, + }, + }, + anthropic: { + id: 'anthropic', + models: { + 'claude-haiku-4-5': { id: 'claude-haiku-4-5', family: 'claude-haiku', release_date: '2025-10-01' }, + 'claude-sonnet-4-5': { id: 'claude-sonnet-4-5', family: 'claude-sonnet', release_date: '2025-09-01' }, + }, + }, +}; + +describe('parseModelRef', () => { + it('splits provider/model on the first slash', () => { + expect(parseModelRef('anthropic/claude-haiku-4-5')).toEqual({ + providerID: 'anthropic', + modelID: 'claude-haiku-4-5', + }); + }); + + it('keeps slashes inside the model id', () => { + expect(parseModelRef('openrouter/google/gemini-2.5-flash')).toEqual({ + providerID: 'openrouter', + modelID: 'google/gemini-2.5-flash', + }); + }); + + it('rejects values without a provider or model part', () => { + expect(parseModelRef('anthropic/')).toBeNull(); + expect(parseModelRef('/model')).toBeNull(); + expect(parseModelRef('plain')).toBeNull(); + expect(parseModelRef(undefined)).toBeNull(); + }); +}); + +describe('isUsableAuthEntry', () => { + it('accepts api keys, oauth tokens, and wellknown tokens', () => { + expect(isUsableAuthEntry({ type: 'api', key: 'sk-x' })).toBe(true); + expect(isUsableAuthEntry({ type: 'oauth', access: 'a', refresh: 'r', expires: 0 })).toBe(true); + expect(isUsableAuthEntry({ type: 'wellknown', key: 'k', token: 't' })).toBe(true); + }); + + it('rejects empty or malformed entries', () => { + expect(isUsableAuthEntry({ type: 'api', key: '' })).toBe(false); + expect(isUsableAuthEntry({ type: 'oauth' })).toBe(false); + expect(isUsableAuthEntry(null)).toBe(false); + }); +}); + +describe('resolveSmallModel', () => { + it('gives the OpenChamber settings override top priority', () => { + const result = resolveSmallModel({ + auth: { anthropic: { type: 'api', key: 'sk-x' } }, + catalog, + settingsSmallModel: 'anthropic/claude-haiku-4-5', + configSmallModel: 'openai/gpt-4o-mini', + preferredProviderID: 'anthropic', + }); + expect(result).toEqual({ providerID: 'anthropic', modelID: 'claude-haiku-4-5', source: 'settings' }); + }); + + it('prefers the configured small_model', () => { + const result = resolveSmallModel({ + auth: { anthropic: { type: 'api', key: 'sk-x' } }, + catalog, + configSmallModel: 'openai/gpt-4o-mini', + }); + expect(result).toEqual({ providerID: 'openai', modelID: 'gpt-4o-mini', source: 'config' }); + }); + + it('scans authenticated providers by family priority, newest first', () => { + const result = resolveSmallModel({ + auth: { + google: { type: 'api', key: 'g-key' }, + anthropic: { type: 'api', key: 'sk-x' }, + }, + catalog, + configSmallModel: null, + }); + expect(result).toEqual({ providerID: 'google', modelID: 'gemini-2.5-flash', source: 'family-scan' }); + }); + + it('skips providers without a usable credential', () => { + const result = resolveSmallModel({ + auth: { + google: { type: 'api', key: '' }, + anthropic: { type: 'api', key: 'sk-x' }, + }, + catalog, + configSmallModel: null, + }); + expect(result).toEqual({ providerID: 'anthropic', modelID: 'claude-haiku-4-5', source: 'family-scan' }); + }); + + it('falls back to Copilot utility models when only Copilot is logged in', () => { + const result = resolveSmallModel({ + auth: { 'github-copilot': { type: 'oauth', access: 't', refresh: 't', expires: 0 } }, + catalog, + configSmallModel: null, + }); + expect(result?.providerID).toBe('github-copilot'); + expect(result?.source).toBe('copilot-utility'); + }); + + it('returns null when nothing is authenticated', () => { + expect(resolveSmallModel({ auth: {}, catalog, configSmallModel: null })).toBeNull(); + }); + + it('prefers the session provider over other authenticated providers', () => { + const result = resolveSmallModel({ + auth: { + google: { type: 'api', key: 'g-key' }, + anthropic: { type: 'api', key: 'sk-x' }, + }, + catalog, + configSmallModel: null, + preferredProviderID: 'anthropic', + }); + expect(result).toEqual({ providerID: 'anthropic', modelID: 'claude-haiku-4-5', source: 'family-scan' }); + }); + + it('ignores a preferred provider without a usable login', () => { + const result = resolveSmallModel({ + auth: { google: { type: 'api', key: 'g-key' } }, + catalog, + configSmallModel: null, + preferredProviderID: 'anthropic', + }); + expect(result).toEqual({ providerID: 'google', modelID: 'gemini-2.5-flash', source: 'family-scan' }); + }); + + it('never uses a session provider without a login (opencode free models)', () => { + // Vanilla setups default the picker to opencode/big-pickle with no + // opencode token — those free models only work through OpenCode itself + // and must never be called directly, so the session context is ignored. + const result = resolveSmallModel({ + auth: { openai: { type: 'oauth', access: 'a', refresh: 'r', expires: Date.now() + 60_000 } }, + catalog, + configSmallModel: null, + preferredProviderID: 'opencode', + preferredModelID: 'big-pickle', + }); + expect(result).toEqual({ providerID: 'openai', modelID: 'gpt-5.4-mini', source: 'codex-small' }); + }); + + it('resolves nothing on a vanilla setup with no logins at all', () => { + const result = resolveSmallModel({ + auth: {}, + catalog, + configSmallModel: null, + preferredProviderID: 'opencode', + preferredModelID: 'big-pickle', + }); + expect(result).toBeNull(); + }); + + it('falls back to the session model instead of scanning other providers', () => { + const result = resolveSmallModel({ + auth: { + 'opencode-go': { type: 'api', key: 'oc-key' }, + openai: { type: 'oauth', access: 'a', refresh: 'r', expires: Date.now() + 60_000 }, + }, + catalog: { + 'opencode-go': { + id: 'opencode-go', + models: { + 'deepseek-v4-flash': { id: 'deepseek-v4-flash', family: 'deepseek-flash', release_date: '2026-01-01' }, + }, + }, + }, + configSmallModel: null, + preferredProviderID: 'opencode-go', + preferredModelID: 'deepseek-v4-flash', + }); + expect(result).toEqual({ providerID: 'opencode-go', modelID: 'deepseek-v4-flash', source: 'session-model' }); + }); + + it('falls back to the session model itself when nothing resolves', () => { + const result = resolveSmallModel({ + auth: { mistral: { type: 'api', key: 'm-key' } }, + catalog, + configSmallModel: null, + preferredProviderID: 'mistral', + preferredModelID: 'mistral-large-latest', + }); + expect(result).toEqual({ providerID: 'mistral', modelID: 'mistral-large-latest', source: 'session-model' }); + }); +}); diff --git a/packages/web/server/lib/small-model/routes.js b/packages/web/server/lib/small-model/routes.js new file mode 100644 index 00000000..c53143de --- /dev/null +++ b/packages/web/server/lib/small-model/routes.js @@ -0,0 +1,44 @@ +export function registerSmallModelRoutes(app, { getSmallModelService }) { + app.get('/api/small-model', async (req, res) => { + try { + const { describeSmallModel, listAuthenticatedProviders } = await getSmallModelService(); + const resolved = await describeSmallModel({ + directory: typeof req.query.directory === 'string' ? req.query.directory : undefined, + preferredProviderID: typeof req.query.providerID === 'string' ? req.query.providerID : undefined, + preferredModelID: typeof req.query.modelID === 'string' ? req.query.modelID : undefined, + }); + res.json({ + available: Boolean(resolved), + model: resolved, + authenticatedProviders: listAuthenticatedProviders(), + }); + } catch (error) { + console.error('Failed to resolve small model:', error); + res.status(500).json({ error: error.message || 'Failed to resolve small model' }); + } + }); + + app.post('/api/small-model/generate', async (req, res) => { + try { + const { generateSmallModelText } = await getSmallModelService(); + const { prompt, system, maxOutputTokens, model, directory, preferredProviderID, preferredModelID, restrictToPreferredProvider } = req.body || {}; + const result = await generateSmallModelText({ + prompt, + system, + maxOutputTokens, + model, + directory, + preferredProviderID, + preferredModelID, + restrictToPreferredProvider: restrictToPreferredProvider === true, + }); + res.json(result); + } catch (error) { + const statusCode = Number(error?.statusCode) || 500; + if (statusCode >= 500) { + console.error('Small model generation failed:', error); + } + res.status(statusCode).json({ error: error.message || 'Small model generation failed' }); + } + }); +} diff --git a/scripts/oc-dev.mjs b/scripts/oc-dev.mjs index c530d099..06ca5441 100755 --- a/scripts/oc-dev.mjs +++ b/scripts/oc-dev.mjs @@ -173,6 +173,18 @@ function step(label, fn) { return result; } +function printReleaseNextSteps(version) { + log.success(`Release v${version} prepared locally`); + log.info('Next steps:'); + console.log(` git add -A`); + console.log(` git commit -m "release v${version}"`); + console.log(` git tag v${version}`); + console.log(` git push origin main --tags`); + console.log(''); + console.log('This will trigger the GitHub Actions release workflow.'); + console.log(`Make sure CHANGELOG.md contains a section like "## [${version}] - YYYY-MM-DD" before pushing.`); +} + function normalizeAction(action = '') { const normalized = action.toLowerCase(); const aliases = { @@ -575,7 +587,7 @@ async function createRelease(options) { if (!/^\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$/.test(version)) throw new Error('Invalid version format. Use semver, e.g. 1.4.7 or 1.4.7-beta.1'); step('Validating codebase', () => run('bun', ['run', 'release:prepare'])); step(`Bumping version to ${version}`, () => run('node', ['scripts/bump-version.mjs', version])); - log.success(`Release v${version} prepared locally`); + printReleaseNextSteps(version); } async function chooseAction(config) { From 0a7807a9dcb25cffea93f39adaf346626a2a0315 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 5 Jul 2026 23:21:59 +0300 Subject: [PATCH 0110/1073] fix: run deployed web CLI from Bun global install Detects the OpenChamber CLI from Bun's global install directory Starts the global instance via the installed CLI path instead of relying on PATH Fails fast if the global CLI was not installed --- scripts/oc-dev.mjs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/scripts/oc-dev.mjs b/scripts/oc-dev.mjs index 06ca5441..c6b2226e 100755 --- a/scripts/oc-dev.mjs +++ b/scripts/oc-dev.mjs @@ -286,6 +286,11 @@ function installedWebCli(directory) { return existsSync(cliPath) ? cliPath : ''; } +function installedGlobalWebCli() { + const bunInstall = process.env.BUN_INSTALL || path.join(os.homedir(), '.bun'); + return installedWebCli(path.join(bunInstall, 'install', 'global')); +} + function stopInstalledInstance(directory, port) { const cliPath = installedWebCli(directory); if (!cliPath) return; @@ -370,7 +375,11 @@ async function deployWeb(options, config) { run('bun', ['remove', '-g', 'openchamber'], { allowFail: true, label: 'remove openchamber' }); }); step('Installing package globally', () => run('bun', ['add', '-g', packageFile])); - step(`Starting global instance on ${GLOBAL_PORT}`, () => run('openchamber', ['--port', GLOBAL_PORT], { env: { OPENCHAMBER_UI_PASSWORD: process.env.OPENCHAMBER_PASSWORD || '', OPENCHAMBER_HOST: '0.0.0.0' } })); + step(`Starting global instance on ${GLOBAL_PORT}`, () => { + const cliPath = installedGlobalWebCli(); + if (!cliPath) throw new Error('Global OpenChamber CLI was not installed by bun add -g'); + run('node', [cliPath, '--port', GLOBAL_PORT], { env: { OPENCHAMBER_UI_PASSWORD: process.env.OPENCHAMBER_PASSWORD || '', OPENCHAMBER_HOST: '0.0.0.0' } }); + }); } async function deployRemoteWeb(options, config) { From ec61cf35732dac3d76ff7f27f6cbbd243b86378e Mon Sep 17 00:00:00 2001 From: Leonid <127580858+bashrusakh@users.noreply.github.com> Date: Mon, 6 Jul 2026 07:33:23 +1100 Subject: [PATCH 0111/1073] fix(auth): narrow mobile auth fallback (#2046) Co-authored-by: bashrusakh --- packages/ui/src/apps/renderMobileApp.tsx | 4 +- .../auth/SessionAuthGate.behavior.test.tsx | 315 ++++++++++++++++++ .../components/auth/SessionAuthGate.test.ts | 13 + .../src/components/auth/SessionAuthGate.tsx | 9 +- .../components/auth/sessionAuthGateState.ts | 11 + 5 files changed, 345 insertions(+), 7 deletions(-) create mode 100644 packages/ui/src/components/auth/SessionAuthGate.behavior.test.tsx create mode 100644 packages/ui/src/components/auth/SessionAuthGate.test.ts create mode 100644 packages/ui/src/components/auth/sessionAuthGateState.ts diff --git a/packages/ui/src/apps/renderMobileApp.tsx b/packages/ui/src/apps/renderMobileApp.tsx index e20e187b..a665d2d5 100644 --- a/packages/ui/src/apps/renderMobileApp.tsx +++ b/packages/ui/src/apps/renderMobileApp.tsx @@ -76,9 +76,7 @@ export function renderMobileApp(apis: RuntimeAPIs) { // Auth gating differs by shell: the native Capacitor app authenticates via // its own instance-connect flow (MobileConnectionWelcome asks for the // password per instance), while the plain mobile BROWSER against a - // --ui-password server must get the classic SessionAuthGate unlock page — - // dropping it (v1.13.9) left browsers on a dead "unable to reach server" - // screen with no way to enter the password. + // --ui-password server must keep the classic SessionAuthGate unlock page. const app = ; createRoot(rootElement).render( diff --git a/packages/ui/src/components/auth/SessionAuthGate.behavior.test.tsx b/packages/ui/src/components/auth/SessionAuthGate.behavior.test.tsx new file mode 100644 index 00000000..871bc74c --- /dev/null +++ b/packages/ui/src/components/auth/SessionAuthGate.behavior.test.tsx @@ -0,0 +1,315 @@ +import { describe, expect, mock, test } from 'bun:test'; + +type ComponentFn

= Record> = (props: P) => unknown; + +type HookRecord = { + values: unknown[]; + deps: Array; +}; + +type HookEffect = () => void | (() => void); +type HookCallback = (...args: unknown[]) => unknown; +type JSXProps = Record & { children?: unknown }; +type JSXElementType

= Record> = ComponentFn

| string | symbol; + +const hookRecords = new Map(); +let currentRecord: HookRecord | null = null; +let hookIndex = 0; +let pendingEffects: Array<() => void> = []; + +const resetHarness = () => { + hookRecords.clear(); + currentRecord = null; + hookIndex = 0; + pendingEffects = []; +}; + +const shallowEqualDeps = (left?: unknown[], right?: unknown[]): boolean => { + if (!left || !right) return false; + if (left.length !== right.length) return false; + return left.every((value, index) => Object.is(value, right[index])); +}; + +const getRecord = (component: unknown): HookRecord => { + const existing = hookRecords.get(component); + if (existing) return existing; + const record: HookRecord = { values: [], deps: [] }; + hookRecords.set(component, record); + return record; +}; + +const getHookRecord = (): HookRecord => { + if (!currentRecord) { + throw new Error('Hooks can only run during a render pass'); + } + return currentRecord; +}; + +const renderComponent =

>(component: ComponentFn

, props: P): unknown => { + const previousRecord = currentRecord; + const previousHookIndex = hookIndex; + currentRecord = getRecord(component); + hookIndex = 0; + + try { + return component(props); + } finally { + currentRecord = previousRecord; + hookIndex = previousHookIndex; + } +}; + +function useCallback(callback: T, deps?: unknown[]): T { + const record = getHookRecord(); + const index = hookIndex++; + const previousDeps = record.deps[index]; + if (!shallowEqualDeps(previousDeps, deps)) { + record.values[index] = callback; + record.deps[index] = deps; + } + return record.values[index] as T; +} + +function useEffect(effect: HookEffect, deps?: unknown[]): void { + const record = getHookRecord(); + const index = hookIndex++; + const previousDeps = record.deps[index]; + if (!shallowEqualDeps(previousDeps, deps)) { + record.deps[index] = deps; + pendingEffects.push(() => { + effect(); + }); + } +} + +function useMemo(factory: () => T, deps?: unknown[]): T { + const record = getHookRecord(); + const index = hookIndex++; + const previousDeps = record.deps[index]; + if (!shallowEqualDeps(previousDeps, deps)) { + record.values[index] = factory(); + record.deps[index] = deps; + } + return record.values[index] as T; +} + +function useRef(initialValue: T): { current: T } { + const record = getHookRecord(); + const index = hookIndex++; + if (record.values[index] === undefined) { + record.values[index] = { current: initialValue }; + } + return record.values[index] as { current: T }; +} + +function useState(initialValue: T | (() => T)): readonly [T, (next: T | ((prev: T) => T)) => void] { + const record = getHookRecord(); + const index = hookIndex++; + if (record.values[index] === undefined) { + record.values[index] = typeof initialValue === 'function' + ? (initialValue as () => T)() + : initialValue; + } + + const setState = (next: T | ((prev: T) => T)) => { + record.values[index] = typeof next === 'function' + ? (next as (prev: T) => T)(record.values[index] as T) + : next; + }; + + return [record.values[index] as T, setState] as const; +} + +function jsx

>(type: JSXElementType

, props: JSXProps & P): unknown { + if (type === reactJsxRuntime.Fragment) { + return props.children ?? null; + } + + if (typeof type === 'function') { + return renderComponent(type, props as P); + } + + return { type, props }; +} + +const ReactMock = { + useCallback, + useEffect, + useMemo, + useRef, + useState, +}; + +const reactJsxRuntime = { + Fragment: Symbol('Fragment'), + jsx, + jsxs: jsx, + jsxDEV: jsx, +}; + +let desktopShell = false; +let runtimeFetchRejects = true; + +mock.module('react/jsx-runtime', () => reactJsxRuntime); +mock.module('react/jsx-dev-runtime', () => reactJsxRuntime); + +mock.module('react', () => ({ + __esModule: true, + default: ReactMock, + ...ReactMock, +})); + +mock.module('@simplewebauthn/browser', () => ({ + browserSupportsWebAuthn: mock(() => false), +})); + +mock.module('@/components/ui/button', () => ({ + Button: ({ children }: { children?: unknown }) => children ?? null, +})); + +mock.module('@/components/ui/checkbox', () => ({ + Checkbox: () => null, +})); + +mock.module('@/components/ui/input', () => ({ + Input: () => null, +})); + +mock.module('@/components/ui', () => ({ + toast: { + success: mock(() => undefined), + error: mock(() => undefined), + message: mock(() => undefined), + }, +})); + +mock.module('@/components/ui/OpenChamberLogo', () => ({ + OpenChamberLogo: () => 'logo', +})); + +mock.module('@/components/icon/Icon', () => ({ + Icon: () => null, +})); + +mock.module('@/components/desktop/DesktopHostSwitcher', () => ({ + DesktopHostSwitcherInline: () => 'host-switcher', +})); + +mock.module('@/lib/i18n', () => ({ + useI18n: () => ({ t: (key: string) => key }), +})); + +mock.module('@/lib/desktop', () => ({ + invokeDesktop: mock(() => Promise.resolve(null)), + isDesktopShell: mock(() => desktopShell), + isVSCodeRuntime: mock(() => false), +})); + +mock.module('@/lib/persistence', () => ({ + initializeAppearancePreferences: mock(() => Promise.resolve()), + syncDesktopSettings: mock(() => Promise.resolve()), +})); + +mock.module('@/lib/directoryPersistence', () => ({ + applyPersistedDirectoryPreferences: mock(() => Promise.resolve()), +})); + +mock.module('@/lib/runtime-fetch', () => ({ + runtimeFetch: mock(async () => { + if (runtimeFetchRejects) { + throw new Error('offline'); + } + + return new Response(JSON.stringify({ authenticated: false }), { + status: 401, + headers: { 'content-type': 'application/json' }, + }); + }), +})); + +mock.module('@/lib/runtime-auth', () => ({ + getRuntimeExtraHeadersSync: mock(() => ({})), +})); + +mock.module('@/lib/runtime-switch', () => ({ + getRuntimeApiBaseUrl: mock(() => ''), + subscribeRuntimeEndpointChanged: mock(() => () => {}), + switchRuntimeEndpoint: mock(() => undefined), +})); + +mock.module('@/lib/desktopHosts', () => ({ + desktopHostsGet: mock(() => Promise.resolve(null)), + desktopHostsSet: mock(() => Promise.resolve()), + getDesktopHostApiUrl: mock(() => ''), + normalizeHostUrl: mock(() => ''), +})); + +mock.module('@/lib/passkeys', () => ({ + authenticateWithPasskey: mock(() => Promise.resolve(null)), + cancelPasskeyCeremony: mock(() => undefined), + defaultPasskeyStatus: { enabled: false, hasPasskeys: false, passkeyCount: 0, rpID: null }, + fetchPasskeyStatus: mock(() => Promise.resolve({ enabled: false, hasPasskeys: false, passkeyCount: 0, rpID: null })), + isPasskeyCeremonyAbort: mock(() => false), + registerCurrentDevicePasskey: mock(() => Promise.resolve(null)), +})); + +const { SessionAuthGate } = await import('./SessionAuthGate'); + +const flushEffects = async () => { + while (pendingEffects.length > 0) { + const effects = pendingEffects; + pendingEffects = []; + for (const effect of effects) { + effect(); + } + await Promise.resolve(); + } + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + await Promise.resolve(); +}; + +const renderGate = async () => { + const firstPass = renderComponent(SessionAuthGate, { children: 'child' }); + await flushEffects(); + const secondPass = renderComponent(SessionAuthGate, { children: 'child' }); + await flushEffects(); + return secondPass ?? firstPass; +}; + +const collectText = (node: unknown): string => { + if (node === null || node === undefined || typeof node === 'boolean') return ''; + if (typeof node === 'string' || typeof node === 'number') return String(node); + if (Array.isArray(node)) return node.map((child) => collectText(child)).join(' '); + if (typeof node === 'object') { + const element = node as { props?: { children?: unknown } }; + return collectText(element.props?.children); + } + return ''; +}; + +describe('SessionAuthGate status-check failure behavior', () => { + test('keeps non-desktop status-check rejection on the error screen', async () => { + resetHarness(); + desktopShell = false; + runtimeFetchRejects = true; + + const tree = await renderGate(); + const text = collectText(tree); + + expect(text).toContain('sessionAuth.error.networkTitle'); + expect(text).not.toContain('sessionAuth.locked.unlockTitle'); + }); + + test('keeps desktop-shell status-check rejection on the locked password prompt', async () => { + resetHarness(); + desktopShell = true; + runtimeFetchRejects = true; + + const tree = await renderGate(); + const text = collectText(tree); + + expect(text).toContain('sessionAuth.locked.unlockTitle'); + expect(text).not.toContain('sessionAuth.error.networkTitle'); + }); +}); diff --git a/packages/ui/src/components/auth/SessionAuthGate.test.ts b/packages/ui/src/components/auth/SessionAuthGate.test.ts new file mode 100644 index 00000000..543a6fbe --- /dev/null +++ b/packages/ui/src/components/auth/SessionAuthGate.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, test } from 'bun:test'; + +import { resolveStatusCheckFailureState } from './sessionAuthGateState'; + +describe('resolveStatusCheckFailureState', () => { + test('keeps the desktop-shell password login fallback intact', () => { + expect(resolveStatusCheckFailureState({ shouldUseDesktopShellPasswordLogin: true })).toBe('locked'); + }); + + test('uses the network error screen for non-desktop status-check failures', () => { + expect(resolveStatusCheckFailureState({})).toBe('error'); + }); +}); diff --git a/packages/ui/src/components/auth/SessionAuthGate.tsx b/packages/ui/src/components/auth/SessionAuthGate.tsx index 00c7c336..558e77c8 100644 --- a/packages/ui/src/components/auth/SessionAuthGate.tsx +++ b/packages/ui/src/components/auth/SessionAuthGate.tsx @@ -15,6 +15,7 @@ import { runtimeFetch } from '@/lib/runtime-fetch'; import { getRuntimeExtraHeadersSync } from '@/lib/runtime-auth'; import { getRuntimeApiBaseUrl, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch'; import { desktopHostsGet, desktopHostsSet, getDesktopHostApiUrl, normalizeHostUrl } from '@/lib/desktopHosts'; +import { resolveStatusCheckFailureState, type GateState } from './sessionAuthGateState'; import { authenticateWithPasskey, cancelPasskeyCeremony, @@ -292,8 +293,6 @@ interface SessionAuthGateProps { children: React.ReactNode; } -type GateState = 'pending' | 'authenticated' | 'locked' | 'error' | 'rate-limited'; - interface ErrorScreenProps { onRetry: () => void; errorType?: 'network' | 'rate-limit'; @@ -301,7 +300,9 @@ interface ErrorScreenProps { children?: React.ReactNode; } -export const SessionAuthGate: React.FC = ({ children }) => { +export const SessionAuthGate: React.FC = ({ + children, +}) => { const { t } = useI18n(); const vscodeRuntime = React.useMemo(() => isVSCodeRuntime(), []); const skipAuth = vscodeRuntime; @@ -422,7 +423,7 @@ export const SessionAuthGate: React.FC = ({ children }) => setIsTunnelLocked(false); } catch (error) { console.warn('Failed to check session status:', error); - if (shouldUseDesktopShellPasswordLogin()) { + if (resolveStatusCheckFailureState({ shouldUseDesktopShellPasswordLogin: shouldUseDesktopShellPasswordLogin() }) === 'locked') { setState('locked'); setRetryAfter(undefined); setIsTunnelLocked(false); diff --git a/packages/ui/src/components/auth/sessionAuthGateState.ts b/packages/ui/src/components/auth/sessionAuthGateState.ts new file mode 100644 index 00000000..258d4acc --- /dev/null +++ b/packages/ui/src/components/auth/sessionAuthGateState.ts @@ -0,0 +1,11 @@ +export type GateState = 'pending' | 'authenticated' | 'locked' | 'error' | 'rate-limited'; + +export const resolveStatusCheckFailureState = (options: { + shouldUseDesktopShellPasswordLogin?: boolean; +}): Exclude => { + if (options.shouldUseDesktopShellPasswordLogin) { + return 'locked'; + } + + return 'error'; +}; From a6edc7baee239d8c70371ffd782929fa1dd509a8 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 5 Jul 2026 23:51:11 +0300 Subject: [PATCH 0112/1073] fix(capacitor): validate mobile connection on app resume Checks the runtime session before restoring a mobile connection Disconnects and resets state when the session is no longer valid Adds tests for reachable, unreachable, and unauthenticated runtimes --- packages/ui/src/apps/MobileApp.tsx | 33 +++++++-- .../ui/src/apps/mobileConnections.test.ts | 71 +++++++++++++++++++ packages/ui/src/apps/mobileConnections.ts | 25 +++++++ 3 files changed, 123 insertions(+), 6 deletions(-) create mode 100644 packages/ui/src/apps/mobileConnections.test.ts diff --git a/packages/ui/src/apps/MobileApp.tsx b/packages/ui/src/apps/MobileApp.tsx index 4581b1d9..bc7f9a2c 100644 --- a/packages/ui/src/apps/MobileApp.tsx +++ b/packages/ui/src/apps/MobileApp.tsx @@ -57,7 +57,7 @@ import { MobileFilesSurface } from './MobileFilesSurface'; import { MobileSessionsSheet } from './MobileSessionsSheet'; import { MobileSurfaceShell } from './MobileSurfaceShell'; import { DedicatedMobileAppProvider, type MobileAppActions } from './mobileAppContext'; -import { autoConnectLastInstance, isSameConnectionUrl, useMobileConnection } from './mobileConnections'; +import { autoConnectLastInstance, isSameConnectionUrl, useMobileConnection, validateMobileConnectionSession } from './mobileConnections'; import { isQrScanSupported, parseConnectionPayload, scanConnectionQr } from './mobileQrScan'; import { resetAppForRuntimeEndpointChange } from './runtimeEndpointReset'; import { useAppFontEffects } from './useAppFontEffects'; @@ -516,6 +516,12 @@ const mobileInputKeyboardProps = { const NATIVE_RESUME_SYNC_EVENT_THROTTLE_MS = 1_000; +const getRuntimeClientToken = (): string => { + if (typeof window === 'undefined') return ''; + const token = (window as typeof window & { __OPENCHAMBER_CLIENT_TOKEN__?: string }).__OPENCHAMBER_CLIENT_TOKEN__; + return typeof token === 'string' ? token.trim() : ''; +}; + const getProjectLabel = (path: string): string => { const normalized = normalizePath(path); if (!normalized) return ''; @@ -2184,18 +2190,33 @@ export function MobileApp({ apis }: MobileAppProps) { const [autoConnectPhase, setAutoConnectPhase] = React.useState<'pending' | 'attempting' | 'done'>('pending'); const isNativeMobileApp = React.useMemo(() => isCapacitorMobileApp(), []); const lastNativeResumeSyncEventAtRef = React.useRef(0); + const nativeResumeValidationSeqRef = React.useRef(0); const handleNativeResume = React.useCallback(() => { - if (!getRuntimeApiBaseUrl()) return; + const apiBaseUrl = getRuntimeApiBaseUrl(); + if (!apiBaseUrl) return; + const validationSeq = nativeResumeValidationSeqRef.current + 1; + nativeResumeValidationSeqRef.current = validationSeq; + + void validateMobileConnectionSession({ url: apiBaseUrl, clientToken: getRuntimeClientToken() }).then((isValid) => { + if (nativeResumeValidationSeqRef.current !== validationSeq) return; + if (!isValid) { + switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' }); + setConnectionEpoch((value) => value + 1); + return; + } + + void initializeApp(); + void refreshGitHubAuthStatus(apis.github, { force: true }); + if (providersCount === 0) void loadProviders({ source: 'mobileApp:nativeResume' }); + if (agentsCount === 0) void loadAgents({ source: 'mobileApp:nativeResume' }); + }); + const now = Date.now(); if (now - lastNativeResumeSyncEventAtRef.current >= NATIVE_RESUME_SYNC_EVENT_THROTTLE_MS) { lastNativeResumeSyncEventAtRef.current = now; window.dispatchEvent(new Event('openchamber:system-resume')); } - void initializeApp(); - void refreshGitHubAuthStatus(apis.github, { force: true }); - if (providersCount === 0) void loadProviders({ source: 'mobileApp:nativeResume' }); - if (agentsCount === 0) void loadAgents({ source: 'mobileApp:nativeResume' }); }, [agentsCount, apis.github, initializeApp, loadAgents, loadProviders, providersCount, refreshGitHubAuthStatus]); useNativeMobileChrome(); diff --git a/packages/ui/src/apps/mobileConnections.test.ts b/packages/ui/src/apps/mobileConnections.test.ts new file mode 100644 index 00000000..0f16f3c5 --- /dev/null +++ b/packages/ui/src/apps/mobileConnections.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, mock, test } from 'bun:test'; + +import { validateMobileConnectionSession } from './mobileConnections'; + +const originalFetch = globalThis.fetch; +const originalWindow = globalThis.window; + +const installTestWindow = () => { + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + setTimeout: globalThis.setTimeout.bind(globalThis), + clearTimeout: globalThis.clearTimeout.bind(globalThis), + location: { protocol: 'https:' }, + }, + }); +}; + +const restoreGlobals = () => { + globalThis.fetch = originalFetch; + Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow }); +}; + +describe('validateMobileConnectionSession', () => { + test('accepts a reachable authenticated runtime', async () => { + const fetchMock = mock(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith('/health')) return Response.json({ ok: true }); + if (url.endsWith('/auth/session')) return Response.json({ authenticated: true, scope: 'client' }); + return new Response(null, { status: 404 }); + }); + try { + installTestWindow(); + globalThis.fetch = fetchMock as typeof fetch; + + const result = await validateMobileConnectionSession({ url: 'https://runtime.example', clientToken: 'token' }); + expect(result).toBe(true); + } finally { + restoreGlobals(); + } + }); + + test('rejects unreachable runtimes', async () => { + try { + installTestWindow(); + globalThis.fetch = mock(async () => new Response(null, { status: 503 })) as typeof fetch; + + const result = await validateMobileConnectionSession({ url: 'https://runtime.example', clientToken: 'token' }); + expect(result).toBe(false); + } finally { + restoreGlobals(); + } + }); + + test('rejects invalid or unauthenticated sessions', async () => { + const fetchMock = mock(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith('/health')) return Response.json({ ok: true }); + return Response.json({ authenticated: false }, { status: 401 }); + }); + try { + installTestWindow(); + globalThis.fetch = fetchMock as typeof fetch; + + const result = await validateMobileConnectionSession({ url: 'https://runtime.example', clientToken: 'expired' }); + expect(result).toBe(false); + } finally { + restoreGlobals(); + } + }); +}); diff --git a/packages/ui/src/apps/mobileConnections.ts b/packages/ui/src/apps/mobileConnections.ts index 35a748af..a2307cf0 100644 --- a/packages/ui/src/apps/mobileConnections.ts +++ b/packages/ui/src/apps/mobileConnections.ts @@ -452,6 +452,31 @@ export const autoConnectLastInstance = async (): Promise => { return true; }; +export const validateMobileConnectionSession = async (input: { + url: string; + clientToken?: string | null; +}): Promise => { + let url = ''; + try { + url = normalizeConnectionUrl(input.url); + } catch { + return false; + } + if (!url) return false; + + const token = input.clientToken?.trim() || undefined; + const headers = token ? { Authorization: `Bearer ${token}` } : undefined; + + const health = await requestWithTimeout(`${url}/health`, { method: 'GET', headers }); + if (!health?.ok) return false; + + const session = await requestWithTimeout(`${url}/auth/session`, { method: 'GET', credentials: 'include', headers }); + if (!session || (!session.ok && session.status !== 404)) return false; + + const status = await readSessionStatus(session); + return !(status && status.disabled !== true && status.authenticated === false); +}; + // --------------------------------------------------------------------------- // Shared connection controller // --------------------------------------------------------------------------- From 086982c1b3fe14c7b968b2b0826ce096d61027e8 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 6 Jul 2026 00:37:24 +0300 Subject: [PATCH 0113/1073] feat: add temporary sidebar share opinion prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a dialog with call booking and survey actions for user feedback Shows a one-time sidebar toast prompting users to share what’s useful or missing Adds localized copy for the new feedback entry points --- .../feedback/ShareOpinionDialog.tsx | 65 +++++++++++++++++++ packages/ui/src/components/icon/sprite.ts | 1 + .../src/components/session/SessionSidebar.tsx | 39 +++++++++++ .../session/sidebar/SidebarFooter.tsx | 14 +++- packages/ui/src/lib/i18n/messages/en.ts | 7 ++ packages/ui/src/lib/i18n/messages/es.ts | 7 ++ packages/ui/src/lib/i18n/messages/fr.ts | 7 ++ packages/ui/src/lib/i18n/messages/ja.ts | 7 ++ packages/ui/src/lib/i18n/messages/ko.ts | 7 ++ packages/ui/src/lib/i18n/messages/pl.ts | 7 ++ packages/ui/src/lib/i18n/messages/pt-BR.ts | 7 ++ packages/ui/src/lib/i18n/messages/uk.ts | 7 ++ packages/ui/src/lib/i18n/messages/zh-CN.ts | 7 ++ packages/ui/src/lib/i18n/messages/zh-TW.ts | 7 ++ 14 files changed, 188 insertions(+), 1 deletion(-) create mode 100644 packages/ui/src/components/feedback/ShareOpinionDialog.tsx diff --git a/packages/ui/src/components/feedback/ShareOpinionDialog.tsx b/packages/ui/src/components/feedback/ShareOpinionDialog.tsx new file mode 100644 index 00000000..10959136 --- /dev/null +++ b/packages/ui/src/components/feedback/ShareOpinionDialog.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { Icon } from '@/components/icon/Icon'; +import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer'; +import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'; +import { useI18n } from '@/lib/i18n'; +import { openExternalUrl } from '@/lib/url'; + +type ShareOpinionDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; +}; + +const SHARE_OPINION_MARKDOWN = `**Help shape what OpenChamber becomes next!** + +Hey 👋, + +OpenChamber has grown mostly through word of mouth, GitHub issues, Discord feedback, and people telling me what is broken, confusing, or surprisingly useful. + +I'm planning the next chapter now - mobile, better remote access, a tighter VS Code ↔ desktop/web/mobile flow, and more, but before building too much, I want to hear from the people actually using it. + +I'm doing a round of short 1-on-1 calls. No sales pitch, no formal script - just a real conversation about how you use OpenChamber, what you love, what frustrates you, and what would make it much more valuable. + +You can book a call or use the short survey below. + +What you get: + +- A direct chance to influence the roadmap +- Your pain points and feature requests prioritized with more context +- A Power User role in Discord for people helping shape the product +- My genuine thanks for helping make OpenChamber better + +**This project is what it is because of your feedback. Thank you, genuinely.** + +I'll remove this button in two weeks 🙂`; + +export function ShareOpinionDialog({ open, onOpenChange }: ShareOpinionDialogProps): React.ReactNode { + const { t } = useI18n(); + + return ( +

+ + {t('shareOpinion.dialog.title')} + +
+ + +
+
+
+ ); +} diff --git a/packages/ui/src/components/icon/sprite.ts b/packages/ui/src/components/icon/sprite.ts index 5f1df0dc..0cc872c6 100644 --- a/packages/ui/src/components/icon/sprite.ts +++ b/packages/ui/src/components/icon/sprite.ts @@ -227,6 +227,7 @@ export const iconSpriteData = { "unpin": ``, "user-3": ``, "user": ``, + "video-chat": ``, "volume-up": ``, "window": ``, } as const satisfies Record; diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index af841951..f8e8b562 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -35,6 +35,7 @@ import { useStickyProjectHeaders } from './sidebar/hooks/useStickyProjectHeaders import { getGitHubPrStatusKey, usePrVisualSummaryByKeys, useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore'; import { ProjectEditDialog } from '@/components/layout/ProjectEditDialog'; import { UpdateDialog } from '@/components/ui/UpdateDialog'; +import { ShareOpinionDialog } from '@/components/feedback/ShareOpinionDialog'; import { SessionGroupSection } from './sidebar/SessionGroupSection'; import { SidebarHeader } from './sidebar/SidebarHeader'; import { SidebarActivitySections } from './sidebar/SidebarActivitySections'; @@ -80,6 +81,7 @@ import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import { subscribeOpenchamberEvents } from '@/lib/openchamberEvents'; +const SHARE_OPINION_TOAST_STORAGE_KEY = 'openchamber.shareOpinionToast.dismissed.v2'; const PROJECT_COLLAPSE_STORAGE_KEY = 'oc.sessions.projectCollapse'; const GROUP_ORDER_STORAGE_KEY = 'oc.sessions.groupOrder'; const GROUP_COLLAPSE_STORAGE_KEY = 'oc.sessions.groupCollapse'; @@ -196,6 +198,7 @@ export const SessionSidebar: React.FC = ({ const newWorktreeDialogOpen = useUIStore((state) => state.isNewWorktreeDialogOpen); const setNewWorktreeDialogOpen = useUIStore((state) => state.setNewWorktreeDialogOpen); const [updateDialogOpen, setUpdateDialogOpen] = React.useState(false); + const [shareOpinionDialogOpen, setShareOpinionDialogOpen] = React.useState(false); const [openSidebarMenuKey, setOpenSidebarMenuKey] = React.useState(null); const [renamingFolderId, setRenamingFolderId] = React.useState(null); const [renameFolderDraft, setRenameFolderDraft] = React.useState(''); @@ -635,6 +638,36 @@ export const SessionSidebar: React.FC = ({ }); }, [t, updateStore]); + const handleOpenShareOpinionDialog = React.useCallback(() => { + setShareOpinionDialogOpen(true); + }, []); + + React.useEffect(() => { + if (typeof window === 'undefined') { + return; + } + try { + if (window.localStorage.getItem(SHARE_OPINION_TOAST_STORAGE_KEY) === 'true') { + return; + } + window.localStorage.setItem(SHARE_OPINION_TOAST_STORAGE_KEY, 'true'); + } catch { + // If storage is unavailable, still show once for this sidebar mount. + } + const timeoutId = window.setTimeout(() => { + toast.info(t('shareOpinion.toast.title'), { + description: t('shareOpinion.toast.description'), + action: { + label: t('shareOpinion.actions.shareOpinion'), + onClick: () => setShareOpinionDialogOpen(true), + }, + duration: 12_000, + }); + }, 1_000); + + return () => window.clearTimeout(timeoutId); + }, [t]); + const handleOpenSettings = React.useCallback(() => { if (mobileVariant) { setSessionSwitcherOpen(false); @@ -1619,10 +1652,16 @@ export const SessionSidebar: React.FC = ({ onOpenShortcuts={toggleHelpDialog} onOpenAbout={() => setAboutDialogOpen(true)} onOpenUpdate={handleOpenUpdateDialog} + onOpenShareOpinion={handleOpenShareOpinionDialog} showRuntimeButtons={!isVSCode} showUpdateButton={showSidebarUpdateButton} /> + + void; onOpenAbout: () => void; onOpenUpdate: () => void; + onOpenShareOpinion: () => void; showRuntimeButtons?: boolean; showUpdateButton?: boolean; }; @@ -20,6 +21,7 @@ export function SidebarFooter({ onOpenShortcuts, onOpenAbout, onOpenUpdate, + onOpenShareOpinion, showRuntimeButtons = true, showUpdateButton = true, }: Props): React.ReactNode { @@ -65,7 +67,17 @@ export function SidebarFooter({ > {t('sessions.sidebar.footer.actions.update')} - ) : null} + ) : ( + + )}
); } diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index ecc04004..175439bd 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -22,6 +22,13 @@ export const dict = { 'pwa.installPrompt.dismiss': 'Dismiss', 'pwa.installPrompt.started': 'Install started', 'pwa.installPrompt.installed': 'OpenChamber installed', + 'shareOpinion.actions.shareOpinion': 'Share opinion', + 'shareOpinion.actions.bookCall': 'Book a call', + 'shareOpinion.actions.shortSurvey': 'Short survey', + 'shareOpinion.dialog.title': 'Share your opinion', + 'shareOpinion.toast.title': 'Help shape OpenChamber', + 'shareOpinion.toast.description': 'Share what is useful, confusing, or missing to help shape what comes next.', + 'sessions.sidebar.footer.actions.shareOpinion': 'Share opinion', 'layout.mainTab.chat': 'Chat', 'layout.mainTab.plan': 'Plan', 'layout.mainTab.diff': 'Diff', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 5c412588..22f56c48 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -23,6 +23,13 @@ export const dict: Record = { "pwa.installPrompt.dismiss": "Descartar", "pwa.installPrompt.started": "Instalación iniciada", "pwa.installPrompt.installed": "OpenChamber se instaló", + "shareOpinion.actions.shareOpinion": "Compartir opinión", + "shareOpinion.actions.bookCall": "Reservar llamada", + "shareOpinion.actions.shortSurvey": "Encuesta breve", + "shareOpinion.dialog.title": "Comparte tu opinión", + "shareOpinion.toast.title": "Ayuda a dar forma a OpenChamber", + "shareOpinion.toast.description": "Cuenta qué es útil, confuso o falta para ayudar a definir lo que viene después.", + "sessions.sidebar.footer.actions.shareOpinion": "Compartir opinión", "layout.mainTab.chat": "Chat", "layout.mainTab.plan": "Plan", "layout.mainTab.diff": "Diff", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index efd36d71..330028e5 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -22,6 +22,13 @@ export const dict = { 'pwa.installPrompt.dismiss': 'Ignorer', 'pwa.installPrompt.started': 'Installation démarrée', 'pwa.installPrompt.installed': 'OpenChamber installé', + 'shareOpinion.actions.shareOpinion': 'Donner son avis', + 'shareOpinion.actions.bookCall': 'Réserver un appel', + 'shareOpinion.actions.shortSurvey': 'Court sondage', + 'shareOpinion.dialog.title': 'Donnez votre avis', + 'shareOpinion.toast.title': 'Aidez à façonner OpenChamber', + 'shareOpinion.toast.description': 'Dites ce qui est utile, confus ou manquant pour aider à définir la suite.', + 'sessions.sidebar.footer.actions.shareOpinion': 'Donner son avis', 'layout.mainTab.chat': 'Chat', 'layout.mainTab.plan': 'Plan', 'layout.mainTab.diff': 'Diff', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index b48f61f3..df98d74a 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -23,6 +23,13 @@ export const dict: Record = { 'pwa.installPrompt.dismiss': '閉じる', 'pwa.installPrompt.started': 'インストールを開始しました', 'pwa.installPrompt.installed': 'OpenChamberをインストールしました', + 'shareOpinion.actions.shareOpinion': '意見を共有', + 'shareOpinion.actions.bookCall': '通話を予約', + 'shareOpinion.actions.shortSurvey': '短いアンケート', + 'shareOpinion.dialog.title': 'ご意見をお聞かせください', + 'shareOpinion.toast.title': 'OpenChamber の次を一緒に作る', + 'shareOpinion.toast.description': '便利な点、分かりにくい点、足りない点を教えて、次の方向づくりに参加してください。', + 'sessions.sidebar.footer.actions.shareOpinion': '意見を共有', 'layout.mainTab.chat': 'チャット', 'layout.mainTab.plan': '計画', 'layout.mainTab.diff': '差分', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 91b83456..8047d3c6 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -23,6 +23,13 @@ export const dict: Record = { 'pwa.installPrompt.dismiss': '닫기', 'pwa.installPrompt.started': '설치가 시작되었습니다', 'pwa.installPrompt.installed': 'OpenChamber가 설치되었습니다', + 'shareOpinion.actions.shareOpinion': '의견 공유', + 'shareOpinion.actions.bookCall': '통화 예약', + 'shareOpinion.actions.shortSurvey': '짧은 설문', + 'shareOpinion.dialog.title': '의견을 공유해 주세요', + 'shareOpinion.toast.title': 'OpenChamber의 방향을 함께 만들어 주세요', + 'shareOpinion.toast.description': '유용한 점, 혼란스러운 점, 부족한 점을 알려 주셔서 다음 방향을 함께 만들어 주세요.', + 'sessions.sidebar.footer.actions.shareOpinion': '의견 공유', 'layout.mainTab.chat': '채팅', 'layout.mainTab.plan': '계획', 'layout.mainTab.diff': '변경사항', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 2ecc05c4..2b4c2feb 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -24,6 +24,13 @@ export const dict: Record = { 'pwa.installPrompt.dismiss': 'Odrzuć', 'pwa.installPrompt.started': 'Instalacja rozpoczęta', 'pwa.installPrompt.installed': 'OpenChamber został zainstalowany', + 'shareOpinion.actions.shareOpinion': 'Podziel się opinią', + 'shareOpinion.actions.bookCall': 'Umów rozmowę', + 'shareOpinion.actions.shortSurvey': 'Krótka ankieta', + 'shareOpinion.dialog.title': 'Podziel się opinią', + 'shareOpinion.toast.title': 'Pomóż kształtować OpenChamber', + 'shareOpinion.toast.description': 'Powiedz, co jest przydatne, mylące albo czego brakuje, aby pomóc kształtować kolejne kroki.', + 'sessions.sidebar.footer.actions.shareOpinion': 'Podziel się opinią', 'layout.mainTab.chat': 'Czat', 'layout.mainTab.plan': 'Plan', 'layout.mainTab.diff': 'Różnice', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 00950b28..2ffd48ac 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -23,6 +23,13 @@ export const dict: Record = { "pwa.installPrompt.dismiss": "Dispensar", "pwa.installPrompt.started": "Instalação iniciada", "pwa.installPrompt.installed": "OpenChamber instalado", + "shareOpinion.actions.shareOpinion": "Compartilhar opinião", + "shareOpinion.actions.bookCall": "Agendar chamada", + "shareOpinion.actions.shortSurvey": "Pesquisa breve", + "shareOpinion.dialog.title": "Compartilhe sua opinião", + "shareOpinion.toast.title": "Ajude a moldar o OpenChamber", + "shareOpinion.toast.description": "Conte o que é útil, confuso ou está faltando para ajudar a definir os próximos passos.", + "sessions.sidebar.footer.actions.shareOpinion": "Compartilhar opinião", "layout.mainTab.chat": "Chat", "layout.mainTab.plan": "Plano", "layout.mainTab.diff": "Diff", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 2553e302..40792dc8 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -23,6 +23,13 @@ export const dict: Record = { "pwa.installPrompt.dismiss": "Сховати", "pwa.installPrompt.started": "Установлення розпочато", "pwa.installPrompt.installed": "OpenChamber встановлено", + "shareOpinion.actions.shareOpinion": "Поділитися думкою", + "shareOpinion.actions.bookCall": "Забронювати дзвінок", + "shareOpinion.actions.shortSurvey": "Коротке опитування", + "shareOpinion.dialog.title": "Поділіться своєю думкою", + "shareOpinion.toast.title": "Допоможіть сформувати OpenChamber", + "shareOpinion.toast.description": "Розкажіть, що корисне, незрозуміле чи відсутнє, щоб допомогти сформувати наступні кроки.", + "sessions.sidebar.footer.actions.shareOpinion": "Поділитися думкою", "layout.mainTab.chat": "Чат", "layout.mainTab.plan": "План", "layout.mainTab.diff": "Diff", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index eee31d39..7c68825c 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -23,6 +23,13 @@ export const dict: Record = { 'pwa.installPrompt.dismiss': '忽略', 'pwa.installPrompt.started': '已开始安装', 'pwa.installPrompt.installed': 'OpenChamber 已安装', + 'shareOpinion.actions.shareOpinion': '分享意见', + 'shareOpinion.actions.bookCall': '预约通话', + 'shareOpinion.actions.shortSurvey': '简短问卷', + 'shareOpinion.dialog.title': '分享你的意见', + 'shareOpinion.toast.title': '帮助塑造 OpenChamber', + 'shareOpinion.toast.description': '告诉我们哪些有用、令人困惑或缺失,帮助塑造下一步。', + 'sessions.sidebar.footer.actions.shareOpinion': '分享意见', 'layout.mainTab.chat': '聊天', 'layout.mainTab.plan': '计划', 'layout.mainTab.diff': '差异', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index b6cac65f..605b3eae 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -23,6 +23,13 @@ export const dict: Record = { 'pwa.installPrompt.dismiss': '忽略', 'pwa.installPrompt.started': '已開始安裝', 'pwa.installPrompt.installed': 'OpenChamber 已安裝', + 'shareOpinion.actions.shareOpinion': '分享意見', + 'shareOpinion.actions.bookCall': '預約通話', + 'shareOpinion.actions.shortSurvey': '簡短問卷', + 'shareOpinion.dialog.title': '分享你的意見', + 'shareOpinion.toast.title': '協助塑造 OpenChamber', + 'shareOpinion.toast.description': '告訴我們哪些有用、令人困惑或缺少什麼,協助塑造下一步。', + 'sessions.sidebar.footer.actions.shareOpinion': '分享意見', 'layout.mainTab.chat': '聊天', 'layout.mainTab.plan': '計畫', 'layout.mainTab.diff': '差異', From 81c9e6eaa2926c9f47371a8ed3cc29fc4b9d9285 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 6 Jul 2026 00:39:49 +0300 Subject: [PATCH 0114/1073] fix: preserve chat composer focus on mobile keyboard Keeps the textarea reference available during mobile viewport adjustments Ensures the composer scrolls back into view after keyboard interactions Updates text selection menu dependencies to include the current session --- packages/ui/src/components/chat/ChatInput.tsx | 2 +- packages/ui/src/components/chat/message/TextSelectionMenu.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 00d7cccf..f7c8eae2 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -4265,6 +4265,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo if (!isMobile || !isMobileExpanded || isCapacitorApp()) return; const vv = window.visualViewport; const form = composerFormRef.current; + const textarea = textareaRef.current; if (!vv || !form) return; // The form is trapped inside lower stacking contexts (the composer // wrapper's z-10), so it cannot out-stack the app header with z-index @@ -4299,7 +4300,6 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo // session and won't re-reveal the (still focused) field on its own, // which left the composer parked behind the keyboard. requestAnimationFrame(() => { - const textarea = textareaRef.current; if (textarea && document.activeElement === textarea) { textarea.scrollIntoView({ block: 'nearest' }); } diff --git a/packages/ui/src/components/chat/message/TextSelectionMenu.tsx b/packages/ui/src/components/chat/message/TextSelectionMenu.tsx index d100267b..3e5b1199 100644 --- a/packages/ui/src/components/chat/message/TextSelectionMenu.tsx +++ b/packages/ui/src/components/chat/message/TextSelectionMenu.tsx @@ -544,7 +544,7 @@ export const TextSelectionMenu: React.FC = ({ containerR } finally { setIsAddingToNotes(false); } - }, [currentProjectRef, hideMenu, selectedText, selectedTextMarkdown, t]); + }, [currentProjectRef, currentSessionId, hideMenu, selectedText, selectedTextMarkdown, t]); if (!position.show) return null; From 708cd723f723c9652efaa319d7d685b84e2d8b57 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 6 Jul 2026 00:45:17 +0300 Subject: [PATCH 0115/1073] fix: update button removal notice duration in ShareOpinionDialog --- packages/ui/src/components/feedback/ShareOpinionDialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/components/feedback/ShareOpinionDialog.tsx b/packages/ui/src/components/feedback/ShareOpinionDialog.tsx index 10959136..b3609c63 100644 --- a/packages/ui/src/components/feedback/ShareOpinionDialog.tsx +++ b/packages/ui/src/components/feedback/ShareOpinionDialog.tsx @@ -31,7 +31,7 @@ What you get: **This project is what it is because of your feedback. Thank you, genuinely.** -I'll remove this button in two weeks 🙂`; +I'll remove this button in 10 days 🙂`; export function ShareOpinionDialog({ open, onOpenChange }: ShareOpinionDialogProps): React.ReactNode { const { t } = useI18n(); From 0f3b7f96c25dbf5f7bfdf15a4ea1c3cc3a06ddab Mon Sep 17 00:00:00 2001 From: Catan <84828825+catan271@users.noreply.github.com> Date: Mon, 6 Jul 2026 04:58:44 +0700 Subject: [PATCH 0116/1073] feat(chat): recognize file:start-end as a clickable reference (#2000) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File references in chat messages can now use the 'path:start-end' form (e.g. 'src/foo.ts:120-145'). The reference becomes clickable in the renderer and, on click, the file opens at the start line. Range selection is intentionally not done at this layer — the 'path:start-end' form is parsed only so the link resolves to the correct path; navigation jumps to the start line, matching the behavior of the existing 'path:line' form. - Extract the file-reference parser to a dedicated module so it can be unit-tested without pulling in the markdown renderer's worker dependencies. - Add a range branch to 'parseFileReference' and update the block-code path regex to recognize the new form. - Switch the colon-form regex to a non-greedy path match so 'path:line:col' is no longer mis-parsed as 'path:line' with the first numeric suffix dropped into the path. - Add a unit test covering the new and existing parser forms. --- .../chat/MarkdownRendererImpl.test.ts | 98 +++++++++++ .../components/chat/MarkdownRendererImpl.tsx | 139 ++-------------- .../components/chat/fileReferenceParser.ts | 157 ++++++++++++++++++ 3 files changed, 268 insertions(+), 126 deletions(-) create mode 100644 packages/ui/src/components/chat/MarkdownRendererImpl.test.ts create mode 100644 packages/ui/src/components/chat/fileReferenceParser.ts diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.test.ts b/packages/ui/src/components/chat/MarkdownRendererImpl.test.ts new file mode 100644 index 00000000..eb8b0e54 --- /dev/null +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from 'bun:test'; + +import { parseFileReference, type ParsedFileReference } from './fileReferenceParser'; + +const parse = (value: string): ParsedFileReference | null => parseFileReference(value); + +describe('parseFileReference', () => { + test('returns null for empty or whitespace input', () => { + expect(parse('')).toBeNull(); + expect(parse(' ')).toBeNull(); + }); + + test('parses bare path', () => { + expect(parse('src/foo.ts')).toEqual({ path: 'src/foo.ts' }); + }); + + test('parses path with single line', () => { + expect(parse('src/foo.ts:42')).toEqual({ path: 'src/foo.ts', line: 42 }); + }); + + test('parses path with line and column', () => { + expect(parse('src/foo.ts:42:8')).toEqual({ path: 'src/foo.ts', line: 42, column: 8 }); + }); + + test('parses path with line range', () => { + expect(parse('src/foo.ts:42-58')).toEqual({ + path: 'src/foo.ts', + line: 42, + endLine: 58, + }); + }); + + test('parses path with single-line range (start equals end)', () => { + expect(parse('src/foo.ts:10-10')).toEqual({ + path: 'src/foo.ts', + line: 10, + endLine: 10, + }); + }); + + test('rejects range with end before start', () => { + expect(parse('src/foo.ts:20-10')).toBeNull(); + }); + + test('falls back to path-only when range endpoint is non-numeric', () => { + // `src/foo.ts:10-abc` and `src/foo.ts:abc-20` are malformed; the + // line info is discarded and only the path is returned (the trailing + // `:`-suffix is stripped). + expect(parse('src/foo.ts:10-abc')).toEqual({ path: 'src/foo.ts' }); + expect(parse('src/foo.ts:abc-20')).toEqual({ path: 'src/foo.ts' }); + }); + + test('strips backtick and quote wrapping from range forms', () => { + expect(parse('`src/foo.ts:10-20`')).toEqual({ + path: 'src/foo.ts', + line: 10, + endLine: 20, + }); + expect(parse('"src/foo.ts:1-3"')).toEqual({ + path: 'src/foo.ts', + line: 1, + endLine: 3, + }); + }); + + test('parses absolute Windows path with line range', () => { + expect(parse('C:/repo/src/foo.ts:5-9')).toEqual({ + path: 'C:/repo/src/foo.ts', + line: 5, + endLine: 9, + }); + }); + + test('preserves line:col form (does not interpret as range)', () => { + expect(parse('src/foo.ts:42:8')).toEqual({ + path: 'src/foo.ts', + line: 42, + column: 8, + }); + }); + + test('preserves hash form', () => { + expect(parse('src/foo.ts#L42C8')).toEqual({ + path: 'src/foo.ts', + line: 42, + column: 8, + }); + expect(parse('src/foo.ts#L42')).toEqual({ + path: 'src/foo.ts', + line: 42, + }); + }); + + test('range form takes precedence over line-only when suffix matches digits-dash-digits', () => { + const result = parse('src/foo.ts:42-58'); + expect(result).toEqual({ path: 'src/foo.ts', line: 42, endLine: 58 }); + }); +}); diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx index db97312c..6e71125c 100644 --- a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx @@ -18,7 +18,7 @@ import type { EditorAPI } from '@/lib/api/types'; import { isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime } from '@/lib/desktop'; import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface'; import { ensureOutsideFileGrantForDesktop } from '@/lib/outsideFileGrants'; -import { getDirectoryForFilePath, isAbsoluteFilePath, isFilePathWithinDirectory, normalizeFilePath, toAbsoluteFilePath } from '@/lib/path-utils'; +import { getDirectoryForFilePath, isFilePathWithinDirectory, toAbsoluteFilePath } from '@/lib/path-utils'; import { renderMarkdownBlocks, renderMarkdownSync } from './markdown/markdownCore'; import { ensureMarkdownShikiTheme, getMarkdownSyntaxVars } from './markdown/markdownTheme'; import { @@ -28,6 +28,13 @@ import { type DecorateLabels, type MermaidRender, } from './markdown/decorate'; +import { + BLOCK_PATH_TOKEN_RE, + isAbsoluteReferencePath, + normalizeReferencePath, + parseFileReference, + type ParsedFileReference, +} from './fileReferenceParser'; const useCurrentMermaidTheme = () => { const themeSystem = useOptionalThemeSystem(); @@ -148,16 +155,9 @@ const FILE_LINK_SELECTOR = '[data-openchamber-file-link="true"]'; const BLOCK_PATH_TOKEN_ATTR = 'data-openchamber-block-path-token'; const BLOCK_PATH_TOKEN_SELECTOR = `[${BLOCK_PATH_TOKEN_ATTR}]`; const CODE_BLOCK_PATH_SCANNED_ATTR = 'data-openchamber-block-paths-scanned'; -// Matches `path[:line[:col]]` inside shell/grep-style output. Requires a file -// extension (1-8 alphanumerics) so plain words don't qualify; the path itself -// must contain at least one extension-bearing segment. -// -// Known limitation: backslash-separated Windows paths (e.g. -// `C:\Users\test\file.ts:12`) are not matched because the path character class -// does not include `\`. Compiler output inside fenced code blocks predominantly -// uses forward slashes, so this is a niche gap. The inline-code pipeline is not -// affected — it reads full text content rather than matching with a regex. -const BLOCK_PATH_TOKEN_RE = /(?:[A-Za-z]:[\\/])?[\w.\-/@+]*[\w\-/@+]\.[A-Za-z0-9]{1,8}(?::\d+){0,2}/g; +// Matches `path[:line[:col]]` or `path:start-end` inside shell/grep-style +// output. The regex is defined in `./fileReferenceParser`; the inline-code +// pipeline reads full text content rather than using this regex. const MAX_BLOCK_CODE_SCAN_LENGTH = 200_000; const FILE_REFERENCE_STAT_CONCURRENCY = 4; const FILE_REFERENCE_STAT_CACHE_MAX = 1000; @@ -177,12 +177,6 @@ const getFileReferenceLinkLimit = (): number => ( isVSCodeRuntime() ? VSCODE_FILE_REFERENCE_LINK_LIMIT : FILE_REFERENCE_LINK_LIMIT ); -type ParsedFileReference = { - path: string; - line?: number; - column?: number; -}; - const KNOWN_FILE_BASENAMES = new Set([ 'dockerfile', 'makefile', @@ -192,126 +186,19 @@ const KNOWN_FILE_BASENAMES = new Set([ '.gitignore', '.npmrc', ]); -const KNOWN_BASENAME_PATTERN = Array.from(KNOWN_FILE_BASENAMES) - .map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) - .join('|'); const normalizePath = (value: string): string => { - return normalizeFilePath(value); + return normalizeReferencePath(value); }; const isAbsolutePath = (value: string): boolean => { - return isAbsoluteFilePath(value); + return isAbsoluteReferencePath(value); }; const toAbsolutePath = (basePath: string, targetPath: string): string => { return toAbsoluteFilePath(basePath, targetPath); }; -const trimPathCandidate = (value: string): string => { - let next = (value || '').trim(); - if (!next) { - return ''; - } - - if ((next.startsWith('`') && next.endsWith('`')) || (next.startsWith('"') && next.endsWith('"')) || (next.startsWith("'") && next.endsWith("'"))) { - next = next.slice(1, -1).trim(); - } - - next = next.replace(/[.,;!?]+$/g, ''); - - if (next.endsWith(')') && !next.includes('(')) { - next = next.slice(0, -1); - } - if (next.endsWith(']') && !next.includes('[')) { - next = next.slice(0, -1); - } - - return next; -}; - -const stripTrailingReference = (value: string): string => { - let next = trimPathCandidate(value); - if (!next) { - return ''; - } - - const semicolonIndex = next.indexOf(';'); - if (semicolonIndex >= 0) { - next = next.slice(0, semicolonIndex); - } - - next = next.replace(/#.*$/, ''); - - const extensionSuffixMatch = next.match(/^(.*\.[A-Za-z0-9_-]{1,16}):.*$/); - if (extensionSuffixMatch) { - next = extensionSuffixMatch[1] ?? next; - } - - const basenameSuffixMatch = KNOWN_BASENAME_PATTERN.length > 0 - ? next.match(new RegExp(`^(.*(?:/|^)(${KNOWN_BASENAME_PATTERN})):.*$`, 'i')) - : null; - if (basenameSuffixMatch) { - next = basenameSuffixMatch[1] ?? next; - } - - return trimPathCandidate(next); -}; - -const parseFileReference = (value: string): ParsedFileReference | null => { - const trimmed = trimPathCandidate(value); - if (!trimmed) { - return null; - } - - const semicolonIndex = trimmed.indexOf(';'); - const withoutSemicolonSuffix = semicolonIndex >= 0 - ? trimPathCandidate(trimmed.slice(0, semicolonIndex)) - : trimmed; - if (!withoutSemicolonSuffix) { - return null; - } - - const hashMatch = withoutSemicolonSuffix.match(/^(.*)#L(\d+)(?:C(\d+))?$/i); - if (hashMatch) { - const path = stripTrailingReference(hashMatch[1] ?? ''); - const line = Number.parseInt(hashMatch[2] ?? '', 10); - const column = hashMatch[3] ? Number.parseInt(hashMatch[3], 10) : undefined; - if (!path || !Number.isFinite(line)) { - return null; - } - - return { - path, - line, - column: Number.isFinite(column ?? Number.NaN) ? column : undefined, - }; - } - - const colonMatch = withoutSemicolonSuffix.match(/^(.*):(\d+)(?::(\d+))?$/); - if (colonMatch) { - const path = stripTrailingReference(colonMatch[1] ?? ''); - const line = Number.parseInt(colonMatch[2] ?? '', 10); - const column = colonMatch[3] ? Number.parseInt(colonMatch[3], 10) : undefined; - if (!path || !Number.isFinite(line)) { - return null; - } - - return { - path, - line, - column: Number.isFinite(column ?? Number.NaN) ? column : undefined, - }; - } - - const pathOnly = stripTrailingReference(withoutSemicolonSuffix); - if (!pathOnly) { - return null; - } - - return { path: pathOnly }; -}; - const hasFileExtension = (path: string): boolean => { const base = path.split('/').filter(Boolean).pop() ?? ''; if (!base || base.endsWith('.')) { diff --git a/packages/ui/src/components/chat/fileReferenceParser.ts b/packages/ui/src/components/chat/fileReferenceParser.ts new file mode 100644 index 00000000..b2c6b91e --- /dev/null +++ b/packages/ui/src/components/chat/fileReferenceParser.ts @@ -0,0 +1,157 @@ +import { isAbsoluteFilePath, normalizeFilePath } from '@/lib/path-utils'; + +export type ParsedFileReference = { + path: string; + line?: number; + column?: number; + endLine?: number; +}; + +const KNOWN_FILE_BASENAMES = new Set([ + 'dockerfile', + 'makefile', + 'readme', + 'license', + '.env', + '.gitignore', + '.npmrc', +]); +const KNOWN_BASENAME_PATTERN = Array.from(KNOWN_FILE_BASENAMES) + .map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + .join('|'); + +export const normalizeReferencePath = (value: string): string => normalizeFilePath(value); + +export const isAbsoluteReferencePath = (value: string): boolean => isAbsoluteFilePath(value); + +const trimPathCandidate = (value: string): string => { + let next = (value || '').trim(); + if (!next) { + return ''; + } + + if ((next.startsWith('`') && next.endsWith('`')) || (next.startsWith('"') && next.endsWith('"')) || (next.startsWith("'") && next.endsWith("'"))) { + next = next.slice(1, -1).trim(); + } + + next = next.replace(/[.,;!?]+$/g, ''); + + if (next.endsWith(')') && !next.includes('(')) { + next = next.slice(0, -1); + } + if (next.endsWith(']') && !next.includes('[')) { + next = next.slice(0, -1); + } + + return next; +}; + +const stripTrailingReference = (value: string): string => { + let next = trimPathCandidate(value); + if (!next) { + return ''; + } + + const semicolonIndex = next.indexOf(';'); + if (semicolonIndex >= 0) { + next = next.slice(0, semicolonIndex); + } + + next = next.replace(/#.*$/, ''); + + const extensionSuffixMatch = next.match(/^(.*\.[A-Za-z0-9_-]{1,16}):.*$/); + if (extensionSuffixMatch) { + next = extensionSuffixMatch[1] ?? next; + } + + const basenameSuffixMatch = KNOWN_BASENAME_PATTERN.length > 0 + ? next.match(new RegExp(`^(.*(?:/|^)(${KNOWN_BASENAME_PATTERN})):.*$`, 'i')) + : null; + if (basenameSuffixMatch) { + next = basenameSuffixMatch[1] ?? next; + } + + return trimPathCandidate(next); +}; + +export const parseFileReference = (value: string): ParsedFileReference | null => { + const trimmed = trimPathCandidate(value); + if (!trimmed) { + return null; + } + + const semicolonIndex = trimmed.indexOf(';'); + const withoutSemicolonSuffix = semicolonIndex >= 0 + ? trimPathCandidate(trimmed.slice(0, semicolonIndex)) + : trimmed; + if (!withoutSemicolonSuffix) { + return null; + } + + // Range form: `path:start-end`. Tried before the colon form so a suffix + // like `:10-20` is consumed as a range rather than truncated to a line + // number. Range and col (`:line:col`) are mutually exclusive. + const rangeMatch = withoutSemicolonSuffix.match(/^(.*?):(\d+)-(\d+)$/); + if (rangeMatch) { + const path = stripTrailingReference(rangeMatch[1] ?? ''); + const line = Number.parseInt(rangeMatch[2] ?? '', 10); + const endLine = Number.parseInt(rangeMatch[3] ?? '', 10); + if (!path || !Number.isFinite(line) || !Number.isFinite(endLine) || endLine < line) { + return null; + } + + return { path, line, endLine }; + } + + const hashMatch = withoutSemicolonSuffix.match(/^(.*)#L(\d+)(?:C(\d+))?$/i); + if (hashMatch) { + const path = stripTrailingReference(hashMatch[1] ?? ''); + const line = Number.parseInt(hashMatch[2] ?? '', 10); + const column = hashMatch[3] ? Number.parseInt(hashMatch[3], 10) : undefined; + if (!path || !Number.isFinite(line)) { + return null; + } + + return { + path, + line, + column: Number.isFinite(column ?? Number.NaN) ? column : undefined, + }; + } + + const colonMatch = withoutSemicolonSuffix.match(/^(.*?):(\d+)(?::(\d+))?$/); + if (colonMatch) { + const path = stripTrailingReference(colonMatch[1] ?? ''); + const line = Number.parseInt(colonMatch[2] ?? '', 10); + const column = colonMatch[3] ? Number.parseInt(colonMatch[3], 10) : undefined; + if (!path || !Number.isFinite(line)) { + return null; + } + + return { + path, + line, + column: Number.isFinite(column ?? Number.NaN) ? column : undefined, + }; + } + + const pathOnly = stripTrailingReference(withoutSemicolonSuffix); + if (!pathOnly) { + return null; + } + + return { path: pathOnly }; +}; + +// Matches `path[:line[:col]]` or `path:start-end` inside shell/grep-style +// output. Requires a file extension (1-8 alphanumerics) so plain words don't +// qualify; the path itself must contain at least one extension-bearing +// segment. The line suffix is either `:N`, `:N:M`, or `:N-M` (range); col and +// range are mutually exclusive. +// +// Known limitation: backslash-separated Windows paths (e.g. +// `C:\Users\test\file.ts:12`) are not matched because the path character class +// does not include `\`. Compiler output inside fenced code blocks predominantly +// uses forward slashes, so this is a niche gap. The inline-code pipeline is not +// affected — it reads full text content rather than matching with a regex. +export const BLOCK_PATH_TOKEN_RE = /(?:[A-Za-z]:[\\/])?[\w.\-/@+]*[\w\-/@+]\.[A-Za-z0-9]{1,8}(?::\d+(?:-\d+)?(?::\d+)?)?/g; From d8f0ef074b951702182825d62e88b047620101b8 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 6 Jul 2026 01:26:56 +0300 Subject: [PATCH 0117/1073] fix: compute first changed diff line from patch hunks Uses hunk contents to find the first modified line instead of the hunk start Handles added, removed, and binary-only patches more accurately Adds tests for patch parsing edge cases --- .../ui/src/components/views/DiffView.test.ts | 26 ++++++++++++++ packages/ui/src/components/views/DiffView.tsx | 25 +++---------- .../ui/src/components/views/diffPatchUtils.ts | 36 +++++++++++++++++++ 3 files changed, 67 insertions(+), 20 deletions(-) create mode 100644 packages/ui/src/components/views/DiffView.test.ts create mode 100644 packages/ui/src/components/views/diffPatchUtils.ts diff --git a/packages/ui/src/components/views/DiffView.test.ts b/packages/ui/src/components/views/DiffView.test.ts new file mode 100644 index 00000000..19f38bf5 --- /dev/null +++ b/packages/ui/src/components/views/DiffView.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from 'bun:test'; + +import { getFirstChangedModifiedLineFromPatch } from './diffPatchUtils'; + +describe('getFirstChangedModifiedLineFromPatch', () => { + test('returns the first added line instead of the hunk context start', () => { + expect(getFirstChangedModifiedLineFromPatch(`diff --git a/src/file.ts b/src/file.ts +@@ -56,10 +56,11 @@ + unchanged 58 + unchanged 59 + unchanged 60 ++changed 61 + unchanged 62`)).toBe(59); + }); + + test('returns the following modified line for deletion-only hunks', () => { + expect(getFirstChangedModifiedLineFromPatch(`@@ -10,4 +10,3 @@ + context +-removed + after`)).toBe(11); + }); + + test('returns null when the patch has no hunk change lines', () => { + expect(getFirstChangedModifiedLineFromPatch('Binary files a/image.png and b/image.png differ')).toBeNull(); + }); +}); diff --git a/packages/ui/src/components/views/DiffView.tsx b/packages/ui/src/components/views/DiffView.tsx index 6a620a6b..c1447a67 100644 --- a/packages/ui/src/components/views/DiffView.tsx +++ b/packages/ui/src/components/views/DiffView.tsx @@ -39,6 +39,7 @@ import { fileDiffFromPatch } from '@/lib/diff/patchFileDiff'; import { isVSCodeRuntime } from '@/lib/desktop'; import { startReviewFlow } from '@/lib/reviewFlow'; import { useSessionUIStore } from '@/sync/session-ui-store'; +import { getFirstChangedModifiedLineFromPatch } from './diffPatchUtils'; import type { FileDiffMetadata } from '@pierre/diffs'; // Minimum width for side-by-side diff view (px) @@ -160,24 +161,6 @@ const getFirstChangedModifiedLine = (original: string, modified: string): number return 1; }; -const getFirstVisibleModifiedLineFromPatch = (patch: string): number | null => { - if (!patch) { - return null; - } - - const match = patch.match(/@@\s*-\d+(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s*@@/m); - if (!match) { - return null; - } - - const parsed = Number.parseInt(match[1], 10); - if (!Number.isFinite(parsed) || parsed < 1) { - return null; - } - - return parsed; -}; - const isBinaryPatch = (patch: string): boolean => /^Binary files .+ differ$/m.test(patch) || /^GIT binary patch$/m.test(patch); @@ -1422,7 +1405,9 @@ export const DiffView: React.FC = ({ try { let targetLine: number | null = null; - if (cachedDiffData && !cachedDiffData.isBinary && !isImageFile(filePath)) { + if (cachedDiffData?.patch && !cachedDiffData.isBinary && !isImageFile(filePath)) { + targetLine = getFirstChangedModifiedLineFromPatch(cachedDiffData.patch); + } else if (cachedDiffData && cachedDiffData.contextMode === 'full' && !cachedDiffData.isBinary && !isImageFile(filePath)) { targetLine = getFirstChangedModifiedLine(cachedDiffData.original, cachedDiffData.modified); } @@ -1433,7 +1418,7 @@ export const DiffView: React.FC = ({ staged: activeDiffStaged, contextLines: 3, }); - targetLine = getFirstVisibleModifiedLineFromPatch(patchResponse.diff); + targetLine = getFirstChangedModifiedLineFromPatch(patchResponse.diff); } catch { targetLine = null; } diff --git a/packages/ui/src/components/views/diffPatchUtils.ts b/packages/ui/src/components/views/diffPatchUtils.ts new file mode 100644 index 00000000..8ab335c3 --- /dev/null +++ b/packages/ui/src/components/views/diffPatchUtils.ts @@ -0,0 +1,36 @@ +export const getFirstChangedModifiedLineFromPatch = (patch: string): number | null => { + if (!patch) { + return null; + } + + const lines = patch.split('\n'); + let modifiedLine: number | null = null; + + for (const line of lines) { + const hunkMatch = line.match(/^@@\s*-\d+(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s*@@/); + if (hunkMatch) { + const parsed = Number.parseInt(hunkMatch[1] ?? '', 10); + modifiedLine = Number.isFinite(parsed) && parsed >= 1 ? parsed : null; + continue; + } + + if (modifiedLine === null) { + continue; + } + + if (line.startsWith(' ')) { + modifiedLine += 1; + continue; + } + + if (line.startsWith('+')) { + return modifiedLine; + } + + if (line.startsWith('-')) { + return Math.max(1, modifiedLine); + } + } + + return null; +}; From 9a7d7a4379fd78c8005f5d975e57247c60599433 Mon Sep 17 00:00:00 2001 From: Catan <84828825+catan271@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:16:23 +0700 Subject: [PATCH 0118/1073] fix(vscode): restore previous view when exiting settings (closes #1776) (closes #1848) --- .../ui/src/components/layout/VSCodeLayout.tsx | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/layout/VSCodeLayout.tsx b/packages/ui/src/components/layout/VSCodeLayout.tsx index a2dd707d..10d44b28 100644 --- a/packages/ui/src/components/layout/VSCodeLayout.tsx +++ b/packages/ui/src/components/layout/VSCodeLayout.tsx @@ -108,6 +108,10 @@ export const VSCodeLayout: React.FC = () => { }, []); const [currentView, setCurrentView] = React.useState(() => (bootDraftOpen ? 'chat' : 'sessions')); + // Mirror currentView so the navigate event handler (registered once) can read the live value. + const currentViewRef = React.useRef(currentView); + // Snapshot of the view the user was on before opening Settings, so close restores it. + const viewBeforeSettingsRef = React.useRef(null); const [containerWidth, setContainerWidth] = React.useState(0); const [expandedSidebarWidth, setExpandedSidebarWidth] = React.useState(SESSIONS_SIDEBAR_WIDTH); const [isResizingExpandedSidebar, setIsResizingExpandedSidebar] = React.useState(false); @@ -185,6 +189,11 @@ export const VSCodeLayout: React.FC = () => { } }, [currentSessionId]); + // Keep currentViewRef in sync so the stable navigate handler reads the live view. + React.useEffect(() => { + currentViewRef.current = currentView; + }, [currentView]); + React.useEffect(() => { const vscodeApi = runtimeApis.vscode; if (!vscodeApi) { @@ -347,6 +356,9 @@ export const VSCodeLayout: React.FC = () => { const detail = (event as CustomEvent<{ view?: string }>).detail; const view = detail?.view; if (view === 'settings') { + if (currentViewRef.current !== 'settings') { + viewBeforeSettingsRef.current = currentViewRef.current; + } setCurrentView('settings'); } else if (view === 'chat') { setCurrentView('chat'); @@ -532,7 +544,11 @@ export const VSCodeLayout: React.FC = () => { // Settings view setCurrentView(usesExpandedLayout ? 'chat' : 'sessions')} + onClose={() => { + const previousView = viewBeforeSettingsRef.current; + viewBeforeSettingsRef.current = null; + setCurrentView(previousView ?? (usesExpandedLayout ? 'chat' : 'sessions')); + }} forceMobile={usesMobileLayout} /> From 46784e9ed2a13b2f2a461a2b9a3c3f9068c5c4e7 Mon Sep 17 00:00:00 2001 From: Catan <84828825+catan271@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:18:38 +0700 Subject: [PATCH 0119/1073] fix(vscode): persist model favorites (#1995) --- packages/ui/src/lib/modelPrefsAutoSave.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/ui/src/lib/modelPrefsAutoSave.ts b/packages/ui/src/lib/modelPrefsAutoSave.ts index e9d8ecbf..130f5253 100644 --- a/packages/ui/src/lib/modelPrefsAutoSave.ts +++ b/packages/ui/src/lib/modelPrefsAutoSave.ts @@ -1,6 +1,5 @@ import { useUIStore } from '@/stores/useUIStore'; import { updateDesktopSettings } from '@/lib/persistence'; -import { isVSCodeRuntime } from '@/lib/desktop'; type ModelRef = { providerID: string; modelID: string }; type ModelPrefsPayload = { @@ -72,9 +71,6 @@ export const startModelPrefsAutoSave = () => { if (typeof window === 'undefined') { return () => {}; } - if (isVSCodeRuntime()) { - return () => {}; - } let timer: number | null = null; let lastSent: ModelPrefsPayload | null = null; From 18d1c9111a8341ca109a5b65aed9d455aa1b930c Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 6 Jul 2026 17:24:01 +0300 Subject: [PATCH 0120/1073] feat: add load earlier support to chat timeline Adds a load older button when earlier history is available Preserves scroll position while older messages are loaded Shows date-grouped messages with clearer per-message timestamps --- .../ui/src/components/chat/ChatContainer.tsx | 3 + .../ui/src/components/chat/TimelineDialog.tsx | 286 ++++++++++++------ 2 files changed, 198 insertions(+), 91 deletions(-) diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index 47e5d92b..883b832e 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -993,6 +993,9 @@ export const ChatContainer: React.FC = ({ autoOpenDraft = tr onScrollToMessage={timelineController.scrollToMessage} onScrollByTurnOffset={navigation.scrollByTurnOffset} onResumeToLatest={resumeToLatestInstant} + canLoadEarlier={timelineController.historySignals.canLoadEarlier} + isLoadingEarlier={timelineController.isLoadingOlder} + onLoadEarlier={handleLoadOlderClick} />
); diff --git a/packages/ui/src/components/chat/TimelineDialog.tsx b/packages/ui/src/components/chat/TimelineDialog.tsx index 4800c27b..bc16b005 100644 --- a/packages/ui/src/components/chat/TimelineDialog.tsx +++ b/packages/ui/src/components/chat/TimelineDialog.tsx @@ -7,6 +7,7 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionMessageRecords } from '@/sync/sync-context'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; @@ -22,6 +23,9 @@ interface TimelineDialogProps { onScrollToMessage?: (messageId: string) => void | Promise; onScrollByTurnOffset?: (offset: number) => void; onResumeToLatest?: () => void; + canLoadEarlier?: boolean; + isLoadingEarlier?: boolean; + onLoadEarlier?: () => void; } export const TimelineDialog: React.FC = ({ @@ -30,6 +34,9 @@ export const TimelineDialog: React.FC = ({ onScrollToMessage, onScrollByTurnOffset, onResumeToLatest, + canLoadEarlier = false, + isLoadingEarlier = false, + onLoadEarlier, }) => { const { t } = useI18n(); const currentSessionId = useSessionUIStore((state) => state.currentSessionId); @@ -43,31 +50,31 @@ export const TimelineDialog: React.FC = ({ const [searchQuery, setSearchQuery] = React.useState(''); const [selectedIndex, setSelectedIndex] = React.useState(0); const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]); + const listRef = React.useRef(null); + const pendingLoadAnchorRef = React.useRef<{ messageId: string; top: number } | null>(null); + const preservingLoadPositionRef = React.useRef(false); + const wasOpenRef = React.useRef(open); - const formatRelativeTime = React.useCallback((timestamp: number): string => { - const now = Date.now(); - const diffMs = now - timestamp; - const diffSecs = Math.floor(diffMs / 1000); - const diffMins = Math.floor(diffSecs / 60); - const diffHours = Math.floor(diffMins / 60); - const diffDays = Math.floor(diffHours / 24); + const formatDateGroup = React.useCallback((timestamp: number): string => { + return new Date(timestamp).toLocaleDateString(getCurrentIntlLocale(), { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + }, []); - if (diffSecs < 60) return t('chat.timeline.relative.justNow'); - if (diffMins < 60) return t('chat.timeline.relative.minutesAgo', { count: diffMins }); - if (diffHours < 24) return t('chat.timeline.relative.hoursAgo', { count: diffHours }); - if (diffDays < 7) return t('chat.timeline.relative.daysAgo', { count: diffDays }); - return new Date(timestamp).toLocaleDateString(getCurrentIntlLocale()); - }, [t]); + const formatMessageTime = React.useCallback((timestamp: number): string => { + return new Date(timestamp).toLocaleTimeString(getCurrentIntlLocale(), { + hour: 'numeric', + minute: '2-digit', + }); + }, []); // Timeline actions are only valid for user messages. const userMessages = React.useMemo(() => { return messages .filter((message) => message.info.role === 'user') - .map((message, index) => ({ - message, - messageNumber: index + 1, - })) - .reverse(); + .map((message) => ({ message })); }, [messages]); // Filter by search query using all text parts in each user message. @@ -83,19 +90,89 @@ export const TimelineDialog: React.FC = ({ }, [userMessages, searchQuery]); React.useEffect(() => { - setSelectedIndex(0); - }, [filteredMessages]); + if (preservingLoadPositionRef.current) { + return; + } + + setSelectedIndex(searchQuery.trim() ? 0 : Math.max(0, filteredMessages.length - 1)); + }, [filteredMessages, searchQuery]); React.useEffect(() => { itemRefs.current = itemRefs.current.slice(0, filteredMessages.length); }, [filteredMessages.length]); React.useEffect(() => { + if (preservingLoadPositionRef.current) { + return; + } + itemRefs.current[selectedIndex]?.scrollIntoView({ block: 'nearest', }); }, [selectedIndex]); + React.useEffect(() => { + if (!preservingLoadPositionRef.current || pendingLoadAnchorRef.current || isLoadingEarlier) { + return; + } + + preservingLoadPositionRef.current = false; + }, [filteredMessages.length, isLoadingEarlier]); + + React.useLayoutEffect(() => { + const wasOpen = wasOpenRef.current; + wasOpenRef.current = open; + + if (!open || wasOpen || preservingLoadPositionRef.current || searchQuery.trim()) { + return; + } + + const container = listRef.current; + if (!container) { + return; + } + + container.scrollTop = container.scrollHeight; + }, [open, searchQuery]); + + React.useLayoutEffect(() => { + const anchor = pendingLoadAnchorRef.current; + const container = listRef.current; + if (!anchor || !container || isLoadingEarlier) { + return; + } + + pendingLoadAnchorRef.current = null; + const anchoredRow = itemRefs.current.find((row) => row?.dataset.timelineMessageId === anchor.messageId); + if (!anchoredRow) { + return; + } + + const nextTop = anchoredRow.getBoundingClientRect().top - container.getBoundingClientRect().top; + container.scrollTop += nextTop - anchor.top; + }, [filteredMessages.length, isLoadingEarlier]); + + const handleLoadEarlier = React.useCallback(() => { + const container = listRef.current; + if (container) { + const containerTop = container.getBoundingClientRect().top; + const firstVisibleRow = itemRefs.current.find((row) => { + if (!row) return false; + return row.getBoundingClientRect().bottom >= containerTop; + }); + + if (firstVisibleRow?.dataset.timelineMessageId) { + pendingLoadAnchorRef.current = { + messageId: firstVisibleRow.dataset.timelineMessageId, + top: firstVisibleRow.getBoundingClientRect().top - containerTop, + }; + } + } + + preservingLoadPositionRef.current = true; + onLoadEarlier?.(); + }, [onLoadEarlier]); + const navigateToMessage = React.useCallback(async (messageId: string) => { const didNavigate = await onScrollToMessage?.(messageId); if (didNavigate === false) { @@ -171,16 +248,40 @@ export const TimelineDialog: React.FC = ({ />
-
+ {canLoadEarlier && onLoadEarlier && ( +
+ +
+ )} + +
{filteredMessages.length === 0 ? (
{searchQuery ? t('chat.timeline.empty.search') : t('chat.timeline.empty.session')}
) : ( - filteredMessages.map(({ message, messageNumber }, index) => { + filteredMessages.map(({ message }, index) => { const preview = getMessagePreview(message.parts); const timestamp = message.info.time.created; - const relativeTime = formatRelativeTime(timestamp); + const dateGroup = formatDateGroup(timestamp); + const previous = filteredMessages[index - 1]; + const previousDateGroup = previous + ? formatDateGroup(previous.message.info.time.created) + : null; + const showDateGroup = dateGroup !== previousDateGroup; + const messageTime = formatMessageTime(timestamp); const isSelected = index === selectedIndex; const snippet = searchQuery.trim() @@ -188,82 +289,85 @@ export const TimelineDialog: React.FC = ({ : null; return ( -
{ - itemRefs.current[index] = element; - }} - className={cn( - "group flex items-center gap-2 py-1.5 hover:bg-interactive-hover/30 rounded transition-colors cursor-pointer", - isSelected && "bg-interactive-selection text-interactive-selection-foreground" + + {showDateGroup && ( +
+
+ + {dateGroup} + +
+
)} - onClick={() => void navigateToMessage(message.info.id)} - onMouseEnter={() => setSelectedIndex(index)} - > - - {messageNumber}. - -

- {snippet ?? (preview || t('chat.timeline.noTextContent'))} - {!snippet && preview && preview.length >= 80 && '…'} -

- -
+
{ + itemRefs.current[index] = element; + }} + data-timeline-message-id={message.info.id} + className={cn( + "group flex items-center gap-3 py-1.5 hover:bg-interactive-hover/30 rounded transition-colors cursor-pointer", + isSelected && "bg-interactive-selection text-interactive-selection-foreground" + )} + onClick={() => void navigateToMessage(message.info.id)} + onMouseEnter={() => setSelectedIndex(index)} + > - {relativeTime} + {messageTime} +

+ {snippet ?? (preview || t('chat.timeline.noTextContent'))} + {!snippet && preview && preview.length >= 80 && '…'} +

-
- - - - - {t('chat.timeline.actions.revertFromHere')} - +
+
+ + + + + {t('chat.timeline.actions.revertFromHere')} + - - - - - {t('chat.timeline.actions.forkFromHere')} - + + + + + {t('chat.timeline.actions.forkFromHere')} + +
-
+ ); }) )} From 1824b51155e831e590f342b318f74627c31d4065 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 6 Jul 2026 20:00:53 +0300 Subject: [PATCH 0121/1073] fix: scope session assist to session directory --- .../ui/src/components/chat/ChatContainer.tsx | 2 +- packages/ui/src/components/chat/ChatInput.tsx | 2 + .../components/chat/SessionRecapSpacer.tsx | 5 ++- .../components/chat/SessionSuggestionChip.tsx | 5 ++- packages/ui/src/hooks/useSessionAssist.ts | 12 +++--- .../web/server/lib/session-assist/runtime.js | 38 ++++++++++++++++++- 6 files changed, 52 insertions(+), 12 deletions(-) diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index 883b832e..ea3f4964 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -267,7 +267,7 @@ const ChatViewport = React.memo(({
)} - +
diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index f7c8eae2..df4dabdb 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -4758,6 +4758,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo