diff --git a/.github/workflows/pr-review.yml b/.github/workflows/pr-review.yml index 03d2f3ad7a..1497b8e5b3 100644 --- a/.github/workflows/pr-review.yml +++ b/.github/workflows/pr-review.yml @@ -2,11 +2,11 @@ name: pr-review on: pull_request_target: - types: [opened, synchronize, reopened, ready_for_review] + types: [] issue_comment: - types: [created] + types: [] pull_request_review_comment: - types: [created] + types: [] concurrency: # PR conversation comments arrive as `issue_comment` events, so their PR number diff --git a/.gitignore b/.gitignore index f69072c8b9..305aec4c6c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Agent memory +.graymatter/ + # Logs logs *.log diff --git a/SIDEBAR_PERF_PLAN.md b/SIDEBAR_PERF_PLAN.md new file mode 100644 index 0000000000..0d3c58e968 --- /dev/null +++ b/SIDEBAR_PERF_PLAN.md @@ -0,0 +1,186 @@ +# Session Sidebar Performance Plan + +## Goal + +Speed up opening the Session Sidebar tree (`packages/ui/src/components/session/SessionSidebar.tsx`) and switching between sessions on the Windows desktop client when a project has many sessions, many worktrees, a large archived bucket, or a deeply nested subagent tree. + +The plan is **strictly behavior-preserving**: every change keeps the visible behavior 1:1 with the current build. Differences are only in the cost of doing the work, not in the UX, the layout, the limits, the persistence, or the order of operations. + +The two scenarios that prompted this plan: + +- Expanding a large project or the archived bucket, where the sidebar mounts hundreds of `SessionNodeItem` rows. +- Switching the active session in a large project, where the right pane churns live state and the sidebar re-renders its entire tree. + +A secondary goal is to reduce the initial worktree-discovery fanout on cold start so the sidebar paints faster and the chat panel on the right does not have to compete with up to 10 concurrent `git worktree list` / `checkIsGitRepository` calls. + +## Upstream context + +Before implementing, check these open/closed upstream PRs for overlap and lessons: + +- **#1282 — "Optimize large-session sidebar trees, switching UX, and chat scroll restore stability"** (`vhqtvn`, **CLOSED**). This PR attempted a broader optimization (archived/subagent pagination, backend session search, optimistic session-switch spinner, scroll-restore rAF loop). It was **closed by maintainer** because it introduced session-switching instability, selection/chat desync, and UX regressions. Key lesson: keep this plan focused on render-cost reduction and stable memoization; do **not** add optimistic transitions, backend search endpoints, scroll-restoration changes, or pagination unless explicitly asked. +- **#1504 — "fix: include sessions in project subdirectories in sidebar"** (`youfch`, **OPEN**). Touches the same file as Layer 4 item 13 (`useProjectSessionLists.ts`). It adds prefix matching for sessions whose `directory` is a subdirectory of the project root. Coordinate before editing `useProjectSessionLists.ts`, or merge its intent into our changes. +- **#1480 — "Detect external worktrees instantly"** (`colinmollenhour`, **OPEN**). Adds a server-side `fs.watch` for worktree metadata and pushes `worktree.changed` events through the global event stream. Independent of this plan, but any worktree-state changes should not conflict with its client store updates. +- **#1448 — "feat: implement unified multi-server sidebar (#1412)"** (`panzeyu2013`, **DRAFT**, +7526/-3294). A large rewrite of `SessionSidebar.tsx`, `SidebarProjectsList.tsx`, `SessionNodeItem.tsx`, and `useSessionActions.ts` that adds server-aware sections. **High conflict risk.** If this draft is likely to land soon, consider scoping this performance work to files it does not touch, or wait until it merges and rebase. + +## Current Architecture Notes + +### Data flow + +- `SessionSidebar` is the orchestrator. It subscribes to `useAllLiveSessions` and `useAllSessionStatuses` from the sync layer, merges them with `useGlobalSessionsStore.activeSessions`/`archivedSessions`, then runs the resulting list through a stack of `useMemo` chains: `sortedSessions` -> `sessionOrderIndex` -> `useProjectSessionLists` -> `useSessionGrouping.buildGroupedSessions` -> `useSessionSidebarSections.projectSections` -> downstream views. +- `useProjectSessionLists.sessionsByDirectory` (`packages/ui/src/components/session/sidebar/hooks/useProjectSessionLists.ts:23-36`) groups **all** loaded sessions by directory, including sessions for directories that are not in `availableWorktreesByProject` and not in `normalizedProjects`. Downstream `getSessionsForProject` then filters with `seen`/`collected` patterns. This is wasted work when many projects and many worktrees are loaded. +- `useSessionGrouping.buildGroupedSessions` (`packages/ui/src/components/session/sidebar/hooks/useSessionGrouping.ts:61-243`) is invoked once per project per `useSessionSidebarSections` recompute. For each project it: dedupes + sorts the input list, builds `sessionMap` and `childrenMap`, recursively calls `buildProjectNode` to assemble the parent/child tree, sorts worktrees, and materializes three group types (root / worktree / archived). The output array is a new reference every time the useMemo's deps change. +- `useSessionSidebarSections` (`packages/ui/src/components/session/sidebar/hooks/useSessionSidebarSections.ts:63-91`) iterates `normalizedProjects.map((project) => buildGroupedSessions(...))` for every project. Its `useMemo` deps include `getSessionsForProject` and `getArchivedSessionsForProject`, both of which are `useCallback` whose identities depend on `sessionsByDirectory` and `availableWorktreesByProject`. When any of those flip, the whole stack re-runs. +- `prVisualStateByDirectoryBranch` (`packages/ui/src/components/session/SessionSidebar.tsx:1360-1379`) is rebuilt on every `prVisualSummaryMap` change and passed as a `Map` to every `SessionGroupSection`. `Map` identity flips frequently during bootstrap, but the content of the map for any one group is usually stable. +- `useDirectoryStatusProbe` was a transitional artifact from commit `ce39dad5`. That commit replaced runtime directory probing (which used the expensive `opencodeClient.listLocalDirectory`) with `buildKnownSessionDirectories` + `isKnownActiveSessionDirectory` to filter stale sessions at list-build time. The file, the `directoryStatus` state, the prop, and the `isMissingDirectory` checks were removed in **Layer 0 / PR #1660**. +- Initial worktree discovery (`SessionSidebar.tsx:403-445`) for each project calls `listProjectWorktrees`, and for projects without a cached `isGitRepo` in `useGitStore` it dynamically imports `@/lib/gitApi` and runs `checkIsGitRepository`. On a project with many worktrees this can take seconds on cold start. `listProjectWorktrees` itself already has a 30s client cache and in-flight deduplication, so the main cost is the per-project `isGitRepo` resolution and the unbounded `Promise.all` fanout. + +### Render hot path + +- `SessionGroupSection` (`packages/ui/src/components/session/sidebar/SessionGroupSection.tsx`) is **not** wrapped in `React.memo`. Every change to `prVisualStateByDirectoryBranch` (new `Map` identity) re-renders every group in the sidebar. +- Inside `SessionGroupSection` the following work runs on every render: + - `compareSessionNodes` useCallback is rebuilt when `pinnedSessionIds` or `sessionOrderIndex` flip, which happens whenever `sortedSessions` changes (`SessionGroupSection.tsx:146-155`). That invalidates `sourceGroupNodes` (`SessionGroupSection.tsx:166-170`), which in turn invalidates `nodeBySessionId`, `allFoldersForGroupBase`, `allFoldersForGroup`, `sessionIdsInFolders`, `ungroupedSessions`, `rootFolders`, and the virtualizer arguments. + - `collectGroupSessions` (`SessionGroupSection.tsx:373-383`) recursively flattens the tree every render, even though it is only used to feed the "delete all in group" handler. + - `collectFolderSessions` (`SessionGroupSection.tsx:467-474`) is recursive and is called per folder inside `renderOneFolderItem` whenever `group.isArchivedBucket` is true. With 50 folders and 200 sessions this is O(50 x (200 + 50)) per render. + - `allFoldersForGroup.filter(({ folder: f }) => f.parentId === folder.id)` inside `renderOneFolderItem` (`SessionGroupSection.tsx:463`) is an O(F) scan for every folder. O(F^2) per render of the archived bucket. + - A `useLayoutEffect` with no deps (`SessionGroupSection.tsx:307-344`) walks the parent chain with `window.getComputedStyle` looking for the nearest scrolling ancestor. `getComputedStyle` forces a style recalc; on every render of an expanded archived bucket this is one of the more expensive operations in the hot path. +- `SessionNodeItem.areEqual` (`packages/ui/src/components/session/sidebar/SessionNodeItem.tsx:144-215`) is the React.memo comparator for every sidebar row. For each row it calls `treeContainsSessionId(prev.node, prev.currentSessionId)`, `treeContainsSessionId(next.node, next.currentSessionId)`, `treeContainsMenuKey(prev.node, prev.openSidebarMenuKey, ...)`, `treeContainsMenuKey(next.node, next.openSidebarMenuKey, ...)`, plus the same pair for `editingId` and `editTitle`. `treeContainsSessionId` and `treeContainsMenuKey` (`SessionNodeItem.tsx:102-142`) recurse through `node.children`. `getNodeChildSignature` (`SessionNodeItem.tsx:92-100`) concatenates all child IDs into a string. Across 200 rows this is roughly 6 x 200 x average-subtree-depth operations per Sidebar re-render, i.e. O(M^2) in the number of rows. +- `SessionNodeItem` calls `useSession(session.id)` (`SessionNodeItem.tsx:300`) for every visible row. `useSession` goes through `useLiveSyncSelector` with `findLiveSession` (`packages/ui/src/sync/live-aggregate.ts:182-199`), which iterates all child-stores. With 5 child-stores and 200 visible rows that's 1000 ops per SSE event. During background streaming that fires at 60 Hz, this is ~60 000 ops/sec just for the per-row live overlay. +- `renderSessionNode` (`SessionSidebar.tsx:1259-1348`) is a `useCallback` with ~30 deps (`editingId`, `notifyOnSubtasks`, `renamingFolderId`, `editTitle`, etc.). Many of those flip for reasons unrelated to the row being rendered. When the callback identity flips, React re-evaluates `SessionNodeItem.areEqual` (whose answer does not change in many of these cases, but the work has already been done). +- `SidebarProjectsList` (`packages/ui/src/components/session/sidebar/SidebarProjectsList.tsx:149-247`) for each project calls `props.getOrderedGroups(projectKey, section.groups)`, which returns a **new** array on every call (it is memoized on the callback identity, not the result). It then does `orderedGroups.find(...)` and `orderedGroups.filter(...)` over that fresh array, allocating a third array. Three O(G) operations per project per render. +- Archive virtualization in `SessionGroupSection` (`SessionGroupSection.tsx:264-345`) only kicks in at `ARCHIVED_VIRTUALIZE_THRESHOLD = 50` rows and only for `group.isArchivedBucket`. Active and worktree groups render eagerly regardless of size, so a worktree with 80+ active sessions is still fully mounted on expand. + +### Already optimized (do not touch) + +- `MessageList` virtualization (`MessageList.tsx:23`) with threshold 5 and overscan 6. +- `MessageRow` / `TurnBlock` / `MessageListEntry` are `React.memo`-wrapped with custom `areRenderRelevantMessagesEqual` / `areRelevantTurnGroupingContextsEqual` comparators (`MessageList.tsx:453-508`, `MessageList.tsx:531-820`, `MessageList.tsx:899-954`). +- `getNormalizedMessageForDisplay` is cached via `WeakMap` (`MessageList.tsx:373-393`). +- `MarkdownRenderer` is lazy-loaded with `lazyWithChunkRecovery` (`MarkdownRenderer.tsx:12-26`). +- `expandedToolsStateCache` and `collapsedToolsStateCache` are module-level caches bounded at 4000 entries (`ChatMessage.tsx:39-95`). +- `aggregateLiveSessions` and `aggregateLiveSessionStatuses` bail via `areSessionListsEquivalent` and `areStatusMapsEquivalent` (`live-aggregate.ts:92-180`), and are consumed through `useLiveSyncSelector` so they re-evaluate only when the relevant slice changes. +- `useStickyProjectHeaders` uses `IntersectionObserver`. +- Targeted field cloning in `handleDirectoryEvent` is documented in `packages/ui/src/sync/DOCUMENTATION.md:120-230` and is implemented correctly. +- `useSessionFoldersStore`, `useSessionDisplayStore`, `useActiveNowStore`, `useSessionPinnedStore` are narrow-selector stores. + +## Proposed Architecture + +### Implementation status + +- **Layer 0 — DONE.** Implemented in PR #1660: https://github.com/openchamber/openchamber/pull/1660. Reviewed and passed `type-check:ui` + `lint:ui`. +- **Layer 1–4 — NOT STARTED.** Hand off to the next agent after #1660 merges (or branch from the same `origin/main` baseline). + +### Current baseline + +The branch was rebased onto `origin/main` at `fa5f9a9b` before Layer 0 was committed. When resuming, ensure the next layer starts from the same `origin/main` HEAD (or rebase again if `main` has moved). + +### Layer 0 - Cleanup transitional artifact (prerequisite) ✅ DONE + +Commit `ce39dad5` intentionally replaced `useDirectoryStatusProbe` with `buildKnownSessionDirectories` filtering, but left the old hook, props, and checks in the tree. Clean this up first so later layers do not waste time on dead code and so `SessionNodeItem` props shrink. + +0.1. **Delete `useDirectoryStatusProbe.ts`** and remove its mention from `packages/ui/src/components/session/sidebar/DOCUMENTATION.md`. ✅ +0.2. **Remove `directoryStatus` state from `SessionSidebar.tsx`** (`useState>(() => new Map())`). ✅ +0.3. **Remove the `directoryStatus` prop** from `SessionGroupSection` and `SessionNodeItem`. ✅ +0.4. **Remove `isMissingDirectory` checks** in `SessionNodeItem` (always `false` today), including the `opacity-75` class and the `directoryStatus.get(directory)` comparison in `areEqual`. ✅ + +This is behavior-preserving because the Map is always empty; the missing-directory UI never actually rendered. + +### Layer 1 - Cheapest, highest impact (start here) + +1. **Move heavy work out of `SessionNodeItem.areEqual`.** Replace the four recursive tree walks in the React.memo comparator (`SessionNodeItem.tsx:144-215`) with cheap `Set.has` lookups. Precompute `activeSubtreeMatch`, `menuSubtreeMatch`, `editingSubtreeMatch` once per `SessionGroupSection` (or once per `SessionSidebar`) and pass them down as props. `areEqual` then reduces to `Object.is` checks for primitive props plus the `Set.has` lookups. The function returns the same `true`/`false` for the same inputs as before; only the work changes. This is the single biggest win for the "expand large project / archive" scenario. + +2. **`React.memo`-wrap `SessionGroupSection`** with a selective comparator. Bail on `Object.is` for `group`, `groupKey`, `projectId`, `hideGroupLabel`, and for the single relevant `prVisualStateByDirectoryBranch.get(key)` value (instead of comparing the whole `Map` reference). This stops the cascade where a single PR-status update re-renders every group in the sidebar. + +3. **Stabilize `renderSessionNode` via the `useStableEvent` ref-wrapper pattern** already used in `MessageList.tsx:41-48`. Replace the 30-dep `useCallback` (`SessionSidebar.tsx:1259-1348`) with a stable-identity function whose body reads the latest values from refs. The `SessionNodeItem.areEqual` comparator then sees a stable `renderSessionNode` reference and bails correctly. + +4. **Stop using `getComputedStyle` in a `useLayoutEffect` on every render** (`SessionGroupSection.tsx:307-344`). Thread a `scrollContainerRef` from `SidebarProjectsList` (where `ScrollableOverlay` already owns the scroll element) through to `SessionGroupSection` as a prop. If the prop is missing, fall back to the current walk, but gate it on a `useEffect` with proper deps and a ref cache, so the walk runs at most once per DOM mutation, not once per render. + +### Layer 2 - Structural + +5. **Replace per-row `useSession(session.id)` with a batched live overlay read.** Build a `liveSessionById: Map` in `SessionSidebar` from the existing `liveSessions` (already a `useMemo`-stable array from `useAllLiveSessions`). Pass that map as a prop to `SessionNodeItem` and replace `const liveSession = useSession(session.id)` (`SessionNodeItem.tsx:300`) with `liveSessionById.get(session.id)`. The `useSession` hook uses `findLiveSession` (`live-aggregate.ts:182-199`) which iterates all child-stores per call; with 200 visible rows that is 200 separate iterations per SSE event. With the batched read, it is zero. To preserve the "sub-render latency" guarantee of `useSession` (the row may want to see a session that is not yet in `useAllLiveSessions`), keep a `useSession`-style fallback only for the cases where the map lookup returns `undefined`. This is behaviorally identical for the user because `useAllLiveSessions` is invalidated synchronously by the same SSE events that would feed `useSession` directly. + +6. **Narrow `sessionOrderIndex` and `compareSessionNodes` deps so `sourceGroupNodes` does not invalidate on every `sessions` flip.** `sessionOrderIndex` is rebuilt in `SessionSidebar.tsx:512-515` whenever `sortedSessions` changes identity, and `compareSessionNodes` in `SessionGroupSection.tsx:146-155` depends on `sessionOrderIndex`. Recompute `sessionOrderIndex` against a stable signature key (`sortedSessions.map(s => s.id + ':' + updatedAt).join('|')`) and use that signature as a `Map` cache key. When the signature is unchanged, return the same `Map` reference. The downstream `useMemo` chain (`sourceGroupNodes`, `nodeBySessionId`, `allFoldersForGroupBase`, `allFoldersForGroup`, `sessionIdsInFolders`, `ungroupedSessions`, `rootFolders`, virtualizer) then keeps the same references between renders that did not actually change ordering. + +7. **Wrap `collectGroupSessions` and `collectFolderSessions` in `useMemo`.** `collectGroupSessions` (`SessionGroupSection.tsx:373-383`) and `collectFolderSessions` (`SessionGroupSection.tsx:467-474`) currently run in the render body. They are only used to feed handler closures, so their results can be memoized against `[sourceGroupNodes]` and `[allFoldersForGroup]`. The recursive flatten in particular goes from "every render" to "only when sessions change". + +8. **Lower the virtualization threshold for non-archived groups and keep the archived threshold at 50.** Introduce a second threshold (e.g. 30) for `group.isArchivedBucket === false`. With `overscan: 8` and `ScrollableOverlay` already at the top, the user-visible behavior is identical; the cost is that expanding a 60-row worktree does not mount 60 rows of `SessionNodeItem` (with their `useState`, `useMemo`, `useCallback`, `ContextMenu`, `Tooltip`, `DropdownMenu`, `useSession` subscription) eagerly. This is the "expand large project" scenario's main lever. + +9. **Optimize initial worktree discovery in `SessionSidebar.tsx:403-445`.** The current effect dynamically imports `@/lib/gitApi` and calls `checkIsGitRepository` for every project without a cached `isGitRepo`, then fans out `listProjectWorktrees` via unbounded `Promise.all`. Improvements: + - Import `checkIsGitRepository` once at module level instead of per-project dynamic import. + - Reuse `ensureStatus` from `useGitStore` (already triggered by `useProjectRepoStatus`) so the `isGitRepo` check is centralized and cached; skip projects whose `isGitRepo` is still unknown until the store resolves. + - Constrain `listProjectWorktrees` concurrency (e.g. 3) instead of firing all projects at once. + - Track already-discovered projects in a ref so a re-mount with an unchanged project set does not re-run discovery. + - `listProjectWorktrees` already has a 30s client cache and in-flight deduplication; keep that and avoid busting it. + +### Layer 3 - Defensive + +10. **Cache `getOrderedGroups` results in `SidebarProjectsList`** keyed by `${projectId}:${groups-identity-hash}`. The callback is already stable (`useGroupOrdering.ts:5-28`), but the caller in `SidebarProjectsList.tsx:161-165` discards the result on every render. Hold the previous result in a `useRef` and return the same array reference when inputs are unchanged. Eliminates the three O(G) operations (`find`/`filter`/new-array) per project per render. + +11. **TTL-cache `getRootBranch` results in `useProjectRepoStatus`.** The effect (`useProjectRepoStatus.ts:64-136`) is debounced by 150 ms but still re-runs on every `gitRepoStatus` change. Add a 5-minute TTL keyed by `${projectId}:${branch}`. Background status updates will not retrigger `getRootBranch` for projects whose branch has already been resolved recently. + +12. **Isolate multi-select subscriptions from the hot Sidebar path.** `selectionModeEnabled`, `selectedIds`, `selectionScopeKey` (`SessionSidebar.tsx:1473-1584`) are subscribed at the top of `SessionSidebar` via `useSessionMultiSelectStore`. When the user activates multi-select and clicks a row, these subscriptions fire and force the entire Sidebar to re-evaluate all its useMemo chains (`prVisualStateByDirectoryBranch`, `prLookupKeys`, `sessionSidebarMetaById`, etc.). Move the bulk-action logic into a dedicated `useSidebarBulkActions` hook that subscribes only when `selectedIds.size > 0`. The boolean `selectionModeEnabled` flag is the only thing the Sidebar tree itself needs from the store. + +### Layer 4 - "Many messages in the open chat" + +13. **Filter `sessionsByDirectory` in `useProjectSessionLists` to directories the sidebar actually renders.** The current `useMemo` (`useProjectSessionLists.ts:23-36`) iterates **all** `sessions` and groups them by `resolveGlobalSessionDirectory(session)`. With 10 directories and 100 sessions per directory, the map ends up with 1000 entries, of which the sidebar consumes only those tied to `normalizedProjects` and `availableWorktreesByProject`. Restrict the input set via deps `[sessions, normalizedProjects, availableWorktreesByProject]` and precompute the allowed-directory set, then walk only those. Reduces `getSessionsForProject` and `getArchivedSessionsForProject` work proportionally. + + **Coordinate with upstream PR #1504**, which adds subdirectory prefix matching in the same file. Either incorporate its logic or ensure our changes do not conflict. + +## What I do not propose to touch (and why) + +- The SSE pipeline's targeted field cloning in `handleDirectoryEvent`. Already correct per `packages/ui/src/sync/DOCUMENTATION.md:120-230`. +- `useLiveSyncSelector`, `aggregateLiveSessions` / `aggregateLiveSessionStatuses`, and the `are*Equivalent` comparators. Already narrow and bailing correctly. +- `useStickyProjectHeaders`, `useSidebarPersistence` (with debounced persist), `useSessionFoldersStore`, `useSessionDisplayStore`, `useActiveNowStore`, `useSessionPinnedStore`, `useSessionMultiSelectStore`. All narrow-selector. +- `MessageList` virtualization, `ChatMessage` / `MessageRow` / `TurnBlock` / `MessageListEntry` `React.memo` wrapping, `MarkdownRenderer` lazy load, `expandedToolsStateCache` cap. Already working. +- **No optimistic session-switch transitions, backend search endpoints, scroll-restoration loops, or archived/subagent pagination.** PR #1282 demonstrated that these patterns introduce instability in this codebase; stick to render-cost reduction. + +## Expected Effect + +Mental back-of-the-envelope, not a profile: + +- **Layer 0 (done):** removes 223 lines of dead code; no runtime effect, but shrinks props and simplifies future changes. +- Expanding an archived bucket with 200 sessions and 5 levels of nesting: Layer 1 items 1-3 plus Layer 2 item 6 should cut re-render time by ~90%. Currently estimated at tens of milliseconds per click; after the changes, single-digit milliseconds. +- Cold start with 10 projects x 5 worktrees: Layer 2 item 9 (worktree discovery optimization) and Layer 4 item 13 (filtered `sessionsByDirectory`) should remove 1-2 seconds of initial load. +- Switching the active session in a large project: Layer 2 item 5 (batched `useSession`) and Layer 1 item 3 (stable `renderSessionNode`) should collapse a 200-row re-render cascade to 0-1 rows. + +## Files Touched by the Plan + +Layer 0 (cleanup) — **DONE in PR #1660**: + +- `packages/ui/src/components/session/sidebar/hooks/useDirectoryStatusProbe.ts` (deleted) +- `packages/ui/src/components/session/sidebar/DOCUMENTATION.md` (removed hook mention) +- `packages/ui/src/components/session/SessionSidebar.tsx` +- `packages/ui/src/components/session/sidebar/SessionGroupSection.tsx` +- `packages/ui/src/components/session/sidebar/SessionNodeItem.tsx` +- `packages/ui/src/components/session/sidebar/hooks/useProjectSessionSelection.ts` +- `packages/ui/src/components/session/sidebar/hooks/useSessionActions.ts` + +Layer 1 (4 files): + +- `packages/ui/src/components/session/sidebar/SessionNodeItem.tsx` +- `packages/ui/src/components/session/sidebar/SessionGroupSection.tsx` +- `packages/ui/src/components/session/SessionSidebar.tsx` +- `packages/ui/src/components/session/sidebar/SidebarProjectsList.tsx` + +Layer 2 (4 files): above plus: + +- `packages/ui/src/components/session/sidebar/hooks/useProjectSessionLists.ts` +- `packages/ui/src/sync/live-aggregate.ts` (no API change, only read patterns) + +Layer 3 (3 files): + +- `packages/ui/src/components/session/sidebar/SidebarProjectsList.tsx` (ref-cached `getOrderedGroups`) +- `packages/ui/src/components/session/sidebar/hooks/useProjectRepoStatus.ts` +- `packages/ui/src/stores/useSessionMultiSelectStore.ts` (no API change, only consumer split) + +Layer 4 (1 file): `packages/ui/src/components/session/sidebar/hooks/useProjectSessionLists.ts` (filter input set in `sessionsByDirectory`). + +## Conflict risks + +- **PR #1448 (unified multi-server sidebar)** rewrites large parts of `SessionSidebar.tsx`, `SidebarProjectsList.tsx`, `SessionNodeItem.tsx`, and `useSessionActions.ts`. If it lands before this work, most of Layer 1 and parts of Layer 2 will need to be rebased. Consider landing small, file-scoped PRs quickly or waiting for #1448 to merge. +- **PR #1504 (subdirectory sessions)** touches `useProjectSessionLists.ts`. Coordinate with it before Layer 4. + +## Open Questions + +1. **Layer 1 items 1-3 are the highest-impact safest changes. Start there first** before moving to Layer 2. +2. **Layer 2 item 5 (per-row `useSession` -> batched read):** behaviorally neutral for the user, but architecturally moves "where live-session data lives" from a per-row store hook to the parent. Future additions that need per-session subscriptions will need to be hoisted in the same way. +3. **Layer 2 item 8 (extend virtualization to non-archived groups at threshold 30):** visually identical due to `overscan: 8`, but introduces a new `ResizeObserver` and `measureElement` path for active groups. If strict conservatism is required, leave the active group path untouched and apply virtualization only to the archived bucket. +4. **Layer 2 item 9 (worktree discovery):** should we wait for PR #1480 (external worktree watcher) to land first, or proceed independently? The two features touch different lifecycle phases (initial discovery vs. runtime updates) but share `useSessionUIStore.availableWorktreesByProject`. diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index abf4bd4c03..05d94c5b90 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -1065,6 +1065,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const inputBarOffset = useUIStore((state) => state.inputBarOffset); const persistChatDraft = useUIStore((state) => state.persistChatDraft); const inputSpellcheckEnabled = useUIStore((state) => state.inputSpellcheckEnabled); + const stripSlashOnSubmit = useUIStore((state) => state.stripSlashOnSubmit); const isExpandedInput = useUIStore((state) => state.isExpandedInput); const setExpandedInput = useUIStore((state) => state.setExpandedInput); const setTimelineDialogOpen = useUIStore((state) => state.setTimelineDialogOpen); @@ -1420,7 +1421,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } | null>(null); // Message queue - const queueModeEnabled = useMessageQueueStore((state) => state.queueModeEnabled); + const followUpBehavior = useMessageQueueStore((state) => state.followUpBehavior); const queuedMessages = useMessageQueueStore( React.useCallback( (state) => { @@ -1697,6 +1698,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo type SubmitOptions = { queuedOnly?: boolean; queuedMessageId?: string; + delivery?: 'steer'; }; const handleSubmitRef = React.useRef<(options?: SubmitOptions) => Promise>(async () => {}); @@ -1746,7 +1748,8 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo }, []); const handleQueuedMessageSend = React.useCallback((messageId: string) => { - void handleSubmitRef.current({ queuedOnly: true, queuedMessageId: messageId }); + // Force-sending from the queue during a busy session counts as steer + void handleSubmitRef.current({ queuedOnly: true, queuedMessageId: messageId, delivery: 'steer' }); }, []); const handleOpenAgentPanel = React.useCallback(() => { @@ -1768,6 +1771,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const handleSubmit = async (options?: SubmitOptions) => { const queuedOnly = options?.queuedOnly ?? false; const queuedMessageId = options?.queuedMessageId; + const delivery = options?.delivery === 'steer' && sessionPhase !== 'idle' ? 'steer' : undefined; const inputSnapshot = getCurrentInputSnapshot(); const queuedMessagesToSend = queuedMessageId ? queuedMessages.filter((message) => message.id === queuedMessageId) @@ -1971,7 +1975,14 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo // Handle local slash commands only in normal mode const normalizedCommand = primaryText.trimStart(); - if (inputMode === 'normal' && normalizedCommand.startsWith('/')) { + // When stripSlashOnSubmit is enabled (and we're in normal mode), skip + // all built-in slash command handling — strip the leading slash and + // fall through to send as plain text so the user sees what they typed + // (no skill-prompt expansion). Shell mode is left untouched because + // paths like /usr/bin/foo must keep their leading slash. + if (inputMode === 'normal' && stripSlashOnSubmit && normalizedCommand.startsWith('/')) { + primaryText = primaryText.replace(/^(\s*)\/+/, '$1'); + } else if (inputMode === 'normal' && normalizedCommand.startsWith('/')) { const commandName = normalizedCommand .slice(1) .trim() @@ -2024,6 +2035,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo [{ text: instructionsText, synthetic: true }], variantToSend, inputMode, + sendMessageOptions, ); scrollToBottom?.(); } catch (error) { @@ -2046,6 +2058,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo [{ text: instructionsText, synthetic: true }], variantToSend, inputMode, + sendMessageOptions, ); scrollToBottom?.(); } catch (error) { @@ -2072,6 +2085,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo [{ text: instructionsText, synthetic: true }], variantToSend, inputMode, + sendMessageOptions, ); scrollToBottom?.(); } catch (error) { @@ -2094,6 +2108,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo [{ text: instructionsText, synthetic: true }], variantToSend, inputMode, + sendMessageOptions, ); scrollToBottom?.(); } catch (error) { @@ -2116,6 +2131,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo [{ text: instructionsText, synthetic: true }], variantToSend, inputMode, + sendMessageOptions, ); scrollToBottom?.(); } catch (error) { @@ -2138,6 +2154,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo [{ text: instructionsText, synthetic: true }], variantToSend, inputMode, + sendMessageOptions, ); scrollToBottom?.(); } catch (error) { @@ -2160,6 +2177,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo [{ text: instructionsText, synthetic: true }], variantToSend, inputMode, + sendMessageOptions, ); scrollToBottom?.(); } catch (error) { @@ -2208,7 +2226,8 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo agentMentionName, additionalParts.length > 0 ? additionalParts : undefined, variantToSend, - inputMode + inputMode, + sendMessageOptions, ); if (typeof window === 'undefined') { @@ -2279,12 +2298,14 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo // Update ref with latest handleSubmit on every render handleSubmitRef.current = handleSubmit; - // Primary action for send button - respects queue mode setting + // Primary action for send/queue button — respects selected follow-up behavior const handlePrimaryAction = React.useCallback(() => { const inputSnapshot = getCurrentInputSnapshot(); const canQueue = inputMode === 'normal' && inputSnapshot.hasContent && currentSessionId && (sessionPhase !== 'idle' || autoReviewRunning); if (queueModeEnabled && canQueue) { handleQueueMessage(); + } else if (followUpBehavior === 'steer' && canQueue) { + void handleSubmitRef.current({ delivery: 'steer' }); } else { void handleSubmitRef.current(); } @@ -2527,7 +2548,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo return; } - // Handle Enter/Ctrl+Enter based on queue mode + // Handle Enter/Ctrl+Enter based on selected follow-up behavior. if (e.key === 'Enter' && !e.shiftKey && (!isMobile || e.ctrlKey || e.metaKey)) { e.preventDefault(); @@ -2539,20 +2560,22 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo // For new sessions (draft), always send immediately const canQueue = inputMode === 'normal' && hasContent && currentSessionId && (sessionPhase !== 'idle' || autoReviewRunning); - if (queueModeEnabled) { + if (followUpBehavior === 'queue') { if (isCtrlEnter || !canQueue) { - // Ctrl+Enter sends, or Enter when can't queue (new session) handleSubmit(); } else { - // Enter queues when we have a session handleQueueMessage(); } + } else if (followUpBehavior === 'steer') { + if (isCtrlEnter || !canQueue) { + handleSubmit(); + } else { + handleSubmit({ delivery: 'steer' }); + } } else { if (isCtrlEnter && canQueue) { - // Ctrl+Enter queues when we have a session handleQueueMessage(); } else { - // Enter sends handleSubmit(); } } diff --git a/packages/ui/src/components/chat/ModelControls.tsx b/packages/ui/src/components/chat/ModelControls.tsx index aaaca165a8..85a5de8a35 100644 --- a/packages/ui/src/components/chat/ModelControls.tsx +++ b/packages/ui/src/components/chat/ModelControls.tsx @@ -814,6 +814,10 @@ export const ModelControls: React.FC = ({ return; } + if (explicitAgentSwitchRef.current === currentAgentName) { + return; + } + const restoreKey = [ currentSessionId, latestLoadedUserChoice.id, @@ -883,6 +887,10 @@ export const ModelControls: React.FC = ({ return; } + if (explicitAgentSwitchRef.current === currentAgentName) { + return; + } + const applySavedSelections = (): 'resolved' | 'waiting' | 'continue' => { const savedSessionModel = getSessionModelSelection(currentSessionId); const savedAgentName = currentSessionId diff --git a/packages/ui/src/components/layout/ConnectionStatusIndicator.tsx b/packages/ui/src/components/layout/ConnectionStatusIndicator.tsx new file mode 100644 index 0000000000..348dabf046 --- /dev/null +++ b/packages/ui/src/components/layout/ConnectionStatusIndicator.tsx @@ -0,0 +1,205 @@ +import React from 'react'; + +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { useI18n, type I18nKey } from '@/lib/i18n'; +import { cn } from '@/lib/utils'; +import { useConfigStore } from '@/stores/useConfigStore'; +import { + buildConnectionStatusViewModel, + type ConnectionTone, + type ConnectionStatusViewModel, +} from '@/lib/connection-status/connectionStatus'; + +/** + * Compact header-grade button styles. Mirrors the icon-button visual rhythm of + * `HeaderIconActionButton` in `Header.tsx` (no drag, hover highlight, focus + * ring) but uses a slightly smaller `h-7 w-7` footprint so the dot can sit + * alongside the existing icon cluster without pushing other controls. + */ +const CONNECTION_INDICATOR_BUTTON_CLASS = + 'app-region-no-drag inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-interactive-hover transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary'; + +/** The dot itself — a small status pill. Color is supplied via the tone token. */ +const CONNECTION_DOT_CLASS = 'h-2 w-2 rounded-full'; + +/** + * Map a view-model tone to a Tailwind theme-token class. This matches the + * pattern in `DesktopHostSwitcher.tsx` (lines 84-93): `bg-status-*` for the + * meaningful states, `bg-muted-foreground/40` for the muted / unknown state. + * No hardcoded colors and no Tailwind palette classes — the values are + * project theme tokens. + */ +const toneToDotClass = (tone: ConnectionTone): string => { + switch (tone) { + case 'ok': + return 'bg-status-success'; + case 'warn': + return 'bg-status-warning'; + case 'error': + return 'bg-status-error'; + case 'muted': + return 'bg-muted-foreground/40'; + default: { + // Defensive: `ConnectionTone` is a closed union, but TypeScript cannot + // prove exhaustiveness without `never`. Fall through to muted. + const _exhaustive: never = tone; + void _exhaustive; + return 'bg-muted-foreground/40'; + } + } +}; + +const hop1LabelKeyToTone = (labelKey: ConnectionStatusViewModel['hop1']['labelKey']): ConnectionTone => { + switch (labelKey) { + case 'connectionStatus.hop1.connected': + return 'ok'; + case 'connectionStatus.hop1.connecting': + case 'connectionStatus.hop1.reconnecting': + return 'muted'; + case 'connectionStatus.hop1.disconnected': + return 'error'; + default: + return 'muted'; + } +}; + +const hop2LabelKeyToTone = (labelKey: ConnectionStatusViewModel['hop2']['labelKey']): ConnectionTone => { + switch (labelKey) { + case 'connectionStatus.hop2.healthy': + return 'ok'; + case 'connectionStatus.hop2.unhealthy': + return 'warn'; + case 'connectionStatus.hop2.unknown': + return 'muted'; + default: + return 'muted'; + } +}; + +type ConnectionStatusIndicatorBodyProps = { + viewModel: ConnectionStatusViewModel; +}; + +/** + * Inner renderer for the connection status indicator. Split out from the + * public component so that the narrow `useConfigStore` selectors live in + * exactly one place and the dot itself can be `React.memo`'d on + * `viewModel` only. This keeps the dot from re-rendering when unrelated + * state (sessions, streaming deltas, etc.) changes upstream. + */ +const ConnectionStatusIndicatorBody = React.memo(function ConnectionStatusIndicatorBody({ + viewModel, +}: ConnectionStatusIndicatorBodyProps) { + const { t } = useI18n(); + const dotClass = toneToDotClass(viewModel.tone); + const stateLabel = t(viewModel.overallLabelKey as I18nKey); + const ariaLabel = t('connectionStatus.aria.indicator', { state: stateLabel }); + + // Translate each tooltip line. The view model always emits exactly three + // lines: title + hop1 + hop2. When a line carries a `reasonKey` param, + // resolve the reason separately and compose it as + // "". The separator is a non-translated presenter + // concern (per the i18n mapping note in the plan: the reason is itself a + // complete message, not a grammar fragment). + const translatedLines = viewModel.tooltipLines.map((line) => { + const text = t(line.key as I18nKey); + const reasonKey = line.params && typeof line.params.reasonKey === 'string' + ? line.params.reasonKey + : null; + if (reasonKey !== null) { + const reason = t(reasonKey as I18nKey); + return `${text} — ${reason}`; + } + return text; + }); + + const hopLineDotClasses = [ + toneToDotClass(hop1LabelKeyToTone(viewModel.hop1.labelKey)), + toneToDotClass(hop2LabelKeyToTone(viewModel.hop2.labelKey)), + ]; + + return ( + + + + + +
+

