perf: harden sync architecture and modularize runtimes (#803)
* fix: added desktop app background throttling
* perf: add streaming debug metrics panel
- Show streaming performance metrics in the debug panel
- Auto-enable stream profiling while the panel is open
- Add JSON export for sharing UI and VS Code metrics
* perf: batch streaming updates more aggressively
- Buffer message deltas and metadata updates to cut render churn
- Skip no-op part updates before they touch the message store
- Fix the desktop debug panel shortcut binding
* perf: split streaming event handling and coalesce deltas
- Move streaming content events onto a dedicated fast path
- Defer non-critical stream side effects off the hot path
- Merge repeated message delta events before they reach the UI
* perf: isolate streaming rows from chat rerenders
- Memoize chat rows against render-relevant message changes only
- Read live assistant text directly from store to narrow streaming updates
- Split the active streaming entry from the stable message list path
* perf: streamline chat streaming and SSE proxying
- Reduce chat rerenders around the active streaming path
- Simplify server SSE forwarding to avoid duplicate proxy work
* fix: preserve the first streaming text chunk
- Show the initial text chunk immediately before batched deltas arrive
- Bypass batching for the first text or reasoning part update
- Keep later streaming updates buffered for performance
* perf: align streaming/render hot paths with opencode parity
* perf: harden turn/cache stability and stale delta suppression
* fix: stabilize chat rendering and disable timeline interactions
- Disabled timeline dialog access from shortcuts, commands, and chat input
- Reduced chat render churn by simplifying message list and turn staging behavior
- Improved session-switch stability to prevent update-depth crashes
* perf: track static message rerenders during streaming
* perf: reduce sorted-mode activity rerender fanout
* perf: reduce chat rerender fanout and add active-turn metrics
- Reduced sorted-mode rerender coupling by tightening turn context propagation
- Added a metric for static rerenders outside the active turn during streaming
- Exposed new chat render counters in the debug panel for parity tracking
* fix: keep sorted activity mounted while stream grows
* fix: stabilize session and history scroll rendering
* refactor: decouple server routes from index
* refactor: extract fs module from server index
* refactor: move opencode route ownership into module
* refactor: extract notification route registration
* refactor: extract opencode and notification runtimes from index
* refactor: extract settings runtime and complete server modularization pass
* refactor: modularize server config, skills, icons, and tunnel routes
* refactor: extract server modules from monolithic index.js
Split proxy, routes, runtime helpers, and notification emitter
into dedicated modules under packages/web/server/lib/.
* refactor: replace session/message stores with SSE-driven sync layer
Delete ~9200 lines of old architecture (useEventStream, messageStore,
sessionStore, useSessionStore, questionStore, useTodoStore, client SSE).
New sync layer: event pipeline with coalescing + 16ms flush, pure event
reducer, per-directory child stores with LRU eviction, cursor pagination,
optimistic updates, deferred timeline staging, text throttle.
Migrate all UI consumers to sync hooks (useSessionMessages,
useSessionMessageRecords, useSessionStatus, useSessionPermissions, etc).
Strip session-ui-store to UI-only state, delegate SDK ops to
session-actions with abort-if-busy, optimistic store updates, and
response merging for revert/fork/archive/delete.
Add notification-store for SSE-driven session attention tracking,
cross-directory GlobalSessionStatusStore for sidebar indicators,
client-side diff snapshot sanitization to prevent memory bloat,
and revert message filtering via useVisibleSessionMessages.
* feat: notification store, session actions, activity detection
Add notification-store.ts for SSE-driven attention tracking.
Add sanitize.ts to strip diff snapshot memory bloat.
Add session-actions.ts with optimistic revert/fork/archive/delete.
Improve useSessionActivity with incomplete-message fallback.
Delete useServerSessionStatus polling hook.
* fix: add directory param to all SDK calls, fix command/shell/abort routing
All SDK calls in session-actions.ts now pass directory parameter —
required by OpenCode server to scope session operations. Without it,
abort, commands, revert, fork, and other operations returned 500.
Add routeMessage() in session-ui-store for shell mode (session.shell),
slash commands (session.command), and normal prompts. Command lookup
checks both sync child store and useCommandsStore. Handle /compact
locally via session.summarize().
Implement getContextUsage() to restore header context usage display —
reads token counts from last assistant message in sync store.
* refactor: replace custom API proxy with http-proxy-middleware
Remove ~280 lines of custom proxy code: forwardSseRequest,
forwardGenericApiRequest, collectRequestBodyBuffer, header
manipulation, hop-by-hop filtering, SSE block buffering.
Replace with single createProxyMiddleware() call that handles
SSE streaming, large bodies, and timeouts out of the box.
Dynamic router for OpenCode port changes after restarts.
Auth headers injected via proxyReq hook.
Keep: readiness gate, Windows session merge, API prefix detection.
* perf: targeted event draft cloning to fix streaming render cascade
Event handler was eagerly cloning all state slices on every event,
breaking Zustand selector referential equality. During streaming
(~60 events/sec), this caused every subscriber to re-render regardless
of which slice actually changed.
Now only clones fields the specific event type mutates. Also extracts
StatusRowContainer to isolate high-frequency useAssistantStatus
subscription, removes dead messageStreamStatesMap subscription from
ChatContainer, and narrows useAssistantStatus to only track last
assistant message parts.
MessageList renders: 1972 → 296 per streaming session (-85%).
* fix: null safety for sync state slices
Add defensive ?? {} guards on permission, question, session_status,
and message record access. Prevents crashes when child store state
is partially initialized during bootstrap.
* perf: dedup inflight SDK calls, extract concurrency util, delay PR tracking
Extract mapWithConcurrency to shared lib/concurrency.ts. Add in-flight
dedup for loadProviders/loadAgents to prevent concurrent duplicate SDK
calls. Delay initial PR background tracking by 5s to reduce startup
CPU burst.
* fix: header session lookup across all child stores
Session title and context panel click failed when session belonged to
a different directory than the current child store. Fall back to
getAllSyncSessions() to search all initialized stores.
* chore: bump @opencode-ai/sdk to 1.3.5
* docs: add sync event handling guide
* Optimize session prefetch and improve delete/archive UX
- Add settlement delay to session prefetch to avoid race conditions on
rapid session switches
- Reduce git diff prefetch and session cache limits for better performance
- Implement optimistic UI updates for session delete/archive operations
with proper rollback on failure
- Wire session prefetch hook into SessionSidebar with sync integration
* Add file content cache and sync optimizations
- Wrap FilesAPI with in-memory LRU cache for file content with dual
constraints (entry count and byte size)
- Optimize chat timeline scroll restoration using useLayoutEffect
- Preserve React references in message and part arrays to prevent
unnecessary re-renders when prepending history
- Add session prefetch TTL cache to prevent redundant fetches
- Integrate session prefetch cache clearing with eviction flow
* Improve session sidebar error handling and add diff prefetch filtering
Load active and archived sessions independently using Promise.allSettled
to prevent one failure from blocking the other. Add retry logic to session
API calls and skip large files during diff prefetch to improve performance.
* Replace sendMessage with optimisticSend wrapper
Introduces optimistic UI updates for normal chat messages to provide
instant feedback. Messages appear immediately in the UI while the API
call executes in the background, with automatic rollback on errors.
* perf: split stores, proper optimistic send, fix revert/directory bugs
- split session-ui-store into voice/input/selection/viewport stores
to reduce subscriber re-evaluation during streaming
- wire optimisticSend through useSync shadow Map infrastructure
matching OpenCode's pattern (no heuristic part detection)
- port OpenCode Identifier.ascending ID format for correct sorting
- pass messageID to promptAsync to prevent duplicate messages
- fix worktree directory not propagating to session actions
(dynamic dir() via opencodeClient.getDirectory)
- fix setCurrentSession accepting directoryHint for new sessions
- fix revert not hiding messages (session limit was 5, bumped to match loaded count)
- fix revert optimistic message removal from store
- fix load-more flicker (useLayoutEffect scroll compensation)
- add prefetch TTL cache, file content LRU cache
- add session prefetch for adjacent sessions
- add instant archive/delete (optimistic before SDK call)
- migrate legacy window.__zustand_session_store__ to session-ui-store
- add retry + independent error handling for archived sessions
- add AGENTS.md performance rules
* perf: startup optimization — dedup, caching, light git status, diff rendering gates
- defer diff prefetch to git tab open, reduce concurrency 4→2, skip >500 changed lines
- cap project git checks concurrency (2), directory status probe (3)
- dedup provider/agent loading, github auth, worktree list (in-flight + TTL caches)
- delay PR tracking 5s, cache 403 search failures per-repo
- coalesce settings PUT (200ms debounce), cache settings GET (2s TTL)
- cache canonical directory resolution (60s TTL)
- persist missing directory status to localStorage (10min TTL)
- light/heavy git status: polling skips numstat+line counting+rev-list
- large diff rendering gate (>500 lines → "render anyway" button)
- tokenization degradation for >500KB files in Pierre
- parallelize main.tsx pre-render awaits
- batch sidebar file tree expanded paths restoration (3 at a time)
- remove bare useConfigStore() subscription in AgentsPage
- sync worktree sandboxes to OpenCode SQLite DB
- fix RightSidebarTabs ternary → explicit tab matching
- defensive guards on sync state (session_status, permission, question, message)
* fix: add defensive guards on remaining sync state field accesses
guard session_status, permission, message, todo, part, config with ?? {}
in useDirectorySync selectors, session-cache, and bootstrap
* fix: add missing directory dep to useCallback in use-sync.ts
* fix: preserve diffStats when light-mode polling overwrites status
* perf: optimize startup git status polling and diff rendering
Preserves diff stats when lightweight polling updates repository status
Reduces startup overhead with smarter git polling and store updates
Adds detailed optimization and migration docs for next performance steps
* fix: keep chat diff stats stable during git status updates
Prevents lightweight git polling from dropping diff statistics
Keeps MessageList diff indicators consistent while status refreshes
Improves reliability of git-aware chat rendering
* fix: user animation replay, queued message variant, startup provider loading
- consume animation ID after first play to prevent re-animation
on neighbor assistant message completion
- capture send config (model/agent/variant) at queue time matching
OpenCode's FollowupDraft pattern instead of re-resolving at send time
- replace one-shot startup recovery effect with polling interval
that retries every 2s until providers and agents load
- fix optimistic bridge to avoid re-render loop (stable ref wrappers)
* chore: update tauri to 2.10.3 and all plugins to latest
- tauri 2.9.4 → 2.10.3
- tauri-build 2.5.3 → 2.5.6
- tauri-plugin-dialog 2.4.2 → 2.6.0
- tauri-plugin-log 2.7.1 → 2.8.0
- tauri-plugin-shell 2.3.3 → 2.3.5
- tauri-plugin-updater 2 (floating) → 2.10.0 (pinned)
- @tauri-apps/api ^2.9.0 → ^2.10.1
- wry 0.53.5 → 0.54.4 (transitive)
* refactor: decouple web server index orchestration runtimes
* fix: align VS Code runtime behavior with web and reduce draft view CPU load
- Queue VS Code bridge and SSE startup requests until API readiness to avoid false bootstrap failures
- Make agent manager actions directory-aware and remove real worktrees with safer partial-failure handling
- Replace heavy logo animation path with a lightweight pulse to cut draft-session CPU usage
* fix: restore auto-selected file sending in chat input
- Send server-selected files as proper file URLs in the message payload
- Include server-backed attachments in submit flow instead of dropping them
- Restore queued-message attachments through the refactored input store
* fix: restore session model selection consistently on session switch
- Restore agent, model, and variant from the latest loaded user message for each session
- Wait for session messages before applying restored selections to avoid stale or missing state
- Remove legacy session-choice inference paths that caused overlap and instability
* fix: restore permission replies and auto-accept across sessions
- Scope permission and question replies to the target session directory so answers take effect reliably
- Make permission auto-accept immediately handle pending requests and react to new permission prompts
- Keep parent-session handling working for child-session requests through the shared response path
* feat: add reusable fuzzy branch fuzzy-search helper and dialog integration (#798)
* feat: add reusable fuzzy branch search for worktrees
* chore: drop planning docs from feature branch
* feat: make worktree branch refresh manual
* feat: add configurable session retention action
* refactor: centralize global session state in ui store
* fix: cancel debounced permission push after reply
* docs: clarify global and directory session store architecture
* docs: refine agent development rules and session activity guidance
- Clarify agent code of conduct and durable development patterns
- Add explicit shared-store rerender and live-state guidance
- Narrow session activity fallback to avoid stale working state
* chore: updated .gitignore
---------
Co-authored-by: Iuliia Ivashko <yulia.ivashko@gmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Iuliia Ivashko
parent
8dfe833faf
commit
c9e31a0e6c
@@ -28,6 +28,7 @@ local-dev*
|
|||||||
*.sw?
|
*.sw?
|
||||||
.opencode/plans/*
|
.opencode/plans/*
|
||||||
.hive
|
.hive
|
||||||
|
docs/personal/*
|
||||||
|
|
||||||
# Build outputs
|
# Build outputs
|
||||||
build/
|
build/
|
||||||
|
|||||||
@@ -107,18 +107,56 @@ All scripts are in `package.json`.
|
|||||||
- Do not run git/GitHub commands unless explicitly asked.
|
- Do not run git/GitHub commands unless explicitly asked.
|
||||||
- Keep baseline green (run `bun run type-check`, `bun run lint`, `bun run build` before finalizing changes).
|
- Keep baseline green (run `bun run type-check`, `bun run lint`, `bun run build` before finalizing changes).
|
||||||
|
|
||||||
|
## Agent code of conduct
|
||||||
|
- Prefer the smallest correct change.
|
||||||
|
- Preserve working behavior before improving structure.
|
||||||
|
- Do not add cleverness where a direct implementation is enough.
|
||||||
|
- Do not infer critical state from weak signals when a stronger source exists.
|
||||||
|
- Do not encode policy only in UI; enforce it in core logic.
|
||||||
|
- Do not hide data loss, partial failure, or fallback behavior. Make it explicit in code.
|
||||||
|
- Finish work end-to-end: implementation, verification, and cleanup.
|
||||||
|
|
||||||
## Development rules
|
## Development rules
|
||||||
- Keep diffs tight; avoid drive-by refactors.
|
- Keep diffs tight; avoid drive-by refactors.
|
||||||
- Backend changes: keep web/desktop/vscode runtimes consistent (if relevant).
|
- Follow local precedent; inspect nearby code before introducing new patterns.
|
||||||
- Follow local precedent; search nearby code first.
|
- Backend changes: keep web, desktop, and VS Code behavior consistent when they share contracts.
|
||||||
- TypeScript: avoid `any`/blind casts; keep ESLint/TS green.
|
- TypeScript: avoid `any`, blind casts, and shape guessing.
|
||||||
- React: prefer function components + hooks; class only when needed (e.g. error boundaries).
|
- React: prefer function components + hooks; use classes only when required.
|
||||||
- Control flow: avoid nested ternaries; prefer early returns + `if/else`/`switch`.
|
- Control flow: prefer early returns and explicit branching over nested ternaries.
|
||||||
- Styling: Tailwind v4; typography via `packages/ui/src/lib/typography.ts`; theme vars via `packages/ui/src/lib/theme/`.
|
- Styling: Tailwind v4, typography via `packages/ui/src/lib/typography.ts`, theme vars via `packages/ui/src/lib/theme/`.
|
||||||
- Shared UI patterns: for "series of items + divider + series of items" layouts, use shared UI primitives instead of duplicating ad-hoc markup in feature components.
|
- Shared UI patterns: reuse shared primitives before introducing feature-local markup patterns.
|
||||||
- Toasts: use custom toast wrapper from `@/components/ui` (backed by `packages/ui/src/components/ui/toast.ts`); do not import `sonner` directly in feature code.
|
- Toasts: use the wrapper from `@/components/ui`; do not import `sonner` directly in feature code.
|
||||||
- No new deps unless asked.
|
- No new deps unless asked.
|
||||||
- Never add secrets (`.env`, keys) or log sensitive data.
|
- Never add secrets or log sensitive data.
|
||||||
|
|
||||||
|
## Architecture patterns
|
||||||
|
|
||||||
|
### Thin entrypoints, focused modules
|
||||||
|
- Keep orchestration entrypoints thin: `index.js`, bridge files, bootstrap files, provider roots.
|
||||||
|
- Move route, domain, and runtime logic into focused modules with clear ownership.
|
||||||
|
- Prefer dependency injection over hidden module coupling.
|
||||||
|
- Add or update module documentation when ownership changes.
|
||||||
|
|
||||||
|
### Strong source of truth
|
||||||
|
- Prefer deterministic state over heuristics.
|
||||||
|
- Use live server/session state for live activity. Do not let historical anomalies masquerade as current execution.
|
||||||
|
- If a fallback is necessary, scope it narrowly to the active entity and treat it as temporary.
|
||||||
|
- Restore derived UI state from authoritative records. Example: restore model or agent from the latest user message, not assistant-side guesses.
|
||||||
|
|
||||||
|
### Live state vs historical state
|
||||||
|
- Derive live UI behavior from live state channels, not persisted history.
|
||||||
|
- Use historical records to restore context, not to infer that work is still in progress.
|
||||||
|
- If live state is delayed, use the narrowest possible transient fallback and clear it as soon as authoritative state arrives.
|
||||||
|
|
||||||
|
### Cross-runtime parity
|
||||||
|
- If web defines a route or payload contract that shared UI depends on, keep VS Code and desktop parity where applicable.
|
||||||
|
- Shared behavior differences must be intentional and visible in code.
|
||||||
|
- Do not ship a web-only assumption into shared UI.
|
||||||
|
|
||||||
|
### Partial-failure-safe flows
|
||||||
|
- Cross-directory and multi-entity operations must tolerate partial failure.
|
||||||
|
- Prefer per-item results, rollback paths, or resumable cleanup over all-or-nothing assumptions.
|
||||||
|
- Never leave optimistic state or local caches stranded after failure.
|
||||||
|
|
||||||
## CLI Parity and Safety Policy (MANDATORY)
|
## CLI Parity and Safety Policy (MANDATORY)
|
||||||
|
|
||||||
@@ -175,6 +213,102 @@ skill({ name: "theme-system" })
|
|||||||
|
|
||||||
This skill contains all color tokens, semantic logic, decision tree, and usage patterns. All UI colors must use theme tokens - never hardcoded values or Tailwind color classes.
|
This skill contains all color tokens, semantic logic, decision tree, and usage patterns. All UI colors must use theme tokens - never hardcoded values or Tailwind color classes.
|
||||||
|
|
||||||
|
## Performance rules (MANDATORY)
|
||||||
|
|
||||||
|
These rules exist because violating them has caused measurable regressions (render cascades, memory bloat, UI jank). They apply to all UI and sync layer work.
|
||||||
|
|
||||||
|
### Shared-store render discipline
|
||||||
|
|
||||||
|
- **Treat common stores as render fanout boundaries.** An unnecessary reference change in shared state can re-render large parts of the app.
|
||||||
|
- **Do not put high-frequency state in broadly consumed stores.** Fast-changing state should live in narrow stores with narrow subscribers.
|
||||||
|
- **Update only the fields that changed.** Preserve references for untouched state branches.
|
||||||
|
- **Prefer leaf selectors over container selectors.** Subscribe to the smallest stable value that satisfies the component.
|
||||||
|
- **Isolate hot consumers.** If a value changes often and only a few components need it, move it to a narrower store or consume it in a memoized child.
|
||||||
|
|
||||||
|
### Zustand referential equality
|
||||||
|
|
||||||
|
Zustand skips re-renders when a selector returns the same reference (`Object.is`). Every new object/array reference triggers a re-render in every subscriber.
|
||||||
|
|
||||||
|
- **Never spread all state fields in an update.** Only create new references for fields that actually changed. A `message.part.delta` event should not clone `session`, `permission`, etc.
|
||||||
|
- **Select leaf values, not containers.** `useStore((s) => s.permission[sessionID])` is correct. `useStore((s) => s.permission)` subscribes to every permission change across all sessions.
|
||||||
|
- **Preserve references when merging.** If prepending older messages, keep existing message object references. Only add truly new items. Return the original array if nothing was added.
|
||||||
|
|
||||||
|
### Store splitting
|
||||||
|
|
||||||
|
A single store with N properties means every subscriber re-evaluates on every state change. Split stores by change frequency and subscriber set.
|
||||||
|
|
||||||
|
- **Group state by how often it changes.** Streaming state (updated 60/sec) must not live with user preferences (updated on click).
|
||||||
|
- **Group state by who reads it.** If only 2 components need a value, it belongs in a store that only those 2 subscribe to.
|
||||||
|
- **Cross-store reads use `.getState()`.** Actions in one store that need another store call `useOtherStore.getState()` — imperative, no subscription.
|
||||||
|
- **Never add unrelated state to an existing store** just because it's convenient. Create a new store.
|
||||||
|
|
||||||
|
### Event pipeline and SSE
|
||||||
|
|
||||||
|
- **Gate expensive operations on the hot path.** During streaming, `message.part.delta` and `message.part.updated` fire ~60/sec. Any `findIndex`, `filter`, or iteration added to these handlers multiplies across every event. Gate behind a cheap boolean check first (e.g., check `next[0]` before scanning the array).
|
||||||
|
- **Skip no-op updates.** If an incoming event doesn't change the state (same role, same finish, same timestamps), return `false` from the reducer to avoid creating new references.
|
||||||
|
- **Coalesce by key.** Same-entity events (e.g., repeated `session.status` for the same session) should replace earlier ones in the queue, not accumulate.
|
||||||
|
- **Preserve event ordering semantics.** Reducers and queues must not let stale deltas or out-of-order events corrupt the latest state.
|
||||||
|
- **Do not widen live-activity fallbacks.** A fallback for delayed status should inspect only the current trailing entity, not arbitrary historical records.
|
||||||
|
|
||||||
|
### Polling payload fidelity
|
||||||
|
|
||||||
|
- **Do not let lightweight polling erase rich fields.** If light mode omits fields (e.g., `diffStats`), preserve previous rich data until a heavy follow-up fetch lands.
|
||||||
|
- **Use two-phase polling.** Run cheap change detection first; only run heavy status fetches for directories that actually changed.
|
||||||
|
|
||||||
|
### Optimistic updates
|
||||||
|
|
||||||
|
- **Use the shadow Map pattern.** Insert optimistic data into the store for instant UI, AND register it in a separate tracking Map. Cleanup happens deterministically via `mergeOptimisticPage` on the next data fetch — not via heuristics in the event reducer.
|
||||||
|
- **Pass client-generated IDs to the server.** Use the same ID format as the server (hex-encoded timestamps). Pass `messageID` to `promptAsync` so the server echoes back the same ID. This prevents duplicates and enables in-place replacement.
|
||||||
|
- **Rollback on error.** Remove the optimistic entry from both the store and the shadow Map.
|
||||||
|
- **Stabilize bridge callbacks.** When wiring hook callbacks into module-level refs, use stable ref wrappers so effects do not loop on changing function identities.
|
||||||
|
|
||||||
|
### Session/input consistency
|
||||||
|
|
||||||
|
- **Capture send config at queue time.** Queue items must include provider/model/agent/variant snapshot; do not re-resolve from mutable live state at send time.
|
||||||
|
- **Keep server-selected attachments sendable.** Preserve server-backed file selections in queue/submit flows and convert them to proper `file://` URLs before sending.
|
||||||
|
|
||||||
|
### Bootstrap resilience
|
||||||
|
|
||||||
|
- **Treat startup 502/503 as transient.** Retry bootstrap/session-list flows with bounded retries/intervals, especially in VS Code where API readiness can lag bridge startup.
|
||||||
|
- **Use polling recovery when failures are swallowed.** If an async loader resolves without throwing on failure, recover with interval retries gated by loaded-state checks.
|
||||||
|
|
||||||
|
### Scroll and DOM
|
||||||
|
|
||||||
|
- **Never use `await waitForFrames()` for scroll preservation.** Frames of visible scroll jump are unacceptable. Use `useLayoutEffect` to adjust scroll synchronously after React commits DOM — before the browser paints.
|
||||||
|
- **Capture scroll state before the state change, restore in layout effect.** The pattern: save `scrollHeight`/`scrollTop` into a ref before triggering the update, consume it in `useLayoutEffect` on the rendered output.
|
||||||
|
|
||||||
|
### Component isolation
|
||||||
|
|
||||||
|
- **Extract high-frequency hook consumers into separate components.** If a hook re-evaluates 60/sec (e.g., streaming status), wrap its consumer in a `React.memo` child component so the parent doesn't re-render.
|
||||||
|
- **Use custom `React.memo` comparators for message rows.** Compare render-relevant fields (role, finish, parts count, part IDs) — not object references.
|
||||||
|
|
||||||
|
### Caching and memory
|
||||||
|
|
||||||
|
- **Cap in-memory caches with both count and byte limits.** Entry count alone doesn't prevent memory bloat from large files. Use dual-constraint LRU (e.g., 40 entries OR 20MB).
|
||||||
|
- **Set store session limits to match loaded data.** If bootstrap loads N sessions, set `limit >= N`. Otherwise the next SSE event triggers trimming that silently removes sessions.
|
||||||
|
- **Invalidate caches on mutations.** File content cache must clear entries on write, delete, rename. Prefetch cache must clear on session eviction.
|
||||||
|
- **Use TTLs to prevent redundant fetches.** If a session was fetched <15s ago, skip re-fetching — SSE events keep it current.
|
||||||
|
|
||||||
|
### Directory context
|
||||||
|
|
||||||
|
- **Never cache directory strings in closures.** Directory can change at any time (worktree switch). Read it dynamically from `opencodeClient.getDirectory()` at call time.
|
||||||
|
- **Pass directory hints when the source of truth isn't available yet.** Newly created sessions aren't in the sync store until SSE delivers them. Pass the known directory as a parameter instead of relying on lookup.
|
||||||
|
|
||||||
|
## Regression-prevention checklist
|
||||||
|
- When adding fallback logic, ask: can stale persisted data keep this path active forever?
|
||||||
|
- When deriving UI state, ask: is this live state, historical state, or inferred state?
|
||||||
|
- When adding store fields, ask: who reads this, how often does it change, and should it live elsewhere?
|
||||||
|
- When touching polling or bootstrap, ask: can a lighter payload erase richer existing data?
|
||||||
|
- When handling optimistic updates, ask: where is rollback, reconciliation, and duplicate prevention?
|
||||||
|
- When changing shared routes or state contracts, ask: what breaks in web, desktop, and VS Code?
|
||||||
|
- When fixing a bug with a heuristic, prefer narrowing the heuristic over widening it.
|
||||||
|
|
||||||
|
## Validation expectations
|
||||||
|
- Run `bun run type-check`, `bun run lint`, and `bun run build` before finalizing.
|
||||||
|
- For hot-path changes, verify behavior under streaming or repeated events, not just static render.
|
||||||
|
- For sync or startup changes, verify fresh load, retry/failure, and restart behavior.
|
||||||
|
- For session changes, verify create, stream, abort, permission, archive/delete, and revisit flows when relevant.
|
||||||
|
|
||||||
## Recent changes
|
## Recent changes
|
||||||
- Releases + high-level changes: `CHANGELOG.md`
|
- Releases + high-level changes: `CHANGELOG.md`
|
||||||
- Recent commits: `git log --oneline` (latest tags: `v1.4.6`, `v1.4.5`)
|
- Recent commits: `git log --oneline` (latest tags: `v1.4.6`, `v1.4.5`)
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
"@ibm/plex": "^6.4.1",
|
"@ibm/plex": "^6.4.1",
|
||||||
"@lezer/highlight": "^1.2.3",
|
"@lezer/highlight": "^1.2.3",
|
||||||
"@octokit/rest": "^22.0.1",
|
"@octokit/rest": "^22.0.1",
|
||||||
"@opencode-ai/sdk": "^1.3.0",
|
"@opencode-ai/sdk": "^1.3.7",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
@@ -133,7 +133,7 @@
|
|||||||
"@fontsource/ibm-plex-sans": "^5.1.1",
|
"@fontsource/ibm-plex-sans": "^5.1.1",
|
||||||
"@ibm/plex": "^6.4.1",
|
"@ibm/plex": "^6.4.1",
|
||||||
"@lezer/highlight": "^1.2.3",
|
"@lezer/highlight": "^1.2.3",
|
||||||
"@opencode-ai/sdk": "^1.3.0",
|
"@opencode-ai/sdk": "^1.3.7",
|
||||||
"@pierre/diffs": "1.1.0-beta.13",
|
"@pierre/diffs": "1.1.0-beta.13",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
@@ -179,7 +179,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.33.0",
|
"@eslint/js": "^9.33.0",
|
||||||
"@tailwindcss/postcss": "^4.0.0",
|
"@tailwindcss/postcss": "^4.0.0",
|
||||||
"@tauri-apps/api": "^2.9.0",
|
"@tauri-apps/api": "^2.10.1",
|
||||||
"@types/node": "^24.3.1",
|
"@types/node": "^24.3.1",
|
||||||
"@types/prismjs": "^1.26.6",
|
"@types/prismjs": "^1.26.6",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
@@ -208,7 +208,7 @@
|
|||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@openchamber/ui": "workspace:*",
|
"@openchamber/ui": "workspace:*",
|
||||||
"@opencode-ai/sdk": "^1.3.0",
|
"@opencode-ai/sdk": "^1.3.7",
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
"jsonc-parser": "^3.3.1",
|
"jsonc-parser": "^3.3.1",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
@@ -240,7 +240,7 @@
|
|||||||
"@fontsource/ibm-plex-sans": "^5.1.1",
|
"@fontsource/ibm-plex-sans": "^5.1.1",
|
||||||
"@ibm/plex": "^6.4.1",
|
"@ibm/plex": "^6.4.1",
|
||||||
"@octokit/rest": "^22.0.1",
|
"@octokit/rest": "^22.0.1",
|
||||||
"@opencode-ai/sdk": "^1.3.0",
|
"@opencode-ai/sdk": "^1.3.7",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
@@ -817,7 +817,7 @@
|
|||||||
|
|
||||||
"@openchamber/web": ["@openchamber/web@workspace:packages/web"],
|
"@openchamber/web": ["@openchamber/web@workspace:packages/web"],
|
||||||
|
|
||||||
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.3.0", "", {}, "sha512-5WyYEpcV6Zk9otXOMIrvZRbJm1yxt/c8EXSBn1p6Sw1yagz8HRljkoUTJFxzD0x2+/6vAZItr3OrXDZfE+oA2g=="],
|
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.3.7", "", {}, "sha512-ugkta0v0dMZchN15QGmqHb9zf35k+K1VM9wt3x4ZRJ6GxKAs0XlCmQPQJflgV9YSedNxjkgTud0GCCIWUSiUOg=="],
|
||||||
|
|
||||||
"@pierre/diffs": ["@pierre/diffs@1.1.0-beta.13", "", { "dependencies": { "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-D35rxDu5V7XHX5aVGU6PF12GhscL+I+9QYgxK/i3h0d2XSirAxDdVNm49aYwlOhgmdvL0NbS1IHxPswVB5yJvw=="],
|
"@pierre/diffs": ["@pierre/diffs@1.1.0-beta.13", "", { "dependencies": { "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-D35rxDu5V7XHX5aVGU6PF12GhscL+I+9QYgxK/i3h0d2XSirAxDdVNm49aYwlOhgmdvL0NbS1IHxPswVB5yJvw=="],
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -89,7 +89,7 @@
|
|||||||
"@ibm/plex": "^6.4.1",
|
"@ibm/plex": "^6.4.1",
|
||||||
"@lezer/highlight": "^1.2.3",
|
"@lezer/highlight": "^1.2.3",
|
||||||
"@octokit/rest": "^22.0.1",
|
"@octokit/rest": "^22.0.1",
|
||||||
"@opencode-ai/sdk": "^1.3.0",
|
"@opencode-ai/sdk": "^1.3.7",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
|||||||
Generated
+1137
-848
File diff suppressed because it is too large
Load Diff
@@ -19,17 +19,17 @@ log = "0.4.28"
|
|||||||
reqwest = { version = "0.12.4", default-features = false, features = ["rustls-tls", "blocking"] }
|
reqwest = { version = "0.12.4", default-features = false, features = ["rustls-tls", "blocking"] }
|
||||||
serde = { version = "1.0.210", features = ["derive"] }
|
serde = { version = "1.0.210", features = ["derive"] }
|
||||||
serde_json = "1.0.143"
|
serde_json = "1.0.143"
|
||||||
tauri = { version = "2.9.4", features = ["macos-private-api"] }
|
tauri = { version = "2.10.3", features = ["macos-private-api"] }
|
||||||
tauri-plugin-dialog = "2.4.2"
|
tauri-plugin-dialog = "2.6.0"
|
||||||
tauri-plugin-log = "2.7.1"
|
tauri-plugin-log = "2.8.0"
|
||||||
tauri-plugin-shell = "2.3.3"
|
tauri-plugin-shell = "2.3.5"
|
||||||
tauri-plugin-notification = "2.3.3"
|
tauri-plugin-notification = "2.3.3"
|
||||||
tauri-plugin-updater = "2"
|
tauri-plugin-updater = "2.10.0"
|
||||||
tokio = { version = "1.38", features = ["rt-multi-thread", "time"] }
|
tokio = { version = "1.38", features = ["rt-multi-thread", "time"] }
|
||||||
url = "2.5"
|
url = "2.5"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2.5.3", features = [] }
|
tauri-build = { version = "2.5.6", features = [] }
|
||||||
|
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
window-vibrancy = "0.7.1"
|
window-vibrancy = "0.7.1"
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ use std::{
|
|||||||
},
|
},
|
||||||
time::Duration,
|
time::Duration,
|
||||||
};
|
};
|
||||||
use tauri::utils::config::BackgroundThrottlingPolicy;
|
|
||||||
use tauri::{Emitter, Manager, WebviewUrl, WebviewWindowBuilder};
|
use tauri::{Emitter, Manager, WebviewUrl, WebviewWindowBuilder};
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
use window_vibrancy::{
|
use window_vibrancy::{
|
||||||
@@ -277,7 +276,7 @@ fn build_macos_menu<R: tauri::Runtime>(
|
|||||||
MENU_ITEM_TOGGLE_MEMORY_DEBUG_ID,
|
MENU_ITEM_TOGGLE_MEMORY_DEBUG_ID,
|
||||||
"Toggle Memory Debug",
|
"Toggle Memory Debug",
|
||||||
true,
|
true,
|
||||||
Some("Cmd+Shift+D"),
|
Some("CmdOrCtrl+Shift+D"),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let help_dialog = MenuItem::with_id(
|
let help_dialog = MenuItem::with_id(
|
||||||
@@ -2630,8 +2629,7 @@ fn create_window(
|
|||||||
.min_inner_size(MIN_WINDOW_WIDTH as f64, MIN_WINDOW_HEIGHT as f64)
|
.min_inner_size(MIN_WINDOW_WIDTH as f64, MIN_WINDOW_HEIGHT as f64)
|
||||||
.decorations(true)
|
.decorations(true)
|
||||||
.visible(false)
|
.visible(false)
|
||||||
.initialization_script(&init_script)
|
.initialization_script(&init_script);
|
||||||
.background_throttling(BackgroundThrottlingPolicy::Disabled);
|
|
||||||
|
|
||||||
let apply_restored_state = restored_state
|
let apply_restored_state = restored_state
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -2693,8 +2691,7 @@ fn create_startup_window(app: &tauri::AppHandle, restore_geometry: bool) -> Resu
|
|||||||
.min_inner_size(MIN_WINDOW_WIDTH as f64, MIN_WINDOW_HEIGHT as f64)
|
.min_inner_size(MIN_WINDOW_WIDTH as f64, MIN_WINDOW_HEIGHT as f64)
|
||||||
.decorations(true)
|
.decorations(true)
|
||||||
.visible(true)
|
.visible(true)
|
||||||
.initialization_script(&splash_script)
|
.initialization_script(&splash_script);
|
||||||
.background_throttling(BackgroundThrottlingPolicy::Disabled);
|
|
||||||
|
|
||||||
let apply_restored_state = restored_state
|
let apply_restored_state = restored_state
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
|||||||
@@ -28,8 +28,7 @@
|
|||||||
"y": 26
|
"y": 26
|
||||||
},
|
},
|
||||||
"dragDropEnabled": false,
|
"dragDropEnabled": false,
|
||||||
"visible": false,
|
"visible": false
|
||||||
"backgroundThrottling": "disabled"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
"@fontsource/ibm-plex-sans": "^5.1.1",
|
"@fontsource/ibm-plex-sans": "^5.1.1",
|
||||||
"@ibm/plex": "^6.4.1",
|
"@ibm/plex": "^6.4.1",
|
||||||
"@lezer/highlight": "^1.2.3",
|
"@lezer/highlight": "^1.2.3",
|
||||||
"@opencode-ai/sdk": "^1.3.0",
|
"@opencode-ai/sdk": "^1.3.7",
|
||||||
"@pierre/diffs": "1.1.0-beta.13",
|
"@pierre/diffs": "1.1.0-beta.13",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
@@ -85,7 +85,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.33.0",
|
"@eslint/js": "^9.33.0",
|
||||||
"@tailwindcss/postcss": "^4.0.0",
|
"@tailwindcss/postcss": "^4.0.0",
|
||||||
"@tauri-apps/api": "^2.9.0",
|
"@tauri-apps/api": "^2.10.1",
|
||||||
"@types/node": "^24.3.1",
|
"@types/node": "^24.3.1",
|
||||||
"@types/prismjs": "^1.26.6",
|
"@types/prismjs": "^1.26.6",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
|
|||||||
+176
-139
@@ -6,12 +6,12 @@ import { ChatView } from '@/components/views';
|
|||||||
import { FireworksProvider } from '@/contexts/FireworksContext';
|
import { FireworksProvider } from '@/contexts/FireworksContext';
|
||||||
import { Toaster } from '@/components/ui/sonner';
|
import { Toaster } from '@/components/ui/sonner';
|
||||||
import { MemoryDebugPanel } from '@/components/ui/MemoryDebugPanel';
|
import { MemoryDebugPanel } from '@/components/ui/MemoryDebugPanel';
|
||||||
|
import { setStreamPerfEnabled } from '@/stores/utils/streamDebug';
|
||||||
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
|
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
|
||||||
import { useEventStream } from '@/hooks/useEventStream';
|
// useEventStream removed — replaced by SyncProvider + SyncBridge
|
||||||
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
|
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
|
||||||
import { useMenuActions } from '@/hooks/useMenuActions';
|
import { useMenuActions } from '@/hooks/useMenuActions';
|
||||||
import { useSessionStatusBootstrap } from '@/hooks/useSessionStatusBootstrap';
|
import { useSessionStatusBootstrap } from '@/hooks/useSessionStatusBootstrap';
|
||||||
import { useServerSessionStatus } from '@/hooks/useServerSessionStatus';
|
|
||||||
import { useSessionAutoCleanup } from '@/hooks/useSessionAutoCleanup';
|
import { useSessionAutoCleanup } from '@/hooks/useSessionAutoCleanup';
|
||||||
import { useQueuedMessageAutoSend } from '@/hooks/useQueuedMessageAutoSend';
|
import { useQueuedMessageAutoSend } from '@/hooks/useQueuedMessageAutoSend';
|
||||||
import { useRouter } from '@/hooks/useRouter';
|
import { useRouter } from '@/hooks/useRouter';
|
||||||
@@ -25,9 +25,12 @@ import { useConfigStore } from '@/stores/useConfigStore';
|
|||||||
import { hasModifier } from '@/lib/utils';
|
import { hasModifier } from '@/lib/utils';
|
||||||
import { isDesktopLocalOriginActive, isDesktopShell } from '@/lib/desktop';
|
import { isDesktopLocalOriginActive, isDesktopShell } from '@/lib/desktop';
|
||||||
import { OnboardingScreen } from '@/components/onboarding/OnboardingScreen';
|
import { OnboardingScreen } from '@/components/onboarding/OnboardingScreen';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
import { opencodeClient } from '@/lib/opencode/client';
|
import { opencodeClient } from '@/lib/opencode/client';
|
||||||
|
import { SyncProvider, useSessions } from '@/sync/sync-context';
|
||||||
|
import { useSync } from '@/sync/use-sync';
|
||||||
|
import { setOptimisticRefs } from '@/sync/session-actions';
|
||||||
import { useFontPreferences } from '@/hooks/useFontPreferences';
|
import { useFontPreferences } from '@/hooks/useFontPreferences';
|
||||||
import { CODE_FONT_OPTION_MAP, DEFAULT_MONO_FONT, DEFAULT_UI_FONT, UI_FONT_OPTION_MAP } from '@/lib/fontOptions';
|
import { CODE_FONT_OPTION_MAP, DEFAULT_MONO_FONT, DEFAULT_UI_FONT, UI_FONT_OPTION_MAP } from '@/lib/fontOptions';
|
||||||
import { ConfigUpdateOverlay } from '@/components/ui/ConfigUpdateOverlay';
|
import { ConfigUpdateOverlay } from '@/components/ui/ConfigUpdateOverlay';
|
||||||
@@ -45,7 +48,8 @@ const CLI_MISSING_ERROR_REGEX =
|
|||||||
const CLI_ONBOARDING_HEALTH_POLL_MS = 1500;
|
const CLI_ONBOARDING_HEALTH_POLL_MS = 1500;
|
||||||
|
|
||||||
const AboutDialogWrapper: React.FC = () => {
|
const AboutDialogWrapper: React.FC = () => {
|
||||||
const { isAboutDialogOpen, setAboutDialogOpen } = useUIStore();
|
const isAboutDialogOpen = useUIStore((s) => s.isAboutDialogOpen);
|
||||||
|
const setAboutDialogOpen = useUIStore((s) => s.setAboutDialogOpen);
|
||||||
return (
|
return (
|
||||||
<AboutDialog
|
<AboutDialog
|
||||||
open={isAboutDialogOpen}
|
open={isAboutDialogOpen}
|
||||||
@@ -94,16 +98,74 @@ const readEmbeddedSessionChatConfig = (): EmbeddedSessionChatConfig | null => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const EmbeddedSessionSelectionGate: React.FC<{
|
||||||
|
embeddedSessionChat: EmbeddedSessionChatConfig | null;
|
||||||
|
isVSCodeRuntime: boolean;
|
||||||
|
}> = ({ embeddedSessionChat, isVSCodeRuntime }) => {
|
||||||
|
const sessions = useSessions();
|
||||||
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||||
|
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!embeddedSessionChat || isVSCodeRuntime) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentSessionId === embeddedSessionChat.sessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sessions.some((session) => session.id === embeddedSessionChat.sessionId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void setCurrentSession(embeddedSessionChat.sessionId);
|
||||||
|
}, [currentSessionId, embeddedSessionChat, isVSCodeRuntime, sessions, setCurrentSession]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SyncOptimisticBridge: React.FC = () => {
|
||||||
|
const sync = useSync();
|
||||||
|
const addRef = React.useRef(sync.optimistic.add);
|
||||||
|
const removeRef = React.useRef(sync.optimistic.remove);
|
||||||
|
addRef.current = sync.optimistic.add;
|
||||||
|
removeRef.current = sync.optimistic.remove;
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setOptimisticRefs(
|
||||||
|
(input) => addRef.current(input),
|
||||||
|
(input) => removeRef.current(input),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function SyncAppEffects({ apis, embeddedBackgroundWorkEnabled }: {
|
||||||
|
apis: RuntimeAPIs;
|
||||||
|
embeddedBackgroundWorkEnabled: boolean;
|
||||||
|
}) {
|
||||||
|
const githubApi = embeddedBackgroundWorkEnabled ? apis.github : undefined;
|
||||||
|
useGitHubPrBackgroundTracking(githubApi, apis.git);
|
||||||
|
usePwaManifestSync();
|
||||||
|
useSessionAutoCleanup(embeddedBackgroundWorkEnabled);
|
||||||
|
useQueuedMessageAutoSend(embeddedBackgroundWorkEnabled);
|
||||||
|
useKeyboardShortcuts();
|
||||||
|
|
||||||
|
return <SyncOptimisticBridge />;
|
||||||
|
}
|
||||||
|
|
||||||
function App({ apis }: AppProps) {
|
function App({ apis }: AppProps) {
|
||||||
const { initializeApp, isInitialized, isConnected } = useConfigStore();
|
const initializeApp = useConfigStore((s) => s.initializeApp);
|
||||||
|
const isInitialized = useConfigStore((s) => s.isInitialized);
|
||||||
|
const isConnected = useConfigStore((s) => s.isConnected);
|
||||||
const providersCount = useConfigStore((state) => state.providers.length);
|
const providersCount = useConfigStore((state) => state.providers.length);
|
||||||
const agentsCount = useConfigStore((state) => state.agents.length);
|
const agentsCount = useConfigStore((state) => state.agents.length);
|
||||||
const loadProviders = useConfigStore((state) => state.loadProviders);
|
const loadProviders = useConfigStore((state) => state.loadProviders);
|
||||||
const loadAgents = useConfigStore((state) => state.loadAgents);
|
const loadAgents = useConfigStore((state) => state.loadAgents);
|
||||||
const { error, clearError, loadSessions } = useSessionStore();
|
const error = useSessionUIStore((s) => s.error);
|
||||||
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
const clearError = useSessionUIStore((s) => s.clearError);
|
||||||
const setCurrentSession = useSessionStore((state) => state.setCurrentSession);
|
|
||||||
const sessions = useSessionStore((state) => state.sessions);
|
|
||||||
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||||
const setDirectory = useDirectoryStore((state) => state.setDirectory);
|
const setDirectory = useDirectoryStore((state) => state.setDirectory);
|
||||||
const isSwitchingDirectory = useDirectoryStore((state) => state.isSwitchingDirectory);
|
const isSwitchingDirectory = useDirectoryStore((state) => state.isSwitchingDirectory);
|
||||||
@@ -118,6 +180,13 @@ function App({ apis }: AppProps) {
|
|||||||
const embeddedSessionChat = React.useMemo<EmbeddedSessionChatConfig | null>(() => readEmbeddedSessionChatConfig(), []);
|
const embeddedSessionChat = React.useMemo<EmbeddedSessionChatConfig | null>(() => readEmbeddedSessionChatConfig(), []);
|
||||||
const embeddedBackgroundWorkEnabled = !embeddedSessionChat || isEmbeddedVisible;
|
const embeddedBackgroundWorkEnabled = !embeddedSessionChat || isEmbeddedVisible;
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setStreamPerfEnabled(showMemoryDebug);
|
||||||
|
return () => {
|
||||||
|
setStreamPerfEnabled(false);
|
||||||
|
};
|
||||||
|
}, [showMemoryDebug]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setIsVSCodeRuntime(apis.runtime.isVSCode);
|
setIsVSCodeRuntime(apis.runtime.isVSCode);
|
||||||
}, [apis.runtime.isVSCode]);
|
}, [apis.runtime.isVSCode]);
|
||||||
@@ -135,8 +204,6 @@ function App({ apis }: AppProps) {
|
|||||||
void refreshGitHubAuthStatus(apis.github, { force: true });
|
void refreshGitHubAuthStatus(apis.github, { force: true });
|
||||||
}, [apis.github, embeddedSessionChat, refreshGitHubAuthStatus]);
|
}, [apis.github, embeddedSessionChat, refreshGitHubAuthStatus]);
|
||||||
|
|
||||||
useGitHubPrBackgroundTracking(embeddedBackgroundWorkEnabled ? apis.github : undefined, apis.git);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (typeof document === 'undefined') {
|
if (typeof document === 'undefined') {
|
||||||
return;
|
return;
|
||||||
@@ -202,70 +269,45 @@ function App({ apis }: AppProps) {
|
|||||||
init();
|
init();
|
||||||
}, [initializeApp, isVSCodeRuntime]);
|
}, [initializeApp, isVSCodeRuntime]);
|
||||||
|
|
||||||
const startupRecoveryInProgressRef = React.useRef(false);
|
// Startup recovery: poll until providers AND agents are loaded.
|
||||||
const startupRecoveryLastAttemptRef = React.useRef(0);
|
// loadProviders/loadAgents resolve normally even on failure (errors swallowed),
|
||||||
|
// so a reactive effect can't detect failure — we need an interval.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (isVSCodeRuntime) {
|
if (isVSCodeRuntime || !isConnected) return;
|
||||||
return;
|
if (providersCount > 0 && agentsCount > 0) return;
|
||||||
}
|
|
||||||
if (!isConnected) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (providersCount > 0 && agentsCount > 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (startupRecoveryInProgressRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = Date.now();
|
let active = true;
|
||||||
if (now - startupRecoveryLastAttemptRef.current < 750) {
|
const attempt = async () => {
|
||||||
return;
|
const state = useConfigStore.getState();
|
||||||
}
|
if (state.providers.length > 0 && state.agents.length > 0) return;
|
||||||
|
|
||||||
startupRecoveryLastAttemptRef.current = now;
|
|
||||||
startupRecoveryInProgressRef.current = true;
|
|
||||||
|
|
||||||
const repair = async () => {
|
|
||||||
try {
|
try {
|
||||||
if (providersCount === 0) {
|
if (state.providers.length === 0) await loadProviders();
|
||||||
await loadProviders();
|
if (useConfigStore.getState().agents.length === 0) await loadAgents();
|
||||||
}
|
} catch { /* retry next interval */ }
|
||||||
if (agentsCount === 0) {
|
|
||||||
await loadAgents();
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Keep UI responsive; we'll retry on next cycle.
|
|
||||||
} finally {
|
|
||||||
startupRecoveryInProgressRef.current = false;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
void repair();
|
void attempt();
|
||||||
}, [agentsCount, isConnected, isVSCodeRuntime, loadAgents, loadProviders, providersCount]);
|
const id = setInterval(() => { if (active) void attempt(); }, 2000);
|
||||||
|
return () => { active = false; clearInterval(id); };
|
||||||
|
}, [isConnected, isVSCodeRuntime, loadAgents, loadProviders, providersCount, agentsCount]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (isSwitchingDirectory) {
|
if (isSwitchingDirectory) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const syncDirectoryAndSessions = async () => {
|
// VS Code runtime loads sessions via VSCodeLayout bootstrap to avoid startup races.
|
||||||
// VS Code runtime loads sessions via VSCodeLayout bootstrap to avoid startup races.
|
if (isVSCodeRuntime) {
|
||||||
if (isVSCodeRuntime) {
|
return;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (!isConnected) {
|
if (!isConnected) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
opencodeClient.setDirectory(currentDirectory);
|
opencodeClient.setDirectory(currentDirectory);
|
||||||
|
|
||||||
await loadSessions();
|
// Session loading is handled by the sync system's bootstrap — no manual loadSessions needed.
|
||||||
};
|
}, [currentDirectory, isSwitchingDirectory, isConnected, isVSCodeRuntime]);
|
||||||
|
|
||||||
syncDirectoryAndSessions();
|
|
||||||
}, [currentDirectory, isSwitchingDirectory, loadSessions, isConnected, isVSCodeRuntime]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!embeddedSessionChat || typeof window === 'undefined') {
|
if (!embeddedSessionChat || typeof window === 'undefined') {
|
||||||
@@ -317,22 +359,6 @@ function App({ apis }: AppProps) {
|
|||||||
setDirectory(embeddedSessionChat.directory, { showOverlay: false });
|
setDirectory(embeddedSessionChat.directory, { showOverlay: false });
|
||||||
}, [currentDirectory, embeddedSessionChat, isVSCodeRuntime, setDirectory]);
|
}, [currentDirectory, embeddedSessionChat, isVSCodeRuntime, setDirectory]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!embeddedSessionChat || isVSCodeRuntime) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentSessionId === embeddedSessionChat.sessionId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!sessions.some((session) => session.id === embeddedSessionChat.sessionId)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
void setCurrentSession(embeddedSessionChat.sessionId);
|
|
||||||
}, [currentSessionId, embeddedSessionChat, isVSCodeRuntime, sessions, setCurrentSession]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!embeddedSessionChat || typeof window === 'undefined') {
|
if (!embeddedSessionChat || typeof window === 'undefined') {
|
||||||
return;
|
return;
|
||||||
@@ -365,22 +391,17 @@ function App({ apis }: AppProps) {
|
|||||||
window.dispatchEvent(new Event('openchamber:app-ready'));
|
window.dispatchEvent(new Event('openchamber:app-ready'));
|
||||||
}, [isInitialized, isSwitchingDirectory]);
|
}, [isInitialized, isSwitchingDirectory]);
|
||||||
|
|
||||||
useEventStream({ enabled: embeddedBackgroundWorkEnabled });
|
// useEventStream replaced by SyncProvider + SyncBridge
|
||||||
|
|
||||||
// Server-authoritative session status polling
|
// Session attention now handled by notification-store via SSE events (session.idle/session.error)
|
||||||
// Replaces SSE-dependent status updates with reliable HTTP polling
|
|
||||||
useServerSessionStatus({ enabled: embeddedBackgroundWorkEnabled });
|
|
||||||
|
|
||||||
usePushVisibilityBeacon({ enabled: embeddedBackgroundWorkEnabled });
|
usePushVisibilityBeacon({ enabled: embeddedBackgroundWorkEnabled });
|
||||||
usePwaManifestSync();
|
|
||||||
usePwaInstallPrompt();
|
usePwaInstallPrompt();
|
||||||
|
|
||||||
useWindowTitle();
|
useWindowTitle();
|
||||||
|
|
||||||
useRouter();
|
useRouter();
|
||||||
|
|
||||||
useKeyboardShortcuts();
|
|
||||||
|
|
||||||
const handleToggleMemoryDebug = React.useCallback(() => {
|
const handleToggleMemoryDebug = React.useCallback(() => {
|
||||||
setShowMemoryDebug(prev => !prev);
|
setShowMemoryDebug(prev => !prev);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -388,8 +409,6 @@ function App({ apis }: AppProps) {
|
|||||||
useMenuActions(handleToggleMemoryDebug);
|
useMenuActions(handleToggleMemoryDebug);
|
||||||
|
|
||||||
useSessionStatusBootstrap({ enabled: embeddedBackgroundWorkEnabled });
|
useSessionStatusBootstrap({ enabled: embeddedBackgroundWorkEnabled });
|
||||||
useSessionAutoCleanup({ enabled: embeddedBackgroundWorkEnabled });
|
|
||||||
useQueuedMessageAutoSend({ enabled: embeddedBackgroundWorkEnabled });
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (embeddedSessionChat) {
|
if (embeddedSessionChat) {
|
||||||
@@ -397,14 +416,19 @@ function App({ apis }: AppProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (hasModifier(e) && e.shiftKey && e.key === 'D') {
|
const isDebugShortcut = hasModifier(e)
|
||||||
|
&& e.shiftKey
|
||||||
|
&& !e.altKey
|
||||||
|
&& (e.code === 'KeyD' || e.key.toLowerCase() === 'd');
|
||||||
|
|
||||||
|
if (isDebugShortcut) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setShowMemoryDebug(prev => !prev);
|
setShowMemoryDebug(prev => !prev);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown, true);
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
return () => window.removeEventListener('keydown', handleKeyDown, true);
|
||||||
}, [embeddedSessionChat]);
|
}, [embeddedSessionChat]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -478,14 +502,18 @@ function App({ apis }: AppProps) {
|
|||||||
if (embeddedSessionChat) {
|
if (embeddedSessionChat) {
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<RuntimeAPIProvider apis={apis}>
|
<SyncProvider sdk={opencodeClient.getSdkClient()} directory={currentDirectory || ''}>
|
||||||
<TooltipProvider delayDuration={700} skipDelayDuration={150}>
|
<RuntimeAPIProvider apis={apis}>
|
||||||
<div className="h-full text-foreground bg-background">
|
<TooltipProvider delayDuration={700} skipDelayDuration={150}>
|
||||||
<ChatView />
|
<div className="h-full text-foreground bg-background">
|
||||||
<Toaster />
|
<EmbeddedSessionSelectionGate embeddedSessionChat={embeddedSessionChat} isVSCodeRuntime={isVSCodeRuntime} />
|
||||||
</div>
|
<SyncAppEffects apis={apis} embeddedBackgroundWorkEnabled={embeddedBackgroundWorkEnabled} />
|
||||||
</TooltipProvider>
|
<ChatView />
|
||||||
</RuntimeAPIProvider>
|
<Toaster />
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
</RuntimeAPIProvider>
|
||||||
|
</SyncProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -493,62 +521,71 @@ function App({ apis }: AppProps) {
|
|||||||
// VS Code runtime - simplified layout without git/terminal views
|
// VS Code runtime - simplified layout without git/terminal views
|
||||||
if (isVSCodeRuntime) {
|
if (isVSCodeRuntime) {
|
||||||
// Check if this is the Agent Manager panel
|
// Check if this is the Agent Manager panel
|
||||||
const panelType = typeof window !== 'undefined'
|
const panelType = typeof window !== 'undefined'
|
||||||
? (window as { __OPENCHAMBER_PANEL_TYPE__?: 'chat' | 'agentManager' }).__OPENCHAMBER_PANEL_TYPE__
|
? (window as { __OPENCHAMBER_PANEL_TYPE__?: 'chat' | 'agentManager' }).__OPENCHAMBER_PANEL_TYPE__
|
||||||
: 'chat';
|
: 'chat';
|
||||||
|
|
||||||
if (panelType === 'agentManager') {
|
if (panelType === 'agentManager') {
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<RuntimeAPIProvider apis={apis}>
|
<SyncProvider sdk={opencodeClient.getSdkClient()} directory={currentDirectory || ''}>
|
||||||
<TooltipProvider delayDuration={700} skipDelayDuration={150}>
|
<RuntimeAPIProvider apis={apis}>
|
||||||
<div className="h-full text-foreground bg-background">
|
|
||||||
<AgentManagerView />
|
|
||||||
<Toaster />
|
|
||||||
</div>
|
|
||||||
</TooltipProvider>
|
|
||||||
</RuntimeAPIProvider>
|
|
||||||
</ErrorBoundary>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ErrorBoundary>
|
|
||||||
<RuntimeAPIProvider apis={apis}>
|
|
||||||
<FireworksProvider>
|
|
||||||
<TooltipProvider delayDuration={700} skipDelayDuration={150}>
|
<TooltipProvider delayDuration={700} skipDelayDuration={150}>
|
||||||
<div className="h-full text-foreground bg-background">
|
<div className="h-full text-foreground bg-background">
|
||||||
<VSCodeLayout />
|
<SyncAppEffects apis={apis} embeddedBackgroundWorkEnabled={embeddedBackgroundWorkEnabled} />
|
||||||
|
<AgentManagerView />
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</FireworksProvider>
|
</RuntimeAPIProvider>
|
||||||
</RuntimeAPIProvider>
|
</SyncProvider>
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<SyncProvider sdk={opencodeClient.getSdkClient()} directory={currentDirectory || ''}>
|
||||||
|
<RuntimeAPIProvider apis={apis}>
|
||||||
|
<FireworksProvider>
|
||||||
|
<TooltipProvider delayDuration={700} skipDelayDuration={150}>
|
||||||
|
<div className="h-full text-foreground bg-background">
|
||||||
|
<SyncAppEffects apis={apis} embeddedBackgroundWorkEnabled={embeddedBackgroundWorkEnabled} />
|
||||||
|
<VSCodeLayout />
|
||||||
|
<Toaster />
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
</FireworksProvider>
|
||||||
|
</RuntimeAPIProvider>
|
||||||
|
</SyncProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<RuntimeAPIProvider apis={apis}>
|
<SyncProvider sdk={opencodeClient.getSdkClient()} directory={currentDirectory || ''}>
|
||||||
<GitPollingProvider>
|
<RuntimeAPIProvider apis={apis}>
|
||||||
<FireworksProvider>
|
<GitPollingProvider>
|
||||||
<VoiceProvider>
|
<FireworksProvider>
|
||||||
<TooltipProvider delayDuration={700} skipDelayDuration={150}>
|
<VoiceProvider>
|
||||||
<div className={isDesktopRuntime ? 'h-full text-foreground bg-transparent' : 'h-full text-foreground bg-background'}>
|
<TooltipProvider delayDuration={700} skipDelayDuration={150}>
|
||||||
<MainLayout />
|
<div className={isDesktopRuntime ? 'h-full text-foreground bg-transparent' : 'h-full text-foreground bg-background'}>
|
||||||
<Toaster />
|
<SyncAppEffects apis={apis} embeddedBackgroundWorkEnabled={embeddedBackgroundWorkEnabled} />
|
||||||
<ConfigUpdateOverlay />
|
<MainLayout />
|
||||||
<AboutDialogWrapper />
|
<Toaster />
|
||||||
{showMemoryDebug && (
|
<ConfigUpdateOverlay />
|
||||||
<MemoryDebugPanel onClose={() => setShowMemoryDebug(false)} />
|
<AboutDialogWrapper />
|
||||||
)}
|
{showMemoryDebug && (
|
||||||
</div>
|
<MemoryDebugPanel onClose={() => setShowMemoryDebug(false)} />
|
||||||
</TooltipProvider>
|
)}
|
||||||
</VoiceProvider>
|
</div>
|
||||||
</FireworksProvider>
|
</TooltipProvider>
|
||||||
</GitPollingProvider>
|
</VoiceProvider>
|
||||||
</RuntimeAPIProvider>
|
</FireworksProvider>
|
||||||
|
</GitPollingProvider>
|
||||||
|
</RuntimeAPIProvider>
|
||||||
|
</SyncProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { RiArrowLeftLine } from '@remixicon/react';
|
import { RiArrowLeftLine } from '@remixicon/react';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
|
||||||
import type { Message, Part } from '@opencode-ai/sdk/v2';
|
import type { Message, Part } from '@opencode-ai/sdk/v2';
|
||||||
|
|
||||||
import { ChatInput } from './ChatInput';
|
import { ChatInput } from './ChatInput';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import ChatEmptyState from './ChatEmptyState';
|
import ChatEmptyState from './ChatEmptyState';
|
||||||
@@ -14,10 +12,10 @@ import { ScrollShadow } from '@/components/ui/ScrollShadow';
|
|||||||
import { useChatScrollManager } from '@/hooks/useChatScrollManager';
|
import { useChatScrollManager } from '@/hooks/useChatScrollManager';
|
||||||
import { useChatTimelineController } from './hooks/useChatTimelineController';
|
import { useChatTimelineController } from './hooks/useChatTimelineController';
|
||||||
import { useChatTurnNavigation } from './hooks/useChatTurnNavigation';
|
import { useChatTurnNavigation } from './hooks/useChatTurnNavigation';
|
||||||
|
import { useTimelineStaging } from '@/hooks/useTimelineStaging';
|
||||||
import { useDeviceInfo } from '@/lib/device';
|
import { useDeviceInfo } from '@/lib/device';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar';
|
import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar';
|
||||||
import { TimelineDialog } from './TimelineDialog';
|
|
||||||
import type { PermissionRequest } from '@/types/permission';
|
import type { PermissionRequest } from '@/types/permission';
|
||||||
import type { QuestionRequest } from '@/types/question';
|
import type { QuestionRequest } from '@/types/question';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -26,6 +24,19 @@ import {
|
|||||||
flattenBlockingRequests,
|
flattenBlockingRequests,
|
||||||
} from './lib/blockingRequests';
|
} from './lib/blockingRequests';
|
||||||
|
|
||||||
|
// New sync system imports
|
||||||
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
|
import { useViewportStore } from '@/sync/viewport-store';
|
||||||
|
import { useStreamingStore } from '@/sync/streaming';
|
||||||
|
import {
|
||||||
|
useSessionMessageRecords,
|
||||||
|
useSessions,
|
||||||
|
useDirectorySync,
|
||||||
|
useSessionStatus,
|
||||||
|
} from '@/sync/sync-context';
|
||||||
|
import { useSync } from '@/sync/use-sync';
|
||||||
|
import { getAllSyncSessions } from '@/sync/sync-refs';
|
||||||
|
|
||||||
const EMPTY_MESSAGES: Array<{ info: Message; parts: Part[] }> = [];
|
const EMPTY_MESSAGES: Array<{ info: Message; parts: Part[] }> = [];
|
||||||
const EMPTY_PERMISSIONS: PermissionRequest[] = [];
|
const EMPTY_PERMISSIONS: PermissionRequest[] = [];
|
||||||
const EMPTY_QUESTIONS: QuestionRequest[] = [];
|
const EMPTY_QUESTIONS: QuestionRequest[] = [];
|
||||||
@@ -71,101 +82,97 @@ const HYDRATING_SKELETON_ITEMS: Array<{
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const ChatContainer: React.FC = () => {
|
export const ChatContainer: React.FC = () => {
|
||||||
const {
|
// Session UI state
|
||||||
currentSessionId,
|
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
|
||||||
loadMessages,
|
const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft);
|
||||||
loadMoreMessages,
|
const setCurrentSession = useSessionUIStore((s) => s.setCurrentSession);
|
||||||
updateViewportAnchor,
|
const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
|
||||||
openNewSessionDraft,
|
const updateViewportAnchor = useViewportStore((s) => s.updateViewportAnchor);
|
||||||
setCurrentSession,
|
const isSyncing = useViewportStore((s) => s.isSyncing);
|
||||||
newSessionDraft,
|
const sessionMemoryStateMap = useViewportStore((s) => s.sessionMemoryState);
|
||||||
} = useSessionStore(
|
|
||||||
useShallow((state) => ({
|
// Sync actions
|
||||||
currentSessionId: state.currentSessionId,
|
const sync = useSync();
|
||||||
loadMessages: state.loadMessages,
|
const loadMessages = React.useCallback(
|
||||||
loadMoreMessages: state.loadMoreMessages,
|
(sessionId: string) => sync.syncSession(sessionId),
|
||||||
updateViewportAnchor: state.updateViewportAnchor,
|
[sync],
|
||||||
openNewSessionDraft: state.openNewSessionDraft,
|
);
|
||||||
setCurrentSession: state.setCurrentSession,
|
const loadMoreMessages = React.useCallback(
|
||||||
newSessionDraft: state.newSessionDraft,
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
}))
|
(sessionId: string, _direction: 'up' | 'down') => sync.loadMore(sessionId),
|
||||||
|
[sync],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { isSyncing, messageStreamStates, sessionMemoryStateMap } = useSessionStore(
|
// UI store
|
||||||
useShallow((state) => ({
|
const { isExpandedInput, stickyUserHeader, chatRenderMode } = useUIStore();
|
||||||
isSyncing: state.isSyncing,
|
|
||||||
messageStreamStates: state.messageStreamStates,
|
|
||||||
sessionMemoryStateMap: state.sessionMemoryState,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
const {
|
// Streaming state
|
||||||
isTimelineDialogOpen,
|
const streamingMessageId = useStreamingStore(
|
||||||
setTimelineDialogOpen,
|
|
||||||
isExpandedInput,
|
|
||||||
stickyUserHeader,
|
|
||||||
chatRenderMode,
|
|
||||||
} = useUIStore();
|
|
||||||
|
|
||||||
const sessionMessages = useSessionStore(
|
|
||||||
React.useCallback(
|
React.useCallback(
|
||||||
(state) => (currentSessionId ? state.messages.get(currentSessionId) ?? EMPTY_MESSAGES : EMPTY_MESSAGES),
|
(s) => (currentSessionId ? s.streamingMessageIds.get(currentSessionId) ?? null : null),
|
||||||
[currentSessionId]
|
[currentSessionId],
|
||||||
)
|
),
|
||||||
|
);
|
||||||
|
// Messages from sync system
|
||||||
|
const sessionMessageRecords = useSessionMessageRecords(currentSessionId ?? '');
|
||||||
|
const sessionMessages = currentSessionId ? sessionMessageRecords : EMPTY_MESSAGES;
|
||||||
|
|
||||||
|
// Sessions from sync system
|
||||||
|
const sessions = useSessions();
|
||||||
|
|
||||||
|
// Session status from sync system
|
||||||
|
const sessionStatusForCurrent = useSessionStatus(currentSessionId ?? '') ?? IDLE_SESSION_STATUS;
|
||||||
|
|
||||||
|
// Permissions & questions from sync system
|
||||||
|
const allPermissions = useDirectorySync(
|
||||||
|
React.useCallback((s) => s.permission ?? {}, []),
|
||||||
|
);
|
||||||
|
const allQuestions = useDirectorySync(
|
||||||
|
React.useCallback((s) => s.question ?? {}, []),
|
||||||
);
|
);
|
||||||
|
|
||||||
const sessions = useSessionStore((state) => state.sessions);
|
// Convert Record → Map for blockingRequests helpers
|
||||||
|
const permissionsMap = React.useMemo(() => {
|
||||||
|
const m = new Map<string, PermissionRequest[]>();
|
||||||
|
for (const [k, v] of Object.entries(allPermissions)) m.set(k, v as PermissionRequest[]);
|
||||||
|
return m;
|
||||||
|
}, [allPermissions]);
|
||||||
|
|
||||||
const blockingRequestState = useSessionStore(
|
const questionsMap = React.useMemo(() => {
|
||||||
useShallow((state) => ({
|
const m = new Map<string, QuestionRequest[]>();
|
||||||
sessions: state.sessions,
|
for (const [k, v] of Object.entries(allQuestions)) m.set(k, v as QuestionRequest[]);
|
||||||
permissions: state.permissions,
|
return m;
|
||||||
questions: state.questions,
|
}, [allQuestions]);
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
const scopedSessionIds = React.useMemo(
|
const scopedSessionIds = React.useMemo(
|
||||||
() => collectVisibleSessionIdsForBlockingRequests(
|
() => collectVisibleSessionIdsForBlockingRequests(
|
||||||
blockingRequestState.sessions.map((session) => ({ id: session.id, parentID: session.parentID })),
|
sessions.map((session) => ({ id: session.id, parentID: session.parentID })),
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
),
|
),
|
||||||
[blockingRequestState.sessions, currentSessionId]
|
[sessions, currentSessionId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const sessionPermissions = React.useMemo(() => {
|
const sessionPermissions = React.useMemo(() => {
|
||||||
if (scopedSessionIds.length === 0) return EMPTY_PERMISSIONS;
|
if (scopedSessionIds.length === 0) return EMPTY_PERMISSIONS;
|
||||||
return flattenBlockingRequests(blockingRequestState.permissions, scopedSessionIds);
|
return flattenBlockingRequests(permissionsMap, scopedSessionIds);
|
||||||
}, [blockingRequestState.permissions, scopedSessionIds]);
|
}, [permissionsMap, scopedSessionIds]);
|
||||||
|
|
||||||
const sessionQuestions = React.useMemo(() => {
|
const sessionQuestions = React.useMemo(() => {
|
||||||
if (scopedSessionIds.length === 0) return EMPTY_QUESTIONS;
|
if (scopedSessionIds.length === 0) return EMPTY_QUESTIONS;
|
||||||
return flattenBlockingRequests(blockingRequestState.questions, scopedSessionIds);
|
return flattenBlockingRequests(questionsMap, scopedSessionIds);
|
||||||
}, [blockingRequestState.questions, scopedSessionIds]);
|
}, [questionsMap, scopedSessionIds]);
|
||||||
|
|
||||||
const historyMeta = useSessionStore(
|
// History metadata — use sync's hasMore/isLoading
|
||||||
React.useCallback(
|
const historyMeta = React.useMemo(() => {
|
||||||
(state) => (currentSessionId ? state.sessionHistoryMeta.get(currentSessionId) ?? null : null),
|
if (!currentSessionId) return null;
|
||||||
[currentSessionId]
|
return {
|
||||||
)
|
limit: sessionMessages.length,
|
||||||
);
|
complete: !sync.hasMore(currentSessionId),
|
||||||
|
loading: sync.isLoading(currentSessionId),
|
||||||
|
};
|
||||||
|
}, [currentSessionId, sessionMessages.length, sync]);
|
||||||
|
|
||||||
const streamingMessageId = useSessionStore(
|
const hasSessionMessagesEntry = sessionMessages.length > 0 || (currentSessionId ? sync.hasMore(currentSessionId) : false);
|
||||||
React.useCallback(
|
|
||||||
(state) => (currentSessionId ? state.streamingMessageIds.get(currentSessionId) ?? null : null),
|
|
||||||
[currentSessionId]
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const sessionStatusForCurrent = useSessionStore(
|
|
||||||
React.useCallback(
|
|
||||||
(state) => (currentSessionId ? state.sessionStatus?.get(currentSessionId) ?? IDLE_SESSION_STATUS : IDLE_SESSION_STATUS),
|
|
||||||
[currentSessionId]
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const hasSessionMessagesEntry = useSessionStore(
|
|
||||||
React.useCallback((state) => (currentSessionId ? state.messages.has(currentSessionId) : false), [currentSessionId])
|
|
||||||
);
|
|
||||||
|
|
||||||
const { isMobile } = useDeviceInfo();
|
const { isMobile } = useDeviceInfo();
|
||||||
const draftOpen = Boolean(newSessionDraft?.open);
|
const draftOpen = Boolean(newSessionDraft?.open);
|
||||||
@@ -173,24 +180,18 @@ export const ChatContainer: React.FC = () => {
|
|||||||
const messageListRef = React.useRef<MessageListHandle | null>(null);
|
const messageListRef = React.useRef<MessageListHandle | null>(null);
|
||||||
|
|
||||||
const parentSession = React.useMemo(() => {
|
const parentSession = React.useMemo(() => {
|
||||||
if (!currentSessionId) {
|
if (!currentSessionId) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const current = sessions.find((session) => session.id === currentSessionId);
|
const current = sessions.find((session) => session.id === currentSessionId);
|
||||||
const parentID = current?.parentID;
|
const parentID = current?.parentID;
|
||||||
if (!parentID) {
|
if (!parentID) return null;
|
||||||
return null;
|
return sessions.find((session) => session.id === parentID)
|
||||||
}
|
?? getAllSyncSessions().find((session) => session.id === parentID)
|
||||||
|
?? null;
|
||||||
return sessions.find((session) => session.id === parentID) ?? null;
|
|
||||||
}, [currentSessionId, sessions]);
|
}, [currentSessionId, sessions]);
|
||||||
|
|
||||||
const handleReturnToParentSession = React.useCallback(() => {
|
const handleReturnToParentSession = React.useCallback(() => {
|
||||||
if (!parentSession) {
|
if (!parentSession) return;
|
||||||
return;
|
setCurrentSession(parentSession.id);
|
||||||
}
|
|
||||||
void setCurrentSession(parentSession.id);
|
|
||||||
}, [parentSession, setCurrentSession]);
|
}, [parentSession, setCurrentSession]);
|
||||||
|
|
||||||
const returnToParentButton = parentSession ? (
|
const returnToParentButton = parentSession ? (
|
||||||
@@ -219,13 +220,15 @@ export const ChatContainer: React.FC = () => {
|
|||||||
}, [sessionPermissions, sessionQuestions]);
|
}, [sessionPermissions, sessionQuestions]);
|
||||||
|
|
||||||
const activeTurnChangeRef = React.useRef<(turnId: string | null) => void>(() => {});
|
const activeTurnChangeRef = React.useRef<(turnId: string | null) => void>(() => {});
|
||||||
|
const handleActiveTurnChange = React.useCallback((turnId: string | null) => {
|
||||||
|
activeTurnChangeRef.current(turnId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
scrollRef,
|
scrollRef,
|
||||||
handleMessageContentChange,
|
handleMessageContentChange,
|
||||||
getAnimationHandlers,
|
getAnimationHandlers,
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
releasePinnedScroll,
|
|
||||||
isPinned,
|
isPinned,
|
||||||
isOverflowing,
|
isOverflowing,
|
||||||
isProgrammaticFollowActive,
|
isProgrammaticFollowActive,
|
||||||
@@ -238,16 +241,20 @@ export const ChatContainer: React.FC = () => {
|
|||||||
isSyncing,
|
isSyncing,
|
||||||
isMobile,
|
isMobile,
|
||||||
chatRenderMode,
|
chatRenderMode,
|
||||||
messageStreamStates,
|
|
||||||
sessionPermissions: sessionBlockingCards,
|
sessionPermissions: sessionBlockingCards,
|
||||||
onActiveTurnChange: (turnId) => {
|
onActiveTurnChange: handleActiveTurnChange,
|
||||||
activeTurnChangeRef.current(turnId);
|
});
|
||||||
},
|
|
||||||
|
// Deferred timeline staging — renders 1 message on first paint,
|
||||||
|
// adds 3 per rAF frame to avoid blocking.
|
||||||
|
const { stagedMessages } = useTimelineStaging({
|
||||||
|
sessionKey: currentSessionId ?? '',
|
||||||
|
messages: sessionMessages,
|
||||||
});
|
});
|
||||||
|
|
||||||
const timelineController = useChatTimelineController({
|
const timelineController = useChatTimelineController({
|
||||||
sessionId: currentSessionId,
|
sessionId: currentSessionId,
|
||||||
messages: sessionMessages,
|
messages: stagedMessages,
|
||||||
historyMeta,
|
historyMeta,
|
||||||
scrollRef,
|
scrollRef,
|
||||||
messageListRef,
|
messageListRef,
|
||||||
@@ -272,16 +279,11 @@ export const ChatContainer: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (typeof window === 'undefined' || !currentSessionId) {
|
if (typeof window === 'undefined' || !currentSessionId) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSessionReselected = (event: Event) => {
|
const handleSessionReselected = (event: Event) => {
|
||||||
const customEvent = event as CustomEvent<string>;
|
const customEvent = event as CustomEvent<string>;
|
||||||
if (customEvent.detail !== currentSessionId) {
|
if (customEvent.detail !== currentSessionId) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
resumeToBottomInstant();
|
resumeToBottomInstant();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -293,9 +295,7 @@ export const ChatContainer: React.FC = () => {
|
|||||||
|
|
||||||
React.useLayoutEffect(() => {
|
React.useLayoutEffect(() => {
|
||||||
const container = scrollRef.current;
|
const container = scrollRef.current;
|
||||||
if (!container) {
|
if (!container) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateChatScrollHeight = () => {
|
const updateChatScrollHeight = () => {
|
||||||
container.style.setProperty('--chat-scroll-height', `${container.clientHeight}px`);
|
container.style.setProperty('--chat-scroll-height', `${container.clientHeight}px`);
|
||||||
@@ -329,23 +329,15 @@ export const ChatContainer: React.FC = () => {
|
|||||||
};
|
};
|
||||||
}, [currentSessionId, isDesktopExpandedInput, scrollRef]);
|
}, [currentSessionId, isDesktopExpandedInput, scrollRef]);
|
||||||
|
|
||||||
const hasHistoryMetadata = React.useMemo(() => {
|
const hasHistoryMetadata = Boolean(historyMeta);
|
||||||
return Boolean(historyMeta);
|
|
||||||
}, [historyMeta]);
|
|
||||||
|
|
||||||
const isSessionHydrating =
|
const isSessionHydrating =
|
||||||
Boolean(currentSessionId)
|
Boolean(currentSessionId)
|
||||||
&& (!hasSessionMessagesEntry || !hasHistoryMetadata || historyMeta?.loading === true);
|
&& (!hasSessionMessagesEntry || !hasHistoryMetadata || historyMeta?.loading === true);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!currentSessionId) {
|
if (!currentSessionId) return;
|
||||||
return;
|
if (hasSessionMessagesEntry && hasHistoryMetadata) return;
|
||||||
}
|
|
||||||
|
|
||||||
const hasSessionMessages = hasSessionMessagesEntry;
|
|
||||||
if (hasSessionMessages && hasHistoryMetadata) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
await loadMessages(currentSessionId).finally(() => {
|
await loadMessages(currentSessionId).finally(() => {
|
||||||
@@ -523,6 +515,9 @@ export const ChatContainer: React.FC = () => {
|
|||||||
<ScrollShadow
|
<ScrollShadow
|
||||||
className="absolute inset-0 overflow-y-auto overflow-x-hidden z-0 chat-scroll overlay-scrollbar-target"
|
className="absolute inset-0 overflow-y-auto overflow-x-hidden z-0 chat-scroll overlay-scrollbar-target"
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
|
style={(timelineController.pendingRevealWork || timelineController.isLoadingOlder)
|
||||||
|
? { overflowAnchor: 'none' }
|
||||||
|
: undefined}
|
||||||
observeMutations={false}
|
observeMutations={false}
|
||||||
hideTopShadow={isMobile && stickyUserHeader}
|
hideTopShadow={isMobile && stickyUserHeader}
|
||||||
data-scroll-shadow="true"
|
data-scroll-shadow="true"
|
||||||
@@ -569,20 +564,6 @@ export const ChatContainer: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
<ChatInput scrollToBottom={scrollToBottom} />
|
<ChatInput scrollToBottom={scrollToBottom} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TimelineDialog
|
|
||||||
open={isTimelineDialogOpen}
|
|
||||||
onOpenChange={setTimelineDialogOpen}
|
|
||||||
onScrollToMessage={(messageId) => {
|
|
||||||
releasePinnedScroll();
|
|
||||||
return navigation.scrollToMessageId(messageId, { behavior: 'smooth', updateHash: false });
|
|
||||||
}}
|
|
||||||
onScrollByTurnOffset={(offset) => {
|
|
||||||
releasePinnedScroll();
|
|
||||||
void navigation.scrollByTurnOffset(offset);
|
|
||||||
}}
|
|
||||||
onResumeToLatest={navigation.resumeToLatest}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,12 +16,16 @@ import {
|
|||||||
RiSendPlane2Line,
|
RiSendPlane2Line,
|
||||||
} from '@remixicon/react';
|
} from '@remixicon/react';
|
||||||
import { BrowserVoiceButton } from '@/components/voice';
|
import { BrowserVoiceButton } from '@/components/voice';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
// sessionStore removed — currentSessionId comes from useSessionUIStore
|
||||||
import { useSessionStore as useSessionManagementStore } from '@/stores/sessionStore';
|
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore';
|
import { useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore';
|
||||||
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
|
import { useSelectionStore } from '@/sync/selection-store';
|
||||||
|
import { useInputStore } from '@/sync/input-store';
|
||||||
import type { AttachedFile } from '@/stores/types/sessionTypes';
|
import type { AttachedFile } from '@/stores/types/sessionTypes';
|
||||||
|
import * as sessionActions from '@/sync/session-actions';
|
||||||
|
import { useSessionMessageRecords } from '@/sync/sync-context';
|
||||||
import { useInlineCommentDraftStore, type InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
|
import { useInlineCommentDraftStore, type InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
|
||||||
import { appendInlineComments } from '@/lib/messages/inlineComments';
|
import { appendInlineComments } from '@/lib/messages/inlineComments';
|
||||||
import { AttachedFilesList } from './FileAttachment';
|
import { AttachedFilesList } from './FileAttachment';
|
||||||
@@ -40,8 +44,7 @@ import { MobileSessionStatusBar } from './MobileSessionStatusBar';
|
|||||||
import { useAssistantStatus } from '@/hooks/useAssistantStatus';
|
import { useAssistantStatus } from '@/hooks/useAssistantStatus';
|
||||||
import { useCurrentSessionActivity } from '@/hooks/useSessionActivity';
|
import { useCurrentSessionActivity } from '@/hooks/useSessionActivity';
|
||||||
import { toast } from '@/components/ui';
|
import { toast } from '@/components/ui';
|
||||||
import { useFileStore } from '@/stores/fileStore';
|
// useMessageStore removed — messages now come from sync system
|
||||||
import { useMessageStore } from '@/stores/messageStore';
|
|
||||||
import { isTauriShell, isVSCodeRuntime } from '@/lib/desktop';
|
import { isTauriShell, isVSCodeRuntime } from '@/lib/desktop';
|
||||||
import { isIMECompositionEvent } from '@/lib/ime';
|
import { isIMECompositionEvent } from '@/lib/ime';
|
||||||
import { StopIcon } from '@/components/icons/StopIcon';
|
import { StopIcon } from '@/components/icons/StopIcon';
|
||||||
@@ -81,6 +84,28 @@ const VS_CODE_DROP_DATA_TYPES = [
|
|||||||
|
|
||||||
const FILE_URI_PREFIX = 'file://';
|
const FILE_URI_PREFIX = 'file://';
|
||||||
|
|
||||||
|
const encodeFilePath = (filepath: string): string => {
|
||||||
|
let normalized = filepath.replace(/\\/g, '/');
|
||||||
|
if (/^[A-Za-z]:/.test(normalized)) {
|
||||||
|
normalized = `/${normalized}`;
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
.split('/')
|
||||||
|
.map((segment, index) => {
|
||||||
|
if (index === 1 && /^[A-Za-z]:$/.test(segment)) return segment;
|
||||||
|
return encodeURIComponent(segment);
|
||||||
|
})
|
||||||
|
.join('/');
|
||||||
|
};
|
||||||
|
|
||||||
|
const toServerFileUrl = (filepath: string): string => {
|
||||||
|
const normalized = filepath.replace(/\\/g, '/').trim();
|
||||||
|
if (normalized.toLowerCase().startsWith(FILE_URI_PREFIX)) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
return `file://${encodeFilePath(normalized)}`;
|
||||||
|
};
|
||||||
|
|
||||||
const isLikelyAbsolutePath = (value: string): boolean => (
|
const isLikelyAbsolutePath = (value: string): boolean => (
|
||||||
value.startsWith('/')
|
value.startsWith('/')
|
||||||
|| value.startsWith('\\\\')
|
|| value.startsWith('\\\\')
|
||||||
@@ -273,7 +298,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
const initialSessionIdRef = React.useRef<string | null>(null);
|
const initialSessionIdRef = React.useRef<string | null>(null);
|
||||||
const [message, setMessage] = React.useState(() => {
|
const [message, setMessage] = React.useState(() => {
|
||||||
// Read per-session draft at mount time using the current session from the store
|
// Read per-session draft at mount time using the current session from the store
|
||||||
const sessionId = useSessionStore.getState().currentSessionId;
|
const sessionId = useSessionUIStore.getState().currentSessionId;
|
||||||
initialSessionIdRef.current = sessionId;
|
initialSessionIdRef.current = sessionId;
|
||||||
const draft = getStoredDraft(sessionId);
|
const draft = getStoredDraft(sessionId);
|
||||||
if (draft) {
|
if (draft) {
|
||||||
@@ -313,25 +338,32 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
const lastPersistedDraftRef = React.useRef<Map<string, string>>(new Map());
|
const lastPersistedDraftRef = React.useRef<Map<string, string>>(new Map());
|
||||||
const currentSessionIdForDraftRef = React.useRef<string | null>(null);
|
const currentSessionIdForDraftRef = React.useRef<string | null>(null);
|
||||||
|
|
||||||
const sendMessage = useSessionStore((state) => state.sendMessage);
|
// TODO: port sendMessage to session-actions (complex — creates sessions, handles attachments, etc.)
|
||||||
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const newSessionDraft = useSessionStore((state) => state.newSessionDraft);
|
const sendMessage = React.useRef((...args: any[]) =>
|
||||||
|
Promise.resolve((useSessionUIStore.getState().sendMessage as (...a: unknown[]) => unknown)(...args)),
|
||||||
|
).current;
|
||||||
|
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
|
||||||
|
const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
|
||||||
const newSessionDraftOpen = Boolean(newSessionDraft?.open);
|
const newSessionDraftOpen = Boolean(newSessionDraft?.open);
|
||||||
const setNewSessionDraftTarget = useSessionStore((state) => state.setNewSessionDraftTarget);
|
const setNewSessionDraftTarget = useSessionUIStore((s) => s.setNewSessionDraftTarget);
|
||||||
const availableWorktreesByProject = useSessionStore((state) => state.availableWorktreesByProject);
|
const availableWorktreesByProject = useSessionUIStore((s) => s.availableWorktreesByProject);
|
||||||
const abortCurrentOperation = useSessionStore((state) => state.abortCurrentOperation);
|
const abortPromptSessionId = useSessionUIStore((s) => s.abortPromptSessionId);
|
||||||
const acknowledgeSessionAbort = useSessionStore((state) => state.acknowledgeSessionAbort);
|
const clearAbortPrompt = useSessionUIStore((s) => s.clearAbortPrompt);
|
||||||
const abortPromptSessionId = useSessionStore((state) => state.abortPromptSessionId);
|
const attachedFiles = useInputStore((s) => s.attachedFiles);
|
||||||
const clearAbortPrompt = useSessionStore((state) => state.clearAbortPrompt);
|
const addAttachedFile = useInputStore((s) => s.addAttachedFile);
|
||||||
const attachedFiles = useSessionStore((state) => state.attachedFiles);
|
const clearAttachedFiles = useInputStore((s) => s.clearAttachedFiles);
|
||||||
const addAttachedFile = useSessionStore((state) => state.addAttachedFile);
|
const saveSessionAgentSelection = useSelectionStore((s) => s.saveSessionAgentSelection);
|
||||||
const clearAttachedFiles = useSessionStore((state) => state.clearAttachedFiles);
|
const consumePendingInputText = useInputStore((s) => s.consumePendingInputText);
|
||||||
const saveSessionAgentSelection = useSessionStore((state) => state.saveSessionAgentSelection);
|
const setPendingInputText = useInputStore((s) => s.setPendingInputText);
|
||||||
const consumePendingInputText = useSessionStore((state) => state.consumePendingInputText);
|
const pendingInputText = useInputStore((s) => s.pendingInputText);
|
||||||
const setPendingInputText = useSessionStore((state) => state.setPendingInputText);
|
const consumePendingSyntheticParts = useInputStore((s) => s.consumePendingSyntheticParts);
|
||||||
const pendingInputText = useSessionStore((state) => state.pendingInputText);
|
const acknowledgeSessionAbort = useSessionUIStore((s) => s.acknowledgeSessionAbort);
|
||||||
const consumePendingSyntheticParts = useSessionStore((state) => state.consumePendingSyntheticParts);
|
const abortCurrentOperation = React.useCallback(
|
||||||
const currentManagementSessionId = useSessionManagementStore((state) => state.currentSessionId);
|
(sessionIdOverride?: string) => sessionActions.abortCurrentOperation(sessionIdOverride ?? currentSessionId ?? ''),
|
||||||
|
[currentSessionId],
|
||||||
|
);
|
||||||
|
const currentManagementSessionId = currentSessionId;
|
||||||
const projects = useProjectsStore((state) => state.projects);
|
const projects = useProjectsStore((state) => state.projects);
|
||||||
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
||||||
const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly);
|
const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly);
|
||||||
@@ -339,7 +371,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
const { currentProviderId, currentModelId, currentVariant, currentAgentName, setAgent, getVisibleAgents } = useConfigStore();
|
const { currentProviderId, currentModelId, currentVariant, currentAgentName, setAgent, getVisibleAgents } = useConfigStore();
|
||||||
const agents = getVisibleAgents();
|
const agents = getVisibleAgents();
|
||||||
const primaryAgents = React.useMemo(() => agents.filter((agent) => agent.mode === 'primary'), [agents]);
|
const primaryAgents = React.useMemo(() => agents.filter((agent) => agent.mode === 'primary'), [agents]);
|
||||||
const { isMobile, inputBarOffset, isKeyboardOpen, setTimelineDialogOpen, cornerRadius, persistChatDraft, inputSpellcheckEnabled, isExpandedInput, setExpandedInput } = useUIStore();
|
const { isMobile, inputBarOffset, isKeyboardOpen, cornerRadius, persistChatDraft, inputSpellcheckEnabled, isExpandedInput, setExpandedInput } = useUIStore();
|
||||||
const { working } = useAssistantStatus();
|
const { working } = useAssistantStatus();
|
||||||
const { git: runtimeGit } = useRuntimeAPIs();
|
const { git: runtimeGit } = useRuntimeAPIs();
|
||||||
const { currentTheme } = useThemeSystem();
|
const { currentTheme } = useThemeSystem();
|
||||||
@@ -351,10 +383,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
const isDesktopExpanded = isExpandedInput && !isMobile;
|
const isDesktopExpanded = isExpandedInput && !isMobile;
|
||||||
const chatInputRadius = 'var(--radius-lg)';
|
const chatInputRadius = 'var(--radius-lg)';
|
||||||
|
|
||||||
const sendableAttachedFiles = React.useMemo(
|
const sendableAttachedFiles = attachedFiles;
|
||||||
() => attachedFiles.filter((file) => file.source !== 'server'),
|
|
||||||
[attachedFiles],
|
|
||||||
);
|
|
||||||
|
|
||||||
const hasInlineMentionForHighlight = React.useMemo(() => {
|
const hasInlineMentionForHighlight = React.useMemo(() => {
|
||||||
if (!message || !message.includes('@') || inputMode === 'shell') {
|
if (!message || !message.includes('@') || inputMode === 'shell') {
|
||||||
@@ -426,8 +455,12 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
|
|
||||||
const sanitizeAttachmentsForSend = React.useCallback(
|
const sanitizeAttachmentsForSend = React.useCallback(
|
||||||
(files: AttachedFile[] | undefined): AttachedFile[] => (files ?? [])
|
(files: AttachedFile[] | undefined): AttachedFile[] => (files ?? [])
|
||||||
.filter((file) => file.source !== 'server')
|
.map((file) => ({
|
||||||
.map((file) => ({ ...file })),
|
...file,
|
||||||
|
dataUrl: file.source === 'server' && file.serverPath
|
||||||
|
? toServerFileUrl(file.serverPath)
|
||||||
|
: file.dataUrl,
|
||||||
|
})),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -498,7 +531,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
filename,
|
filename,
|
||||||
mimeType: 'text/plain',
|
mimeType: 'text/plain',
|
||||||
size: 0,
|
size: 0,
|
||||||
dataUrl: normalizedServerPath,
|
dataUrl: toServerFileUrl(normalizedServerPath),
|
||||||
source: 'server',
|
source: 'server',
|
||||||
serverPath: normalizedServerPath,
|
serverPath: normalizedServerPath,
|
||||||
});
|
});
|
||||||
@@ -565,15 +598,10 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
|
|
||||||
// User message history for up/down arrow navigation
|
// User message history for up/down arrow navigation
|
||||||
// Get raw messages from store (stable reference)
|
// Get raw messages from store (stable reference)
|
||||||
const sessionMessages = useMessageStore(
|
const sessionMessages = useSessionMessageRecords(currentSessionId ?? "");
|
||||||
React.useCallback(
|
|
||||||
(state) => (currentSessionId ? state.messages.get(currentSessionId) : undefined),
|
|
||||||
[currentSessionId]
|
|
||||||
)
|
|
||||||
);
|
|
||||||
// Derive user message history with useMemo to avoid infinite re-renders
|
// Derive user message history with useMemo to avoid infinite re-renders
|
||||||
const userMessageHistory = React.useMemo(() => {
|
const userMessageHistory = React.useMemo(() => {
|
||||||
if (!sessionMessages) return [];
|
if (!sessionMessages || !currentSessionId) return [];
|
||||||
return sessionMessages
|
return sessionMessages
|
||||||
.filter((m) => m.info.role === 'user')
|
.filter((m) => m.info.role === 'user')
|
||||||
.map((m) => {
|
.map((m) => {
|
||||||
@@ -585,7 +613,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
})
|
})
|
||||||
.filter((text) => text.length > 0)
|
.filter((text) => text.length > 0)
|
||||||
.reverse(); // Most recent first
|
.reverse(); // Most recent first
|
||||||
}, [sessionMessages]);
|
}, [sessionMessages, currentSessionId]);
|
||||||
|
|
||||||
// Keep messageRef in sync with message state
|
// Keep messageRef in sync with message state
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -866,6 +894,12 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
addToQueue(currentSessionId, {
|
addToQueue(currentSessionId, {
|
||||||
content: messageToQueue,
|
content: messageToQueue,
|
||||||
attachments: attachmentsToQueue.length > 0 ? attachmentsToQueue : undefined,
|
attachments: attachmentsToQueue.length > 0 ? attachmentsToQueue : undefined,
|
||||||
|
sendConfig: currentProviderId && currentModelId ? {
|
||||||
|
providerID: currentProviderId,
|
||||||
|
modelID: currentModelId,
|
||||||
|
agent: currentAgentName ?? undefined,
|
||||||
|
variant: currentVariant ?? undefined,
|
||||||
|
} : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clear input and attachments
|
// Clear input and attachments
|
||||||
@@ -877,7 +911,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
if (!isMobile) {
|
if (!isMobile) {
|
||||||
textareaRef.current?.focus();
|
textareaRef.current?.focus();
|
||||||
}
|
}
|
||||||
}, [hasContent, currentSessionId, message, sendableAttachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, consumeDrafts]);
|
}, [hasContent, currentSessionId, message, sendableAttachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, consumeDrafts, currentProviderId, currentModelId, currentAgentName, currentVariant]);
|
||||||
|
|
||||||
const handleSubmit = async (options?: SubmitOptions) => {
|
const handleSubmit = async (options?: SubmitOptions) => {
|
||||||
const queuedOnly = options?.queuedOnly ?? false;
|
const queuedOnly = options?.queuedOnly ?? false;
|
||||||
@@ -1037,25 +1071,26 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
.split(/\s+/)[0]
|
.split(/\s+/)[0]
|
||||||
?.toLowerCase();
|
?.toLowerCase();
|
||||||
|
|
||||||
// NEW: /undo - revert to last message (populates input with reverted message text)
|
|
||||||
if (commandName === 'undo' && currentSessionId) {
|
if (commandName === 'undo' && currentSessionId) {
|
||||||
await useSessionStore.getState().handleSlashUndo(currentSessionId);
|
await useSessionUIStore.getState().handleSlashUndo(currentSessionId);
|
||||||
// Don't clear message - pendingInputText will populate it with reverted message
|
|
||||||
scrollToBottom?.({ instant: true, force: true });
|
scrollToBottom?.({ instant: true, force: true });
|
||||||
return; // Don't send to assistant
|
return;
|
||||||
}
|
}
|
||||||
// NEW: /redo - unrevert or partial redo (populates input with message text)
|
|
||||||
else if (commandName === 'redo' && currentSessionId) {
|
else if (commandName === 'redo' && currentSessionId) {
|
||||||
await useSessionStore.getState().handleSlashRedo(currentSessionId);
|
await useSessionUIStore.getState().handleSlashRedo(currentSessionId);
|
||||||
// Don't clear message - pendingInputText will populate it
|
|
||||||
scrollToBottom?.({ instant: true, force: true });
|
scrollToBottom?.({ instant: true, force: true });
|
||||||
return; // Don't send to assistant
|
return;
|
||||||
}
|
}
|
||||||
// NEW: /timeline - open timeline dialog
|
else if (commandName === 'compact' && currentSessionId) {
|
||||||
else if (commandName === 'timeline' && currentSessionId) {
|
const { opencodeClient } = await import('@/lib/opencode/client');
|
||||||
setTimelineDialogOpen(true);
|
const sdk = opencodeClient.getSdkClient();
|
||||||
setMessage('');
|
const configState = useConfigStore.getState();
|
||||||
return; // Don't send to assistant
|
await sdk.session.summarize({
|
||||||
|
sessionID: currentSessionId,
|
||||||
|
modelID: configState.currentModelId || '',
|
||||||
|
providerID: configState.currentProviderId || '',
|
||||||
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1108,21 +1143,21 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
if (normalized.includes('payload too large') || normalized.includes('413') || normalized.includes('entity too large')) {
|
if (normalized.includes('payload too large') || normalized.includes('413') || normalized.includes('entity too large')) {
|
||||||
toast.error('Attachments are too large to send. Please try reducing the number or size of images.');
|
toast.error('Attachments are too large to send. Please try reducing the number or size of images.');
|
||||||
if (allAttachments.length > 0) {
|
if (allAttachments.length > 0) {
|
||||||
useFileStore.setState({ attachedFiles: allAttachments });
|
useInputStore.setState({ attachedFiles: allAttachments });
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isSoftNetworkError) {
|
if (isSoftNetworkError) {
|
||||||
if (allAttachments.length > 0) {
|
if (allAttachments.length > 0) {
|
||||||
useFileStore.setState({ attachedFiles: allAttachments });
|
useInputStore.setState({ attachedFiles: allAttachments });
|
||||||
toast.error('Failed to send attachments. Try fewer files or smaller images.');
|
toast.error('Failed to send attachments. Try fewer files or smaller images.');
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allAttachments.length > 0) {
|
if (allAttachments.length > 0) {
|
||||||
useFileStore.setState({ attachedFiles: allAttachments });
|
useInputStore.setState({ attachedFiles: allAttachments });
|
||||||
}
|
}
|
||||||
toast.error(rawMessage || 'Message failed to send. Attachments restored.');
|
toast.error(rawMessage || 'Message failed to send. Attachments restored.');
|
||||||
});
|
});
|
||||||
@@ -2560,7 +2595,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
if (!selectedDraftDirectory || !selectedDraftBranchIsKnown) {
|
if (!selectedDraftDirectory || !selectedDraftBranchIsKnown) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
useSessionStore.getState().setDraftPreserveDirectoryOverride(false);
|
useSessionUIStore.getState().setDraftPreserveDirectoryOverride(false);
|
||||||
}, [newSessionDraft?.open, newSessionDraft?.preserveDirectoryOverride, selectedDraftBranchIsKnown, selectedDraftDirectory]);
|
}, [newSessionDraft?.open, newSessionDraft?.preserveDirectoryOverride, selectedDraftBranchIsKnown, selectedDraftDirectory]);
|
||||||
|
|
||||||
const shouldShowDraftBranchSelector = React.useMemo(() => {
|
const shouldShowDraftBranchSelector = React.useMemo(() => {
|
||||||
@@ -2574,7 +2609,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
}, [isDiscoveringDraftBranches, projectRootBranchOption, worktreeBranchOptions.length]);
|
}, [isDiscoveringDraftBranches, projectRootBranchOption, worktreeBranchOptions.length]);
|
||||||
|
|
||||||
const handleDraftProjectChange = React.useCallback((projectId: string) => {
|
const handleDraftProjectChange = React.useCallback((projectId: string) => {
|
||||||
const draft = useSessionStore.getState().newSessionDraft;
|
const draft = useSessionUIStore.getState().newSessionDraft;
|
||||||
if (draft?.pendingWorktreeRequestId || draft?.bootstrapPendingDirectory || draft?.preserveDirectoryOverride) {
|
if (draft?.pendingWorktreeRequestId || draft?.bootstrapPendingDirectory || draft?.preserveDirectoryOverride) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2592,7 +2627,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
}, [activeProjectId, projects, setActiveProjectIdOnly, setNewSessionDraftTarget]);
|
}, [activeProjectId, projects, setActiveProjectIdOnly, setNewSessionDraftTarget]);
|
||||||
|
|
||||||
const handleDraftDirectoryChange = React.useCallback((directory: string) => {
|
const handleDraftDirectoryChange = React.useCallback((directory: string) => {
|
||||||
const draft = useSessionStore.getState().newSessionDraft;
|
const draft = useSessionUIStore.getState().newSessionDraft;
|
||||||
if (draft?.pendingWorktreeRequestId || draft?.bootstrapPendingDirectory || draft?.preserveDirectoryOverride) {
|
if (draft?.pendingWorktreeRequestId || draft?.bootstrapPendingDirectory || draft?.preserveDirectoryOverride) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ import { useShallow } from 'zustand/react/shallow';
|
|||||||
|
|
||||||
import { defaultCodeDark, defaultCodeLight } from '@/lib/codeTheme';
|
import { defaultCodeDark, defaultCodeLight } from '@/lib/codeTheme';
|
||||||
import { MessageFreshnessDetector } from '@/lib/messageFreshness';
|
import { MessageFreshnessDetector } from '@/lib/messageFreshness';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { useContextStore } from '@/stores/contextStore';
|
import { useContextStore } from '@/stores/contextStore';
|
||||||
|
import { useStreamingStore } from '@/sync/streaming';
|
||||||
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
|
import { useSelectionStore } from '@/sync/selection-store';
|
||||||
|
import * as sessionActions from '@/sync/session-actions';
|
||||||
import { useDeviceInfo } from '@/lib/device';
|
import { useDeviceInfo } from '@/lib/device';
|
||||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||||
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
|
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
|
||||||
@@ -26,6 +29,8 @@ import { isLikelyProviderAuthFailure, PROVIDER_AUTH_FAILURE_MESSAGE } from '@/li
|
|||||||
import type { TurnGroupingContext } from './lib/turns/types';
|
import type { TurnGroupingContext } from './lib/turns/types';
|
||||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
import { FadeInOnReveal } from './message/FadeInOnReveal';
|
import { FadeInOnReveal } from './message/FadeInOnReveal';
|
||||||
|
import { streamPerfCount } from '@/stores/utils/streamDebug';
|
||||||
|
import { areOptionalRenderRelevantMessagesEqual, areRenderRelevantMessageInfoEqual, areRenderRelevantPartsEqual } from './message/renderCompare';
|
||||||
|
|
||||||
const ToolOutputDialog = React.lazy(() => import('./message/ToolOutputDialog'));
|
const ToolOutputDialog = React.lazy(() => import('./message/ToolOutputDialog'));
|
||||||
|
|
||||||
@@ -123,6 +128,8 @@ interface ChatMessageProps {
|
|||||||
animationHandlers?: AnimationHandlers;
|
animationHandlers?: AnimationHandlers;
|
||||||
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
|
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
|
||||||
turnGroupingContext?: TurnGroupingContext;
|
turnGroupingContext?: TurnGroupingContext;
|
||||||
|
assistantHeaderMessageId?: string;
|
||||||
|
isInActiveTurn?: boolean;
|
||||||
animateUserOnMount?: boolean;
|
animateUserOnMount?: boolean;
|
||||||
onUserAnimationConsumed?: (messageId: string) => void;
|
onUserAnimationConsumed?: (messageId: string) => void;
|
||||||
}
|
}
|
||||||
@@ -134,6 +141,8 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||||||
onContentChange,
|
onContentChange,
|
||||||
animationHandlers,
|
animationHandlers,
|
||||||
turnGroupingContext,
|
turnGroupingContext,
|
||||||
|
assistantHeaderMessageId,
|
||||||
|
isInActiveTurn = false,
|
||||||
animateUserOnMount = false,
|
animateUserOnMount = false,
|
||||||
onUserAnimationConsumed,
|
onUserAnimationConsumed,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -141,36 +150,31 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||||||
const { currentTheme } = useThemeSystem();
|
const { currentTheme } = useThemeSystem();
|
||||||
const messageContainerRef = React.useRef<HTMLDivElement | null>(null);
|
const messageContainerRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const sessionState = useSessionStore(
|
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
|
||||||
useShallow((state) => ({
|
const streamState = useStreamingStore((s) => s.messageStreamStates.get(message.info.id));
|
||||||
lifecyclePhase: state.messageStreamStates.get(message.info.id)?.phase ?? null,
|
const lifecyclePhase = isInActiveTurn ? (streamState?.phase ?? null) : null;
|
||||||
isStreamingMessage: (() => {
|
|
||||||
const sessionId =
|
|
||||||
(message.info as { sessionID?: string }).sessionID ??
|
|
||||||
state.currentSessionId ??
|
|
||||||
null;
|
|
||||||
if (!sessionId) return false;
|
|
||||||
return (state.streamingMessageIds.get(sessionId) ?? null) === message.info.id;
|
|
||||||
})(),
|
|
||||||
currentSessionId: state.currentSessionId,
|
|
||||||
getAgentModelForSession: state.getAgentModelForSession,
|
|
||||||
getSessionModelSelection: state.getSessionModelSelection,
|
|
||||||
revertToMessage: state.revertToMessage,
|
|
||||||
forkFromMessage: state.forkFromMessage,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
const {
|
const msgSessionId = (message.info as { sessionID?: string }).sessionID ?? currentSessionId ?? null;
|
||||||
lifecyclePhase,
|
const streamingMsgForSession = useStreamingStore((s) => msgSessionId ? s.streamingMessageIds.get(msgSessionId) ?? null : null);
|
||||||
isStreamingMessage,
|
const isStreamingMessage = isInActiveTurn ? streamingMsgForSession === message.info.id : false;
|
||||||
currentSessionId,
|
const hasActiveStreamInSession = typeof streamingMsgForSession === 'string' && streamingMsgForSession.length > 0;
|
||||||
getAgentModelForSession,
|
|
||||||
getSessionModelSelection,
|
|
||||||
revertToMessage,
|
|
||||||
forkFromMessage,
|
|
||||||
} = sessionState;
|
|
||||||
|
|
||||||
const providers = useConfigStore((state) => state.providers);
|
const getAgentModelForSession = useSelectionStore((s) => s.getAgentModelForSession);
|
||||||
|
const getSessionModelSelection = useSelectionStore((s) => s.getSessionModelSelection);
|
||||||
|
const revertToMessage = sessionActions.revertToMessage;
|
||||||
|
const forkFromMessage = sessionActions.forkFromMessage;
|
||||||
|
|
||||||
|
streamPerfCount('ui.chat_message.render');
|
||||||
|
if (isStreamingMessage) {
|
||||||
|
streamPerfCount('ui.chat_message.render.streaming');
|
||||||
|
} else if (hasActiveStreamInSession) {
|
||||||
|
streamPerfCount('ui.chat_message.render.static_during_stream');
|
||||||
|
if (!isInActiveTurn) {
|
||||||
|
streamPerfCount('ui.chat_message.render.static_outside_active_turn_during_stream');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const providers = useConfigStore.getState().providers;
|
||||||
const { showReasoningTraces, stickyUserHeader, chatRenderMode, showExpandedBashTools, showExpandedEditTools } = useUIStore(
|
const { showReasoningTraces, stickyUserHeader, chatRenderMode, showExpandedBashTools, showExpandedEditTools } = useUIStore(
|
||||||
useShallow((state) => ({
|
useShallow((state) => ({
|
||||||
showReasoningTraces: state.showReasoningTraces,
|
showReasoningTraces: state.showReasoningTraces,
|
||||||
@@ -211,11 +215,11 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||||||
|
|
||||||
const sessionId = message.info.sessionID;
|
const sessionId = message.info.sessionID;
|
||||||
|
|
||||||
// Subscribe to context changes so badges update immediately on mode switches.
|
// Keep non-active-turn rows detached from context-store churn.
|
||||||
const { currentContextAgent, savedSessionAgentSelection } = useContextStore(
|
const { currentContextAgent, savedSessionAgentSelection } = useContextStore(
|
||||||
useShallow((state) => ({
|
useShallow((state) => ({
|
||||||
currentContextAgent: sessionId ? state.currentAgentContext.get(sessionId) : undefined,
|
currentContextAgent: isInActiveTurn && sessionId ? state.currentAgentContext.get(sessionId) : undefined,
|
||||||
savedSessionAgentSelection: sessionId ? state.sessionAgentSelections.get(sessionId) : undefined,
|
savedSessionAgentSelection: isInActiveTurn && sessionId ? state.sessionAgentSelections.get(sessionId) : undefined,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -607,7 +611,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||||||
}, [message.info.id]);
|
}, [message.info.id]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const headerMessageId = turnGroupingContext?.headerMessageId;
|
const headerMessageId = assistantHeaderMessageId ?? turnGroupingContext?.headerMessageId;
|
||||||
if (isUser || !headerMessageId || headerMessageId !== message.info.id) {
|
if (isUser || !headerMessageId || headerMessageId !== message.info.id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -616,13 +620,13 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||||||
if (isCurrentlyStreaming) {
|
if (isCurrentlyStreaming) {
|
||||||
setHasStartedStreamingHeader(true);
|
setHasStartedStreamingHeader(true);
|
||||||
}
|
}
|
||||||
}, [isUser, message.info.id, streamPhase, turnGroupingContext?.headerMessageId]);
|
}, [assistantHeaderMessageId, isUser, message.info.id, streamPhase, turnGroupingContext?.headerMessageId]);
|
||||||
|
|
||||||
const shouldShowHeader = React.useMemo(() => {
|
const shouldShowHeader = React.useMemo(() => {
|
||||||
if (isUser) return true;
|
if (isUser) return true;
|
||||||
|
|
||||||
// Use turn grouping context if available for more precise control
|
// Use turn grouping context if available for more precise control
|
||||||
const headerMessageId = turnGroupingContext?.headerMessageId;
|
const headerMessageId = assistantHeaderMessageId ?? turnGroupingContext?.headerMessageId;
|
||||||
if (headerMessageId) {
|
if (headerMessageId) {
|
||||||
// For turn grouping: only show header for the first assistant message in the turn
|
// For turn grouping: only show header for the first assistant message in the turn
|
||||||
const isFirstAssistantInTurn = message.info.id === headerMessageId;
|
const isFirstAssistantInTurn = message.info.id === headerMessageId;
|
||||||
@@ -644,7 +648,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||||||
|
|
||||||
// Ungrouped fallback path: always show assistant header.
|
// Ungrouped fallback path: always show assistant header.
|
||||||
return true;
|
return true;
|
||||||
}, [hasStartedStreamingHeader, isUser, turnGroupingContext, streamPhase, message.info.id]);
|
}, [assistantHeaderMessageId, hasStartedStreamingHeader, isUser, turnGroupingContext, streamPhase, message.info.id]);
|
||||||
|
|
||||||
const handleCopyCode = React.useCallback((code: string) => {
|
const handleCopyCode = React.useCallback((code: string) => {
|
||||||
void copyTextToClipboard(code).then((result) => {
|
void copyTextToClipboard(code).then((result) => {
|
||||||
@@ -1086,6 +1090,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<MessageBody
|
<MessageBody
|
||||||
|
sessionId={message.info.sessionID}
|
||||||
messageId={message.info.id}
|
messageId={message.info.id}
|
||||||
parts={visibleParts}
|
parts={visibleParts}
|
||||||
isUser={isUser}
|
isUser={isUser}
|
||||||
@@ -1131,4 +1136,15 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default React.memo(ChatMessage);
|
export default React.memo(ChatMessage, (prev, next) => {
|
||||||
|
return areRenderRelevantMessageInfoEqual(prev.message.info, next.message.info)
|
||||||
|
&& areRenderRelevantPartsEqual(prev.message.parts, next.message.parts)
|
||||||
|
&& areOptionalRenderRelevantMessagesEqual(prev.previousMessage, next.previousMessage)
|
||||||
|
&& areOptionalRenderRelevantMessagesEqual(prev.nextMessage, next.nextMessage)
|
||||||
|
&& prev.onContentChange === next.onContentChange
|
||||||
|
&& prev.turnGroupingContext === next.turnGroupingContext
|
||||||
|
&& prev.assistantHeaderMessageId === next.assistantHeaderMessageId
|
||||||
|
&& prev.isInActiveTurn === next.isInActiveTurn
|
||||||
|
&& prev.animateUserOnMount === next.animateUserOnMount
|
||||||
|
&& prev.onUserAnimationConsumed === next.onUserAnimationConsumed;
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { RiCommandLine, RiFileLine, RiFlashlightLine, RiRefreshLine, RiScissorsLine, RiTerminalBoxLine, RiArrowGoBackLine, RiArrowGoForwardLine, RiTimeLine } from '@remixicon/react';
|
import { RiCommandLine, RiFileLine, RiFlashlightLine, RiRefreshLine, RiScissorsLine, RiTerminalBoxLine, RiArrowGoBackLine, RiArrowGoForwardLine } from '@remixicon/react';
|
||||||
import { cn, fuzzyMatch } from '@/lib/utils';
|
import { cn, fuzzyMatch } from '@/lib/utils';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
|
import { useSessionMessages } from '@/sync/sync-context';
|
||||||
import { useCommandsStore } from '@/stores/useCommandsStore';
|
import { useCommandsStore } from '@/stores/useCommandsStore';
|
||||||
import { useSkillsStore } from '@/stores/useSkillsStore';
|
import { useSkillsStore } from '@/stores/useSkillsStore';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
|
||||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||||
|
|
||||||
interface CommandInfo {
|
interface CommandInfo {
|
||||||
@@ -42,16 +42,9 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
|||||||
onTabSelect,
|
onTabSelect,
|
||||||
style,
|
style,
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
const { hasMessagesInCurrentSession, currentSessionId } = useSessionStore(
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||||
useShallow((state) => {
|
const sessionMessages = useSessionMessages(currentSessionId ?? '');
|
||||||
const sessionId = state.currentSessionId;
|
const hasMessagesInCurrentSession = sessionMessages.length > 0;
|
||||||
const messageCount = sessionId ? (state.messages.get(sessionId)?.length ?? 0) : 0;
|
|
||||||
return {
|
|
||||||
hasMessagesInCurrentSession: messageCount > 0,
|
|
||||||
currentSessionId: sessionId,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
const hasSession = Boolean(currentSessionId);
|
const hasSession = Boolean(currentSessionId);
|
||||||
|
|
||||||
const [commands, setCommands] = React.useState<CommandInfo[]>([]);
|
const [commands, setCommands] = React.useState<CommandInfo[]>([]);
|
||||||
@@ -114,7 +107,6 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
|||||||
? [
|
? [
|
||||||
{ name: 'undo', description: 'Undo the last message', isBuiltIn: true },
|
{ name: 'undo', description: 'Undo the last message', isBuiltIn: true },
|
||||||
{ name: 'redo', description: 'Redo previously undone messages', isBuiltIn: true },
|
{ name: 'redo', description: 'Redo previously undone messages', isBuiltIn: true },
|
||||||
{ name: 'timeline', description: 'Jump to a specific message', isBuiltIn: true },
|
|
||||||
]
|
]
|
||||||
: []
|
: []
|
||||||
),
|
),
|
||||||
@@ -158,7 +150,6 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
|||||||
? [
|
? [
|
||||||
{ name: 'undo', description: 'Undo the last message', isBuiltIn: true },
|
{ name: 'undo', description: 'Undo the last message', isBuiltIn: true },
|
||||||
{ name: 'redo', description: 'Redo previously undone messages', isBuiltIn: true },
|
{ name: 'redo', description: 'Redo previously undone messages', isBuiltIn: true },
|
||||||
{ name: 'timeline', description: 'Jump to a specific message', isBuiltIn: true },
|
|
||||||
]
|
]
|
||||||
: []
|
: []
|
||||||
),
|
),
|
||||||
@@ -233,8 +224,6 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
|||||||
return <RiArrowGoBackLine className="h-3.5 w-3.5 text-orange-500" />;
|
return <RiArrowGoBackLine className="h-3.5 w-3.5 text-orange-500" />;
|
||||||
case 'redo':
|
case 'redo':
|
||||||
return <RiArrowGoForwardLine className="h-3.5 w-3.5 text-orange-500" />;
|
return <RiArrowGoForwardLine className="h-3.5 w-3.5 text-orange-500" />;
|
||||||
case 'timeline':
|
|
||||||
return <RiTimeLine className="h-3.5 w-3.5 text-blue-500" />;
|
|
||||||
case 'compact':
|
case 'compact':
|
||||||
return <RiScissorsLine className="h-3.5 w-3.5 text-purple-500" />;
|
return <RiScissorsLine className="h-3.5 w-3.5 text-purple-500" />;
|
||||||
case 'test':
|
case 'test':
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useRef, memo } from 'react';
|
import React, { useRef, memo } from 'react';
|
||||||
import { RiAttachment2, RiCloseLine, RiFileImageLine, RiFileLine, RiFilePdfLine, RiGithubLine, RiGitPullRequestLine } from '@remixicon/react';
|
import { RiAttachment2, RiCloseLine, RiFileImageLine, RiFileLine, RiFilePdfLine, RiGithubLine, RiGitPullRequestLine } from '@remixicon/react';
|
||||||
import { useSessionStore, type AttachedFile } from '@/stores/useSessionStore';
|
import { useInputStore } from '@/sync/input-store';
|
||||||
|
import type { AttachedFile } from '@/sync/session-ui-store';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { toast } from '@/components/ui';
|
import { toast } from '@/components/ui';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -13,7 +14,7 @@ import type { ToolPopupContent } from './message/types';
|
|||||||
|
|
||||||
export const FileAttachmentButton = memo(() => {
|
export const FileAttachmentButton = memo(() => {
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const { addAttachedFile } = useSessionStore();
|
const { addAttachedFile } = useInputStore();
|
||||||
const { isMobile } = useUIStore();
|
const { isMobile } = useUIStore();
|
||||||
const isVSCodeRuntime = useIsVSCodeRuntime();
|
const isVSCodeRuntime = useIsVSCodeRuntime();
|
||||||
const buttonSizeClass = isMobile ? 'h-9 w-9' : 'h-7 w-7';
|
const buttonSizeClass = isMobile ? 'h-9 w-9' : 'h-7 w-7';
|
||||||
@@ -255,7 +256,7 @@ const FileChip = memo(({ file, onRemove }: FileChipProps) => {
|
|||||||
FileChip.displayName = 'FileChip';
|
FileChip.displayName = 'FileChip';
|
||||||
|
|
||||||
export const AttachedFilesList = memo(() => {
|
export const AttachedFilesList = memo(() => {
|
||||||
const { attachedFiles, removeAttachedFile } = useSessionStore();
|
const { attachedFiles, removeAttachedFile } = useInputStore();
|
||||||
|
|
||||||
const localFiles = attachedFiles.filter((file) => file.source !== 'server');
|
const localFiles = attachedFiles.filter((file) => file.source !== 'server');
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,8 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
|
import { useSelectionStore } from '@/sync/selection-store';
|
||||||
import { getAgentDisplayName } from './mobileControlsUtils';
|
import { getAgentDisplayName } from './mobileControlsUtils';
|
||||||
import { getAgentColor } from '@/lib/agentColors';
|
import { getAgentColor } from '@/lib/agentColors';
|
||||||
|
|
||||||
@@ -16,8 +17,8 @@ const LONG_PRESS_MS = 500;
|
|||||||
// NOTE: Use pointer events instead of onClick to keep soft keyboard open on mobile
|
// NOTE: Use pointer events instead of onClick to keep soft keyboard open on mobile
|
||||||
export const MobileAgentButton: React.FC<MobileAgentButtonProps> = ({ onCycleAgent, onOpenAgentPanel, className }) => {
|
export const MobileAgentButton: React.FC<MobileAgentButtonProps> = ({ onCycleAgent, onOpenAgentPanel, className }) => {
|
||||||
const { currentAgentName, getVisibleAgents } = useConfigStore();
|
const { currentAgentName, getVisibleAgents } = useConfigStore();
|
||||||
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||||
const sessionAgentName = useSessionStore((state) =>
|
const sessionAgentName = useSelectionStore((state) =>
|
||||||
currentSessionId ? state.getSessionAgentSelection(currentSessionId) : null
|
currentSessionId ? state.getSessionAgentSelection(currentSessionId) : null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
|
import { useSelectionStore } from '@/sync/selection-store';
|
||||||
|
import { useSessions, useAllSessionStatuses } from '@/sync/sync-context';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||||
@@ -52,6 +54,7 @@ import { ProjectEditDialog } from '@/components/layout/ProjectEditDialog';
|
|||||||
import { useDrawerSwipe } from '@/hooks/useDrawerSwipe';
|
import { useDrawerSwipe } from '@/hooks/useDrawerSwipe';
|
||||||
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||||
|
import { useNotificationStore } from '@/sync/notification-store';
|
||||||
|
|
||||||
interface MobileSessionStatusBarProps {
|
interface MobileSessionStatusBarProps {
|
||||||
onSessionSwitch?: (sessionId: string) => void;
|
onSessionSwitch?: (sessionId: string) => void;
|
||||||
@@ -74,9 +77,10 @@ const normalize = (value: string): string => {
|
|||||||
|
|
||||||
function useSessionGrouping(
|
function useSessionGrouping(
|
||||||
sessions: Session[],
|
sessions: Session[],
|
||||||
sessionStatus: Map<string, { type: string }> | undefined,
|
sessionStatus: Record<string, { type: string }> | undefined
|
||||||
sessionAttentionStates: Map<string, { needsAttention: boolean }> | undefined
|
|
||||||
) {
|
) {
|
||||||
|
const unseenCounts = useNotificationStore((s) => s.index.session.unseenCount);
|
||||||
|
|
||||||
const parentChildMap = React.useMemo(() => {
|
const parentChildMap = React.useMemo(() => {
|
||||||
const map = new Map<string, Session[]>();
|
const map = new Map<string, Session[]>();
|
||||||
const allIds = new Set(sessions.map((s) => s.id));
|
const allIds = new Set(sessions.map((s) => s.id));
|
||||||
@@ -91,7 +95,7 @@ function useSessionGrouping(
|
|||||||
}, [sessions]);
|
}, [sessions]);
|
||||||
|
|
||||||
const getStatusType = React.useCallback((sessionId: string): 'busy' | 'retry' | 'idle' => {
|
const getStatusType = React.useCallback((sessionId: string): 'busy' | 'retry' | 'idle' => {
|
||||||
const status = sessionStatus?.get(sessionId);
|
const status = sessionStatus?.[sessionId];
|
||||||
if (status?.type === 'busy' || status?.type === 'retry') return status.type;
|
if (status?.type === 'busy' || status?.type === 'retry') return status.type;
|
||||||
return 'idle';
|
return 'idle';
|
||||||
}, [sessionStatus]);
|
}, [sessionStatus]);
|
||||||
@@ -126,7 +130,7 @@ function useSessionGrouping(
|
|||||||
topLevel.forEach((session) => {
|
topLevel.forEach((session) => {
|
||||||
const statusType = getStatusType(session.id);
|
const statusType = getStatusType(session.id);
|
||||||
const hasRunning = hasRunningChildren(session.id);
|
const hasRunning = hasRunningChildren(session.id);
|
||||||
const attention = sessionAttentionStates?.get(session.id)?.needsAttention ?? false;
|
const attention = (unseenCounts[session.id] ?? 0) > 0;
|
||||||
|
|
||||||
const enriched: SessionWithStatus = {
|
const enriched: SessionWithStatus = {
|
||||||
...session,
|
...session,
|
||||||
@@ -155,28 +159,27 @@ function useSessionGrouping(
|
|||||||
viewed.sort(sortByUpdated);
|
viewed.sort(sortByUpdated);
|
||||||
|
|
||||||
return [...running, ...viewed];
|
return [...running, ...viewed];
|
||||||
}, [sessions, getStatusType, hasRunningChildren, getRunningChildrenCount, getChildIndicators, sessionAttentionStates]);
|
}, [sessions, getStatusType, hasRunningChildren, getRunningChildrenCount, getChildIndicators, unseenCounts]);
|
||||||
|
|
||||||
const totalRunning = processedSessions.reduce((sum, s) => {
|
const totalRunning = processedSessions.reduce((sum, s) => {
|
||||||
const selfRunning = s._statusType !== 'idle' ? 1 : 0;
|
const selfRunning = s._statusType !== 'idle' ? 1 : 0;
|
||||||
return sum + selfRunning + (s._runningChildrenCount ?? 0);
|
return sum + selfRunning + (s._runningChildrenCount ?? 0);
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
const totalUnread = processedSessions.filter((s) => sessionAttentionStates?.get(s.id)?.needsAttention ?? false).length;
|
const totalUnread = processedSessions.filter((s) => (unseenCounts[s.id] ?? 0) > 0).length;
|
||||||
|
|
||||||
return { sessions: processedSessions, totalRunning, totalUnread, totalCount: processedSessions.length };
|
return { sessions: processedSessions, totalRunning, totalUnread, totalCount: processedSessions.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
function useSessionHelpers(
|
function useSessionHelpers(
|
||||||
agents: Array<{ name: string }>,
|
agents: Array<{ name: string }>,
|
||||||
sessionStatus: Map<string, { type: string }> | undefined,
|
sessionStatus: Record<string, { type: string }> | undefined
|
||||||
sessionAttentionStates: Map<string, { needsAttention: boolean }> | undefined
|
|
||||||
) {
|
) {
|
||||||
const getSessionAgentName = React.useCallback((session: Session): string => {
|
const getSessionAgentName = React.useCallback((session: Session): string => {
|
||||||
const agent = (session as { agent?: string }).agent;
|
const agent = (session as { agent?: string }).agent;
|
||||||
if (agent) return agent;
|
if (agent) return agent;
|
||||||
|
|
||||||
const sessionAgentSelection = useSessionStore.getState().getSessionAgentSelection(session.id);
|
const sessionAgentSelection = useSelectionStore.getState().getSessionAgentSelection(session.id);
|
||||||
if (sessionAgentSelection) return sessionAgentSelection;
|
if (sessionAgentSelection) return sessionAgentSelection;
|
||||||
|
|
||||||
return agents[0]?.name ?? 'agent';
|
return agents[0]?.name ?? 'agent';
|
||||||
@@ -189,14 +192,14 @@ function useSessionHelpers(
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const isRunning = React.useCallback((sessionId: string): boolean => {
|
const isRunning = React.useCallback((sessionId: string): boolean => {
|
||||||
const status = sessionStatus?.get(sessionId);
|
const status = sessionStatus?.[sessionId];
|
||||||
return status?.type === 'busy' || status?.type === 'retry';
|
return status?.type === 'busy' || status?.type === 'retry';
|
||||||
}, [sessionStatus]);
|
}, [sessionStatus]);
|
||||||
|
|
||||||
// Use server-authoritative attention state instead of local activity state
|
const unseenCounts = useNotificationStore((s) => s.index.session.unseenCount);
|
||||||
const needsAttention = React.useCallback((sessionId: string): boolean => {
|
const needsAttention = React.useCallback((sessionId: string): boolean => {
|
||||||
return sessionAttentionStates?.get(sessionId)?.needsAttention ?? false;
|
return (unseenCounts[sessionId] ?? 0) > 0;
|
||||||
}, [sessionAttentionStates]);
|
}, [unseenCounts]);
|
||||||
|
|
||||||
return { getSessionAgentName, getSessionTitle, isRunning, needsAttention };
|
return { getSessionAgentName, getSessionTitle, isRunning, needsAttention };
|
||||||
}
|
}
|
||||||
@@ -204,17 +207,16 @@ function useSessionHelpers(
|
|||||||
// Hook to calculate project status indicators
|
// Hook to calculate project status indicators
|
||||||
function useProjectStatus(
|
function useProjectStatus(
|
||||||
sessions: Session[],
|
sessions: Session[],
|
||||||
sessionStatus: Map<string, { type: string }> | undefined,
|
sessionStatus: Record<string, { type: string }> | undefined,
|
||||||
sessionAttentionStates: Map<string, { needsAttention: boolean }> | undefined,
|
|
||||||
currentSessionId: string | null
|
currentSessionId: string | null
|
||||||
) {
|
) {
|
||||||
const availableWorktreesByProject = useSessionStore((state) => state.availableWorktreesByProject);
|
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
|
||||||
const sessionsByDirectory = useSessionStore((state) => state.sessionsByDirectory);
|
const getSessionsByDirectory = useSessionUIStore((state) => state.getSessionsByDirectory);
|
||||||
const getSessionsByDirectory = useSessionStore((state) => state.getSessionsByDirectory);
|
const notifUnseenCounts = useNotificationStore((s) => s.index.session.unseenCount);
|
||||||
|
|
||||||
const projectStatusMap = React.useCallback((projectPath: string): { hasRunning: boolean; hasUnread: boolean } => {
|
const projectStatusMap = React.useCallback((projectPath: string): { hasRunning: boolean; hasUnread: boolean } => {
|
||||||
const getStatusType = (sessionId: string): 'busy' | 'retry' | 'idle' => {
|
const getStatusType = (sessionId: string): 'busy' | 'retry' | 'idle' => {
|
||||||
const status = sessionStatus?.get(sessionId);
|
const status = sessionStatus?.[sessionId];
|
||||||
if (status?.type === 'busy' || status?.type === 'retry') return status.type;
|
if (status?.type === 'busy' || status?.type === 'retry') return status.type;
|
||||||
return 'idle';
|
return 'idle';
|
||||||
};
|
};
|
||||||
@@ -241,7 +243,7 @@ function useProjectStatus(
|
|||||||
let hasUnread = false;
|
let hasUnread = false;
|
||||||
|
|
||||||
for (const dir of dirs) {
|
for (const dir of dirs) {
|
||||||
const list = sessionsByDirectory.get(dir) ?? getSessionsByDirectory(dir);
|
const list = getSessionsByDirectory(dir);
|
||||||
for (const session of list) {
|
for (const session of list) {
|
||||||
if (!session?.id || seen.has(session.id)) {
|
if (!session?.id || seen.has(session.id)) {
|
||||||
continue;
|
continue;
|
||||||
@@ -253,7 +255,7 @@ function useProjectStatus(
|
|||||||
hasRunning = true;
|
hasRunning = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.id !== currentSessionId && sessionAttentionStates?.get(session.id)?.needsAttention === true) {
|
if (session.id !== currentSessionId && (notifUnseenCounts[session.id] ?? 0) > 0) {
|
||||||
hasUnread = true;
|
hasUnread = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,7 +269,7 @@ function useProjectStatus(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { hasRunning, hasUnread };
|
return { hasRunning, hasUnread };
|
||||||
}, [sessionsByDirectory, getSessionsByDirectory, availableWorktreesByProject, sessionStatus, sessionAttentionStates, currentSessionId]);
|
}, [getSessionsByDirectory, availableWorktreesByProject, sessionStatus, notifUnseenCounts, currentSessionId]);
|
||||||
|
|
||||||
return projectStatusMap;
|
return projectStatusMap;
|
||||||
}
|
}
|
||||||
@@ -1290,7 +1292,7 @@ function ExpandedView({
|
|||||||
const [collapsedHeight, setCollapsedHeight] = React.useState<number | null>(null);
|
const [collapsedHeight, setCollapsedHeight] = React.useState<number | null>(null);
|
||||||
const [hasMeasured, setHasMeasured] = React.useState(false);
|
const [hasMeasured, setHasMeasured] = React.useState(false);
|
||||||
const { handleTouchStart, handleTouchMove, handleTouchEnd } = useDrawerSwipe();
|
const { handleTouchStart, handleTouchMove, handleTouchEnd } = useDrawerSwipe();
|
||||||
const availableWorktreesByProject = useSessionStore((state) => state.availableWorktreesByProject);
|
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (containerRef.current && !hasMeasured && !isExpanded) {
|
if (containerRef.current && !hasMeasured && !isExpanded) {
|
||||||
@@ -1429,13 +1431,12 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
|
|||||||
cornerRadius,
|
cornerRadius,
|
||||||
}) => {
|
}) => {
|
||||||
const { currentTheme } = useThemeSystem();
|
const { currentTheme } = useThemeSystem();
|
||||||
const sessions = useSessionStore((state) => state.sessions);
|
const sessions = useSessions();
|
||||||
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||||
const sessionStatus = useSessionStore((state) => state.sessionStatus);
|
const sessionStatus = useAllSessionStatuses();
|
||||||
const sessionAttentionStates = useSessionStore((state) => state.sessionAttentionStates);
|
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
|
||||||
const setCurrentSession = useSessionStore((state) => state.setCurrentSession);
|
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
|
||||||
const openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft);
|
const getContextUsage = useSessionUIStore((state) => state.getContextUsage);
|
||||||
const getContextUsage = useSessionStore((state) => state.getContextUsage);
|
|
||||||
const agents = useConfigStore((state) => state.agents);
|
const agents = useConfigStore((state) => state.agents);
|
||||||
const { getCurrentModel } = useConfigStore();
|
const { getCurrentModel } = useConfigStore();
|
||||||
const { isMobile, showMobileSessionStatusBar, isMobileSessionStatusBarCollapsed, setIsMobileSessionStatusBarCollapsed } = useUIStore();
|
const { isMobile, showMobileSessionStatusBar, isMobileSessionStatusBarCollapsed, setIsMobileSessionStatusBarCollapsed } = useUIStore();
|
||||||
@@ -1452,9 +1453,9 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
|
|||||||
// Directory store
|
// Directory store
|
||||||
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
|
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
|
||||||
|
|
||||||
const { sessions: sortedSessions, totalRunning, totalUnread, totalCount } = useSessionGrouping(sessions, sessionStatus, sessionAttentionStates);
|
const { sessions: sortedSessions, totalRunning, totalUnread, totalCount } = useSessionGrouping(sessions, sessionStatus);
|
||||||
const { getSessionAgentName, getSessionTitle, needsAttention } = useSessionHelpers(agents, sessionStatus, sessionAttentionStates);
|
const { getSessionAgentName, getSessionTitle, needsAttention } = useSessionHelpers(agents, sessionStatus);
|
||||||
const getProjectStatus = useProjectStatus(sessions, sessionStatus, sessionAttentionStates, currentSessionId);
|
const getProjectStatus = useProjectStatus(sessions, sessionStatus, currentSessionId);
|
||||||
|
|
||||||
const currentSession = sessions.find((s) => s.id === currentSessionId);
|
const currentSession = sessions.find((s) => s.id === currentSessionId);
|
||||||
const currentSessionTitle = currentSession
|
const currentSessionTitle = currentSession
|
||||||
|
|||||||
@@ -47,7 +47,10 @@ import { getEditModeColors } from '@/lib/permissions/editModeColors';
|
|||||||
import { cn, fuzzyMatch } from '@/lib/utils';
|
import { cn, fuzzyMatch } from '@/lib/utils';
|
||||||
import { useContextStore } from '@/stores/contextStore';
|
import { useContextStore } from '@/stores/contextStore';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
|
import { useSelectionStore } from '@/sync/selection-store';
|
||||||
|
import { useDirectorySync, useSessionMessages } from '@/sync/sync-context';
|
||||||
|
import { useSync } from '@/sync/use-sync';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { useModelLists } from '@/hooks/useModelLists';
|
import { useModelLists } from '@/hooks/useModelLists';
|
||||||
import { useIsTextTruncated } from '@/hooks/useIsTextTruncated';
|
import { useIsTextTruncated } from '@/hooks/useIsTextTruncated';
|
||||||
@@ -314,20 +317,23 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
const agents = getVisibleAgents();
|
const agents = getVisibleAgents();
|
||||||
const primaryAgents = React.useMemo(() => agents.filter((agent) => agent.mode === 'primary'), [agents]);
|
const primaryAgents = React.useMemo(() => agents.filter((agent) => agent.mode === 'primary'), [agents]);
|
||||||
|
|
||||||
|
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
|
||||||
|
const getDirectoryForSession = useSessionUIStore((s) => s.getDirectoryForSession);
|
||||||
|
const sync = useSync();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
currentSessionId,
|
getSessionModelSelection,
|
||||||
messages,
|
saveSessionModelSelection,
|
||||||
saveSessionAgentSelection,
|
saveSessionAgentSelection,
|
||||||
saveAgentModelForSession,
|
saveAgentModelForSession,
|
||||||
getAgentModelForSession,
|
getAgentModelForSession,
|
||||||
saveAgentModelVariantForSession,
|
saveAgentModelVariantForSession,
|
||||||
getAgentModelVariantForSession,
|
getAgentModelVariantForSession,
|
||||||
analyzeAndSaveExternalSessionChoices,
|
} = useSelectionStore();
|
||||||
} = useSessionStore();
|
|
||||||
|
|
||||||
const contextHydrated = useContextStore((state) => state.hasHydrated);
|
const contextHydrated = useContextStore((state) => state.hasHydrated);
|
||||||
|
|
||||||
const sessionSavedAgentName = useContextStore((state) =>
|
const sessionSavedAgentName = useSelectionStore((state) =>
|
||||||
currentSessionId ? state.sessionAgentSelections.get(currentSessionId) ?? null : null
|
currentSessionId ? state.sessionAgentSelections.get(currentSessionId) ?? null : null
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -563,31 +569,45 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
];
|
];
|
||||||
|
|
||||||
const prevAgentNameRef = React.useRef<string | undefined>(undefined);
|
const prevAgentNameRef = React.useRef<string | undefined>(undefined);
|
||||||
|
const latestLoadedUserChoiceRestoreRef = React.useRef<string | null>(null);
|
||||||
|
|
||||||
const currentSessionMessageCount = currentSessionId ? (messages.get(currentSessionId)?.length ?? -1) : -1;
|
const currentSessionDirectory = currentSessionId ? getDirectoryForSession(currentSessionId) : undefined;
|
||||||
|
const hasCurrentSessionMessagesEntry = useDirectorySync(
|
||||||
|
React.useCallback(
|
||||||
|
(state) => (currentSessionId ? state.message[currentSessionId] !== undefined : false),
|
||||||
|
[currentSessionId],
|
||||||
|
),
|
||||||
|
currentSessionDirectory ?? undefined,
|
||||||
|
);
|
||||||
|
const currentSessionMessagesFromSync = useSessionMessages(currentSessionId ?? '', currentSessionDirectory ?? undefined);
|
||||||
|
const latestLoadedUserChoice = React.useMemo(() => {
|
||||||
|
for (let i = currentSessionMessagesFromSync.length - 1; i >= 0; i -= 1) {
|
||||||
|
const message = currentSessionMessagesFromSync[i] as typeof currentSessionMessagesFromSync[number] & {
|
||||||
|
model?: { providerID?: string; modelID?: string };
|
||||||
|
variant?: string;
|
||||||
|
mode?: string;
|
||||||
|
};
|
||||||
|
if (message.role !== 'user') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const sessionInitializationRef = React.useRef<{
|
const providerID = typeof message.model?.providerID === 'string' && message.model.providerID.trim().length > 0
|
||||||
sessionId: string;
|
? message.model.providerID
|
||||||
resolved: boolean;
|
: undefined;
|
||||||
inFlight: boolean;
|
const modelID = typeof message.model?.modelID === 'string' && message.model.modelID.trim().length > 0
|
||||||
} | null>(null);
|
? message.model.modelID
|
||||||
|
: undefined;
|
||||||
|
const agent = typeof message.agent === 'string' && message.agent.trim().length > 0
|
||||||
|
? message.agent
|
||||||
|
: (typeof message.mode === 'string' && message.mode.trim().length > 0 ? message.mode : undefined);
|
||||||
|
const variant = typeof message.variant === 'string' && message.variant.trim().length > 0
|
||||||
|
? message.variant
|
||||||
|
: undefined;
|
||||||
|
|
||||||
// If we have an explicit per-session agent selection (eg. server-injected mode switch),
|
return { id: message.id, agent, providerID, modelID, variant };
|
||||||
// treat the session as resolved and don't run inference/fallback that could cause flicker.
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!currentSessionId) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
const refState = sessionInitializationRef.current;
|
return null;
|
||||||
if (!refState || refState.sessionId !== currentSessionId) {
|
}, [currentSessionMessagesFromSync]);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sessionSavedAgentName && agents.some((agent) => agent.name === sessionSavedAgentName)) {
|
|
||||||
refState.resolved = true;
|
|
||||||
refState.inFlight = false;
|
|
||||||
}
|
|
||||||
}, [agents, currentSessionId, sessionSavedAgentName]);
|
|
||||||
|
|
||||||
const tryApplyModelSelection = React.useCallback(
|
const tryApplyModelSelection = React.useCallback(
|
||||||
(providerId: string, modelId: string, agentName?: string): ModelApplyResult => {
|
(providerId: string, modelId: string, agentName?: string): ModelApplyResult => {
|
||||||
@@ -606,21 +626,93 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
return 'model-missing';
|
return 'model-missing';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const providerMatches = currentProviderId === providerId;
|
||||||
|
const modelMatches = currentModelId === modelId;
|
||||||
|
if (providerMatches && modelMatches) {
|
||||||
|
return 'applied';
|
||||||
|
}
|
||||||
|
|
||||||
setProvider(providerId);
|
setProvider(providerId);
|
||||||
setModel(modelId);
|
setModel(modelId);
|
||||||
|
|
||||||
if (currentSessionId && agentName) {
|
if (currentSessionId) {
|
||||||
saveAgentModelForSession(currentSessionId, agentName, providerId, modelId);
|
saveSessionModelSelection(currentSessionId, providerId, modelId);
|
||||||
|
if (agentName) {
|
||||||
|
saveAgentModelForSession(currentSessionId, agentName, providerId, modelId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'applied';
|
return 'applied';
|
||||||
},
|
},
|
||||||
[providers, setProvider, setModel, currentSessionId, saveAgentModelForSession],
|
[providers, currentProviderId, currentModelId, setProvider, setModel, currentSessionId, saveAgentModelForSession, saveSessionModelSelection],
|
||||||
);
|
);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!currentSessionId) {
|
if (!currentSessionId) {
|
||||||
sessionInitializationRef.current = null;
|
latestLoadedUserChoiceRestoreRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!contextHydrated || providers.length === 0 || !hasCurrentSessionMessagesEntry || !latestLoadedUserChoice?.providerID || !latestLoadedUserChoice.modelID) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const restoreKey = [
|
||||||
|
currentSessionId,
|
||||||
|
latestLoadedUserChoice.id,
|
||||||
|
latestLoadedUserChoice.agent ?? '',
|
||||||
|
latestLoadedUserChoice.providerID,
|
||||||
|
latestLoadedUserChoice.modelID,
|
||||||
|
latestLoadedUserChoice.variant ?? '',
|
||||||
|
].join('|');
|
||||||
|
|
||||||
|
if (latestLoadedUserChoiceRestoreRef.current === restoreKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (latestLoadedUserChoice.agent && currentAgentName !== latestLoadedUserChoice.agent) {
|
||||||
|
setAgent(latestLoadedUserChoice.agent);
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyResult = tryApplyModelSelection(
|
||||||
|
latestLoadedUserChoice.providerID,
|
||||||
|
latestLoadedUserChoice.modelID,
|
||||||
|
latestLoadedUserChoice.agent || currentAgentName || undefined,
|
||||||
|
);
|
||||||
|
if (applyResult !== 'applied') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (latestLoadedUserChoice.agent) {
|
||||||
|
saveSessionAgentSelection(currentSessionId, latestLoadedUserChoice.agent);
|
||||||
|
saveAgentModelVariantForSession(
|
||||||
|
currentSessionId,
|
||||||
|
latestLoadedUserChoice.agent,
|
||||||
|
latestLoadedUserChoice.providerID,
|
||||||
|
latestLoadedUserChoice.modelID,
|
||||||
|
latestLoadedUserChoice.variant,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
saveSessionModelSelection(currentSessionId, latestLoadedUserChoice.providerID, latestLoadedUserChoice.modelID);
|
||||||
|
latestLoadedUserChoiceRestoreRef.current = restoreKey;
|
||||||
|
|
||||||
|
}, [
|
||||||
|
currentSessionId,
|
||||||
|
currentAgentName,
|
||||||
|
contextHydrated,
|
||||||
|
providers,
|
||||||
|
hasCurrentSessionMessagesEntry,
|
||||||
|
latestLoadedUserChoice,
|
||||||
|
setAgent,
|
||||||
|
tryApplyModelSelection,
|
||||||
|
saveSessionAgentSelection,
|
||||||
|
saveAgentModelVariantForSession,
|
||||||
|
saveSessionModelSelection,
|
||||||
|
]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!currentSessionId) {
|
||||||
|
latestLoadedUserChoiceRestoreRef.current = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -628,31 +720,10 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!sessionInitializationRef.current || sessionInitializationRef.current.sessionId !== currentSessionId) {
|
|
||||||
sessionInitializationRef.current = { sessionId: currentSessionId, resolved: false, inFlight: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
const state = sessionInitializationRef.current;
|
|
||||||
if (!state || state.resolved || state.inFlight) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let isCancelled = false;
|
|
||||||
|
|
||||||
const finalize = () => {
|
|
||||||
if (isCancelled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const refState = sessionInitializationRef.current;
|
|
||||||
if (refState && refState.sessionId === currentSessionId) {
|
|
||||||
refState.resolved = true;
|
|
||||||
refState.inFlight = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const applySavedSelections = (): 'resolved' | 'waiting' | 'continue' => {
|
const applySavedSelections = (): 'resolved' | 'waiting' | 'continue' => {
|
||||||
|
const savedSessionModel = getSessionModelSelection(currentSessionId);
|
||||||
const savedAgentName = currentSessionId
|
const savedAgentName = currentSessionId
|
||||||
? (useContextStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current)
|
? useSelectionStore.getState().getSessionAgentSelection(currentSessionId)
|
||||||
: null;
|
: null;
|
||||||
if (savedAgentName) {
|
if (savedAgentName) {
|
||||||
if (currentAgentName !== savedAgentName) {
|
if (currentAgentName !== savedAgentName) {
|
||||||
@@ -668,9 +739,17 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
if (result === 'provider-missing') {
|
if (result === 'provider-missing') {
|
||||||
return 'waiting';
|
return 'waiting';
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (savedSessionModel) {
|
||||||
|
const result = tryApplyModelSelection(savedSessionModel.providerId, savedSessionModel.modelId, savedAgentName || currentAgentName || undefined);
|
||||||
|
if (result === 'applied') {
|
||||||
return 'resolved';
|
return 'resolved';
|
||||||
}
|
}
|
||||||
|
if (result === 'provider-missing') {
|
||||||
|
return 'waiting';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const agent of agents) {
|
for (const agent of agents) {
|
||||||
@@ -683,7 +762,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
setAgent(agent.name);
|
setAgent(agent.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingSelection = useContextStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current;
|
const existingSelection = useSelectionStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current;
|
||||||
if (!existingSelection) {
|
if (!existingSelection) {
|
||||||
saveSessionAgentSelection(currentSessionId, agent.name);
|
saveSessionAgentSelection(currentSessionId, agent.name);
|
||||||
}
|
}
|
||||||
@@ -705,14 +784,14 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const existingSelection = currentSessionId
|
const existingSelection = currentSessionId
|
||||||
? (useContextStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current)
|
? (useSelectionStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// If we already have a valid agent selected (often from server-injected mode switch),
|
// If we already have a valid agent selected (often from server-injected mode switch),
|
||||||
// don't override it with a fallback.
|
// don't override it with a fallback.
|
||||||
const preferred =
|
const preferred =
|
||||||
(currentSessionId
|
(currentSessionId
|
||||||
? (useContextStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current)
|
? (useSelectionStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current)
|
||||||
: null) ||
|
: null) ||
|
||||||
currentAgentName;
|
currentAgentName;
|
||||||
if (preferred && agents.some((agent) => agent.name === preferred)) {
|
if (preferred && agents.some((agent) => agent.name === preferred)) {
|
||||||
@@ -740,174 +819,38 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveSessionPreferences = async () => {
|
const savedOutcome = applySavedSelections();
|
||||||
try {
|
if (savedOutcome === 'resolved' || savedOutcome === 'waiting') {
|
||||||
const savedOutcome = applySavedSelections();
|
return;
|
||||||
if (savedOutcome === 'resolved') {
|
}
|
||||||
finalize();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (savedOutcome === 'waiting') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentSessionMessageCount === -1) {
|
if (!hasCurrentSessionMessagesEntry) {
|
||||||
return;
|
if (!sync.isLoading(currentSessionId)) {
|
||||||
}
|
void sync.syncSession(currentSessionId);
|
||||||
|
|
||||||
if (currentSessionMessageCount > 0) {
|
|
||||||
state.inFlight = true;
|
|
||||||
try {
|
|
||||||
const discoveredChoices = await analyzeAndSaveExternalSessionChoices(currentSessionId, agents);
|
|
||||||
if (isCancelled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (discoveredChoices.size > 0) {
|
|
||||||
let latestAgent: string | null = null;
|
|
||||||
let latestTimestamp = -Infinity;
|
|
||||||
|
|
||||||
for (const [agentName, choice] of discoveredChoices) {
|
|
||||||
if (choice.timestamp > latestTimestamp) {
|
|
||||||
latestTimestamp = choice.timestamp;
|
|
||||||
latestAgent = agentName;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (latestAgent) {
|
|
||||||
// If server/user already selected an agent for this session, don't override
|
|
||||||
// with heuristic inference mid-stream.
|
|
||||||
const latestSaved = useContextStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current;
|
|
||||||
if (latestSaved && latestSaved !== latestAgent) {
|
|
||||||
finalize();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!latestSaved) {
|
|
||||||
saveSessionAgentSelection(currentSessionId, latestAgent);
|
|
||||||
}
|
|
||||||
if (currentAgentName !== latestAgent) {
|
|
||||||
setAgent(latestAgent);
|
|
||||||
}
|
|
||||||
|
|
||||||
const latestChoice = discoveredChoices.get(latestAgent);
|
|
||||||
if (latestChoice) {
|
|
||||||
const applyResult = tryApplyModelSelection(
|
|
||||||
latestChoice.providerId,
|
|
||||||
latestChoice.modelId,
|
|
||||||
latestAgent,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (applyResult === 'applied') {
|
|
||||||
finalize();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (applyResult === 'provider-missing') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
finalize();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (!isCancelled) {
|
|
||||||
console.error('[ModelControls] Error resolving session from messages:', error);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
const refState = sessionInitializationRef.current;
|
|
||||||
if (!isCancelled && refState && refState.sessionId === currentSessionId) {
|
|
||||||
refState.inFlight = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isCancelled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
applyFallbackAgent();
|
|
||||||
finalize();
|
|
||||||
} catch (error) {
|
|
||||||
if (!isCancelled) {
|
|
||||||
console.error('[ModelControls] Error in session switch:', error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
resolveSessionPreferences();
|
if (latestLoadedUserChoice) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
applyFallbackAgent();
|
||||||
isCancelled = true;
|
|
||||||
};
|
|
||||||
}, [
|
}, [
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
currentSessionMessageCount,
|
hasCurrentSessionMessagesEntry,
|
||||||
|
latestLoadedUserChoice,
|
||||||
agents,
|
agents,
|
||||||
primaryAgents,
|
primaryAgents,
|
||||||
currentAgentName,
|
currentAgentName,
|
||||||
|
getSessionModelSelection,
|
||||||
getAgentModelForSession,
|
getAgentModelForSession,
|
||||||
setAgent,
|
setAgent,
|
||||||
tryApplyModelSelection,
|
tryApplyModelSelection,
|
||||||
analyzeAndSaveExternalSessionChoices,
|
|
||||||
saveSessionAgentSelection,
|
saveSessionAgentSelection,
|
||||||
contextHydrated,
|
contextHydrated,
|
||||||
providers,
|
providers,
|
||||||
sessionSavedAgentName,
|
sync,
|
||||||
]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!contextHydrated || !currentSessionId || providers.length === 0 || agents.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const preferredAgent = sessionSavedAgentName || currentAgentName;
|
|
||||||
if (!preferredAgent) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const preferredSelection = getAgentModelForSession(currentSessionId, preferredAgent);
|
|
||||||
if (!preferredSelection) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const provider = providers.find(p => p.id === preferredSelection.providerId);
|
|
||||||
if (!provider) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const modelExists = Array.isArray(provider.models)
|
|
||||||
? provider.models.some((m: ProviderModel) => m.id === preferredSelection.modelId)
|
|
||||||
: false;
|
|
||||||
if (!modelExists) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const providerMatches = currentProviderId === preferredSelection.providerId;
|
|
||||||
const modelMatches = currentModelId === preferredSelection.modelId;
|
|
||||||
if (providerMatches && modelMatches) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (preferredAgent !== currentAgentName) {
|
|
||||||
setAgent(preferredAgent);
|
|
||||||
}
|
|
||||||
|
|
||||||
tryApplyModelSelection(preferredSelection.providerId, preferredSelection.modelId, preferredAgent);
|
|
||||||
}, [
|
|
||||||
contextHydrated,
|
|
||||||
currentSessionId,
|
|
||||||
currentAgentName,
|
|
||||||
currentProviderId,
|
|
||||||
currentModelId,
|
|
||||||
providers,
|
|
||||||
agents,
|
|
||||||
getAgentModelForSession,
|
|
||||||
tryApplyModelSelection,
|
|
||||||
setAgent,
|
|
||||||
sessionSavedAgentName,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import React from 'react';
|
|||||||
import { RiCheckLine, RiCloseLine, RiFileEditLine, RiGlobalLine, RiPencilAiLine, RiQuestionLine, RiTerminalBoxLine, RiTimeLine, RiToolsLine } from '@remixicon/react';
|
import { RiCheckLine, RiCloseLine, RiFileEditLine, RiGlobalLine, RiPencilAiLine, RiQuestionLine, RiTerminalBoxLine, RiTimeLine, RiToolsLine } from '@remixicon/react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { PermissionRequest, PermissionResponse } from '@/types/permission';
|
import type { PermissionRequest, PermissionResponse } from '@/types/permission';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
|
import { useSessions } from '@/sync/sync-context';
|
||||||
|
import * as sessionActions from '@/sync/session-actions';
|
||||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||||
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
|
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
|
||||||
@@ -62,15 +64,14 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [isResponding, setIsResponding] = React.useState(false);
|
const [isResponding, setIsResponding] = React.useState(false);
|
||||||
const [hasResponded, setHasResponded] = React.useState(false);
|
const [hasResponded, setHasResponded] = React.useState(false);
|
||||||
const { respondToPermission } = useSessionStore();
|
const respondToPermission = sessionActions.respondToPermission;;
|
||||||
const isFromSubagent = useSessionStore(
|
const sessions = useSessions();
|
||||||
React.useCallback((state) => {
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||||
const currentSessionId = state.currentSessionId;
|
const isFromSubagent = React.useMemo(() => {
|
||||||
if (!currentSessionId || permission.sessionID === currentSessionId) return false;
|
if (!currentSessionId || permission.sessionID === currentSessionId) return false;
|
||||||
const sourceSession = state.sessions.find((session) => session.id === permission.sessionID);
|
const sourceSession = sessions.find((session) => session.id === permission.sessionID);
|
||||||
return Boolean(sourceSession?.parentID && sourceSession.parentID === currentSessionId);
|
return Boolean(sourceSession?.parentID && sourceSession.parentID === currentSessionId);
|
||||||
}, [permission.sessionID])
|
}, [permission.sessionID, currentSessionId, sessions]);
|
||||||
);
|
|
||||||
const { currentTheme } = useThemeSystem();
|
const { currentTheme } = useThemeSystem();
|
||||||
const syntaxTheme = React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]);
|
const syntaxTheme = React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]);
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React from 'react';
|
|||||||
import { RiCheckLine, RiCloseLine, RiTimeLine } from '@remixicon/react';
|
import { RiCheckLine, RiCloseLine, RiTimeLine } from '@remixicon/react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { PermissionRequest as PermissionRequestPayload, PermissionResponse } from '@/types/permission';
|
import type { PermissionRequest as PermissionRequestPayload, PermissionResponse } from '@/types/permission';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import * as sessionActions from '@/sync/session-actions';
|
||||||
|
|
||||||
interface PermissionRequestProps {
|
interface PermissionRequestProps {
|
||||||
permission: PermissionRequestPayload;
|
permission: PermissionRequestPayload;
|
||||||
@@ -15,7 +15,7 @@ export const PermissionRequest: React.FC<PermissionRequestProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [isResponding, setIsResponding] = React.useState(false);
|
const [isResponding, setIsResponding] = React.useState(false);
|
||||||
const [hasResponded, setHasResponded] = React.useState(false);
|
const [hasResponded, setHasResponded] = React.useState(false);
|
||||||
const { respondToPermission } = useSessionStore();
|
const respondToPermission = sessionActions.respondToPermission;;
|
||||||
|
|
||||||
const handleResponse = async (response: PermissionResponse) => {
|
const handleResponse = async (response: PermissionResponse) => {
|
||||||
setIsResponding(true);
|
setIsResponding(true);
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import { Checkbox } from '@/components/ui/checkbox';
|
|||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { QuestionRequest } from '@/types/question';
|
import type { QuestionRequest } from '@/types/question';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
|
import { useSessions } from '@/sync/sync-context';
|
||||||
|
import * as sessionActions from '@/sync/session-actions';
|
||||||
|
|
||||||
interface QuestionCardProps {
|
interface QuestionCardProps {
|
||||||
question: QuestionRequest;
|
question: QuestionRequest;
|
||||||
@@ -14,15 +16,15 @@ type TabKey = string;
|
|||||||
const SUMMARY_TAB = 'summary';
|
const SUMMARY_TAB = 'summary';
|
||||||
|
|
||||||
export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
|
export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
|
||||||
const { respondToQuestion, rejectQuestion } = useSessionStore();
|
const respondToQuestion = sessionActions.respondToQuestion;
|
||||||
const isFromSubagent = useSessionStore(
|
const rejectQuestion = sessionActions.rejectQuestion;;
|
||||||
React.useCallback((state) => {
|
const sessions = useSessions();
|
||||||
const currentSessionId = state.currentSessionId;
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||||
if (!currentSessionId || question.sessionID === currentSessionId) return false;
|
const isFromSubagent = React.useMemo(() => {
|
||||||
const sourceSession = state.sessions.find((session) => session.id === question.sessionID);
|
if (!currentSessionId || question.sessionID === currentSessionId) return false;
|
||||||
return Boolean(sourceSession?.parentID && sourceSession.parentID === currentSessionId);
|
const sourceSession = sessions.find((session) => session.id === question.sessionID);
|
||||||
}, [question.sessionID])
|
return Boolean(sourceSession?.parentID && sourceSession.parentID === currentSessionId);
|
||||||
);
|
}, [question.sessionID, currentSessionId, sessions]);
|
||||||
const [activeTab, setActiveTab] = React.useState<TabKey>('0');
|
const [activeTab, setActiveTab] = React.useState<TabKey>('0');
|
||||||
const [isResponding, setIsResponding] = React.useState(false);
|
const [isResponding, setIsResponding] = React.useState(false);
|
||||||
const [hasResponded, setHasResponded] = React.useState(false);
|
const [hasResponded, setHasResponded] = React.useState(false);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React, { memo } from 'react';
|
import React, { memo } from 'react';
|
||||||
import { RiCloseLine, RiMessage2Line } from '@remixicon/react';
|
import { RiCloseLine, RiMessage2Line } from '@remixicon/react';
|
||||||
import { useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore';
|
import { useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
import { useFileStore } from '@/stores/fileStore';
|
import { useInputStore } from '@/sync/input-store';
|
||||||
|
|
||||||
interface QueuedMessageChipProps {
|
interface QueuedMessageChipProps {
|
||||||
message: QueuedMessage;
|
message: QueuedMessage;
|
||||||
@@ -67,7 +67,7 @@ interface QueuedMessageChipsProps {
|
|||||||
const EMPTY_QUEUE: QueuedMessage[] = [];
|
const EMPTY_QUEUE: QueuedMessage[] = [];
|
||||||
|
|
||||||
export const QueuedMessageChips = memo(({ onEditMessage }: QueuedMessageChipsProps) => {
|
export const QueuedMessageChips = memo(({ onEditMessage }: QueuedMessageChipsProps) => {
|
||||||
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||||
const queuedMessages = useMessageQueueStore(
|
const queuedMessages = useMessageQueueStore(
|
||||||
React.useCallback(
|
React.useCallback(
|
||||||
(state) => {
|
(state) => {
|
||||||
@@ -84,10 +84,9 @@ export const QueuedMessageChips = memo(({ onEditMessage }: QueuedMessageChipsPro
|
|||||||
|
|
||||||
const popped = popToInput(currentSessionId, message.id);
|
const popped = popToInput(currentSessionId, message.id);
|
||||||
if (popped) {
|
if (popped) {
|
||||||
// Restore attachments to file store if any
|
|
||||||
if (popped.attachments && popped.attachments.length > 0) {
|
if (popped.attachments && popped.attachments.length > 0) {
|
||||||
const currentAttachments = useFileStore.getState().attachedFiles;
|
const currentAttachments = useInputStore.getState().attachedFiles;
|
||||||
useFileStore.setState({
|
useInputStore.setState({
|
||||||
attachedFiles: [...currentAttachments, ...popped.attachments]
|
attachedFiles: [...currentAttachments, ...popped.attachments]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
import { useContextStore } from '@/stores/contextStore';
|
import { useContextStore } from '@/stores/contextStore';
|
||||||
import { formatEffortLabel, getAgentDisplayName, getModelDisplayName } from './mobileControlsUtils';
|
import { formatEffortLabel, getAgentDisplayName, getModelDisplayName } from './mobileControlsUtils';
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ export const StatusChip: React.FC<StatusChipProps> = ({ onClick, className }) =>
|
|||||||
getCurrentModelVariants,
|
getCurrentModelVariants,
|
||||||
getVisibleAgents,
|
getVisibleAgents,
|
||||||
} = useConfigStore();
|
} = useConfigStore();
|
||||||
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||||
const sessionAgentName = useContextStore((state) =>
|
const sessionAgentName = useContextStore((state) =>
|
||||||
currentSessionId ? state.getSessionAgentSelection(currentSessionId) : null
|
currentSessionId ? state.getSessionAgentSelection(currentSessionId) : null
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
import {
|
import {
|
||||||
RiArrowDownSLine,
|
RiArrowDownSLine,
|
||||||
RiArrowUpDoubleLine,
|
RiArrowUpDoubleLine,
|
||||||
@@ -9,8 +10,13 @@ import {
|
|||||||
RiTimeLine,
|
RiTimeLine,
|
||||||
} from "@remixicon/react";
|
} from "@remixicon/react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useTodoStore, type TodoItem, type TodoPriority, type TodoStatus } from "@/stores/useTodoStore";
|
import { useDirectorySync } from "@/sync/sync-context";
|
||||||
import { useSessionStore } from "@/stores/useSessionStore";
|
import type { Todo } from "@opencode-ai/sdk/v2/client";
|
||||||
|
|
||||||
|
// Compat aliases for old TodoItem shape
|
||||||
|
type TodoItem = Todo & { id?: string };
|
||||||
|
type TodoStatus = string;
|
||||||
|
type TodoPriority = string;
|
||||||
import { useUIStore } from "@/stores/useUIStore";
|
import { useUIStore } from "@/stores/useUIStore";
|
||||||
import { WorkingPlaceholder } from "./message/parts/WorkingPlaceholder";
|
import { WorkingPlaceholder } from "./message/parts/WorkingPlaceholder";
|
||||||
import { isVSCodeRuntime } from "@/lib/desktop";
|
import { isVSCodeRuntime } from "@/lib/desktop";
|
||||||
@@ -146,21 +152,15 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
|||||||
agentName,
|
agentName,
|
||||||
}) => {
|
}) => {
|
||||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||||
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||||
const todos = useTodoStore((state) =>
|
const todosRecord = useDirectorySync((state) => state.todo);
|
||||||
currentSessionId ? state.sessionTodos.get(currentSessionId) ?? EMPTY_TODOS : EMPTY_TODOS
|
const todos: TodoItem[] = React.useMemo(
|
||||||
|
() => (currentSessionId ? todosRecord[currentSessionId] ?? EMPTY_TODOS : EMPTY_TODOS),
|
||||||
|
[todosRecord, currentSessionId],
|
||||||
);
|
);
|
||||||
const loadTodos = useTodoStore((state) => state.loadTodos);
|
|
||||||
const { isMobile } = useUIStore();
|
const { isMobile } = useUIStore();
|
||||||
const isCompact = isMobile || isVSCodeRuntime();
|
const isCompact = isMobile || isVSCodeRuntime();
|
||||||
|
|
||||||
// Load todos when session changes
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (currentSessionId) {
|
|
||||||
void loadTodos(currentSessionId);
|
|
||||||
}
|
|
||||||
}, [currentSessionId, loadTodos]);
|
|
||||||
|
|
||||||
// Filter out cancelled todos for display and keep original order.
|
// Filter out cancelled todos for display and keep original order.
|
||||||
// This prevents items from jumping around when status changes.
|
// This prevents items from jumping around when status changes.
|
||||||
const visibleTodos = React.useMemo(() => {
|
const visibleTodos = React.useMemo(() => {
|
||||||
@@ -313,8 +313,8 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
|||||||
|
|
||||||
{/* Todo list */}
|
{/* Todo list */}
|
||||||
<div className="px-3 py-2 max-h-[200px] overflow-y-auto divide-y divide-border">
|
<div className="px-3 py-2 max-h-[200px] overflow-y-auto divide-y divide-border">
|
||||||
{visibleTodos.map((todo) => (
|
{visibleTodos.map((todo, index) => (
|
||||||
<TodoItemRow key={todo.id} todo={todo} />
|
<TodoItemRow key={todo.id ?? `todo-${index}`} todo={todo} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useAssistantStatus } from '@/hooks/useAssistantStatus';
|
||||||
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
|
import { StatusRow } from './StatusRow';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Self-contained wrapper — subscribes to assistant status internally
|
||||||
|
* so MessageList doesn't re-render on every streaming part delta.
|
||||||
|
*/
|
||||||
|
export const StatusRowContainer: React.FC = React.memo(() => {
|
||||||
|
const { working } = useAssistantStatus();
|
||||||
|
const currentAgentName = useConfigStore((state) => state.currentAgentName);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StatusRow
|
||||||
|
isWorking={working.isWorking}
|
||||||
|
statusText={working.statusText}
|
||||||
|
isGenericStatus={working.isGenericStatus}
|
||||||
|
isWaitingForPermission={working.isWaitingForPermission}
|
||||||
|
wasAborted={working.wasAborted}
|
||||||
|
abortActive={working.abortActive}
|
||||||
|
retryInfo={working.retryInfo}
|
||||||
|
showAssistantStatus
|
||||||
|
showTodos={false}
|
||||||
|
agentName={currentAgentName}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
StatusRowContainer.displayName = 'StatusRowContainer';
|
||||||
@@ -7,8 +7,8 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
import { useMessageStore } from '@/stores/messageStore';
|
import { useSessionMessageRecords } from '@/sync/sync-context';
|
||||||
import { RiLoader4Line, RiSearchLine, RiTimeLine, RiGitBranchLine, RiArrowGoBackLine } from '@remixicon/react';
|
import { RiLoader4Line, RiSearchLine, RiTimeLine, RiGitBranchLine, RiArrowGoBackLine } from '@remixicon/react';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import type { Part } from '@opencode-ai/sdk/v2';
|
import type { Part } from '@opencode-ai/sdk/v2';
|
||||||
@@ -44,13 +44,10 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
|
|||||||
onScrollByTurnOffset,
|
onScrollByTurnOffset,
|
||||||
onResumeToLatest,
|
onResumeToLatest,
|
||||||
}) => {
|
}) => {
|
||||||
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||||
const messages = useMessageStore((state) =>
|
const messages = useSessionMessageRecords(currentSessionId ?? '');
|
||||||
currentSessionId ? state.messages.get(currentSessionId) || [] : []
|
const revertToMessage = useSessionUIStore((state) => state.revertToMessage);
|
||||||
);
|
const forkFromMessage = useSessionUIStore((state) => state.forkFromMessage);
|
||||||
const revertToMessage = useSessionStore((state) => state.revertToMessage);
|
|
||||||
const forkFromMessage = useSessionStore((state) => state.forkFromMessage);
|
|
||||||
const loadSessions = useSessionStore((state) => state.loadSessions);
|
|
||||||
|
|
||||||
const [forkingMessageId, setForkingMessageId] = React.useState<string | null>(null);
|
const [forkingMessageId, setForkingMessageId] = React.useState<string | null>(null);
|
||||||
const [searchQuery, setSearchQuery] = React.useState('');
|
const [searchQuery, setSearchQuery] = React.useState('');
|
||||||
@@ -78,7 +75,6 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
|
|||||||
setForkingMessageId(messageId);
|
setForkingMessageId(messageId);
|
||||||
try {
|
try {
|
||||||
await forkFromMessage(currentSessionId, messageId);
|
await forkFromMessage(currentSessionId, messageId);
|
||||||
await loadSessions();
|
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
} finally {
|
} finally {
|
||||||
setForkingMessageId(null);
|
setForkingMessageId(null);
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
|||||||
import { ProviderLogo } from '@/components/ui/ProviderLogo';
|
import { ProviderLogo } from '@/components/ui/ProviderLogo';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
|
import { useSelectionStore } from '@/sync/selection-store';
|
||||||
import { useContextStore } from '@/stores/contextStore';
|
import { useContextStore } from '@/stores/contextStore';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { useModelLists } from '@/hooks/useModelLists';
|
import { useModelLists } from '@/hooks/useModelLists';
|
||||||
@@ -55,11 +56,8 @@ export const UnifiedControlsDrawer: React.FC<UnifiedControlsDrawerProps> = ({
|
|||||||
} = useConfigStore();
|
} = useConfigStore();
|
||||||
const { addRecentModel, addRecentEffort, recentEfforts } = useUIStore();
|
const { addRecentModel, addRecentEffort, recentEfforts } = useUIStore();
|
||||||
const { recentModelsList } = useModelLists();
|
const { recentModelsList } = useModelLists();
|
||||||
const {
|
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
|
||||||
currentSessionId,
|
const { saveAgentModelForSession, saveAgentModelVariantForSession } = useSelectionStore();
|
||||||
saveAgentModelForSession,
|
|
||||||
saveAgentModelVariantForSession,
|
|
||||||
} = useSessionStore();
|
|
||||||
const sessionAgentName = useContextStore((state) =>
|
const sessionAgentName = useContextStore((state) =>
|
||||||
currentSessionId ? state.getSessionAgentSelection(currentSessionId) : null
|
currentSessionId ? state.getSessionAgentSelection(currentSessionId) : null
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,7 +10,18 @@ interface TurnListProps<TEntry extends TurnListEntry> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TurnList = <TEntry extends TurnListEntry>({ entries, renderEntry }: TurnListProps<TEntry>): React.ReactElement => {
|
const TurnList = <TEntry extends TurnListEntry>({ entries, renderEntry }: TurnListProps<TEntry>): React.ReactElement => {
|
||||||
return <>{entries.map((entry) => renderEntry(entry))}</>;
|
return (
|
||||||
|
<>
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<div
|
||||||
|
key={entry.key}
|
||||||
|
data-turn-entry={entry.key}
|
||||||
|
>
|
||||||
|
{renderEntry(entry)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default React.memo(TurnList) as typeof TurnList;
|
export default React.memo(TurnList) as typeof TurnList;
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ export const useChatTimelineController = ({
|
|||||||
historyMetaRef.current = historyMeta;
|
historyMetaRef.current = historyMeta;
|
||||||
}, [historyMeta]);
|
}, [historyMeta]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useLayoutEffect(() => {
|
||||||
if (initializedSessionRef.current === sessionId) {
|
if (initializedSessionRef.current === sessionId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -153,11 +153,11 @@ export const useChatTimelineController = ({
|
|||||||
previousTurnCountRef.current = turnWindowModel.turnCount;
|
previousTurnCountRef.current = turnWindowModel.turnCount;
|
||||||
}, [sessionId, turnWindowModel.turnCount]);
|
}, [sessionId, turnWindowModel.turnCount]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useLayoutEffect(() => {
|
||||||
setTurnStart((current) => clampTurnStart(current, turnWindowModel.turnCount));
|
setTurnStart((current) => clampTurnStart(current, turnWindowModel.turnCount));
|
||||||
}, [turnWindowModel.turnCount]);
|
}, [turnWindowModel.turnCount]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useLayoutEffect(() => {
|
||||||
const previousTurnCount = previousTurnCountRef.current;
|
const previousTurnCount = previousTurnCountRef.current;
|
||||||
const nextTurnCount = turnWindowModel.turnCount;
|
const nextTurnCount = turnWindowModel.turnCount;
|
||||||
if (previousTurnCount === nextTurnCount) {
|
if (previousTurnCount === nextTurnCount) {
|
||||||
@@ -180,6 +180,42 @@ export const useChatTimelineController = ({
|
|||||||
return windowMessagesByTurn(messages, turnWindowModel, turnStart);
|
return windowMessagesByTurn(messages, turnWindowModel, turnStart);
|
||||||
}, [messages, turnStart, turnWindowModel]);
|
}, [messages, turnStart, turnWindowModel]);
|
||||||
|
|
||||||
|
// --- Synchronous scroll compensation for load-more / reveal ---
|
||||||
|
// fetchOlderHistory and revealBufferedTurns store a snapshot here
|
||||||
|
// before triggering the state change. useLayoutEffect consumes it
|
||||||
|
// after React commits new DOM — before the browser paints.
|
||||||
|
const prePrependScrollRef = React.useRef<{
|
||||||
|
height: number;
|
||||||
|
top: number;
|
||||||
|
anchor: ViewportAnchor | null;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
const snap = prePrependScrollRef.current;
|
||||||
|
const container = scrollRef.current;
|
||||||
|
if (!snap || !container) return;
|
||||||
|
prePrependScrollRef.current = null;
|
||||||
|
|
||||||
|
// Try anchor-based restoration first (pixel-perfect)
|
||||||
|
if (snap.anchor) {
|
||||||
|
const anchorEl = container.querySelector<HTMLElement>(
|
||||||
|
`[data-message-id="${snap.anchor.messageId}"]`,
|
||||||
|
);
|
||||||
|
if (anchorEl) {
|
||||||
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
const anchorTop = anchorEl.getBoundingClientRect().top - containerRect.top;
|
||||||
|
container.scrollTop += anchorTop - snap.anchor.offsetTop;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: height-delta compensation
|
||||||
|
const delta = container.scrollHeight - snap.height;
|
||||||
|
if (delta > 0) {
|
||||||
|
container.scrollTop = snap.top + delta;
|
||||||
|
}
|
||||||
|
}, [renderedMessages, scrollRef]);
|
||||||
|
|
||||||
const captureViewportAnchor = React.useCallback((): ViewportAnchor | null => {
|
const captureViewportAnchor = React.useCallback((): ViewportAnchor | null => {
|
||||||
return messageListRef.current?.captureViewportAnchor() ?? null;
|
return messageListRef.current?.captureViewportAnchor() ?? null;
|
||||||
}, [messageListRef]);
|
}, [messageListRef]);
|
||||||
@@ -188,35 +224,19 @@ export const useChatTimelineController = ({
|
|||||||
return messageListRef.current?.restoreViewportAnchor(anchor) ?? false;
|
return messageListRef.current?.restoreViewportAnchor(anchor) ?? false;
|
||||||
}, [messageListRef]);
|
}, [messageListRef]);
|
||||||
|
|
||||||
const restoreViewportWithFallback = React.useCallback((input: {
|
|
||||||
anchor: ViewportAnchor | null;
|
|
||||||
previousHeight: number | null;
|
|
||||||
previousTop: number | null;
|
|
||||||
}) => {
|
|
||||||
const container = scrollRef.current;
|
|
||||||
if (input.anchor && restoreViewportAnchor(input.anchor)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!container || input.previousHeight === null || input.previousTop === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const heightDelta = container.scrollHeight - input.previousHeight;
|
|
||||||
if (heightDelta !== 0) {
|
|
||||||
container.scrollTop = input.previousTop + heightDelta;
|
|
||||||
}
|
|
||||||
}, [restoreViewportAnchor, scrollRef]);
|
|
||||||
|
|
||||||
const revealBufferedTurns = React.useCallback(async (): Promise<boolean> => {
|
const revealBufferedTurns = React.useCallback(async (): Promise<boolean> => {
|
||||||
if (turnStartRef.current <= 0 || pendingRevealWorkRef.current) {
|
if (turnStartRef.current <= 0 || pendingRevealWorkRef.current) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const anchor = captureViewportAnchor();
|
|
||||||
const container = scrollRef.current;
|
const container = scrollRef.current;
|
||||||
const previousHeight = container?.scrollHeight ?? null;
|
if (container) {
|
||||||
const previousTop = container?.scrollTop ?? null;
|
prePrependScrollRef.current = {
|
||||||
|
height: container.scrollHeight,
|
||||||
|
top: container.scrollTop,
|
||||||
|
anchor: captureViewportAnchor(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
setPendingRevealWork(true);
|
setPendingRevealWork(true);
|
||||||
setTurnStart((current) => {
|
setTurnStart((current) => {
|
||||||
@@ -225,14 +245,9 @@ export const useChatTimelineController = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
await waitForFrames(1);
|
await waitForFrames(1);
|
||||||
restoreViewportWithFallback({
|
|
||||||
anchor,
|
|
||||||
previousHeight,
|
|
||||||
previousTop,
|
|
||||||
});
|
|
||||||
setPendingRevealWork(false);
|
setPendingRevealWork(false);
|
||||||
return true;
|
return true;
|
||||||
}, [captureViewportAnchor, restoreViewportWithFallback, scrollRef]);
|
}, [captureViewportAnchor, scrollRef]);
|
||||||
|
|
||||||
const fetchOlderHistory = React.useCallback(async (input: {
|
const fetchOlderHistory = React.useCallback(async (input: {
|
||||||
preserveViewport: boolean;
|
preserveViewport: boolean;
|
||||||
@@ -244,16 +259,22 @@ export const useChatTimelineController = ({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const anchor = input.preserveViewport ? captureViewportAnchor() : null;
|
|
||||||
const container = scrollRef.current;
|
const container = scrollRef.current;
|
||||||
const previousHeight = input.preserveViewport ? (container?.scrollHeight ?? null) : null;
|
|
||||||
const previousTop = input.preserveViewport ? (container?.scrollTop ?? null) : null;
|
|
||||||
const beforeMessages = messagesRef.current;
|
const beforeMessages = messagesRef.current;
|
||||||
const beforeMessageCount = beforeMessages.length;
|
const beforeMessageCount = beforeMessages.length;
|
||||||
const beforeOldestMessageId = beforeMessages[0]?.info?.id ?? null;
|
const beforeOldestMessageId = beforeMessages[0]?.info?.id ?? null;
|
||||||
const beforeLimit = historyMetaRef.current?.limit ?? getMemoryLimits().HISTORICAL_MESSAGES;
|
const beforeLimit = historyMetaRef.current?.limit ?? getMemoryLimits().HISTORICAL_MESSAGES;
|
||||||
|
|
||||||
setPendingRevealWork(true);
|
// Store scroll snapshot BEFORE the fetch so useLayoutEffect can
|
||||||
|
// compensate synchronously when React commits the new messages.
|
||||||
|
if (input.preserveViewport && container) {
|
||||||
|
prePrependScrollRef.current = {
|
||||||
|
height: container.scrollHeight,
|
||||||
|
top: container.scrollTop,
|
||||||
|
anchor: captureViewportAnchor(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
setIsLoadingOlder(true);
|
setIsLoadingOlder(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -274,20 +295,11 @@ export const useChatTimelineController = ({
|
|||||||
&& typeof afterOldestMessageId === 'string'
|
&& typeof afterOldestMessageId === 'string'
|
||||||
&& beforeOldestMessageId !== afterOldestMessageId);
|
&& beforeOldestMessageId !== afterOldestMessageId);
|
||||||
|
|
||||||
if (input.preserveViewport) {
|
|
||||||
restoreViewportWithFallback({
|
|
||||||
anchor,
|
|
||||||
previousHeight,
|
|
||||||
previousTop,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return historyGrew || afterLimit > beforeLimit;
|
return historyGrew || afterLimit > beforeLimit;
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoadingOlder(false);
|
setIsLoadingOlder(false);
|
||||||
setPendingRevealWork(false);
|
|
||||||
}
|
}
|
||||||
}, [captureViewportAnchor, loadMoreMessages, restoreViewportWithFallback, scrollRef]);
|
}, [captureViewportAnchor, loadMoreMessages, scrollRef]);
|
||||||
|
|
||||||
const loadEarlier = React.useCallback(async () => {
|
const loadEarlier = React.useCallback(async () => {
|
||||||
if (await revealBufferedTurns()) {
|
if (await revealBufferedTurns()) {
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { projectTurnRecords } from '../lib/turns/projectTurnRecords';
|
import { projectTurnRecords } from '../lib/turns/projectTurnRecords';
|
||||||
import { stabilizeTurnProjection } from '../lib/turns/stabilizeTurnProjection';
|
import { stabilizeTurnProjection } from '../lib/turns/stabilizeTurnProjection';
|
||||||
import type { ChatMessageEntry, TurnProjectionResult } from '../lib/turns/types';
|
import type { ChatMessageEntry, TurnProjectionResult, TurnRecord } from '../lib/turns/types';
|
||||||
|
import { streamPerfMeasure } from '@/stores/utils/streamDebug';
|
||||||
|
|
||||||
interface UseTurnRecordsOptions {
|
interface UseTurnRecordsOptions {
|
||||||
|
sessionKey?: string;
|
||||||
showTextJustificationActivity: boolean;
|
showTextJustificationActivity: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,33 +20,59 @@ export const useTurnRecords = (
|
|||||||
options: UseTurnRecordsOptions,
|
options: UseTurnRecordsOptions,
|
||||||
): TurnRecordsResult => {
|
): TurnRecordsResult => {
|
||||||
const previousProjectionRef = React.useRef<TurnProjectionResult | null>(null);
|
const previousProjectionRef = React.useRef<TurnProjectionResult | null>(null);
|
||||||
|
const staticTurnsRef = React.useRef<TurnRecord[]>([]);
|
||||||
|
const streamingTurnRef = React.useRef<TurnRecord | undefined>(undefined);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
previousProjectionRef.current = null;
|
previousProjectionRef.current = null;
|
||||||
}, [options.showTextJustificationActivity]);
|
staticTurnsRef.current = [];
|
||||||
|
streamingTurnRef.current = undefined;
|
||||||
|
}, [options.sessionKey, options.showTextJustificationActivity]);
|
||||||
|
|
||||||
const projection = React.useMemo(() => {
|
const projection = React.useMemo(() => {
|
||||||
const rawProjection = projectTurnRecords(messages, {
|
return streamPerfMeasure('ui.turns.projection_ms', () => {
|
||||||
previousProjection: previousProjectionRef.current,
|
const rawProjection = projectTurnRecords(messages, {
|
||||||
showTextJustificationActivity: options.showTextJustificationActivity,
|
previousProjection: previousProjectionRef.current,
|
||||||
|
showTextJustificationActivity: options.showTextJustificationActivity,
|
||||||
|
});
|
||||||
|
const stabilizedProjection = stabilizeTurnProjection(rawProjection, previousProjectionRef.current);
|
||||||
|
previousProjectionRef.current = stabilizedProjection;
|
||||||
|
return stabilizedProjection;
|
||||||
});
|
});
|
||||||
const stabilizedProjection = stabilizeTurnProjection(rawProjection, previousProjectionRef.current);
|
|
||||||
previousProjectionRef.current = stabilizedProjection;
|
|
||||||
return stabilizedProjection;
|
|
||||||
}, [messages, options.showTextJustificationActivity]);
|
}, [messages, options.showTextJustificationActivity]);
|
||||||
|
|
||||||
const staticTurns = React.useMemo(() => {
|
const staticTurns = React.useMemo(() => {
|
||||||
if (projection.turns.length <= 1) {
|
const nextStatic = projection.turns.length <= 1
|
||||||
return [];
|
? []
|
||||||
|
: projection.turns.slice(0, -1);
|
||||||
|
const previousStatic = staticTurnsRef.current;
|
||||||
|
|
||||||
|
if (previousStatic.length === nextStatic.length) {
|
||||||
|
let isSame = true;
|
||||||
|
for (let index = 0; index < nextStatic.length; index += 1) {
|
||||||
|
if (previousStatic[index] !== nextStatic[index]) {
|
||||||
|
isSame = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isSame) {
|
||||||
|
return previousStatic;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return projection.turns.slice(0, -1);
|
|
||||||
|
staticTurnsRef.current = nextStatic;
|
||||||
|
return nextStatic;
|
||||||
}, [projection.turns]);
|
}, [projection.turns]);
|
||||||
|
|
||||||
const streamingTurn = React.useMemo(() => {
|
const streamingTurn = React.useMemo(() => {
|
||||||
if (projection.turns.length === 0) {
|
const nextStreamingTurn = projection.turns.length === 0
|
||||||
return undefined;
|
? undefined
|
||||||
|
: projection.turns[projection.turns.length - 1];
|
||||||
|
if (streamingTurnRef.current === nextStreamingTurn) {
|
||||||
|
return streamingTurnRef.current;
|
||||||
}
|
}
|
||||||
return projection.turns[projection.turns.length - 1];
|
streamingTurnRef.current = nextStreamingTurn;
|
||||||
|
return nextStreamingTurn;
|
||||||
}, [projection.turns]);
|
}, [projection.turns]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { SessionMemoryState } from '@/stores/types/sessionTypes';
|
import type { SessionMemoryState } from '@/sync/viewport-store';
|
||||||
|
|
||||||
export interface TurnHistorySignalsInput {
|
export interface TurnHistorySignalsInput {
|
||||||
memoryState: SessionMemoryState | null;
|
memoryState: SessionMemoryState | null;
|
||||||
|
|||||||
@@ -171,16 +171,11 @@ export const projectTurnActivity = (input: ProjectActivityInput): ProjectActivit
|
|||||||
});
|
});
|
||||||
|
|
||||||
let firstWithAny: string | undefined;
|
let firstWithAny: string | undefined;
|
||||||
let cumulative = 0;
|
|
||||||
for (const message of input.assistantMessages) {
|
for (const message of input.assistantMessages) {
|
||||||
const count = countByMessage.get(message.info.id) ?? 0;
|
const count = countByMessage.get(message.info.id) ?? 0;
|
||||||
if (count > 0 && !firstWithAny) {
|
if (count > 0 && !firstWithAny) {
|
||||||
firstWithAny = message.info.id;
|
firstWithAny = message.info.id;
|
||||||
}
|
}
|
||||||
cumulative += count;
|
|
||||||
if (cumulative >= 2) {
|
|
||||||
return message.info.id;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return firstWithAny;
|
return firstWithAny;
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ export interface StageTurnsResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_STAGE_CONFIG: TurnStageConfig = {
|
const DEFAULT_STAGE_CONFIG: TurnStageConfig = {
|
||||||
init: 1,
|
init: 10,
|
||||||
batch: 3,
|
batch: 8,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getInitialStageCount = (total: number, config: TurnStageConfig): number => {
|
export const getInitialStageCount = (total: number, config: TurnStageConfig): number => {
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ export type Turn = Pick<TurnRecord, 'turnId' | 'userMessage' | 'assistantMessage
|
|||||||
|
|
||||||
export interface TurnGroupingContext {
|
export interface TurnGroupingContext {
|
||||||
turnId: string;
|
turnId: string;
|
||||||
|
activityOwnerMessageId?: string;
|
||||||
isFirstAssistantInTurn: boolean;
|
isFirstAssistantInTurn: boolean;
|
||||||
isLastAssistantInTurn: boolean;
|
isLastAssistantInTurn: boolean;
|
||||||
summaryBody?: string;
|
summaryBody?: string;
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
|
|||||||
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
|
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
|
||||||
|
|
||||||
import { SimpleMarkdownRenderer } from '../MarkdownRenderer';
|
import { SimpleMarkdownRenderer } from '../MarkdownRenderer';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { flattenAssistantTextParts } from '@/lib/messages/messageText';
|
import { flattenAssistantTextParts } from '@/lib/messages/messageText';
|
||||||
import { MULTIRUN_EXECUTION_FORK_PROMPT_META_TEXT } from '@/lib/messages/executionMeta';
|
import { MULTIRUN_EXECUTION_FORK_PROMPT_META_TEXT } from '@/lib/messages/executionMeta';
|
||||||
@@ -35,6 +35,7 @@ import { ToolRevealOnMount } from './parts/ToolRevealOnMount';
|
|||||||
import { StaticToolRow } from './parts/ProgressiveGroup';
|
import { StaticToolRow } from './parts/ProgressiveGroup';
|
||||||
import { isExpandableTool, isStandaloneTool } from './parts/toolRenderUtils';
|
import { isExpandableTool, isStandaloneTool } from './parts/toolRenderUtils';
|
||||||
import TurnActivity from '../components/TurnActivity';
|
import TurnActivity from '../components/TurnActivity';
|
||||||
|
import { areRenderRelevantPartsEqual } from './renderCompare';
|
||||||
|
|
||||||
type SubtaskPartLike = Part & {
|
type SubtaskPartLike = Part & {
|
||||||
type: 'subtask';
|
type: 'subtask';
|
||||||
@@ -77,7 +78,7 @@ const normalizeSubtaskModel = (model: SubtaskPartLike['model']): string | null =
|
|||||||
|
|
||||||
const UserSubtaskPart: React.FC<{ part: SubtaskPartLike }> = ({ part }) => {
|
const UserSubtaskPart: React.FC<{ part: SubtaskPartLike }> = ({ part }) => {
|
||||||
const [expanded, setExpanded] = React.useState(false);
|
const [expanded, setExpanded] = React.useState(false);
|
||||||
const setCurrentSession = useSessionStore((state) => state.setCurrentSession);
|
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
|
||||||
|
|
||||||
const description = typeof part.description === 'string' ? part.description.trim() : '';
|
const description = typeof part.description === 'string' ? part.description.trim() : '';
|
||||||
const command = typeof part.command === 'string' ? part.command.trim() : '';
|
const command = typeof part.command === 'string' ? part.command.trim() : '';
|
||||||
@@ -254,6 +255,7 @@ const formatTurnDuration = (durationMs: number): string => {
|
|||||||
|
|
||||||
|
|
||||||
interface MessageBodyProps {
|
interface MessageBodyProps {
|
||||||
|
sessionId?: string;
|
||||||
messageId: string;
|
messageId: string;
|
||||||
parts: Part[];
|
parts: Part[];
|
||||||
isUser: boolean;
|
isUser: boolean;
|
||||||
@@ -562,6 +564,7 @@ const UserMessageBody: React.FC<{
|
|||||||
};
|
};
|
||||||
|
|
||||||
const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
||||||
|
sessionId,
|
||||||
messageId,
|
messageId,
|
||||||
parts,
|
parts,
|
||||||
isMessageCompleted,
|
isMessageCompleted,
|
||||||
@@ -696,7 +699,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
|||||||
return visibleParts.filter((part) => part.type === 'text');
|
return visibleParts.filter((part) => part.type === 'text');
|
||||||
}, [visibleParts]);
|
}, [visibleParts]);
|
||||||
|
|
||||||
const createSessionFromAssistantMessage = useSessionStore((state) => state.createSessionFromAssistantMessage);
|
const createSessionFromAssistantMessage = useSessionUIStore((state) => state.createSessionFromAssistantMessage);
|
||||||
const openMultiRunLauncherWithPrompt = useUIStore((state) => state.openMultiRunLauncherWithPrompt);
|
const openMultiRunLauncherWithPrompt = useUIStore((state) => state.openMultiRunLauncherWithPrompt);
|
||||||
const chatRenderMode = useUIStore((state) => state.chatRenderMode);
|
const chatRenderMode = useUIStore((state) => state.chatRenderMode);
|
||||||
const isSortedRenderMode = chatRenderMode === 'sorted';
|
const isSortedRenderMode = chatRenderMode === 'sorted';
|
||||||
@@ -1065,6 +1068,8 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
|||||||
return all.filter((segment) => segment.anchorMessageId === messageId);
|
return all.filter((segment) => segment.anchorMessageId === messageId);
|
||||||
}, [isSortedRenderMode, messageId, turnGroupingContext?.activityGroupSegments]);
|
}, [isSortedRenderMode, messageId, turnGroupingContext?.activityGroupSegments]);
|
||||||
|
|
||||||
|
const hasAnchoredActivitySegments = activityGroupSegmentsForMessage.length > 0;
|
||||||
|
|
||||||
const activityByPart = React.useMemo(() => {
|
const activityByPart = React.useMemo(() => {
|
||||||
const byRef = new Map<Part, (typeof activityPartsForTurn)[number]>();
|
const byRef = new Map<Part, (typeof activityPartsForTurn)[number]>();
|
||||||
const byId = new Map<string, (typeof activityPartsForTurn)[number]>();
|
const byId = new Map<string, (typeof activityPartsForTurn)[number]>();
|
||||||
@@ -1092,9 +1097,14 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
|||||||
}, [activityPartsForTurn]);
|
}, [activityPartsForTurn]);
|
||||||
|
|
||||||
const toggleActivityGroup = turnGroupingContext?.toggleGroup;
|
const toggleActivityGroup = turnGroupingContext?.toggleGroup;
|
||||||
|
const isActivityOwnerMessage = !isSortedRenderMode
|
||||||
|
|| !turnGroupingContext?.activityOwnerMessageId
|
||||||
|
|| turnGroupingContext.activityOwnerMessageId === messageId
|
||||||
|
|| hasAnchoredActivitySegments;
|
||||||
|
|
||||||
const shouldRenderActivityGroup = isSortedRenderMode
|
const shouldRenderActivityGroup = isSortedRenderMode
|
||||||
&& activityGroupSegmentsForMessage.length > 0
|
&& isActivityOwnerMessage
|
||||||
|
&& hasAnchoredActivitySegments
|
||||||
&& Boolean(toggleActivityGroup);
|
&& Boolean(toggleActivityGroup);
|
||||||
|
|
||||||
|
|
||||||
@@ -1155,6 +1165,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
|||||||
<AssistantTextPart
|
<AssistantTextPart
|
||||||
key={`assistant-text-${messageId}-${i}`}
|
key={`assistant-text-${messageId}-${i}`}
|
||||||
part={part}
|
part={part}
|
||||||
|
sessionId={sessionId}
|
||||||
messageId={messageId}
|
messageId={messageId}
|
||||||
streamPhase={streamPhase}
|
streamPhase={streamPhase}
|
||||||
chatRenderMode={chatRenderMode}
|
chatRenderMode={chatRenderMode}
|
||||||
@@ -1190,6 +1201,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
|||||||
<AssistantTextPart
|
<AssistantTextPart
|
||||||
key={`reasoning-${messageId}-${i}`}
|
key={`reasoning-${messageId}-${i}`}
|
||||||
part={part}
|
part={part}
|
||||||
|
sessionId={sessionId}
|
||||||
messageId={messageId}
|
messageId={messageId}
|
||||||
streamPhase={streamPhase}
|
streamPhase={streamPhase}
|
||||||
chatRenderMode={chatRenderMode}
|
chatRenderMode={chatRenderMode}
|
||||||
@@ -1206,8 +1218,13 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
|||||||
const toolPart = part as ToolPartType;
|
const toolPart = part as ToolPartType;
|
||||||
const toolName = toolPart.tool?.toLowerCase() ?? '';
|
const toolName = toolPart.tool?.toLowerCase() ?? '';
|
||||||
|
|
||||||
|
if (isSortedRenderMode && !isActivityOwnerMessage) {
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const activity = activityByPart.get(part);
|
const activity = activityByPart.get(part);
|
||||||
if (activity?.kind === 'tool' && !isStandaloneTool(toolName)) {
|
if (activity?.kind === 'tool' && (shouldRenderActivityGroup || !isStandaloneTool(toolName))) {
|
||||||
i += 1;
|
i += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1279,8 +1296,10 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
|||||||
expandedTools,
|
expandedTools,
|
||||||
hasStopFinish,
|
hasStopFinish,
|
||||||
isMobile,
|
isMobile,
|
||||||
|
isActivityOwnerMessage,
|
||||||
isSortedRenderMode,
|
isSortedRenderMode,
|
||||||
messageId,
|
messageId,
|
||||||
|
sessionId,
|
||||||
onContentChange,
|
onContentChange,
|
||||||
onShowPopup,
|
onShowPopup,
|
||||||
onToggleTool,
|
onToggleTool,
|
||||||
@@ -1525,4 +1544,37 @@ const MessageBody: React.FC<MessageBodyProps> = ({ isUser, ...props }) => {
|
|||||||
return <AssistantMessageBody {...props} />;
|
return <AssistantMessageBody {...props} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default React.memo(MessageBody);
|
export default React.memo(MessageBody, (prev, next) => {
|
||||||
|
return prev.sessionId === next.sessionId
|
||||||
|
&& prev.messageId === next.messageId
|
||||||
|
&& prev.isUser === next.isUser
|
||||||
|
&& areRenderRelevantPartsEqual(prev.parts, next.parts)
|
||||||
|
&& prev.isMessageCompleted === next.isMessageCompleted
|
||||||
|
&& prev.messageFinish === next.messageFinish
|
||||||
|
&& prev.messageCompletedAt === next.messageCompletedAt
|
||||||
|
&& prev.messageCreatedAt === next.messageCreatedAt
|
||||||
|
&& prev.syntaxTheme === next.syntaxTheme
|
||||||
|
&& prev.isMobile === next.isMobile
|
||||||
|
&& prev.hasTouchInput === next.hasTouchInput
|
||||||
|
&& prev.copiedCode === next.copiedCode
|
||||||
|
&& prev.expandedTools === next.expandedTools
|
||||||
|
&& prev.streamPhase === next.streamPhase
|
||||||
|
&& prev.allowAnimation === next.allowAnimation
|
||||||
|
&& prev.shouldShowHeader === next.shouldShowHeader
|
||||||
|
&& prev.hasTextContent === next.hasTextContent
|
||||||
|
&& prev.copiedMessage === next.copiedMessage
|
||||||
|
&& prev.showReasoningTraces === next.showReasoningTraces
|
||||||
|
&& prev.agentMention === next.agentMention
|
||||||
|
&& prev.turnGroupingContext === next.turnGroupingContext
|
||||||
|
&& prev.errorMessage === next.errorMessage
|
||||||
|
&& prev.userActionsMode === next.userActionsMode
|
||||||
|
&& prev.stickyUserHeaderEnabled === next.stickyUserHeaderEnabled
|
||||||
|
&& prev.onCopyCode === next.onCopyCode
|
||||||
|
&& prev.onToggleTool === next.onToggleTool
|
||||||
|
&& prev.onShowPopup === next.onShowPopup
|
||||||
|
&& prev.onContentChange === next.onContentChange
|
||||||
|
&& prev.onCopyMessage === next.onCopyMessage
|
||||||
|
&& prev.onAuxiliaryContentComplete === next.onAuxiliaryContentComplete
|
||||||
|
&& prev.onRevert === next.onRevert
|
||||||
|
&& prev.onFork === next.onFork;
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
|
import { useInputStore } from '@/sync/input-store';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { RiChatNewLine, RiAddLine, RiFileCopyLine } from '@remixicon/react';
|
import { RiChatNewLine, RiAddLine, RiFileCopyLine } from '@remixicon/react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -196,8 +197,8 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
const pendingSelectionRef = React.useRef<SelectionPayload | null>(null);
|
const pendingSelectionRef = React.useRef<SelectionPayload | null>(null);
|
||||||
const openRafRef = React.useRef<number | null>(null);
|
const openRafRef = React.useRef<number | null>(null);
|
||||||
const isMenuVisibleRef = React.useRef(false);
|
const isMenuVisibleRef = React.useRef(false);
|
||||||
const createSession = useSessionStore((state) => state.createSession);
|
const createSession = useSessionUIStore((state) => state.createSession);
|
||||||
const setPendingInputText = useSessionStore((state) => state.setPendingInputText);
|
const setPendingInputText = useInputStore((state) => state.setPendingInputText);
|
||||||
const isMobile = useUIStore((state) => state.isMobile);
|
const isMobile = useUIStore((state) => state.isMobile);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ import type { StreamPhase } from '../types';
|
|||||||
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
|
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
|
||||||
import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle';
|
import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle';
|
||||||
import { resolveAssistantDisplayText, shouldRenderAssistantText } from './assistantTextVisibility';
|
import { resolveAssistantDisplayText, shouldRenderAssistantText } from './assistantTextVisibility';
|
||||||
|
import { streamPerfCount, streamPerfObserve } from '@/stores/utils/streamDebug';
|
||||||
|
|
||||||
type PartWithText = Part & { text?: string; content?: string; value?: string; time?: { start?: number; end?: number } };
|
type PartWithText = Part & { text?: string; content?: string; value?: string; time?: { start?: number; end?: number } };
|
||||||
|
|
||||||
interface AssistantTextPartProps {
|
interface AssistantTextPartProps {
|
||||||
part: Part;
|
part: Part;
|
||||||
|
sessionId?: string;
|
||||||
messageId: string;
|
messageId: string;
|
||||||
streamPhase: StreamPhase;
|
streamPhase: StreamPhase;
|
||||||
chatRenderMode?: 'sorted' | 'live';
|
chatRenderMode?: 'sorted' | 'live';
|
||||||
@@ -22,6 +24,8 @@ const AssistantTextPart: React.FC<AssistantTextPartProps> = ({
|
|||||||
streamPhase,
|
streamPhase,
|
||||||
chatRenderMode = 'live',
|
chatRenderMode = 'live',
|
||||||
}) => {
|
}) => {
|
||||||
|
// Use part directly from props — parent provides the latest version from the store.
|
||||||
|
// No store subscription here to avoid re-render cascade from unrelated delta events.
|
||||||
const partWithText = part as PartWithText;
|
const partWithText = part as PartWithText;
|
||||||
const rawText = typeof partWithText.text === 'string' ? partWithText.text : '';
|
const rawText = typeof partWithText.text === 'string' ? partWithText.text : '';
|
||||||
const contentText = typeof partWithText.content === 'string' ? partWithText.content : '';
|
const contentText = typeof partWithText.content === 'string' ? partWithText.content : '';
|
||||||
@@ -33,6 +37,11 @@ const AssistantTextPart: React.FC<AssistantTextPartProps> = ({
|
|||||||
const isCooldownPhase = streamPhase === 'cooldown';
|
const isCooldownPhase = streamPhase === 'cooldown';
|
||||||
const isStreaming = chatRenderMode === 'live' && (isStreamingPhase || isCooldownPhase);
|
const isStreaming = chatRenderMode === 'live' && (isStreamingPhase || isCooldownPhase);
|
||||||
|
|
||||||
|
streamPerfCount('ui.assistant_text_part.render');
|
||||||
|
if (isStreaming) {
|
||||||
|
streamPerfCount('ui.assistant_text_part.render.streaming');
|
||||||
|
}
|
||||||
|
|
||||||
const throttledTextContent = useStreamingTextThrottle({
|
const throttledTextContent = useStreamingTextThrottle({
|
||||||
text: textContent,
|
text: textContent,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
@@ -45,32 +54,7 @@ const AssistantTextPart: React.FC<AssistantTextPartProps> = ({
|
|||||||
isStreaming,
|
isStreaming,
|
||||||
});
|
});
|
||||||
|
|
||||||
const lastDisplayLengthRef = React.useRef(0);
|
streamPerfObserve('ui.assistant_text_part.display_len', displayTextContent.length);
|
||||||
React.useEffect(() => {
|
|
||||||
if (!isStreaming || typeof window === 'undefined') {
|
|
||||||
lastDisplayLengthRef.current = displayTextContent.length;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const debugEnabled = window.localStorage.getItem('openchamber_stream_debug') === '1';
|
|
||||||
if (!debugEnabled) {
|
|
||||||
lastDisplayLengthRef.current = displayTextContent.length;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (displayTextContent.length < lastDisplayLengthRef.current) {
|
|
||||||
console.info('[STREAM-TRACE] render_shrink', {
|
|
||||||
messageId,
|
|
||||||
partId: part.id,
|
|
||||||
rawTextLen: rawText.length,
|
|
||||||
contentLen: contentText.length,
|
|
||||||
valueLen: valueText.length,
|
|
||||||
chosenLen: textContent.length,
|
|
||||||
throttledLen: throttledTextContent.length,
|
|
||||||
displayLen: displayTextContent.length,
|
|
||||||
prevDisplayLen: lastDisplayLengthRef.current,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
lastDisplayLengthRef.current = displayTextContent.length;
|
|
||||||
}, [contentText.length, displayTextContent.length, isStreaming, messageId, part.id, rawText.length, textContent.length, throttledTextContent.length, valueText.length]);
|
|
||||||
|
|
||||||
const time = partWithText.time;
|
const time = partWithText.time;
|
||||||
const isFinalized = Boolean(time && typeof time.end !== 'undefined');
|
const isFinalized = Boolean(time && typeof time.end !== 'undefined');
|
||||||
@@ -105,4 +89,4 @@ const AssistantTextPart: React.FC<AssistantTextPartProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default AssistantTextPart;
|
export default React.memo(AssistantTextPart);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
|||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { useDurationTickerNow } from './useDurationTicker';
|
import { useDurationTickerNow } from './useDurationTicker';
|
||||||
import { MarkdownRenderer } from '../../MarkdownRenderer';
|
import { MarkdownRenderer } from '../../MarkdownRenderer';
|
||||||
|
import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle';
|
||||||
|
|
||||||
type PartWithText = Part & { text?: string; content?: string; time?: { start?: number; end?: number } };
|
type PartWithText = Part & { text?: string; content?: string; time?: { start?: number; end?: number } };
|
||||||
|
|
||||||
@@ -201,16 +202,21 @@ const ReasoningPart: React.FC<ReasoningPartProps> = ({
|
|||||||
const textContent = React.useMemo(() => cleanReasoningText(rawText), [rawText]);
|
const textContent = React.useMemo(() => cleanReasoningText(rawText), [rawText]);
|
||||||
const time = partWithText.time;
|
const time = partWithText.time;
|
||||||
const isStreaming = chatRenderMode === 'live' && typeof time?.end !== 'number';
|
const isStreaming = chatRenderMode === 'live' && typeof time?.end !== 'number';
|
||||||
|
const throttledText = useStreamingTextThrottle({
|
||||||
|
text: textContent,
|
||||||
|
isStreaming,
|
||||||
|
identityKey: `${messageId}:${part.id ?? 'reasoning'}`,
|
||||||
|
});
|
||||||
|
|
||||||
// Show reasoning even if time.end isn't set yet (during streaming)
|
// Show reasoning even if time.end isn't set yet (during streaming)
|
||||||
// Only hide if there's no text content
|
// Only hide if there's no text content
|
||||||
if (!textContent || textContent.trim().length === 0) {
|
if (!throttledText || throttledText.trim().length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReasoningTimelineBlock
|
<ReasoningTimelineBlock
|
||||||
text={textContent}
|
text={throttledText}
|
||||||
variant="thinking"
|
variant="thinking"
|
||||||
onContentChange={onContentChange}
|
onContentChange={onContentChange}
|
||||||
blockId={part.id || `${messageId}-reasoning`}
|
blockId={part.id || `${messageId}-reasoning`}
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import { toolDisplayStyles } from '@/lib/typography';
|
|||||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||||
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
|
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
|
||||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
|
import { useSessionMessageRecords } from '@/sync/sync-context';
|
||||||
|
import { getSyncChildStores, getSyncDirectory } from '@/sync/sync-refs';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { useSessionActivity } from '@/hooks/useSessionActivity';
|
import { useSessionActivity } from '@/hooks/useSessionActivity';
|
||||||
import { opencodeClient } from '@/lib/opencode/client';
|
import { opencodeClient } from '@/lib/opencode/client';
|
||||||
@@ -572,8 +574,6 @@ type TaskToolSummaryEntry = {
|
|||||||
|
|
||||||
type SessionMessageWithParts = MessageRecord;
|
type SessionMessageWithParts = MessageRecord;
|
||||||
|
|
||||||
const EMPTY_SESSION_MESSAGES: SessionMessageWithParts[] = [];
|
|
||||||
|
|
||||||
const normalizeSessionIdCandidate = (value: unknown): string | undefined => {
|
const normalizeSessionIdCandidate = (value: unknown): string | undefined => {
|
||||||
if (typeof value !== 'string') {
|
if (typeof value !== 'string') {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -844,7 +844,7 @@ const TaskToolSummary: React.FC<{
|
|||||||
animateTailText?: boolean;
|
animateTailText?: boolean;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
}> = ({ entries, isExpanded, isMobile, output, sessionId, onShowPopup, input, animateTailText = true, isActive = false }) => {
|
}> = ({ entries, isExpanded, isMobile, output, sessionId, onShowPopup, input, animateTailText = true, isActive = false }) => {
|
||||||
const setCurrentSession = useSessionStore((state) => state.setCurrentSession);
|
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
|
||||||
const showToolFileIcons = useUIStore((state) => state.showToolFileIcons);
|
const showToolFileIcons = useUIStore((state) => state.showToolFileIcons);
|
||||||
const displayEntries = entries;
|
const displayEntries = entries;
|
||||||
|
|
||||||
@@ -1674,14 +1674,7 @@ const ToolPart: React.FC<ToolPartProps> = ({
|
|||||||
return readTaskSessionIdFromOutput(taskOutputString);
|
return readTaskSessionIdFromOutput(taskOutputString);
|
||||||
}, [isTaskTool, metadata, parsedTaskMetadata.sessionId, partMetadata, taskOutputString]);
|
}, [isTaskTool, metadata, parsedTaskMetadata.sessionId, partMetadata, taskOutputString]);
|
||||||
|
|
||||||
const childSessionMessages = useSessionStore(
|
const childSessionMessages = useSessionMessageRecords(taskSessionId ?? '');
|
||||||
React.useCallback((store) => {
|
|
||||||
if (!taskSessionId) {
|
|
||||||
return EMPTY_SESSION_MESSAGES;
|
|
||||||
}
|
|
||||||
return (store.messages.get(taskSessionId) as SessionMessageWithParts[] | undefined) ?? EMPTY_SESSION_MESSAGES;
|
|
||||||
}, [taskSessionId])
|
|
||||||
);
|
|
||||||
|
|
||||||
const metadataTaskSummaryEntries = React.useMemo<TaskToolSummaryEntry[]>(() => {
|
const metadataTaskSummaryEntries = React.useMemo<TaskToolSummaryEntry[]>(() => {
|
||||||
if (!isTaskTool) {
|
if (!isTaskTool) {
|
||||||
@@ -1901,7 +1894,20 @@ const ToolPart: React.FC<ToolPartProps> = ({
|
|||||||
|
|
||||||
taskPollLastSignatureRef.current = nextSignature;
|
taskPollLastSignatureRef.current = nextSignature;
|
||||||
taskPollNoChangeCountRef.current = 0;
|
taskPollNoChangeCountRef.current = 0;
|
||||||
useSessionStore.getState().syncMessages(taskSessionId, messages);
|
// Inject fetched subagent messages into sync child store
|
||||||
|
const childStores = getSyncChildStores();
|
||||||
|
const dir = getSyncDirectory();
|
||||||
|
childStores.update(dir, (prev) => {
|
||||||
|
const records = messages as SessionMessageWithParts[];
|
||||||
|
const partPatch: Record<string, import('@opencode-ai/sdk/v2').Part[]> = { ...prev.part };
|
||||||
|
for (const rec of records) {
|
||||||
|
partPatch[rec.info.id] = rec.parts;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
message: { ...prev.message, [taskSessionId]: records.map((r) => r.info) as import('@opencode-ai/sdk/v2').Message[] },
|
||||||
|
part: partPatch,
|
||||||
|
};
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore transient subagent fetch errors.
|
// Ignore transient subagent fetch errors.
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import type { Message, Part } from '@opencode-ai/sdk/v2';
|
||||||
|
|
||||||
|
type MessageRecord = {
|
||||||
|
info: Message;
|
||||||
|
parts: Part[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const readPartId = (part: Part | undefined): string | null => {
|
||||||
|
if (!part) return null;
|
||||||
|
const candidate = (part as { id?: unknown }).id;
|
||||||
|
return typeof candidate === 'string' && candidate.length > 0 ? candidate : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readToolStatus = (part: Part | undefined): string | null => {
|
||||||
|
const status = (part as { state?: { status?: unknown } } | undefined)?.state?.status;
|
||||||
|
return typeof status === 'string' ? status : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readPartTime = (part: Part | undefined) => {
|
||||||
|
const time = (part as { time?: { start?: unknown; end?: unknown } } | undefined)?.time;
|
||||||
|
return {
|
||||||
|
start: typeof time?.start === 'number' ? time.start : null,
|
||||||
|
end: typeof time?.end === 'number' ? time.end : null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const readPartText = (part: Part | undefined): string => {
|
||||||
|
const candidate = part as { text?: unknown; content?: unknown; value?: unknown } | undefined;
|
||||||
|
if (!candidate) return '';
|
||||||
|
const text = typeof candidate.text === 'string' ? candidate.text : '';
|
||||||
|
const content = typeof candidate.content === 'string' ? candidate.content : '';
|
||||||
|
const value = typeof candidate.value === 'string' ? candidate.value : '';
|
||||||
|
return [text, content, value].reduce((best, next) => (next.length > best.length ? next : best), '');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const areRenderRelevantPartsEqual = (left: Part[], right: Part[]): boolean => {
|
||||||
|
if (left === right) return true;
|
||||||
|
if (left.length !== right.length) return false;
|
||||||
|
|
||||||
|
for (let index = 0; index < left.length; index += 1) {
|
||||||
|
const leftPart = left[index];
|
||||||
|
const rightPart = right[index];
|
||||||
|
|
||||||
|
if (leftPart.type !== rightPart.type) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftId = readPartId(leftPart);
|
||||||
|
const rightId = readPartId(rightPart);
|
||||||
|
if (leftId !== rightId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leftPart.type === 'tool') {
|
||||||
|
if (readToolStatus(leftPart) !== readToolStatus(rightPart)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const leftTime = readPartTime(leftPart);
|
||||||
|
const rightTime = readPartTime(rightPart);
|
||||||
|
if (leftTime.start !== rightTime.start || leftTime.end !== rightTime.end) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const leftTool = (leftPart as { tool?: unknown }).tool;
|
||||||
|
const rightTool = (rightPart as { tool?: unknown }).tool;
|
||||||
|
if (leftTool !== rightTool) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftTime = readPartTime(leftPart);
|
||||||
|
const rightTime = readPartTime(rightPart);
|
||||||
|
if (leftTime.start !== rightTime.start || leftTime.end !== rightTime.end) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leftPart.type === 'text' || leftPart.type === 'reasoning') {
|
||||||
|
if (readPartText(leftPart) !== readPartText(rightPart)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const areRenderRelevantMessageInfoEqual = (left: Message, right: Message): boolean => {
|
||||||
|
if (left === right) return true;
|
||||||
|
|
||||||
|
return left.id === right.id
|
||||||
|
&& left.role === right.role
|
||||||
|
&& left.sessionID === right.sessionID
|
||||||
|
&& (left as { finish?: unknown }).finish === (right as { finish?: unknown }).finish
|
||||||
|
&& (left as { status?: unknown }).status === (right as { status?: unknown }).status
|
||||||
|
&& (left as { mode?: unknown }).mode === (right as { mode?: unknown }).mode
|
||||||
|
&& (left as { agent?: unknown }).agent === (right as { agent?: unknown }).agent
|
||||||
|
&& (left as { providerID?: unknown }).providerID === (right as { providerID?: unknown }).providerID
|
||||||
|
&& (left as { modelID?: unknown }).modelID === (right as { modelID?: unknown }).modelID
|
||||||
|
&& (left as { variant?: unknown }).variant === (right as { variant?: unknown }).variant
|
||||||
|
&& (left as { clientRole?: unknown }).clientRole === (right as { clientRole?: unknown }).clientRole
|
||||||
|
&& (left as { userMessageMarker?: unknown }).userMessageMarker === (right as { userMessageMarker?: unknown }).userMessageMarker
|
||||||
|
&& ((left as { time?: { created?: unknown; completed?: unknown } }).time?.created ?? null) === ((right as { time?: { created?: unknown; completed?: unknown } }).time?.created ?? null)
|
||||||
|
&& ((left as { time?: { created?: unknown; completed?: unknown } }).time?.completed ?? null) === ((right as { time?: { created?: unknown; completed?: unknown } }).time?.completed ?? null);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const areRenderRelevantMessagesEqual = (left: MessageRecord, right: MessageRecord): boolean => {
|
||||||
|
return areRenderRelevantMessageInfoEqual(left.info, right.info) && areRenderRelevantPartsEqual(left.parts, right.parts);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const areOptionalRenderRelevantMessagesEqual = (left?: MessageRecord, right?: MessageRecord): boolean => {
|
||||||
|
if (!left || !right) {
|
||||||
|
return left === right;
|
||||||
|
}
|
||||||
|
return areRenderRelevantMessagesEqual(left, right);
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { toast } from '@/components/ui';
|
import { toast } from '@/components/ui';
|
||||||
import { useInlineCommentDraftStore, type InlineCommentDraft, type InlineCommentSource } from '@/stores/useInlineCommentDraftStore';
|
import { useInlineCommentDraftStore, type InlineCommentDraft, type InlineCommentSource } from '@/stores/useInlineCommentDraftStore';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
|
|
||||||
type LineRangeBase = {
|
type LineRangeBase = {
|
||||||
start: number;
|
start: number;
|
||||||
@@ -48,8 +48,8 @@ export function useInlineCommentController<TRange extends LineRangeBase>(
|
|||||||
) {
|
) {
|
||||||
const { source, fileLabel, language, getCodeForRange, toStoreRange, fromDraftRange } = options;
|
const { source, fileLabel, language, getCodeForRange, toStoreRange, fromDraftRange } = options;
|
||||||
|
|
||||||
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||||
const newSessionDraftOpen = useSessionStore((state) => state.newSessionDraft?.open);
|
const newSessionDraftOpen = useSessionUIStore((state) => state.newSessionDraft?.open);
|
||||||
|
|
||||||
const addDraft = useInlineCommentDraftStore((state) => state.addDraft);
|
const addDraft = useInlineCommentDraftStore((state) => state.addDraft);
|
||||||
const updateDraft = useInlineCommentDraftStore((state) => state.updateDraft);
|
const updateDraft = useInlineCommentDraftStore((state) => state.updateDraft);
|
||||||
|
|||||||
@@ -7,13 +7,12 @@ import { deriveMessageRole } from '@/components/chat/message/messageRole';
|
|||||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||||
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
|
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
|
import { useSessions, useSessionMessageRecords } from '@/sync/sync-context';
|
||||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
|
|
||||||
type SessionMessage = { info: Message; parts: Part[] };
|
type SessionMessage = { info: Message; parts: Part[] };
|
||||||
|
|
||||||
const EMPTY_SESSION_MESSAGES: SessionMessage[] = [];
|
|
||||||
|
|
||||||
type ProviderModelLike = {
|
type ProviderModelLike = {
|
||||||
id?: string;
|
id?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -277,12 +276,9 @@ export const ContextPanelContent: React.FC = () => {
|
|||||||
const [expandedRawMessages, setExpandedRawMessages] = React.useState<Record<string, boolean>>({});
|
const [expandedRawMessages, setExpandedRawMessages] = React.useState<Record<string, boolean>>({});
|
||||||
const [copiedRawMessageId, setCopiedRawMessageId] = React.useState<string | null>(null);
|
const [copiedRawMessageId, setCopiedRawMessageId] = React.useState<string | null>(null);
|
||||||
const copyResetTimeoutRef = React.useRef<number | null>(null);
|
const copyResetTimeoutRef = React.useRef<number | null>(null);
|
||||||
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||||
const sessions = useSessionStore((state) => state.sessions);
|
const sessions = useSessions();
|
||||||
const sessionMessages = useSessionStore((state) => {
|
const sessionMessages = useSessionMessageRecords(currentSessionId ?? '');
|
||||||
if (!state.currentSessionId) return EMPTY_SESSION_MESSAGES;
|
|
||||||
return state.messages.get(state.currentSessionId) ?? EMPTY_SESSION_MESSAGES;
|
|
||||||
});
|
|
||||||
const providers = useConfigStore((state) => state.providers);
|
const providers = useConfigStore((state) => state.providers);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ import { RiArrowLeftSLine, RiChat4Line, RiChatNewLine, RiCheckLine, RiCloseLine,
|
|||||||
import { DiffIcon } from '@/components/icons/DiffIcon';
|
import { DiffIcon } from '@/components/icons/DiffIcon';
|
||||||
import { useUIStore, type MainTab } from '@/stores/useUIStore';
|
import { useUIStore, type MainTab } from '@/stores/useUIStore';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
|
import { useSessions, useSessionMessageRecords } from '@/sync/sync-context';
|
||||||
|
import { getAllSyncSessions } from '@/sync/sync-refs';
|
||||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||||
import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore';
|
import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore';
|
||||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
@@ -241,17 +243,13 @@ export const Header: React.FC<HeaderProps> = ({
|
|||||||
const { getCurrentModel } = useConfigStore();
|
const { getCurrentModel } = useConfigStore();
|
||||||
const runtimeApis = useRuntimeAPIs();
|
const runtimeApis = useRuntimeAPIs();
|
||||||
|
|
||||||
const getContextUsage = useSessionStore((state) => state.getContextUsage);
|
const getContextUsage = useSessionUIStore((state) => state.getContextUsage);
|
||||||
const openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft);
|
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
|
||||||
const isNewSessionDraftOpen = useSessionStore((state) => Boolean(state.newSessionDraft?.open));
|
const isNewSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
|
||||||
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||||
const currentSessionMessages = useSessionStore((state) => {
|
const currentSessionMessageRecords = useSessionMessageRecords(currentSessionId ?? '');
|
||||||
if (!currentSessionId) {
|
const currentSessionMessages = currentSessionId ? (currentSessionMessageRecords.length > 0 ? currentSessionMessageRecords : undefined) : undefined;
|
||||||
return undefined;
|
const sessions = useSessions();
|
||||||
}
|
|
||||||
return state.messages.get(currentSessionId);
|
|
||||||
});
|
|
||||||
const sessions = useSessionStore((state) => state.sessions);
|
|
||||||
const activeProject = useProjectsStore((state) => {
|
const activeProject = useProjectsStore((state) => {
|
||||||
if (!state.activeProjectId) {
|
if (!state.activeProjectId) {
|
||||||
return null;
|
return null;
|
||||||
@@ -565,14 +563,20 @@ export const Header: React.FC<HeaderProps> = ({
|
|||||||
|
|
||||||
const currentSession = React.useMemo(() => {
|
const currentSession = React.useMemo(() => {
|
||||||
if (!currentSessionId) return null;
|
if (!currentSessionId) return null;
|
||||||
return sessions.find((s) => s.id === currentSessionId) ?? null;
|
// Try current directory's store first, then fall back to all child stores.
|
||||||
|
// The sidebar loads sessions globally via SDK, but the header uses
|
||||||
|
// useSessions() which only has the current directory. This fallback
|
||||||
|
// ensures the title/directory show when the session lives elsewhere.
|
||||||
|
return sessions.find((s) => s.id === currentSessionId)
|
||||||
|
?? getAllSyncSessions().find((s) => s.id === currentSessionId)
|
||||||
|
?? null;
|
||||||
}, [currentSessionId, sessions]);
|
}, [currentSessionId, sessions]);
|
||||||
|
|
||||||
const worktreePath = useSessionStore((state) => {
|
const worktreePath = useSessionUIStore((state) => {
|
||||||
if (!currentSessionId) return '';
|
if (!currentSessionId) return '';
|
||||||
return state.worktreeMetadata.get(currentSessionId)?.path ?? '';
|
return state.worktreeMetadata.get(currentSessionId)?.path ?? '';
|
||||||
});
|
});
|
||||||
const currentSessionWorktreeBranch = useSessionStore((state) => {
|
const currentSessionWorktreeBranch = useSessionUIStore((state) => {
|
||||||
if (!currentSessionId) return null;
|
if (!currentSessionId) return null;
|
||||||
return state.worktreeMetadata.get(currentSessionId)?.branch?.trim() ?? null;
|
return state.worktreeMetadata.get(currentSessionId)?.branch?.trim() ?? null;
|
||||||
});
|
});
|
||||||
@@ -588,7 +592,7 @@ export const Header: React.FC<HeaderProps> = ({
|
|||||||
return normalize(raw || '');
|
return normalize(raw || '');
|
||||||
}, [currentSession?.directory]);
|
}, [currentSession?.directory]);
|
||||||
|
|
||||||
const draftDirectory = useSessionStore((state) => {
|
const draftDirectory = useSessionUIStore((state) => {
|
||||||
if (!state.newSessionDraft?.open) {
|
if (!state.newSessionDraft?.open) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ export const RightSidebarTabs: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-hidden">
|
<div className="min-h-0 flex-1 overflow-hidden">
|
||||||
{rightSidebarTab === 'git' ? <GitView /> : <SidebarFilesTree />}
|
{rightSidebarTab === 'git' && <GitView />}
|
||||||
|
{rightSidebarTab === 'files' && <SidebarFilesTree />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
|
import { useSessions } from '@/sync/sync-context';
|
||||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -23,8 +24,8 @@ const formatDirectoryPath = (path?: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const SidebarContextSummary: React.FC<SidebarContextSummaryProps> = ({ className }) => {
|
export const SidebarContextSummary: React.FC<SidebarContextSummaryProps> = ({ className }) => {
|
||||||
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||||
const sessions = useSessionStore((state) => state.sessions);
|
const sessions = useSessions();
|
||||||
const { currentDirectory } = useDirectoryStore();
|
const { currentDirectory } = useDirectoryStore();
|
||||||
|
|
||||||
const activeSessionTitle = React.useMemo(() => {
|
const activeSessionTitle = React.useMemo(() => {
|
||||||
|
|||||||
@@ -453,13 +453,29 @@ export const SidebarFilesTree: React.FC = () => {
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!root || expandedPaths.length === 0) return;
|
if (!root || expandedPaths.length === 0) return;
|
||||||
|
|
||||||
for (const expandedPath of expandedPaths) {
|
// Sort by depth so parent dirs load before children
|
||||||
const normalized = normalizePath(expandedPath);
|
const toLoad = expandedPaths
|
||||||
if (!normalized || normalized === root) continue;
|
.map((p) => normalizePath(p))
|
||||||
if (!normalized.startsWith(`${root}/`)) continue;
|
.filter((normalized): normalized is string =>
|
||||||
if (loadedDirsRef.current.has(normalized) || inFlightDirsRef.current.has(normalized)) continue;
|
!!normalized &&
|
||||||
void loadDirectory(normalized);
|
normalized !== root &&
|
||||||
}
|
normalized.startsWith(`${root}/`) &&
|
||||||
|
!loadedDirsRef.current.has(normalized) &&
|
||||||
|
!inFlightDirsRef.current.has(normalized),
|
||||||
|
)
|
||||||
|
.sort((a, b) => a.split('/').length - b.split('/').length);
|
||||||
|
|
||||||
|
if (toLoad.length === 0) return;
|
||||||
|
|
||||||
|
// Load with concurrency limit to avoid API stampede on startup
|
||||||
|
let cancelled = false;
|
||||||
|
void (async () => {
|
||||||
|
for (let i = 0; i < toLoad.length && !cancelled; i += 3) {
|
||||||
|
const batch = toLoad.slice(i, i + 3);
|
||||||
|
await Promise.all(batch.map((dir) => loadDirectory(dir)));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
}, [expandedPaths, loadDirectory, root]);
|
}, [expandedPaths, loadDirectory, root]);
|
||||||
|
|
||||||
// --- Fuzzy search scoring (matching FilesView) ---
|
// --- Fuzzy search scoring (matching FilesView) ---
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import React from 'react';
|
|||||||
import { ErrorBoundary } from '../ui/ErrorBoundary';
|
import { ErrorBoundary } from '../ui/ErrorBoundary';
|
||||||
import { SessionSidebar } from '@/components/session/SessionSidebar';
|
import { SessionSidebar } from '@/components/session/SessionSidebar';
|
||||||
import { ChatView, SettingsView } from '@/components/views';
|
import { ChatView, SettingsView } from '@/components/views';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
|
import { useViewportStore } from '@/sync/viewport-store';
|
||||||
|
import { useSessions, useDirectorySync } from '@/sync/sync-context';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay';
|
import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay';
|
||||||
import { McpDropdown } from '@/components/mcp/McpDropdown';
|
import { McpDropdown } from '@/components/mcp/McpDropdown';
|
||||||
@@ -74,7 +76,7 @@ export const VSCodeLayout: React.FC = () => {
|
|||||||
|
|
||||||
const bootDraftOpen = React.useMemo(() => {
|
const bootDraftOpen = React.useMemo(() => {
|
||||||
try {
|
try {
|
||||||
return Boolean(useSessionStore.getState().newSessionDraft?.open);
|
return Boolean(useSessionUIStore.getState().newSessionDraft?.open);
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -88,8 +90,8 @@ export const VSCodeLayout: React.FC = () => {
|
|||||||
const expandedSidebarResizeStartXRef = React.useRef(0);
|
const expandedSidebarResizeStartXRef = React.useRef(0);
|
||||||
const expandedSidebarResizeStartWidthRef = React.useRef(SESSIONS_SIDEBAR_WIDTH);
|
const expandedSidebarResizeStartWidthRef = React.useRef(SESSIONS_SIDEBAR_WIDTH);
|
||||||
const expandedSidebarResizePointerIdRef = React.useRef<number | null>(null);
|
const expandedSidebarResizePointerIdRef = React.useRef<number | null>(null);
|
||||||
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||||
const sessions = useSessionStore((state) => state.sessions);
|
const sessions = useSessions();
|
||||||
|
|
||||||
const activeSessionTitle = React.useMemo(() => {
|
const activeSessionTitle = React.useMemo(() => {
|
||||||
if (!currentSessionId) {
|
if (!currentSessionId) {
|
||||||
@@ -97,21 +99,21 @@ export const VSCodeLayout: React.FC = () => {
|
|||||||
}
|
}
|
||||||
return sessions.find((session) => session.id === currentSessionId)?.title || 'Session';
|
return sessions.find((session) => session.id === currentSessionId)?.title || 'Session';
|
||||||
}, [currentSessionId, sessions]);
|
}, [currentSessionId, sessions]);
|
||||||
const newSessionDraftOpen = useSessionStore((state) => Boolean(state.newSessionDraft?.open));
|
const newSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
|
||||||
const isSyncingMessages = useSessionStore((state) => state.isSyncing);
|
const isSyncingMessages = useViewportStore((state) => state.isSyncing);
|
||||||
const hasActiveSessionWork = useSessionStore((state) => {
|
const hasActiveSessionWork = useDirectorySync((state) => {
|
||||||
const statuses = state.sessionStatus;
|
const statuses = state.session_status;
|
||||||
if (!statuses || statuses.size === 0) {
|
if (!statuses || Object.keys(statuses).length === 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
for (const status of statuses.values()) {
|
for (const status of Object.values(statuses)) {
|
||||||
if (status?.type === 'busy' || status?.type === 'retry') {
|
if (status?.type === 'busy' || status?.type === 'retry') {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
const openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft);
|
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
|
||||||
const [connectionStatus, setConnectionStatus] = React.useState<'connecting' | 'connected' | 'error' | 'disconnected'>(
|
const [connectionStatus, setConnectionStatus] = React.useState<'connecting' | 'connected' | 'error' | 'disconnected'>(
|
||||||
() => (typeof window !== 'undefined'
|
() => (typeof window !== 'undefined'
|
||||||
? (window as { __OPENCHAMBER_CONNECTION__?: { status?: string } }).__OPENCHAMBER_CONNECTION__?.status as
|
? (window as { __OPENCHAMBER_CONNECTION__?: { status?: string } }).__OPENCHAMBER_CONNECTION__?.status as
|
||||||
@@ -120,9 +122,6 @@ export const VSCodeLayout: React.FC = () => {
|
|||||||
);
|
);
|
||||||
const configInitialized = useConfigStore((state) => state.isInitialized);
|
const configInitialized = useConfigStore((state) => state.isInitialized);
|
||||||
const initializeConfig = useConfigStore((state) => state.initializeApp);
|
const initializeConfig = useConfigStore((state) => state.initializeApp);
|
||||||
const loadSessions = useSessionStore((state) => state.loadSessions);
|
|
||||||
const loadMessages = useSessionStore((state) => state.loadMessages);
|
|
||||||
const messages = useSessionStore((state) => state.messages);
|
|
||||||
const [hasInitializedOnce, setHasInitializedOnce] = React.useState<boolean>(() => configInitialized);
|
const [hasInitializedOnce, setHasInitializedOnce] = React.useState<boolean>(() => configInitialized);
|
||||||
const [isInitializing, setIsInitializing] = React.useState<boolean>(false);
|
const [isInitializing, setIsInitializing] = React.useState<boolean>(false);
|
||||||
const lastBootstrapAttemptAt = React.useRef<number>(0);
|
const lastBootstrapAttemptAt = React.useRef<number>(0);
|
||||||
@@ -158,18 +157,11 @@ export const VSCodeLayout: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const timeoutId = window.setTimeout(() => {
|
const timeoutId = window.setTimeout(() => {
|
||||||
const state = useSessionStore.getState();
|
const state = useSessionUIStore.getState();
|
||||||
const stillNoSession = !state.currentSessionId;
|
const stillNoSession = !state.currentSessionId;
|
||||||
const draftStillClosed = !state.newSessionDraft?.open;
|
const draftStillClosed = !state.newSessionDraft?.open;
|
||||||
const stillSyncing = state.isSyncing;
|
const stillSyncing = useViewportStore.getState().isSyncing;
|
||||||
const stillActiveWork = (() => {
|
const stillActiveWork = false; // sync bootstrap tracks session status
|
||||||
const statuses = state.sessionStatus;
|
|
||||||
if (!statuses || statuses.size === 0) return false;
|
|
||||||
for (const status of statuses.values()) {
|
|
||||||
if (status?.type === 'busy' || status?.type === 'retry') return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
})();
|
|
||||||
|
|
||||||
if (stillNoSession && draftStillClosed && !stillSyncing && !stillActiveWork) {
|
if (stillNoSession && draftStillClosed && !stillSyncing && !stillActiveWork) {
|
||||||
setCurrentView('sessions');
|
setCurrentView('sessions');
|
||||||
@@ -270,17 +262,10 @@ export const VSCodeLayout: React.FC = () => {
|
|||||||
if (!configState.isInitialized || !configState.isConnected || configState.providers.length === 0 || configState.agents.length === 0) {
|
if (!configState.isInitialized || !configState.isConnected || configState.providers.length === 0 || configState.agents.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await loadSessions();
|
|
||||||
const sessionsError = useSessionStore.getState().error;
|
|
||||||
if (debugEnabled) console.log('[OpenChamber][VSCode][bootstrap] post-load', {
|
if (debugEnabled) console.log('[OpenChamber][VSCode][bootstrap] post-load', {
|
||||||
providers: configState.providers.length,
|
providers: configState.providers.length,
|
||||||
agents: configState.agents.length,
|
agents: configState.agents.length,
|
||||||
sessions: useSessionStore.getState().sessions.length,
|
|
||||||
sessionsError,
|
|
||||||
});
|
});
|
||||||
if (typeof sessionsError === 'string' && sessionsError.length > 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setHasInitializedOnce(true);
|
setHasInitializedOnce(true);
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore bootstrap failures
|
// Ignore bootstrap failures
|
||||||
@@ -289,7 +274,7 @@ export const VSCodeLayout: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
void runBootstrap();
|
void runBootstrap();
|
||||||
}, [connectionStatus, configInitialized, hasInitializedOnce, initializeConfig, isInitializing, loadSessions]);
|
}, [connectionStatus, configInitialized, hasInitializedOnce, initializeConfig, isInitializing]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (viewMode !== 'editor') {
|
if (viewMode !== 'editor') {
|
||||||
@@ -314,35 +299,9 @@ export const VSCodeLayout: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
hasAppliedInitialSession.current = true;
|
hasAppliedInitialSession.current = true;
|
||||||
void useSessionStore.getState().setCurrentSession(initialSessionId);
|
void useSessionUIStore.getState().setCurrentSession(initialSessionId);
|
||||||
}, [connectionStatus, hasInitializedOnce, initialSessionId, openNewSessionDraft, sessions, viewMode]);
|
}, [connectionStatus, hasInitializedOnce, initialSessionId, openNewSessionDraft, sessions, viewMode]);
|
||||||
|
|
||||||
// Hydrate messages when viewing chat
|
|
||||||
React.useEffect(() => {
|
|
||||||
const hydrateMessages = async () => {
|
|
||||||
if (!hasInitializedOnce || connectionStatus !== 'connected' || currentView !== 'chat' || newSessionDraftOpen) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!currentSessionId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasMessagesEntry = messages.has(currentSessionId);
|
|
||||||
if (hasMessagesEntry) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await loadMessages(currentSessionId);
|
|
||||||
} catch {
|
|
||||||
/* ignored */
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
void hydrateMessages();
|
|
||||||
}, [connectionStatus, currentSessionId, currentView, hasInitializedOnce, loadMessages, messages, newSessionDraftOpen]);
|
|
||||||
|
|
||||||
// Track container width for responsive settings layout
|
// Track container width for responsive settings layout
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
@@ -532,8 +491,8 @@ interface VSCodeHeaderProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, onNewSession, onSettings, onAgentManager, showMcp, showContextUsage, showRateLimits }) => {
|
const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, onNewSession, onSettings, onAgentManager, showMcp, showContextUsage, showRateLimits }) => {
|
||||||
const { getCurrentModel } = useConfigStore();
|
const getCurrentModel = useConfigStore((s) => s.getCurrentModel);
|
||||||
const getContextUsage = useSessionStore((state) => state.getContextUsage);
|
const getContextUsage = useSessionUIStore((state) => state.getContextUsage);
|
||||||
const quotaResults = useQuotaStore((state) => state.results);
|
const quotaResults = useQuotaStore((state) => state.results);
|
||||||
const fetchAllQuotas = useQuotaStore((state) => state.fetchAllQuotas);
|
const fetchAllQuotas = useQuotaStore((state) => state.fetchAllQuotas);
|
||||||
const isQuotaLoading = useQuotaStore((state) => state.isLoading);
|
const isQuotaLoading = useQuotaStore((state) => state.isLoading);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { ScrollShadow } from '@/components/ui/ScrollShadow';
|
|||||||
import { cn, formatDirectoryName } from '@/lib/utils';
|
import { cn, formatDirectoryName } from '@/lib/utils';
|
||||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
import { useMultiRunStore } from '@/stores/useMultiRunStore';
|
import { useMultiRunStore } from '@/stores/useMultiRunStore';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||||
import { getWorktreeSetupCommands } from '@/lib/openchamberConfig';
|
import { getWorktreeSetupCommands } from '@/lib/openchamberConfig';
|
||||||
import type { ProjectRef } from '@/lib/openchamberConfig';
|
import type { ProjectRef } from '@/lib/openchamberConfig';
|
||||||
@@ -440,7 +440,7 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
|
|||||||
const result = await createMultiRun(params);
|
const result = await createMultiRun(params);
|
||||||
if (result) {
|
if (result) {
|
||||||
if (result.firstSessionId) {
|
if (result.firstSessionId) {
|
||||||
useSessionStore.getState().setCurrentSession(result.firstSessionId);
|
useSessionUIStore.getState().setCurrentSession(result.firstSessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close launcher
|
// Close launcher
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ import { NumberInput } from '@/components/ui/number-input';
|
|||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { toast } from '@/components/ui';
|
import { toast } from '@/components/ui';
|
||||||
import { useAgentsStore, type AgentConfig, type AgentScope } from '@/stores/useAgentsStore';
|
import { useAgentsStore, type AgentConfig, type AgentScope } from '@/stores/useAgentsStore';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useDirectorySync } from '@/sync/sync-context';
|
||||||
import { usePermissionStore } from '@/stores/permissionStore';
|
|
||||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
import { useDeviceInfo } from '@/lib/device';
|
import { useDeviceInfo } from '@/lib/device';
|
||||||
import { opencodeClient } from '@/lib/opencode/client';
|
import { opencodeClient } from '@/lib/opencode/client';
|
||||||
@@ -184,7 +183,6 @@ const buildPermissionConfigWithGlobal = (
|
|||||||
export const AgentsPage: React.FC = () => {
|
export const AgentsPage: React.FC = () => {
|
||||||
const { isMobile } = useDeviceInfo();
|
const { isMobile } = useDeviceInfo();
|
||||||
const { selectedAgentName, getAgentByName, createAgent, updateAgent, agents, agentDraft, setAgentDraft } = useAgentsStore();
|
const { selectedAgentName, getAgentByName, createAgent, updateAgent, agents, agentDraft, setAgentDraft } = useAgentsStore();
|
||||||
useConfigStore();
|
|
||||||
|
|
||||||
const selectedAgent = selectedAgentName ? getAgentByName(selectedAgentName) : null;
|
const selectedAgent = selectedAgentName ? getAgentByName(selectedAgentName) : null;
|
||||||
const isNewAgent = Boolean(agentDraft && agentDraft.name === selectedAgentName && !selectedAgent);
|
const isNewAgent = Boolean(agentDraft && agentDraft.name === selectedAgentName && !selectedAgent);
|
||||||
@@ -220,7 +218,7 @@ export const AgentsPage: React.FC = () => {
|
|||||||
const currentDirectory = useDirectoryStore((state) => state.currentDirectory ?? null);
|
const currentDirectory = useDirectoryStore((state) => state.currentDirectory ?? null);
|
||||||
const [toolIds, setToolIds] = React.useState<string[]>([]);
|
const [toolIds, setToolIds] = React.useState<string[]>([]);
|
||||||
|
|
||||||
const permissionsBySession = usePermissionStore((state) => state.permissions);
|
const permissionsBySession = useDirectorySync((state) => state.permission);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -264,7 +262,7 @@ export const AgentsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const permissions of permissionsBySession.values()) {
|
for (const permissions of Object.values(permissionsBySession)) {
|
||||||
for (const request of permissions) {
|
for (const request of permissions) {
|
||||||
const permissionName = request.permission?.trim();
|
const permissionName = request.permission?.trim();
|
||||||
if (permissionName && permissionName !== 'invalid') {
|
if (permissionName && permissionName !== 'invalid') {
|
||||||
|
|||||||
@@ -5,33 +5,44 @@ import { toast } from '@/components/ui';
|
|||||||
import { NumberInput } from '@/components/ui/number-input';
|
import { NumberInput } from '@/components/ui/number-input';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { useSessionAutoCleanup } from '@/hooks/useSessionAutoCleanup';
|
import { useSessionAutoCleanup } from '@/hooks/useSessionAutoCleanup';
|
||||||
|
|
||||||
const MIN_DAYS = 1;
|
const MIN_DAYS = 1;
|
||||||
const MAX_DAYS = 365;
|
const MAX_DAYS = 365;
|
||||||
const DEFAULT_RETENTION_DAYS = 30;
|
const DEFAULT_RETENTION_DAYS = 30;
|
||||||
|
const RETENTION_ACTION_OPTIONS = [
|
||||||
|
{ value: 'archive', label: 'Archive' },
|
||||||
|
{ value: 'delete', label: 'Delete' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
export const SessionRetentionSettings: React.FC = () => {
|
export const SessionRetentionSettings: React.FC = () => {
|
||||||
const autoDeleteEnabled = useUIStore((state) => state.autoDeleteEnabled);
|
const autoDeleteEnabled = useUIStore((state) => state.autoDeleteEnabled);
|
||||||
const autoDeleteAfterDays = useUIStore((state) => state.autoDeleteAfterDays);
|
const autoDeleteAfterDays = useUIStore((state) => state.autoDeleteAfterDays);
|
||||||
|
const sessionRetentionAction = useUIStore((state) => state.sessionRetentionAction);
|
||||||
const setAutoDeleteEnabled = useUIStore((state) => state.setAutoDeleteEnabled);
|
const setAutoDeleteEnabled = useUIStore((state) => state.setAutoDeleteEnabled);
|
||||||
const setAutoDeleteAfterDays = useUIStore((state) => state.setAutoDeleteAfterDays);
|
const setAutoDeleteAfterDays = useUIStore((state) => state.setAutoDeleteAfterDays);
|
||||||
|
const setSessionRetentionAction = useUIStore((state) => state.setSessionRetentionAction);
|
||||||
|
|
||||||
const { candidates, isRunning, runCleanup } = useSessionAutoCleanup({ autoRun: false });
|
const { candidates, isRunning, runCleanup, action } = useSessionAutoCleanup({ autoRun: false });
|
||||||
const pendingCount = candidates.length;
|
const pendingCount = candidates.length;
|
||||||
|
|
||||||
const handleRunCleanup = React.useCallback(async () => {
|
const handleRunCleanup = React.useCallback(async () => {
|
||||||
const result = await runCleanup({ force: true });
|
const result = await runCleanup({ force: true });
|
||||||
if (result.deletedIds.length === 0 && result.failedIds.length === 0) {
|
const verb = result.action === 'archive' ? 'archiving' : 'deletion';
|
||||||
toast.message('No sessions eligible for deletion');
|
const pastTense = result.action === 'archive' ? 'Archived' : 'Deleted';
|
||||||
|
const failureVerb = result.action === 'archive' ? 'archive' : 'delete';
|
||||||
|
|
||||||
|
if (result.completedIds.length === 0 && result.failedIds.length === 0) {
|
||||||
|
toast.message(`No sessions eligible for ${verb}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (result.deletedIds.length > 0) {
|
if (result.completedIds.length > 0) {
|
||||||
toast.success(`Deleted ${result.deletedIds.length} session${result.deletedIds.length === 1 ? '' : 's'}`);
|
toast.success(`${pastTense} ${result.completedIds.length} session${result.completedIds.length === 1 ? '' : 's'}`);
|
||||||
}
|
}
|
||||||
if (result.failedIds.length > 0) {
|
if (result.failedIds.length > 0) {
|
||||||
toast.error(`Failed to delete ${result.failedIds.length} session${result.failedIds.length === 1 ? '' : 's'}`);
|
toast.error(`Failed to ${failureVerb} ${result.failedIds.length} session${result.failedIds.length === 1 ? '' : 's'}`);
|
||||||
}
|
}
|
||||||
}, [runCleanup]);
|
}, [runCleanup]);
|
||||||
|
|
||||||
@@ -47,7 +58,7 @@ export const SessionRetentionSettings: React.FC = () => {
|
|||||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||||
Automatically delete inactive sessions based on their last activity. Keeps recent 5 sessions.
|
Automatically archive or delete inactive sessions based on last activity. Keeps the 5 most recent sessions.
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,6 +114,31 @@ export const SessionRetentionSettings: React.FC = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
|
||||||
|
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
|
||||||
|
<span className="typography-ui-label text-foreground">When sessions expire</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-1 sm:w-fit">
|
||||||
|
{RETENTION_ACTION_OPTIONS.map((option) => (
|
||||||
|
<Button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="xs"
|
||||||
|
className={cn(
|
||||||
|
'!font-normal',
|
||||||
|
sessionRetentionAction === option.value
|
||||||
|
? 'border-[var(--primary-base)] text-[var(--primary-base)] bg-[var(--primary-base)]/10 hover:text-[var(--primary-base)]'
|
||||||
|
: 'text-foreground'
|
||||||
|
)}
|
||||||
|
onClick={() => setSessionRetentionAction(option.value)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="mt-1 px-2 py-1.5 space-y-1">
|
<div className="mt-1 px-2 py-1.5 space-y-1">
|
||||||
@@ -124,7 +160,7 @@ export const SessionRetentionSettings: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="typography-meta text-muted-foreground">
|
<p className="typography-meta text-muted-foreground">
|
||||||
Eligible for deletion right now: <span className="tabular-nums">{pendingCount}</span>
|
Eligible for {action === 'archive' ? 'archiving' : 'deletion'} right now: <span className="tabular-nums">{pendingCount}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
|
import { useSessions } from '@/sync/sync-context';
|
||||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
import { useDeviceInfo } from '@/lib/device';
|
import { useDeviceInfo } from '@/lib/device';
|
||||||
import { checkIsGitRepository } from '@/lib/gitApi';
|
import { checkIsGitRepository } from '@/lib/gitApi';
|
||||||
@@ -24,7 +25,8 @@ export const WorktreeSectionContent: React.FC<WorktreeSectionContentProps> = ({
|
|||||||
|
|
||||||
const projectPath = projectRefProp?.path ?? activeProject?.path ?? null;
|
const projectPath = projectRefProp?.path ?? activeProject?.path ?? null;
|
||||||
|
|
||||||
const { sessions, getWorktreeMetadata } = useSessionStore();
|
const getWorktreeMetadata = useSessionUIStore((s) => s.getWorktreeMetadata);
|
||||||
|
const sessions = useSessions();
|
||||||
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
|
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
|
||||||
|
|
||||||
const [setupCommands, setSetupCommands] = React.useState<string[]>([]);
|
const [setupCommands, setSetupCommands] = React.useState<string[]>([]);
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import { createWorktreeWithDefaults } from '@/lib/worktrees/worktreeCreate';
|
|||||||
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
|
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
|
||||||
import { getWorktreeSetupCommands } from '@/lib/openchamberConfig';
|
import { getWorktreeSetupCommands } from '@/lib/openchamberConfig';
|
||||||
import { sessionEvents } from '@/lib/sessionEvents';
|
import { sessionEvents } from '@/lib/sessionEvents';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessions } from '@/sync/sync-context';
|
||||||
|
|
||||||
export interface BranchPickerProject {
|
export interface BranchPickerProject {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -65,7 +65,7 @@ const normalizePath = (value: string | null | undefined): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function BranchPickerDialog({ open, onOpenChange, project }: BranchPickerDialogProps) {
|
export function BranchPickerDialog({ open, onOpenChange, project }: BranchPickerDialogProps) {
|
||||||
const sessions = useSessionStore((state) => state.sessions);
|
const sessions = useSessions();
|
||||||
const [searchQuery, setSearchQuery] = React.useState('');
|
const [searchQuery, setSearchQuery] = React.useState('');
|
||||||
const [branches, setBranches] = React.useState<GitBranch | null>(null);
|
const [branches, setBranches] = React.useState<GitBranch | null>(null);
|
||||||
const [worktrees, setWorktrees] = React.useState<GitWorktreeInfo[]>([]);
|
const [worktrees, setWorktrees] = React.useState<GitWorktreeInfo[]>([]);
|
||||||
|
|||||||
@@ -21,9 +21,10 @@ import {
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
|
import { useSelectionStore } from '@/sync/selection-store';
|
||||||
|
import * as sessionActions from '@/sync/session-actions';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { useMessageStore } from '@/stores/messageStore';
|
|
||||||
import { useContextStore } from '@/stores/contextStore';
|
import { useContextStore } from '@/stores/contextStore';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||||
@@ -377,7 +378,7 @@ export function GitHubIssuePickerDialog({
|
|||||||
return created.id;
|
return created.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await useSessionStore.getState().createSession(sessionTitle, projectDirectory, null);
|
const session = await sessionActions.createSession(sessionTitle, projectDirectory, null);
|
||||||
if (!session?.id) {
|
if (!session?.id) {
|
||||||
throw new Error('Failed to create session');
|
throw new Error('Failed to create session');
|
||||||
}
|
}
|
||||||
@@ -385,10 +386,10 @@ export function GitHubIssuePickerDialog({
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
// Ensure worktree-based sessions also get the issue title.
|
// Ensure worktree-based sessions also get the issue title.
|
||||||
void useSessionStore.getState().updateSessionTitle(sessionId, sessionTitle).catch(() => undefined);
|
void sessionActions.updateSessionTitle(sessionId, sessionTitle).catch(() => undefined);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
useSessionStore.getState().initializeNewOpenChamberSession(sessionId, useConfigStore.getState().agents);
|
useSessionUIStore.getState().initializeNewOpenChamberSession(sessionId, useConfigStore.getState().agents);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -397,7 +398,7 @@ export function GitHubIssuePickerDialog({
|
|||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
|
|
||||||
const configState = useConfigStore.getState();
|
const configState = useConfigStore.getState();
|
||||||
const lastUsedProvider = useMessageStore.getState().lastUsedProvider;
|
const lastUsedProvider = useSelectionStore.getState().lastUsedProvider;
|
||||||
|
|
||||||
const defaultModel = resolveDefaultModelSelection();
|
const defaultModel = resolveDefaultModelSelection();
|
||||||
const providerID = defaultModel?.providerID || configState.currentProviderId || lastUsedProvider?.providerID;
|
const providerID = defaultModel?.providerID || configState.currentProviderId || lastUsedProvider?.providerID;
|
||||||
|
|||||||
@@ -10,15 +10,19 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { toast } from '@/components/ui';
|
import { toast } from '@/components/ui';
|
||||||
import {
|
import {
|
||||||
Select,
|
DropdownMenu,
|
||||||
SelectContent,
|
DropdownMenuContent,
|
||||||
SelectItem,
|
DropdownMenuTrigger,
|
||||||
SelectTrigger,
|
} from '@/components/ui/dropdown-menu';
|
||||||
SelectValue,
|
import {
|
||||||
SelectLabel,
|
Command,
|
||||||
SelectGroup,
|
CommandEmpty,
|
||||||
SelectSeparator,
|
CommandGroup,
|
||||||
} from '@/components/ui/select';
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
CommandSeparator,
|
||||||
|
} from '@/components/ui/command';
|
||||||
import {
|
import {
|
||||||
RiGitBranchLine,
|
RiGitBranchLine,
|
||||||
RiGitRepositoryLine,
|
RiGitRepositoryLine,
|
||||||
@@ -29,14 +33,16 @@ import {
|
|||||||
RiCheckLine,
|
RiCheckLine,
|
||||||
RiExternalLinkLine,
|
RiExternalLinkLine,
|
||||||
RiCloseLine,
|
RiCloseLine,
|
||||||
|
RiArrowDownSLine,
|
||||||
} from '@remixicon/react';
|
} from '@remixicon/react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||||
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
|
import { useSelectionStore } from '@/sync/selection-store';
|
||||||
|
import * as sessionActions from '@/sync/session-actions';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { useMessageStore } from '@/stores/messageStore';
|
|
||||||
import { useContextStore } from '@/stores/contextStore';
|
import { useContextStore } from '@/stores/contextStore';
|
||||||
import { validateWorktreeCreate, createWorktree } from '@/lib/worktrees/worktreeManager';
|
import { validateWorktreeCreate, createWorktree } from '@/lib/worktrees/worktreeManager';
|
||||||
import { withWorktreeUpstreamDefaults } from '@/lib/worktrees/worktreeCreate';
|
import { withWorktreeUpstreamDefaults } from '@/lib/worktrees/worktreeCreate';
|
||||||
@@ -44,6 +50,7 @@ import { getWorktreeSetupCommands } from '@/lib/openchamberConfig';
|
|||||||
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
|
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
|
||||||
import { generateBranchSlug } from '@/lib/git/branchNameGenerator';
|
import { generateBranchSlug } from '@/lib/git/branchNameGenerator';
|
||||||
import { opencodeClient } from '@/lib/opencode/client';
|
import { opencodeClient } from '@/lib/opencode/client';
|
||||||
|
import { rankBranchesForQuery } from '@/lib/worktrees/branchSearch';
|
||||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||||
import { useGitBranches, useGitStore } from '@/stores/useGitStore';
|
import { useGitBranches, useGitStore } from '@/stores/useGitStore';
|
||||||
import { GitHubIntegrationDialog } from './GitHubIntegrationDialog';
|
import { GitHubIntegrationDialog } from './GitHubIntegrationDialog';
|
||||||
@@ -233,12 +240,6 @@ export function NewWorktreeDialog({
|
|||||||
const isLoadingBranches = useGitStore((state) => state.isLoadingBranches);
|
const isLoadingBranches = useGitStore((state) => state.isLoadingBranches);
|
||||||
const fetchBranches = useGitStore((state) => state.fetchBranches);
|
const fetchBranches = useGitStore((state) => state.fetchBranches);
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!open || !projectDirectory || !git) return;
|
|
||||||
if (branches?.all) return;
|
|
||||||
void fetchBranches(projectDirectory, git);
|
|
||||||
}, [open, projectDirectory, git, branches?.all, fetchBranches]);
|
|
||||||
|
|
||||||
// Compute local and remote branch lists (same pattern as GitView)
|
// Compute local and remote branch lists (same pattern as GitView)
|
||||||
const localBranches = React.useMemo(() => {
|
const localBranches = React.useMemo(() => {
|
||||||
if (!branches?.all) return [];
|
if (!branches?.all) return [];
|
||||||
@@ -256,8 +257,7 @@ export function NewWorktreeDialog({
|
|||||||
}, [branches]);
|
}, [branches]);
|
||||||
|
|
||||||
// Get existing worktrees for the current project to avoid conflicts
|
// Get existing worktrees for the current project to avoid conflicts
|
||||||
const availableWorktreesByProject = useSessionStore((state) => state.availableWorktreesByProject);
|
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
|
||||||
const loadSessions = useSessionStore((state) => state.loadSessions);
|
|
||||||
const existingWorktreeNames = React.useMemo(() => {
|
const existingWorktreeNames = React.useMemo(() => {
|
||||||
if (!projectDirectory) return new Set<string>();
|
if (!projectDirectory) return new Set<string>();
|
||||||
const worktrees = availableWorktreesByProject.get(projectDirectory) ?? [];
|
const worktrees = availableWorktreesByProject.get(projectDirectory) ?? [];
|
||||||
@@ -278,10 +278,122 @@ export function NewWorktreeDialog({
|
|||||||
|
|
||||||
const [githubDialogOpen, setGithubDialogOpen] = React.useState(false);
|
const [githubDialogOpen, setGithubDialogOpen] = React.useState(false);
|
||||||
|
|
||||||
|
// Desktop branch picker states
|
||||||
|
const [existingBranchDropdownOpen, setExistingBranchDropdownOpen] = React.useState(false);
|
||||||
|
const [sourceBranchDropdownOpen, setSourceBranchDropdownOpen] = React.useState(false);
|
||||||
|
|
||||||
// Mobile branch picker states
|
// Mobile branch picker states
|
||||||
const [existingBranchPickerOpen, setExistingBranchPickerOpen] = React.useState(false);
|
const [existingBranchPickerOpen, setExistingBranchPickerOpen] = React.useState(false);
|
||||||
const [sourceBranchPickerOpen, setSourceBranchPickerOpen] = React.useState(false);
|
const [sourceBranchPickerOpen, setSourceBranchPickerOpen] = React.useState(false);
|
||||||
|
|
||||||
|
// Shared query state per picker (desktop + mobile)
|
||||||
|
const [existingBranchQuery, setExistingBranchQuery] = React.useState('');
|
||||||
|
const [sourceBranchQuery, setSourceBranchQuery] = React.useState('');
|
||||||
|
const existingBranchDropdownContentRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
|
const sourceBranchDropdownContentRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
|
const existingBranchMobileListWrapperRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
|
const sourceBranchMobileListWrapperRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
const findScrollableContainer = React.useCallback((startNode: HTMLElement | null): HTMLElement | null => {
|
||||||
|
let node: HTMLElement | null = startNode;
|
||||||
|
while (node && node !== document.body) {
|
||||||
|
const { overflowY } = window.getComputedStyle(node);
|
||||||
|
if ((overflowY === 'auto' || overflowY === 'scroll') && node.scrollHeight > node.clientHeight) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
node = node.parentElement;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetScrollToTop = React.useCallback((container: HTMLElement | null) => {
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.scrollTop = 0;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetDesktopPickerScroll = React.useCallback((contentRef: React.RefObject<HTMLDivElement | null>) => {
|
||||||
|
const list = contentRef.current?.querySelector<HTMLElement>('[data-slot="command-list"]') ?? null;
|
||||||
|
resetScrollToTop(list);
|
||||||
|
}, [resetScrollToTop]);
|
||||||
|
|
||||||
|
const resetMobilePickerScroll = React.useCallback((wrapperRef: React.RefObject<HTMLDivElement | null>) => {
|
||||||
|
const scrollContainer = findScrollableContainer(wrapperRef.current);
|
||||||
|
resetScrollToTop(scrollContainer);
|
||||||
|
}, [findScrollableContainer, resetScrollToTop]);
|
||||||
|
|
||||||
|
const existingBranchRankedGroups = React.useMemo(() => {
|
||||||
|
return rankBranchesForQuery({
|
||||||
|
localBranches,
|
||||||
|
remoteBranches,
|
||||||
|
query: existingBranchQuery,
|
||||||
|
});
|
||||||
|
}, [localBranches, remoteBranches, existingBranchQuery]);
|
||||||
|
|
||||||
|
const sourceBranchRankedGroups = React.useMemo(() => {
|
||||||
|
return rankBranchesForQuery({
|
||||||
|
localBranches,
|
||||||
|
remoteBranches,
|
||||||
|
query: sourceBranchQuery,
|
||||||
|
});
|
||||||
|
}, [localBranches, remoteBranches, sourceBranchQuery]);
|
||||||
|
|
||||||
|
const hasExistingBranchQuery = existingBranchQuery.trim().length > 0;
|
||||||
|
const hasSourceBranchQuery = sourceBranchQuery.trim().length > 0;
|
||||||
|
const hasExistingBranchMatches = existingBranchRankedGroups.matching.length > 0;
|
||||||
|
const hasSourceBranchMatches = sourceBranchRankedGroups.matching.length > 0;
|
||||||
|
const canFetchBranches = Boolean(projectDirectory && git);
|
||||||
|
|
||||||
|
const handleFetchBranches = React.useCallback(() => {
|
||||||
|
if (!projectDirectory || !git) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void fetchBranches(projectDirectory, git);
|
||||||
|
}, [projectDirectory, git, fetchBranches]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!existingBranchDropdownOpen && !existingBranchPickerOpen) {
|
||||||
|
setExistingBranchQuery('');
|
||||||
|
}
|
||||||
|
}, [existingBranchDropdownOpen, existingBranchPickerOpen]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!sourceBranchDropdownOpen && !sourceBranchPickerOpen) {
|
||||||
|
setSourceBranchQuery('');
|
||||||
|
}
|
||||||
|
}, [sourceBranchDropdownOpen, sourceBranchPickerOpen]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (existingBranchDropdownOpen) {
|
||||||
|
resetDesktopPickerScroll(existingBranchDropdownContentRef);
|
||||||
|
}
|
||||||
|
if (existingBranchPickerOpen) {
|
||||||
|
resetMobilePickerScroll(existingBranchMobileListWrapperRef);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
existingBranchDropdownOpen,
|
||||||
|
existingBranchPickerOpen,
|
||||||
|
existingBranchQuery,
|
||||||
|
resetDesktopPickerScroll,
|
||||||
|
resetMobilePickerScroll,
|
||||||
|
]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (sourceBranchDropdownOpen) {
|
||||||
|
resetDesktopPickerScroll(sourceBranchDropdownContentRef);
|
||||||
|
}
|
||||||
|
if (sourceBranchPickerOpen) {
|
||||||
|
resetMobilePickerScroll(sourceBranchMobileListWrapperRef);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
sourceBranchDropdownOpen,
|
||||||
|
sourceBranchPickerOpen,
|
||||||
|
sourceBranchQuery,
|
||||||
|
resetDesktopPickerScroll,
|
||||||
|
resetMobilePickerScroll,
|
||||||
|
]);
|
||||||
|
|
||||||
// Validation state
|
// Validation state
|
||||||
const [validation, setValidation] = React.useState<ValidationState>({
|
const [validation, setValidation] = React.useState<ValidationState>({
|
||||||
isValidating: false,
|
isValidating: false,
|
||||||
@@ -399,7 +511,7 @@ export function NewWorktreeDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const configState = useConfigStore.getState();
|
const configState = useConfigStore.getState();
|
||||||
const lastUsedProvider = useMessageStore.getState().lastUsedProvider;
|
const lastUsedProvider = useSelectionStore.getState().lastUsedProvider;
|
||||||
const defaultModel = resolveDefaultModelSelection();
|
const defaultModel = resolveDefaultModelSelection();
|
||||||
const providerID = defaultModel?.providerID || configState.currentProviderId || lastUsedProvider?.providerID;
|
const providerID = defaultModel?.providerID || configState.currentProviderId || lastUsedProvider?.providerID;
|
||||||
const modelID = defaultModel?.modelID || configState.currentModelId || lastUsedProvider?.modelID;
|
const modelID = defaultModel?.modelID || configState.currentModelId || lastUsedProvider?.modelID;
|
||||||
@@ -630,6 +742,12 @@ Nice-to-have:
|
|||||||
selectedBranch: '',
|
selectedBranch: '',
|
||||||
worktreeName: '',
|
worktreeName: '',
|
||||||
});
|
});
|
||||||
|
setExistingBranchDropdownOpen(false);
|
||||||
|
setSourceBranchDropdownOpen(false);
|
||||||
|
setExistingBranchPickerOpen(false);
|
||||||
|
setSourceBranchPickerOpen(false);
|
||||||
|
setExistingBranchQuery('');
|
||||||
|
setSourceBranchQuery('');
|
||||||
setValidation({
|
setValidation({
|
||||||
isValidating: false,
|
isValidating: false,
|
||||||
branchError: null,
|
branchError: null,
|
||||||
@@ -847,16 +965,16 @@ Nice-to-have:
|
|||||||
? `#${linkedPrState.number} ${linkedPrState.title}`.trim()
|
? `#${linkedPrState.number} ${linkedPrState.title}`.trim()
|
||||||
: 'New session';
|
: 'New session';
|
||||||
|
|
||||||
const session = await useSessionStore.getState().createSession(sessionTitle, metadata.path, null);
|
const session = await sessionActions.createSession(sessionTitle, metadata.path, null);
|
||||||
if (!session?.id) {
|
if (!session?.id) {
|
||||||
throw new Error('Failed to create session');
|
throw new Error('Failed to create session');
|
||||||
}
|
}
|
||||||
|
|
||||||
createdSessionId = session.id;
|
createdSessionId = session.id;
|
||||||
void useSessionStore.getState().updateSessionTitle(session.id, sessionTitle).catch(() => undefined);
|
void sessionActions.updateSessionTitle(session.id, sessionTitle).catch(() => undefined);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
useSessionStore.getState().initializeNewOpenChamberSession(session.id, useConfigStore.getState().agents);
|
useSessionUIStore.getState().initializeNewOpenChamberSession(session.id, useConfigStore.getState().agents);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -871,8 +989,6 @@ Nice-to-have:
|
|||||||
description: `${metadata.branch || metadata.name}${sourceLabel ? ` from ${sourceLabel}` : ''} - bootstrapping in background`,
|
description: `${metadata.branch || metadata.name}${sourceLabel ? ` from ${sourceLabel}` : ''} - bootstrapping in background`,
|
||||||
});
|
});
|
||||||
|
|
||||||
void loadSessions().catch(() => undefined);
|
|
||||||
|
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
|
|
||||||
if (createdSessionId) {
|
if (createdSessionId) {
|
||||||
@@ -1037,17 +1153,29 @@ Nice-to-have:
|
|||||||
<label className="typography-ui-label text-foreground block font-semibold">
|
<label className="typography-ui-label text-foreground block font-semibold">
|
||||||
Select Branch
|
Select Branch
|
||||||
</label>
|
</label>
|
||||||
<Button
|
<div className="flex items-center gap-2">
|
||||||
variant="outline"
|
<Button
|
||||||
size="sm"
|
variant="outline"
|
||||||
onClick={() => setExistingBranchPickerOpen(true)}
|
size="sm"
|
||||||
className="w-full justify-between h-9"
|
onClick={() => setExistingBranchPickerOpen(true)}
|
||||||
>
|
className="flex-1 justify-between h-9"
|
||||||
<span className={existingBranchState.selectedBranch ? 'text-foreground' : 'text-muted-foreground'}>
|
>
|
||||||
{existingBranchState.selectedBranch || 'Choose a branch...'}
|
<span className={existingBranchState.selectedBranch ? 'text-foreground' : 'text-muted-foreground'}>
|
||||||
</span>
|
{existingBranchState.selectedBranch || 'Choose a branch...'}
|
||||||
<RiGitBranchLine className="h-4 w-4 text-muted-foreground" />
|
</span>
|
||||||
</Button>
|
<RiGitBranchLine className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 px-0 shrink-0"
|
||||||
|
onClick={handleFetchBranches}
|
||||||
|
disabled={!canFetchBranches || isLoadingBranches}
|
||||||
|
title="Fetch branches"
|
||||||
|
>
|
||||||
|
{isLoadingBranches ? <RiLoader4Line className="size-4 animate-spin" /> : <RiRefreshLine className="size-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Mobile Branch Picker Overlay */}
|
{/* Mobile Branch Picker Overlay */}
|
||||||
<MobileOverlayPanel
|
<MobileOverlayPanel
|
||||||
@@ -1055,7 +1183,13 @@ Nice-to-have:
|
|||||||
title="Select Branch"
|
title="Select Branch"
|
||||||
onClose={() => setExistingBranchPickerOpen(false)}
|
onClose={() => setExistingBranchPickerOpen(false)}
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4" ref={existingBranchMobileListWrapperRef}>
|
||||||
|
<Input
|
||||||
|
value={existingBranchQuery}
|
||||||
|
onChange={(e) => setExistingBranchQuery(e.target.value)}
|
||||||
|
placeholder="Search branches..."
|
||||||
|
className="h-8"
|
||||||
|
/>
|
||||||
{isLoadingBranches ? (
|
{isLoadingBranches ? (
|
||||||
<div className="px-2 py-8 text-center typography-small text-muted-foreground">
|
<div className="px-2 py-8 text-center typography-small text-muted-foreground">
|
||||||
Loading branches...
|
Loading branches...
|
||||||
@@ -1065,14 +1199,52 @@ Nice-to-have:
|
|||||||
No branches found
|
No branches found
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div className="space-y-4">
|
||||||
{localBranches.length > 0 && (
|
{hasExistingBranchQuery && hasExistingBranchMatches && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="typography-small font-semibold text-foreground px-2">
|
<div className="typography-small font-semibold text-foreground px-2">
|
||||||
Local branches
|
Matching branches
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{localBranches.map(branch => (
|
{existingBranchRankedGroups.matching.map((branch) => (
|
||||||
|
<button
|
||||||
|
key={`${branch.source}-${branch.value}`}
|
||||||
|
onClick={() => {
|
||||||
|
setExistingBranchState(prev => ({
|
||||||
|
...prev,
|
||||||
|
selectedBranch: branch.value,
|
||||||
|
worktreeName: slugifyWorktreeName(branch.label),
|
||||||
|
}));
|
||||||
|
setValidation(prev => ({ ...prev, touched: true }));
|
||||||
|
setExistingBranchPickerOpen(false);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'w-full text-left px-3 py-2.5 rounded-md transition-colors',
|
||||||
|
existingBranchState.selectedBranch === branch.value
|
||||||
|
? 'bg-interactive-selection text-interactive-selection-foreground'
|
||||||
|
: 'hover:bg-interactive-hover'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="typography-small break-all">{branch.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasExistingBranchQuery && !hasExistingBranchMatches && (
|
||||||
|
<div className="px-2 py-1 text-center typography-small text-muted-foreground">
|
||||||
|
No matching branches
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{existingBranchRankedGroups.otherLocal.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="typography-small font-semibold text-foreground px-2">
|
||||||
|
{hasExistingBranchQuery ? 'Other local branches' : 'Local branches'}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{existingBranchRankedGroups.otherLocal.map((branch) => (
|
||||||
<button
|
<button
|
||||||
key={branch}
|
key={branch}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -1097,13 +1269,14 @@ Nice-to-have:
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{remoteBranches.length > 0 && (
|
|
||||||
|
{existingBranchRankedGroups.otherRemote.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="typography-small font-semibold text-foreground px-2">
|
<div className="typography-small font-semibold text-foreground px-2">
|
||||||
Remote branches
|
{hasExistingBranchQuery ? 'Other remote branches' : 'Remote branches'}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{remoteBranches.map(branch => (
|
{existingBranchRankedGroups.otherRemote.map((branch) => (
|
||||||
<button
|
<button
|
||||||
key={`remotes/${branch}`}
|
key={`remotes/${branch}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -1128,7 +1301,7 @@ Nice-to-have:
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</MobileOverlayPanel>
|
</MobileOverlayPanel>
|
||||||
@@ -1274,7 +1447,13 @@ Nice-to-have:
|
|||||||
title="Select Source Branch"
|
title="Select Source Branch"
|
||||||
onClose={() => setSourceBranchPickerOpen(false)}
|
onClose={() => setSourceBranchPickerOpen(false)}
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4" ref={sourceBranchMobileListWrapperRef}>
|
||||||
|
<Input
|
||||||
|
value={sourceBranchQuery}
|
||||||
|
onChange={(e) => setSourceBranchQuery(e.target.value)}
|
||||||
|
placeholder="Search branches..."
|
||||||
|
className="h-8"
|
||||||
|
/>
|
||||||
{isLoadingBranches ? (
|
{isLoadingBranches ? (
|
||||||
<div className="px-2 py-8 text-center typography-small text-muted-foreground">
|
<div className="px-2 py-8 text-center typography-small text-muted-foreground">
|
||||||
Loading branches...
|
Loading branches...
|
||||||
@@ -1284,14 +1463,47 @@ Nice-to-have:
|
|||||||
No branches found
|
No branches found
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div className="space-y-4">
|
||||||
{localBranches.length > 0 && (
|
{hasSourceBranchQuery && hasSourceBranchMatches && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="typography-small font-semibold text-foreground px-2">
|
<div className="typography-small font-semibold text-foreground px-2">
|
||||||
Local branches
|
Matching branches
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{localBranches.map(branch => (
|
{sourceBranchRankedGroups.matching.map((branch) => (
|
||||||
|
<button
|
||||||
|
key={`${branch.source}-${branch.value}`}
|
||||||
|
onClick={() => {
|
||||||
|
setNewBranchState(prev => ({ ...prev, sourceBranch: branch.value }));
|
||||||
|
setSourceBranchPickerOpen(false);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'w-full text-left px-3 py-2.5 rounded-md transition-colors',
|
||||||
|
newBranchState.sourceBranch === branch.value
|
||||||
|
? 'bg-interactive-selection text-interactive-selection-foreground'
|
||||||
|
: 'hover:bg-interactive-hover'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="typography-small break-all">{branch.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasSourceBranchQuery && !hasSourceBranchMatches && (
|
||||||
|
<div className="px-2 py-1 text-center typography-small text-muted-foreground">
|
||||||
|
No matching branches
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sourceBranchRankedGroups.otherLocal.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="typography-small font-semibold text-foreground px-2">
|
||||||
|
{hasSourceBranchQuery ? 'Other local branches' : 'Local branches'}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{sourceBranchRankedGroups.otherLocal.map((branch) => (
|
||||||
<button
|
<button
|
||||||
key={branch}
|
key={branch}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -1311,13 +1523,14 @@ Nice-to-have:
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{remoteBranches.length > 0 && (
|
|
||||||
|
{sourceBranchRankedGroups.otherRemote.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="typography-small font-semibold text-foreground px-2">
|
<div className="typography-small font-semibold text-foreground px-2">
|
||||||
Remote branches
|
{hasSourceBranchQuery ? 'Other remote branches' : 'Remote branches'}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{remoteBranches.map(branch => (
|
{sourceBranchRankedGroups.otherRemote.map((branch) => (
|
||||||
<button
|
<button
|
||||||
key={`remotes/${branch}`}
|
key={`remotes/${branch}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -1337,7 +1550,7 @@ Nice-to-have:
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</MobileOverlayPanel>
|
</MobileOverlayPanel>
|
||||||
@@ -1435,59 +1648,129 @@ Nice-to-have:
|
|||||||
<label className="typography-ui-label text-foreground block font-semibold">
|
<label className="typography-ui-label text-foreground block font-semibold">
|
||||||
Select Branch
|
Select Branch
|
||||||
</label>
|
</label>
|
||||||
<Select
|
<div className="flex items-center gap-2">
|
||||||
value={existingBranchState.selectedBranch}
|
<DropdownMenu open={existingBranchDropdownOpen} onOpenChange={setExistingBranchDropdownOpen}>
|
||||||
onValueChange={(value) => {
|
<DropdownMenuTrigger asChild>
|
||||||
setExistingBranchState(prev => ({
|
<Button variant="outline" size="sm" className="h-9 min-w-[220px] max-w-full justify-between gap-2">
|
||||||
...prev,
|
<span className={cn('truncate', existingBranchState.selectedBranch ? 'text-foreground' : 'text-muted-foreground')}>
|
||||||
selectedBranch: value,
|
{existingBranchState.selectedBranch || 'Choose a branch...'}
|
||||||
worktreeName: slugifyWorktreeName(value),
|
</span>
|
||||||
}));
|
<RiArrowDownSLine className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
setValidation(prev => ({ ...prev, touched: true }));
|
</Button>
|
||||||
}}
|
</DropdownMenuTrigger>
|
||||||
>
|
<DropdownMenuContent align="start" className="w-[320px] p-0" ref={existingBranchDropdownContentRef}>
|
||||||
<SelectTrigger size="lg" className="w-fit">
|
<Command shouldFilter={false}>
|
||||||
<SelectValue placeholder="Choose a branch..." />
|
<CommandInput
|
||||||
</SelectTrigger>
|
placeholder="Search branches..."
|
||||||
<SelectContent className="max-h-[280px] max-w-[320px]">
|
value={existingBranchQuery}
|
||||||
{isLoadingBranches ? (
|
onValueChange={setExistingBranchQuery}
|
||||||
<div className="px-2 py-4 text-center typography-small text-muted-foreground">
|
/>
|
||||||
Loading branches...
|
<CommandList disableHorizontal>
|
||||||
</div>
|
{isLoadingBranches ? (
|
||||||
) : localBranches.length === 0 && remoteBranches.length === 0 ? (
|
<div className="px-2 py-4 text-center typography-small text-muted-foreground">
|
||||||
<div className="px-2 py-4 text-center typography-small text-muted-foreground">
|
Loading branches...
|
||||||
No branches found
|
</div>
|
||||||
</div>
|
) : localBranches.length === 0 && remoteBranches.length === 0 ? (
|
||||||
) : (
|
<CommandEmpty>No branches found</CommandEmpty>
|
||||||
<>
|
) : (
|
||||||
{localBranches.length > 0 && (
|
<>
|
||||||
<SelectGroup>
|
{hasExistingBranchQuery && hasExistingBranchMatches && (
|
||||||
<SelectLabel className="typography-small font-semibold text-foreground">Local branches</SelectLabel>
|
<CommandGroup heading="Matching branches">
|
||||||
{localBranches.map(branch => (
|
{existingBranchRankedGroups.matching.map((branch) => (
|
||||||
<SelectItem key={branch} value={branch} className="whitespace-normal break-all">
|
<CommandItem
|
||||||
{branch}
|
key={`${branch.source}-${branch.value}`}
|
||||||
</SelectItem>
|
value={branch.value}
|
||||||
))}
|
onSelect={() => {
|
||||||
</SelectGroup>
|
setExistingBranchState((prev) => ({
|
||||||
)}
|
...prev,
|
||||||
{localBranches.length > 0 && remoteBranches.length > 0 && (
|
selectedBranch: branch.value,
|
||||||
<SelectSeparator />
|
worktreeName: slugifyWorktreeName(branch.label),
|
||||||
)}
|
}));
|
||||||
{remoteBranches.length > 0 && (
|
setValidation((prev) => ({ ...prev, touched: true }));
|
||||||
<SelectGroup>
|
setExistingBranchDropdownOpen(false);
|
||||||
<SelectLabel className="typography-small font-semibold text-foreground">Remote branches</SelectLabel>
|
}}
|
||||||
{remoteBranches.map(branch => (
|
>
|
||||||
<SelectItem key={`remotes/${branch}`} value={`remotes/${branch}`} className="whitespace-normal break-all">
|
<span className="typography-small break-all">{branch.label}</span>
|
||||||
{branch}
|
</CommandItem>
|
||||||
</SelectItem>
|
))}
|
||||||
))}
|
</CommandGroup>
|
||||||
</SelectGroup>
|
)}
|
||||||
)}
|
|
||||||
</>
|
{hasExistingBranchQuery && !hasExistingBranchMatches && (
|
||||||
)}
|
<div className="px-2 py-1 text-center typography-small text-muted-foreground">
|
||||||
</SelectContent>
|
No matching branches
|
||||||
</Select>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
|
{existingBranchRankedGroups.otherLocal.length > 0 && (
|
||||||
|
<>
|
||||||
|
{hasExistingBranchQuery && <CommandSeparator />}
|
||||||
|
<CommandGroup heading={hasExistingBranchQuery ? 'Other local branches' : 'Local branches'}>
|
||||||
|
{existingBranchRankedGroups.otherLocal.map((branch) => (
|
||||||
|
<CommandItem
|
||||||
|
key={`local-${branch}`}
|
||||||
|
value={branch}
|
||||||
|
onSelect={() => {
|
||||||
|
setExistingBranchState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
selectedBranch: branch,
|
||||||
|
worktreeName: slugifyWorktreeName(branch),
|
||||||
|
}));
|
||||||
|
setValidation((prev) => ({ ...prev, touched: true }));
|
||||||
|
setExistingBranchDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="typography-small break-all">{branch}</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{existingBranchRankedGroups.otherRemote.length > 0 && (
|
||||||
|
<>
|
||||||
|
{(existingBranchRankedGroups.otherLocal.length > 0 || hasExistingBranchQuery) && (
|
||||||
|
<CommandSeparator />
|
||||||
|
)}
|
||||||
|
<CommandGroup heading={hasExistingBranchQuery ? 'Other remote branches' : 'Remote branches'}>
|
||||||
|
{existingBranchRankedGroups.otherRemote.map((branch) => (
|
||||||
|
<CommandItem
|
||||||
|
key={`remote-${branch}`}
|
||||||
|
value={`remotes/${branch}`}
|
||||||
|
onSelect={() => {
|
||||||
|
setExistingBranchState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
selectedBranch: `remotes/${branch}`,
|
||||||
|
worktreeName: slugifyWorktreeName(branch),
|
||||||
|
}));
|
||||||
|
setValidation((prev) => ({ ...prev, touched: true }));
|
||||||
|
setExistingBranchDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="typography-small break-all">{branch}</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 px-0 shrink-0"
|
||||||
|
onClick={handleFetchBranches}
|
||||||
|
disabled={!canFetchBranches || isLoadingBranches}
|
||||||
|
title="Fetch branches"
|
||||||
|
>
|
||||||
|
{isLoadingBranches ? <RiLoader4Line className="size-4 animate-spin" /> : <RiRefreshLine className="size-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -1606,51 +1889,101 @@ Nice-to-have:
|
|||||||
<label className="typography-ui-label text-foreground block font-semibold">
|
<label className="typography-ui-label text-foreground block font-semibold">
|
||||||
Source Branch
|
Source Branch
|
||||||
</label>
|
</label>
|
||||||
<Select
|
<DropdownMenu open={sourceBranchDropdownOpen} onOpenChange={setSourceBranchDropdownOpen}>
|
||||||
value={newBranchState.sourceBranch}
|
<DropdownMenuTrigger asChild>
|
||||||
onValueChange={(value) => setNewBranchState(prev => ({ ...prev, sourceBranch: value }))}
|
<Button variant="outline" size="sm" className="h-9 min-w-[220px] max-w-full justify-between gap-2">
|
||||||
>
|
<span className={cn('truncate', newBranchState.sourceBranch ? 'text-foreground' : 'text-muted-foreground')}>
|
||||||
<SelectTrigger size="lg" className="w-fit">
|
{newBranchState.sourceBranch || 'Select source branch...'}
|
||||||
<SelectValue placeholder="Select source branch..." />
|
</span>
|
||||||
</SelectTrigger>
|
<RiArrowDownSLine className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
<SelectContent className="max-h-[280px] max-w-[320px]">
|
</Button>
|
||||||
{isLoadingBranches ? (
|
</DropdownMenuTrigger>
|
||||||
<div className="px-2 py-4 text-center typography-small text-muted-foreground">
|
<DropdownMenuContent align="start" className="w-[320px] p-0" ref={sourceBranchDropdownContentRef}>
|
||||||
Loading branches...
|
<Command shouldFilter={false}>
|
||||||
</div>
|
<CommandInput
|
||||||
) : localBranches.length === 0 && remoteBranches.length === 0 ? (
|
placeholder="Search branches..."
|
||||||
<div className="px-2 py-4 text-center typography-small text-muted-foreground">
|
value={sourceBranchQuery}
|
||||||
No branches found
|
onValueChange={setSourceBranchQuery}
|
||||||
</div>
|
/>
|
||||||
) : (
|
<CommandList disableHorizontal>
|
||||||
<>
|
{isLoadingBranches ? (
|
||||||
{localBranches.length > 0 && (
|
<div className="px-2 py-4 text-center typography-small text-muted-foreground">
|
||||||
<SelectGroup>
|
Loading branches...
|
||||||
<SelectLabel className="typography-small font-semibold text-foreground">Local branches</SelectLabel>
|
</div>
|
||||||
{localBranches.map(branch => (
|
) : localBranches.length === 0 && remoteBranches.length === 0 ? (
|
||||||
<SelectItem key={branch} value={branch} className="whitespace-normal break-all">
|
<CommandEmpty>No branches found</CommandEmpty>
|
||||||
{branch}
|
) : (
|
||||||
</SelectItem>
|
<>
|
||||||
))}
|
{hasSourceBranchQuery && hasSourceBranchMatches && (
|
||||||
</SelectGroup>
|
<CommandGroup heading="Matching branches">
|
||||||
|
{sourceBranchRankedGroups.matching.map((branch) => (
|
||||||
|
<CommandItem
|
||||||
|
key={`${branch.source}-${branch.value}`}
|
||||||
|
value={branch.value}
|
||||||
|
onSelect={() => {
|
||||||
|
setNewBranchState((prev) => ({ ...prev, sourceBranch: branch.value }));
|
||||||
|
setSourceBranchDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="typography-small break-all">{branch.label}</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasSourceBranchQuery && !hasSourceBranchMatches && (
|
||||||
|
<div className="px-2 py-1 text-center typography-small text-muted-foreground">
|
||||||
|
No matching branches
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sourceBranchRankedGroups.otherLocal.length > 0 && (
|
||||||
|
<>
|
||||||
|
{hasSourceBranchQuery && <CommandSeparator />}
|
||||||
|
<CommandGroup heading={hasSourceBranchQuery ? 'Other local branches' : 'Local branches'}>
|
||||||
|
{sourceBranchRankedGroups.otherLocal.map((branch) => (
|
||||||
|
<CommandItem
|
||||||
|
key={`local-${branch}`}
|
||||||
|
value={branch}
|
||||||
|
onSelect={() => {
|
||||||
|
setNewBranchState((prev) => ({ ...prev, sourceBranch: branch }));
|
||||||
|
setSourceBranchDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="typography-small break-all">{branch}</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sourceBranchRankedGroups.otherRemote.length > 0 && (
|
||||||
|
<>
|
||||||
|
{(sourceBranchRankedGroups.otherLocal.length > 0 || hasSourceBranchQuery) && (
|
||||||
|
<CommandSeparator />
|
||||||
|
)}
|
||||||
|
<CommandGroup heading={hasSourceBranchQuery ? 'Other remote branches' : 'Remote branches'}>
|
||||||
|
{sourceBranchRankedGroups.otherRemote.map((branch) => (
|
||||||
|
<CommandItem
|
||||||
|
key={`remote-${branch}`}
|
||||||
|
value={`remotes/${branch}`}
|
||||||
|
onSelect={() => {
|
||||||
|
setNewBranchState((prev) => ({ ...prev, sourceBranch: `remotes/${branch}` }));
|
||||||
|
setSourceBranchDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="typography-small break-all">{branch}</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{localBranches.length > 0 && remoteBranches.length > 0 && (
|
</CommandList>
|
||||||
<SelectSeparator />
|
</Command>
|
||||||
)}
|
</DropdownMenuContent>
|
||||||
{remoteBranches.length > 0 && (
|
</DropdownMenu>
|
||||||
<SelectGroup>
|
|
||||||
<SelectLabel className="typography-small font-semibold text-foreground">Remote branches</SelectLabel>
|
|
||||||
{remoteBranches.map(branch => (
|
|
||||||
<SelectItem key={`remotes/${branch}`} value={`remotes/${branch}`} className="whitespace-normal break-all">
|
|
||||||
{branch}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectGroup>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
{newBranchState.sourceBranch && (
|
{newBranchState.sourceBranch && (
|
||||||
<div className="typography-micro text-muted-foreground">
|
<div className="typography-micro text-muted-foreground">
|
||||||
New branch will be created from {newBranchState.sourceBranch}
|
New branch will be created from {newBranchState.sourceBranch}
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ import {
|
|||||||
type ProjectRef,
|
type ProjectRef,
|
||||||
} from '@/lib/openchamberConfig';
|
} from '@/lib/openchamberConfig';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
|
import { useInputStore } from '@/sync/input-store';
|
||||||
import { createWorktreeDraft } from '@/lib/worktreeSessionCreator';
|
import { createWorktreeDraft } from '@/lib/worktreeSessionCreator';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -52,9 +53,9 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
|
|||||||
const [sendingTodoId, setSendingTodoId] = React.useState<string | null>(null);
|
const [sendingTodoId, setSendingTodoId] = React.useState<string | null>(null);
|
||||||
const [expandedTodoIds, setExpandedTodoIds] = React.useState<Set<string>>(() => new Set());
|
const [expandedTodoIds, setExpandedTodoIds] = React.useState<Set<string>>(() => new Set());
|
||||||
|
|
||||||
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||||
const openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft);
|
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
|
||||||
const setPendingInputText = useSessionStore((state) => state.setPendingInputText);
|
const setPendingInputText = useInputStore((state) => state.setPendingInputText);
|
||||||
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
|
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
|
||||||
const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen);
|
const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen);
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ import type { Session } from '@opencode-ai/sdk/v2';
|
|||||||
import type { WorktreeMetadata } from '@/types/worktree';
|
import type { WorktreeMetadata } from '@/types/worktree';
|
||||||
import { getWorktreeStatus } from '@/lib/worktrees/worktreeStatus';
|
import { getWorktreeStatus } from '@/lib/worktrees/worktreeStatus';
|
||||||
import { removeProjectWorktree } from '@/lib/worktrees/worktreeManager';
|
import { removeProjectWorktree } from '@/lib/worktrees/worktreeManager';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
|
import * as sessionActions from '@/sync/session-actions';
|
||||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
@@ -59,17 +60,14 @@ export const SessionDialogs: React.FC = () => {
|
|||||||
const [hasCompletedDirtyCheck, setHasCompletedDirtyCheck] = React.useState(false);
|
const [hasCompletedDirtyCheck, setHasCompletedDirtyCheck] = React.useState(false);
|
||||||
const [dirtyWorktreePaths, setDirtyWorktreePaths] = React.useState<Set<string>>(new Set());
|
const [dirtyWorktreePaths, setDirtyWorktreePaths] = React.useState<Set<string>>(new Set());
|
||||||
|
|
||||||
const {
|
const getWorktreeMetadata = useSessionUIStore((s) => s.getWorktreeMetadata);
|
||||||
deleteSession,
|
const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
|
||||||
deleteSessions,
|
const setNewSessionDraftTarget = useSessionUIStore((s) => s.setNewSessionDraftTarget);
|
||||||
archiveSession,
|
const setDraftBootstrapPendingDirectory = useSessionUIStore((s) => s.setDraftBootstrapPendingDirectory);
|
||||||
archiveSessions,
|
const deleteSession = sessionActions.deleteSession;
|
||||||
loadSessions,
|
const archiveSession = sessionActions.archiveSession;
|
||||||
getWorktreeMetadata,
|
const deleteSessions = useSessionUIStore((s) => s.deleteSessions);
|
||||||
newSessionDraft,
|
const archiveSessions = useSessionUIStore((s) => s.archiveSessions);
|
||||||
setNewSessionDraftTarget,
|
|
||||||
setDraftBootstrapPendingDirectory,
|
|
||||||
} = useSessionStore();
|
|
||||||
const showDeletionDialog = useUIStore((state) => state.showDeletionDialog);
|
const showDeletionDialog = useUIStore((state) => state.showDeletionDialog);
|
||||||
const setShowDeletionDialog = useUIStore((state) => state.setShowDeletionDialog);
|
const setShowDeletionDialog = useUIStore((state) => state.setShowDeletionDialog);
|
||||||
const { currentDirectory, homeDirectory, isHomeReady } = useDirectoryStore();
|
const { currentDirectory, homeDirectory, isHomeReady } = useDirectoryStore();
|
||||||
@@ -113,24 +111,7 @@ export const SessionDialogs: React.FC = () => {
|
|||||||
isProcessingDelete || !isWorktreeDelete || !canRemoveRemoteBranches;
|
isProcessingDelete || !isWorktreeDelete || !canRemoveRemoteBranches;
|
||||||
const deleteLocalOptionDisabled = isProcessingDelete || !isWorktreeDelete;
|
const deleteLocalOptionDisabled = isProcessingDelete || !isWorktreeDelete;
|
||||||
|
|
||||||
React.useEffect(() => {
|
// Session loading is handled by sync bootstrap — no manual loadSessions needed.
|
||||||
loadSessions();
|
|
||||||
}, [loadSessions, currentDirectory]);
|
|
||||||
|
|
||||||
const projectsKey = React.useMemo(
|
|
||||||
() => projects.map((project) => `${project.id}:${project.path}`).join('|'),
|
|
||||||
[projects],
|
|
||||||
);
|
|
||||||
const lastProjectsKeyRef = React.useRef(projectsKey);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (projectsKey === lastProjectsKeyRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
lastProjectsKeyRef.current = projectsKey;
|
|
||||||
loadSessions();
|
|
||||||
}, [loadSessions, projectsKey]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (hasShownInitialDirectoryPrompt || !isHomeReady || projects.length > 0) {
|
if (hasShownInitialDirectoryPrompt || !isHomeReady || projects.length > 0) {
|
||||||
@@ -444,7 +425,6 @@ export const SessionDialogs: React.FC = () => {
|
|||||||
description: renderToastDescription(archiveNote),
|
description: renderToastDescription(archiveNote),
|
||||||
});
|
});
|
||||||
closeDeleteDialog();
|
closeDeleteDialog();
|
||||||
loadSessions();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,10 +477,8 @@ export const SessionDialogs: React.FC = () => {
|
|||||||
if (isWorktreeDelete && deleteDialog.worktree && failedIds.length === 0) {
|
if (isWorktreeDelete && deleteDialog.worktree && failedIds.length === 0) {
|
||||||
// Remove selected worktree even if per-session metadata is missing.
|
// Remove selected worktree even if per-session metadata is missing.
|
||||||
// Use same projectRef logic as the no-sessions path.
|
// Use same projectRef logic as the no-sessions path.
|
||||||
const removed = await removeSelectedWorktree(deleteDialog.worktree, deleteLocalBranch);
|
await removeSelectedWorktree(deleteDialog.worktree, deleteLocalBranch);
|
||||||
if (removed) {
|
// sync handles session refresh automatically
|
||||||
await loadSessions();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (deletedIds.length > 0) {
|
if (deletedIds.length > 0) {
|
||||||
@@ -537,10 +515,8 @@ export const SessionDialogs: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isWorktreeDelete && deleteDialog.sessions.length === 1 && deleteDialog.worktree) {
|
if (isWorktreeDelete && deleteDialog.sessions.length === 1 && deleteDialog.worktree) {
|
||||||
const removed = await removeSelectedWorktree(deleteDialog.worktree, deleteLocalBranch);
|
await removeSelectedWorktree(deleteDialog.worktree, deleteLocalBranch);
|
||||||
if (removed) {
|
// sync bootstrap refreshes sessions automatically
|
||||||
await loadSessions();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
closeDeleteDialog();
|
closeDeleteDialog();
|
||||||
@@ -560,7 +536,6 @@ export const SessionDialogs: React.FC = () => {
|
|||||||
isWorktreeDelete,
|
isWorktreeDelete,
|
||||||
canRemoveRemoteBranches,
|
canRemoveRemoteBranches,
|
||||||
removeSelectedWorktree,
|
removeSelectedWorktree,
|
||||||
loadSessions,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const targetWorktree = deleteDialog?.worktree ?? deleteDialogSummaries[0]?.metadata ?? null;
|
const targetWorktree = deleteDialog?.worktree ?? deleteDialogSummaries[0]?.metadata ?? null;
|
||||||
|
|||||||
@@ -7,8 +7,12 @@ import { isDesktopLocalOriginActive, isDesktopShell, isTauriShell } from '@/lib/
|
|||||||
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||||
import { sessionEvents } from '@/lib/sessionEvents';
|
import { sessionEvents } from '@/lib/sessionEvents';
|
||||||
import { formatDirectoryName, cn } from '@/lib/utils';
|
import { formatDirectoryName, cn } from '@/lib/utils';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
|
import { useViewportStore } from '@/sync/viewport-store';
|
||||||
|
import { useSessions, useDirectorySync, useAllSessionStatuses } from '@/sync/sync-context';
|
||||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
|
import { useSync } from '@/sync/use-sync';
|
||||||
|
import { useSessionPrefetch } from './sidebar/hooks/useSessionPrefetch';
|
||||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import type { GitHubPullRequestStatus } from '@/lib/api/types';
|
import type { GitHubPullRequestStatus } from '@/lib/api/types';
|
||||||
@@ -26,7 +30,6 @@ import { useProjectSessionSelection } from './sidebar/hooks/useProjectSessionSel
|
|||||||
import { useGroupOrdering } from './sidebar/hooks/useGroupOrdering';
|
import { useGroupOrdering } from './sidebar/hooks/useGroupOrdering';
|
||||||
import { useSessionGrouping } from './sidebar/hooks/useSessionGrouping';
|
import { useSessionGrouping } from './sidebar/hooks/useSessionGrouping';
|
||||||
import { useSessionSearchEffects } from './sidebar/hooks/useSessionSearchEffects';
|
import { useSessionSearchEffects } from './sidebar/hooks/useSessionSearchEffects';
|
||||||
import { useSessionPrefetch } from './sidebar/hooks/useSessionPrefetch';
|
|
||||||
import { useDirectoryStatusProbe } from './sidebar/hooks/useDirectoryStatusProbe';
|
import { useDirectoryStatusProbe } from './sidebar/hooks/useDirectoryStatusProbe';
|
||||||
import { useSessionActions } from './sidebar/hooks/useSessionActions';
|
import { useSessionActions } from './sidebar/hooks/useSessionActions';
|
||||||
import { useSidebarPersistence } from './sidebar/hooks/useSidebarPersistence';
|
import { useSidebarPersistence } from './sidebar/hooks/useSidebarPersistence';
|
||||||
@@ -44,6 +47,9 @@ import { SidebarFooter } from './sidebar/SidebarFooter';
|
|||||||
import { SidebarProjectsList } from './sidebar/SidebarProjectsList';
|
import { SidebarProjectsList } from './sidebar/SidebarProjectsList';
|
||||||
import { SessionNodeItem } from './sidebar/SessionNodeItem';
|
import { SessionNodeItem } from './sidebar/SessionNodeItem';
|
||||||
import { useUpdateStore } from '@/stores/useUpdateStore';
|
import { useUpdateStore } from '@/stores/useUpdateStore';
|
||||||
|
import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager';
|
||||||
|
import { checkIsGitRepository } from '@/lib/gitApi';
|
||||||
|
import type { WorktreeMetadata } from '@/types/worktree';
|
||||||
import type { SortableDragHandleProps } from './sidebar/sortableItems';
|
import type { SortableDragHandleProps } from './sidebar/sortableItems';
|
||||||
import {
|
import {
|
||||||
FolderDeleteConfirmDialog,
|
FolderDeleteConfirmDialog,
|
||||||
@@ -64,6 +70,7 @@ import {
|
|||||||
formatProjectLabel,
|
formatProjectLabel,
|
||||||
normalizePath,
|
normalizePath,
|
||||||
} from './sidebar/utils';
|
} from './sidebar/utils';
|
||||||
|
import { refreshGlobalSessions, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||||
|
|
||||||
const PROJECT_COLLAPSE_STORAGE_KEY = 'oc.sessions.projectCollapse';
|
const PROJECT_COLLAPSE_STORAGE_KEY = 'oc.sessions.projectCollapse';
|
||||||
const GROUP_ORDER_STORAGE_KEY = 'oc.sessions.groupOrder';
|
const GROUP_ORDER_STORAGE_KEY = 'oc.sessions.groupOrder';
|
||||||
@@ -299,27 +306,93 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
|
|
||||||
const gitDirectories = useGitStore((state) => state.directories);
|
const gitDirectories = useGitStore((state) => state.directories);
|
||||||
|
|
||||||
const sessions = useSessionStore((state) => state.sessions);
|
const sync = useSync();
|
||||||
const archivedSessions = useSessionStore((state) => state.archivedSessions);
|
const syncSessions = useSessions();
|
||||||
const sessionsByDirectory = useSessionStore((state) => state.sessionsByDirectory);
|
const globalActiveSessions = useGlobalSessionsStore((state) => state.activeSessions);
|
||||||
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
const archivedSessions = useGlobalSessionsStore((state) => state.archivedSessions);
|
||||||
const newSessionDraftOpen = useSessionStore((state) => Boolean(state.newSessionDraft?.open));
|
const sessionsByDirectory = useGlobalSessionsStore((state) => state.sessionsByDirectory);
|
||||||
const setCurrentSession = useSessionStore((state) => state.setCurrentSession);
|
const hasLoadedGlobalSessions = useGlobalSessionsStore((state) => state.hasLoaded);
|
||||||
const loadMessages = useSessionStore((state) => state.loadMessages);
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||||
const updateSessionTitle = useSessionStore((state) => state.updateSessionTitle);
|
const newSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
|
||||||
const shareSession = useSessionStore((state) => state.shareSession);
|
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
|
||||||
const unshareSession = useSessionStore((state) => state.unshareSession);
|
const updateSessionTitle = useSessionUIStore((state) => state.updateSessionTitle);
|
||||||
const sessionMemoryState = useSessionStore((state) => state.sessionMemoryState);
|
const shareSession = useSessionUIStore((state) => state.shareSession);
|
||||||
const sessionStatus = useSessionStore((state) => state.sessionStatus);
|
const unshareSession = useSessionUIStore((state) => state.unshareSession);
|
||||||
const sessionAttentionStates = useSessionStore((state) => state.sessionAttentionStates);
|
const sessionMemoryState = useViewportStore((state) => state.sessionMemoryState);
|
||||||
const permissions = useSessionStore((state) => state.permissions);
|
const globalSessionStatuses = useAllSessionStatuses();
|
||||||
const worktreeMetadata = useSessionStore((state) => state.worktreeMetadata);
|
// sessionAttentionStates removed — now using notification-store directly in SessionNodeItem
|
||||||
const availableWorktreesByProject = useSessionStore((state) => state.availableWorktreesByProject);
|
const permissionsRecord = useDirectorySync((state) => state.permission);
|
||||||
const getSessionsByDirectory = useSessionStore((state) => state.getSessionsByDirectory);
|
|
||||||
const openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft);
|
const sessionStatus = React.useMemo(
|
||||||
|
() => new Map(Object.entries(globalSessionStatuses)),
|
||||||
|
[globalSessionStatuses],
|
||||||
|
);
|
||||||
|
const permissions = React.useMemo(
|
||||||
|
() => new Map(Object.entries(permissionsRecord)),
|
||||||
|
[permissionsRecord],
|
||||||
|
);
|
||||||
|
const worktreeMetadata = useSessionUIStore((state) => state.worktreeMetadata);
|
||||||
|
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
|
||||||
|
const getSessionsByDirectory = useSessionUIStore((state) => state.getSessionsByDirectory);
|
||||||
|
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
|
||||||
const prStatusEntries = useGitHubPrStatusStore((state) => state.entries);
|
const prStatusEntries = useGitHubPrStatusStore((state) => state.entries);
|
||||||
const updateStore = useUpdateStore();
|
const updateStore = useUpdateStore();
|
||||||
|
|
||||||
|
const sessions = React.useMemo(
|
||||||
|
() => (hasLoadedGlobalSessions ? globalActiveSessions : syncSessions),
|
||||||
|
[globalActiveSessions, hasLoadedGlobalSessions, syncSessions],
|
||||||
|
);
|
||||||
|
|
||||||
|
const syncSessionSignature = React.useMemo(
|
||||||
|
() => syncSessions
|
||||||
|
.map((session) => `${session.id}:${session.time?.updated ?? session.time?.created ?? 0}:${session.time?.archived ? 1 : 0}`)
|
||||||
|
.join('|'),
|
||||||
|
[syncSessions],
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const discoverWorktrees = async () => {
|
||||||
|
const projectEntries = useProjectsStore.getState().projects;
|
||||||
|
if (projectEntries.length === 0) return;
|
||||||
|
|
||||||
|
const worktreesByProject = new Map<string, WorktreeMetadata[]>();
|
||||||
|
const allWorktrees: WorktreeMetadata[] = [];
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
projectEntries.map(async (project) => {
|
||||||
|
const projectPath = normalizePath(project.path);
|
||||||
|
if (!projectPath) return;
|
||||||
|
try {
|
||||||
|
const isGitRepo = await checkIsGitRepository(projectPath);
|
||||||
|
if (!isGitRepo) return;
|
||||||
|
const worktrees = await listProjectWorktrees({ id: project.id, path: projectPath });
|
||||||
|
if (cancelled || worktrees.length === 0) return;
|
||||||
|
worktreesByProject.set(projectPath, worktrees);
|
||||||
|
allWorktrees.push(...worktrees);
|
||||||
|
} catch {
|
||||||
|
// ignore discovery errors
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
useSessionUIStore.setState({
|
||||||
|
availableWorktrees: allWorktrees,
|
||||||
|
availableWorktreesByProject: worktreesByProject,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
void refreshGlobalSessions(syncSessions);
|
||||||
|
void discoverWorktrees();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [currentDirectory, syncSessionSignature, syncSessions]);
|
||||||
|
|
||||||
const tauriIpcAvailable = React.useMemo(() => isTauriShell(), []);
|
const tauriIpcAvailable = React.useMemo(() => isTauriShell(), []);
|
||||||
const isDesktopShellRuntime = React.useMemo(() => isDesktopShell(), []);
|
const isDesktopShellRuntime = React.useMemo(() => isDesktopShell(), []);
|
||||||
const [isDesktopWindowFullscreen, setIsDesktopWindowFullscreen] = React.useState(false);
|
const [isDesktopWindowFullscreen, setIsDesktopWindowFullscreen] = React.useState(false);
|
||||||
@@ -614,10 +687,10 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
updateStore.available &&
|
updateStore.available &&
|
||||||
(updateStore.runtimeType === 'desktop' || updateStore.runtimeType === 'web');
|
(updateStore.runtimeType === 'desktop' || updateStore.runtimeType === 'web');
|
||||||
|
|
||||||
const deleteSession = useSessionStore((state) => state.deleteSession);
|
const deleteSession = useSessionUIStore((state) => state.deleteSession);
|
||||||
const deleteSessions = useSessionStore((state) => state.deleteSessions);
|
const deleteSessions = useSessionUIStore((state) => state.deleteSessions);
|
||||||
const archiveSession = useSessionStore((state) => state.archiveSession);
|
const archiveSession = useSessionUIStore((state) => state.archiveSession);
|
||||||
const archiveSessions = useSessionStore((state) => state.archiveSessions);
|
const archiveSessions = useSessionUIStore((state) => state.archiveSessions);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
copiedSessionId,
|
copiedSessionId,
|
||||||
@@ -820,7 +893,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
setProjectRootBranches,
|
setProjectRootBranches,
|
||||||
});
|
});
|
||||||
|
|
||||||
const isSessionsLoading = useSessionStore((state) => state.isLoading);
|
const isSessionsLoading = useSessionUIStore((state) => state.isLoading);
|
||||||
useSessionFolderCleanup({
|
useSessionFolderCleanup({
|
||||||
isSessionsLoading,
|
isSessionsLoading,
|
||||||
sessions,
|
sessions,
|
||||||
@@ -970,6 +1043,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
branchLabel?: string | null;
|
branchLabel?: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
}>();
|
}>();
|
||||||
|
const projectPathLengthBySessionId = new Map<string, number>();
|
||||||
|
|
||||||
projectSections.forEach((section) => {
|
projectSections.forEach((section) => {
|
||||||
const projectLabel = formatProjectLabel(
|
const projectLabel = formatProjectLabel(
|
||||||
@@ -984,12 +1058,19 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
|
|
||||||
const visit = (nodes: SessionNode[]) => {
|
const visit = (nodes: SessionNode[]) => {
|
||||||
nodes.forEach((node) => {
|
nodes.forEach((node) => {
|
||||||
|
const nextProjectPathLength = section.project.normalizedPath.length;
|
||||||
|
const currentProjectPathLength = projectPathLengthBySessionId.get(node.session.id) ?? -1;
|
||||||
|
if (nextProjectPathLength < currentProjectPathLength) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
meta.set(node.session.id, {
|
meta.set(node.session.id, {
|
||||||
node,
|
node,
|
||||||
projectId: section.project.id,
|
projectId: section.project.id,
|
||||||
groupDirectory: group.directory,
|
groupDirectory: group.directory,
|
||||||
secondaryMeta,
|
secondaryMeta,
|
||||||
});
|
});
|
||||||
|
projectPathLengthBySessionId.set(node.session.id, nextProjectPathLength);
|
||||||
if (node.children.length > 0) {
|
if (node.children.length > 0) {
|
||||||
visit(node.children);
|
visit(node.children);
|
||||||
}
|
}
|
||||||
@@ -1008,12 +1089,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
[activeNowEntries, sessions],
|
[activeNowEntries, sessions],
|
||||||
);
|
);
|
||||||
|
|
||||||
useSessionPrefetch({
|
// Prefetch is wired below, after recentSessionIds is computed.
|
||||||
currentSessionId,
|
|
||||||
sortedSessions,
|
|
||||||
recentSessionIds: activeNowSessions.map((session) => session.id),
|
|
||||||
loadMessages,
|
|
||||||
});
|
|
||||||
|
|
||||||
const activitySections = React.useMemo(() => {
|
const activitySections = React.useMemo(() => {
|
||||||
const toItem = (session: Session) => {
|
const toItem = (session: Session) => {
|
||||||
@@ -1036,6 +1112,15 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
return new Set(activitySections.flatMap((section) => section.items.map((item) => item.node.session.id)));
|
return new Set(activitySections.flatMap((section) => section.items.map((item) => item.node.session.id)));
|
||||||
}, [activitySections]);
|
}, [activitySections]);
|
||||||
|
|
||||||
|
const recentSessionIdsList = React.useMemo(() => [...recentSessionIds], [recentSessionIds]);
|
||||||
|
|
||||||
|
useSessionPrefetch({
|
||||||
|
currentSessionId,
|
||||||
|
sortedSessions,
|
||||||
|
recentSessionIds: recentSessionIdsList,
|
||||||
|
loadMessages: sync.syncSession,
|
||||||
|
});
|
||||||
|
|
||||||
const sectionsForSidebarRender = React.useMemo(() => {
|
const sectionsForSidebarRender = React.useMemo(() => {
|
||||||
if (!isVSCode || hasSessionSearchQuery || recentSessionIds.size === 0) {
|
if (!isVSCode || hasSessionSearchQuery || recentSessionIds.size === 0) {
|
||||||
return sectionsForRender;
|
return sectionsForRender;
|
||||||
@@ -1105,7 +1190,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
expandedParents={expandedParents}
|
expandedParents={expandedParents}
|
||||||
hasSessionSearchQuery={hasSessionSearchQuery}
|
hasSessionSearchQuery={hasSessionSearchQuery}
|
||||||
normalizedSessionSearchQuery={normalizedSessionSearchQuery}
|
normalizedSessionSearchQuery={normalizedSessionSearchQuery}
|
||||||
sessionAttentionStates={sessionAttentionStates as Map<string, { needsAttention?: boolean }>}
|
|
||||||
notifyOnSubtasks={notifyOnSubtasks}
|
notifyOnSubtasks={notifyOnSubtasks}
|
||||||
sessionStatus={sessionStatus as Map<string, { type?: string }> | undefined}
|
sessionStatus={sessionStatus as Map<string, { type?: string }> | undefined}
|
||||||
permissions={permissions as Map<string, unknown[]>}
|
permissions={permissions as Map<string, unknown[]>}
|
||||||
@@ -1147,7 +1231,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
expandedParents,
|
expandedParents,
|
||||||
hasSessionSearchQuery,
|
hasSessionSearchQuery,
|
||||||
normalizedSessionSearchQuery,
|
normalizedSessionSearchQuery,
|
||||||
sessionAttentionStates,
|
|
||||||
notifyOnSubtasks,
|
notifyOnSubtasks,
|
||||||
sessionStatus,
|
sessionStatus,
|
||||||
permissions,
|
permissions,
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { DraggableSessionRow } from './sessionFolderDnd';
|
|||||||
import type { SessionNode, SessionSummaryMeta } from './types';
|
import type { SessionNode, SessionSummaryMeta } from './types';
|
||||||
import { formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText, resolveSessionDiffStats } from './utils';
|
import { formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText, resolveSessionDiffStats } from './utils';
|
||||||
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
||||||
|
import { useSessionUnseenCount } from '@/sync/notification-store';
|
||||||
|
|
||||||
const ATTENTION_DIAMOND_INDICES = new Set([1, 3, 4, 5, 7]);
|
const ATTENTION_DIAMOND_INDICES = new Set([1, 3, 4, 5, 7]);
|
||||||
|
|
||||||
@@ -65,7 +66,6 @@ type Props = {
|
|||||||
expandedParents: Set<string>;
|
expandedParents: Set<string>;
|
||||||
hasSessionSearchQuery: boolean;
|
hasSessionSearchQuery: boolean;
|
||||||
normalizedSessionSearchQuery: string;
|
normalizedSessionSearchQuery: string;
|
||||||
sessionAttentionStates: Map<string, { needsAttention?: boolean }>;
|
|
||||||
notifyOnSubtasks: boolean;
|
notifyOnSubtasks: boolean;
|
||||||
sessionStatus?: Map<string, { type?: string }>;
|
sessionStatus?: Map<string, { type?: string }>;
|
||||||
permissions: Map<string, unknown[]>;
|
permissions: Map<string, unknown[]>;
|
||||||
@@ -113,7 +113,6 @@ export function SessionNodeItem(props: Props): React.ReactNode {
|
|||||||
expandedParents,
|
expandedParents,
|
||||||
hasSessionSearchQuery,
|
hasSessionSearchQuery,
|
||||||
normalizedSessionSearchQuery,
|
normalizedSessionSearchQuery,
|
||||||
sessionAttentionStates,
|
|
||||||
notifyOnSubtasks,
|
notifyOnSubtasks,
|
||||||
sessionStatus,
|
sessionStatus,
|
||||||
permissions,
|
permissions,
|
||||||
@@ -177,8 +176,8 @@ export function SessionNodeItem(props: Props): React.ReactNode {
|
|||||||
const isPinnedSession = pinnedSessionIds.has(session.id);
|
const isPinnedSession = pinnedSessionIds.has(session.id);
|
||||||
const isExpanded = hasSessionSearchQuery ? true : expandedParents.has(session.id);
|
const isExpanded = hasSessionSearchQuery ? true : expandedParents.has(session.id);
|
||||||
const isSubtaskSession = Boolean((session as Session & { parentID?: string | null }).parentID);
|
const isSubtaskSession = Boolean((session as Session & { parentID?: string | null }).parentID);
|
||||||
const rawNeedsAttention = sessionAttentionStates.get(session.id)?.needsAttention === true;
|
const unseenCount = useSessionUnseenCount(session.id);
|
||||||
const needsAttention = rawNeedsAttention && (!isSubtaskSession || notifyOnSubtasks);
|
const needsAttention = unseenCount > 0 && (!isSubtaskSession || notifyOnSubtasks);
|
||||||
const sessionSummary = session.summary as SessionSummaryMeta | undefined;
|
const sessionSummary = session.summary as SessionSummaryMeta | undefined;
|
||||||
const sessionDiffStats = resolveSessionDiffStats(sessionSummary);
|
const sessionDiffStats = resolveSessionDiffStats(sessionSummary);
|
||||||
const sessionTimestamp = session.time?.updated || session.time?.created || Date.now();
|
const sessionTimestamp = session.time?.updated || session.time?.created || Date.now();
|
||||||
|
|||||||
@@ -1,25 +1,73 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { Session } from '@opencode-ai/sdk/v2';
|
import type { Session } from '@opencode-ai/sdk/v2';
|
||||||
import { opencodeClient } from '@/lib/opencode/client';
|
import { opencodeClient } from '@/lib/opencode/client';
|
||||||
|
import { mapWithConcurrency } from '@/lib/concurrency';
|
||||||
import { normalizePath } from '../utils';
|
import { normalizePath } from '../utils';
|
||||||
|
|
||||||
type ProjectLike = { path: string };
|
type ProjectLike = { path: string };
|
||||||
|
|
||||||
|
type DirectoryStatusValue = 'unknown' | 'exists' | 'missing';
|
||||||
|
|
||||||
type Args = {
|
type Args = {
|
||||||
sortedSessions: Session[];
|
sortedSessions: Session[];
|
||||||
projects: ProjectLike[];
|
projects: ProjectLike[];
|
||||||
directoryStatus: Map<string, 'unknown' | 'exists' | 'missing'>;
|
directoryStatus: Map<string, DirectoryStatusValue>;
|
||||||
setDirectoryStatus: React.Dispatch<React.SetStateAction<Map<string, 'unknown' | 'exists' | 'missing'>>>;
|
setDirectoryStatus: React.Dispatch<React.SetStateAction<Map<string, DirectoryStatusValue>>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PROBE_CONCURRENCY = 3;
|
||||||
|
const MISSING_CACHE_KEY = 'oc.directoryProbe.missing';
|
||||||
|
// Re-probe missing directories periodically in case they're recreated
|
||||||
|
const MISSING_REPROBE_MS = 10 * 60 * 1000; // 10 minutes
|
||||||
|
|
||||||
|
type MissingCache = Record<string, number>; // directory -> timestamp
|
||||||
|
|
||||||
|
function loadMissingCache(): MissingCache {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(MISSING_CACHE_KEY);
|
||||||
|
if (!raw) return {};
|
||||||
|
return JSON.parse(raw) as MissingCache;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveMissingCache(cache: MissingCache): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(MISSING_CACHE_KEY, JSON.stringify(cache));
|
||||||
|
} catch {
|
||||||
|
// ignore quota errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probeDirectory(directory: string): Promise<DirectoryStatusValue> {
|
||||||
|
try {
|
||||||
|
await opencodeClient.listLocalDirectory(directory);
|
||||||
|
return 'exists';
|
||||||
|
} catch {
|
||||||
|
const looksLikeSdkWorktree =
|
||||||
|
directory.includes('/opencode/worktree/') ||
|
||||||
|
directory.includes('/.opencode/data/worktree/') ||
|
||||||
|
directory.includes('/.local/share/opencode/worktree/');
|
||||||
|
|
||||||
|
if (looksLikeSdkWorktree) {
|
||||||
|
const ok = await opencodeClient.probeDirectory(directory).catch(() => false);
|
||||||
|
if (ok) return 'exists';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'missing';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const useDirectoryStatusProbe = ({
|
export const useDirectoryStatusProbe = ({
|
||||||
sortedSessions,
|
sortedSessions,
|
||||||
projects,
|
projects,
|
||||||
directoryStatus,
|
directoryStatus,
|
||||||
setDirectoryStatus,
|
setDirectoryStatus,
|
||||||
}: Args): void => {
|
}: Args): void => {
|
||||||
const directoryStatusRef = React.useRef<Map<string, 'unknown' | 'exists' | 'missing'>>(new Map());
|
const directoryStatusRef = React.useRef<Map<string, DirectoryStatusValue>>(new Map());
|
||||||
const checkingDirectories = React.useRef<Set<string>>(new Set());
|
const probeInFlightRef = React.useRef(false);
|
||||||
|
const missingCacheRef = React.useRef<MissingCache>(loadMissingCache());
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
directoryStatusRef.current = directoryStatus;
|
directoryStatusRef.current = directoryStatus;
|
||||||
@@ -29,68 +77,83 @@ export const useDirectoryStatusProbe = ({
|
|||||||
const directories = new Set<string>();
|
const directories = new Set<string>();
|
||||||
sortedSessions.forEach((session) => {
|
sortedSessions.forEach((session) => {
|
||||||
const dir = normalizePath((session as Session & { directory?: string | null }).directory ?? null);
|
const dir = normalizePath((session as Session & { directory?: string | null }).directory ?? null);
|
||||||
if (dir) {
|
if (dir) directories.add(dir);
|
||||||
directories.add(dir);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
projects.forEach((project) => {
|
projects.forEach((project) => {
|
||||||
const normalized = normalizePath(project.path);
|
const normalized = normalizePath(project.path);
|
||||||
if (normalized) {
|
if (normalized) directories.add(normalized);
|
||||||
directories.add(normalized);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
directories.forEach((directory) => {
|
const now = Date.now();
|
||||||
|
const missingCache = missingCacheRef.current;
|
||||||
|
const toProbe: string[] = [];
|
||||||
|
const preseeded = new Map<string, DirectoryStatusValue>();
|
||||||
|
|
||||||
|
for (const directory of directories) {
|
||||||
const known = directoryStatusRef.current.get(directory);
|
const known = directoryStatusRef.current.get(directory);
|
||||||
if ((known && known !== 'unknown') || checkingDirectories.current.has(directory)) {
|
if (known && known !== 'unknown') continue;
|
||||||
return;
|
|
||||||
|
// Use cached "missing" status if fresh enough — skip the HTTP probe
|
||||||
|
const cachedAt = missingCache[directory];
|
||||||
|
if (cachedAt && now - cachedAt < MISSING_REPROBE_MS) {
|
||||||
|
preseeded.set(directory, 'missing');
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
checkingDirectories.current.add(directory);
|
|
||||||
opencodeClient
|
|
||||||
.listLocalDirectory(directory)
|
|
||||||
.then(() => {
|
|
||||||
setDirectoryStatus((prev) => {
|
|
||||||
const next = new Map(prev);
|
|
||||||
if (next.get(directory) === 'exists') {
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
next.set(directory, 'exists');
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(async () => {
|
|
||||||
const looksLikeSdkWorktree =
|
|
||||||
directory.includes('/opencode/worktree/') ||
|
|
||||||
directory.includes('/.opencode/data/worktree/') ||
|
|
||||||
directory.includes('/.local/share/opencode/worktree/');
|
|
||||||
|
|
||||||
if (looksLikeSdkWorktree) {
|
toProbe.push(directory);
|
||||||
const ok = await opencodeClient.probeDirectory(directory).catch(() => false);
|
}
|
||||||
if (ok) {
|
|
||||||
setDirectoryStatus((prev) => {
|
// Apply preseeded missing statuses immediately (no HTTP call)
|
||||||
const next = new Map(prev);
|
if (preseeded.size > 0) {
|
||||||
if (next.get(directory) === 'exists') {
|
setDirectoryStatus((prev) => {
|
||||||
return prev;
|
let changed = false;
|
||||||
}
|
const next = new Map(prev);
|
||||||
next.set(directory, 'exists');
|
for (const [dir, status] of preseeded) {
|
||||||
return next;
|
if (next.get(dir) !== status) {
|
||||||
});
|
next.set(dir, status);
|
||||||
return;
|
changed = true;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
return changed ? next : prev;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
setDirectoryStatus((prev) => {
|
if (toProbe.length === 0 || probeInFlightRef.current) return;
|
||||||
const next = new Map(prev);
|
probeInFlightRef.current = true;
|
||||||
if (next.get(directory) === 'missing') {
|
|
||||||
return prev;
|
let cancelled = false;
|
||||||
}
|
let cacheChanged = false;
|
||||||
next.set(directory, 'missing');
|
|
||||||
return next;
|
void mapWithConcurrency(toProbe, PROBE_CONCURRENCY, async (directory) => {
|
||||||
});
|
const status = await probeDirectory(directory);
|
||||||
})
|
|
||||||
.finally(() => {
|
// Update missing cache
|
||||||
checkingDirectories.current.delete(directory);
|
if (status === 'missing') {
|
||||||
|
missingCache[directory] = Date.now();
|
||||||
|
cacheChanged = true;
|
||||||
|
} else if (missingCache[directory]) {
|
||||||
|
delete missingCache[directory];
|
||||||
|
cacheChanged = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cancelled) {
|
||||||
|
setDirectoryStatus((prev) => {
|
||||||
|
if (prev.get(directory) === status) return prev;
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(directory, status);
|
||||||
|
return next;
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
return { directory, status };
|
||||||
|
}).finally(() => {
|
||||||
|
probeInFlightRef.current = false;
|
||||||
|
if (cacheChanged) {
|
||||||
|
saveMissingCache(missingCache);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}, [sortedSessions, projects, setDirectoryStatus]);
|
}, [sortedSessions, projects, setDirectoryStatus]);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { checkIsGitRepository } from '@/lib/gitApi';
|
import { checkIsGitRepository } from '@/lib/gitApi';
|
||||||
|
import { mapWithConcurrency } from '@/lib/concurrency';
|
||||||
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
|
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
|
||||||
|
|
||||||
type Project = { id: string; path: string; normalizedPath: string };
|
type Project = { id: string; path: string; normalizedPath: string };
|
||||||
@@ -39,26 +40,25 @@ export const useProjectRepoStatus = (args: Args): void => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
normalized.forEach((project) => {
|
void mapWithConcurrency(normalized, 2, async (project) => {
|
||||||
checkIsGitRepository(project.path)
|
try {
|
||||||
.then((result) => {
|
const result = await checkIsGitRepository(project.path);
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setProjectRepoStatus((prev) => {
|
setProjectRepoStatus((prev) => {
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
next.set(project.id, result);
|
next.set(project.id, result);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
})
|
} catch {
|
||||||
.catch(() => {
|
if (!cancelled) {
|
||||||
if (!cancelled) {
|
setProjectRepoStatus((prev) => {
|
||||||
setProjectRepoStatus((prev) => {
|
const next = new Map(prev);
|
||||||
const next = new Map(prev);
|
next.set(project.id, null);
|
||||||
next.set(project.id, null);
|
return next;
|
||||||
return next;
|
});
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -78,12 +78,10 @@ export const useProjectRepoStatus = (args: Args): void => {
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
const entries = await Promise.all(
|
const entries = await mapWithConcurrency(normalizedProjects, 2, async (project) => {
|
||||||
normalizedProjects.map(async (project) => {
|
const branch = await getRootBranch(project.normalizedPath).catch(() => null);
|
||||||
const branch = await getRootBranch(project.normalizedPath).catch(() => null);
|
return { id: project.id, branch };
|
||||||
return { id: project.id, branch };
|
});
|
||||||
}),
|
|
||||||
);
|
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { Session } from '@opencode-ai/sdk/v2';
|
import type { Session } from '@opencode-ai/sdk/v2';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
|
import { getSyncMessages } from '@/sync/sync-refs';
|
||||||
|
|
||||||
const SESSION_PREFETCH_HOVER_DELAY_MS = 180;
|
const SESSION_PREFETCH_HOVER_DELAY_MS = 180;
|
||||||
|
const SESSION_PREFETCH_SETTLE_MS = 600;
|
||||||
const SESSION_PREFETCH_CONCURRENCY = 1;
|
const SESSION_PREFETCH_CONCURRENCY = 1;
|
||||||
const SESSION_PREFETCH_PENDING_LIMIT = 6;
|
const SESSION_PREFETCH_PENDING_LIMIT = 6;
|
||||||
|
|
||||||
@@ -10,7 +12,7 @@ type Args = {
|
|||||||
currentSessionId: string | null;
|
currentSessionId: string | null;
|
||||||
sortedSessions: Session[];
|
sortedSessions: Session[];
|
||||||
recentSessionIds?: string[];
|
recentSessionIds?: string[];
|
||||||
loadMessages: (sessionId: string, limit?: number) => Promise<void>;
|
loadMessages: (sessionId: string) => Promise<unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useSessionPrefetch = ({ currentSessionId, sortedSessions, recentSessionIds = [], loadMessages }: Args): void => {
|
export const useSessionPrefetch = ({ currentSessionId, sortedSessions, recentSessionIds = [], loadMessages }: Args): void => {
|
||||||
@@ -29,15 +31,14 @@ export const useSessionPrefetch = ({ currentSessionId, sortedSessions, recentSes
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = useSessionStore.getState();
|
const state = useSessionUIStore.getState();
|
||||||
if (state.currentSessionId === nextSessionId) {
|
if (state.currentSessionId === nextSessionId) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasMessages = state.messages.has(nextSessionId);
|
// Check if messages already loaded in sync child store
|
||||||
const historyMeta = state.sessionHistoryMeta.get(nextSessionId);
|
const hasMessages = getSyncMessages(nextSessionId).length > 0;
|
||||||
const isHydrated = hasMessages && typeof historyMeta?.complete === 'boolean';
|
if (hasMessages) {
|
||||||
if (isHydrated) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,11 +57,9 @@ export const useSessionPrefetch = ({ currentSessionId, sortedSessions, recentSes
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = useSessionStore.getState();
|
// Already loaded in sync
|
||||||
const hasMessages = state.messages.has(sessionId);
|
const hasMessages = getSyncMessages(sessionId).length > 0;
|
||||||
const historyMeta = state.sessionHistoryMeta.get(sessionId);
|
if (hasMessages) {
|
||||||
const isHydrated = hasMessages && typeof historyMeta?.complete === 'boolean';
|
|
||||||
if (isHydrated) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,30 +88,32 @@ export const useSessionPrefetch = ({ currentSessionId, sortedSessions, recentSes
|
|||||||
sessionPrefetchTimersRef.current.set(sessionId, timer);
|
sessionPrefetchTimersRef.current.set(sessionId, timer);
|
||||||
}, [currentSessionId, pumpSessionPrefetchQueue]);
|
}, [currentSessionId, pumpSessionPrefetchQueue]);
|
||||||
|
|
||||||
|
// Wait for the active session to finish loading before prefetching neighbors.
|
||||||
|
// On rapid session switches the timer resets, so only the final session triggers prefetch.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!currentSessionId || sortedSessions.length === 0) {
|
if (!currentSessionId || sortedSessions.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const currentIndex = sortedSessions.findIndex((session) => session.id === currentSessionId);
|
const timer = window.setTimeout(() => {
|
||||||
if (currentIndex < 0) {
|
const currentIndex = sortedSessions.findIndex((session) => session.id === currentSessionId);
|
||||||
return;
|
if (currentIndex < 0) return;
|
||||||
}
|
scheduleSessionPrefetch(sortedSessions[currentIndex - 1]?.id);
|
||||||
scheduleSessionPrefetch(sortedSessions[currentIndex - 1]?.id);
|
scheduleSessionPrefetch(sortedSessions[currentIndex + 1]?.id);
|
||||||
scheduleSessionPrefetch(sortedSessions[currentIndex + 1]?.id);
|
}, SESSION_PREFETCH_SETTLE_MS);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
}, [currentSessionId, scheduleSessionPrefetch, sortedSessions]);
|
}, [currentSessionId, scheduleSessionPrefetch, sortedSessions]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!currentSessionId || recentSessionIds.length === 0) {
|
if (!currentSessionId || recentSessionIds.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
const currentIndex = recentSessionIds.indexOf(currentSessionId);
|
const currentIndex = recentSessionIds.indexOf(currentSessionId);
|
||||||
if (currentIndex < 0) {
|
if (currentIndex < 0) return;
|
||||||
return;
|
scheduleSessionPrefetch(recentSessionIds[currentIndex - 1]);
|
||||||
}
|
scheduleSessionPrefetch(recentSessionIds[currentIndex + 1]);
|
||||||
|
}, SESSION_PREFETCH_SETTLE_MS);
|
||||||
scheduleSessionPrefetch(recentSessionIds[currentIndex - 1]);
|
return () => window.clearTimeout(timer);
|
||||||
scheduleSessionPrefetch(recentSessionIds[currentIndex + 1]);
|
|
||||||
}, [currentSessionId, recentSessionIds, scheduleSessionPrefetch]);
|
}, [currentSessionId, recentSessionIds, scheduleSessionPrefetch]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|||||||
@@ -10,43 +10,38 @@ import {
|
|||||||
CommandShortcut,
|
CommandShortcut,
|
||||||
} from '@/components/ui/command';
|
} from '@/components/ui/command';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||||
import { useDeviceInfo } from '@/lib/device';
|
import { useDeviceInfo } from '@/lib/device';
|
||||||
import { RiAddLine, RiChatAi3Line, RiCheckLine, RiCodeLine, RiComputerLine, RiGitBranchLine, RiLayoutLeftLine, RiLayoutRightLine, RiMoonLine, RiQuestionLine, RiSettings3Line, RiSunLine, RiTerminalBoxLine, RiTimeLine } from '@remixicon/react';
|
import { RiAddLine, RiChatAi3Line, RiCheckLine, RiCodeLine, RiComputerLine, RiGitBranchLine, RiLayoutLeftLine, RiLayoutRightLine, RiMoonLine, RiQuestionLine, RiSettings3Line, RiSunLine, RiTerminalBoxLine } from '@remixicon/react';
|
||||||
import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
|
import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
|
||||||
import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts';
|
import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts';
|
||||||
import { isDesktopShell, isVSCodeRuntime, isWebRuntime } from '@/lib/desktop';
|
import { isDesktopShell, isVSCodeRuntime, isWebRuntime } from '@/lib/desktop';
|
||||||
import { SETTINGS_PAGE_METADATA, SETTINGS_GROUP_LABELS, type SettingsRuntimeContext } from '@/lib/settings/metadata';
|
import { SETTINGS_PAGE_METADATA, SETTINGS_GROUP_LABELS, type SettingsRuntimeContext } from '@/lib/settings/metadata';
|
||||||
|
|
||||||
export const CommandPalette: React.FC = () => {
|
export const CommandPalette: React.FC = () => {
|
||||||
const {
|
const isCommandPaletteOpen = useUIStore((s) => s.isCommandPaletteOpen);
|
||||||
isCommandPaletteOpen,
|
const setCommandPaletteOpen = useUIStore((s) => s.setCommandPaletteOpen);
|
||||||
setCommandPaletteOpen,
|
const setHelpDialogOpen = useUIStore((s) => s.setHelpDialogOpen);
|
||||||
setHelpDialogOpen,
|
const setActiveMainTab = useUIStore((s) => s.setActiveMainTab);
|
||||||
setActiveMainTab,
|
const setSettingsDialogOpen = useUIStore((s) => s.setSettingsDialogOpen);
|
||||||
setSettingsDialogOpen,
|
const setSettingsPage = useUIStore((s) => s.setSettingsPage);
|
||||||
setSettingsPage,
|
const setSessionSwitcherOpen = useUIStore((s) => s.setSessionSwitcherOpen);
|
||||||
setSessionSwitcherOpen,
|
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
|
||||||
setTimelineDialogOpen,
|
const toggleRightSidebar = useUIStore((s) => s.toggleRightSidebar);
|
||||||
toggleSidebar,
|
const setRightSidebarOpen = useUIStore((s) => s.setRightSidebarOpen);
|
||||||
toggleRightSidebar,
|
const setRightSidebarTab = useUIStore((s) => s.setRightSidebarTab);
|
||||||
setRightSidebarOpen,
|
const toggleBottomTerminal = useUIStore((s) => s.toggleBottomTerminal);
|
||||||
setRightSidebarTab,
|
const setBottomTerminalExpanded = useUIStore((s) => s.setBottomTerminalExpanded);
|
||||||
toggleBottomTerminal,
|
const isBottomTerminalExpanded = useUIStore((s) => s.isBottomTerminalExpanded);
|
||||||
setBottomTerminalExpanded,
|
const shortcutOverrides = useUIStore((s) => s.shortcutOverrides);
|
||||||
isBottomTerminalExpanded,
|
|
||||||
shortcutOverrides,
|
|
||||||
} = useUIStore();
|
|
||||||
|
|
||||||
const {
|
const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft);
|
||||||
openNewSessionDraft,
|
const setCurrentSession = useSessionUIStore((s) => s.setCurrentSession);
|
||||||
setCurrentSession,
|
const getSessionsByDirectory = useSessionUIStore((s) => s.getSessionsByDirectory);
|
||||||
getSessionsByDirectory,
|
|
||||||
} = useSessionStore();
|
|
||||||
|
|
||||||
const { currentDirectory } = useDirectoryStore();
|
const currentDirectory = useDirectoryStore((s) => s.currentDirectory);
|
||||||
const { themeMode, setThemeMode } = useThemeSystem();
|
const { themeMode, setThemeMode } = useThemeSystem();
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
@@ -167,11 +162,6 @@ export const CommandPalette: React.FC = () => {
|
|||||||
handleClose();
|
handleClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenTimeline = () => {
|
|
||||||
setTimelineDialogOpen(true);
|
|
||||||
handleClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const directorySessions = getSessionsByDirectory(currentDirectory ?? '');
|
const directorySessions = getSessionsByDirectory(currentDirectory ?? '');
|
||||||
const currentSessions = React.useMemo(() => {
|
const currentSessions = React.useMemo(() => {
|
||||||
return directorySessions.slice(0, 5);
|
return directorySessions.slice(0, 5);
|
||||||
@@ -252,11 +242,6 @@ export const CommandPalette: React.FC = () => {
|
|||||||
<span>Open Git Panel</span>
|
<span>Open Git Panel</span>
|
||||||
<CommandShortcut>{shortcut('open_git_panel')}</CommandShortcut>
|
<CommandShortcut>{shortcut('open_git_panel')}</CommandShortcut>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
<CommandItem onSelect={handleOpenTimeline}>
|
|
||||||
<RiTimeLine className="mr-2 h-4 w-4" />
|
|
||||||
<span>Open Timeline</span>
|
|
||||||
<CommandShortcut>{shortcut('open_timeline')}</CommandShortcut>
|
|
||||||
</CommandItem>
|
|
||||||
<CommandItem onSelect={handleOpenSettings}>
|
<CommandItem onSelect={handleOpenSettings}>
|
||||||
<RiSettings3Line className="mr-2 h-4 w-4" />
|
<RiSettings3Line className="mr-2 h-4 w-4" />
|
||||||
<span>Open Settings</span>
|
<span>Open Settings</span>
|
||||||
|
|||||||
@@ -186,12 +186,6 @@ export const HelpDialog: React.FC = () => {
|
|||||||
description: "Switch Project",
|
description: "Switch Project",
|
||||||
icon: RiLayoutLeftLine,
|
icon: RiLayoutLeftLine,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'open_timeline',
|
|
||||||
description: "Open Timeline",
|
|
||||||
icon: RiTimeLine,
|
|
||||||
keys: '',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'toggle_services_menu',
|
id: 'toggle_services_menu',
|
||||||
description: 'Toggle Services Menu',
|
description: 'Toggle Services Menu',
|
||||||
|
|||||||
@@ -1,37 +1,162 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useSessionStore, MEMORY_LIMITS } from '@/stores/useSessionStore';
|
import { RiBarChartBoxLine, RiCloseLine, RiDatabase2Line, RiFileCopyLine, RiPulseLine, RiRefreshLine } from '@remixicon/react';
|
||||||
|
|
||||||
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
|
import { useViewportStore } from '@/sync/viewport-store';
|
||||||
|
import { useSessions, useDirectorySync } from '@/sync/sync-context';
|
||||||
|
import { MEMORY_LIMITS } from '@/stores/types/sessionTypes';
|
||||||
import { useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore';
|
import { useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore';
|
||||||
import { getMessageLimit, getBackgroundTrimLimit } from '@/stores/types/sessionTypes';
|
import { getBackgroundTrimLimit } from '@/stores/types/sessionTypes';
|
||||||
|
import { getStreamPerfSnapshot, getVsCodeStreamPerfSnapshot, resetStreamPerf, type StreamPerfSnapshot } from '@/stores/utils/streamDebug';
|
||||||
import { Card } from '@/components/ui/card';
|
import { Card } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
|
||||||
import { RiCloseLine, RiDatabase2Line, RiPulseLine } from '@remixicon/react';
|
|
||||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||||
|
|
||||||
interface MemoryDebugPanelProps {
|
interface DebugPanelProps {
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MemoryDebugPanel: React.FC<MemoryDebugPanelProps> = ({ onClose }) => {
|
type DebugTab = 'memory' | 'streaming';
|
||||||
const {
|
|
||||||
sessions,
|
const formatDuration = (durationMs: number): string => {
|
||||||
messages,
|
if (durationMs < 1000) {
|
||||||
sessionMemoryState,
|
return `${Math.round(durationMs)}ms`;
|
||||||
currentSessionId,
|
}
|
||||||
} = useSessionStore();
|
|
||||||
|
const seconds = durationMs / 1000;
|
||||||
|
if (seconds < 60) {
|
||||||
|
return `${seconds.toFixed(1)}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const remainderSeconds = Math.round(seconds % 60);
|
||||||
|
return `${minutes}m ${remainderSeconds}s`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MetricCard: React.FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rounded-md p-2"
|
||||||
|
style={{ backgroundColor: 'color-mix(in srgb, var(--surface-muted) 55%, transparent)' }}
|
||||||
|
>
|
||||||
|
<div className="typography-meta text-[var(--surface-muted-foreground)]">{label}</div>
|
||||||
|
<div className="typography-markdown font-semibold text-[var(--surface-foreground)]">{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const PerfSection: React.FC<{ title: string; snapshot: StreamPerfSnapshot; emptyLabel: string }> = ({ title, snapshot, emptyLabel }) => {
|
||||||
|
const topEntries = snapshot.entries.slice(0, 12);
|
||||||
|
const totalSamples = snapshot.entries.reduce((sum, entry) => sum + entry.count, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 border-t border-[var(--interactive-border)] pt-2 first:border-t-0 first:pt-0">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="typography-ui-label font-semibold text-[var(--surface-foreground)]">{title}</div>
|
||||||
|
<div className="typography-meta text-[var(--surface-muted-foreground)]">
|
||||||
|
{snapshot.startedAt ? formatDuration(snapshot.durationMs) : 'idle'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<MetricCard label="Metrics" value={snapshot.entries.length} />
|
||||||
|
<MetricCard label="Samples" value={totalSamples} />
|
||||||
|
<MetricCard label="Last Update" value={snapshot.lastUpdatedAt ? 'live' : 'n/a'} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{topEntries.length === 0 ? (
|
||||||
|
<div
|
||||||
|
className="rounded-md p-3 typography-meta text-[var(--surface-muted-foreground)]"
|
||||||
|
style={{ backgroundColor: 'color-mix(in srgb, var(--surface-muted) 45%, transparent)' }}
|
||||||
|
>
|
||||||
|
{emptyLabel}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ScrollableOverlay outerClassName="max-h-64" className="pr-1">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{topEntries.map((entry) => (
|
||||||
|
<div
|
||||||
|
key={entry.metric}
|
||||||
|
className="rounded-md border border-[var(--interactive-border)] p-2"
|
||||||
|
style={{ backgroundColor: 'color-mix(in srgb, var(--surface-elevated) 88%, transparent)' }}
|
||||||
|
>
|
||||||
|
<div className="typography-meta font-medium text-[var(--surface-foreground)] break-all">{entry.metric}</div>
|
||||||
|
<div className="mt-1 grid grid-cols-4 gap-2 typography-meta text-[var(--surface-muted-foreground)]">
|
||||||
|
<span>count {entry.count}</span>
|
||||||
|
<span>avg {entry.avg}</span>
|
||||||
|
<span>max {entry.max}</span>
|
||||||
|
<span>total {entry.total}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollableOverlay>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DebugPanel: React.FC<DebugPanelProps> = ({ onClose }) => {
|
||||||
|
const [activeTab, setActiveTab] = React.useState<DebugTab>('memory');
|
||||||
|
const [copyState, setCopyState] = React.useState<'idle' | 'copied' | 'error'>('idle');
|
||||||
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||||
|
const sessionMemoryState = useViewportStore((state) => state.sessionMemoryState);
|
||||||
|
const sessions = useSessions();
|
||||||
|
const messageRecord = useDirectorySync((state) => state.message);
|
||||||
const totalGitHubRequests = useGitHubPrStatusStore((state) => state.totalRequestCount);
|
const totalGitHubRequests = useGitHubPrStatusStore((state) => state.totalRequestCount);
|
||||||
|
const [streamSnapshot, setStreamSnapshot] = React.useState<StreamPerfSnapshot>(() => getStreamPerfSnapshot());
|
||||||
|
const [vscodeStreamSnapshot, setVsCodeStreamSnapshot] = React.useState<StreamPerfSnapshot>(() => getVsCodeStreamPerfSnapshot());
|
||||||
|
const streamMetricCounts = React.useMemo(() => {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
streamSnapshot.entries.forEach((entry) => {
|
||||||
|
counts.set(entry.metric, entry.count);
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
messageListRender: counts.get('ui.message_list.render') ?? 0,
|
||||||
|
messageListRenderStreaming: counts.get('ui.message_list.render.streaming') ?? 0,
|
||||||
|
chatMessageRender: counts.get('ui.chat_message.render') ?? 0,
|
||||||
|
chatMessageRenderStreaming: counts.get('ui.chat_message.render.streaming') ?? 0,
|
||||||
|
chatMessageRenderStaticDuringStream: counts.get('ui.chat_message.render.static_during_stream') ?? 0,
|
||||||
|
chatMessageRenderStaticOutsideActiveTurnDuringStream:
|
||||||
|
counts.get('ui.chat_message.render.static_outside_active_turn_during_stream') ?? 0,
|
||||||
|
};
|
||||||
|
}, [streamSnapshot.entries]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const refresh = () => {
|
||||||
|
setStreamSnapshot(getStreamPerfSnapshot());
|
||||||
|
setVsCodeStreamSnapshot(getVsCodeStreamPerfSnapshot());
|
||||||
|
};
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
const intervalId = window.setInterval(refresh, 500);
|
||||||
|
return () => window.clearInterval(intervalId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (copyState === 'idle') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
setCopyState('idle');
|
||||||
|
}, 1500);
|
||||||
|
|
||||||
|
return () => window.clearTimeout(timeoutId);
|
||||||
|
}, [copyState]);
|
||||||
|
|
||||||
const totalMessages = React.useMemo(() => {
|
const totalMessages = React.useMemo(() => {
|
||||||
let total = 0;
|
let total = 0;
|
||||||
messages.forEach((sessionMessages) => {
|
for (const sessionId of Object.keys(messageRecord)) {
|
||||||
total += sessionMessages.length;
|
total += messageRecord[sessionId]?.length ?? 0;
|
||||||
});
|
}
|
||||||
return total;
|
return total;
|
||||||
}, [messages]);
|
}, [messageRecord]);
|
||||||
|
|
||||||
const sessionStats = React.useMemo(() => {
|
const sessionStats = React.useMemo(() => {
|
||||||
return sessions.map(session => {
|
return sessions.map(session => {
|
||||||
const messageCount = messages.get(session.id)?.length || 0;
|
const messageCount = messageRecord[session.id]?.length || 0;
|
||||||
const memoryState = sessionMemoryState.get(session.id);
|
const memoryState = sessionMemoryState.get(session.id);
|
||||||
return {
|
return {
|
||||||
id: session.id,
|
id: session.id,
|
||||||
@@ -44,120 +169,209 @@ export const MemoryDebugPanel: React.FC<MemoryDebugPanelProps> = ({ onClose }) =
|
|||||||
isCurrent: session.id === currentSessionId
|
isCurrent: session.id === currentSessionId
|
||||||
};
|
};
|
||||||
}).sort((a, b) => b.lastAccessed - a.lastAccessed);
|
}).sort((a, b) => b.lastAccessed - a.lastAccessed);
|
||||||
}, [sessions, messages, sessionMemoryState, currentSessionId]);
|
}, [sessions, messageRecord, sessionMemoryState, currentSessionId]);
|
||||||
|
|
||||||
const cachedSessionCount = messages.size;
|
const cachedSessionCount = Object.keys(messageRecord).length;
|
||||||
|
|
||||||
|
const handleCopyStreamingDebug = React.useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
ui: getStreamPerfSnapshot(),
|
||||||
|
vscode: getVsCodeStreamPerfSnapshot(),
|
||||||
|
};
|
||||||
|
await navigator.clipboard.writeText(JSON.stringify(payload, null, 2));
|
||||||
|
setCopyState('copied');
|
||||||
|
} catch {
|
||||||
|
setCopyState('error');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="fixed bottom-4 right-4 w-96 p-4 shadow-none z-50 bg-background/95 bottom-safe-area">
|
<Card
|
||||||
<div className="flex items-center justify-between mb-3">
|
className="fixed bottom-4 right-4 z-50 w-[28rem] p-4 shadow-none bottom-safe-area"
|
||||||
|
style={{ backgroundColor: 'color-mix(in srgb, var(--surface-background) 94%, transparent)' }}
|
||||||
|
>
|
||||||
|
<div className="mb-3 flex items-center justify-between gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<RiDatabase2Line className="h-4 w-4" />
|
{activeTab === 'memory' ? (
|
||||||
<h3 className="font-semibold typography-ui-label">Memory Debug Panel</h3>
|
<RiDatabase2Line className="h-4 w-4 text-[var(--surface-foreground)]" />
|
||||||
|
) : (
|
||||||
|
<RiBarChartBoxLine className="h-4 w-4 text-[var(--surface-foreground)]" />
|
||||||
|
)}
|
||||||
|
<h3 className="typography-ui-label font-semibold text-[var(--surface-foreground)]">Debug Panel</h3>
|
||||||
</div>
|
</div>
|
||||||
{onClose && (
|
<div className="flex items-center gap-1">
|
||||||
<Button
|
{activeTab === 'streaming' ? (
|
||||||
size="icon"
|
<>
|
||||||
variant="ghost"
|
<Button size="xs" variant="ghost" onClick={handleCopyStreamingDebug}>
|
||||||
className="h-6 w-6"
|
<RiFileCopyLine className="h-3.5 w-3.5" />
|
||||||
onClick={onClose}
|
</Button>
|
||||||
>
|
|
||||||
<RiCloseLine className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
{}
|
|
||||||
<div className="grid grid-cols-2 gap-2 typography-meta">
|
|
||||||
<div className="bg-muted/50 rounded p-2">
|
|
||||||
<div className="text-muted-foreground">Total Messages</div>
|
|
||||||
<div className="typography-markdown font-semibold">{totalMessages}</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-muted/50 rounded p-2">
|
|
||||||
<div className="text-muted-foreground">Cached Sessions</div>
|
|
||||||
<div className="typography-markdown font-semibold">{cachedSessionCount} / {MEMORY_LIMITS.MAX_SESSIONS}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{null}
|
|
||||||
|
|
||||||
{}
|
|
||||||
<div className="typography-meta space-y-1 border-t pt-2">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-muted-foreground">Viewport Window:</span>
|
|
||||||
<span>{getBackgroundTrimLimit()} messages</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-muted-foreground">Zombie Timeout:</span>
|
|
||||||
<span>{MEMORY_LIMITS.ZOMBIE_TIMEOUT / 1000 / 60} minutes</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-muted-foreground">GitHub Total Requests:</span>
|
|
||||||
<span>{totalGitHubRequests}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{}
|
|
||||||
<div className="border-t pt-2">
|
|
||||||
<div className="typography-meta font-semibold mb-1">Sessions in Memory:</div>
|
|
||||||
<ScrollableOverlay outerClassName="max-h-48" className="space-y-1 pr-1">
|
|
||||||
{sessionStats.map(stat => (
|
|
||||||
<div
|
|
||||||
key={stat.id}
|
|
||||||
className={`typography-meta p-1.5 rounded flex items-center justify-between ${
|
|
||||||
stat.isCurrent ? 'bg-primary/10' : 'bg-muted/30'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
|
||||||
<span className="truncate">{stat.title}</span>
|
|
||||||
{stat.isStreaming && (
|
|
||||||
<RiPulseLine className="h-3 w-3 text-primary animate-pulse" />
|
|
||||||
)}
|
|
||||||
{stat.isZombie && (
|
|
||||||
<span className="text-status-warning">!</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className={`font-mono ${
|
|
||||||
stat.messageCount > getMessageLimit() ? 'text-status-warning' : ''
|
|
||||||
}`}>
|
|
||||||
{stat.messageCount} msgs
|
|
||||||
</span>
|
|
||||||
{stat.backgroundCount > 0 && (
|
|
||||||
<span className="text-primary">+{stat.backgroundCount}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</ScrollableOverlay>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2 pt-2 border-t">
|
|
||||||
<Tooltip delayDuration={1000}>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="xs"
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
className="typography-meta"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
console.log('[MemoryDebug] Session store state:', {
|
resetStreamPerf();
|
||||||
sessions: sessions.map(s => ({ id: s.id, title: s.title })),
|
setStreamSnapshot(getStreamPerfSnapshot());
|
||||||
currentSessionId,
|
setVsCodeStreamSnapshot(getVsCodeStreamPerfSnapshot());
|
||||||
cachedSessions: Array.from(messages.keys()),
|
|
||||||
memoryStates: Object.fromEntries(sessionMemoryState),
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Log State
|
<RiRefreshLine className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</>
|
||||||
<TooltipContent side="top">
|
) : null}
|
||||||
Log current memory state to browser console
|
{onClose ? (
|
||||||
</TooltipContent>
|
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={onClose}>
|
||||||
</Tooltip>
|
<RiCloseLine className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="mb-3 flex gap-1 rounded-md p-1"
|
||||||
|
style={{ backgroundColor: 'color-mix(in srgb, var(--surface-muted) 55%, transparent)' }}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={activeTab === 'memory' ? 'secondary' : 'ghost'}
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => setActiveTab('memory')}
|
||||||
|
>
|
||||||
|
Memory
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={activeTab === 'streaming' ? 'secondary' : 'ghost'}
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => setActiveTab('streaming')}
|
||||||
|
>
|
||||||
|
Streaming
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === 'memory' ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-2 typography-meta">
|
||||||
|
<MetricCard label="Total Messages" value={totalMessages} />
|
||||||
|
<MetricCard label="Cached Sessions" value={`${cachedSessionCount} / ${MEMORY_LIMITS.MAX_SESSIONS}`} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="typography-meta space-y-1 border-t border-[var(--interactive-border)] pt-2">
|
||||||
|
<div className="flex justify-between gap-2">
|
||||||
|
<span className="text-[var(--surface-muted-foreground)]">Viewport Window</span>
|
||||||
|
<span className="text-[var(--surface-foreground)]">{getBackgroundTrimLimit()} messages</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-2">
|
||||||
|
<span className="text-[var(--surface-muted-foreground)]">Zombie Timeout</span>
|
||||||
|
<span className="text-[var(--surface-foreground)]">{MEMORY_LIMITS.ZOMBIE_TIMEOUT / 1000 / 60} minutes</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-2">
|
||||||
|
<span className="text-[var(--surface-muted-foreground)]">GitHub Total Requests</span>
|
||||||
|
<span className="text-[var(--surface-foreground)]">{totalGitHubRequests}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-[var(--interactive-border)] pt-2">
|
||||||
|
<div className="mb-1 typography-meta font-semibold text-[var(--surface-foreground)]">Sessions in Memory</div>
|
||||||
|
<ScrollableOverlay outerClassName="max-h-48" className="space-y-1 pr-1">
|
||||||
|
{sessionStats.map(stat => (
|
||||||
|
<div
|
||||||
|
key={stat.id}
|
||||||
|
className="typography-meta flex items-center justify-between rounded p-1.5"
|
||||||
|
style={{
|
||||||
|
backgroundColor: stat.isCurrent
|
||||||
|
? 'color-mix(in srgb, var(--interactive-selection) 22%, transparent)'
|
||||||
|
: 'color-mix(in srgb, var(--surface-muted) 35%, transparent)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||||
|
<span className="truncate text-[var(--surface-foreground)]">{stat.title}</span>
|
||||||
|
{stat.isStreaming ? <RiPulseLine className="h-3 w-3 animate-pulse text-[var(--status-info)]" /> : null}
|
||||||
|
{stat.isZombie ? <span className="text-[var(--status-warning)]">!</span> : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-mono text-[var(--surface-foreground)]">
|
||||||
|
{stat.messageCount} msgs
|
||||||
|
</span>
|
||||||
|
{stat.backgroundCount > 0 ? (
|
||||||
|
<span className="text-[var(--status-info)]">+{stat.backgroundCount}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</ScrollableOverlay>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 border-t border-[var(--interactive-border)] pt-2">
|
||||||
|
<Tooltip delayDuration={1000}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="typography-meta"
|
||||||
|
onClick={() => {
|
||||||
|
console.log('[DebugPanel] Session store state:', {
|
||||||
|
sessions: sessions.map(s => ({ id: s.id, title: s.title })),
|
||||||
|
currentSessionId,
|
||||||
|
cachedSessions: Object.keys(messageRecord),
|
||||||
|
memoryStates: Object.fromEntries(sessionMemoryState),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Log State
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">Log current memory state to browser console</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-2 rounded-md border border-[var(--interactive-border)] px-3 py-2 typography-meta text-[var(--surface-muted-foreground)]">
|
||||||
|
<span>
|
||||||
|
{copyState === 'copied'
|
||||||
|
? 'Streaming debug JSON copied'
|
||||||
|
: copyState === 'error'
|
||||||
|
? 'Failed to copy JSON'
|
||||||
|
: 'Copy exports both UI and VS Code streaming metrics as JSON'}
|
||||||
|
</span>
|
||||||
|
<Button size="xs" variant="outline" onClick={handleCopyStreamingDebug}>
|
||||||
|
Copy JSON
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<MetricCard label="UI Metrics" value={streamSnapshot.entries.length} />
|
||||||
|
<MetricCard label="VS Code Metrics" value={vscodeStreamSnapshot.entries.length} />
|
||||||
|
<MetricCard label="MsgList Renders" value={streamMetricCounts.messageListRender} />
|
||||||
|
<MetricCard label="MsgList Stream Renders" value={streamMetricCounts.messageListRenderStreaming} />
|
||||||
|
<MetricCard label="ChatMessage Renders" value={streamMetricCounts.chatMessageRender} />
|
||||||
|
<MetricCard label="ChatMessage Stream Renders" value={streamMetricCounts.chatMessageRenderStreaming} />
|
||||||
|
<MetricCard label="ChatMessage Static During Stream" value={streamMetricCounts.chatMessageRenderStaticDuringStream} />
|
||||||
|
<MetricCard
|
||||||
|
label="ChatMessage Static Outside Active Turn"
|
||||||
|
value={streamMetricCounts.chatMessageRenderStaticOutsideActiveTurnDuringStream}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PerfSection
|
||||||
|
title="UI Streaming Metrics"
|
||||||
|
snapshot={streamSnapshot}
|
||||||
|
emptyLabel="No UI streaming samples yet. Start a stream and keep this panel open."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{vscodeStreamSnapshot.entries.length > 0 ? (
|
||||||
|
<PerfSection
|
||||||
|
title="VS Code Bridge Metrics"
|
||||||
|
snapshot={vscodeStreamSnapshot}
|
||||||
|
emptyLabel="No VS Code bridge samples yet."
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const MemoryDebugPanel = DebugPanel;
|
||||||
|
|||||||
@@ -192,6 +192,23 @@ export const OpenChamberLogo: React.FC<OpenChamberLogoProps> = ({
|
|||||||
role="img"
|
role="img"
|
||||||
aria-label="OpenChamber logo"
|
aria-label="OpenChamber logo"
|
||||||
>
|
>
|
||||||
|
<style>{`
|
||||||
|
@keyframes openchamber-logo-pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.openchamber-logo-pulse {
|
||||||
|
animation: openchamber-logo-pulse 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.openchamber-logo-pulse {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
|
||||||
{/* Left face - base fill */}
|
{/* Left face - base fill */}
|
||||||
<path
|
<path
|
||||||
d={`M${center.x} ${center.y} L${left.x} ${left.y} L${bottomLeft.x} ${bottomLeft.y} L${bottom.x} ${bottom.y} Z`}
|
d={`M${center.x} ${center.y} L${left.x} ${left.y} L${bottomLeft.x} ${bottomLeft.y} L${bottom.x} ${bottom.y} Z`}
|
||||||
@@ -240,17 +257,7 @@ export const OpenChamberLogo: React.FC<OpenChamberLogoProps> = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* OpenCode logo on top face */}
|
{/* OpenCode logo on top face */}
|
||||||
<g opacity={isAnimated ? undefined : 1}>
|
<g className={isAnimated ? 'openchamber-logo-pulse' : undefined} opacity={1}>
|
||||||
{isAnimated && (
|
|
||||||
<animate
|
|
||||||
attributeName="opacity"
|
|
||||||
values="0.4;1;0.4"
|
|
||||||
dur="3s"
|
|
||||||
repeatCount="indefinite"
|
|
||||||
calcMode="spline"
|
|
||||||
keySplines="0.4 0 0.6 1; 0.4 0 0.6 1"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{/*
|
{/*
|
||||||
Isometric transform for top face:
|
Isometric transform for top face:
|
||||||
OpenCode logo (32x40 viewBox) centered and projected to isometric plane
|
OpenCode logo (32x40 viewBox) centered and projected to isometric plane
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { ChatContainer } from '@/components/chat/ChatContainer';
|
import { ChatContainer } from '@/components/chat/ChatContainer';
|
||||||
import { ChatErrorBoundary } from '@/components/chat/ChatErrorBoundary';
|
import { ChatErrorBoundary } from '@/components/chat/ChatErrorBoundary';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
|
|
||||||
export const ChatView: React.FC = () => {
|
export const ChatView: React.FC = () => {
|
||||||
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChatErrorBoundary sessionId={currentSessionId || undefined}>
|
<ChatErrorBoundary sessionId={currentSessionId || undefined}>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { getContextFileOpenFailureMessage, validateContextFileOpen } from '@/lib
|
|||||||
// Minimum width for side-by-side diff view (px)
|
// Minimum width for side-by-side diff view (px)
|
||||||
const SIDE_BY_SIDE_MIN_WIDTH = 1100;
|
const SIDE_BY_SIDE_MIN_WIDTH = 1100;
|
||||||
const DIFF_REQUEST_TIMEOUT_MS = 15000;
|
const DIFF_REQUEST_TIMEOUT_MS = 15000;
|
||||||
|
const LARGE_DIFF_CHANGED_LINES = 500;
|
||||||
|
|
||||||
// Perf: limit concurrent expanded diffs in stacked view.
|
// Perf: limit concurrent expanded diffs in stacked view.
|
||||||
// Expanding many diffs mounts many Pierre instances + lots of DOM.
|
// Expanding many diffs mounts many Pierre instances + lots of DOM.
|
||||||
@@ -638,6 +639,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
|||||||
const [diffRetryNonce, setDiffRetryNonce] = React.useState(0);
|
const [diffRetryNonce, setDiffRetryNonce] = React.useState(0);
|
||||||
const [diffLoadError, setDiffLoadError] = React.useState<string | null>(null);
|
const [diffLoadError, setDiffLoadError] = React.useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = React.useState(false);
|
const [isLoading, setIsLoading] = React.useState(false);
|
||||||
|
const [forceRenderLarge, setForceRenderLarge] = React.useState(false);
|
||||||
const lastDiffRequestRef = React.useRef<string | null>(null);
|
const lastDiffRequestRef = React.useRef<string | null>(null);
|
||||||
const sectionRef = React.useRef<HTMLDivElement | null>(null);
|
const sectionRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
@@ -881,7 +883,24 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
|||||||
Loading diff…
|
Loading diff…
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{diffData ? (
|
{diffData && !forceRenderLarge && (file.insertions + file.deletions) > LARGE_DIFF_CHANGED_LINES ? (
|
||||||
|
<div className="flex flex-col items-center gap-2 px-4 py-8 text-sm text-muted-foreground">
|
||||||
|
<div className="typography-ui-label font-semibold text-foreground">
|
||||||
|
Large diff ({file.insertions + file.deletions} changed lines)
|
||||||
|
</div>
|
||||||
|
<div className="typography-meta text-muted-foreground">
|
||||||
|
Rendering may be slow. You can still view the diff by clicking below.
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="typography-ui-label text-primary hover:underline"
|
||||||
|
onClick={() => setForceRenderLarge(true)}
|
||||||
|
>
|
||||||
|
Render anyway
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{diffData && (forceRenderLarge || (file.insertions + file.deletions) <= LARGE_DIFF_CHANGED_LINES) ? (
|
||||||
<InlineDiffViewer
|
<InlineDiffViewer
|
||||||
filePath={file.path}
|
filePath={file.path}
|
||||||
diff={diffData}
|
diff={diffData}
|
||||||
@@ -917,7 +936,9 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
|||||||
const isGitRepo = useIsGitRepo(effectiveDirectory ?? null);
|
const isGitRepo = useIsGitRepo(effectiveDirectory ?? null);
|
||||||
const status = useGitStatus(effectiveDirectory ?? null);
|
const status = useGitStatus(effectiveDirectory ?? null);
|
||||||
const isLoadingStatus = useGitStore((state) => state.isLoadingStatus);
|
const isLoadingStatus = useGitStore((state) => state.isLoadingStatus);
|
||||||
const { setActiveDirectory, fetchStatus, setDiff } = useGitStore();
|
const setActiveDirectory = useGitStore((state) => state.setActiveDirectory);
|
||||||
|
const fetchStatus = useGitStore((state) => state.fetchStatus);
|
||||||
|
const setDiff = useGitStore((state) => state.setDiff);
|
||||||
|
|
||||||
const [selectedFile, setSelectedFile] = React.useState<string | null>(null);
|
const [selectedFile, setSelectedFile] = React.useState<string | null>(null);
|
||||||
const [stackedExpandTarget, setStackedExpandTarget] = React.useState<string | null>(null);
|
const [stackedExpandTarget, setStackedExpandTarget] = React.useState<string | null>(null);
|
||||||
@@ -1722,7 +1743,8 @@ export const useDiffFileCount = (): number => {
|
|||||||
const { git } = useRuntimeAPIs();
|
const { git } = useRuntimeAPIs();
|
||||||
const effectiveDirectory = useEffectiveDirectory();
|
const effectiveDirectory = useEffectiveDirectory();
|
||||||
|
|
||||||
const { setActiveDirectory, fetchStatus } = useGitStore();
|
const setActiveDirectory = useGitStore((state) => state.setActiveDirectory);
|
||||||
|
const fetchStatus = useGitStore((state) => state.fetchStatus);
|
||||||
const fileCount = useGitFileCount(effectiveDirectory ?? null);
|
const fileCount = useGitFileCount(effectiveDirectory ?? null);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { useFireworksCelebration } from '@/contexts/FireworksContext';
|
import { useFireworksCelebration } from '@/contexts/FireworksContext';
|
||||||
import type { GitIdentityProfile, CommitFileEntry } from '@/lib/api/types';
|
import type { GitIdentityProfile, CommitFileEntry } from '@/lib/api/types';
|
||||||
@@ -225,13 +225,11 @@ export const GitView: React.FC = () => {
|
|||||||
const currentDirectory = useEffectiveDirectory();
|
const currentDirectory = useEffectiveDirectory();
|
||||||
const [worktreeBootstrapStatus, setWorktreeBootstrapStatus] = React.useState<'pending' | 'ready' | 'failed' | null>(null);
|
const [worktreeBootstrapStatus, setWorktreeBootstrapStatus] = React.useState<'pending' | 'ready' | 'failed' | null>(null);
|
||||||
const [isWaitingForGitRefreshAfterBootstrap, setIsWaitingForGitRefreshAfterBootstrap] = React.useState(false);
|
const [isWaitingForGitRefreshAfterBootstrap, setIsWaitingForGitRefreshAfterBootstrap] = React.useState(false);
|
||||||
const {
|
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
|
||||||
currentSessionId,
|
const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
|
||||||
worktreeMetadata: worktreeMap,
|
const setDraftBootstrapPendingDirectory = useSessionUIStore((s) => s.setDraftBootstrapPendingDirectory);
|
||||||
availableWorktrees,
|
const worktreeMap = useSessionUIStore((s) => s.worktreeMetadata);
|
||||||
newSessionDraft,
|
const availableWorktrees = useSessionUIStore((s) => s.availableWorktrees);
|
||||||
setDraftBootstrapPendingDirectory,
|
|
||||||
} = useSessionStore();
|
|
||||||
const normalizedCurrentDirectory = normalizePath(currentDirectory);
|
const normalizedCurrentDirectory = normalizePath(currentDirectory);
|
||||||
const inferredWorktreeMetadata = React.useMemo(() => {
|
const inferredWorktreeMetadata = React.useMemo(() => {
|
||||||
if (!normalizedCurrentDirectory) {
|
if (!normalizedCurrentDirectory) {
|
||||||
@@ -276,16 +274,14 @@ export const GitView: React.FC = () => {
|
|||||||
const currentIdentity = useGitIdentity(currentDirectory ?? null);
|
const currentIdentity = useGitIdentity(currentDirectory ?? null);
|
||||||
const isLoading = useGitStore((state) => state.isLoadingStatus);
|
const isLoading = useGitStore((state) => state.isLoadingStatus);
|
||||||
const isLogLoading = useGitStore((state) => state.isLoadingLog);
|
const isLogLoading = useGitStore((state) => state.isLoadingLog);
|
||||||
const {
|
const setActiveDirectory = useGitStore((state) => state.setActiveDirectory);
|
||||||
setActiveDirectory,
|
const fetchAll = useGitStore((state) => state.fetchAll);
|
||||||
fetchAll,
|
const fetchStatus = useGitStore((state) => state.fetchStatus);
|
||||||
fetchStatus,
|
const fetchBranches = useGitStore((state) => state.fetchBranches);
|
||||||
fetchBranches,
|
const fetchLog = useGitStore((state) => state.fetchLog);
|
||||||
fetchLog,
|
const fetchIdentity = useGitStore((state) => state.fetchIdentity);
|
||||||
fetchIdentity,
|
const prefetchDiffs = useGitStore((state) => state.prefetchDiffs);
|
||||||
prefetchDiffs,
|
const setLogMaxCount = useGitStore((state) => state.setLogMaxCount);
|
||||||
setLogMaxCount,
|
|
||||||
} = useGitStore();
|
|
||||||
const isMobile = useUIStore((state) => state.isMobile);
|
const isMobile = useUIStore((state) => state.isMobile);
|
||||||
const openContextDiff = useUIStore((state) => state.openContextDiff);
|
const openContextDiff = useUIStore((state) => state.openContextDiff);
|
||||||
const navigateToDiff = useUIStore((state) => state.navigateToDiff);
|
const navigateToDiff = useUIStore((state) => state.navigateToDiff);
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ import { useDeviceInfo } from '@/lib/device';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
|
||||||
|
// Threshold (bytes) above which syntax highlighting is degraded for performance
|
||||||
|
const LARGE_CONTENT_BYTES = 500_000;
|
||||||
|
|
||||||
interface PierreDiffViewerProps {
|
interface PierreDiffViewerProps {
|
||||||
original: string;
|
original: string;
|
||||||
modified: string;
|
modified: string;
|
||||||
@@ -439,6 +442,11 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
|||||||
}, [darkResolvedTheme, diffThemeKey, isDark, lightResolvedTheme]);
|
}, [darkResolvedTheme, diffThemeKey, isDark, lightResolvedTheme]);
|
||||||
|
|
||||||
|
|
||||||
|
const isLargeContent = useMemo(() =>
|
||||||
|
Math.max(original.length, modified.length) > LARGE_CONTENT_BYTES,
|
||||||
|
[original.length, modified.length],
|
||||||
|
);
|
||||||
|
|
||||||
const options = useMemo(() => ({
|
const options = useMemo(() => ({
|
||||||
theme: {
|
theme: {
|
||||||
dark: darkTheme.metadata.id,
|
dark: darkTheme.metadata.id,
|
||||||
@@ -450,8 +458,9 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
|||||||
hunkSeparators: 'line-info-basic' as const,
|
hunkSeparators: 'line-info-basic' as const,
|
||||||
// Perf: disable intra-line diff (word-level) globally.
|
// Perf: disable intra-line diff (word-level) globally.
|
||||||
lineDiffType: 'none' as const,
|
lineDiffType: 'none' as const,
|
||||||
maxLineDiffLength: 1000,
|
// Perf: degrade tokenization/highlighting for large files (>500KB)
|
||||||
maxLineLengthForHighlighting: 1000,
|
maxLineDiffLength: isLargeContent ? 0 : 1000,
|
||||||
|
maxLineLengthForHighlighting: isLargeContent ? 1 : 1000,
|
||||||
expansionLineCount: 20,
|
expansionLineCount: 20,
|
||||||
overflow: wrapLines ? ('wrap' as const) : ('scroll' as const),
|
overflow: wrapLines ? ('wrap' as const) : ('scroll' as const),
|
||||||
disableFileHeader: true,
|
disableFileHeader: true,
|
||||||
@@ -460,7 +469,7 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
|||||||
onLineSelected: handleSelectionChange,
|
onLineSelected: handleSelectionChange,
|
||||||
unsafeCSS: WEBKIT_SCROLL_FIX_CSS,
|
unsafeCSS: WEBKIT_SCROLL_FIX_CSS,
|
||||||
renderAnnotation,
|
renderAnnotation,
|
||||||
}), [darkTheme.metadata.id, isDark, lightTheme.metadata.id, renderSideBySide, wrapLines, handleSelectionChange, renderAnnotation]);
|
}), [darkTheme.metadata.id, isDark, isLargeContent, lightTheme.metadata.id, renderSideBySide, wrapLines, handleSelectionChange, renderAnnotation]);
|
||||||
|
|
||||||
|
|
||||||
const lineAnnotations = useMemo(() => {
|
const lineAnnotations = useMemo(() => {
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
|
|||||||
import { createFlexokiCodeMirrorTheme } from '@/lib/codemirror/flexokiTheme';
|
import { createFlexokiCodeMirrorTheme } from '@/lib/codemirror/flexokiTheme';
|
||||||
import { languageByExtension } from '@/lib/codemirror/languageByExtension';
|
import { languageByExtension } from '@/lib/codemirror/languageByExtension';
|
||||||
import { RiCheckLine, RiClipboardLine, RiFileCopy2Line } from '@remixicon/react';
|
import { RiCheckLine, RiClipboardLine, RiFileCopy2Line } from '@remixicon/react';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
|
import { useSessions } from '@/sync/sync-context';
|
||||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||||
import { EditorView } from '@codemirror/view';
|
import { EditorView } from '@codemirror/view';
|
||||||
@@ -80,8 +81,8 @@ type SelectedLineRange = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const PlanView: React.FC = () => {
|
export const PlanView: React.FC = () => {
|
||||||
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||||
const sessions = useSessionStore((state) => state.sessions);
|
const sessions = useSessions();
|
||||||
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
|
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
|
||||||
const runtimeApis = useRuntimeAPIs();
|
const runtimeApis = useRuntimeAPIs();
|
||||||
useUIStore();
|
useUIStore();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { RiAddLine, RiArrowDownLine, RiArrowGoBackLine, RiArrowLeftLine, RiArrowRightLine, RiArrowUpLine, RiCloseLine, RiCommandLine } from '@remixicon/react';
|
import { RiAddLine, RiArrowDownLine, RiArrowGoBackLine, RiArrowLeftLine, RiArrowRightLine, RiArrowUpLine, RiCloseLine, RiCommandLine } from '@remixicon/react';
|
||||||
|
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
import { useTerminalStore } from '@/stores/useTerminalStore';
|
import { useTerminalStore } from '@/stores/useTerminalStore';
|
||||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||||
import { type TerminalStreamEvent } from '@/lib/api/types';
|
import { type TerminalStreamEvent } from '@/lib/api/types';
|
||||||
@@ -93,7 +93,8 @@ export const TerminalView: React.FC = () => {
|
|||||||
const showTerminalQuickKeysOnDesktop = useUIStore((state) => state.showTerminalQuickKeysOnDesktop);
|
const showTerminalQuickKeysOnDesktop = useUIStore((state) => state.showTerminalQuickKeysOnDesktop);
|
||||||
const showQuickKeys = isMobile || showTerminalQuickKeysOnDesktop;
|
const showQuickKeys = isMobile || showTerminalQuickKeysOnDesktop;
|
||||||
|
|
||||||
const { currentSessionId, newSessionDraft } = useSessionStore();
|
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
|
||||||
|
const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
|
||||||
const hasActiveContext = currentSessionId !== null || newSessionDraft?.open === true;
|
const hasActiveContext = currentSessionId !== null || newSessionDraft?.open === true;
|
||||||
|
|
||||||
const effectiveDirectory = useEffectiveDirectory() ?? null;
|
const effectiveDirectory = useEffectiveDirectory() ?? null;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
RiCheckLine,
|
RiCheckLine,
|
||||||
RiMore2Line,
|
RiMore2Line,
|
||||||
RiFileCopyLine,
|
RiFileCopyLine,
|
||||||
|
RiLoader4Line,
|
||||||
} from '@remixicon/react';
|
} from '@remixicon/react';
|
||||||
import { toast } from '@/components/ui';
|
import { toast } from '@/components/ui';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -12,7 +13,8 @@ import { copyTextToClipboard } from '@/lib/clipboard';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { ProviderLogo } from '@/components/ui/ProviderLogo';
|
import { ProviderLogo } from '@/components/ui/ProviderLogo';
|
||||||
import { useAgentGroupsStore, type AgentGroup, type AgentGroupSession } from '@/stores/useAgentGroupsStore';
|
import { useAgentGroupsStore, type AgentGroup, type AgentGroupSession } from '@/stores/useAgentGroupsStore';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
|
import { useGlobalSessionStatus, useAllSessionStatuses } from '@/sync/sync-context';
|
||||||
import { ChatContainer } from '@/components/chat/ChatContainer';
|
import { ChatContainer } from '@/components/chat/ChatContainer';
|
||||||
import { ChatErrorBoundary } from '@/components/chat/ChatErrorBoundary';
|
import { ChatErrorBoundary } from '@/components/chat/ChatErrorBoundary';
|
||||||
import {
|
import {
|
||||||
@@ -35,53 +37,57 @@ interface AgentGroupDetailProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SessionStatusDot: React.FC<{ sessionId: string }> = ({ sessionId }) => {
|
||||||
|
const status = useGlobalSessionStatus(sessionId);
|
||||||
|
if (!status || status.type === 'idle') return null;
|
||||||
|
return (
|
||||||
|
<span className="relative flex h-2 w-2 flex-shrink-0" title={status.type}>
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75" />
|
||||||
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-amber-500" />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
|
export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
|
||||||
group,
|
group,
|
||||||
className,
|
className,
|
||||||
}) => {
|
}) => {
|
||||||
const { selectedSessionId, selectSession, deleteGroupWorktree, keepOnlyGroupWorktree } = useAgentGroupsStore();
|
const selectedSessionId = useAgentGroupsStore((s) => s.selectedSessionId);
|
||||||
const { setCurrentSession, currentSessionId } = useSessionStore();
|
const selectSession = useAgentGroupsStore((s) => s.selectSession);
|
||||||
|
const deleteGroupSessions = useAgentGroupsStore((s) => s.deleteGroupSessions);
|
||||||
|
const setCurrentSession = useSessionUIStore((s) => s.setCurrentSession);
|
||||||
|
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
|
||||||
const [worktreeDialog, setWorktreeDialog] = React.useState<null | { kind: 'remove' | 'keepOnly'; path: string; label: string }>(null);
|
const [worktreeDialog, setWorktreeDialog] = React.useState<null | { kind: 'remove' | 'keepOnly'; path: string; label: string }>(null);
|
||||||
const [isProcessing, setIsProcessing] = React.useState(false);
|
const [isProcessing, setIsProcessing] = React.useState(false);
|
||||||
|
|
||||||
// Find the currently selected session
|
|
||||||
const selectedSession = React.useMemo(() => {
|
const selectedSession = React.useMemo(() => {
|
||||||
if (!selectedSessionId) return group.sessions[0] ?? null;
|
if (!selectedSessionId) return group.sessions[0] ?? null;
|
||||||
return group.sessions.find((s) => s.id === selectedSessionId) ?? group.sessions[0] ?? null;
|
return group.sessions.find((s) => s.id === selectedSessionId) ?? group.sessions[0] ?? null;
|
||||||
}, [group.sessions, selectedSessionId]);
|
}, [group.sessions, selectedSessionId]);
|
||||||
|
|
||||||
// When selecting a session, switch to that OpenCode session
|
|
||||||
// NOTE: We intentionally do NOT change the global directory here to avoid
|
|
||||||
// re-triggering loadGroups() which would cause groups to disappear
|
|
||||||
const handleSessionSelect = React.useCallback((session: AgentGroupSession) => {
|
const handleSessionSelect = React.useCallback((session: AgentGroupSession) => {
|
||||||
selectSession(session.id);
|
selectSession(session.id);
|
||||||
|
setCurrentSession(session.id, session.path);
|
||||||
// Switch to the OpenCode session
|
|
||||||
setCurrentSession(session.id);
|
|
||||||
}, [selectSession, setCurrentSession]);
|
}, [selectSession, setCurrentSession]);
|
||||||
|
|
||||||
// Auto-select first session when group changes and sync OpenCode session
|
// Auto-select first session when group changes and sync OpenCode session
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (group.sessions.length > 0) {
|
if (group.sessions.length > 0) {
|
||||||
const session = selectedSessionId
|
const session = selectedSessionId
|
||||||
? group.sessions.find((s) => s.id === selectedSessionId) ?? group.sessions[0]
|
? group.sessions.find((s) => s.id === selectedSessionId) ?? group.sessions[0]
|
||||||
: group.sessions[0];
|
: group.sessions[0];
|
||||||
|
|
||||||
if (session) {
|
if (session) {
|
||||||
// Always ensure the OpenCode session is synced
|
if (session.id !== currentSessionId) {
|
||||||
if (session.id !== currentSessionId) {
|
setCurrentSession(session.id, session.path);
|
||||||
setCurrentSession(session.id);
|
}
|
||||||
}
|
if (!selectedSessionId) {
|
||||||
|
selectSession(session.id);
|
||||||
// Update selection if not already selected
|
|
||||||
if (!selectedSessionId) {
|
|
||||||
selectSession(session.id);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [group.name, group.sessions, selectedSessionId, currentSessionId, selectSession, setCurrentSession]);
|
}, [group.name, group.sessions, selectedSessionId, currentSessionId, selectSession, setCurrentSession]);
|
||||||
|
|
||||||
// Check if the current OpenCode session matches the selected agent group session
|
|
||||||
const isSessionSynced = selectedSession?.id === currentSessionId;
|
const isSessionSynced = selectedSession?.id === currentSessionId;
|
||||||
|
|
||||||
const handleCopyWorktreePath = React.useCallback(() => {
|
const handleCopyWorktreePath = React.useCallback(() => {
|
||||||
@@ -98,12 +104,12 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
|
|||||||
});
|
});
|
||||||
}, [selectedSession?.path]);
|
}, [selectedSession?.path]);
|
||||||
|
|
||||||
const handleRemoveSelectedWorktree = React.useCallback(async () => {
|
const handleRemoveSelectedWorktree = React.useCallback(() => {
|
||||||
if (!selectedSession) return;
|
if (!selectedSession) return;
|
||||||
setWorktreeDialog({ kind: 'remove', path: selectedSession.path, label: selectedSession.displayLabel });
|
setWorktreeDialog({ kind: 'remove', path: selectedSession.path, label: selectedSession.displayLabel });
|
||||||
}, [selectedSession]);
|
}, [selectedSession]);
|
||||||
|
|
||||||
const handleKeepOnlySelectedWorktree = React.useCallback(async () => {
|
const handleKeepOnlySelectedWorktree = React.useCallback(() => {
|
||||||
if (!selectedSession) return;
|
if (!selectedSession) return;
|
||||||
setWorktreeDialog({ kind: 'keepOnly', path: selectedSession.path, label: selectedSession.displayLabel });
|
setWorktreeDialog({ kind: 'keepOnly', path: selectedSession.path, label: selectedSession.displayLabel });
|
||||||
}, [selectedSession]);
|
}, [selectedSession]);
|
||||||
@@ -112,32 +118,36 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
|
|||||||
if (!worktreeDialog || isProcessing) return;
|
if (!worktreeDialog || isProcessing) return;
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
try {
|
try {
|
||||||
|
const normalize = (v: string) => v.replace(/\\/g, '/').replace(/\/+$/, '') || v;
|
||||||
|
const targetPath = normalize(worktreeDialog.path);
|
||||||
|
let sessionsToDelete: AgentGroupSession[];
|
||||||
|
|
||||||
if (worktreeDialog.kind === 'remove') {
|
if (worktreeDialog.kind === 'remove') {
|
||||||
toast.info('Removing worktree...');
|
toast.info('Removing worktree...');
|
||||||
const ok = await deleteGroupWorktree(group.name, worktreeDialog.path);
|
sessionsToDelete = group.sessions.filter((s) => normalize(s.path) === targetPath);
|
||||||
if (ok) {
|
|
||||||
toast.success('Worktree removed');
|
|
||||||
} else {
|
|
||||||
const error = useAgentGroupsStore.getState().error;
|
|
||||||
toast.error(error || 'Failed to remove worktree');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
toast.info('Removing other worktrees...');
|
toast.info('Removing other worktrees...');
|
||||||
const ok = await keepOnlyGroupWorktree(group.name, worktreeDialog.path);
|
sessionsToDelete = group.sessions.filter((s) => normalize(s.path) !== targetPath);
|
||||||
if (ok) {
|
}
|
||||||
toast.success('Removed other worktrees');
|
|
||||||
} else {
|
const { failedIds, failedWorktreePaths } = await deleteGroupSessions(sessionsToDelete, { removeWorktrees: true });
|
||||||
const error = useAgentGroupsStore.getState().error;
|
if (failedIds.length > 0 || failedWorktreePaths.length > 0) {
|
||||||
toast.error(error || 'Failed to remove other worktrees');
|
toast.error('Failed to fully remove worktree');
|
||||||
return;
|
} else {
|
||||||
}
|
toast.success(worktreeDialog.kind === 'remove' ? 'Worktree removed' : 'Removed other worktrees');
|
||||||
}
|
}
|
||||||
setWorktreeDialog(null);
|
setWorktreeDialog(null);
|
||||||
} finally {
|
} finally {
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
}
|
}
|
||||||
}, [deleteGroupWorktree, group.name, isProcessing, keepOnlyGroupWorktree, worktreeDialog]);
|
}, [deleteGroupSessions, group.sessions, isProcessing, worktreeDialog]);
|
||||||
|
|
||||||
|
// Group-level status: show if any session is busy
|
||||||
|
const allStatuses = useAllSessionStatuses();
|
||||||
|
const groupBusy = React.useMemo(
|
||||||
|
() => group.sessions.some((s) => allStatuses[s.id]?.type === 'busy'),
|
||||||
|
[group.sessions, allStatuses],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex h-full flex-col bg-background', className)}>
|
<div className={cn('flex h-full flex-col bg-background', className)}>
|
||||||
@@ -145,7 +155,10 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
|
|||||||
<div className="flex-shrink-0 border-b border-border/30 px-4 py-3">
|
<div className="flex-shrink-0 border-b border-border/30 px-4 py-3">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h1 className="typography-heading-lg text-foreground truncate">{group.name}</h1>
|
<div className="flex items-center gap-2">
|
||||||
|
<h1 className="typography-heading-lg text-foreground truncate">{group.name}</h1>
|
||||||
|
{groupBusy && <RiLoader4Line className="h-4 w-4 animate-spin text-amber-500 flex-shrink-0" />}
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2 mt-1 typography-meta text-muted-foreground">
|
<div className="flex items-center gap-2 mt-1 typography-meta text-muted-foreground">
|
||||||
<span>{group.sessionCount} model{group.sessionCount !== 1 ? 's' : ''}</span>
|
<span>{group.sessionCount} model{group.sessionCount !== 1 ? 's' : ''}</span>
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
@@ -156,7 +169,7 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Model Selector Dropdown */}
|
{/* Model Selector Dropdown */}
|
||||||
{group.sessions.length > 0 && (
|
{group.sessions.length > 0 && (
|
||||||
<div className="mt-3 flex items-center gap-2">
|
<div className="mt-3 flex items-center gap-2">
|
||||||
@@ -170,9 +183,9 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
|
|||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
{selectedSession && (
|
{selectedSession && (
|
||||||
<>
|
<>
|
||||||
<ProviderLogo
|
<ProviderLogo
|
||||||
providerId={selectedSession.providerId}
|
providerId={selectedSession.providerId}
|
||||||
className="h-5 w-5 flex-shrink-0"
|
className="h-5 w-5 flex-shrink-0"
|
||||||
/>
|
/>
|
||||||
<span className="truncate typography-body">
|
<span className="truncate typography-body">
|
||||||
{selectedSession.modelId}
|
{selectedSession.modelId}
|
||||||
@@ -182,6 +195,7 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
|
|||||||
#{selectedSession.instanceNumber}
|
#{selectedSession.instanceNumber}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
<SessionStatusDot sessionId={selectedSession.id} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -195,9 +209,9 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
|
|||||||
onClick={() => handleSessionSelect(session)}
|
onClick={() => handleSessionSelect(session)}
|
||||||
className="flex items-center gap-2 py-2"
|
className="flex items-center gap-2 py-2"
|
||||||
>
|
>
|
||||||
<ProviderLogo
|
<ProviderLogo
|
||||||
providerId={session.providerId}
|
providerId={session.providerId}
|
||||||
className="h-5 w-5 flex-shrink-0"
|
className="h-5 w-5 flex-shrink-0"
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -209,6 +223,7 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
|
|||||||
#{session.instanceNumber}
|
#{session.instanceNumber}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
<SessionStatusDot sessionId={session.id} />
|
||||||
</div>
|
</div>
|
||||||
{session.branch && (
|
{session.branch && (
|
||||||
<div className="flex items-center gap-1 typography-micro text-muted-foreground/60">
|
<div className="flex items-center gap-1 typography-micro text-muted-foreground/60">
|
||||||
@@ -236,7 +251,7 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onSelect={(e) => {
|
onSelect={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
void handleRemoveSelectedWorktree();
|
handleRemoveSelectedWorktree();
|
||||||
}}
|
}}
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
>
|
>
|
||||||
@@ -245,7 +260,7 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onSelect={(e) => {
|
onSelect={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
void handleKeepOnlySelectedWorktree();
|
handleKeepOnlySelectedWorktree();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Leave this one, remove others
|
Leave this one, remove others
|
||||||
@@ -292,7 +307,7 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* Chat Content */}
|
{/* Chat Content */}
|
||||||
<div className="flex-1 min-h-0">
|
<div className="flex-1 min-h-0">
|
||||||
{selectedSession ? (
|
{selectedSession ? (
|
||||||
@@ -302,7 +317,6 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
|
|||||||
</ChatErrorBoundary>
|
</ChatErrorBoundary>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
{/* Info banner about the worktree */}
|
|
||||||
<div className="px-4 py-2 bg-muted/30 border-b border-border/30">
|
<div className="px-4 py-2 bg-muted/30 border-b border-border/30">
|
||||||
<div className="flex items-center gap-2 typography-meta text-muted-foreground">
|
<div className="flex items-center gap-2 typography-meta text-muted-foreground">
|
||||||
<ProviderLogo providerId={selectedSession.providerId} className="h-4 w-4" />
|
<ProviderLogo providerId={selectedSession.providerId} className="h-4 w-4" />
|
||||||
@@ -315,8 +329,6 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Loading or no session state */}
|
|
||||||
<div className="flex-1 flex items-center justify-center">
|
<div className="flex-1 flex items-center justify-center">
|
||||||
<div className="text-center p-8">
|
<div className="text-center p-8">
|
||||||
<p className="typography-body text-muted-foreground mb-2">
|
<p className="typography-body text-muted-foreground mb-2">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
RiMore2Line,
|
RiMore2Line,
|
||||||
RiSearchLine,
|
RiSearchLine,
|
||||||
RiGitBranchLine,
|
RiGitBranchLine,
|
||||||
|
RiLoader4Line,
|
||||||
} from '@remixicon/react';
|
} from '@remixicon/react';
|
||||||
import { toast } from '@/components/ui';
|
import { toast } from '@/components/ui';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -26,16 +27,16 @@ import {
|
|||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useAgentGroupsStore, type AgentGroup } from '@/stores/useAgentGroupsStore';
|
import { useAgentGroupsStore, type AgentGroup } from '@/stores/useAgentGroupsStore';
|
||||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
import { useAllSessionStatuses } from '@/sync/sync-context';
|
||||||
|
|
||||||
const formatRelativeTime = (timestamp: number): string => {
|
const formatRelativeTime = (timestamp: number): string => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const diff = now - timestamp;
|
const diff = now - timestamp;
|
||||||
|
|
||||||
const minutes = Math.floor(diff / (60 * 1000));
|
const minutes = Math.floor(diff / (60 * 1000));
|
||||||
const hours = Math.floor(diff / (60 * 60 * 1000));
|
const hours = Math.floor(diff / (60 * 60 * 1000));
|
||||||
const days = Math.floor(diff / (24 * 60 * 60 * 1000));
|
const days = Math.floor(diff / (24 * 60 * 60 * 1000));
|
||||||
|
|
||||||
if (minutes < 1) return 'now';
|
if (minutes < 1) return 'now';
|
||||||
if (minutes < 60) return `${minutes}m`;
|
if (minutes < 60) return `${minutes}m`;
|
||||||
if (hours < 24) return `${hours}h`;
|
if (hours < 24) return `${hours}h`;
|
||||||
@@ -45,30 +46,30 @@ const formatRelativeTime = (timestamp: number): string => {
|
|||||||
interface AgentGroupItemProps {
|
interface AgentGroupItemProps {
|
||||||
group: AgentGroup;
|
group: AgentGroup;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
|
isBusy: boolean;
|
||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AgentGroupItem: React.FC<AgentGroupItemProps> = ({ group, isSelected, onSelect }) => {
|
const AgentGroupItem: React.FC<AgentGroupItemProps> = ({ group, isSelected, isBusy, onSelect }) => {
|
||||||
const [menuOpen, setMenuOpen] = React.useState(false);
|
const [menuOpen, setMenuOpen] = React.useState(false);
|
||||||
const [confirmOpen, setConfirmOpen] = React.useState(false);
|
const [confirmOpen, setConfirmOpen] = React.useState(false);
|
||||||
const [isDeleting, setIsDeleting] = React.useState(false);
|
const [isDeleting, setIsDeleting] = React.useState(false);
|
||||||
const deleteGroup = useAgentGroupsStore((state) => state.deleteGroup);
|
const deleteGroupSessions = useAgentGroupsStore((s) => s.deleteGroupSessions);
|
||||||
|
|
||||||
const handleDeleteGroup = React.useCallback(async () => {
|
const handleDeleteGroup = React.useCallback(async () => {
|
||||||
if (isDeleting) return;
|
if (isDeleting) return;
|
||||||
setIsDeleting(true);
|
setIsDeleting(true);
|
||||||
toast.info(`Deleting "${group.name}"...`);
|
toast.info(`Deleting "${group.name}"...`);
|
||||||
const ok = await deleteGroup(group.name);
|
const { failedIds, failedWorktreePaths } = await deleteGroupSessions(group.sessions, { removeWorktrees: true });
|
||||||
if (ok) {
|
if (failedIds.length === 0 && failedWorktreePaths.length === 0) {
|
||||||
toast.success(`Deleted "${group.name}"`);
|
toast.success(`Deleted "${group.name}"`);
|
||||||
} else {
|
} else {
|
||||||
const error = useAgentGroupsStore.getState().error;
|
toast.error(`Failed to fully delete "${group.name}"`);
|
||||||
toast.error(error || `Failed to delete "${group.name}"`);
|
|
||||||
}
|
}
|
||||||
setIsDeleting(false);
|
setIsDeleting(false);
|
||||||
setConfirmOpen(false);
|
setConfirmOpen(false);
|
||||||
}, [deleteGroup, group.name, isDeleting]);
|
}, [deleteGroupSessions, group.name, group.sessions, isDeleting]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
@@ -83,9 +84,12 @@ const AgentGroupItem: React.FC<AgentGroupItemProps> = ({ group, isSelected, onSe
|
|||||||
type="button"
|
type="button"
|
||||||
className="flex min-w-0 flex-1 flex-col gap-0.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
className="flex min-w-0 flex-1 flex-col gap-0.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||||
>
|
>
|
||||||
<span className="truncate typography-ui-label font-normal text-foreground">
|
<div className="flex items-center gap-1.5">
|
||||||
{group.name}
|
<span className="truncate typography-ui-label font-normal text-foreground">
|
||||||
</span>
|
{group.name}
|
||||||
|
</span>
|
||||||
|
{isBusy && <RiLoader4Line className="h-3 w-3 animate-spin text-amber-500 flex-shrink-0" />}
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="typography-micro text-muted-foreground/60 flex items-center gap-1">
|
<span className="typography-micro text-muted-foreground/60 flex items-center gap-1">
|
||||||
<RiGitBranchLine className="h-3 w-3" />
|
<RiGitBranchLine className="h-3 w-3" />
|
||||||
@@ -96,7 +100,7 @@ const AgentGroupItem: React.FC<AgentGroupItemProps> = ({ group, isSelected, onSe
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5 self-stretch">
|
<div className="flex items-center gap-1.5 self-stretch">
|
||||||
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
|
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@@ -154,6 +158,7 @@ const AgentGroupItem: React.FC<AgentGroupItemProps> = ({ group, isSelected, onSe
|
|||||||
|
|
||||||
interface AgentManagerSidebarProps {
|
interface AgentManagerSidebarProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
groups: AgentGroup[];
|
||||||
selectedGroupName?: string | null;
|
selectedGroupName?: string | null;
|
||||||
onGroupSelect?: (groupName: string) => void;
|
onGroupSelect?: (groupName: string) => void;
|
||||||
onNewAgent?: () => void;
|
onNewAgent?: () => void;
|
||||||
@@ -161,36 +166,40 @@ interface AgentManagerSidebarProps {
|
|||||||
|
|
||||||
export const AgentManagerSidebar: React.FC<AgentManagerSidebarProps> = ({
|
export const AgentManagerSidebar: React.FC<AgentManagerSidebarProps> = ({
|
||||||
className,
|
className,
|
||||||
|
groups,
|
||||||
selectedGroupName,
|
selectedGroupName,
|
||||||
onGroupSelect,
|
onGroupSelect,
|
||||||
onNewAgent,
|
onNewAgent,
|
||||||
}) => {
|
}) => {
|
||||||
const [searchQuery, setSearchQuery] = React.useState('');
|
const [searchQuery, setSearchQuery] = React.useState('');
|
||||||
const [showAll, setShowAll] = React.useState(false);
|
const [showAll, setShowAll] = React.useState(false);
|
||||||
|
const isLoading = useAgentGroupsStore((s) => s.isLoading);
|
||||||
const { groups, isLoading, loadGroups } = useAgentGroupsStore();
|
|
||||||
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
// Session statuses for busy indicators
|
||||||
|
const allStatuses = useAllSessionStatuses();
|
||||||
// Load groups when directory changes
|
const busyGroups = React.useMemo(() => {
|
||||||
React.useEffect(() => {
|
const set = new Set<string>();
|
||||||
if (currentDirectory) {
|
for (const group of groups) {
|
||||||
loadGroups();
|
if (group.sessions.some((s) => allStatuses[s.id]?.type === 'busy')) {
|
||||||
|
set.add(group.name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [currentDirectory, loadGroups]);
|
return set;
|
||||||
|
}, [groups, allStatuses]);
|
||||||
|
|
||||||
const MAX_VISIBLE = 5;
|
const MAX_VISIBLE = 5;
|
||||||
|
|
||||||
const filteredGroups = React.useMemo(() => {
|
const filteredGroups = React.useMemo(() => {
|
||||||
if (!searchQuery.trim()) return groups;
|
if (!searchQuery.trim()) return groups;
|
||||||
const query = searchQuery.toLowerCase();
|
const query = searchQuery.toLowerCase();
|
||||||
return groups.filter(group =>
|
return groups.filter(group =>
|
||||||
group.name.toLowerCase().includes(query)
|
group.name.toLowerCase().includes(query)
|
||||||
);
|
);
|
||||||
}, [searchQuery, groups]);
|
}, [searchQuery, groups]);
|
||||||
|
|
||||||
const visibleGroups = showAll ? filteredGroups : filteredGroups.slice(0, MAX_VISIBLE);
|
const visibleGroups = showAll ? filteredGroups : filteredGroups.slice(0, MAX_VISIBLE);
|
||||||
const remainingCount = filteredGroups.length - MAX_VISIBLE;
|
const remainingCount = filteredGroups.length - MAX_VISIBLE;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex h-full flex-col text-foreground border-r border-border/30', className)}>
|
<div className={cn('flex h-full flex-col text-foreground border-r border-border/30', className)}>
|
||||||
{/* Search Input */}
|
{/* Search Input */}
|
||||||
@@ -205,7 +214,7 @@ export const AgentManagerSidebar: React.FC<AgentManagerSidebarProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* New Agent Button */}
|
{/* New Agent Button */}
|
||||||
<div className="px-2.5 pb-2">
|
<div className="px-2.5 pb-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -217,7 +226,7 @@ export const AgentManagerSidebar: React.FC<AgentManagerSidebarProps> = ({
|
|||||||
<span className="typography-ui-label">New Agent Group</span>
|
<span className="typography-ui-label">New Agent Group</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Agent Groups Section Header */}
|
{/* Agent Groups Section Header */}
|
||||||
<div className="px-2.5 py-1.5 flex items-center gap-1">
|
<div className="px-2.5 py-1.5 flex items-center gap-1">
|
||||||
<RiArrowDownSLine className="h-4 w-4 text-muted-foreground" />
|
<RiArrowDownSLine className="h-4 w-4 text-muted-foreground" />
|
||||||
@@ -230,7 +239,7 @@ export const AgentManagerSidebar: React.FC<AgentManagerSidebarProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Group List */}
|
{/* Group List */}
|
||||||
<ScrollableOverlay
|
<ScrollableOverlay
|
||||||
outerClassName="flex-1 min-h-0"
|
outerClassName="flex-1 min-h-0"
|
||||||
@@ -241,11 +250,11 @@ export const AgentManagerSidebar: React.FC<AgentManagerSidebarProps> = ({
|
|||||||
key={group.name}
|
key={group.name}
|
||||||
group={group}
|
group={group}
|
||||||
isSelected={selectedGroupName === group.name}
|
isSelected={selectedGroupName === group.name}
|
||||||
|
isBusy={busyGroups.has(group.name)}
|
||||||
onSelect={() => onGroupSelect?.(group.name)}
|
onSelect={() => onGroupSelect?.(group.name)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Show More Link */}
|
|
||||||
{!showAll && remainingCount > 0 && (
|
{!showAll && remainingCount > 0 && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -255,8 +264,7 @@ export const AgentManagerSidebar: React.FC<AgentManagerSidebarProps> = ({
|
|||||||
... More ({remainingCount})
|
... More ({remainingCount})
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Show Less Link */}
|
|
||||||
{showAll && filteredGroups.length > MAX_VISIBLE && (
|
{showAll && filteredGroups.length > MAX_VISIBLE && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -266,8 +274,7 @@ export const AgentManagerSidebar: React.FC<AgentManagerSidebarProps> = ({
|
|||||||
Show less
|
Show less
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Empty State */}
|
|
||||||
{!isLoading && filteredGroups.length === 0 && (
|
{!isLoading && filteredGroups.length === 0 && (
|
||||||
<div className="py-4 text-center">
|
<div className="py-4 text-center">
|
||||||
<p className="typography-meta text-muted-foreground">
|
<p className="typography-meta text-muted-foreground">
|
||||||
|
|||||||
@@ -6,10 +6,8 @@ import { AgentGroupDetail } from './AgentGroupDetail';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useAgentGroupsStore } from '@/stores/useAgentGroupsStore';
|
import { useAgentGroupsStore } from '@/stores/useAgentGroupsStore';
|
||||||
import { useMultiRunStore } from '@/stores/useMultiRunStore';
|
import { useMultiRunStore } from '@/stores/useMultiRunStore';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
import { streamDebugEnabled } from '@/stores/utils/streamDebug';
|
|
||||||
import type { CreateMultiRunParams } from '@/types/multirun';
|
import type { CreateMultiRunParams } from '@/types/multirun';
|
||||||
|
|
||||||
interface AgentManagerViewProps {
|
interface AgentManagerViewProps {
|
||||||
@@ -30,37 +28,37 @@ export const AgentManagerView: React.FC<AgentManagerViewProps> = ({ className })
|
|||||||
'connecting' | 'connected' | 'error' | 'disconnected' | undefined
|
'connecting' | 'connected' | 'error' | 'disconnected' | undefined
|
||||||
: 'connecting') || 'connecting'
|
: 'connecting') || 'connecting'
|
||||||
);
|
);
|
||||||
const configInitialized = useConfigStore((state) => state.isInitialized);
|
const configInitialized = useConfigStore((s) => s.isInitialized);
|
||||||
const initializeApp = useConfigStore((state) => state.initializeApp);
|
const initializeApp = useConfigStore((s) => s.initializeApp);
|
||||||
const loadSessions = useSessionStore((state) => state.loadSessions);
|
const setDirectory = useDirectoryStore((s) => s.setDirectory);
|
||||||
const setDirectory = useDirectoryStore((state) => state.setDirectory);
|
const currentDirectory = useDirectoryStore((s) => s.currentDirectory);
|
||||||
const bootstrapAttemptAt = React.useRef<number>(0);
|
const bootstrapAttemptAt = React.useRef<number>(0);
|
||||||
|
|
||||||
const {
|
const groups = useAgentGroupsStore((s) => s.groups);
|
||||||
selectedGroupName,
|
const selectedGroupName = useAgentGroupsStore((s) => s.selectedGroupName);
|
||||||
selectGroup,
|
const selectGroup = useAgentGroupsStore((s) => s.selectGroup);
|
||||||
getSelectedGroup,
|
const loadGroups = useAgentGroupsStore((s) => s.loadGroups);
|
||||||
loadGroups,
|
|
||||||
} = useAgentGroupsStore();
|
|
||||||
|
|
||||||
const { createMultiRun, isLoading: isCreatingMultiRun } = useMultiRunStore();
|
const createMultiRun = useMultiRunStore((s) => s.createMultiRun);
|
||||||
|
const isCreatingMultiRun = useMultiRunStore((s) => s.isLoading);
|
||||||
|
|
||||||
|
const selectedGroup = React.useMemo(
|
||||||
|
() => (selectedGroupName ? groups.find((g) => g.name === selectedGroupName) ?? null : null),
|
||||||
|
[groups, selectedGroupName],
|
||||||
|
);
|
||||||
|
|
||||||
|
// VS Code connection bootstrap
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!isVSCodeRuntime) {
|
if (!isVSCodeRuntime) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const current =
|
const current =
|
||||||
(typeof window !== 'undefined'
|
(typeof window !== 'undefined'
|
||||||
? (window as unknown as { __OPENCHAMBER_CONNECTION__?: { status?: string } }).__OPENCHAMBER_CONNECTION__?.status
|
? (window as unknown as { __OPENCHAMBER_CONNECTION__?: { status?: string } }).__OPENCHAMBER_CONNECTION__?.status
|
||||||
: undefined) as 'connecting' | 'connected' | 'error' | 'disconnected' | undefined;
|
: undefined) as 'connecting' | 'connected' | 'error' | 'disconnected' | undefined;
|
||||||
if (current === 'connected' || current === 'connecting' || current === 'error' || current === 'disconnected') {
|
if (current) setConnectionStatus(current);
|
||||||
setConnectionStatus(current);
|
|
||||||
}
|
|
||||||
|
|
||||||
const handler = (event: Event) => {
|
const handler = (event: Event) => {
|
||||||
const detail = (event as CustomEvent<{ status?: string; error?: string }>).detail;
|
const status = (event as CustomEvent<{ status?: string }>).detail?.status;
|
||||||
const status = detail?.status;
|
|
||||||
if (status === 'connected' || status === 'connecting' || status === 'error' || status === 'disconnected') {
|
if (status === 'connected' || status === 'connecting' || status === 'error' || status === 'disconnected') {
|
||||||
setConnectionStatus(status);
|
setConnectionStatus(status);
|
||||||
}
|
}
|
||||||
@@ -70,14 +68,9 @@ export const AgentManagerView: React.FC<AgentManagerViewProps> = ({ className })
|
|||||||
}, [isVSCodeRuntime]);
|
}, [isVSCodeRuntime]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!isVSCodeRuntime || connectionStatus !== 'connected') {
|
if (!isVSCodeRuntime || connectionStatus !== 'connected') return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - bootstrapAttemptAt.current < 750) {
|
if (now - bootstrapAttemptAt.current < 750) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
bootstrapAttemptAt.current = now;
|
bootstrapAttemptAt.current = now;
|
||||||
|
|
||||||
const workspaceFolder = (typeof window !== 'undefined'
|
const workspaceFolder = (typeof window !== 'undefined'
|
||||||
@@ -85,52 +78,22 @@ export const AgentManagerView: React.FC<AgentManagerViewProps> = ({ className })
|
|||||||
: null);
|
: null);
|
||||||
|
|
||||||
if (typeof workspaceFolder === 'string' && workspaceFolder.trim().length > 0) {
|
if (typeof workspaceFolder === 'string' && workspaceFolder.trim().length > 0) {
|
||||||
try {
|
try { setDirectory(workspaceFolder, { showOverlay: false }); } catch { /* ignored */ }
|
||||||
setDirectory(workspaceFolder, { showOverlay: false });
|
|
||||||
} catch {
|
|
||||||
// ignored
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const runBootstrap = async () => {
|
if (!configInitialized) void initializeApp();
|
||||||
try {
|
}, [connectionStatus, configInitialized, initializeApp, isVSCodeRuntime, setDirectory]);
|
||||||
if (!configInitialized) {
|
|
||||||
await initializeApp();
|
|
||||||
}
|
|
||||||
|
|
||||||
const configState = useConfigStore.getState();
|
// Load groups on mount and when directory changes
|
||||||
if (
|
React.useEffect(() => {
|
||||||
!configState.isInitialized ||
|
void loadGroups();
|
||||||
!configState.isConnected ||
|
}, [currentDirectory, loadGroups]);
|
||||||
configState.providers.length === 0 ||
|
|
||||||
configState.agents.length === 0
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await loadSessions();
|
|
||||||
|
|
||||||
if (streamDebugEnabled()) {
|
|
||||||
console.log('[OpenChamber][VSCode][agentManager] bootstrap complete', {
|
|
||||||
providers: configState.providers.length,
|
|
||||||
agents: configState.agents.length,
|
|
||||||
sessions: useSessionStore.getState().sessions.length,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignored
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
void runBootstrap();
|
|
||||||
}, [connectionStatus, configInitialized, initializeApp, isVSCodeRuntime, loadSessions, setDirectory]);
|
|
||||||
|
|
||||||
const handleGroupSelect = React.useCallback((groupName: string) => {
|
const handleGroupSelect = React.useCallback((groupName: string) => {
|
||||||
selectGroup(groupName);
|
selectGroup(groupName);
|
||||||
}, [selectGroup]);
|
}, [selectGroup]);
|
||||||
|
|
||||||
const handleNewAgent = React.useCallback(() => {
|
const handleNewAgent = React.useCallback(() => {
|
||||||
// Clear selection to show the empty state / new agent form
|
|
||||||
selectGroup(null);
|
selectGroup(null);
|
||||||
}, [selectGroup]);
|
}, [selectGroup]);
|
||||||
|
|
||||||
@@ -141,54 +104,30 @@ export const AgentManagerView: React.FC<AgentManagerViewProps> = ({ className })
|
|||||||
|
|
||||||
if (result) {
|
if (result) {
|
||||||
toast.success(`Agent group "${params.name}" created with ${result.sessionIds.length} session(s)`);
|
toast.success(`Agent group "${params.name}" created with ${result.sessionIds.length} session(s)`);
|
||||||
const groupSlug = result.groupSlug;
|
// Refresh groups — new worktrees + sessions now exist
|
||||||
|
await loadGroups();
|
||||||
const waitForGroup = async (attempts = 6) => {
|
selectGroup(result.groupSlug);
|
||||||
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
||||||
await loadGroups();
|
|
||||||
const groupsState = useAgentGroupsStore.getState();
|
|
||||||
if (groupsState.groups.some((group) => group.name === groupSlug)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Refresh sessions + groups and wait briefly for OpenCode to surface the new worktree sessions.
|
|
||||||
try {
|
|
||||||
await useSessionStore.getState().loadSessions();
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
|
|
||||||
await waitForGroup();
|
|
||||||
selectGroup(groupSlug);
|
|
||||||
} else {
|
} else {
|
||||||
const error = useMultiRunStore.getState().error;
|
const error = useMultiRunStore.getState().error;
|
||||||
toast.error(error || 'Failed to create agent group');
|
toast.error(error || 'Failed to create agent group');
|
||||||
}
|
}
|
||||||
}, [createMultiRun, loadGroups, selectGroup]);
|
}, [createMultiRun, loadGroups, selectGroup]);
|
||||||
|
|
||||||
const selectedGroup = getSelectedGroup();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex h-full w-full bg-background', className)}>
|
<div className={cn('flex h-full w-full bg-background', className)}>
|
||||||
{/* Left Sidebar - Agent Groups List */}
|
|
||||||
<div className="w-64 flex-shrink-0">
|
<div className="w-64 flex-shrink-0">
|
||||||
<AgentManagerSidebar
|
<AgentManagerSidebar
|
||||||
|
groups={groups}
|
||||||
selectedGroupName={selectedGroupName}
|
selectedGroupName={selectedGroupName}
|
||||||
onGroupSelect={handleGroupSelect}
|
onGroupSelect={handleGroupSelect}
|
||||||
onNewAgent={handleNewAgent}
|
onNewAgent={handleNewAgent}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Content Area */}
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
{selectedGroup ? (
|
{selectedGroup ? (
|
||||||
<AgentGroupDetail group={selectedGroup} />
|
<AgentGroupDetail group={selectedGroup} />
|
||||||
) : (
|
) : (
|
||||||
<AgentManagerEmptyState
|
<AgentManagerEmptyState
|
||||||
onCreateGroup={handleCreateGroup}
|
onCreateGroup={handleCreateGroup}
|
||||||
isCreating={isCreatingMultiRun}
|
isCreating={isCreatingMultiRun}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import {
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
import { RiAlertLine, RiLoader4Line, RiChat1Line, RiAddLine } from '@remixicon/react';
|
import { RiAlertLine, RiLoader4Line, RiChat1Line, RiAddLine } from '@remixicon/react';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
|
import { useInputStore } from '@/sync/input-store';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { toast } from '@/components/ui';
|
import { toast } from '@/components/ui';
|
||||||
import { getConflictDetails, type MergeConflictDetails } from '@/lib/gitApi';
|
import { getConflictDetails, type MergeConflictDetails } from '@/lib/gitApi';
|
||||||
@@ -33,10 +34,10 @@ export const ConflictDialog: React.FC<ConflictDialogProps> = ({
|
|||||||
onAbort,
|
onAbort,
|
||||||
onClearState,
|
onClearState,
|
||||||
}) => {
|
}) => {
|
||||||
const openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft);
|
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
|
||||||
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||||
const setPendingInputText = useSessionStore((state) => state.setPendingInputText);
|
const setPendingInputText = useInputStore((state) => state.setPendingInputText);
|
||||||
const setPendingSyntheticParts = useSessionStore((state) => state.setPendingSyntheticParts);
|
const setPendingSyntheticParts = useInputStore((state) => state.setPendingSyntheticParts);
|
||||||
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
|
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = React.useState(false);
|
const [isLoading, setIsLoading] = React.useState(false);
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import {
|
|||||||
CommandList,
|
CommandList,
|
||||||
} from '@/components/ui/command';
|
} from '@/components/ui/command';
|
||||||
import { toast } from '@/components/ui';
|
import { toast } from '@/components/ui';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
|
import { useInputStore } from '@/sync/input-store';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { execCommand } from '@/lib/execCommands';
|
import { execCommand } from '@/lib/execCommands';
|
||||||
import {
|
import {
|
||||||
@@ -55,7 +56,7 @@ export const IntegrateCommitsSection: React.FC<{
|
|||||||
refreshKey,
|
refreshKey,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
}) => {
|
}) => {
|
||||||
const currentSessionId = useSessionStore((s) => s.currentSessionId);
|
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
|
||||||
const setActiveMainTab = useUIStore((s) => s.setActiveMainTab);
|
const setActiveMainTab = useUIStore((s) => s.setActiveMainTab);
|
||||||
const [branchDropdownOpen, setBranchDropdownOpen] = React.useState(false);
|
const [branchDropdownOpen, setBranchDropdownOpen] = React.useState(false);
|
||||||
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
||||||
@@ -167,7 +168,7 @@ export const IntegrateCommitsSection: React.FC<{
|
|||||||
const persistTarget = React.useCallback(
|
const persistTarget = React.useCallback(
|
||||||
(branch: string) => {
|
(branch: string) => {
|
||||||
if (!currentSessionId) return;
|
if (!currentSessionId) return;
|
||||||
useSessionStore.getState().setWorktreeMetadata(currentSessionId, {
|
useSessionUIStore.getState().setWorktreeMetadata(currentSessionId, {
|
||||||
...worktreeMetadata,
|
...worktreeMetadata,
|
||||||
createdFromBranch: branch,
|
createdFromBranch: branch,
|
||||||
});
|
});
|
||||||
@@ -175,7 +176,7 @@ export const IntegrateCommitsSection: React.FC<{
|
|||||||
[currentSessionId, worktreeMetadata]
|
[currentSessionId, worktreeMetadata]
|
||||||
);
|
);
|
||||||
|
|
||||||
const openNewSessionDraft = useSessionStore((s) => s.openNewSessionDraft);
|
const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft);
|
||||||
|
|
||||||
const buildConflictContext = React.useCallback((payload: { state: IntegrateInProgress; details: IntegrateConflictDetails }) => {
|
const buildConflictContext = React.useCallback((payload: { state: IntegrateInProgress; details: IntegrateConflictDetails }) => {
|
||||||
const visibleText = `Resolve cherry-pick conflicts, stage the resolved files, and continue the cherry-pick. Keep intent of commit ${payload.state.currentCommit} onto branch ${payload.state.targetBranch}.`;
|
const visibleText = `Resolve cherry-pick conflicts, stage the resolved files, and continue the cherry-pick. Keep intent of commit ${payload.state.currentCommit} onto branch ${payload.state.targetBranch}.`;
|
||||||
@@ -217,8 +218,8 @@ Important:
|
|||||||
return { visibleText, instructionsText, payloadText };
|
return { visibleText, instructionsText, payloadText };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setPendingInputText = useSessionStore((s) => s.setPendingInputText);
|
const setPendingInputText = useInputStore((s) => s.setPendingInputText);
|
||||||
const setPendingSyntheticParts = useSessionStore((s) => s.setPendingSyntheticParts);
|
const setPendingSyntheticParts = useInputStore((s) => s.setPendingSyntheticParts);
|
||||||
|
|
||||||
const handleResolveWithAi = React.useCallback((
|
const handleResolveWithAi = React.useCallback((
|
||||||
payload: { state: IntegrateInProgress; details: IntegrateConflictDetails },
|
payload: { state: IntegrateInProgress; details: IntegrateConflictDetails },
|
||||||
|
|||||||
@@ -51,8 +51,8 @@ import { useDeviceInfo } from '@/lib/device';
|
|||||||
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||||
import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
|
import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { useMessageStore } from '@/stores/messageStore';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSelectionStore } from '@/sync/selection-store';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||||
import { getGitHubPrStatusKey, useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore';
|
import { getGitHubPrStatusKey, useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore';
|
||||||
@@ -284,7 +284,7 @@ export const PullRequestSection: React.FC<{
|
|||||||
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
|
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
|
||||||
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
|
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
|
||||||
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
|
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
|
||||||
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||||
const { isMobile, hasTouchInput } = useDeviceInfo();
|
const { isMobile, hasTouchInput } = useDeviceInfo();
|
||||||
|
|
||||||
const openGitHubSettings = React.useCallback(() => {
|
const openGitHubSettings = React.useCallback(() => {
|
||||||
@@ -633,7 +633,7 @@ export const PullRequestSection: React.FC<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { currentProviderId, currentModelId, currentAgentName, currentVariant } = useConfigStore.getState();
|
const { currentProviderId, currentModelId, currentAgentName, currentVariant } = useConfigStore.getState();
|
||||||
const lastUsedProvider = useMessageStore.getState().lastUsedProvider;
|
const lastUsedProvider = useSelectionStore.getState().lastUsedProvider;
|
||||||
const providerID = currentProviderId || lastUsedProvider?.providerID;
|
const providerID = currentProviderId || lastUsedProvider?.providerID;
|
||||||
const modelID = currentModelId || lastUsedProvider?.modelID;
|
const modelID = currentModelId || lastUsedProvider?.modelID;
|
||||||
if (!providerID || !modelID) {
|
if (!providerID || !modelID) {
|
||||||
@@ -656,14 +656,13 @@ export const PullRequestSection: React.FC<{
|
|||||||
instructionsText: string,
|
instructionsText: string,
|
||||||
payloadText: string,
|
payloadText: string,
|
||||||
) => {
|
) => {
|
||||||
void useMessageStore.getState().sendMessage(
|
void useSessionUIStore.getState().sendMessage(
|
||||||
visibleText,
|
visibleText,
|
||||||
target.providerID,
|
target.providerID,
|
||||||
target.modelID,
|
target.modelID,
|
||||||
target.currentAgentName ?? undefined,
|
target.currentAgentName ?? undefined,
|
||||||
target.sessionId,
|
|
||||||
undefined,
|
undefined,
|
||||||
null,
|
undefined,
|
||||||
[
|
[
|
||||||
{ text: instructionsText, synthetic: true },
|
{ text: instructionsText, synthetic: true },
|
||||||
{ text: payloadText, synthetic: true },
|
{ text: payloadText, synthetic: true },
|
||||||
|
|||||||
@@ -1,7 +1,84 @@
|
|||||||
import React, { type JSX, type ReactNode } from 'react';
|
import React, { type JSX, type ReactNode } from 'react';
|
||||||
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
|
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
|
||||||
import type { RuntimeAPIs } from '@/lib/api/types';
|
import type { FilesAPI, RuntimeAPIs } from '@/lib/api/types';
|
||||||
|
import {
|
||||||
|
approxStringBytes,
|
||||||
|
evictContentLru,
|
||||||
|
setContentBytes,
|
||||||
|
touchContent as touchContentLru,
|
||||||
|
removeContentBytes,
|
||||||
|
} from '@/sync/content-cache';
|
||||||
|
|
||||||
|
/** Wrap a FilesAPI with an in-memory LRU content cache. */
|
||||||
|
function withContentCache(files: FilesAPI): FilesAPI {
|
||||||
|
const cache = new Map<string, { content: string; path: string }>();
|
||||||
|
|
||||||
|
const cachedReadFile: FilesAPI['readFile'] = files.readFile
|
||||||
|
? async (path: string) => {
|
||||||
|
const hit = cache.get(path);
|
||||||
|
if (hit) {
|
||||||
|
touchContentLru(path);
|
||||||
|
return hit;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await files.readFile!(path);
|
||||||
|
const bytes = approxStringBytes(result.content);
|
||||||
|
cache.set(path, result);
|
||||||
|
setContentBytes(path, bytes);
|
||||||
|
|
||||||
|
// Evict if over limits
|
||||||
|
const keep = new Set<string>();
|
||||||
|
evictContentLru(keep, (evictPath) => {
|
||||||
|
cache.delete(evictPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// Invalidate cache on writes, deletes, renames
|
||||||
|
const cachedWriteFile: FilesAPI['writeFile'] = files.writeFile
|
||||||
|
? async (path, content) => {
|
||||||
|
cache.delete(path);
|
||||||
|
removeContentBytes(path);
|
||||||
|
return files.writeFile!(path, content);
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const cachedDelete: FilesAPI['delete'] = files.delete
|
||||||
|
? async (path) => {
|
||||||
|
cache.delete(path);
|
||||||
|
removeContentBytes(path);
|
||||||
|
return files.delete!(path);
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const cachedRename: FilesAPI['rename'] = files.rename
|
||||||
|
? async (oldPath, newPath) => {
|
||||||
|
cache.delete(oldPath);
|
||||||
|
removeContentBytes(oldPath);
|
||||||
|
cache.delete(newPath);
|
||||||
|
removeContentBytes(newPath);
|
||||||
|
return files.rename!(oldPath, newPath);
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...files,
|
||||||
|
readFile: cachedReadFile,
|
||||||
|
writeFile: cachedWriteFile,
|
||||||
|
delete: cachedDelete,
|
||||||
|
rename: cachedRename,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function RuntimeAPIProvider({ apis, children }: { apis: RuntimeAPIs; children: ReactNode }): JSX.Element {
|
export function RuntimeAPIProvider({ apis, children }: { apis: RuntimeAPIs; children: ReactNode }): JSX.Element {
|
||||||
return <RuntimeAPIContext.Provider value={apis}>{children}</RuntimeAPIContext.Provider>;
|
const cachedApis = React.useMemo<RuntimeAPIs>(
|
||||||
|
() => ({
|
||||||
|
...apis,
|
||||||
|
files: withContentCache(apis.files),
|
||||||
|
}),
|
||||||
|
[apis],
|
||||||
|
);
|
||||||
|
return <RuntimeAPIContext.Provider value={cachedApis}>{children}</RuntimeAPIContext.Provider>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { AssistantMessage, Message, Part, ReasoningPart, TextPart, ToolPart } from '@opencode-ai/sdk/v2';
|
import type { AssistantMessage, Message, Part, ReasoningPart, TextPart, ToolPart } from '@opencode-ai/sdk/v2';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
|
||||||
|
|
||||||
import type { MessageStreamPhase } from '@/stores/types/sessionTypes';
|
import type { MessageStreamPhase } from '@/stores/types/sessionTypes';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
|
import { useDirectorySync, useSessionPermissions, useSessionStatus } from '@/sync/sync-context';
|
||||||
import { isFullySyntheticMessage } from '@/lib/messages/synthetic';
|
import { isFullySyntheticMessage } from '@/lib/messages/synthetic';
|
||||||
import { useCurrentSessionActivity } from './useSessionActivity';
|
import { useCurrentSessionActivity } from './useSessionActivity';
|
||||||
|
|
||||||
@@ -52,6 +52,11 @@ interface AssistantSessionMessageRecord {
|
|||||||
parts: Part[];
|
parts: Part[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SessionMessageRecord = {
|
||||||
|
info: Message;
|
||||||
|
parts: Part[];
|
||||||
|
};
|
||||||
|
|
||||||
const DEFAULT_WORKING: WorkingSummary = {
|
const DEFAULT_WORKING: WorkingSummary = {
|
||||||
activity: 'idle',
|
activity: 'idle',
|
||||||
hasWorkingContext: false,
|
hasWorkingContext: false,
|
||||||
@@ -74,6 +79,9 @@ const DEFAULT_WORKING: WorkingSummary = {
|
|||||||
retryInfo: null,
|
retryInfo: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const EMPTY_MESSAGES: Message[] = [];
|
||||||
|
const EMPTY_PARTS: Part[] = [];
|
||||||
|
const EMPTY_SESSION_MESSAGES: SessionMessageRecord[] = [];
|
||||||
const isAssistantMessage = (message: Message): message is AssistantMessageWithState => message.role === 'assistant';
|
const isAssistantMessage = (message: Message): message is AssistantMessageWithState => message.role === 'assistant';
|
||||||
|
|
||||||
const isReasoningPart = (part: Part): part is ReasoningPart => part.type === 'reasoning';
|
const isReasoningPart = (part: Part): part is ReasoningPart => part.type === 'reasoning';
|
||||||
@@ -114,36 +122,68 @@ const getToolDisplayName = (part: ToolPart): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function useAssistantStatus(): AssistantStatusSnapshot {
|
export function useAssistantStatus(): AssistantStatusSnapshot {
|
||||||
const { currentSessionId, messages, permissions, sessionAbortFlags } = useSessionStore(
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||||
useShallow((state) => ({
|
|
||||||
currentSessionId: state.currentSessionId,
|
const rawSessionMessages = useDirectorySync(
|
||||||
messages: state.messages,
|
React.useCallback((state) => {
|
||||||
permissions: state.permissions,
|
if (!currentSessionId) {
|
||||||
sessionAbortFlags: state.sessionAbortFlags,
|
return EMPTY_MESSAGES;
|
||||||
}))
|
}
|
||||||
|
return state.message[currentSessionId] ?? EMPTY_MESSAGES;
|
||||||
|
}, [currentSessionId])
|
||||||
|
);
|
||||||
|
|
||||||
|
// Only subscribe to parts for the last assistant message — avoids re-render
|
||||||
|
// on every part delta for earlier messages.
|
||||||
|
const lastAssistantId = React.useMemo(() => {
|
||||||
|
for (let i = rawSessionMessages.length - 1; i >= 0; i--) {
|
||||||
|
if (rawSessionMessages[i].role === 'assistant') return rawSessionMessages[i].id;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [rawSessionMessages]);
|
||||||
|
|
||||||
|
const lastAssistantParts = useDirectorySync(
|
||||||
|
React.useCallback((state) => {
|
||||||
|
if (!lastAssistantId) return EMPTY_PARTS;
|
||||||
|
return state.part[lastAssistantId] ?? EMPTY_PARTS;
|
||||||
|
}, [lastAssistantId])
|
||||||
|
);
|
||||||
|
|
||||||
|
const sessionMessages = React.useMemo<SessionMessageRecord[]>(
|
||||||
|
() => {
|
||||||
|
if (rawSessionMessages.length === 0) {
|
||||||
|
return EMPTY_SESSION_MESSAGES;
|
||||||
|
}
|
||||||
|
return rawSessionMessages.map((msg) => ({
|
||||||
|
info: msg,
|
||||||
|
parts: msg.id === lastAssistantId ? lastAssistantParts : EMPTY_PARTS,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
[lastAssistantParts, rawSessionMessages, lastAssistantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const sessionPermissionRequests = useSessionPermissions(currentSessionId ?? '');
|
||||||
|
|
||||||
|
const sessionAbortRecord = useSessionUIStore(
|
||||||
|
React.useCallback((state) => {
|
||||||
|
if (!currentSessionId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return state.sessionAbortFlags?.get(currentSessionId) ?? null;
|
||||||
|
}, [currentSessionId])
|
||||||
);
|
);
|
||||||
|
|
||||||
const { phase: activityPhase, isWorking: isPhaseWorking } = useCurrentSessionActivity();
|
const { phase: activityPhase, isWorking: isPhaseWorking } = useCurrentSessionActivity();
|
||||||
|
|
||||||
const sessionRetryAttempt = useSessionStore((state) => {
|
const currentSessionStatus = useSessionStatus(currentSessionId ?? '');
|
||||||
if (!currentSessionId || !state.sessionStatus) return undefined;
|
|
||||||
const s = state.sessionStatus.get(currentSessionId);
|
|
||||||
return s?.type === 'retry' ? s.attempt : undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
const sessionRetryNext = useSessionStore((state) => {
|
const sessionRetryAttempt = currentSessionStatus?.type === 'retry'
|
||||||
if (!currentSessionId || !state.sessionStatus) return undefined;
|
? (currentSessionStatus as { type: 'retry'; attempt?: number }).attempt
|
||||||
const s = state.sessionStatus.get(currentSessionId);
|
: undefined;
|
||||||
return s?.type === 'retry' ? s.next : undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
const sessionMessages = React.useMemo<Array<{ info: Message; parts: Part[] }>>(() => {
|
const sessionRetryNext = currentSessionStatus?.type === 'retry'
|
||||||
if (!currentSessionId) {
|
? (currentSessionStatus as { type: 'retry'; next?: number }).next
|
||||||
return [];
|
: undefined;
|
||||||
}
|
|
||||||
const records = messages.get(currentSessionId) ?? [];
|
|
||||||
return records as Array<{ info: Message; parts: Part[] }>;
|
|
||||||
}, [currentSessionId, messages]);
|
|
||||||
|
|
||||||
type ParsedStatusResult = {
|
type ParsedStatusResult = {
|
||||||
activePartType: 'text' | 'tool' | 'reasoning' | 'editing' | undefined;
|
activePartType: 'text' | 'tool' | 'reasoning' | 'editing' | undefined;
|
||||||
@@ -287,11 +327,9 @@ export function useAssistantStatus(): AssistantStatusSnapshot {
|
|||||||
}, [sessionMessages]);
|
}, [sessionMessages]);
|
||||||
|
|
||||||
const abortState = React.useMemo(() => {
|
const abortState = React.useMemo(() => {
|
||||||
const sessionId = currentSessionId;
|
const hasActiveAbort = Boolean(sessionAbortRecord && !sessionAbortRecord.acknowledged);
|
||||||
const abortRecord = sessionId ? sessionAbortFlags?.get(sessionId) ?? null : null;
|
|
||||||
const hasActiveAbort = Boolean(abortRecord && !abortRecord.acknowledged);
|
|
||||||
return { wasAborted: hasActiveAbort, abortActive: hasActiveAbort };
|
return { wasAborted: hasActiveAbort, abortActive: hasActiveAbort };
|
||||||
}, [currentSessionId, sessionAbortFlags]);
|
}, [sessionAbortRecord]);
|
||||||
|
|
||||||
const baseWorking = React.useMemo<WorkingSummary>(() => {
|
const baseWorking = React.useMemo<WorkingSummary>(() => {
|
||||||
|
|
||||||
@@ -388,9 +426,7 @@ export function useAssistantStatus(): AssistantStatusSnapshot {
|
|||||||
return baseWorking;
|
return baseWorking;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionId = currentSessionId;
|
const hasPendingPermission = sessionPermissionRequests.length > 0;
|
||||||
const permissionList = sessionId ? permissions?.get(sessionId) ?? [] : [];
|
|
||||||
const hasPendingPermission = permissionList.length > 0;
|
|
||||||
|
|
||||||
if (!hasPendingPermission) {
|
if (!hasPendingPermission) {
|
||||||
return baseWorking;
|
return baseWorking;
|
||||||
@@ -403,7 +439,7 @@ export function useAssistantStatus(): AssistantStatusSnapshot {
|
|||||||
canAbort: false,
|
canAbort: false,
|
||||||
retryInfo: null,
|
retryInfo: null,
|
||||||
};
|
};
|
||||||
}, [currentSessionId, permissions, baseWorking]);
|
}, [baseWorking, sessionPermissionRequests]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
forming,
|
forming,
|
||||||
|
|||||||
@@ -27,7 +27,9 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { browserVoiceService } from '@/lib/voice/browserVoiceService';
|
import { browserVoiceService } from '@/lib/voice/browserVoiceService';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
|
import { useInputStore } from '@/sync/input-store';
|
||||||
|
import { getSyncMessages, getSyncParts } from '@/sync/sync-refs';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { useServerTTS } from './useServerTTS';
|
import { useServerTTS } from './useServerTTS';
|
||||||
import { useSayTTS } from './useSayTTS';
|
import { useSayTTS } from './useSayTTS';
|
||||||
@@ -120,18 +122,16 @@ export function useBrowserVoice(): UseBrowserVoiceReturn {
|
|||||||
const isActiveRef = useRef(false);
|
const isActiveRef = useRef(false);
|
||||||
const processingMessageRef = useRef(false);
|
const processingMessageRef = useRef(false);
|
||||||
const lastTranscriptRef = useRef('');
|
const lastTranscriptRef = useRef('');
|
||||||
const messagesRef = useRef<Map<string, { info: { role: string }; parts: Array<{ type: string; text?: string }> }>>(new Map());
|
|
||||||
const pendingResumeOnVisibleRef = useRef(false);
|
const pendingResumeOnVisibleRef = useRef(false);
|
||||||
const pendingFinalTranscriptRef = useRef('');
|
const pendingFinalTranscriptRef = useRef('');
|
||||||
const finalTranscriptTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const finalTranscriptTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const deviceChangeRestartTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const deviceChangeRestartTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
// Store access
|
// Store access
|
||||||
const currentSessionId = useSessionStore((s) => s.currentSessionId);
|
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
|
||||||
const sendMessage = useSessionStore((s) => s.sendMessage);
|
const sendMessage = useSessionUIStore((s) => s.sendMessage);
|
||||||
const setPendingInputText = useSessionStore((s) => s.setPendingInputText);
|
const setPendingInputText = useInputStore((s) => s.setPendingInputText);
|
||||||
const messages = useSessionStore((s) => s.messages);
|
const createSession = useSessionUIStore((s) => s.createSession);
|
||||||
const createSession = useSessionStore((s) => s.createSession);
|
|
||||||
const { currentProviderId, currentModelId, currentAgentName, voiceModeEnabled, voiceProvider, speechRate, speechPitch, speechVolume, sayVoice, browserVoice, openaiVoice, summarizeVoiceConversation, summarizeCharacterThreshold } = useConfigStore();
|
const { currentProviderId, currentModelId, currentAgentName, voiceModeEnabled, voiceProvider, speechRate, speechPitch, speechVolume, sayVoice, browserVoice, openaiVoice, summarizeVoiceConversation, summarizeCharacterThreshold } = useConfigStore();
|
||||||
|
|
||||||
const shouldCheckOpenAIAvailability = voiceModeEnabled && voiceProvider === 'openai';
|
const shouldCheckOpenAIAvailability = voiceModeEnabled && voiceProvider === 'openai';
|
||||||
@@ -147,16 +147,6 @@ export function useBrowserVoice(): UseBrowserVoiceReturn {
|
|||||||
enabled: shouldCheckSayAvailability,
|
enabled: shouldCheckSayAvailability,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update messages ref when messages change
|
|
||||||
useEffect(() => {
|
|
||||||
if (currentSessionId) {
|
|
||||||
const sessionMessages = messages.get(currentSessionId);
|
|
||||||
if (sessionMessages) {
|
|
||||||
messagesRef.current = new Map(sessionMessages.map(m => [m.info.id, m]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [messages, currentSessionId]);
|
|
||||||
|
|
||||||
// Stop voice when session changes to prevent microphone from staying active
|
// Stop voice when session changes to prevent microphone from staying active
|
||||||
// This ensures voice mode doesn't carry over between sessions
|
// This ensures voice mode doesn't carry over between sessions
|
||||||
const prevSessionIdRef = useRef<string | null>(null);
|
const prevSessionIdRef = useRef<string | null>(null);
|
||||||
@@ -374,22 +364,23 @@ export function useBrowserVoice(): UseBrowserVoiceReturn {
|
|||||||
// Wait for AI response and speak it
|
// Wait for AI response and speak it
|
||||||
// We'll poll for new assistant messages
|
// We'll poll for new assistant messages
|
||||||
const checkForResponse = async () => {
|
const checkForResponse = async () => {
|
||||||
if (!isActiveRef.current) return;
|
if (!isActiveRef.current || !sessionId) return;
|
||||||
|
|
||||||
const sessionMessages = messagesRef.current;
|
const rawMessages = getSyncMessages(sessionId);
|
||||||
const assistantMessages = Array.from(sessionMessages.values())
|
const assistantMessages = rawMessages
|
||||||
.filter(m => m.info.role === 'assistant')
|
.filter(m => m.role === 'assistant')
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const aTime = (a.info as { time?: { created?: number } }).time?.created ?? 0;
|
const aTime = (a as { time?: { created?: number } }).time?.created ?? 0;
|
||||||
const bTime = (b.info as { time?: { created?: number } }).time?.created ?? 0;
|
const bTime = (b as { time?: { created?: number } }).time?.created ?? 0;
|
||||||
return bTime - aTime;
|
return bTime - aTime;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (assistantMessages.length > 0) {
|
if (assistantMessages.length > 0) {
|
||||||
const latestMessage = assistantMessages[0];
|
const latestMessage = assistantMessages[0];
|
||||||
const textParts = latestMessage.parts
|
const parts = getSyncParts(latestMessage.id);
|
||||||
.filter(p => p.type === 'text')
|
const textParts = parts
|
||||||
.map(p => p.text)
|
.filter((p: { type: string; text?: string }) => p.type === 'text')
|
||||||
|
.map((p: { type: string; text?: string }) => p.text ?? '')
|
||||||
.join(' ');
|
.join(' ');
|
||||||
|
|
||||||
if (textParts.trim()) {
|
if (textParts.trim()) {
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ import {
|
|||||||
|
|
||||||
import { useScrollEngine } from './useScrollEngine';
|
import { useScrollEngine } from './useScrollEngine';
|
||||||
|
|
||||||
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect;
|
|
||||||
|
|
||||||
export type ContentChangeReason = 'text' | 'structural' | 'permission';
|
export type ContentChangeReason = 'text' | 'structural' | 'permission';
|
||||||
|
|
||||||
interface ChatMessageRecord {
|
interface ChatMessageRecord {
|
||||||
@@ -41,7 +39,6 @@ interface UseChatScrollManagerOptions {
|
|||||||
isSyncing: boolean;
|
isSyncing: boolean;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
chatRenderMode?: 'sorted' | 'live';
|
chatRenderMode?: 'sorted' | 'live';
|
||||||
messageStreamStates: Map<string, unknown>;
|
|
||||||
onActiveTurnChange?: (turnId: string | null) => void;
|
onActiveTurnChange?: (turnId: string | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,6 +107,7 @@ export const useChatScrollManager = ({
|
|||||||
const lastScrollTopRef = React.useRef<number>(0);
|
const lastScrollTopRef = React.useRef<number>(0);
|
||||||
const touchLastYRef = React.useRef<number | null>(null);
|
const touchLastYRef = React.useRef<number | null>(null);
|
||||||
const pinnedSyncRafRef = React.useRef<number | null>(null);
|
const pinnedSyncRafRef = React.useRef<number | null>(null);
|
||||||
|
const preferInstantPinRef = React.useRef(false);
|
||||||
const viewportAnchorTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
const viewportAnchorTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const pendingViewportAnchorRef = React.useRef<{ sessionId: string; anchor: number } | null>(null);
|
const pendingViewportAnchorRef = React.useRef<{ sessionId: string; anchor: number } | null>(null);
|
||||||
const lastViewportAnchorRef = React.useRef<{ sessionId: string; anchor: number } | null>(null);
|
const lastViewportAnchorRef = React.useRef<{ sessionId: string; anchor: number } | null>(null);
|
||||||
@@ -178,10 +176,20 @@ export const useChatScrollManager = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const distanceFromBottom = getDistanceFromBottom();
|
const distanceFromBottom = getDistanceFromBottom();
|
||||||
|
if (distanceFromBottom <= getAutoFollowThreshold()) {
|
||||||
|
preferInstantPinRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preferInstantPinRef.current) {
|
||||||
|
scrollToBottomInternal({ instant: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (distanceFromBottom > getAutoFollowThreshold()) {
|
if (distanceFromBottom > getAutoFollowThreshold()) {
|
||||||
scrollPinnedToBottom();
|
scrollPinnedToBottom();
|
||||||
}
|
}
|
||||||
}, [getAutoFollowThreshold, getDistanceFromBottom, scrollPinnedToBottom, updateScrollButtonVisibility]);
|
}, [getAutoFollowThreshold, getDistanceFromBottom, scrollPinnedToBottom, scrollToBottomInternal, updateScrollButtonVisibility]);
|
||||||
|
|
||||||
const schedulePinnedStateAndIndicators = React.useCallback(() => {
|
const schedulePinnedStateAndIndicators = React.useCallback(() => {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
@@ -264,6 +272,7 @@ export const useChatScrollManager = ({
|
|||||||
|
|
||||||
const releasePinnedScroll = React.useCallback(() => {
|
const releasePinnedScroll = React.useCallback(() => {
|
||||||
scrollEngine.cancelFollow();
|
scrollEngine.cancelFollow();
|
||||||
|
preferInstantPinRef.current = false;
|
||||||
updatePinnedState(false);
|
updatePinnedState(false);
|
||||||
schedulePinnedStateAndIndicators();
|
schedulePinnedStateAndIndicators();
|
||||||
}, [schedulePinnedStateAndIndicators, scrollEngine, updatePinnedState]);
|
}, [schedulePinnedStateAndIndicators, scrollEngine, updatePinnedState]);
|
||||||
@@ -296,6 +305,7 @@ export const useChatScrollManager = ({
|
|||||||
if (!isPinnedRef.current) {
|
if (!isPinnedRef.current) {
|
||||||
const distanceFromBottom = getDistanceFromBottom();
|
const distanceFromBottom = getDistanceFromBottom();
|
||||||
if (distanceFromBottom <= getPinThreshold()) {
|
if (distanceFromBottom <= getPinThreshold()) {
|
||||||
|
preferInstantPinRef.current = false;
|
||||||
updatePinnedState(true);
|
updatePinnedState(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -412,7 +422,7 @@ export const useChatScrollManager = ({
|
|||||||
}, [handleScrollEvent, handleWheelIntent, scrollEngine, updatePinnedState]);
|
}, [handleScrollEvent, handleWheelIntent, scrollEngine, updatePinnedState]);
|
||||||
|
|
||||||
// Session switch - always start pinned at bottom
|
// Session switch - always start pinned at bottom
|
||||||
useIsomorphicLayoutEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!currentSessionId || currentSessionId === lastSessionIdRef.current) {
|
if (!currentSessionId || currentSessionId === lastSessionIdRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -423,6 +433,7 @@ export const useChatScrollManager = ({
|
|||||||
pendingViewportAnchorRef.current = null;
|
pendingViewportAnchorRef.current = null;
|
||||||
|
|
||||||
// Always start pinned at bottom on session switch
|
// Always start pinned at bottom on session switch
|
||||||
|
preferInstantPinRef.current = true;
|
||||||
updatePinnedState(true);
|
updatePinnedState(true);
|
||||||
setShowScrollButton(false);
|
setShowScrollButton(false);
|
||||||
|
|
||||||
@@ -534,12 +545,17 @@ export const useChatScrollManager = ({
|
|||||||
|
|
||||||
const container = scrollRef.current;
|
const container = scrollRef.current;
|
||||||
if (!container) {
|
if (!container) {
|
||||||
onActiveTurnChange(null);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let lastActiveTurnId: string | null = null;
|
||||||
|
|
||||||
const spy = createScrollSpy({
|
const spy = createScrollSpy({
|
||||||
onActive: (turnId) => {
|
onActive: (turnId) => {
|
||||||
|
if (turnId === lastActiveTurnId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastActiveTurnId = turnId;
|
||||||
onActiveTurnChange(turnId);
|
onActiveTurnChange(turnId);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -626,7 +642,6 @@ export const useChatScrollManager = ({
|
|||||||
container.removeEventListener('scroll', handleScroll);
|
container.removeEventListener('scroll', handleScroll);
|
||||||
mutationObserver.disconnect();
|
mutationObserver.disconnect();
|
||||||
spy.destroy();
|
spy.destroy();
|
||||||
onActiveTurnChange(null);
|
|
||||||
};
|
};
|
||||||
}, [currentSessionId, onActiveTurnChange, scrollRef, sessionMessages.length]);
|
}, [currentSessionId, onActiveTurnChange, scrollRef, sessionMessages.length]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
|
import { useSessions } from '@/sync/sync-context';
|
||||||
import type { Session } from '@opencode-ai/sdk/v2';
|
import type { Session } from '@opencode-ai/sdk/v2';
|
||||||
|
|
||||||
export const useChatSearchDirectory = (): string | undefined => {
|
export const useChatSearchDirectory = (): string | undefined => {
|
||||||
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||||
const sessions = useSessionStore((state) => state.sessions);
|
const sessions = useSessions();
|
||||||
const worktreeMap = useSessionStore((state) => state.worktreeMetadata);
|
const worktreeMap = useSessionUIStore((state) => state.worktreeMetadata);
|
||||||
const newSessionDraft = useSessionStore((state) => state.newSessionDraft);
|
const newSessionDraft = useSessionUIStore((state) => state.newSessionDraft);
|
||||||
|
|
||||||
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
||||||
const projects = useProjectsStore((state) => state.projects);
|
const projects = useProjectsStore((state) => state.projects);
|
||||||
|
|||||||
@@ -1,27 +1,26 @@
|
|||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
|
import { useSessions } from '@/sync/sync-context';
|
||||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
import type { Session } from '@opencode-ai/sdk/v2';
|
import type { Session } from '@opencode-ai/sdk/v2';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook that resolves the effective working directory for tabs (Git, Diff, Files, Terminal).
|
* Hook that resolves the effective working directory for tabs (Git, Diff, Files, Terminal).
|
||||||
*
|
*
|
||||||
* Priority order:
|
* Priority order:
|
||||||
* 1. Worktree metadata path (for worktree sessions)
|
* 1. Worktree metadata path (for worktree sessions)
|
||||||
* 2. Session directory (for active sessions)
|
* 2. Session directory (for active sessions)
|
||||||
* 3. Draft session directoryOverride (when creating a new session)
|
* 3. Draft session directoryOverride (when creating a new session)
|
||||||
* 4. Fallback directory from DirectoryStore
|
* 4. Fallback directory from DirectoryStore
|
||||||
*
|
*
|
||||||
* This ensures that tabs show content from the correct project directory
|
* This ensures that tabs show content from the correct project directory
|
||||||
* even when a draft session is being created.
|
* even when a draft session is being created.
|
||||||
*/
|
*/
|
||||||
export const useEffectiveDirectory = (): string | undefined => {
|
export const useEffectiveDirectory = (): string | undefined => {
|
||||||
const {
|
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
|
||||||
currentSessionId,
|
const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
|
||||||
sessions,
|
const sessions = useSessions();
|
||||||
worktreeMetadata: worktreeMap,
|
const worktreeMap = useSessionUIStore((s) => s.worktreeMetadata);
|
||||||
newSessionDraft,
|
const fallbackDirectory = useDirectoryStore((s) => s.currentDirectory);
|
||||||
} = useSessionStore();
|
|
||||||
const { currentDirectory: fallbackDirectory } = useDirectoryStore();
|
|
||||||
|
|
||||||
// If we have an active session, use its directory
|
// If we have an active session, use its directory
|
||||||
if (currentSessionId) {
|
if (currentSessionId) {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,13 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { Session } from '@opencode-ai/sdk/v2';
|
import type { Session } from '@opencode-ai/sdk/v2';
|
||||||
import type { RuntimeAPIs } from '@/lib/api/types';
|
import type { RuntimeAPIs } from '@/lib/api/types';
|
||||||
|
import { mapWithConcurrency } from '@/lib/concurrency';
|
||||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||||
import { useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore';
|
import { useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore';
|
||||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
|
import { useSessions } from '@/sync/sync-context';
|
||||||
|
|
||||||
const MAX_BACKGROUND_PR_DIRECTORIES = 20;
|
const MAX_BACKGROUND_PR_DIRECTORIES = 20;
|
||||||
const ACTIVE_DIRECTORY_REFRESH_TTL_MS = 15_000;
|
const ACTIVE_DIRECTORY_REFRESH_TTL_MS = 15_000;
|
||||||
@@ -94,34 +96,6 @@ const prioritizeDirectoriesForFetch = (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const mapWithConcurrency = async <T, R>(
|
|
||||||
values: T[],
|
|
||||||
concurrency: number,
|
|
||||||
mapper: (value: T) => Promise<R>,
|
|
||||||
): Promise<R[]> => {
|
|
||||||
if (values.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const safeConcurrency = Math.max(1, Math.min(concurrency, values.length));
|
|
||||||
const results = new Array<R>(values.length);
|
|
||||||
let cursor = 0;
|
|
||||||
|
|
||||||
const worker = async () => {
|
|
||||||
while (true) {
|
|
||||||
const nextIndex = cursor;
|
|
||||||
cursor += 1;
|
|
||||||
if (nextIndex >= values.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
results[nextIndex] = await mapper(values[nextIndex]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
await Promise.all(Array.from({ length: safeConcurrency }, () => worker()));
|
|
||||||
return results;
|
|
||||||
};
|
|
||||||
|
|
||||||
const toPrTargets = (cache: Map<string, BranchCacheEntry>, directories: string[]): PrTarget[] => {
|
const toPrTargets = (cache: Map<string, BranchCacheEntry>, directories: string[]): PrTarget[] => {
|
||||||
const result: PrTarget[] = [];
|
const result: PrTarget[] = [];
|
||||||
directories.forEach((directory) => {
|
directories.forEach((directory) => {
|
||||||
@@ -144,10 +118,9 @@ export const useGitHubPrBackgroundTracking = (
|
|||||||
): void => {
|
): void => {
|
||||||
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||||
const projects = useProjectsStore((state) => state.projects);
|
const projects = useProjectsStore((state) => state.projects);
|
||||||
const sessions = useSessionStore((state) => state.sessions);
|
const sessions = useSessions();
|
||||||
const archivedSessions = useSessionStore((state) => state.archivedSessions);
|
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
|
||||||
const availableWorktreesByProject = useSessionStore((state) => state.availableWorktreesByProject);
|
const worktreeMetadata = useSessionUIStore((state) => state.worktreeMetadata);
|
||||||
const worktreeMetadata = useSessionStore((state) => state.worktreeMetadata);
|
|
||||||
|
|
||||||
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
|
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
|
||||||
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
|
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
|
||||||
@@ -229,7 +202,7 @@ export const useGitHubPrBackgroundTracking = (
|
|||||||
add(metadata.path);
|
add(metadata.path);
|
||||||
});
|
});
|
||||||
|
|
||||||
[...sessions, ...archivedSessions]
|
[...sessions]
|
||||||
.sort((a, b) => (b.time?.updated ?? 0) - (a.time?.updated ?? 0))
|
.sort((a, b) => (b.time?.updated ?? 0) - (a.time?.updated ?? 0))
|
||||||
.forEach((rawSession) => {
|
.forEach((rawSession) => {
|
||||||
const session = rawSession as SessionLike;
|
const session = rawSession as SessionLike;
|
||||||
@@ -238,7 +211,7 @@ export const useGitHubPrBackgroundTracking = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
return Array.from(ordered.values()).slice(0, MAX_BACKGROUND_PR_DIRECTORIES);
|
return Array.from(ordered.values()).slice(0, MAX_BACKGROUND_PR_DIRECTORIES);
|
||||||
}, [archivedSessions, availableWorktreesByProject, currentDirectory, projects, sessions, worktreeMetadata]);
|
}, [availableWorktreesByProject, currentDirectory, projects, sessions, worktreeMetadata]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -275,7 +248,7 @@ export const useGitHubPrBackgroundTracking = (
|
|||||||
STATUS_FETCH_CONCURRENCY,
|
STATUS_FETCH_CONCURRENCY,
|
||||||
async (directory) => {
|
async (directory) => {
|
||||||
try {
|
try {
|
||||||
const status = await git.getGitStatus(directory);
|
const status = await git.getGitStatus(directory, { mode: 'light' });
|
||||||
const branch = typeof status.current === 'string' ? status.current.trim() : '';
|
const branch = typeof status.current === 'string' ? status.current.trim() : '';
|
||||||
return {
|
return {
|
||||||
directory,
|
directory,
|
||||||
@@ -383,7 +356,11 @@ export const useGitHubPrBackgroundTracking = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
void runRefresh({ forceCurrent: true, maxFetchCount: MAX_STATUS_FETCH_ON_RESUME });
|
// Delay initial PR tracking to avoid startup CPU burst
|
||||||
|
const startupDelayId = window.setTimeout(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
void runRefresh({ forceCurrent: true, maxFetchCount: MAX_STATUS_FETCH_ON_RESUME });
|
||||||
|
}, 5_000);
|
||||||
|
|
||||||
const intervalId = window.setInterval(() => {
|
const intervalId = window.setInterval(() => {
|
||||||
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') {
|
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') {
|
||||||
@@ -442,6 +419,7 @@ export const useGitHubPrBackgroundTracking = (
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
window.clearTimeout(startupDelayId);
|
||||||
window.clearInterval(intervalId);
|
window.clearInterval(intervalId);
|
||||||
window.removeEventListener('focus', refreshOnResume);
|
window.removeEventListener('focus', refreshOnResume);
|
||||||
document.removeEventListener('visibilitychange', refreshOnResume);
|
document.removeEventListener('visibilitychange', refreshOnResume);
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import React from 'react';
|
|||||||
import { useGitStore } from '@/stores/useGitStore';
|
import { useGitStore } from '@/stores/useGitStore';
|
||||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
|
import { useSessions, useSessionStatus } from '@/sync/sync-context';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Background git polling hook - monitors git status regardless of which tab is open.
|
* Background git polling hook - monitors git status regardless of which tab is open.
|
||||||
@@ -20,8 +21,17 @@ export function useGitPolling() {
|
|||||||
|
|
||||||
const { git } = useRuntimeAPIs();
|
const { git } = useRuntimeAPIs();
|
||||||
const fallbackDirectory = useDirectoryStore((state) => state.currentDirectory);
|
const fallbackDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||||
const { currentSessionId, sessions, worktreeMetadata: worktreeMap, sessionStatus } = useSessionStore();
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||||
const { setActiveDirectory, startPolling, setPollingMode, stopPolling, fetchAll, fetchStatus, clearDiffCache } = useGitStore();
|
const sessions = useSessions();
|
||||||
|
const worktreeMap = useSessionUIStore((state) => state.worktreeMetadata);
|
||||||
|
const currentStatus = useSessionStatus(currentSessionId ?? '');
|
||||||
|
const setActiveDirectory = useGitStore((state) => state.setActiveDirectory);
|
||||||
|
const startPolling = useGitStore((state) => state.startPolling);
|
||||||
|
const setPollingMode = useGitStore((state) => state.setPollingMode);
|
||||||
|
const stopPolling = useGitStore((state) => state.stopPolling);
|
||||||
|
const fetchAll = useGitStore((state) => state.fetchAll);
|
||||||
|
const fetchStatus = useGitStore((state) => state.fetchStatus);
|
||||||
|
const clearDiffCache = useGitStore((state) => state.clearDiffCache);
|
||||||
const immediateRefreshTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
const immediateRefreshTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const lastImmediateRefreshAtRef = React.useRef<number>(0);
|
const lastImmediateRefreshAtRef = React.useRef<number>(0);
|
||||||
|
|
||||||
@@ -40,12 +50,12 @@ export function useGitPolling() {
|
|||||||
if (!currentSessionId) {
|
if (!currentSessionId) {
|
||||||
return 'idle';
|
return 'idle';
|
||||||
}
|
}
|
||||||
const activeStatus = sessionStatus?.get(currentSessionId)?.type;
|
const activeStatus = currentStatus?.type;
|
||||||
if (activeStatus === 'busy' || activeStatus === 'retry') {
|
if (activeStatus === 'busy' || activeStatus === 'retry') {
|
||||||
return activeStatus;
|
return activeStatus;
|
||||||
}
|
}
|
||||||
return 'idle';
|
return 'idle';
|
||||||
}, [currentSessionId, sessionStatus]);
|
}, [currentSessionId, currentStatus]);
|
||||||
|
|
||||||
const pollingMode = activeSessionStatus === 'busy' || activeSessionStatus === 'retry' ? 'busy' : 'normal';
|
const pollingMode = activeSessionStatus === 'busy' || activeSessionStatus === 'retry' ? 'busy' : 'normal';
|
||||||
|
|
||||||
@@ -84,7 +94,7 @@ export function useGitPolling() {
|
|||||||
immediateRefreshTimerRef.current = null;
|
immediateRefreshTimerRef.current = null;
|
||||||
lastImmediateRefreshAtRef.current = Date.now();
|
lastImmediateRefreshAtRef.current = Date.now();
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const statusChanged = await fetchStatus(targetDirectory, git, { silent: true });
|
const statusChanged = await fetchStatus(targetDirectory, git, { silent: true, mode: 'light' });
|
||||||
if (shouldForceDiffRefresh && !statusChanged) {
|
if (shouldForceDiffRefresh && !statusChanged) {
|
||||||
clearDiffCache(targetDirectory);
|
clearDiffCache(targetDirectory);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
|
import { useSelectionStore } from '@/sync/selection-store';
|
||||||
|
import * as sessionActions from '@/sync/session-actions';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||||
import { useAssistantStatus } from '@/hooks/useAssistantStatus';
|
import { useAssistantStatus } from '@/hooks/useAssistantStatus';
|
||||||
@@ -10,24 +12,26 @@ import { showOpenCodeStatus } from '@/lib/openCodeStatus';
|
|||||||
import { eventMatchesShortcut, getEffectiveShortcutCombo } from '@/lib/shortcuts';
|
import { eventMatchesShortcut, getEffectiveShortcutCombo } from '@/lib/shortcuts';
|
||||||
|
|
||||||
export const useKeyboardShortcuts = () => {
|
export const useKeyboardShortcuts = () => {
|
||||||
const { openNewSessionDraft, abortCurrentOperation, armAbortPrompt, clearAbortPrompt, currentSessionId } = useSessionStore();
|
const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft);
|
||||||
const {
|
const armAbortPrompt = useSessionUIStore((s) => s.armAbortPrompt);
|
||||||
toggleCommandPalette,
|
const clearAbortPrompt = useSessionUIStore((s) => s.clearAbortPrompt);
|
||||||
toggleHelpDialog,
|
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
|
||||||
toggleSidebar,
|
const abortCurrentOperation = sessionActions.abortCurrentOperation;;
|
||||||
toggleRightSidebar,
|
const toggleCommandPalette = useUIStore((s) => s.toggleCommandPalette);
|
||||||
setRightSidebarOpen,
|
const toggleHelpDialog = useUIStore((s) => s.toggleHelpDialog);
|
||||||
setRightSidebarTab,
|
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
|
||||||
toggleBottomTerminal,
|
const toggleRightSidebar = useUIStore((s) => s.toggleRightSidebar);
|
||||||
setBottomTerminalExpanded,
|
const setRightSidebarOpen = useUIStore((s) => s.setRightSidebarOpen);
|
||||||
isMobile,
|
const setRightSidebarTab = useUIStore((s) => s.setRightSidebarTab);
|
||||||
setSessionSwitcherOpen,
|
const toggleBottomTerminal = useUIStore((s) => s.toggleBottomTerminal);
|
||||||
setActiveMainTab,
|
const setBottomTerminalExpanded = useUIStore((s) => s.setBottomTerminalExpanded);
|
||||||
setSettingsDialogOpen,
|
const isMobile = useUIStore((s) => s.isMobile);
|
||||||
setModelSelectorOpen,
|
const setSessionSwitcherOpen = useUIStore((s) => s.setSessionSwitcherOpen);
|
||||||
toggleExpandedInput,
|
const setActiveMainTab = useUIStore((s) => s.setActiveMainTab);
|
||||||
shortcutOverrides,
|
const setSettingsDialogOpen = useUIStore((s) => s.setSettingsDialogOpen);
|
||||||
} = useUIStore();
|
const setModelSelectorOpen = useUIStore((s) => s.setModelSelectorOpen);
|
||||||
|
const toggleExpandedInput = useUIStore((s) => s.toggleExpandedInput);
|
||||||
|
const shortcutOverrides = useUIStore((s) => s.shortcutOverrides);
|
||||||
const { themeMode, setThemeMode } = useThemeSystem();
|
const { themeMode, setThemeMode } = useThemeSystem();
|
||||||
const { working } = useAssistantStatus();
|
const { working } = useAssistantStatus();
|
||||||
const abortPrimedUntilRef = React.useRef<number | null>(null);
|
const abortPrimedUntilRef = React.useRef<number | null>(null);
|
||||||
@@ -108,13 +112,6 @@ export const useKeyboardShortcuts = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (eventMatchesShortcut(e, combo('open_timeline'))) {
|
|
||||||
e.preventDefault();
|
|
||||||
const { isTimelineDialogOpen, setTimelineDialogOpen } = useUIStore.getState();
|
|
||||||
setTimelineDialogOpen(!isTimelineDialogOpen);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (eventMatchesShortcut(e, combo('open_settings'))) {
|
if (eventMatchesShortcut(e, combo('open_settings'))) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const { isSettingsDialogOpen } = useUIStore.getState();
|
const { isSettingsDialogOpen } = useUIStore.getState();
|
||||||
@@ -270,14 +267,13 @@ export const useKeyboardShortcuts = () => {
|
|||||||
configState.cycleCurrentVariant();
|
configState.cycleCurrentVariant();
|
||||||
|
|
||||||
const nextVariant = useConfigStore.getState().currentVariant;
|
const nextVariant = useConfigStore.getState().currentVariant;
|
||||||
const sessionState = useSessionStore.getState();
|
const sessionId = useSessionUIStore.getState().currentSessionId;
|
||||||
const sessionId = sessionState.currentSessionId;
|
|
||||||
const agentName = useConfigStore.getState().currentAgentName;
|
const agentName = useConfigStore.getState().currentAgentName;
|
||||||
const providerId = useConfigStore.getState().currentProviderId;
|
const providerId = useConfigStore.getState().currentProviderId;
|
||||||
const modelId = useConfigStore.getState().currentModelId;
|
const modelId = useConfigStore.getState().currentModelId;
|
||||||
|
|
||||||
if (sessionId && agentName && providerId && modelId) {
|
if (sessionId && agentName && providerId && modelId) {
|
||||||
sessionState.saveAgentModelVariantForSession(sessionId, agentName, providerId, modelId, nextVariant);
|
useSelectionStore.getState().saveAgentModelVariantForSession(sessionId, agentName, providerId, modelId, nextVariant);
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@@ -387,7 +383,7 @@ export const useKeyboardShortcuts = () => {
|
|||||||
if (primedUntil && now < primedUntil) {
|
if (primedUntil && now < primedUntil) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
resetAbortPriming();
|
resetAbortPriming();
|
||||||
void abortCurrentOperation(sessionId || undefined);
|
void abortCurrentOperation(sessionId ?? '');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { toast } from '@/components/ui';
|
import { toast } from '@/components/ui';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||||
import { useUpdateStore } from '@/stores/useUpdateStore';
|
import { useUpdateStore } from '@/stores/useUpdateStore';
|
||||||
@@ -48,16 +48,14 @@ type MenuAction =
|
|||||||
export const useMenuActions = (
|
export const useMenuActions = (
|
||||||
onToggleMemoryDebug?: () => void
|
onToggleMemoryDebug?: () => void
|
||||||
) => {
|
) => {
|
||||||
const { openNewSessionDraft } = useSessionStore();
|
const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft);
|
||||||
const {
|
const toggleCommandPalette = useUIStore((s) => s.toggleCommandPalette);
|
||||||
toggleCommandPalette,
|
const toggleHelpDialog = useUIStore((s) => s.toggleHelpDialog);
|
||||||
toggleHelpDialog,
|
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
|
||||||
toggleSidebar,
|
const setSessionSwitcherOpen = useUIStore((s) => s.setSessionSwitcherOpen);
|
||||||
setSessionSwitcherOpen,
|
const setActiveMainTab = useUIStore((s) => s.setActiveMainTab);
|
||||||
setActiveMainTab,
|
const setSettingsDialogOpen = useUIStore((s) => s.setSettingsDialogOpen);
|
||||||
setSettingsDialogOpen,
|
const setAboutDialogOpen = useUIStore((s) => s.setAboutDialogOpen);
|
||||||
setAboutDialogOpen,
|
|
||||||
} = useUIStore();
|
|
||||||
const { addProject } = useProjectsStore();
|
const { addProject } = useProjectsStore();
|
||||||
const checkForUpdates = useUpdateStore((state) => state.checkForUpdates);
|
const checkForUpdates = useUpdateStore((state) => state.checkForUpdates);
|
||||||
const { requestAccess, startAccessing } = useFileSystemAccess();
|
const { requestAccess, startAccessing } = useFileSystemAccess();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
|
import { useSessions } from '@/sync/sync-context';
|
||||||
import { isWebRuntime } from '@/lib/desktop';
|
import { isWebRuntime } from '@/lib/desktop';
|
||||||
import { PWA_RECENT_SESSIONS_STORAGE_KEY } from '@/lib/pwa';
|
import { PWA_RECENT_SESSIONS_STORAGE_KEY } from '@/lib/pwa';
|
||||||
|
|
||||||
@@ -60,8 +61,8 @@ const buildRecentShortcuts = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const usePwaManifestSync = () => {
|
export const usePwaManifestSync = () => {
|
||||||
const sessions = useSessionStore((state) => state.sessions);
|
const sessions = useSessions();
|
||||||
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||||
|
|
||||||
const recentShortcuts = React.useMemo(() => {
|
const recentShortcuts = React.useMemo(() => {
|
||||||
return buildRecentShortcuts(sessions, currentSessionId);
|
return buildRecentShortcuts(sessions, currentSessionId);
|
||||||
|
|||||||
@@ -1,32 +1,26 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { AttachedFile } from '@/stores/types/sessionTypes';
|
import type { AttachedFile } from '@/stores/types/sessionTypes';
|
||||||
import { useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore';
|
import { useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
import { useMessageStore } from '@/stores/messageStore';
|
import { useSelectionStore } from '@/sync/selection-store';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { useContextStore } from '@/stores/contextStore';
|
import { useContextStore } from '@/stores/contextStore';
|
||||||
import { parseAgentMentions } from '@/lib/messages/agentMentions';
|
import { parseAgentMentions } from '@/lib/messages/agentMentions';
|
||||||
|
import { getSyncSessionStatus } from '@/sync/sync-refs';
|
||||||
|
import { useDirectorySync } from '@/sync/sync-context';
|
||||||
|
|
||||||
type SessionStatusType = 'idle' | 'busy' | 'retry';
|
type SessionStatusType = 'idle' | 'busy' | 'retry';
|
||||||
|
|
||||||
const RECENT_ABORT_WINDOW_MS = 2000;
|
const RECENT_ABORT_WINDOW_MS = 2000;
|
||||||
|
|
||||||
const hasRecentAbort = (sessionId: string): boolean => {
|
const hasRecentAbort = (sessionId: string): boolean => {
|
||||||
const abortRecord = useSessionStore.getState().sessionAbortFlags.get(sessionId);
|
const abortRecord = useSessionUIStore.getState().sessionAbortFlags.get(sessionId);
|
||||||
if (!abortRecord) {
|
if (!abortRecord) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return Date.now() - abortRecord.timestamp < RECENT_ABORT_WINDOW_MS;
|
return Date.now() - abortRecord.timestamp < RECENT_ABORT_WINDOW_MS;
|
||||||
};
|
};
|
||||||
|
|
||||||
const setSessionStatus = (sessionId: string, type: SessionStatusType) => {
|
|
||||||
useSessionStore.setState((state) => {
|
|
||||||
const next = new Map(state.sessionStatus ?? new Map());
|
|
||||||
next.set(sessionId, { type });
|
|
||||||
return { sessionStatus: next };
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildQueuedPayload = (queue: QueuedMessage[]) => {
|
const buildQueuedPayload = (queue: QueuedMessage[]) => {
|
||||||
const agents = useConfigStore.getState().getVisibleAgents();
|
const agents = useConfigStore.getState().getVisibleAgents();
|
||||||
let primaryText = '';
|
let primaryText = '';
|
||||||
@@ -64,7 +58,7 @@ const buildQueuedPayload = (queue: QueuedMessage[]) => {
|
|||||||
const resolveSessionSendConfig = (sessionId: string) => {
|
const resolveSessionSendConfig = (sessionId: string) => {
|
||||||
const context = useContextStore.getState();
|
const context = useContextStore.getState();
|
||||||
const config = useConfigStore.getState();
|
const config = useConfigStore.getState();
|
||||||
const message = useMessageStore.getState();
|
const selection = useSelectionStore.getState();
|
||||||
|
|
||||||
const selectedAgent =
|
const selectedAgent =
|
||||||
context.getSessionAgentSelection(sessionId)
|
context.getSessionAgentSelection(sessionId)
|
||||||
@@ -81,16 +75,17 @@ const resolveSessionSendConfig = (sessionId: string) => {
|
|||||||
agentModel?.providerId
|
agentModel?.providerId
|
||||||
?? sessionModel?.providerId
|
?? sessionModel?.providerId
|
||||||
?? config.currentProviderId
|
?? config.currentProviderId
|
||||||
?? message.lastUsedProvider?.providerID;
|
?? selection.lastUsedProvider?.providerID;
|
||||||
const modelID =
|
const modelID =
|
||||||
agentModel?.modelId
|
agentModel?.modelId
|
||||||
?? sessionModel?.modelId
|
?? sessionModel?.modelId
|
||||||
?? config.currentModelId
|
?? config.currentModelId
|
||||||
?? message.lastUsedProvider?.modelID;
|
?? selection.lastUsedProvider?.modelID;
|
||||||
|
|
||||||
const variant =
|
const variant =
|
||||||
selectedAgent && providerID && modelID
|
selectedAgent && providerID && modelID
|
||||||
? context.getAgentModelVariantForSession(sessionId, selectedAgent, providerID, modelID)
|
? (selection.getAgentModelVariantForSession(sessionId, selectedAgent, providerID, modelID)
|
||||||
|
?? context.getAgentModelVariantForSession(sessionId, selectedAgent, providerID, modelID))
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -101,10 +96,10 @@ const resolveSessionSendConfig = (sessionId: string) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useQueuedMessageAutoSend(options?: { enabled?: boolean }) {
|
export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?: boolean }) {
|
||||||
const enabled = options?.enabled ?? true;
|
const enabled = typeof enabledOrOptions === 'boolean' ? enabledOrOptions : (enabledOrOptions?.enabled ?? true);
|
||||||
const queuedMessages = useMessageQueueStore((state) => state.queuedMessages);
|
const queuedMessages = useMessageQueueStore((state) => state.queuedMessages);
|
||||||
const sessionStatus = useSessionStore((state) => state.sessionStatus);
|
const sessionStatusRecord = useDirectorySync((state) => state.session_status);
|
||||||
|
|
||||||
const inFlightSessionsRef = React.useRef<Set<string>>(new Set());
|
const inFlightSessionsRef = React.useRef<Set<string>>(new Set());
|
||||||
const previousStatusRef = React.useRef<Map<string, SessionStatusType>>(new Map());
|
const previousStatusRef = React.useRef<Map<string, SessionStatusType>>(new Map());
|
||||||
@@ -125,7 +120,7 @@ export function useQueuedMessageAutoSend(options?: { enabled?: boolean }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentStatus = useSessionStore.getState().sessionStatus?.get(sessionId)?.type ?? 'idle';
|
const currentStatus = getSyncSessionStatus(sessionId)?.type ?? 'idle';
|
||||||
if (currentStatus !== 'idle') {
|
if (currentStatus !== 'idle') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -135,21 +130,23 @@ export function useQueuedMessageAutoSend(options?: { enabled?: boolean }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolved = resolveSessionSendConfig(sessionId);
|
// Use send config captured at queue time; fall back to current config
|
||||||
|
const captured = queueSnapshot[0]?.sendConfig;
|
||||||
|
const resolved = captured?.providerID && captured?.modelID
|
||||||
|
? captured
|
||||||
|
: resolveSessionSendConfig(sessionId);
|
||||||
if (!resolved.providerID || !resolved.modelID) {
|
if (!resolved.providerID || !resolved.modelID) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
inFlightSessionsRef.current.add(sessionId);
|
inFlightSessionsRef.current.add(sessionId);
|
||||||
setSessionStatus(sessionId, 'busy');
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await useMessageStore.getState().sendMessage(
|
await useSessionUIStore.getState().sendMessage(
|
||||||
payload.primaryText,
|
payload.primaryText,
|
||||||
resolved.providerID,
|
resolved.providerID,
|
||||||
resolved.modelID,
|
resolved.modelID,
|
||||||
resolved.agent,
|
resolved.agent,
|
||||||
sessionId,
|
|
||||||
payload.primaryAttachments,
|
payload.primaryAttachments,
|
||||||
payload.agentMentionName,
|
payload.agentMentionName,
|
||||||
payload.additionalParts,
|
payload.additionalParts,
|
||||||
@@ -162,22 +159,23 @@ export function useQueuedMessageAutoSend(options?: { enabled?: boolean }) {
|
|||||||
removeFromQueue(sessionId, item.id);
|
removeFromQueue(sessionId, item.id);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setSessionStatus(sessionId, 'idle');
|
|
||||||
console.warn('[queue] queued auto-send failed:', error);
|
console.warn('[queue] queued auto-send failed:', error);
|
||||||
} finally {
|
} finally {
|
||||||
inFlightSessionsRef.current.delete(sessionId);
|
inFlightSessionsRef.current.delete(sessionId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const statusRecord = sessionStatusRecord ?? {};
|
||||||
const nextStatusMap = new Map(previousStatusRef.current);
|
const nextStatusMap = new Map(previousStatusRef.current);
|
||||||
const statusEntries = sessionStatus ? Array.from(sessionStatus.entries()) : [];
|
for (const [sessionId, status] of Object.entries(statusRecord)) {
|
||||||
statusEntries.forEach(([sessionId, status]) => {
|
if (status) {
|
||||||
nextStatusMap.set(sessionId, status.type);
|
nextStatusMap.set(sessionId, status.type as SessionStatusType);
|
||||||
});
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const queueEntries = Object.entries(queuedMessages);
|
const queueEntries = Object.entries(queuedMessages);
|
||||||
queueEntries.forEach(([sessionId, queue]) => {
|
queueEntries.forEach(([sessionId, queue]) => {
|
||||||
const currentStatusType = (sessionStatus?.get(sessionId)?.type ?? 'idle') as SessionStatusType;
|
const currentStatusType = (statusRecord[sessionId]?.type ?? 'idle') as SessionStatusType;
|
||||||
const previousStatusType = previousStatusRef.current.get(sessionId);
|
const previousStatusType = previousStatusRef.current.get(sessionId);
|
||||||
const becameIdle =
|
const becameIdle =
|
||||||
(previousStatusType === 'busy' || previousStatusType === 'retry')
|
(previousStatusType === 'busy' || previousStatusType === 'retry')
|
||||||
@@ -192,5 +190,5 @@ export function useQueuedMessageAutoSend(options?: { enabled?: boolean }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
previousStatusRef.current = nextStatusMap;
|
previousStatusRef.current = nextStatusMap;
|
||||||
}, [enabled, queuedMessages, sessionStatus]);
|
}, [enabled, queuedMessages, sessionStatusRecord]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { parseRoute, updateBrowserURL, hasRouteParams } from '@/lib/router';
|
import { parseRoute, updateBrowserURL, hasRouteParams } from '@/lib/router';
|
||||||
import type { RouteState, AppRouteState } from '@/lib/router';
|
import type { RouteState, AppRouteState } from '@/lib/router';
|
||||||
@@ -38,7 +38,7 @@ export function useRouter(): void {
|
|||||||
const isApplyingRouteRef = React.useRef(false);
|
const isApplyingRouteRef = React.useRef(false);
|
||||||
|
|
||||||
// Get store actions (stable references)
|
// Get store actions (stable references)
|
||||||
const setCurrentSession = useSessionStore((state) => state.setCurrentSession);
|
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
|
||||||
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
|
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
|
||||||
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
|
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
|
||||||
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
|
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
|
||||||
@@ -58,7 +58,7 @@ export function useRouter(): void {
|
|||||||
try {
|
try {
|
||||||
// 1. Apply session first (may trigger async operations)
|
// 1. Apply session first (may trigger async operations)
|
||||||
if (route.sessionId) {
|
if (route.sessionId) {
|
||||||
const currentSessionId = useSessionStore.getState().currentSessionId;
|
const currentSessionId = useSessionUIStore.getState().currentSessionId;
|
||||||
if (route.sessionId !== currentSessionId) {
|
if (route.sessionId !== currentSessionId) {
|
||||||
await setCurrentSession(route.sessionId);
|
await setCurrentSession(route.sessionId);
|
||||||
}
|
}
|
||||||
@@ -97,7 +97,7 @@ export function useRouter(): void {
|
|||||||
* Get current app state for URL serialization.
|
* Get current app state for URL serialization.
|
||||||
*/
|
*/
|
||||||
const getCurrentAppState = React.useCallback((): AppRouteState => {
|
const getCurrentAppState = React.useCallback((): AppRouteState => {
|
||||||
const sessionState = useSessionStore.getState();
|
const sessionState = useSessionUIStore.getState();
|
||||||
const uiState = useUIStore.getState();
|
const uiState = useUIStore.getState();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -158,9 +158,9 @@ export function useRouter(): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let prevSessionId: string | null = useSessionStore.getState().currentSessionId;
|
let prevSessionId: string | null = useSessionUIStore.getState().currentSessionId;
|
||||||
|
|
||||||
const unsubscribe = useSessionStore.subscribe((state) => {
|
const unsubscribe = useSessionUIStore.subscribe((state) => {
|
||||||
const sessionId = state.currentSessionId;
|
const sessionId = state.currentSessionId;
|
||||||
|
|
||||||
// Skip if no change or if we're currently applying a route
|
// Skip if no change or if we're currently applying a route
|
||||||
@@ -261,7 +261,7 @@ export function navigateToRoute(route: Partial<RouteState>): void {
|
|||||||
if (win.__VSCODE_CONFIG__ !== undefined) {
|
if (win.__VSCODE_CONFIG__ !== undefined) {
|
||||||
// In VS Code, just apply state changes directly
|
// In VS Code, just apply state changes directly
|
||||||
if (route.sessionId) {
|
if (route.sessionId) {
|
||||||
void useSessionStore.getState().setCurrentSession(route.sessionId);
|
void useSessionUIStore.getState().setCurrentSession(route.sessionId);
|
||||||
}
|
}
|
||||||
if (route.settingsPath) {
|
if (route.settingsPath) {
|
||||||
useUIStore.getState().setSettingsPage(resolveSettingsSlug(route.settingsPath));
|
useUIStore.getState().setSettingsPage(resolveSettingsSlug(route.settingsPath));
|
||||||
@@ -300,7 +300,7 @@ export function navigateToRoute(route: Partial<RouteState>): void {
|
|||||||
|
|
||||||
// Also apply to state
|
// Also apply to state
|
||||||
if (route.sessionId) {
|
if (route.sessionId) {
|
||||||
void useSessionStore.getState().setCurrentSession(route.sessionId);
|
void useSessionUIStore.getState().setCurrentSession(route.sessionId);
|
||||||
}
|
}
|
||||||
if (route.settingsPath) {
|
if (route.settingsPath) {
|
||||||
useUIStore.getState().setSettingsPage(resolveSettingsSlug(route.settingsPath));
|
useUIStore.getState().setSettingsPage(resolveSettingsSlug(route.settingsPath));
|
||||||
@@ -321,7 +321,7 @@ export function getShareableURL(): string {
|
|||||||
return '/';
|
return '/';
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionState = useSessionStore.getState();
|
const sessionState = useSessionUIStore.getState();
|
||||||
const uiState = useUIStore.getState();
|
const uiState = useUIStore.getState();
|
||||||
|
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|||||||
@@ -1,334 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
|
||||||
import { opencodeClient } from '@/lib/opencode/client';
|
|
||||||
|
|
||||||
interface SessionState {
|
|
||||||
status: 'idle' | 'busy' | 'retry';
|
|
||||||
lastUpdateAt: number;
|
|
||||||
metadata?: {
|
|
||||||
attempt?: number;
|
|
||||||
message?: string;
|
|
||||||
next?: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SessionAttentionState {
|
|
||||||
needsAttention: boolean;
|
|
||||||
lastUserMessageAt: number | null;
|
|
||||||
lastStatusChangeAt: number;
|
|
||||||
status: 'idle' | 'busy' | 'retry';
|
|
||||||
isViewed: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ServerSnapshotResponse {
|
|
||||||
statusSessions: Record<string, SessionState>;
|
|
||||||
attentionSessions: Record<string, SessionAttentionState>;
|
|
||||||
serverTime: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const IMMEDIATE_POLL_DELAY_MS = 150;
|
|
||||||
const FOLLOW_UP_POLL_DELAY_MS = 1100;
|
|
||||||
const MIN_IMMEDIATE_POLL_GAP_MS = 1200;
|
|
||||||
const FOLLOW_UP_REARM_COOLDOWN_MS = 5000;
|
|
||||||
|
|
||||||
// Ref to be accessed from outside (e.g., useEventStream) for triggering immediate poll
|
|
||||||
let triggerImmediatePollRef: (() => void) | null = null;
|
|
||||||
|
|
||||||
// Global function to trigger immediate poll from outside React
|
|
||||||
export const triggerSessionStatusPoll = () => {
|
|
||||||
if (triggerImmediatePollRef) {
|
|
||||||
triggerImmediatePollRef();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to synchronize session status and attention state from server.
|
|
||||||
*
|
|
||||||
* Architecture: server maintains authoritative state, client applies snapshots.
|
|
||||||
* SSE remains the primary transport; snapshots repair missed updates.
|
|
||||||
*/
|
|
||||||
export function useServerSessionStatus(options?: { enabled?: boolean }) {
|
|
||||||
const enabled = options?.enabled ?? true;
|
|
||||||
const isSyncingRef = React.useRef(false);
|
|
||||||
const hasPendingImmediateSyncRef = React.useRef(false);
|
|
||||||
const lastSyncAtRef = React.useRef(0);
|
|
||||||
const lastImmediatePollRequestAtRef = React.useRef(0);
|
|
||||||
const lastFollowUpPollRequestAtRef = React.useRef(0);
|
|
||||||
const timeoutRef = React.useRef<NodeJS.Timeout | null>(null);
|
|
||||||
const followUpTimeoutRef = React.useRef<NodeJS.Timeout | null>(null);
|
|
||||||
|
|
||||||
const fetchSessionStatus = React.useCallback(async (immediate = false) => {
|
|
||||||
const now = Date.now();
|
|
||||||
if (!immediate && now - lastSyncAtRef.current < 1000) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (immediate && now - lastSyncAtRef.current < 600) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prevent concurrent syncs; if an immediate sync is requested while running,
|
|
||||||
// queue one more pass right after current request settles.
|
|
||||||
if (isSyncingRef.current) {
|
|
||||||
if (immediate) {
|
|
||||||
hasPendingImmediateSyncRef.current = true;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isSyncingRef.current = true;
|
|
||||||
lastSyncAtRef.current = now;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [snapshotResult, upstreamStatusResult] = await Promise.allSettled([
|
|
||||||
fetch('/api/sessions/snapshot', {
|
|
||||||
method: 'GET',
|
|
||||||
cache: 'no-store',
|
|
||||||
headers: { Accept: 'application/json' },
|
|
||||||
}).then(async (r) => {
|
|
||||||
if (!r.ok) {
|
|
||||||
console.warn('[useServerSessionStatus] API returned', r.status);
|
|
||||||
if (r.status === 401) {
|
|
||||||
console.warn('[useServerSessionStatus] Authentication required - session may have expired');
|
|
||||||
}
|
|
||||||
throw new Error(String(r.status));
|
|
||||||
}
|
|
||||||
return (await r.json()) as ServerSnapshotResponse;
|
|
||||||
}),
|
|
||||||
opencodeClient.getGlobalSessionStatus(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const snapshotData: ServerSnapshotResponse | null =
|
|
||||||
snapshotResult.status === 'fulfilled' ? snapshotResult.value : null;
|
|
||||||
const statusSessions = snapshotData?.statusSessions ?? {};
|
|
||||||
const attentionSessions = snapshotData?.attentionSessions ?? {};
|
|
||||||
|
|
||||||
const upstreamStatuses =
|
|
||||||
upstreamStatusResult.status === 'fulfilled' ? (upstreamStatusResult.value ?? {}) : {};
|
|
||||||
|
|
||||||
// Update the session store with server state
|
|
||||||
const currentStatuses = useSessionStore.getState().sessionStatus || new Map();
|
|
||||||
let newStatuses: Map<string, { type: 'idle' | 'busy' | 'retry'; confirmedAt?: number; attempt?: number; message?: string; next?: number }> | null = null;
|
|
||||||
const ensureStatusesMap = () => {
|
|
||||||
if (!newStatuses) {
|
|
||||||
newStatuses = new Map(currentStatuses);
|
|
||||||
}
|
|
||||||
return newStatuses;
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const [sessionId, state] of Object.entries(statusSessions)) {
|
|
||||||
const existing = currentStatuses.get(sessionId);
|
|
||||||
const hasChanged =
|
|
||||||
!existing ||
|
|
||||||
existing.type !== state.status ||
|
|
||||||
existing.attempt !== state.metadata?.attempt ||
|
|
||||||
existing.message !== state.metadata?.message ||
|
|
||||||
existing.next !== state.metadata?.next ||
|
|
||||||
existing.confirmedAt !== state.lastUpdateAt;
|
|
||||||
|
|
||||||
// Only update if server state is different
|
|
||||||
if (hasChanged) {
|
|
||||||
ensureStatusesMap().set(sessionId, {
|
|
||||||
type: state.status,
|
|
||||||
confirmedAt: state.lastUpdateAt,
|
|
||||||
attempt: state.metadata?.attempt,
|
|
||||||
message: state.metadata?.message,
|
|
||||||
next: state.metadata?.next,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Overlay OpenCode's own session status endpoint.
|
|
||||||
// This is the source-of-truth for retry message payload and works even when
|
|
||||||
// OpenChamber server-side tracking misses transient updates.
|
|
||||||
for (const [sessionId, upstream] of Object.entries(upstreamStatuses)) {
|
|
||||||
const existing = (newStatuses ?? currentStatuses).get(sessionId);
|
|
||||||
const hasChanged =
|
|
||||||
!existing ||
|
|
||||||
existing.type !== upstream.type ||
|
|
||||||
existing.attempt !== upstream.attempt ||
|
|
||||||
existing.message !== upstream.message ||
|
|
||||||
existing.next !== upstream.next;
|
|
||||||
|
|
||||||
if (hasChanged) {
|
|
||||||
ensureStatusesMap().set(sessionId, {
|
|
||||||
type: upstream.type,
|
|
||||||
confirmedAt: Date.now(),
|
|
||||||
attempt: upstream.attempt,
|
|
||||||
message: upstream.message,
|
|
||||||
next: upstream.next,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for sessions that are no longer in server state (treat as idle)
|
|
||||||
const activeServerStatusIds = new Set(Object.keys(statusSessions));
|
|
||||||
const activeUpstreamIds = new Set(Object.keys(upstreamStatuses));
|
|
||||||
|
|
||||||
for (const [sessionId, currentStatus] of (newStatuses ?? currentStatuses)) {
|
|
||||||
if ((currentStatus.type === 'busy' || currentStatus.type === 'retry') &&
|
|
||||||
!activeServerStatusIds.has(sessionId) &&
|
|
||||||
!activeUpstreamIds.has(sessionId)) {
|
|
||||||
// Session was busy but not in server state anymore -> mark as idle
|
|
||||||
ensureStatusesMap().set(sessionId, {
|
|
||||||
type: 'idle',
|
|
||||||
confirmedAt: Date.now(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update attention state from server
|
|
||||||
const currentAttentionStates = useSessionStore.getState().sessionAttentionStates || new Map();
|
|
||||||
let newAttentionStates: Map<string, SessionAttentionState> | null = null;
|
|
||||||
const ensureAttentionMap = () => {
|
|
||||||
if (!newAttentionStates) {
|
|
||||||
newAttentionStates = new Map(currentAttentionStates);
|
|
||||||
}
|
|
||||||
return newAttentionStates;
|
|
||||||
};
|
|
||||||
let attentionStatesChanged = false;
|
|
||||||
|
|
||||||
for (const [sessionId, attentionState] of Object.entries(attentionSessions)) {
|
|
||||||
const existing = currentAttentionStates.get(sessionId);
|
|
||||||
const serverState = attentionState as SessionAttentionState;
|
|
||||||
const hasChanged =
|
|
||||||
!existing ||
|
|
||||||
existing.needsAttention !== serverState.needsAttention ||
|
|
||||||
existing.lastUserMessageAt !== serverState.lastUserMessageAt ||
|
|
||||||
existing.lastStatusChangeAt !== serverState.lastStatusChangeAt ||
|
|
||||||
existing.status !== serverState.status ||
|
|
||||||
existing.isViewed !== serverState.isViewed;
|
|
||||||
|
|
||||||
if (hasChanged) {
|
|
||||||
ensureAttentionMap().set(sessionId, serverState);
|
|
||||||
attentionStatesChanged = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove attention states for sessions that no longer exist
|
|
||||||
for (const sessionId of (newAttentionStates ?? currentAttentionStates).keys()) {
|
|
||||||
const inStatus = !!statusSessions[sessionId];
|
|
||||||
const inAttention = !!attentionSessions[sessionId];
|
|
||||||
if (!inStatus && !inAttention) {
|
|
||||||
ensureAttentionMap().delete(sessionId);
|
|
||||||
attentionStatesChanged = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only update store if something actually changed
|
|
||||||
const statusChanged = newStatuses !== null;
|
|
||||||
if (statusChanged || attentionStatesChanged) {
|
|
||||||
useSessionStore.setState({
|
|
||||||
...(statusChanged && newStatuses ? { sessionStatus: newStatuses } : {}),
|
|
||||||
...(attentionStatesChanged && newAttentionStates ? { sessionAttentionStates: newAttentionStates } : {}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.debug('[useServerSessionStatus] Updated session statuses from server:', {
|
|
||||||
statusCount: Object.keys(statusSessions).length,
|
|
||||||
upstreamCount: Object.keys(upstreamStatuses).length,
|
|
||||||
attentionCount: Object.keys(attentionSessions).length,
|
|
||||||
serverTime: snapshotData?.serverTime,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[useServerSessionStatus] Error fetching session status:', error);
|
|
||||||
} finally {
|
|
||||||
isSyncingRef.current = false;
|
|
||||||
if (hasPendingImmediateSyncRef.current) {
|
|
||||||
hasPendingImmediateSyncRef.current = false;
|
|
||||||
setTimeout(() => {
|
|
||||||
void fetchSessionStatus(true);
|
|
||||||
}, 120);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Function to trigger immediate snapshot sync from external modules
|
|
||||||
const triggerImmediatePoll = React.useCallback(() => {
|
|
||||||
const now = Date.now();
|
|
||||||
const elapsed = now - lastImmediatePollRequestAtRef.current;
|
|
||||||
lastImmediatePollRequestAtRef.current = now;
|
|
||||||
|
|
||||||
if (!timeoutRef.current) {
|
|
||||||
const minGapDelay = elapsed >= MIN_IMMEDIATE_POLL_GAP_MS
|
|
||||||
? IMMEDIATE_POLL_DELAY_MS
|
|
||||||
: Math.max(IMMEDIATE_POLL_DELAY_MS, MIN_IMMEDIATE_POLL_GAP_MS - elapsed);
|
|
||||||
|
|
||||||
timeoutRef.current = setTimeout(() => {
|
|
||||||
timeoutRef.current = null;
|
|
||||||
void fetchSessionStatus(true);
|
|
||||||
}, minGapDelay);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run one follow-up sync after short settle period to catch delayed
|
|
||||||
// server status transitions that happen right after reconnect/restore.
|
|
||||||
// Re-arm at most once per cooldown window to avoid stacked follow-ups.
|
|
||||||
if (!followUpTimeoutRef.current && now - lastFollowUpPollRequestAtRef.current >= FOLLOW_UP_REARM_COOLDOWN_MS) {
|
|
||||||
lastFollowUpPollRequestAtRef.current = now;
|
|
||||||
followUpTimeoutRef.current = setTimeout(() => {
|
|
||||||
followUpTimeoutRef.current = null;
|
|
||||||
void fetchSessionStatus(true);
|
|
||||||
}, FOLLOW_UP_POLL_DELAY_MS);
|
|
||||||
}
|
|
||||||
}, [fetchSessionStatus]);
|
|
||||||
|
|
||||||
// Initial snapshot sync on mount
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!enabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
void fetchSessionStatus(true);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (timeoutRef.current) {
|
|
||||||
clearTimeout(timeoutRef.current);
|
|
||||||
}
|
|
||||||
if (followUpTimeoutRef.current) {
|
|
||||||
clearTimeout(followUpTimeoutRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [enabled, fetchSessionStatus]);
|
|
||||||
|
|
||||||
// Sync snapshot when tab becomes visible
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!enabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleVisibilityChange = () => {
|
|
||||||
if (document.visibilityState === 'visible') {
|
|
||||||
triggerImmediatePoll();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
||||||
};
|
|
||||||
}, [enabled, triggerImmediatePoll]);
|
|
||||||
|
|
||||||
// Update the ref for external access
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!enabled) {
|
|
||||||
triggerImmediatePollRef = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
triggerImmediatePollRef = triggerImmediatePoll;
|
|
||||||
return () => {
|
|
||||||
triggerImmediatePollRef = null;
|
|
||||||
};
|
|
||||||
}, [enabled, triggerImmediatePoll]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
fetchSessionStatus,
|
|
||||||
triggerImmediatePoll,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export ref accessor for external modules
|
|
||||||
export const getTriggerImmediatePoll = () => triggerImmediatePollRef;
|
|
||||||
|
|
||||||
export default useServerSessionStatus;
|
|
||||||
@@ -1,20 +1,14 @@
|
|||||||
|
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
|
import { useSessionStatus, useSessionMessages, useSessionPermissions } from '@/sync/sync-context';
|
||||||
|
|
||||||
// Mirrors OpenCode SessionStatus: busy|retry|idle.
|
// Mirrors OpenCode SessionStatus: busy|retry|idle.
|
||||||
export type SessionActivityPhase = 'idle' | 'busy' | 'retry';
|
export type SessionActivityPhase = 'idle' | 'busy' | 'retry';
|
||||||
|
|
||||||
export interface SessionActivityResult {
|
export interface SessionActivityResult {
|
||||||
|
|
||||||
phase: SessionActivityPhase;
|
phase: SessionActivityPhase;
|
||||||
|
|
||||||
isWorking: boolean;
|
isWorking: boolean;
|
||||||
|
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
|
|
||||||
// Kept for backward compatibility; always false with server session.status.
|
|
||||||
isCooldown: boolean;
|
isCooldown: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,33 +19,49 @@ const IDLE_RESULT: SessionActivityResult = {
|
|||||||
isCooldown: false,
|
isCooldown: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines if a session is actively working.
|
||||||
|
* Checks session_status and, as a narrow fallback, only the trailing
|
||||||
|
* assistant message when its completion update has not landed yet.
|
||||||
|
* Returns idle when permissions are pending (permission indicator takes priority).
|
||||||
|
*/
|
||||||
export function useSessionActivity(sessionId: string | null | undefined): SessionActivityResult {
|
export function useSessionActivity(sessionId: string | null | undefined): SessionActivityResult {
|
||||||
|
const status = useSessionStatus(sessionId ?? '');
|
||||||
const phase = useSessionStore((state) => {
|
const messages = useSessionMessages(sessionId ?? '');
|
||||||
if (!sessionId || !state.sessionStatus) {
|
const permissions = useSessionPermissions(sessionId ?? '');
|
||||||
return 'idle' as SessionActivityPhase;
|
|
||||||
}
|
|
||||||
const status = state.sessionStatus.get(sessionId);
|
|
||||||
return (status?.type ?? 'idle') as SessionActivityPhase;
|
|
||||||
});
|
|
||||||
|
|
||||||
return React.useMemo<SessionActivityResult>(() => {
|
return React.useMemo<SessionActivityResult>(() => {
|
||||||
if (phase === 'idle') {
|
if (!sessionId) return IDLE_RESULT;
|
||||||
return IDLE_RESULT;
|
|
||||||
}
|
// Permissions pending → idle (permission indicator takes priority)
|
||||||
const isBusy = phase === 'busy';
|
if (permissions.length > 0) return IDLE_RESULT;
|
||||||
// No cooldown in server session.status; treat retry as working.
|
|
||||||
const isCooldown = false;
|
const phase: SessionActivityPhase = (status?.type ?? 'idle') as SessionActivityPhase;
|
||||||
|
|
||||||
|
// Only trust the trailing assistant message as a transient fallback while
|
||||||
|
// waiting for session.status/message.updated to settle.
|
||||||
|
const lastMessage = messages[messages.length - 1];
|
||||||
|
const hasPendingAssistant = Boolean(
|
||||||
|
lastMessage
|
||||||
|
&& lastMessage.role === 'assistant'
|
||||||
|
&& typeof (lastMessage as { time?: { completed?: number } }).time?.completed !== 'number',
|
||||||
|
);
|
||||||
|
|
||||||
|
const statusWorking = phase !== 'idle';
|
||||||
|
const isWorking = statusWorking || hasPendingAssistant;
|
||||||
|
|
||||||
|
if (!isWorking) return IDLE_RESULT;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
phase,
|
phase: statusWorking ? phase : 'busy',
|
||||||
isWorking: phase === 'busy' || phase === 'retry',
|
isWorking: true,
|
||||||
isBusy,
|
isBusy: phase === 'busy' || (!statusWorking && hasPendingAssistant),
|
||||||
isCooldown,
|
isCooldown: false,
|
||||||
};
|
};
|
||||||
}, [phase]);
|
}, [sessionId, status, messages, permissions]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCurrentSessionActivity(): SessionActivityResult {
|
export function useCurrentSessionActivity(): SessionActivityResult {
|
||||||
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||||
return useSessionActivity(currentSessionId);
|
return useSessionActivity(currentSessionId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { Session } from '@opencode-ai/sdk/v2';
|
import type { Session } from '@opencode-ai/sdk/v2';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { opencodeClient } from '@/lib/opencode/client';
|
||||||
|
import { ensureGlobalSessionsLoaded, useGlobalSessionsStore, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
|
||||||
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
|
import { getAllSyncSessions } from '@/sync/sync-refs';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
|
|
||||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
@@ -50,8 +53,9 @@ export const buildAutoDeleteCandidates = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
type CleanupResult = {
|
type CleanupResult = {
|
||||||
deletedIds: string[];
|
completedIds: string[];
|
||||||
failedIds: string[];
|
failedIds: string[];
|
||||||
|
action: 'archive' | 'delete';
|
||||||
skippedReason?: 'disabled' | 'loading' | 'cooldown' | 'no-candidates' | 'running';
|
skippedReason?: 'disabled' | 'loading' | 'cooldown' | 'no-candidates' | 'running';
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -60,57 +64,65 @@ type CleanupOptions = {
|
|||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useSessionAutoCleanup = (options?: CleanupOptions) => {
|
export const useSessionAutoCleanup = (enabledOrOptions?: boolean | CleanupOptions) => {
|
||||||
|
const options = typeof enabledOrOptions === 'object' ? enabledOrOptions : undefined;
|
||||||
const autoRun = options?.autoRun !== false;
|
const autoRun = options?.autoRun !== false;
|
||||||
const enabled = options?.enabled ?? true;
|
const enabled = typeof enabledOrOptions === 'boolean' ? enabledOrOptions : (options?.enabled ?? true);
|
||||||
|
|
||||||
const sessions = useSessionStore((state) => state.sessions);
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||||
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
const isLoading = useSessionUIStore((state) => state.isLoading);
|
||||||
const isLoading = useSessionStore((state) => state.isLoading);
|
const globalSessions = useGlobalSessionsStore((state) => state.activeSessions);
|
||||||
const deleteSessions = useSessionStore((state) => state.deleteSessions);
|
const hasLoadedGlobalSessions = useGlobalSessionsStore((state) => state.hasLoaded);
|
||||||
|
|
||||||
const autoDeleteEnabled = useUIStore((state) => state.autoDeleteEnabled);
|
const autoDeleteEnabled = useUIStore((state) => state.autoDeleteEnabled);
|
||||||
const autoDeleteAfterDays = useUIStore((state) => state.autoDeleteAfterDays);
|
const autoDeleteAfterDays = useUIStore((state) => state.autoDeleteAfterDays);
|
||||||
|
const sessionRetentionAction = useUIStore((state) => state.sessionRetentionAction);
|
||||||
const autoDeleteLastRunAt = useUIStore((state) => state.autoDeleteLastRunAt);
|
const autoDeleteLastRunAt = useUIStore((state) => state.autoDeleteLastRunAt);
|
||||||
const setAutoDeleteLastRunAt = useUIStore((state) => state.setAutoDeleteLastRunAt);
|
const setAutoDeleteLastRunAt = useUIStore((state) => state.setAutoDeleteLastRunAt);
|
||||||
|
|
||||||
const [isRunning, setIsRunning] = React.useState(false);
|
const [isRunning, setIsRunning] = React.useState(false);
|
||||||
const runningRef = React.useRef(false);
|
const runningRef = React.useRef(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
void ensureGlobalSessionsLoaded(getAllSyncSessions());
|
||||||
|
}, []);
|
||||||
|
|
||||||
const candidates = React.useMemo(() => {
|
const candidates = React.useMemo(() => {
|
||||||
if (autoDeleteAfterDays <= 0) {
|
if (autoDeleteAfterDays <= 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return buildAutoDeleteCandidates({
|
return buildAutoDeleteCandidates({
|
||||||
sessions,
|
sessions: globalSessions,
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
cutoffDays: autoDeleteAfterDays,
|
cutoffDays: autoDeleteAfterDays,
|
||||||
});
|
});
|
||||||
}, [autoDeleteAfterDays, currentSessionId, sessions]);
|
}, [autoDeleteAfterDays, currentSessionId, globalSessions]);
|
||||||
|
|
||||||
const runCleanup = React.useCallback(
|
const runCleanup = React.useCallback(
|
||||||
async ({ force = false }: { force?: boolean } = {}): Promise<CleanupResult> => {
|
async ({ force = false }: { force?: boolean } = {}): Promise<CleanupResult> => {
|
||||||
if (runningRef.current) {
|
if (runningRef.current) {
|
||||||
return { deletedIds: [], failedIds: [], skippedReason: 'running' };
|
return { completedIds: [], failedIds: [], action: sessionRetentionAction, skippedReason: 'running' };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!autoDeleteEnabled || autoDeleteAfterDays <= 0) {
|
if (!autoDeleteEnabled || autoDeleteAfterDays <= 0) {
|
||||||
if (!force) {
|
if (!force) {
|
||||||
return { deletedIds: [], failedIds: [], skippedReason: 'disabled' };
|
return { completedIds: [], failedIds: [], action: sessionRetentionAction, skippedReason: 'disabled' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return { deletedIds: [], failedIds: [], skippedReason: 'loading' };
|
return { completedIds: [], failedIds: [], action: sessionRetentionAction, skippedReason: 'loading' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (!force && autoDeleteLastRunAt && now - autoDeleteLastRunAt < AUTO_DELETE_INTERVAL_MS) {
|
if (!force && autoDeleteLastRunAt && now - autoDeleteLastRunAt < AUTO_DELETE_INTERVAL_MS) {
|
||||||
return { deletedIds: [], failedIds: [], skippedReason: 'cooldown' };
|
return { completedIds: [], failedIds: [], action: sessionRetentionAction, skippedReason: 'cooldown' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { activeSessions: sessions } = await ensureGlobalSessionsLoaded(getAllSyncSessions());
|
||||||
|
|
||||||
if (sessions.length === 0) {
|
if (sessions.length === 0) {
|
||||||
return { deletedIds: [], failedIds: [], skippedReason: 'no-candidates' };
|
return { completedIds: [], failedIds: [], action: sessionRetentionAction, skippedReason: 'no-candidates' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const candidateIds = buildAutoDeleteCandidates({
|
const candidateIds = buildAutoDeleteCandidates({
|
||||||
@@ -122,14 +134,44 @@ export const useSessionAutoCleanup = (options?: CleanupOptions) => {
|
|||||||
|
|
||||||
if (candidateIds.length === 0) {
|
if (candidateIds.length === 0) {
|
||||||
setAutoDeleteLastRunAt(now);
|
setAutoDeleteLastRunAt(now);
|
||||||
return { deletedIds: [], failedIds: [], skippedReason: 'no-candidates' };
|
return { completedIds: [], failedIds: [], action: sessionRetentionAction, skippedReason: 'no-candidates' };
|
||||||
}
|
}
|
||||||
|
|
||||||
runningRef.current = true;
|
runningRef.current = true;
|
||||||
setIsRunning(true);
|
setIsRunning(true);
|
||||||
try {
|
try {
|
||||||
const result = await deleteSessions(candidateIds, { silent: true });
|
const sessionMap = new Map(sessions.map((session) => [session.id, session]));
|
||||||
return result;
|
const completedIds: string[] = [];
|
||||||
|
const failedIds: string[] = [];
|
||||||
|
|
||||||
|
for (const id of candidateIds) {
|
||||||
|
const session = sessionMap.get(id);
|
||||||
|
const directory = session ? resolveGlobalSessionDirectory(session) : null;
|
||||||
|
if (!directory) {
|
||||||
|
failedIds.push(id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scopedSdk = opencodeClient.getScopedSdkClient(directory);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (sessionRetentionAction === 'archive') {
|
||||||
|
await scopedSdk.session.update({ sessionID: id, directory, time: { archived: Date.now() } });
|
||||||
|
} else {
|
||||||
|
await scopedSdk.session.delete({ sessionID: id, directory });
|
||||||
|
}
|
||||||
|
completedIds.push(id);
|
||||||
|
} catch {
|
||||||
|
failedIds.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionRetentionAction === 'archive') {
|
||||||
|
useGlobalSessionsStore.getState().archiveSessions(completedIds);
|
||||||
|
} else {
|
||||||
|
useGlobalSessionsStore.getState().removeSessions(completedIds);
|
||||||
|
}
|
||||||
|
return { completedIds, failedIds, action: sessionRetentionAction };
|
||||||
} finally {
|
} finally {
|
||||||
runningRef.current = false;
|
runningRef.current = false;
|
||||||
setIsRunning(false);
|
setIsRunning(false);
|
||||||
@@ -141,9 +183,8 @@ export const useSessionAutoCleanup = (options?: CleanupOptions) => {
|
|||||||
autoDeleteEnabled,
|
autoDeleteEnabled,
|
||||||
autoDeleteLastRunAt,
|
autoDeleteLastRunAt,
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
deleteSessions,
|
|
||||||
isLoading,
|
isLoading,
|
||||||
sessions,
|
sessionRetentionAction,
|
||||||
setAutoDeleteLastRunAt,
|
setAutoDeleteLastRunAt,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
@@ -159,7 +200,7 @@ export const useSessionAutoCleanup = (options?: CleanupOptions) => {
|
|||||||
if (!autoDeleteEnabled || autoDeleteAfterDays <= 0) {
|
if (!autoDeleteEnabled || autoDeleteAfterDays <= 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isLoading || sessions.length === 0) {
|
if (isLoading || !hasLoadedGlobalSessions || globalSessions.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -173,8 +214,9 @@ export const useSessionAutoCleanup = (options?: CleanupOptions) => {
|
|||||||
autoDeleteLastRunAt,
|
autoDeleteLastRunAt,
|
||||||
autoRun,
|
autoRun,
|
||||||
enabled,
|
enabled,
|
||||||
|
hasLoadedGlobalSessions,
|
||||||
|
globalSessions.length,
|
||||||
isLoading,
|
isLoading,
|
||||||
sessions.length,
|
|
||||||
runCleanup,
|
runCleanup,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -183,5 +225,6 @@ export const useSessionAutoCleanup = (options?: CleanupOptions) => {
|
|||||||
isRunning,
|
isRunning,
|
||||||
runCleanup,
|
runCleanup,
|
||||||
keepRecentCount: AUTO_DELETE_KEEP_RECENT,
|
keepRecentCount: AUTO_DELETE_KEEP_RECENT,
|
||||||
|
action: sessionRetentionAction,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,47 +1,8 @@
|
|||||||
import React from 'react';
|
/**
|
||||||
import { opencodeClient } from '@/lib/opencode/client';
|
* Session status bootstrap is now handled by the sync system's own bootstrap
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
* (sync/bootstrap.ts). This hook is retained as a no-op for call-site compat.
|
||||||
|
*/
|
||||||
type SessionStatusPayload = {
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
type: 'idle' | 'busy' | 'retry';
|
export const useSessionStatusBootstrap = (_options?: { enabled?: boolean }) => {
|
||||||
attempt?: number;
|
// no-op — session_status is bootstrapped by sync child stores
|
||||||
message?: string;
|
|
||||||
next?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useSessionStatusBootstrap = (options?: { enabled?: boolean }) => {
|
|
||||||
const enabled = options?.enabled ?? true;
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!enabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
const bootstrap = async () => {
|
|
||||||
try {
|
|
||||||
// Use global status to detect busy sessions across all directories,
|
|
||||||
// including sessions started externally (e.g., via CLI) before UI opened
|
|
||||||
const statusMap = await opencodeClient.getGlobalSessionStatus();
|
|
||||||
if (cancelled || !statusMap) return;
|
|
||||||
|
|
||||||
const nextStatus = new Map<string, SessionStatusPayload>();
|
|
||||||
Object.entries(statusMap).forEach(([sessionId, raw]) => {
|
|
||||||
if (!sessionId || !raw) return;
|
|
||||||
const status = raw as SessionStatusPayload;
|
|
||||||
nextStatus.set(sessionId, status);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (nextStatus.size > 0) {
|
|
||||||
useSessionStore.setState({ sessionStatus: nextStatus });
|
|
||||||
}
|
|
||||||
} catch { /* ignored */ }
|
|
||||||
};
|
|
||||||
|
|
||||||
void bootstrap();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [enabled]);
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { useState, useRef, useEffect, useMemo } from "react"
|
||||||
|
|
||||||
|
type StageConfig = {
|
||||||
|
/** How many messages to show on first paint */
|
||||||
|
init: number
|
||||||
|
/** How many to add per animation frame */
|
||||||
|
batch: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type UseTimelineStagingInput<T> = {
|
||||||
|
/** Key that changes when session switches */
|
||||||
|
sessionKey: string
|
||||||
|
/** All messages (sorted) */
|
||||||
|
messages: T[]
|
||||||
|
/** Config for staging behavior */
|
||||||
|
config?: StageConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
type UseTimelineStagingResult<T> = {
|
||||||
|
/** The subset of messages that should be rendered */
|
||||||
|
stagedMessages: T[]
|
||||||
|
/** Whether staging is still in progress */
|
||||||
|
isStaging: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: StageConfig = { init: 1, batch: 3 }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defer-mounts small timeline windows so revealing older turns does not
|
||||||
|
* block first paint with a large DOM mount.
|
||||||
|
*
|
||||||
|
* Once staging completes for a session it never re-stages — backfill and
|
||||||
|
* new messages render immediately.
|
||||||
|
*
|
||||||
|
* Defers mounting older turns so first paint isn't blocked by large DOM.
|
||||||
|
*/
|
||||||
|
export function useTimelineStaging<T>(
|
||||||
|
input: UseTimelineStagingInput<T>,
|
||||||
|
): UseTimelineStagingResult<T> {
|
||||||
|
const config = input.config ?? DEFAULT_CONFIG
|
||||||
|
const { sessionKey, messages } = input
|
||||||
|
|
||||||
|
const [stagedCount, setStagedCount] = useState(() => messages.length)
|
||||||
|
const completedSessions = useRef(new Set<string>())
|
||||||
|
const activeSession = useRef("")
|
||||||
|
const frameRef = useRef<number | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Cancel any pending animation frame
|
||||||
|
if (frameRef.current !== null) {
|
||||||
|
cancelAnimationFrame(frameRef.current)
|
||||||
|
frameRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = messages.length
|
||||||
|
|
||||||
|
// If already completed for this session, show all immediately
|
||||||
|
if (completedSessions.current.has(sessionKey)) {
|
||||||
|
setStagedCount(total)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small message list — no staging needed
|
||||||
|
if (total <= config.init) {
|
||||||
|
setStagedCount(total)
|
||||||
|
completedSessions.current.add(sessionKey)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start staging
|
||||||
|
activeSession.current = sessionKey
|
||||||
|
let count = Math.min(total, config.init)
|
||||||
|
setStagedCount(count)
|
||||||
|
|
||||||
|
const step = () => {
|
||||||
|
// Session changed mid-staging — bail
|
||||||
|
if (activeSession.current !== sessionKey) {
|
||||||
|
frameRef.current = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
count = Math.min(messages.length, count + config.batch)
|
||||||
|
setStagedCount(count)
|
||||||
|
|
||||||
|
if (count >= messages.length) {
|
||||||
|
completedSessions.current.add(sessionKey)
|
||||||
|
activeSession.current = ""
|
||||||
|
frameRef.current = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
frameRef.current = requestAnimationFrame(step)
|
||||||
|
}
|
||||||
|
|
||||||
|
frameRef.current = requestAnimationFrame(step)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (frameRef.current !== null) {
|
||||||
|
cancelAnimationFrame(frameRef.current)
|
||||||
|
frameRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [sessionKey, messages.length, config.init, config.batch])
|
||||||
|
|
||||||
|
const stagedMessages = useMemo(() => {
|
||||||
|
if (stagedCount >= messages.length) return messages
|
||||||
|
return messages.slice(Math.max(0, messages.length - stagedCount))
|
||||||
|
}, [messages, stagedCount])
|
||||||
|
|
||||||
|
const isStaging = activeSession.current === sessionKey &&
|
||||||
|
!completedSessions.current.has(sessionKey)
|
||||||
|
|
||||||
|
return { stagedMessages, isStaging }
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
|
import { useSessionMessageRecords, useSessionPermissions } from '@/sync/sync-context';
|
||||||
import { voiceHooks, isVoiceSessionStarted } from '@/lib/voice';
|
import { voiceHooks, isVoiceSessionStarted } from '@/lib/voice';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -7,45 +8,41 @@ import { voiceHooks, isVoiceSessionStarted } from '@/lib/voice';
|
|||||||
* Call this inside VoiceProvider to enable session awareness during voice.
|
* Call this inside VoiceProvider to enable session awareness during voice.
|
||||||
*/
|
*/
|
||||||
export function useVoiceContext() {
|
export function useVoiceContext() {
|
||||||
const currentSessionId = useSessionStore((s) => s.currentSessionId);
|
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
|
||||||
const messages = useSessionStore((s) =>
|
const messages = useSessionMessageRecords(currentSessionId ?? '');
|
||||||
currentSessionId ? s.messages.get(currentSessionId) : undefined
|
const permissions = useSessionPermissions(currentSessionId ?? '');
|
||||||
);
|
|
||||||
const permissions = useSessionStore((s) =>
|
|
||||||
currentSessionId ? s.permissions.get(currentSessionId) : undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
// Track last seen message count to only forward new messages
|
// Track last seen message count to only forward new messages
|
||||||
const lastMessageCountRef = useRef(0);
|
const lastMessageCountRef = useRef(0);
|
||||||
|
|
||||||
// Forward new messages to voice agent
|
// Forward new messages to voice agent
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentSessionId || !messages || !isVoiceSessionStarted()) return;
|
if (!currentSessionId || !messages || messages.length === 0 || !isVoiceSessionStarted()) return;
|
||||||
|
|
||||||
const currentCount = messages.length;
|
const currentCount = messages.length;
|
||||||
if (currentCount <= lastMessageCountRef.current) return;
|
if (currentCount <= lastMessageCountRef.current) return;
|
||||||
|
|
||||||
// Get only new messages (messages since last check)
|
// Get only new messages (messages since last check)
|
||||||
const newMessages = messages.slice(lastMessageCountRef.current);
|
const newMessages = messages.slice(lastMessageCountRef.current);
|
||||||
lastMessageCountRef.current = currentCount;
|
lastMessageCountRef.current = currentCount;
|
||||||
|
|
||||||
// Format for voice hooks (extract role and content)
|
// Format for voice hooks (extract role and content)
|
||||||
const formattedMessages = newMessages.map(m => ({
|
const formattedMessages = newMessages.map(m => ({
|
||||||
role: m.info.role,
|
role: m.info.role,
|
||||||
content: m.parts.map(p => ('text' in p ? p.text : '')).join('')
|
content: m.parts.map((p: Record<string, unknown>) => ('text' in p ? p.text : '')).join('')
|
||||||
}));
|
}));
|
||||||
|
|
||||||
voiceHooks.onMessages(currentSessionId, formattedMessages);
|
voiceHooks.onMessages(currentSessionId, formattedMessages);
|
||||||
}, [currentSessionId, messages]);
|
}, [currentSessionId, messages]);
|
||||||
|
|
||||||
// Forward permission requests to voice agent
|
// Forward permission requests to voice agent
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentSessionId || !permissions || permissions.length === 0) return;
|
if (!currentSessionId || !permissions || permissions.length === 0) return;
|
||||||
if (!isVoiceSessionStarted()) return;
|
if (!isVoiceSessionStarted()) return;
|
||||||
|
|
||||||
const request = permissions[0];
|
const request = permissions[0];
|
||||||
if (!request) return;
|
if (!request) return;
|
||||||
|
|
||||||
voiceHooks.onPermissionRequested(
|
voiceHooks.onPermissionRequested(
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
request.id,
|
request.id,
|
||||||
@@ -53,7 +50,7 @@ export function useVoiceContext() {
|
|||||||
request.metadata
|
request.metadata
|
||||||
);
|
);
|
||||||
}, [currentSessionId, permissions]);
|
}, [currentSessionId, permissions]);
|
||||||
|
|
||||||
// Reset message count when session changes
|
// Reset message count when session changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
lastMessageCountRef.current = 0;
|
lastMessageCountRef.current = 0;
|
||||||
|
|||||||
@@ -394,7 +394,7 @@ export interface GitWorktreeAPI {
|
|||||||
|
|
||||||
export interface GitAPI {
|
export interface GitAPI {
|
||||||
checkIsGitRepository(directory: string): Promise<boolean>;
|
checkIsGitRepository(directory: string): Promise<boolean>;
|
||||||
getGitStatus(directory: string): Promise<GitStatus>;
|
getGitStatus(directory: string, options?: { mode?: 'light' }): Promise<GitStatus>;
|
||||||
getGitDiff(directory: string, options: GetGitDiffOptions): Promise<GitDiffResponse>;
|
getGitDiff(directory: string, options: GetGitDiffOptions): Promise<GitDiffResponse>;
|
||||||
getGitFileDiff(directory: string, options: GetGitFileDiffOptions): Promise<GitFileDiffResponse>;
|
getGitFileDiff(directory: string, options: GetGitFileDiffOptions): Promise<GitFileDiffResponse>;
|
||||||
revertGitFile(directory: string, filePath: string): Promise<void>;
|
revertGitFile(directory: string, filePath: string): Promise<void>;
|
||||||
@@ -536,6 +536,7 @@ export interface SettingsPayload {
|
|||||||
notificationMode?: 'always' | 'hidden-only';
|
notificationMode?: 'always' | 'hidden-only';
|
||||||
autoDeleteEnabled?: boolean;
|
autoDeleteEnabled?: boolean;
|
||||||
autoDeleteAfterDays?: number;
|
autoDeleteAfterDays?: number;
|
||||||
|
sessionRetentionAction?: 'archive' | 'delete';
|
||||||
queueModeEnabled?: boolean;
|
queueModeEnabled?: boolean;
|
||||||
gitmojiEnabled?: boolean;
|
gitmojiEnabled?: boolean;
|
||||||
inputSpellcheckEnabled?: boolean;
|
inputSpellcheckEnabled?: boolean;
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ type AppearanceSlice = {
|
|||||||
maxLastMessageLength: number;
|
maxLastMessageLength: number;
|
||||||
autoDeleteEnabled: boolean;
|
autoDeleteEnabled: boolean;
|
||||||
autoDeleteAfterDays: number;
|
autoDeleteAfterDays: number;
|
||||||
|
sessionRetentionAction: 'archive' | 'delete';
|
||||||
fontSize: number;
|
fontSize: number;
|
||||||
terminalFontSize: number;
|
terminalFontSize: number;
|
||||||
padding: number;
|
padding: number;
|
||||||
@@ -57,6 +58,7 @@ export const startAppearanceAutoSave = (): void => {
|
|||||||
maxLastMessageLength: useUIStore.getState().maxLastMessageLength,
|
maxLastMessageLength: useUIStore.getState().maxLastMessageLength,
|
||||||
autoDeleteEnabled: useUIStore.getState().autoDeleteEnabled,
|
autoDeleteEnabled: useUIStore.getState().autoDeleteEnabled,
|
||||||
autoDeleteAfterDays: useUIStore.getState().autoDeleteAfterDays,
|
autoDeleteAfterDays: useUIStore.getState().autoDeleteAfterDays,
|
||||||
|
sessionRetentionAction: useUIStore.getState().sessionRetentionAction,
|
||||||
fontSize: useUIStore.getState().fontSize,
|
fontSize: useUIStore.getState().fontSize,
|
||||||
terminalFontSize: useUIStore.getState().terminalFontSize,
|
terminalFontSize: useUIStore.getState().terminalFontSize,
|
||||||
padding: useUIStore.getState().padding,
|
padding: useUIStore.getState().padding,
|
||||||
@@ -103,6 +105,7 @@ export const startAppearanceAutoSave = (): void => {
|
|||||||
maxLastMessageLength: state.maxLastMessageLength,
|
maxLastMessageLength: state.maxLastMessageLength,
|
||||||
autoDeleteEnabled: state.autoDeleteEnabled,
|
autoDeleteEnabled: state.autoDeleteEnabled,
|
||||||
autoDeleteAfterDays: state.autoDeleteAfterDays,
|
autoDeleteAfterDays: state.autoDeleteAfterDays,
|
||||||
|
sessionRetentionAction: state.sessionRetentionAction,
|
||||||
fontSize: state.fontSize,
|
fontSize: state.fontSize,
|
||||||
terminalFontSize: state.terminalFontSize,
|
terminalFontSize: state.terminalFontSize,
|
||||||
padding: state.padding,
|
padding: state.padding,
|
||||||
@@ -159,6 +162,9 @@ export const startAppearanceAutoSave = (): void => {
|
|||||||
if (current.autoDeleteAfterDays !== previous.autoDeleteAfterDays) {
|
if (current.autoDeleteAfterDays !== previous.autoDeleteAfterDays) {
|
||||||
diff.autoDeleteAfterDays = current.autoDeleteAfterDays;
|
diff.autoDeleteAfterDays = current.autoDeleteAfterDays;
|
||||||
}
|
}
|
||||||
|
if (current.sessionRetentionAction !== previous.sessionRetentionAction) {
|
||||||
|
diff.sessionRetentionAction = current.sessionRetentionAction;
|
||||||
|
}
|
||||||
if (current.fontSize !== previous.fontSize) {
|
if (current.fontSize !== previous.fontSize) {
|
||||||
diff.fontSize = current.fontSize;
|
diff.fontSize = current.fontSize;
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user