+ {translatedLines[0]} +

+ {translatedLines.slice(1).map((line, index) => ( + + + ))} +
+
+
+ ); +}); + +/** + * Compact header-grade dot that summarizes connection health across two hops: + * 1. Frontend ↔ OpenChamber runtime + * 2. OpenChamber runtime ↔ OpenCode + * + * Default UI is the dot only. The hover / focus tooltip shows 2-3 short + * lines (title + one per hop). The dot is keyboard-reachable (renders as + * a ` @@ -1012,7 +1011,7 @@ export const ProvidersPage: React.FC = () => { variant="outline" size="xs" className="!font-normal" - onClick={() => showAllModels(selectedProvider.id)} + onClick={() => showAllModels(selectedProvider.id, visibleModelIds)} > {t('settings.providers.page.actions.showAll')} diff --git a/packages/ui/src/components/views/git/SyncActions.tsx b/packages/ui/src/components/views/git/SyncActions.tsx index 710b2ee231..27aae8f1d4 100644 --- a/packages/ui/src/components/views/git/SyncActions.tsx +++ b/packages/ui/src/components/views/git/SyncActions.tsx @@ -50,11 +50,13 @@ export const SyncActions: React.FC = ({ const isPrimaryDisabled = disabled || syncAction !== null || isRemovingRemote || !trackingRemote || blocksRebaseSync; const isDropdownDisabled = disabled || syncAction !== null || isRemovingRemote || remotes.length === 0; const hasKnownSyncWork = aheadCount > 0 || behindCount > 0; - const primaryLabel = [ - t('gitView.sync.sync'), - behindCount > 0 ? `↓${behindCount}` : null, - aheadCount > 0 ? `↑${aheadCount}` : null, - ].filter(Boolean).join(' '); + const primaryLabel = ( + + {t('gitView.sync.sync')} + {behindCount > 0 ? ↓{behindCount} : null} + {aheadCount > 0 ? ↑{aheadCount} : null} + + ); const tooltipLabel = blocksRebaseSync ? t('gitView.sync.commitOrStashTooltip') : trackingRemote @@ -89,7 +91,7 @@ export const SyncActions: React.FC = ({ ) : ( )} - {primaryLabel} + {primaryLabel} {tooltipLabel} diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index c8bfd37e2d..ebf9208d48 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -638,9 +638,11 @@ export interface SettingsPayload { autoDeleteEnabled?: boolean; autoDeleteAfterDays?: number; sessionRetentionAction?: 'archive' | 'delete'; + followUpBehavior?: 'steer' | 'queue' | 'immediate'; queueModeEnabled?: boolean; gitmojiEnabled?: boolean; inputSpellcheckEnabled?: boolean; + stripSlashOnSubmit?: boolean; showOpenCodeUpdateNotifications?: boolean; openCodeUpdateToastDismissedVersion?: string; showToolFileIcons?: boolean; diff --git a/packages/ui/src/lib/connection-status/connectionStatus.test.ts b/packages/ui/src/lib/connection-status/connectionStatus.test.ts new file mode 100644 index 0000000000..c2f46633bf --- /dev/null +++ b/packages/ui/src/lib/connection-status/connectionStatus.test.ts @@ -0,0 +1,425 @@ +import { describe, expect, test } from 'bun:test'; + +import type { + OpenCodeRuntimeState, + RuntimeTransportState, +} from '@/stores/useConfigStore'; + +import { + buildConnectionStatusViewModel, + classifyReason, + type ConnectionStatusViewModel, +} from './connectionStatus'; + +const transportConnected: RuntimeTransportState = { + phase: 'connected', + reason: null, + updatedAt: 0, +}; + +const transportConnecting: RuntimeTransportState = { + phase: 'connecting', + reason: null, + updatedAt: 0, +}; + +const transportReconnecting: RuntimeTransportState = { + phase: 'reconnecting', + reason: 'ws_closed:code=1006', + updatedAt: 0, +}; + +const transportDisconnected: RuntimeTransportState = { + phase: 'disconnected', + reason: 'ws_error_frame:HTTP 502', + updatedAt: 0, +}; + +const transportOffline: RuntimeTransportState = { + phase: 'offline', + reason: 'offline', + updatedAt: 0, +}; + +const openCodeHealthy: OpenCodeRuntimeState = { + phase: 'healthy', + reason: null, + updatedAt: 0, +}; + +const openCodeUnhealthy: OpenCodeRuntimeState = { + phase: 'unhealthy', + reason: 'health_check_unhealthy', + updatedAt: 0, +}; + +const openCodeUnknown: OpenCodeRuntimeState = { + phase: 'unknown', + reason: null, + updatedAt: 0, +}; + +const HOP1_CONNECTED = 'connectionStatus.hop1.connected'; +const HOP1_CONNECTING = 'connectionStatus.hop1.connecting'; +const HOP1_RECONNECTING = 'connectionStatus.hop1.reconnecting'; +const HOP1_DISCONNECTED = 'connectionStatus.hop1.disconnected'; + +const HOP2_HEALTHY = 'connectionStatus.hop2.healthy'; +const HOP2_UNHEALTHY = 'connectionStatus.hop2.unhealthy'; +const HOP2_UNKNOWN = 'connectionStatus.hop2.unknown'; + +const REASON_OFFLINE = 'connectionStatus.reason.offline'; +const REASON_AUTH = 'connectionStatus.reason.authRequired'; +const REASON_UNAVAILABLE = 'connectionStatus.reason.unavailable'; +const REASON_TIMEOUT = 'connectionStatus.reason.timeout'; + +const OVERALL_CONNECTED = 'connectionStatus.overall.connected'; +const OVERALL_RECONNECTING = 'connectionStatus.overall.reconnecting'; +const OVERALL_DEGRADED = 'connectionStatus.overall.degraded'; +const OVERALL_DISCONNECTED = 'connectionStatus.overall.disconnected'; +const OVERALL_UNKNOWN = 'connectionStatus.overall.unknown'; + +const TOOLTIP_TITLE = 'connectionStatus.tooltip.title'; + +describe('buildConnectionStatusViewModel', () => { + test('fresh connected startup: both hops healthy', () => { + const vm = buildConnectionStatusViewModel({ + runtimeTransport: transportConnected, + openCodeRuntime: openCodeHealthy, + navigatorOffline: false, + }); + + expect(vm.overall).toBe('connected'); + expect(vm.tone).toBe('ok'); + expect(vm.overallLabelKey).toBe(OVERALL_CONNECTED); + expect(vm.hop1.labelKey).toBe(HOP1_CONNECTED); + expect(vm.hop1.reasonKey).toBeNull(); + expect(vm.hop1.rawReason).toBeNull(); + expect(vm.hop2.labelKey).toBe(HOP2_HEALTHY); + expect(vm.hop2.reasonKey).toBeNull(); + expect(vm.hop2.rawReason).toBeNull(); + }); + + test('runtime restart / reconnect: transport reconnecting with ws_closed reason', () => { + const vm = buildConnectionStatusViewModel({ + runtimeTransport: transportReconnecting, + openCodeRuntime: openCodeHealthy, + navigatorOffline: false, + }); + + expect(vm.overall).toBe('reconnecting'); + // Per the binding user decision: reconnecting → muted (theme-muted + // neutral), NOT amber. + expect(vm.tone).toBe('muted'); + expect(vm.overallLabelKey).toBe(OVERALL_RECONNECTING); + expect(vm.hop1.labelKey).toBe(HOP1_RECONNECTING); + // `ws_closed:code=1006` does not match any classifyReason rule, so the + // classified reason is null. The raw reason is preserved on hop1 for + // diagnostics only. + expect(vm.hop1.reasonKey).toBe(classifyReason('ws_closed:code=1006')); + expect(vm.hop1.reasonKey).toBeNull(); + expect(vm.hop1.rawReason).toBe('ws_closed:code=1006'); + // hop2 reports whatever the opencode state currently says — and the + // opencode state is still healthy, so hop2 is healthy. + expect(vm.hop2.labelKey).toBe(HOP2_HEALTHY); + expect(vm.hop2.reasonKey).toBeNull(); + }); + + test('browser offline: navigatorOffline wins regardless of transport phase', () => { + // The offline path must win even when the transport still believes it + // is connected (the network stack may not have observed the drop yet). + const vmConnectedButOffline = buildConnectionStatusViewModel({ + runtimeTransport: transportConnected, + openCodeRuntime: openCodeHealthy, + navigatorOffline: true, + }); + expect(vmConnectedButOffline.overall).toBe('disconnected'); + expect(vmConnectedButOffline.tone).toBe('error'); + expect(vmConnectedButOffline.hop1.reasonKey).toBe(REASON_OFFLINE); + expect(vmConnectedButOffline.hop1.rawReason).toBe('offline'); + + // The offline path also wins when the transport is in a different + // non-offline phase. + const vmReconnectingButOffline = buildConnectionStatusViewModel({ + runtimeTransport: transportReconnecting, + openCodeRuntime: openCodeHealthy, + navigatorOffline: true, + }); + expect(vmReconnectingButOffline.overall).toBe('disconnected'); + expect(vmReconnectingButOffline.tone).toBe('error'); + expect(vmReconnectingButOffline.hop1.reasonKey).toBe(REASON_OFFLINE); + + // Transport phase `offline` (without `navigatorOffline`) also lands in + // the disconnected path with the offline reason. + const vmTransportOffline = buildConnectionStatusViewModel({ + runtimeTransport: transportOffline, + openCodeRuntime: openCodeHealthy, + navigatorOffline: false, + }); + expect(vmTransportOffline.overall).toBe('disconnected'); + expect(vmTransportOffline.tone).toBe('error'); + expect(vmTransportOffline.hop1.reasonKey).toBe(REASON_OFFLINE); + }); + + test('runtime unreachable: transport disconnected with HTTP 502 error frame', () => { + // `ws_error_frame:HTTP 502` contains the substring `error` and + // therefore classifies to `connectionStatus.reason.unavailable` per + // the explicit substring rules. Documenting this assertion in code + // so future maintainers understand why this specific reason maps to + // `unavailable` rather than `null`. + const vm = buildConnectionStatusViewModel({ + runtimeTransport: transportDisconnected, + openCodeRuntime: openCodeHealthy, + navigatorOffline: false, + }); + + expect(vm.overall).toBe('disconnected'); + expect(vm.tone).toBe('error'); + expect(vm.hop1.labelKey).toBe(HOP1_DISCONNECTED); + expect(vm.hop1.reasonKey).toBe(REASON_UNAVAILABLE); + expect(vm.hop1.rawReason).toBe('ws_error_frame:HTTP 502'); + // hop2 still reports whatever the opencode state currently says. + expect(vm.hop2.labelKey).toBe(HOP2_HEALTHY); + }); + + test('init error: transport disconnected with init_error reason and opencode state unknown', () => { + // Exercises the disconnected overall path with an `init_error` raw + // reason and an opencode runtime that has not yet reported. Documents + // that `init_error` classifies to the unavailable reason (per + // classifyReason's substring rule) and that the opencode hop remains + // unknown — the dot is disconnected/error, not degraded/warn. + const vm = buildConnectionStatusViewModel({ + runtimeTransport: { phase: 'disconnected', reason: 'init_error', updatedAt: 0 }, + openCodeRuntime: openCodeUnknown, + navigatorOffline: false, + }); + + expect(vm.overall).toBe('disconnected'); + expect(vm.overallLabelKey).toBe(OVERALL_DISCONNECTED); + expect(vm.tone).toBe('error'); + expect(vm.hop1.labelKey).toBe(HOP1_DISCONNECTED); + expect(vm.hop1.reasonKey).toBe(REASON_UNAVAILABLE); + expect(vm.hop1.rawReason).toBe('init_error'); + // opencode has not yet reported, so hop2 stays unknown. + expect(vm.hop2.labelKey).toBe(HOP2_UNKNOWN); + expect(vm.hop2.reasonKey).toBeNull(); + expect(vm.hop2.rawReason).toBeNull(); + }); + + test('opencode unhealthy: transport connected but opencode reports unhealthy', () => { + const vm = buildConnectionStatusViewModel({ + runtimeTransport: transportConnected, + openCodeRuntime: openCodeUnhealthy, + navigatorOffline: false, + }); + + expect(vm.overall).toBe('degraded'); + expect(vm.tone).toBe('warn'); + expect(vm.overallLabelKey).toBe(OVERALL_DEGRADED); + expect(vm.hop1.labelKey).toBe(HOP1_CONNECTED); + expect(vm.hop1.reasonKey).toBeNull(); + expect(vm.hop2.labelKey).toBe(HOP2_UNHEALTHY); + // `health_check_unhealthy` contains the substring `unhealthy` and + // therefore classifies to `unavailable`. + expect(vm.hop2.reasonKey).toBe(REASON_UNAVAILABLE); + expect(vm.hop2.rawReason).toBe('health_check_unhealthy'); + }); + + test('transport switch / recovery: post-reconnect transport is connected', () => { + const vm = buildConnectionStatusViewModel({ + runtimeTransport: { phase: 'connected', reason: null, updatedAt: 0 }, + openCodeRuntime: openCodeHealthy, + navigatorOffline: false, + }); + + expect(vm.overall).toBe('connected'); + expect(vm.tone).toBe('ok'); + expect(vm.overallLabelKey).toBe(OVERALL_CONNECTED); + }); + + test('unknown: transport connected but opencode phase is unknown', () => { + const vm = buildConnectionStatusViewModel({ + runtimeTransport: transportConnected, + openCodeRuntime: openCodeUnknown, + navigatorOffline: false, + }); + + expect(vm.overall).toBe('unknown'); + // Per the binding user decision: unknown → muted (theme-muted + // neutral), NOT amber. + expect(vm.tone).toBe('muted'); + expect(vm.overallLabelKey).toBe(OVERALL_UNKNOWN); + expect(vm.hop1.labelKey).toBe(HOP1_CONNECTED); + expect(vm.hop1.reasonKey).toBeNull(); + expect(vm.hop2.labelKey).toBe(HOP2_UNKNOWN); + expect(vm.hop2.reasonKey).toBeNull(); + }); + + test('tooltipLines is always exactly 3 lines (header + 2 hops), per the user decision', () => { + // Reference-stable contract: the hover always shows the header line + // plus both hop lines, even when the second hop is not currently + // knowable. Never collapses to a single reason line. + const cases: Array<{ + label: string; + input: Parameters[0]; + }> = [ + { + label: 'fresh connected', + input: { + runtimeTransport: transportConnected, + openCodeRuntime: openCodeHealthy, + navigatorOffline: false, + }, + }, + { + label: 'reconnecting', + input: { + runtimeTransport: transportReconnecting, + openCodeRuntime: openCodeHealthy, + navigatorOffline: false, + }, + }, + { + label: 'browser offline', + input: { + runtimeTransport: transportConnected, + openCodeRuntime: openCodeHealthy, + navigatorOffline: true, + }, + }, + { + label: 'runtime unreachable', + input: { + runtimeTransport: transportDisconnected, + openCodeRuntime: openCodeHealthy, + navigatorOffline: false, + }, + }, + { + label: 'opencode unhealthy', + input: { + runtimeTransport: transportConnected, + openCodeRuntime: openCodeUnhealthy, + navigatorOffline: false, + }, + }, + { + label: 'unknown', + input: { + runtimeTransport: transportConnected, + openCodeRuntime: openCodeUnknown, + navigatorOffline: false, + }, + }, + { + label: 'connecting', + input: { + runtimeTransport: transportConnecting, + openCodeRuntime: openCodeUnknown, + navigatorOffline: false, + }, + }, + ]; + + for (const c of cases) { + const vm: ConnectionStatusViewModel = buildConnectionStatusViewModel(c.input); + expect(vm.tooltipLines).toHaveLength(3); + // Line 0 is always the title (no params). + expect(vm.tooltipLines[0]).toEqual({ key: TOOLTIP_TITLE }); + expect(vm.tooltipLines[0].params).toBe(undefined); + // Lines 1 and 2 are the hop lines. + expect(vm.tooltipLines[1].key).toBe(vm.hop1.labelKey); + expect(vm.tooltipLines[2].key).toBe(vm.hop2.labelKey); + } + }); + + test('tooltipLines hop1 line carries reasonKey param only when reasonKey is non-null', () => { + // hop1 with no reason → no params block. + const healthyVm = buildConnectionStatusViewModel({ + runtimeTransport: transportConnected, + openCodeRuntime: openCodeHealthy, + navigatorOffline: false, + }); + expect(healthyVm.tooltipLines[1].params).toBe(undefined); + + // hop1 with a classified reason → params.reasonKey carries the i18n + // key, which the consumer translates separately. + const reconnectingVm = buildConnectionStatusViewModel({ + runtimeTransport: { phase: 'reconnecting', reason: 'auth_required', updatedAt: 0 }, + openCodeRuntime: openCodeHealthy, + navigatorOffline: false, + }); + expect(reconnectingVm.tooltipLines[1].key).toBe(HOP1_RECONNECTING); + expect(reconnectingVm.tooltipLines[1].params).toEqual({ reasonKey: REASON_AUTH }); + }); + + test('hop1 reasonKey for transport `connecting` is always null (no raw reason)', () => { + // The connecting branch ignores the raw reason — there is no classified + // reason for a brand-new connection attempt. + const vm = buildConnectionStatusViewModel({ + runtimeTransport: transportConnecting, + openCodeRuntime: openCodeUnknown, + navigatorOffline: false, + }); + expect(vm.overall).toBe('unknown'); + expect(vm.hop1.labelKey).toBe(HOP1_CONNECTING); + expect(vm.hop1.reasonKey).toBeNull(); + expect(vm.hop1.rawReason).toBeNull(); + }); +}); + +describe('classifyReason', () => { + test('maps `offline` to the offline reason', () => { + expect(classifyReason('offline')).toBe(REASON_OFFLINE); + }); + + test('maps `navigator_offline` to the offline reason', () => { + expect(classifyReason('navigator_offline')).toBe(REASON_OFFLINE); + }); + + test('maps `auth_required` to the authRequired reason', () => { + expect(classifyReason('auth_required')).toBe(REASON_AUTH); + }); + + test('returns null for `ws_closed:code=1006` (does not match any rule)', () => { + // Documenting the negative case: this raw code is preserved on + // `HopState.rawReason` for diagnostics, but does not produce a + // classified reason key — the consumer will render a generic reason. + expect(classifyReason('ws_closed:code=1006')).toBeNull(); + }); + + test('maps `init_error` to the unavailable reason', () => { + expect(classifyReason('init_error')).toBe(REASON_UNAVAILABLE); + }); + + test('maps `health_check_unhealthy` to the unavailable reason', () => { + expect(classifyReason('health_check_unhealthy')).toBe(REASON_UNAVAILABLE); + }); + + test('maps `sse_heartbeat_timeout` to the timeout reason', () => { + expect(classifyReason('sse_heartbeat_timeout')).toBe(REASON_TIMEOUT); + }); + + test('returns null for an unrecognized reason string', () => { + expect(classifyReason('something_totally_unrecognized')).toBeNull(); + }); + + test('returns null for null input', () => { + expect(classifyReason(null)).toBeNull(); + }); + + test('maps 401 / 403 / unauthorized / forbidden to the authRequired reason', () => { + expect(classifyReason('401')).toBe(REASON_AUTH); + expect(classifyReason('403')).toBe(REASON_AUTH); + expect(classifyReason('unauthorized')).toBe(REASON_AUTH); + expect(classifyReason('forbidden')).toBe(REASON_AUTH); + }); + + test('precedence: a string containing both `auth` and `error` resolves to authRequired', () => { + // `auth_error` contains both substrings, but the auth branch is + // checked first, so it resolves to `authRequired`. Pinning the + // precedence here so a future refactor does not silently flip the + // resolution. + expect(classifyReason('auth_error')).toBe(REASON_AUTH); + }); +}); diff --git a/packages/ui/src/lib/connection-status/connectionStatus.ts b/packages/ui/src/lib/connection-status/connectionStatus.ts new file mode 100644 index 0000000000..a1c2a6e045 --- /dev/null +++ b/packages/ui/src/lib/connection-status/connectionStatus.ts @@ -0,0 +1,305 @@ +import type { + OpenCodeRuntimeState, + RuntimeTransportState, +} from '@/stores/useConfigStore'; + +/** + * User-facing tone of the connection dot. Per the binding user decisions: + * - `reconnecting` / `unknown` use `'muted'` (theme-muted neutral), NOT amber. + * - `connected` uses `'ok'`. + * - `disconnected` / `offline` use `'error'`. + * - `degraded` uses `'warn'` (runtime-only degraded case where OpenCode is + * unhealthy but the frontend transport is still up). + */ +export type ConnectionTone = 'ok' | 'warn' | 'error' | 'muted'; + +/** Overall connection verdict shown as the dot's color and the overall hover line. */ +export type ConnectionOverall = + | 'connected' + | 'reconnecting' + | 'degraded' + | 'disconnected' + | 'unknown'; + +/** One hop's presentation state. */ +export type HopState = { + /** Short stable i18n key that resolves to a user-facing label. */ + labelKey: string; + /** + * Optional short stable i18n key for a reason ("offline", "auth required"). + * `null` when the hop is healthy or the raw reason is unrecognized. + */ + reasonKey: string | null; + /** + * Raw internal code preserved for diagnostics only — never rendered in the + * normal UI. Callers must not include this in hover content. + */ + rawReason: string | null; +}; + +/** + * Aggregated view model. The hover always shows BOTH hop lines per the + * binding user decision (no collapsing to a single reason line). + */ +export type ConnectionStatusViewModel = { + overall: ConnectionOverall; + tone: ConnectionTone; + /** Short stable i18n key for the overall verdict label. */ + overallLabelKey: string; + /** Frontend ↔ OpenChamber runtime hop. */ + hop1: HopState; + /** OpenChamber runtime ↔ OpenCode hop. */ + hop2: HopState; + /** + * Pre-rendered hover lines. The consumer translates each `key` with the + * i18n store. When a `params` block is present, the consumer MUST resolve + * the `reasonKey` param by calling `t(reasonKey)` separately to compose + * the localized reason text. This intentionally uses complete-message keys + * for the optional reason clause (per the i18n mapping note in the plan: + * optional clauses must not use the reason as a grammar fragment). + */ + tooltipLines: Array<{ key: string; params?: Record }>; +}; + +/** Stable i18n keys used by the view model. Kept as constants for testability. */ +const KEY_HOP1_CONNECTED = 'connectionStatus.hop1.connected'; +const KEY_HOP1_CONNECTING = 'connectionStatus.hop1.connecting'; +const KEY_HOP1_RECONNECTING = 'connectionStatus.hop1.reconnecting'; +const KEY_HOP1_DISCONNECTED = 'connectionStatus.hop1.disconnected'; + +const KEY_HOP2_HEALTHY = 'connectionStatus.hop2.healthy'; +const KEY_HOP2_UNHEALTHY = 'connectionStatus.hop2.unhealthy'; +const KEY_HOP2_UNKNOWN = 'connectionStatus.hop2.unknown'; + +const KEY_REASON_OFFLINE = 'connectionStatus.reason.offline'; +const KEY_REASON_AUTH_REQUIRED = 'connectionStatus.reason.authRequired'; +const KEY_REASON_UNAVAILABLE = 'connectionStatus.reason.unavailable'; +const KEY_REASON_TIMEOUT = 'connectionStatus.reason.timeout'; + +const KEY_OVERALL_CONNECTED = 'connectionStatus.overall.connected'; +const KEY_OVERALL_RECONNECTING = 'connectionStatus.overall.reconnecting'; +const KEY_OVERALL_DEGRADED = 'connectionStatus.overall.degraded'; +const KEY_OVERALL_DISCONNECTED = 'connectionStatus.overall.disconnected'; +const KEY_OVERALL_UNKNOWN = 'connectionStatus.overall.unknown'; + +const KEY_TOOLTIP_TITLE = 'connectionStatus.tooltip.title'; + +/** + * Map a raw internal disconnect/health reason code to one of a small set of + * user-facing i18n keys. Returns `null` for unrecognized codes — the consumer + * will then show a generic reason. + * + * This is a small, deliberately under-categorized helper. The order of the + * checks is the precedence: offline beats auth, auth beats unavailable, + * unavailable beats timeout/heartbeat. The check order matters when a raw + * reason could match more than one category (e.g. `auth_timeout` resolves to + * `authRequired` because the auth branch is checked first). + * + * Implementation note: explicit substring tests (`.includes`) and exact + * equality — no regex, no over-categorization. Per the binding user decision, + * raw internal codes must NEVER appear in i18n keys. + */ +export const classifyReason = (raw: string | null): string | null => { + if (raw === null) { + return null; + } + + if (raw.includes('offline')) { + return KEY_REASON_OFFLINE; + } else if ( + raw.includes('auth') + || raw === '401' + || raw === '403' + || raw === 'unauthorized' + || raw === 'forbidden' + ) { + return KEY_REASON_AUTH_REQUIRED; + } else if ( + raw.includes('init_error') + || raw.includes('unhealthy') + || raw.includes('unavailable') + || raw.includes('failed') + || raw.includes('error') + ) { + return KEY_REASON_UNAVAILABLE; + } else if (raw.includes('timeout') || raw.includes('heartbeat')) { + return KEY_REASON_TIMEOUT; + } else { + return null; + } +}; + +/** Project the runtime ↔ OpenCode hop state into a presentation `HopState`. */ +const projectOpenCodeHop = (openCode: OpenCodeRuntimeState): HopState => { + switch (openCode.phase) { + case 'healthy': + return { + labelKey: KEY_HOP2_HEALTHY, + reasonKey: null, + rawReason: null, + }; + case 'unhealthy': + return { + labelKey: KEY_HOP2_UNHEALTHY, + reasonKey: classifyReason(openCode.reason), + rawReason: openCode.reason, + }; + case 'unknown': + return { + labelKey: KEY_HOP2_UNKNOWN, + reasonKey: null, + rawReason: null, + }; + default: { + // Defensive: unknown runtime phase → fall back to the unknown key. + // Runtime transport phase union is closed, but TypeScript can't + // prove exhaustiveness through the switch without `never`. + const _exhaustive: never = openCode.phase; + void _exhaustive; + return { + labelKey: KEY_HOP2_UNKNOWN, + reasonKey: null, + rawReason: null, + }; + } + } +}; + +/** Build a `tooltipLines` entry for one hop. The `reasonKey` param is the + * i18n key for the reason; the consumer resolves it by calling + * `t(reasonKey)` separately (per the i18n mapping note in the plan). */ +const buildHopTooltipLine = (hop: HopState): { key: string; params?: Record } => { + if (hop.reasonKey === null) { + return { key: hop.labelKey }; + } + return { + key: hop.labelKey, + params: { reasonKey: hop.reasonKey }, + }; +}; + +/** + * Build the aggregated connection-status view model. + * + * This is a pure, deterministic helper: it does not read globals, does not + * touch `Date.now()`, and has no side effects. The caller (component/hook) + * is responsible for observing the browser's `navigator.onLine` state and + * passing it as `navigatorOffline`. + * + * Aggregation precedence (apply top-down; the first matching branch wins): + * 1. browser offline (`navigatorOffline`) OR transport `offline` → disconnected / error + * 2. transport `disconnected` → disconnected / error + * 3. transport `reconnecting` → reconnecting / muted + * 4. transport `connecting` → unknown / muted + * 5. transport `connected` + opencode `unhealthy` → degraded / warn + * 6. transport `connected` + opencode `healthy` → connected / ok + * 7. transport `connected` + opencode `unknown` (or any unhandled combo) → unknown / muted + */ +export function buildConnectionStatusViewModel(input: { + runtimeTransport: RuntimeTransportState; + openCodeRuntime: OpenCodeRuntimeState; + /** True iff `navigator.onLine === false`. */ + navigatorOffline: boolean; +}): ConnectionStatusViewModel { + const { runtimeTransport, openCodeRuntime, navigatorOffline } = input; + + // --- Step 1: pick overall + tone + hop1 labelKey based on transport phase + let overall: ConnectionOverall; + let tone: ConnectionTone; + let overallLabelKey: string; + let hop1: HopState; + + if (navigatorOffline || runtimeTransport.phase === 'offline') { + overall = 'disconnected'; + tone = 'error'; + overallLabelKey = KEY_OVERALL_DISCONNECTED; + hop1 = { + labelKey: KEY_HOP1_DISCONNECTED, + reasonKey: KEY_REASON_OFFLINE, + rawReason: 'offline', + }; + } else if (runtimeTransport.phase === 'disconnected') { + overall = 'disconnected'; + tone = 'error'; + overallLabelKey = KEY_OVERALL_DISCONNECTED; + hop1 = { + labelKey: KEY_HOP1_DISCONNECTED, + reasonKey: classifyReason(runtimeTransport.reason), + rawReason: runtimeTransport.reason, + }; + } else if (runtimeTransport.phase === 'reconnecting') { + overall = 'reconnecting'; + tone = 'muted'; + overallLabelKey = KEY_OVERALL_RECONNECTING; + hop1 = { + labelKey: KEY_HOP1_RECONNECTING, + reasonKey: classifyReason(runtimeTransport.reason), + rawReason: runtimeTransport.reason, + }; + } else if (runtimeTransport.phase === 'connecting') { + overall = 'unknown'; + tone = 'muted'; + overallLabelKey = KEY_OVERALL_UNKNOWN; + hop1 = { + labelKey: KEY_HOP1_CONNECTING, + reasonKey: null, + rawReason: null, + }; + } else if ( + runtimeTransport.phase === 'connected' + && openCodeRuntime.phase === 'unhealthy' + ) { + overall = 'degraded'; + tone = 'warn'; + overallLabelKey = KEY_OVERALL_DEGRADED; + hop1 = { + labelKey: KEY_HOP1_CONNECTED, + reasonKey: null, + rawReason: null, + }; + } else if ( + runtimeTransport.phase === 'connected' + && openCodeRuntime.phase === 'healthy' + ) { + overall = 'connected'; + tone = 'ok'; + overallLabelKey = KEY_OVERALL_CONNECTED; + hop1 = { + labelKey: KEY_HOP1_CONNECTED, + reasonKey: null, + rawReason: null, + }; + } else { + // `connected` + `unknown` (or any unhandled combo — kept narrow on + // purpose; new phases should be added explicitly above). + overall = 'unknown'; + tone = 'muted'; + overallLabelKey = KEY_OVERALL_UNKNOWN; + hop1 = { + labelKey: KEY_HOP1_CONNECTED, + reasonKey: null, + rawReason: null, + }; + } + + // --- Step 2: pick hop2 based on opencode phase + const hop2 = projectOpenCodeHop(openCodeRuntime); + + // --- Step 3: assemble the tooltip lines. Per the binding user decision, + // we always emit a header line + two hop lines; the `reason` is sometimes + // short (passed via `params.reasonKey` for the i18n resolver to compose). + const tooltipLines: ConnectionStatusViewModel['tooltipLines'] = [ + { key: KEY_TOOLTIP_TITLE }, + buildHopTooltipLine(hop1), + buildHopTooltipLine(hop2), + ]; + + return { + overall, + tone, + overallLabelKey, + hop1, + hop2, + tooltipLines, + }; +} diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index 9bdcdcbad7..589d37b31f 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' | 'immediate'; queueModeEnabled?: boolean; gitmojiEnabled?: boolean; defaultFileViewerPreview?: boolean; @@ -125,6 +126,7 @@ export type DesktopSettings = { pwaOrientation?: 'system' | 'portrait' | 'landscape'; mobileKeyboardMode?: MobileKeyboardMode; inputSpellcheckEnabled?: boolean; + stripSlashOnSubmit?: boolean; showOpenCodeUpdateNotifications?: boolean; openCodeUpdateToastDismissedVersion?: string; showToolFileIcons?: boolean; diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 108a193a05..fc5d373702 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -1679,6 +1679,9 @@ export const settingsDict = { 'settings.openchamber.visual.field.persistDraftMessages': 'Persist Draft Messages', 'settings.openchamber.visual.field.enableSpellcheckInTextInputsAria': 'Enable spellcheck in text inputs', 'settings.openchamber.visual.field.enableSpellcheckInTextInputs': 'Enable Spellcheck in Text Inputs', + 'settings.openchamber.visual.field.stripSlashOnSubmitAria': 'Strip leading slash on submit so slash commands are not expanded', + 'settings.openchamber.visual.field.stripSlashOnSubmit': 'Strip Slash on Submit', + 'settings.openchamber.visual.field.stripSlashOnSubmitDescription': 'When enabled, submitting a slash command or skill strips the leading slash and sends the text as a plain message. The command/skill will not be invoked and the conversation will show what you typed.', 'settings.openchamber.visual.field.sendAnonymousUsageReportsAria': 'Send anonymous usage reports', 'settings.openchamber.visual.field.sendAnonymousUsageReports': 'Send anonymous usage reports', 'settings.openchamber.visual.field.sendAnonymousUsageReportsHint': 'Helps us understand which app versions are actively used so we can prioritize improvements. Only app version, platform, and runtime are collected - no personal data or code.', @@ -1795,4 +1798,11 @@ 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)', + 'settings.openchamber.visual.option.followUpBehavior.immediate.label': 'Send immediately', } as const; diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 0423b342c1..797eaa3929 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -2633,6 +2633,25 @@ export const dict = { 'quota.window.chat': 'Chat Requests', 'quota.window.completions': 'Completions', 'quota.window.premiumInteractions': 'Premium interactions', + 'connectionStatus.hop1.connected': 'Frontend ↔ OpenChamber: connected', + 'connectionStatus.hop1.connecting': 'Frontend ↔ OpenChamber: connecting…', + 'connectionStatus.hop1.reconnecting': 'Frontend ↔ OpenChamber: reconnecting', + 'connectionStatus.hop1.disconnected': 'Frontend ↔ OpenChamber: disconnected', + 'connectionStatus.hop2.healthy': 'OpenChamber ↔ OpenCode: ready', + 'connectionStatus.hop2.unhealthy': 'OpenChamber ↔ OpenCode: unavailable', + 'connectionStatus.hop2.unknown': 'OpenChamber ↔ OpenCode: checking…', + 'connectionStatus.reason.offline': 'Offline', + 'connectionStatus.reason.authRequired': 'Auth/config issue', + 'connectionStatus.reason.unavailable': 'OpenCode unavailable', + 'connectionStatus.reason.timeout': 'Connection timed out', + 'connectionStatus.overall.connected': 'Connected', + 'connectionStatus.overall.reconnecting': 'Reconnecting', + 'connectionStatus.overall.degraded': 'Degraded', + 'connectionStatus.overall.disconnected': 'Disconnected', + 'connectionStatus.overall.unknown': 'Unknown', + 'connectionStatus.tooltip.title': 'Connection status', + + 'connectionStatus.aria.indicator': 'Connection status: {state}', } as const; export type I18nKey = keyof typeof dict; diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index f93ae94e85..83f89d54a7 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -1646,6 +1646,9 @@ export const settingsDict = { "settings.openchamber.visual.field.persistDraftMessages": "Conservar borradores de mensajes", "settings.openchamber.visual.field.enableSpellcheckInTextInputsAria": "Habilitar ortografía en campos de texto", "settings.openchamber.visual.field.enableSpellcheckInTextInputs": "Habilitar ortografía en campos de texto", + "settings.openchamber.visual.field.stripSlashOnSubmitAria": "Eliminar la barra inclinada inicial al enviar para que los comandos con barra no se expandan", + "settings.openchamber.visual.field.stripSlashOnSubmit": "Eliminar barra al enviar", + "settings.openchamber.visual.field.stripSlashOnSubmitDescription": "Cuando está habilitado, enviar un comando o skill con barra inclinada elimina la barra inicial y envía el texto como un mensaje normal. El comando o skill no se invoca y la conversación muestra lo que escribiste.", "settings.openchamber.visual.field.sendAnonymousUsageReportsAria": "Enviar informes anónimos de uso", "settings.openchamber.visual.field.sendAnonymousUsageReports": "Enviar informes anónimos de uso", "settings.openchamber.visual.field.sendAnonymousUsageReportsHint": "Nos ayuda a entender qué versiones de la aplicación se usan activamente para priorizar mejoras. Solo se recopilan la versión de la aplicación, la plataforma y el entorno de ejecución ; no se recopilan datos personales ni código.", @@ -1795,4 +1798,11 @@ 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)", + "settings.openchamber.visual.option.followUpBehavior.immediate.label": "Send immediately", } as const; diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 93e0411043..a6005c5926 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -2634,4 +2634,23 @@ export const dict: Record = { "quota.window.chat": "Chat Requests", "quota.window.completions": "Completions", "quota.window.premiumInteractions": "Premium interactions", + "connectionStatus.hop1.connected": "Frontend ↔ OpenChamber: connected", + "connectionStatus.hop1.connecting": "Frontend ↔ OpenChamber: connecting…", + "connectionStatus.hop1.reconnecting": "Frontend ↔ OpenChamber: reconnecting", + "connectionStatus.hop1.disconnected": "Frontend ↔ OpenChamber: disconnected", + "connectionStatus.hop2.healthy": "OpenChamber ↔ OpenCode: ready", + "connectionStatus.hop2.unhealthy": "OpenChamber ↔ OpenCode: unavailable", + "connectionStatus.hop2.unknown": "OpenChamber ↔ OpenCode: checking…", + "connectionStatus.reason.offline": "Offline", + "connectionStatus.reason.authRequired": "Auth/config issue", + "connectionStatus.reason.unavailable": "OpenCode unavailable", + "connectionStatus.reason.timeout": "Connection timed out", + "connectionStatus.overall.connected": "Connected", + "connectionStatus.overall.reconnecting": "Reconnecting", + "connectionStatus.overall.degraded": "Degraded", + "connectionStatus.overall.disconnected": "Disconnected", + "connectionStatus.overall.unknown": "Unknown", + "connectionStatus.tooltip.title": "Connection status", + + "connectionStatus.aria.indicator": "Connection status: {state}", }; diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index 9cb6220a67..a5ac57f8d0 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -1620,6 +1620,9 @@ export const settingsDict = { 'settings.openchamber.visual.field.persistDraftMessages': 'Conserver les brouillons de messages', 'settings.openchamber.visual.field.enableSpellcheckInTextInputsAria': 'Activer la vérification orthographique dans les saisies de texte', 'settings.openchamber.visual.field.enableSpellcheckInTextInputs': 'Activer la vérification orthographique dans les entrées de texte', + 'settings.openchamber.visual.field.stripSlashOnSubmitAria': 'Supprimer le slash initial à l\'envoi afin que les commandes slash ne soient pas étendues', + 'settings.openchamber.visual.field.stripSlashOnSubmit': 'Supprimer le slash à l\'envoi', + 'settings.openchamber.visual.field.stripSlashOnSubmitDescription': 'Lorsque cette option est activée, l\'envoi d\'une commande ou d\'un skill en slash supprime le slash initial et envoie le texte comme un message ordinaire. La commande ou le skill n\'est pas invoqué et la conversation affiche ce que vous avez tapé.', 'settings.openchamber.visual.field.sendAnonymousUsageReportsAria': 'Envoyer des rapports d\'utilisation anonymes', 'settings.openchamber.visual.field.sendAnonymousUsageReports': 'Envoyer des rapports d\'utilisation anonymes', 'settings.openchamber.visual.field.sendAnonymousUsageReportsHint': 'Nous aide à comprendre quelles versions de l\'application sont activement utilisées afin que nous puissions prioriser les améliorations. Seules la version de l’application, la plate-forme et le runtime sont collectés – aucune donnée personnelle ni code.', @@ -1795,4 +1798,11 @@ 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)', + 'settings.openchamber.visual.option.followUpBehavior.immediate.label': 'Send immediately', } as const; diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index df12dcfef4..388a40040a 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -103,6 +103,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', @@ -2630,6 +2631,25 @@ export const dict = { 'vscodeLayout.actions.archiveAllSuccess': '{count} session(s) archivée(s)', 'vscodeLayout.actions.archiveAllError': 'Impossible d’archiver {count} session(s)', 'vscodeLayout.actions.cancel': 'Annuler', + 'connectionStatus.hop1.connected': 'Frontend ↔ OpenChamber: connected', + 'connectionStatus.hop1.connecting': 'Frontend ↔ OpenChamber: connecting…', + 'connectionStatus.hop1.reconnecting': 'Frontend ↔ OpenChamber: reconnecting', + 'connectionStatus.hop1.disconnected': 'Frontend ↔ OpenChamber: disconnected', + 'connectionStatus.hop2.healthy': 'OpenChamber ↔ OpenCode: ready', + 'connectionStatus.hop2.unhealthy': 'OpenChamber ↔ OpenCode: unavailable', + 'connectionStatus.hop2.unknown': 'OpenChamber ↔ OpenCode: checking…', + 'connectionStatus.reason.offline': 'Offline', + 'connectionStatus.reason.authRequired': 'Auth/config issue', + 'connectionStatus.reason.unavailable': 'OpenCode unavailable', + 'connectionStatus.reason.timeout': 'Connection timed out', + 'connectionStatus.overall.connected': 'Connected', + 'connectionStatus.overall.reconnecting': 'Reconnecting', + 'connectionStatus.overall.degraded': 'Degraded', + 'connectionStatus.overall.disconnected': 'Disconnected', + 'connectionStatus.overall.unknown': 'Unknown', + 'connectionStatus.tooltip.title': 'Connection status', + + 'connectionStatus.aria.indicator': 'Connection status: {state}', } as const; export type I18nKey = keyof typeof dict; diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index 8c9162f130..04ae22ae88 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -1646,6 +1646,9 @@ export const settingsDict = { 'settings.openchamber.visual.field.persistDraftMessages': '초안 메시지 유지', 'settings.openchamber.visual.field.enableSpellcheckInTextInputsAria': '텍스트 입력에서 맞춤법 검사 활성화', 'settings.openchamber.visual.field.enableSpellcheckInTextInputs': '텍스트 입력에서 맞춤법 검사 활성화', + 'settings.openchamber.visual.field.stripSlashOnSubmitAria': '전송 시 슬래시를 제거하여 슬래시 명령이 확장되지 않도록 합니다', + 'settings.openchamber.visual.field.stripSlashOnSubmit': '전송 시 슬래시 제거', + 'settings.openchamber.visual.field.stripSlashOnSubmitDescription': '활성화되면 슬래시 명령이나 스킬을 전송할 때 선행 슬래시가 제거되고 일반 메시지로 텍스트가 전송됩니다. 명령이나 스킬이 호출되지 않으며 대화에는 입력한 내용이 표시됩니다.', 'settings.openchamber.visual.field.sendAnonymousUsageReportsAria': '익명 사용량 보고서 보내기', 'settings.openchamber.visual.field.sendAnonymousUsageReports': '익명 사용량 보고서 보내기', 'settings.openchamber.visual.field.sendAnonymousUsageReportsHint': '활성 사용 앱 버전을 파악해 개선 우선순위를 정하는 데 도움이 됩니다. 앱 버전, 플랫폼, 런타임만 수집되며 개인 데이터나 코드는 수집되지 않습니다.', @@ -1795,4 +1798,11 @@ 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)', + 'settings.openchamber.visual.option.followUpBehavior.immediate.label': 'Send immediately', } as const; diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 1554624603..c7de4facb4 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -2633,4 +2633,23 @@ export const dict: Record = { 'quota.window.chat': 'Chat Requests', 'quota.window.completions': 'Completions', 'quota.window.premiumInteractions': 'Premium interactions', + 'connectionStatus.hop1.connected': 'Frontend ↔ OpenChamber: connected', + 'connectionStatus.hop1.connecting': 'Frontend ↔ OpenChamber: connecting…', + 'connectionStatus.hop1.reconnecting': 'Frontend ↔ OpenChamber: reconnecting', + 'connectionStatus.hop1.disconnected': 'Frontend ↔ OpenChamber: disconnected', + 'connectionStatus.hop2.healthy': 'OpenChamber ↔ OpenCode: ready', + 'connectionStatus.hop2.unhealthy': 'OpenChamber ↔ OpenCode: unavailable', + 'connectionStatus.hop2.unknown': 'OpenChamber ↔ OpenCode: checking…', + 'connectionStatus.reason.offline': 'Offline', + 'connectionStatus.reason.authRequired': 'Auth/config issue', + 'connectionStatus.reason.unavailable': 'OpenCode unavailable', + 'connectionStatus.reason.timeout': 'Connection timed out', + 'connectionStatus.overall.connected': 'Connected', + 'connectionStatus.overall.reconnecting': 'Reconnecting', + 'connectionStatus.overall.degraded': 'Degraded', + 'connectionStatus.overall.disconnected': 'Disconnected', + 'connectionStatus.overall.unknown': 'Unknown', + 'connectionStatus.tooltip.title': 'Connection status', + + 'connectionStatus.aria.indicator': 'Connection status: {state}', }; diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index 5d305ec3bf..4fa5591dce 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -924,6 +924,9 @@ export const settingsDict = { 'settings.openchamber.visual.field.enableSpellcheckInTextInputs': 'Włącz sprawdzanie pisowni w polach tekstowych', 'settings.openchamber.visual.field.enableSpellcheckInTextInputsAria': 'Włącz sprawdzanie pisowni w polach tekstowych', + 'settings.openchamber.visual.field.stripSlashOnSubmitAria': 'Usuń wiodący ukośnik przy wysyłaniu, aby polecenia slash nie były rozwijane', + 'settings.openchamber.visual.field.stripSlashOnSubmit': 'Usuń ukośnik przy wysyłaniu', + 'settings.openchamber.visual.field.stripSlashOnSubmitDescription': 'Po włączeniu, wysłanie polecenia lub umiejętności slash usuwa wiodący ukośnik i wysyła tekst jako zwykłą wiadomość. Polecenie lub umiejętność nie zostanie wywołana, a w rozmowie pojawi się to, co wpisałeś.', 'settings.openchamber.visual.field.fontSizePercentageAria': 'Procentowy rozmiar czcionki', 'settings.openchamber.visual.field.inputBarOffset': 'Przesunięcie paska wpisywania', 'settings.openchamber.visual.field.inputBarOffsetTooltip': 'Podnieś pasek wpisywania, aby uniknąć zasłaniania przez systemowe elementy ekranu, takie jak pasek gestów.', @@ -1787,4 +1790,11 @@ 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)', + 'settings.openchamber.visual.option.followUpBehavior.immediate.label': 'Send immediately', }; diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 78c86d4f74..852136cafa 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -2650,4 +2650,23 @@ export const dict: Record = { 'quota.window.chat': 'Chat Requests', 'quota.window.completions': 'Completions', 'quota.window.premiumInteractions': 'Premium interactions', + 'connectionStatus.hop1.connected': 'Frontend ↔ OpenChamber: connected', + 'connectionStatus.hop1.connecting': 'Frontend ↔ OpenChamber: connecting…', + 'connectionStatus.hop1.reconnecting': 'Frontend ↔ OpenChamber: reconnecting', + 'connectionStatus.hop1.disconnected': 'Frontend ↔ OpenChamber: disconnected', + 'connectionStatus.hop2.healthy': 'OpenChamber ↔ OpenCode: ready', + 'connectionStatus.hop2.unhealthy': 'OpenChamber ↔ OpenCode: unavailable', + 'connectionStatus.hop2.unknown': 'OpenChamber ↔ OpenCode: checking…', + 'connectionStatus.reason.offline': 'Offline', + 'connectionStatus.reason.authRequired': 'Auth/config issue', + 'connectionStatus.reason.unavailable': 'OpenCode unavailable', + 'connectionStatus.reason.timeout': 'Connection timed out', + 'connectionStatus.overall.connected': 'Connected', + 'connectionStatus.overall.reconnecting': 'Reconnecting', + 'connectionStatus.overall.degraded': 'Degraded', + 'connectionStatus.overall.disconnected': 'Disconnected', + 'connectionStatus.overall.unknown': 'Unknown', + 'connectionStatus.tooltip.title': 'Connection status', + + 'connectionStatus.aria.indicator': 'Connection status: {state}', } as const; 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 60738525f7..d9b1fd1e58 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -1646,6 +1646,9 @@ export const settingsDict = { "settings.openchamber.visual.field.persistDraftMessages": "Manter rascunhos de mensagens", "settings.openchamber.visual.field.enableSpellcheckInTextInputsAria": "Ativar ortografia em campos de texto", "settings.openchamber.visual.field.enableSpellcheckInTextInputs": "Ativar ortografia em campos de texto", + "settings.openchamber.visual.field.stripSlashOnSubmitAria": "Remover a barra inicial ao enviar para que comandos com barra não sejam expandidos", + "settings.openchamber.visual.field.stripSlashOnSubmit": "Remover barra ao enviar", + "settings.openchamber.visual.field.stripSlashOnSubmitDescription": "Quando ativado, enviar um comando ou skill com barra remove a barra inicial e envia o texto como uma mensagem comum. O comando ou skill não é invocado e a conversa mostra o que você digitou.", "settings.openchamber.visual.field.sendAnonymousUsageReportsAria": "Enviar relatórios anônimos de uso", "settings.openchamber.visual.field.sendAnonymousUsageReports": "Enviar relatórios anônimos de uso", "settings.openchamber.visual.field.sendAnonymousUsageReportsHint": "Ajuda-nos a entender quais versões do aplicativo são usadas ativamente para priorizar melhorias. Coletamos apenas a versão do aplicativo, a plataforma e o ambiente de execução; não coletamos dados pessoais nem código.", @@ -1795,4 +1798,11 @@ 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)", + "settings.openchamber.visual.option.followUpBehavior.immediate.label": "Send immediately", } as const; diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index d3075f8d30..aacb34c210 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -2634,4 +2634,23 @@ export const dict: Record = { "quota.window.chat": "Chat Requests", "quota.window.completions": "Completions", "quota.window.premiumInteractions": "Premium interactions", + "connectionStatus.hop1.connected": "Frontend ↔ OpenChamber: connected", + "connectionStatus.hop1.connecting": "Frontend ↔ OpenChamber: connecting…", + "connectionStatus.hop1.reconnecting": "Frontend ↔ OpenChamber: reconnecting", + "connectionStatus.hop1.disconnected": "Frontend ↔ OpenChamber: disconnected", + "connectionStatus.hop2.healthy": "OpenChamber ↔ OpenCode: ready", + "connectionStatus.hop2.unhealthy": "OpenChamber ↔ OpenCode: unavailable", + "connectionStatus.hop2.unknown": "OpenChamber ↔ OpenCode: checking…", + "connectionStatus.reason.offline": "Offline", + "connectionStatus.reason.authRequired": "Auth/config issue", + "connectionStatus.reason.unavailable": "OpenCode unavailable", + "connectionStatus.reason.timeout": "Connection timed out", + "connectionStatus.overall.connected": "Connected", + "connectionStatus.overall.reconnecting": "Reconnecting", + "connectionStatus.overall.degraded": "Degraded", + "connectionStatus.overall.disconnected": "Disconnected", + "connectionStatus.overall.unknown": "Unknown", + "connectionStatus.tooltip.title": "Connection status", + + "connectionStatus.aria.indicator": "Connection status: {state}", }; diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index 8d1dcef176..c5f23f120d 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -1646,6 +1646,9 @@ export const settingsDict = { "settings.openchamber.visual.field.persistDraftMessages": "Зберігати чернетки повідомлень", "settings.openchamber.visual.field.enableSpellcheckInTextInputsAria": "Увімкнути перевірку орфографії під час введення тексту", "settings.openchamber.visual.field.enableSpellcheckInTextInputs": "Увімкнути перевірку орфографії в текстових полях", + "settings.openchamber.visual.field.stripSlashOnSubmitAria": "Видаляти початковий слеш під час надсилання, щоб slash-команди не розгорталися", + "settings.openchamber.visual.field.stripSlashOnSubmit": "Видаляти слеш під час надсилання", + "settings.openchamber.visual.field.stripSlashOnSubmitDescription": "Коли ввімкнено, надсилання slash-команди або скілу видаляє початковий слеш і надсилає текст як звичайне повідомлення. Команда або скіл не викликається, а в розмові буде показано те, що ви ввели.", "settings.openchamber.visual.field.sendAnonymousUsageReportsAria": "Надсилати анонімні звіти про використання", "settings.openchamber.visual.field.sendAnonymousUsageReports": "Надсилати анонімні звіти про використання", "settings.openchamber.visual.field.sendAnonymousUsageReportsHint": "Допомагає нам зрозуміти, які версії застосунків активно використовуються, щоб ми могли визначити пріоритети покращень. Збираються лише версія застосунку, платформа та середовище виконання – без особистих даних чи коду.", @@ -1795,4 +1798,11 @@ 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)", + "settings.openchamber.visual.option.followUpBehavior.immediate.label": "Send immediately", } as const; diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index a216f81816..d6b48358f7 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -2634,4 +2634,23 @@ export const dict: Record = { "quota.window.chat": "Chat Requests", "quota.window.completions": "Completions", "quota.window.premiumInteractions": "Premium interactions", + "connectionStatus.hop1.connected": "Frontend ↔ OpenChamber: connected", + "connectionStatus.hop1.connecting": "Frontend ↔ OpenChamber: connecting…", + "connectionStatus.hop1.reconnecting": "Frontend ↔ OpenChamber: reconnecting", + "connectionStatus.hop1.disconnected": "Frontend ↔ OpenChamber: disconnected", + "connectionStatus.hop2.healthy": "OpenChamber ↔ OpenCode: ready", + "connectionStatus.hop2.unhealthy": "OpenChamber ↔ OpenCode: unavailable", + "connectionStatus.hop2.unknown": "OpenChamber ↔ OpenCode: checking…", + "connectionStatus.reason.offline": "Offline", + "connectionStatus.reason.authRequired": "Auth/config issue", + "connectionStatus.reason.unavailable": "OpenCode unavailable", + "connectionStatus.reason.timeout": "Connection timed out", + "connectionStatus.overall.connected": "Connected", + "connectionStatus.overall.reconnecting": "Reconnecting", + "connectionStatus.overall.degraded": "Degraded", + "connectionStatus.overall.disconnected": "Disconnected", + "connectionStatus.overall.unknown": "Unknown", + "connectionStatus.tooltip.title": "Connection status", + + "connectionStatus.aria.indicator": "Connection status: {state}", }; 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 9e2b9ccf09..9ef969d9b7 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -1646,6 +1646,9 @@ export const settingsDict = { 'settings.openchamber.visual.field.persistDraftMessages': '保留草稿消息', 'settings.openchamber.visual.field.enableSpellcheckInTextInputsAria': '在文本输入框启用拼写检查', 'settings.openchamber.visual.field.enableSpellcheckInTextInputs': '在文本输入框启用拼写检查', + 'settings.openchamber.visual.field.stripSlashOnSubmitAria': '提交时去除前导斜杠,以避免斜杠命令被展开', + 'settings.openchamber.visual.field.stripSlashOnSubmit': '提交时去除斜杠', + 'settings.openchamber.visual.field.stripSlashOnSubmitDescription': '启用后,提交斜杠命令或技能时会去除前导斜杠,并以普通消息的形式发送文本。命令或技能不会被调用,对话中会显示你输入的内容。', 'settings.openchamber.visual.field.sendAnonymousUsageReportsAria': '发送匿名使用报告', 'settings.openchamber.visual.field.sendAnonymousUsageReports': '发送匿名使用报告', 'settings.openchamber.visual.field.sendAnonymousUsageReportsHint': '帮助我们了解哪些应用版本正在被积极使用,以便优先改进。仅收集应用版本、平台和运行时信息,不收集个人数据或代码。', @@ -1795,4 +1798,11 @@ 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)', + 'settings.openchamber.visual.option.followUpBehavior.immediate.label': 'Send immediately', } as const; diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index a6080c2648..c7b933796b 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -2634,4 +2634,23 @@ export const dict: Record = { 'quota.window.chat': 'Chat Requests', 'quota.window.completions': 'Completions', 'quota.window.premiumInteractions': 'Premium interactions', + 'connectionStatus.hop1.connected': 'Frontend ↔ OpenChamber: connected', + 'connectionStatus.hop1.connecting': 'Frontend ↔ OpenChamber: connecting…', + 'connectionStatus.hop1.reconnecting': 'Frontend ↔ OpenChamber: reconnecting', + 'connectionStatus.hop1.disconnected': 'Frontend ↔ OpenChamber: disconnected', + 'connectionStatus.hop2.healthy': 'OpenChamber ↔ OpenCode: ready', + 'connectionStatus.hop2.unhealthy': 'OpenChamber ↔ OpenCode: unavailable', + 'connectionStatus.hop2.unknown': 'OpenChamber ↔ OpenCode: checking…', + 'connectionStatus.reason.offline': 'Offline', + 'connectionStatus.reason.authRequired': 'Auth/config issue', + 'connectionStatus.reason.unavailable': 'OpenCode unavailable', + 'connectionStatus.reason.timeout': 'Connection timed out', + 'connectionStatus.overall.connected': 'Connected', + 'connectionStatus.overall.reconnecting': 'Reconnecting', + 'connectionStatus.overall.degraded': 'Degraded', + 'connectionStatus.overall.disconnected': 'Disconnected', + 'connectionStatus.overall.unknown': 'Unknown', + 'connectionStatus.tooltip.title': 'Connection status', + + 'connectionStatus.aria.indicator': 'Connection status: {state}', }; 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 d5518674bc..8d66fdb901 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -1567,6 +1567,9 @@ 'settings.openchamber.visual.field.persistDraftMessages': '保留草稿訊息', 'settings.openchamber.visual.field.enableSpellcheckInTextInputsAria': '在文字輸入方塊啟用拼寫檢查', 'settings.openchamber.visual.field.enableSpellcheckInTextInputs': '在文字輸入方塊啟用拼寫檢查', + 'settings.openchamber.visual.field.stripSlashOnSubmitAria': '送出時移除前導斜線,以避免斜線指令被展開', + 'settings.openchamber.visual.field.stripSlashOnSubmit': '送出時移除斜線', + 'settings.openchamber.visual.field.stripSlashOnSubmitDescription': '啟用後,送出斜線指令或技能時會移除前導斜線,並以普通訊息的形式傳送文字。指令或技能不會被呼叫,對話中會顯示你輸入的內容。', 'settings.openchamber.visual.field.sendAnonymousUsageReportsAria': '送出匿名使用報告', 'settings.openchamber.visual.field.sendAnonymousUsageReports': '送出匿名使用報告', 'settings.openchamber.visual.field.sendAnonymousUsageReportsHint': '協助我們了解哪些應用程式版本仍在被積極使用,以便優先改進。僅收集應用程式版本、平台與執行階段資訊,不收集個人資料或程式碼。', @@ -1795,4 +1798,11 @@ '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)', + 'settings.openchamber.visual.option.followUpBehavior.immediate.label': 'Send immediately', } as const; diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 388bf30e1c..4e17c4be79 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -2633,4 +2633,23 @@ export const dict: Record = { 'quota.window.chat': 'Chat Requests', 'quota.window.completions': 'Completions', 'quota.window.premiumInteractions': 'Premium interactions', + 'connectionStatus.hop1.connected': 'Frontend ↔ OpenChamber: connected', + 'connectionStatus.hop1.connecting': 'Frontend ↔ OpenChamber: connecting…', + 'connectionStatus.hop1.reconnecting': 'Frontend ↔ OpenChamber: reconnecting', + 'connectionStatus.hop1.disconnected': 'Frontend ↔ OpenChamber: disconnected', + 'connectionStatus.hop2.healthy': 'OpenChamber ↔ OpenCode: ready', + 'connectionStatus.hop2.unhealthy': 'OpenChamber ↔ OpenCode: unavailable', + 'connectionStatus.hop2.unknown': 'OpenChamber ↔ OpenCode: checking…', + 'connectionStatus.reason.offline': 'Offline', + 'connectionStatus.reason.authRequired': 'Auth/config issue', + 'connectionStatus.reason.unavailable': 'OpenCode unavailable', + 'connectionStatus.reason.timeout': 'Connection timed out', + 'connectionStatus.overall.connected': 'Connected', + 'connectionStatus.overall.reconnecting': 'Reconnecting', + 'connectionStatus.overall.degraded': 'Degraded', + 'connectionStatus.overall.disconnected': 'Disconnected', + 'connectionStatus.overall.unknown': 'Unknown', + 'connectionStatus.tooltip.title': 'Connection status', + + 'connectionStatus.aria.indicator': 'Connection status: {state}', }; diff --git a/packages/ui/src/lib/opencode/client.ts b/packages/ui/src/lib/opencode/client.ts index aac2bd4253..216ef690d5 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 90cbae22aa..a1df93d743 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) { @@ -489,6 +495,9 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => { if (typeof settings.inputSpellcheckEnabled === 'boolean' && settings.inputSpellcheckEnabled !== store.inputSpellcheckEnabled) { store.setInputSpellcheckEnabled(settings.inputSpellcheckEnabled); } + if (typeof settings.stripSlashOnSubmit === 'boolean' && settings.stripSlashOnSubmit !== store.stripSlashOnSubmit) { + store.setStripSlashOnSubmit(settings.stripSlashOnSubmit); + } if ( typeof settings.showOpenCodeUpdateNotifications === 'boolean' && settings.showOpenCodeUpdateNotifications !== store.showOpenCodeUpdateNotifications @@ -838,8 +847,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; @@ -1015,6 +1026,9 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => { if (typeof candidate.inputSpellcheckEnabled === 'boolean') { result.inputSpellcheckEnabled = candidate.inputSpellcheckEnabled; } + if (typeof candidate.stripSlashOnSubmit === 'boolean') { + result.stripSlashOnSubmit = candidate.stripSlashOnSubmit; + } if (typeof candidate.showOpenCodeUpdateNotifications === 'boolean') { result.showOpenCodeUpdateNotifications = candidate.showOpenCodeUpdateNotifications; } diff --git a/packages/ui/src/lib/settings/search.ts b/packages/ui/src/lib/settings/search.ts index 3d24931b20..82137c7a78 100644 --- a/packages/ui/src/lib/settings/search.ts +++ b/packages/ui/src/lib/settings/search.ts @@ -205,11 +205,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', @@ -224,6 +224,13 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ keywords: ['spelling', 'input'], isAvailable: (ctx) => !ctx.isMobile, }, + { + id: 'chat.strip-slash-on-submit', + page: 'chat', + titleKey: 'settings.openchamber.visual.field.stripSlashOnSubmit', + descriptionKey: 'settings.openchamber.visual.field.stripSlashOnSubmitDescription', + keywords: ['slash', 'command', 'skill', 'expand', 'submit'], + }, { id: 'sessions.default-model', page: 'sessions', diff --git a/packages/ui/src/lib/shortcuts.ts b/packages/ui/src/lib/shortcuts.ts index 5640372bd0..24de883410 100644 --- a/packages/ui/src/lib/shortcuts.ts +++ b/packages/ui/src/lib/shortcuts.ts @@ -300,7 +300,7 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ }, { id: 'cycle_agent', - defaultCombo: 'tab', + defaultCombo: '', label: 'Cycle agent', description: 'Cycle agent while the model selector is open', customizable: true, diff --git a/packages/ui/src/lib/worktreeSessionCreator.ts b/packages/ui/src/lib/worktreeSessionCreator.ts index c93da0148c..2f43e29523 100644 --- a/packages/ui/src/lib/worktreeSessionCreator.ts +++ b/packages/ui/src/lib/worktreeSessionCreator.ts @@ -289,6 +289,165 @@ export async function createWorktreeDraft(options?: { initialPrompt?: string; ti return createInstantWorktreeDraft(options); } +export async function createWorktreeOnly(): Promise { + if (isCreatingWorktreeSession) { + return null; + } + + const activeProject = useProjectsStore.getState().getActiveProject(); + if (!activeProject?.path) { + toast.error('No active project', { + description: 'Please select a project first.', + }); + return null; + } + + const projectDirectory = activeProject.path; + let isGitRepo = false; + try { + isGitRepo = await checkIsGitRepository(projectDirectory); + } catch { + // ignored + } + + if (!isGitRepo) { + toast.error('Not a Git repository', { + description: 'Worktrees can only be created in Git repositories.', + }); + return null; + } + + isCreatingWorktreeSession = true; + + try { + const projectRef: ProjectRef = { id: activeProject.id, path: projectDirectory }; + const preferredName = generateBranchName(); + const setupCommands = await getWorktreeSetupCommands(projectRef); + const metadata = await createWorktreeWithDefaults(projectRef, { + preferredName, + mode: 'new', + branchName: preferredName, + worktreeName: preferredName, + setupCommands, + returnAfterDirectoryCreated: true, + }); + + + return metadata.path; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to create worktree'; + toast.error('Failed to create worktree', { + description: message, + }); + return null; + } finally { + isCreatingWorktreeSession = false; + } +} + +/** + * Create a new session with a worktree for a specific branch. + * Unlike createWorktreeSession(), this allows specifying the project and branch explicitly. + * + * @param projectDirectory - The root directory of the git repository + * @param branchName - The name of the branch to create a worktree for + * @returns The created session, or null if creation failed + */ +export async function createWorktreeSessionForBranch( + projectDirectory: string, + branchName: string, + options?: { + kind?: 'pr' | 'standard'; + existingBranch?: string; + worktreeName?: string; + setUpstream?: boolean; + upstreamRemote?: string; + upstreamBranch?: string; + ensureRemoteName?: string; + ensureRemoteUrl?: string; + createdFromBranch?: string; + returnAfterDirectoryCreated?: boolean; + } +): Promise<{ id: string } | null> { + if (isCreatingWorktreeSession) { + return null; + } + + isCreatingWorktreeSession = true; + + try { + const projectRef = resolveProjectRef(projectDirectory); + if (!projectRef) { + throw new Error('Project is not registered in OpenChamber'); + } + + // Check if it's a git repo (root project path) + let isGitRepo = false; + try { + isGitRepo = await checkIsGitRepository(projectRef.path); + } catch { + // Ignore errors, treat as not a git repo + } + + if (!isGitRepo) { + toast.error('Not a Git repository', { + description: 'Worktrees can only be created in Git repositories.', + }); + return null; + } + + const setupCommands = await getWorktreeSetupCommands(projectRef); + const rootBranch = await getRootBranch(projectRef.path); + const metadata = await createWorktreeWithDefaults(projectRef, { + preferredName: branchName, + mode: 'existing', + existingBranch: options?.existingBranch || branchName, + branchName, + worktreeName: options?.worktreeName || branchName, + setUpstream: options?.setUpstream, + upstreamRemote: options?.upstreamRemote, + upstreamBranch: options?.upstreamBranch, + ensureRemoteName: options?.ensureRemoteName, + ensureRemoteUrl: options?.ensureRemoteUrl, + setupCommands, + returnAfterDirectoryCreated: options?.returnAfterDirectoryCreated, + }); + + const kind = options?.kind ?? 'standard'; + const createdMetadata = { + ...metadata, + createdFromBranch: options?.createdFromBranch || rootBranch, + kind, + }; + + await waitForWorktreeBootstrap(metadata.path); + + // Create the session + const sessionStore = useSessionUIStore.getState(); + const session = await sessionStore.createSession(undefined, metadata.path); + if (!session) { + // Clean up the worktree if session creation failed + await removeProjectWorktree(projectRef, metadata, { deleteLocalBranch: true }).catch(() => undefined); + toast.error('Failed to create session', { + description: 'Could not create a session for the worktree.', + }); + return null; + } + + initializeSessionForWorktree(session.id, createdMetadata); + + return session; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to create worktree session'; + toast.error('Failed to create worktree', { + description: message, + }); + return null; + } finally { + isCreatingWorktreeSession = false; + } +} + /** * Create a worktree session for a new branch name. * Callers can still use startPoint for metadata or follow-up git operations. diff --git a/packages/ui/src/stores/messageQueueStore.ts b/packages/ui/src/stores/messageQueueStore.ts index 5e28dc01e1..4ed4895596 100644 --- a/packages/ui/src/stores/messageQueueStore.ts +++ b/packages/ui/src/stores/messageQueueStore.ts @@ -4,6 +4,33 @@ import { getSafeStorage } from './utils/safeStorage'; import type { AttachedFile } from './types/sessionTypes'; import { updateDesktopSettings } from '@/lib/persistence'; +export type FollowUpBehavior = 'steer' | 'queue' | 'immediate'; + +export const DEFAULT_FOLLOW_UP_BEHAVIOR: FollowUpBehavior = 'queue'; + +export const isFollowUpBehavior = (value: unknown): value is FollowUpBehavior => ( + value === 'steer' || value === 'queue' || value === 'immediate' +); + +export const normalizeFollowUpBehavior = ( + value: unknown, + legacyQueueModeEnabled?: boolean | null, +): FollowUpBehavior => { + if (isFollowUpBehavior(value)) { + return value; + } + + if (legacyQueueModeEnabled === false) { + return 'immediate'; + } + + if (legacyQueueModeEnabled === true) { + return 'queue'; + } + + return DEFAULT_FOLLOW_UP_BEHAVIOR; +}; + export interface QueuedMessage { id: string; content: string; @@ -20,7 +47,7 @@ export interface QueuedMessage { interface MessageQueueState { queuedMessages: Record; // sessionId → queue - queueModeEnabled: boolean; // global toggle + followUpBehavior: FollowUpBehavior; } interface MessageQueueActions { @@ -29,18 +56,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)}`; @@ -126,10 +159,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) => { @@ -138,11 +170,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/stores/useConfigStore.ts b/packages/ui/src/stores/useConfigStore.ts index 246e994b4b..6a24ac02ba 100644 --- a/packages/ui/src/stores/useConfigStore.ts +++ b/packages/ui/src/stores/useConfigStore.ts @@ -940,6 +940,25 @@ const resolveSelectionWithManualGuard = ({ }; }; +// Hop-separated normalized state for the aggregated header connection +// indicator (issue #1737 / #1556). These objects are the source of truth for +// the per-hop view; reason strings are short stable codes that the user-facing +// reason mapper (Phase 2) translates into hover copy. They are intentionally +// not part of the persisted state shape — live connection status must always +// start fresh on reload. +export type RuntimeTransportPhase = "connecting" | "connected" | "reconnecting" | "disconnected" | "offline"; +export type RuntimeTransportState = { + phase: RuntimeTransportPhase; + reason: string | null; + updatedAt: number; +}; +export type OpenCodeRuntimePhase = "unknown" | "healthy" | "unhealthy"; +export type OpenCodeRuntimeState = { + phase: OpenCodeRuntimePhase; + reason: string | null; + updatedAt: number; +}; + interface ConfigStore { activeDirectoryKey: string; @@ -957,8 +976,17 @@ interface ConfigStore { selectionSource: "auto" | "manual"; isConnected: boolean; hasEverConnected: boolean; - connectionPhase: "connecting" | "connected" | "reconnecting"; + connectionPhase: "connecting" | "connected" | "reconnecting" | "disconnected"; lastDisconnectReason: string | null; + // Hop-separated normalized state (issue #1737 / #1556). The legacy fields + // above still drive readiness gating; the two new fields drive the + // aggregated header indicator and are the source of truth for which hop is + // unhealthy. They are intentionally low-frequency, narrow, and never + // coupled to streaming/session state to avoid render fanout. + runtimeTransportState: RuntimeTransportState; + openCodeRuntimeState: OpenCodeRuntimeState; + setRuntimeTransportState: (state: RuntimeTransportState) => void; + setOpenCodeRuntimeState: (state: OpenCodeRuntimeState) => void; isInitialized: boolean; modelsMetadata: Map; // OpenChamber settings-based defaults (take precedence over agent preferences) @@ -1108,6 +1136,14 @@ export const useConfigStore = create()( hasEverConnected: false, connectionPhase: "connecting", lastDisconnectReason: null, + runtimeTransportState: { phase: "connecting", reason: null, updatedAt: 0 }, + openCodeRuntimeState: { phase: "unknown", reason: null, updatedAt: 0 }, + setRuntimeTransportState: (state) => { + set({ runtimeTransportState: state }); + }, + setOpenCodeRuntimeState: (state) => { + set({ openCodeRuntimeState: state }); + }, isInitialized: false, modelsMetadata: new Map(), settingsDefaultModel: undefined, @@ -2997,6 +3033,7 @@ export const useConfigStore = create()( const isHealthy = await probeOpenCodeHealth(options?.timeoutMs); if (isHealthy) { set({ isConnected: true, hasEverConnected: true, connectionPhase: "connected" }); + get().setOpenCodeRuntimeState({ phase: 'healthy', reason: null, updatedAt: Date.now() }); return true; } @@ -3010,6 +3047,7 @@ export const useConfigStore = create()( connectionPhase: state.hasEverConnected ? "reconnecting" : "connecting", lastDisconnectReason: 'health_probe_unhealthy', }); + get().setOpenCodeRuntimeState({ phase: 'unhealthy', reason: 'health_probe_unhealthy', updatedAt: Date.now() }); return false; }, @@ -3047,6 +3085,9 @@ export const useConfigStore = create()( connectionPhase: hasEverConnected ? "reconnecting" : "connecting", lastDisconnectReason: 'health_check_unhealthy', }); + get().setOpenCodeRuntimeState(isHealthy + ? { phase: 'healthy', reason: null, updatedAt: Date.now() } + : { phase: 'unhealthy', reason: 'health_check_unhealthy', updatedAt: Date.now() }); markStartupTrace('checkConnection:end', { healthy: isHealthy, attempts: attempt + 1 }); return isHealthy; } catch (error) { @@ -3065,6 +3106,7 @@ export const useConfigStore = create()( connectionPhase: get().hasEverConnected ? "reconnecting" : "connecting", lastDisconnectReason: 'health_check_failed', }); + get().setOpenCodeRuntimeState({ phase: 'unhealthy', reason: 'health_check_failed', updatedAt: Date.now() }); markStartupTrace('checkConnection:end', { healthy: false, attempts: maxAttempts }); return false; }, @@ -3159,6 +3201,7 @@ export const useConfigStore = create()( connectionPhase: get().hasEverConnected ? "reconnecting" : "connecting", lastDisconnectReason: 'init_error', }); + get().setOpenCodeRuntimeState({ phase: 'unhealthy', reason: 'init_error', updatedAt: Date.now() }); markStartupTrace('initializeApp:error', { error: error instanceof Error ? error.message : String(error) }); } })().finally(() => { diff --git a/packages/ui/src/stores/useMultiRunStore.test.ts b/packages/ui/src/stores/useMultiRunStore.test.ts index b2a1bbb656..6612357340 100644 --- a/packages/ui/src/stores/useMultiRunStore.test.ts +++ b/packages/ui/src/stores/useMultiRunStore.test.ts @@ -199,8 +199,11 @@ describe('useMultiRunStore', () => { expect(worktreeCreateCalls[0]?.project).toEqual({ id: 'project-1', path: '/repo' }); expect(worktreeCreateCalls[0]?.args.returnAfterDirectoryCreated).toBe(true); expect(worktreeCreateCalls[0]?.options).toEqual({ resolvedRootTrackingRemote: null }); - expect(worktreeBootstrapWaitCalls).toEqual([]); - expect(operationOrder).toEqual(['createSession:/repo-worktrees/fix-thing']); + expect(worktreeBootstrapWaitCalls).toEqual(['/repo-worktrees/fix-thing']); + expect(operationOrder).toEqual([ + 'wait:/repo-worktrees/fix-thing', + 'createSession:/repo-worktrees/fix-thing', + ]); expect(registeredDirectories).toEqual([{ sessionID: 'ses_multirun', directory: '/repo-worktrees/fix-thing' }]); expect(worktreeMetadataCalls).toEqual([{ sessionId: 'ses_multirun', path: '/repo-worktrees/fix-thing' }]); }); diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index dc8addea63..a8db533b17 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -615,6 +615,7 @@ interface UIStore { persistChatDraft: boolean; showOpenCodeUpdateNotifications: boolean; inputSpellcheckEnabled: boolean; + stripSlashOnSubmit: boolean; wideChatLayoutEnabled: boolean; showToolFileIcons: boolean; showTurnChangedFiles: boolean; @@ -730,7 +731,7 @@ interface UIStore { toggleHiddenModel: (providerID: string, modelID: string) => void; isHiddenModel: (providerID: string, modelID: string) => boolean; hideAllModels: (providerID: string, modelIDs: string[]) => void; - showAllModels: (providerID: string) => void; + showAllModels: (providerID: string, modelIDs?: string[]) => void; toggleModelProviderCollapsed: (providerID: string) => void; setModelProvidersCollapsed: (providerIDs: string[], collapsed: boolean) => void; isFavoriteModel: (providerID: string, modelID: string) => boolean; @@ -759,6 +760,7 @@ interface UIStore { setPersistChatDraft: (value: boolean) => void; setShowOpenCodeUpdateNotifications: (value: boolean) => void; setInputSpellcheckEnabled: (value: boolean) => void; + setStripSlashOnSubmit: (value: boolean) => void; setWideChatLayoutEnabled: (value: boolean) => void; setShowToolFileIcons: (value: boolean) => void; setShowTurnChangedFiles: (value: boolean) => void; @@ -896,6 +898,7 @@ export const useUIStore = create()( persistChatDraft: true, showOpenCodeUpdateNotifications: true, inputSpellcheckEnabled: false, + stripSlashOnSubmit: false, wideChatLayoutEnabled: false, showToolFileIcons: true, showTurnChangedFiles: false, @@ -1772,10 +1775,20 @@ export const useUIStore = create()( }); }, - showAllModels: (providerID) => { - set((state) => ({ - hiddenModels: state.hiddenModels.filter((item) => item.providerID !== providerID), - })); + showAllModels: (providerID, modelIDs) => { + set((state) => { + if (modelIDs === undefined) { + return { + hiddenModels: state.hiddenModels.filter((item) => item.providerID !== providerID), + }; + } + const idsSet = new Set(modelIDs.filter((id) => typeof id === 'string' && id.length > 0)); + return { + hiddenModels: state.hiddenModels.filter( + (item) => !(item.providerID === providerID && idsSet.has(item.modelID)) + ), + }; + }); }, toggleModelProviderCollapsed: (providerID) => { @@ -1978,6 +1991,9 @@ export const useUIStore = create()( setInputSpellcheckEnabled: (value) => { set({ inputSpellcheckEnabled: value }); }, + setStripSlashOnSubmit: (value) => { + set({ stripSlashOnSubmit: value }); + }, setWideChatLayoutEnabled: (value) => { set({ wideChatLayoutEnabled: value }); }, @@ -2239,6 +2255,7 @@ export const useUIStore = create()( persistChatDraft: state.persistChatDraft, showOpenCodeUpdateNotifications: state.showOpenCodeUpdateNotifications, inputSpellcheckEnabled: state.inputSpellcheckEnabled, + stripSlashOnSubmit: state.stripSlashOnSubmit, wideChatLayoutEnabled: state.wideChatLayoutEnabled, showToolFileIcons: state.showToolFileIcons, showTurnChangedFiles: state.showTurnChangedFiles, diff --git a/packages/ui/src/sync/__tests__/session-status-snapshot.test.ts b/packages/ui/src/sync/__tests__/session-status-snapshot.test.ts index b6b0247737..40424351a8 100644 --- a/packages/ui/src/sync/__tests__/session-status-snapshot.test.ts +++ b/packages/ui/src/sync/__tests__/session-status-snapshot.test.ts @@ -7,6 +7,7 @@ import type { DirectoryStore } from "../child-store" import { applySessionStatusSnapshot, needsSnapshotAfterStatusPoll, + shouldUseDisconnectedTransportPhase, } from "../sync-context" type StatusSnapshot = Record @@ -115,3 +116,21 @@ describe("needsSnapshotAfterStatusPoll", () => { expect(needsSnapshotAfterStatusPoll(store.getState(), "ses_a", undefined)).toBe(false) }) }) + +describe("shouldUseDisconnectedTransportPhase", () => { + test("returns true for auth-like terminal disconnect reasons", () => { + expect(shouldUseDisconnectedTransportPhase("ws_auth_token_unavailable")).toBe(true) + expect(shouldUseDisconnectedTransportPhase("401")).toBe(true) + expect(shouldUseDisconnectedTransportPhase("forbidden")).toBe(true) + }) + + test("returns true when websocket closes before ready", () => { + expect(shouldUseDisconnectedTransportPhase("ws_closed_before_ready")).toBe(true) + }) + + test("returns false for transient reconnect reasons", () => { + expect(shouldUseDisconnectedTransportPhase("ws_closed:code=1006")).toBe(false) + expect(shouldUseDisconnectedTransportPhase("ws_heartbeat_timeout")).toBe(false) + expect(shouldUseDisconnectedTransportPhase(null)).toBe(false) + }) +}) diff --git a/packages/ui/src/sync/session-ui-store.test.js b/packages/ui/src/sync/session-ui-store.test.js index 276385a0d4..02b4a20cd5 100644 --- a/packages/ui/src/sync/session-ui-store.test.js +++ b/packages/ui/src/sync/session-ui-store.test.js @@ -382,3 +382,47 @@ describe('routeMessage skill invocation', () => { expect(sendCommandCalls).toHaveLength(0); }); }); + +describe('openNewSessionDraft project binding', () => { + const projectA = { id: 'proj-a', path: '/projects/alpha', label: 'Alpha' }; + const projectB = { id: 'proj-b', path: '/projects/beta', label: 'Beta' }; + + beforeEach(() => { + useSessionUIStore.setState({ + currentSessionId: null, + currentSessionDirectory: null, + newSessionDraft: { open: false, directoryOverride: null, parentID: null }, + availableWorktreesByProject: new Map(), + }); + useProjectsStore.setState({ + projects: [projectA, projectB], + activeProjectId: projectA.id, + }); + useDirectoryStore.getState().setDirectory(projectB.path, { showOverlay: false }); + }); + + test('binds draft to active project when current directory differs', () => { + useSessionUIStore.getState().openNewSessionDraft(); + const draft = useSessionUIStore.getState().newSessionDraft; + + expect(draft.open).toBe(true); + expect(draft.selectedProjectId).toBe(projectA.id); + expect(draft.directoryOverride).toBe(projectA.path); + }); + + test('respects explicit directoryOverride over active project', () => { + useSessionUIStore.getState().openNewSessionDraft({ directoryOverride: '/projects/beta/src' }); + const draft = useSessionUIStore.getState().newSessionDraft; + + expect(draft.open).toBe(true); + expect(draft.directoryOverride).toBe('/projects/beta/src'); + }); + + test('respects explicit selectedProjectId over active project', () => { + useSessionUIStore.getState().openNewSessionDraft({ selectedProjectId: projectB.id }); + const draft = useSessionUIStore.getState().newSessionDraft; + + expect(draft.open).toBe(true); + expect(draft.selectedProjectId).toBe(projectB.id); + }); +}); diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index f32c837eb4..2be088d856 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -25,6 +25,7 @@ import { useDirectoryStore } from "@/stores/useDirectoryStore" import { useSessionFoldersStore } from "@/stores/useSessionFoldersStore" import { useCommandsStore } from "@/stores/useCommandsStore" import { useSkillsStore } from "@/stores/useSkillsStore" +import { useUIStore } from "@/stores/useUIStore" import { getSafeStorage } from "@/stores/utils/safeStorage" import { markPendingUserSendAnimation } from "@/lib/userSendAnimation" import { flattenAssistantTextParts } from "@/lib/messages/messageText" @@ -83,6 +84,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") { @@ -95,53 +97,66 @@ export function routeMessage(params: { }).then(() => undefined) } - // Slash commands — fire and forget, SSE delivers messages and status - if (params.content.startsWith("/")) { - const [head, ...tail] = params.content.split(" ") - const cmdName = head.slice(1) - - const dirState = getDirectoryState(requestDirectory) - const syncCommands = dirState?.command ?? [] - const storeCommands = useCommandsStore.getState().commands - - // OpenCode registers every skill as a command (source: "skill"), but the - // commands store filters skills out and the synced command list is only - // hydrated at bootstrap. Consult the live skills store so a skill selected - // from the slash menu is invoked via session.command (injecting its - // content) instead of being sent as a literal "/name" message (#1605). - const isCommand = syncCommands.find((c) => c.name === cmdName) - || storeCommands.find((c) => c.name === cmdName) - || useSkillsStore.getState().skills.some((s) => s.name === cmdName) - - if (isCommand) { - return optimisticSend({ - sessionId: params.sessionId, - content: params.content, - providerID: params.providerID, - modelID: params.modelID, - agent: params.agent, - directory: requestDirectory, - files: params.files, - send: (messageID) => opencodeClient.sendCommand({ - id: params.sessionId, + // Slash commands — fire and forget, SSE delivers messages and status. + // This branch handles OpenCode/skill command expansion (sendCommand). The + // ChatInput submit path has its own client-side built-in slash handling + // (undo/redo/timeline/compact/summary/etc.) that also consults the + // stripSlashOnSubmit setting; this branch is the single source of truth for + // the server-side sendCommand expansion, called by both the chat composer + // and any other sendMessage caller (e.g. multi-run prompt composer). + let content = params.content + if (content.startsWith("/")) { + // When stripSlashOnSubmit is enabled, send the slash token as plain text + // (with the leading slash removed) instead of routing to sendCommand. + if (useUIStore.getState().stripSlashOnSubmit) { + content = content.replace(/^\/+/, "") + } else { + const [head, ...tail] = content.split(" ") + const cmdName = head.slice(1) + + const dirState = getDirectoryState(requestDirectory) + const syncCommands = dirState?.command ?? [] + const storeCommands = useCommandsStore.getState().commands + + // OpenCode registers every skill as a command (source: "skill"), but the + // commands store filters skills out and the synced command list is only + // hydrated at bootstrap. Consult the live skills store so a skill selected + // from the slash menu is invoked via session.command (injecting its + // content) instead of being sent as a literal "/name" message (#1605). + const isCommand = syncCommands.find((c) => c.name === cmdName) + || storeCommands.find((c) => c.name === cmdName) + || useSkillsStore.getState().skills.some((s) => s.name === cmdName) + + if (isCommand) { + return optimisticSend({ + sessionId: params.sessionId, + content: params.content, providerID: params.providerID, modelID: params.modelID, - command: cmdName, - arguments: tail.join(" "), agent: params.agent, - variant: params.variant, - files: params.files, - messageId: messageID, directory: requestDirectory, - }).then(() => {}), - }) + files: params.files, + send: (messageID) => opencodeClient.sendCommand({ + id: params.sessionId, + providerID: params.providerID, + modelID: params.modelID, + command: cmdName, + arguments: tail.join(" "), + agent: params.agent, + variant: params.variant, + files: params.files, + messageId: messageID, + directory: requestDirectory, + }).then(() => {}), + }) + } } } // Normal prompt — optimistic insert so message appears instantly return optimisticSend({ sessionId: params.sessionId, - content: params.content, + content, providerID: params.providerID, modelID: params.modelID, agent: params.agent, @@ -151,12 +166,13 @@ export function routeMessage(params: { id: params.sessionId, providerID: params.providerID, modelID: params.modelID, - text: params.content, + text: content, agent: params.agent, agentMentions: params.agentMentionName ? [{ name: params.agentMentionName }] : undefined, variant: params.variant, files: params.files, additionalParts: params.additionalParts, + delivery: params.delivery, messageId: messageID, directory: requestDirectory, }).then(() => {}), @@ -165,6 +181,7 @@ export function routeMessage(params: { type SendMessageOptions = { sessionId?: string + delivery?: 'steer' } type AssistantMessageSessionExecution = { @@ -448,6 +465,7 @@ export async function materializeOpenDraftSession(selection: { const trimmedAgent = typeof selection.agent === "string" && selection.agent.trim().length > 0 ? selection.agent.trim() : undefined + const draftTargetFolderId = draft.targetFolderId let draftDirectoryOverride = draft.bootstrapPendingDirectory ?? draft.directoryOverride ?? null const draftProjectId = draft.selectedProjectId ?? null @@ -456,7 +474,9 @@ export async function materializeOpenDraftSession(selection: { store.resolvePendingDraftWorktreeTarget(draft.pendingWorktreeRequestId, draftDirectoryOverride) } - await waitForWorktreeBootstrapIfConfigured(draftDirectoryOverride, draftProjectId) + if (draftDirectoryOverride) { + await waitForWorktreeBootstrap(draftDirectoryOverride) + } const created = await store.createSession(draft.title, draftDirectoryOverride, draft.parentID ?? null) if (!created?.id) throw new Error("Failed to create session") @@ -485,8 +505,16 @@ export async function materializeOpenDraftSession(selection: { store.initializeNewOpenChamberSession(created.id, configState.agents ?? []) + store.closeNewSessionDraft() store.setCurrentSession(created.id, createdDirectory) + if (draftTargetFolderId) { + const scopeKey = draftDirectoryOverride || created.directory || null + if (scopeKey) { + useSessionFoldersStore.getState().addSessionToFolder(scopeKey, draftTargetFolderId, created.id) + } + } + return { sessionId: created.id, directory: createdDirectory, @@ -710,15 +738,19 @@ export const useSessionUIStore = create()((set, get) => ({ const currentDirProject = resolveDraftProjectForDirectory(projects, availableWorktreesByProject, currentDirectory) const selectedProject = (() => { - if (explicitProject) return explicitProject - if (explicitDirectory !== null) return inferredProjectFromDir - if (currentDirectory) return currentDirProject + if (explicitProject || explicitDirectory !== null) { + return explicitProject ?? inferredProjectFromDir ?? fallbackProject + } + if (activeProject) return activeProject + if (currentDirectory) return currentDirProject ?? fallbackProject return persistedProjectByDir ?? persistedProjectById ?? fallbackProject })() const directory = (() => { if (explicitDirectory !== null) return explicitDirectory if (explicitProject) return normalizePath(explicitProject.path ?? null) + const selectedProjectPath = normalizePath(selectedProject?.path ?? null) + if (selectedProjectPath && selectedProjectPath !== currentDirectory) return selectedProjectPath if (currentDirectory) return currentDirectory if (persistedTarget?.directory) return persistedTarget.directory return normalizePath(selectedProject?.path ?? null) @@ -1040,6 +1072,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 +1151,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/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index 8c27fbaaed..a9a47c3287 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -29,7 +29,7 @@ import { syncDebug } from "./debug" import { getReconnectCandidateSessionIds } from "./reconnect-recovery" import { opencodeClient } from "@/lib/opencode/client" import { usePermissionStore } from "@/stores/permissionStore" -import { useConfigStore } from "@/stores/useConfigStore" +import { useConfigStore, type RuntimeTransportPhase } from "@/stores/useConfigStore" import { useTodosPersistStore } from "@/stores/useTodosPersistStore" import { toast } from "@/components/ui" import { appendNotification } from "./notification-store" @@ -511,21 +511,37 @@ async function resyncDirectorySessionStatuses( // resync: the store believes the session is active but the snapshot reports it // idle/absent — a suspected missed idle that the monotonic poll deliberately // won't lower on its own. The authoritative resync is the recovery path. -export function needsSnapshotAfterStatusPoll( - state: DirectoryStore, - sessionId: string, - snapshotEntry: DirectorySessionStatusSnapshot[string] | undefined, -): boolean { - const incoming = toSessionStatus(snapshotEntry) - if (incoming && incoming.type !== "idle") return false - const currentStatus = state.session_status?.[sessionId] - return Boolean(currentStatus && currentStatus.type !== "idle") -} - -type EventRoutingIndex = { - sessionDirectoryById: Map - messageSessionById: Map - sessionMessageIdsById: Map> +export function needsSnapshotAfterStatusPoll( + state: DirectoryStore, + sessionId: string, + snapshotEntry: DirectorySessionStatusSnapshot[string] | undefined, +): boolean { + const incoming = toSessionStatus(snapshotEntry) + if (incoming && incoming.type !== "idle") return false + const currentStatus = state.session_status?.[sessionId] + return Boolean(currentStatus && currentStatus.type !== "idle") +} + +export function shouldUseDisconnectedTransportPhase(reason: string | null): boolean { + if (typeof reason !== "string" || reason.length === 0) return false + // A close before the WS stream ever becomes ready typically means the + // server rejected the upgrade (for example missing/expired URL auth). Treat + // it as a terminal disconnect so the indicator stays red instead of looking + // like an in-progress reconnect that can self-heal without user action. + if (reason === "ws_closed_before_ready") return true + return ( + reason.includes("auth") + || reason === "401" + || reason === "403" + || reason === "unauthorized" + || reason === "forbidden" + ) +} + +type EventRoutingIndex = { + sessionDirectoryById: Map + messageSessionById: Map + sessionMessageIdsById: Map> } const SHOULD_DISPATCH_VSCODE_NOTIFICATIONS = isVSCodeRuntime() @@ -1582,6 +1598,12 @@ export function SyncProvider(props: { const resyncingDirectoriesRef = useRef(new Set()) const statusPollingDirectoriesRef = useRef(new Set()) const pipelineReconnectRef = useRef<((reason?: string) => void) | null>(null) + // Phase observed immediately before the most recent browser `offline` + // transition. Captured in the `offline` listener (sibling useEffect below) + // and consumed by the `online` listener to restore the previous narrow + // state. Reset to `null` whenever the transport is observed to be online + // again, so each new offline transition re-records a fresh pre-offline phase. + const preOfflinePhaseRef = useRef(null) const system = useMemo( () => ({ @@ -1779,6 +1801,11 @@ export function SyncProvider(props: { hasEverConnected: true, connectionPhase: "connected", }) + useConfigStore.getState().setRuntimeTransportState({ + phase: "connected", + reason: null, + updatedAt: Date.now(), + }) if (isRecentBoot()) { return } @@ -1786,14 +1813,22 @@ export function SyncProvider(props: { triggerDirectoryResync(dir) } }, - onDisconnect: (reason) => { - const { hasEverConnected } = useConfigStore.getState() - useConfigStore.setState({ - isConnected: false, - connectionPhase: hasEverConnected ? "reconnecting" : "connecting", - lastDisconnectReason: reason, - }) - }, + onDisconnect: (reason) => { + const { hasEverConnected } = useConfigStore.getState() + const transportPhase = shouldUseDisconnectedTransportPhase(reason) + ? "disconnected" + : hasEverConnected ? "reconnecting" : "connecting" + useConfigStore.setState({ + isConnected: false, + connectionPhase: transportPhase, + lastDisconnectReason: reason, + }) + useConfigStore.getState().setRuntimeTransportState({ + phase: transportPhase, + reason, + updatedAt: Date.now(), + }) + }, onTransportSwitch: () => { // Transport changes are gap-prone in real networks. Treat them like a // reconnect and refresh active session snapshots from HTTP. @@ -1802,6 +1837,11 @@ export function SyncProvider(props: { hasEverConnected: true, connectionPhase: "connected", }) + useConfigStore.getState().setRuntimeTransportState({ + phase: "connected", + reason: null, + updatedAt: Date.now(), + }) for (const dir of childStores.children.keys()) { triggerDirectoryResync(dir) } @@ -1816,6 +1856,62 @@ export function SyncProvider(props: { } }, [props.sdk, childStores, routingIndex, messageStreamTransport, triggerDirectoryResync]) + // Browser-level online/offline observability for the narrow + // `runtimeTransportState` field. The pipeline itself only reads + // `navigator.onLine` synchronously inside `computeRetryDelay`; it does NOT + // install `online`/`offline` listeners, so subscribing here is safe and + // non-conflicting. The existing `connectionPhase`/`isConnected` readiness + // gating is intentionally NOT touched by these handlers — they only + // adjust the per-hop narrow state. + useEffect(() => { + if (typeof window === "undefined") { + return + } + + const handleOffline = () => { + const current = useConfigStore.getState().runtimeTransportState.phase + if (current === "offline") { + return + } + if ( + current === "connected" + || current === "connecting" + || current === "reconnecting" + || current === "disconnected" + ) { + preOfflinePhaseRef.current = current + useConfigStore.getState().setRuntimeTransportState({ + phase: "offline", + reason: "offline", + updatedAt: Date.now(), + }) + } + } + + const handleOnline = () => { + const restored = preOfflinePhaseRef.current + preOfflinePhaseRef.current = null + if (restored === null || restored === "offline") { + // No recorded pre-offline phase, or the pre-offline state was + // already `offline` — let the event pipeline's `onReconnect` + // eventually set the narrow state to `connected`. + return + } + useConfigStore.getState().setRuntimeTransportState({ + phase: restored, + reason: null, + updatedAt: Date.now(), + }) + } + + window.addEventListener("offline", handleOffline) + window.addEventListener("online", handleOnline) + return () => { + window.removeEventListener("offline", handleOffline) + window.removeEventListener("online", handleOnline) + } + }, []) + useEffect(() => { let stopped = false let running = false diff --git a/packages/web/server/lib/opencode/settings-helpers.js b/packages/web/server/lib/opencode/settings-helpers.js index fd995069c1..286fcd9de8 100644 --- a/packages/web/server/lib/opencode/settings-helpers.js +++ b/packages/web/server/lib/opencode/settings-helpers.js @@ -106,6 +106,16 @@ export const createSettingsHelpers = (dependencies) => { return fallback; }; + const normalizeFollowUpBehavior = (value, legacyQueueModeEnabled = null) => { + if (value === 'steer' || value === 'queue' || value === 'immediate') { + return value; + } + if (legacyQueueModeEnabled === false) { + return 'immediate'; + } + return 'queue'; + }; + const sanitizeSettingsUpdate = (payload) => { if (!payload || typeof payload !== 'object') { return {}; @@ -361,8 +371,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;