summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-03-02 10:50:50 -0600
committerGitHub <[email protected]>2026-03-02 10:50:50 -0600
commit8176bafc555e562ade48a675dffa3f38751ed8c9 (patch)
tree7d4b0f6e98f431999b89c1f24687f6f53bd0bc6b
parent0a3a3216db5974efd3edc9a213054fd97d8dbd34 (diff)
downloadopencode-8176bafc555e562ade48a675dffa3f38751ed8c9.tar.gz
opencode-8176bafc555e562ade48a675dffa3f38751ed8c9.zip
chore(app): solidjs refactoring (#13399)
-rw-r--r--packages/app/create-effect-simplification-spec.md515
-rw-r--r--packages/app/src/components/file-tree.tsx6
-rw-r--r--packages/app/src/components/prompt-input.tsx9
-rw-r--r--packages/app/src/components/session/session-header.tsx11
-rw-r--r--packages/app/src/components/terminal.tsx7
-rw-r--r--packages/app/src/context/global-sync.tsx96
-rw-r--r--packages/app/src/context/global-sync/child-store.ts30
-rw-r--r--packages/app/src/context/layout.tsx82
-rw-r--r--packages/app/src/pages/layout.tsx307
-rw-r--r--packages/app/src/pages/layout/helpers.ts30
-rw-r--r--packages/app/src/pages/session.tsx18
-rw-r--r--packages/app/src/pages/session/file-tabs.tsx63
-rw-r--r--packages/app/src/pages/session/review-tab.tsx34
-rw-r--r--packages/app/src/pages/session/terminal-panel.tsx6
-rw-r--r--packages/app/src/pages/session/use-session-hash-scroll.ts36
15 files changed, 942 insertions, 308 deletions
diff --git a/packages/app/create-effect-simplification-spec.md b/packages/app/create-effect-simplification-spec.md
new file mode 100644
index 000000000..cc101ab05
--- /dev/null
+++ b/packages/app/create-effect-simplification-spec.md
@@ -0,0 +1,515 @@
+# CreateEffect Simplification Implementation Spec
+
+Reduce reactive misuse across `packages/app`.
+
+---
+
+## Context
+
+This work targets `packages/app/src`, which currently has 101 `createEffect` calls across 37 files.
+
+The biggest clusters are `pages/session.tsx` (19), `pages/layout.tsx` (13), `pages/session/file-tabs.tsx` (6), and several context providers that mirror one store into another.
+
+Key issues from the audit:
+
+- Derived state is being written through effects instead of computed directly
+- Session and file resets are handled by watch-and-clear effects instead of keyed state boundaries
+- User-driven actions are hidden inside reactive effects
+- Context layers mirror and hydrate child stores with multiple sync effects
+- Several areas repeat the same imperative trigger pattern in multiple effects
+
+Keep the implementation focused on removing unnecessary effects, not on broad UI redesign.
+
+## Goals
+
+- Cut high-churn `createEffect` usage in the hottest files first
+- Replace effect-driven derived state with reactive derivation
+- Replace reset-on-key effects with keyed ownership boundaries
+- Move event-driven work to direct actions and write paths
+- Remove mirrored store hydration where a single source of truth can exist
+- Leave necessary external sync effects in place, but make them narrower and clearer
+
+## Non-Goals
+
+- Do not rewrite unrelated component structure just to reduce the count
+- Do not change product behavior, navigation flow, or persisted data shape unless required for a cleaner write boundary
+- Do not remove effects that bridge to DOM, editors, polling, or external APIs unless there is a clearly safer equivalent
+- Do not attempt a repo-wide cleanup outside `packages/app`
+
+## Effect Taxonomy And Replacement Rules
+
+Use these rules during implementation.
+
+### Prefer `createMemo`
+
+Use `createMemo` when the target value is pure derived state from other signals or stores.
+
+Do this when an effect only reads reactive inputs and writes another reactive value that could be computed instead.
+
+Apply this to:
+
+- `packages/app/src/pages/session.tsx:141`
+- `packages/app/src/pages/layout.tsx:557`
+- `packages/app/src/components/terminal.tsx:261`
+- `packages/app/src/components/session/session-header.tsx:309`
+
+Rules:
+
+- If no external system is touched, do not use `createEffect`
+- Derive once, then read the memo where needed
+- If normalization is required, prefer normalizing at the write boundary before falling back to a memo
+
+### Prefer Keyed Remounts
+
+Use keyed remounts when local UI state should reset because an identity changed.
+
+Do this with `sessionKey`, `scope()`, or another stable identity instead of watching the key and manually clearing signals.
+
+Apply this to:
+
+- `packages/app/src/pages/session.tsx:325`
+- `packages/app/src/pages/session.tsx:336`
+- `packages/app/src/pages/session.tsx:477`
+- `packages/app/src/pages/session.tsx:869`
+- `packages/app/src/pages/session.tsx:963`
+- `packages/app/src/pages/session/message-timeline.tsx:149`
+- `packages/app/src/context/file.tsx:100`
+
+Rules:
+
+- If the desired behavior is "new identity, fresh local state," key the owner subtree
+- Keep state local to the keyed boundary so teardown and recreation handle the reset naturally
+
+### Prefer Event Handlers And Actions
+
+Use direct handlers, store actions, and async command functions when work happens because a user clicked, selected, reloaded, or navigated.
+
+Do this when an effect is just watching for a flag change, command token, or event-bus signal to trigger imperative logic.
+
+Apply this to:
+
+- `packages/app/src/pages/layout.tsx:484`
+- `packages/app/src/pages/layout.tsx:652`
+- `packages/app/src/pages/layout.tsx:776`
+- `packages/app/src/pages/layout.tsx:1489`
+- `packages/app/src/pages/layout.tsx:1519`
+- `packages/app/src/components/file-tree.tsx:328`
+- `packages/app/src/pages/session/terminal-panel.tsx:55`
+- `packages/app/src/context/global-sync.tsx:148`
+- Duplicated trigger sets in:
+ - `packages/app/src/pages/session/review-tab.tsx:122`
+ - `packages/app/src/pages/session/review-tab.tsx:130`
+ - `packages/app/src/pages/session/review-tab.tsx:138`
+ - `packages/app/src/pages/session/file-tabs.tsx:367`
+ - `packages/app/src/pages/session/file-tabs.tsx:378`
+ - `packages/app/src/pages/session/file-tabs.tsx:389`
+ - `packages/app/src/pages/session/use-session-hash-scroll.ts:144`
+ - `packages/app/src/pages/session/use-session-hash-scroll.ts:149`
+ - `packages/app/src/pages/session/use-session-hash-scroll.ts:167`
+
+Rules:
+
+- If the trigger is user intent, call the action at the source of that intent
+- If the same imperative work is triggered from multiple places, extract one function and call it directly
+
+### Prefer `onMount` And `onCleanup`
+
+Use `onMount` and `onCleanup` for lifecycle-only setup and teardown.
+
+This is the right fit for subscriptions, one-time wiring, timers, and imperative integration that should not rerun for ordinary reactive changes.
+
+Use this when:
+
+- Setup should happen once per owner lifecycle
+- Cleanup should always pair with teardown
+- The work is not conceptually derived state
+
+### Keep `createEffect` When It Is A Real Bridge
+
+Keep `createEffect` when it synchronizes reactive data to an external imperative sink.
+
+Examples that should remain, though they may be narrowed or split:
+
+- DOM/editor sync in `packages/app/src/components/prompt-input.tsx:690`
+- Scroll sync in `packages/app/src/pages/session.tsx:685`
+- Scroll/hash sync in `packages/app/src/pages/session/use-session-hash-scroll.ts:149`
+- External sync in:
+ - `packages/app/src/context/language.tsx:207`
+ - `packages/app/src/context/settings.tsx:110`
+ - `packages/app/src/context/sdk.tsx:26`
+- Polling in:
+ - `packages/app/src/components/status-popover.tsx:59`
+ - `packages/app/src/components/dialog-select-server.tsx:273`
+
+Rules:
+
+- Keep the effect single-purpose
+- Make dependencies explicit and narrow
+- Avoid writing back into the same reactive graph unless absolutely required
+
+## Implementation Plan
+
+### Phase 0: Classification Pass
+
+Before changing code, tag each targeted effect as one of: derive, reset, event, lifecycle, or external bridge.
+
+Acceptance criteria:
+
+- Every targeted effect in this spec is tagged with a replacement strategy before refactoring starts
+- Shared helpers to be introduced are identified up front to avoid repeating patterns
+
+### Phase 1: Derived-State Cleanup
+
+Tackle highest-value, lowest-risk derived-state cleanup first.
+
+Priority items:
+
+- Normalize tabs at write boundaries and remove `packages/app/src/pages/session.tsx:141`
+- Stop syncing `workspaceOrder` in `packages/app/src/pages/layout.tsx:557`
+- Make prompt slash filtering reactive so `packages/app/src/components/prompt-input.tsx:652` can be removed
+- Replace other obvious derived-state effects in terminal and session header
+
+Acceptance criteria:
+
+- No behavior change in tab ordering, prompt filtering, terminal display, or header state
+- Targeted derived-state effects are deleted, not just moved
+
+### Phase 2: Keyed Reset Cleanup
+
+Replace reset-on-key effects with keyed ownership boundaries.
+
+Priority items:
+
+- Key session-scoped UI and state by `sessionKey`
+- Key file-scoped state by `scope()`
+- Remove manual clear-and-reseed effects in session and file context
+
+Acceptance criteria:
+
+- Switching session or file scope recreates the intended local state cleanly
+- No stale state leaks across session or scope changes
+- Target reset effects are deleted
+
+### Phase 3: Event-Driven Work Extraction
+
+Move event-driven work out of reactive effects.
+
+Priority items:
+
+- Replace `globalStore.reload` effect dispatching with direct calls
+- Split mixed-responsibility effect in `packages/app/src/pages/layout.tsx:1489`
+- Collapse duplicated imperative trigger triplets into single functions
+- Move file-tree and terminal-panel imperative work to explicit handlers
+
+Acceptance criteria:
+
+- User-triggered behavior still fires exactly once per intended action
+- No effect remains whose only job is to notice a command-like state and trigger an imperative function
+
+### Phase 4: Context Ownership Cleanup
+
+Remove mirrored child-store hydration patterns.
+
+Priority items:
+
+- Remove child-store hydration mirrors in `packages/app/src/context/global-sync/child-store.ts:184`, `:190`, `:193`
+- Simplify mirror logic in `packages/app/src/context/global-sync.tsx:130`, `:138`
+- Revisit `packages/app/src/context/layout.tsx:424` if it still mirrors instead of deriving
+
+Acceptance criteria:
+
+- There is one clear source of truth for each synced value
+- Child stores no longer need effect-based hydration to stay consistent
+- Initialization and updates both work without manual mirror effects
+
+### Phase 5: Cleanup And Keeper Review
+
+Clean up remaining targeted hotspots and narrow the effects that should stay.
+
+Acceptance criteria:
+
+- Remaining `createEffect` calls in touched files are all true bridges or clearly justified lifecycle sync
+- Mixed-responsibility effects are split into smaller units where still needed
+
+## Detailed Work Items By Area
+
+### 1. Normalize Tab State
+
+Files:
+
+- `packages/app/src/pages/session.tsx:141`
+
+Work:
+
+- Move tab normalization into the functions that create, load, or update tab state
+- Make readers consume already-normalized tab data
+- Remove the effect that rewrites derived tab state after the fact
+
+Rationale:
+
+- Tabs should become valid when written, not be repaired later
+- This removes a feedback loop and makes state easier to trust
+
+Acceptance criteria:
+
+- The effect at `packages/app/src/pages/session.tsx:141` is removed
+- Newly created and restored tabs are normalized before they enter local state
+- Tab rendering still matches current behavior for valid and edge-case inputs
+
+### 2. Key Session-Owned State
+
+Files:
+
+- `packages/app/src/pages/session.tsx:325`
+- `packages/app/src/pages/session.tsx:336`
+- `packages/app/src/pages/session.tsx:477`
+- `packages/app/src/pages/session.tsx:869`
+- `packages/app/src/pages/session.tsx:963`
+- `packages/app/src/pages/session/message-timeline.tsx:149`
+
+Work:
+
+- Identify state that should reset when `sessionKey` changes
+- Move that state under a keyed subtree or keyed owner boundary
+- Remove effects that watch `sessionKey` just to clear local state, refs, or temporary UI flags
+
+Rationale:
+
+- Session identity already defines the lifetime of this UI state
+- Keyed ownership makes reset behavior automatic and easier to reason about
+
+Acceptance criteria:
+
+- The targeted reset effects are removed
+- Changing sessions resets only the intended session-local state
+- Scroll and editor state that should persist are not accidentally reset
+
+### 3. Derive Workspace Order
+
+Files:
+
+- `packages/app/src/pages/layout.tsx:557`
+
+Work:
+
+- Stop writing `workspaceOrder` from live workspace data in an effect
+- Represent user overrides separately from live workspace data
+- Compute effective order from current data plus overrides with a memo or pure helper
+
+Rationale:
+
+- Persisted user intent and live source data should not mirror each other through an effect
+- A computed effective order avoids drift and racey resync behavior
+
+Acceptance criteria:
+
+- The effect at `packages/app/src/pages/layout.tsx:557` is removed
+- Workspace order updates correctly when workspaces appear, disappear, or are reordered by the user
+- User overrides persist without requiring a sync-back effect
+
+### 4. Remove Child-Store Mirrors
+
+Files:
+
+- `packages/app/src/context/global-sync.tsx:130`
+- `packages/app/src/context/global-sync.tsx:138`
+- `packages/app/src/context/global-sync.tsx:148`
+- `packages/app/src/context/global-sync/child-store.ts:184`
+- `packages/app/src/context/global-sync/child-store.ts:190`
+- `packages/app/src/context/global-sync/child-store.ts:193`
+- `packages/app/src/context/layout.tsx:424`
+
+Work:
+
+- Trace the actual ownership of global and child store values
+- Replace hydration and mirror effects with explicit initialization and direct updates
+- Remove the `globalStore.reload` event-bus pattern and call the needed reload paths directly
+
+Rationale:
+
+- Mirrors make it hard to tell which state is authoritative
+- Event-bus style state toggles hide control flow and create accidental reruns
+
+Acceptance criteria:
+
+- Child store hydration no longer depends on effect-based copying
+- Reload work can be followed from the event source to the handler without a reactive relay
+- State remains correct on first load, child creation, and subsequent updates
+
+### 5. Key File-Scoped State
+
+Files:
+
+- `packages/app/src/context/file.tsx:100`
+
+Work:
+
+- Move file-scoped local state under a boundary keyed by `scope()`
+- Remove any effect that watches `scope()` only to reset file-local state
+
+Rationale:
+
+- File scope changes are identity changes
+- Keyed ownership gives a cleaner reset than manual clear logic
+
+Acceptance criteria:
+
+- The effect at `packages/app/src/context/file.tsx:100` is removed
+- Switching scopes resets only scope-local state
+- No previous-scope data appears after a scope change
+
+### 6. Split Layout Side Effects
+
+Files:
+
+- `packages/app/src/pages/layout.tsx:1489`
+- Related event-driven effects near `packages/app/src/pages/layout.tsx:484`, `:652`, `:776`, `:1519`
+
+Work:
+
+- Break the mixed-responsibility effect at `:1489` into direct actions and smaller bridge effects only where required
+- Move user-triggered branches into the actual command or handler that causes them
+- Remove any branch that only exists because one effect is handling unrelated concerns
+
+Rationale:
+
+- Mixed effects hide cause and make reruns hard to predict
+- Smaller units reduce accidental coupling and make future cleanup safer
+
+Acceptance criteria:
+
+- The effect at `packages/app/src/pages/layout.tsx:1489` no longer mixes unrelated responsibilities
+- Event-driven branches execute from direct handlers
+- Remaining effects in this area each have one clear external sync purpose
+
+### 7. Remove Duplicate Triggers
+
+Files:
+
+- `packages/app/src/pages/session/review-tab.tsx:122`
+- `packages/app/src/pages/session/review-tab.tsx:130`
+- `packages/app/src/pages/session/review-tab.tsx:138`
+- `packages/app/src/pages/session/file-tabs.tsx:367`
+- `packages/app/src/pages/session/file-tabs.tsx:378`
+- `packages/app/src/pages/session/file-tabs.tsx:389`
+- `packages/app/src/pages/session/use-session-hash-scroll.ts:144`
+- `packages/app/src/pages/session/use-session-hash-scroll.ts:149`
+- `packages/app/src/pages/session/use-session-hash-scroll.ts:167`
+
+Work:
+
+- Extract one explicit imperative function per behavior
+- Call that function from each source event instead of replicating the same effect pattern multiple times
+- Preserve the scroll-sync effect that is truly syncing with the DOM, but remove duplicate trigger scaffolding around it
+
+Rationale:
+
+- Duplicate triggers make it easy to miss a case or fire twice
+- One named action is easier to test and reason about
+
+Acceptance criteria:
+
+- Repeated imperative effect triplets are collapsed into shared functions
+- Scroll behavior still works, including hash-based navigation
+- No duplicate firing is introduced
+
+### 8. Make Prompt Filtering Reactive
+
+Files:
+
+- `packages/app/src/components/prompt-input.tsx:652`
+- Keep `packages/app/src/components/prompt-input.tsx:690` as needed
+
+Work:
+
+- Convert slash filtering into a pure reactive derivation from the current input and candidate command list
+- Keep only the editor or DOM bridge effect if it is still needed for imperative syncing
+
+Rationale:
+
+- Filtering is classic derived state
+- It should not need an effect if it can be computed from current inputs
+
+Acceptance criteria:
+
+- The effect at `packages/app/src/components/prompt-input.tsx:652` is removed
+- Filtered slash-command results update correctly as the input changes
+- The editor sync effect at `:690` still behaves correctly
+
+### 9. Clean Up Smaller Derived-State Cases
+
+Files:
+
+- `packages/app/src/components/terminal.tsx:261`
+- `packages/app/src/components/session/session-header.tsx:309`
+
+Work:
+
+- Replace effect-written local state with memos or inline derivation
+- Remove intermediate setters when the value can be computed directly
+
+Rationale:
+
+- These are low-risk wins that reinforce the same pattern
+- They also help keep follow-up cleanup consistent
+
+Acceptance criteria:
+
+- Targeted effects are removed
+- UI output remains unchanged under the same inputs
+
+## Verification And Regression Checks
+
+Run focused checks after each phase, not only at the end.
+
+### Suggested Verification
+
+- Switch between sessions rapidly and confirm local session UI resets only where intended
+- Open, close, and reorder tabs and confirm order and normalization remain stable
+- Change workspaces, reload workspace data, and verify effective ordering is correct
+- Change file scope and confirm stale file state does not bleed across scopes
+- Trigger layout actions that previously depended on effects and confirm they still fire once
+- Use slash commands in the prompt and verify filtering updates as you type
+- Test review tab, file tab, and hash-scroll flows for duplicate or missing triggers
+- Verify global sync initialization, reload, and child-store creation paths
+
+### Regression Checks
+
+- No accidental infinite reruns
+- No double-firing network or command actions
+- No lost cleanup for listeners, timers, or scroll handlers
+- No preserved stale state after identity changes
+- No removed effect that was actually bridging to DOM or an external API
+
+If available, add or update tests around pure helpers introduced during this cleanup.
+
+Favor tests for derived ordering, normalization, and action extraction, since those are easiest to lock down.
+
+## Definition Of Done
+
+This work is done when all of the following are true:
+
+- The highest-leverage targets in this spec are implemented
+- Each removed effect has been replaced by a clearer pattern: memo, keyed boundary, direct action, or lifecycle hook
+- The "should remain" effects still exist only where they serve a real external sync purpose
+- Touched files have fewer mixed-responsibility effects and clearer ownership of state
+- Manual verification covers session switching, file scope changes, workspace ordering, prompt filtering, and reload flows
+- No behavior regressions are found in the targeted areas
+
+A reduced raw `createEffect` count is helpful, but it is not the main success metric.
+
+The main success metric is clearer ownership and fewer effect-driven state repairs.
+
+## Risks And Rollout Notes
+
+Main risks:
+
+- Keyed remounts can reset too much if state boundaries are drawn too high
+- Store mirror removal can break initialization order if ownership is not mapped first
+- Moving event work out of effects can accidentally skip triggers that were previously implicit
+
+Rollout notes:
+
+- Land in small phases, with each phase keeping the app behaviorally stable
+- Prefer isolated PRs by phase or by file cluster, especially for context-store changes
+- Review each remaining effect in touched files and leave it only if it clearly bridges to something external
diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx
index 3840f18ed..930832fb6 100644
--- a/packages/app/src/components/file-tree.tsx
+++ b/packages/app/src/components/file-tree.tsx
@@ -325,12 +325,6 @@ export default function FileTree(props: {
),
)
- createEffect(() => {
- const dir = file.tree.state(props.path)
- if (!shouldListExpanded({ level, dir })) return
- void file.tree.list(props.path)
- })
-
const nodes = createMemo(() => {
const nodes = file.tree.children(props.path)
const current = filter()
diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx
index d16791a61..b99ac9373 100644
--- a/packages/app/src/components/prompt-input.tsx
+++ b/packages/app/src/components/prompt-input.tsx
@@ -591,7 +591,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
setActive: setSlashActive,
onInput: slashOnInput,
onKeyDown: slashOnKeyDown,
- refetch: slashRefetch,
} = useFilteredList<SlashCommand>({
items: slashCommands,
key: (x) => x?.id,
@@ -648,14 +647,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
}
- createEffect(
- on(
- () => sync.data.command,
- () => slashRefetch(),
- { defer: true },
- ),
- )
-
// Auto-scroll active command into view when navigating with keyboard
createEffect(() => {
const activeId = slashActive()
diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx
index 27b1b9cc0..94edb8258 100644
--- a/packages/app/src/components/session/session-header.tsx
+++ b/packages/app/src/components/session/session-header.tsx
@@ -306,11 +306,10 @@ export function SessionHeader() {
const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0])
const opening = createMemo(() => openRequest.app !== undefined)
- createEffect(() => {
- const value = prefs.app
- if (options().some((o) => o.id === value)) return
- setPrefs("app", options()[0]?.id ?? "finder")
- })
+ const selectApp = (app: OpenApp) => {
+ if (!options().some((item) => item.id === app)) return
+ setPrefs("app", app)
+ }
const openDir = (app: OpenApp) => {
if (opening() || !canOpen() || !platform.openPath) return
@@ -458,7 +457,7 @@ export function SessionHeader() {
value={current().id}
onChange={(value) => {
if (!OPEN_APPS.includes(value as OpenApp)) return
- setPrefs("app", value as OpenApp)
+ selectApp(value as OpenApp)
}}
>
<For each={options()}>
diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx
index ce811463f..601ace28d 100644
--- a/packages/app/src/components/terminal.tsx
+++ b/packages/app/src/components/terminal.tsx
@@ -1,7 +1,7 @@
import { type HexColor, resolveThemeVariant, useTheme, withAlpha } from "@opencode-ai/ui/theme"
import { showToast } from "@opencode-ai/ui/toast"
import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web"
-import { type ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js"
+import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js"
import { SerializeAddon } from "@/addons/serialize"
import { matchKeybind, parseKeybind } from "@/context/command"
import { useLanguage } from "@/context/language"
@@ -219,7 +219,7 @@ export const Terminal = (props: TerminalProps) => {
}
}
- const [terminalColors, setTerminalColors] = createSignal<TerminalColors>(getTerminalColors())
+ const terminalColors = createMemo(getTerminalColors)
const scheduleFit = () => {
if (disposed) return
@@ -259,8 +259,7 @@ export const Terminal = (props: TerminalProps) => {
}
createEffect(() => {
- const colors = getTerminalColors()
- setTerminalColors(colors)
+ const colors = terminalColors()
if (!term) return
setOptionIfSupported(term, "theme", colors)
})
diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx
index 112bc9240..574929115 100644
--- a/packages/app/src/context/global-sync.tsx
+++ b/packages/app/src/context/global-sync.tsx
@@ -11,7 +11,6 @@ import { showToast } from "@opencode-ai/ui/toast"
import { getFilename } from "@opencode-ai/util/path"
import {
createContext,
- createEffect,
getOwner,
Match,
onCleanup,
@@ -35,7 +34,6 @@ import { trimSessions } from "./global-sync/session-trim"
import type { ProjectMeta } from "./global-sync/types"
import { SESSION_RECENT_LIMIT } from "./global-sync/types"
import { sanitizeProject } from "./global-sync/utils"
-import { usePlatform } from "./platform"
import { formatServerError } from "@/utils/server-errors"
type GlobalStore = {
@@ -54,7 +52,6 @@ type GlobalStore = {
function createGlobalSync() {
const globalSDK = useGlobalSDK()
- const platform = usePlatform()
const language = useLanguage()
const owner = getOwner()
if (!owner) throw new Error("GlobalSync must be created within owner")
@@ -64,7 +61,7 @@ function createGlobalSync() {
const sessionLoads = new Map<string, Promise<void>>()
const sessionMeta = new Map<string, { limit: number }>()
- const [projectCache, setProjectCache, , projectCacheReady] = persisted(
+ const [projectCache, setProjectCache, projectInit] = persisted(
Persist.global("globalSync.project", ["globalSync.project.v1"]),
createStore({ value: [] as Project[] }),
)
@@ -80,6 +77,57 @@ function createGlobalSync() {
reload: undefined,
})
+ let active = true
+ let projectWritten = false
+
+ onCleanup(() => {
+ active = false
+ })
+
+ const cacheProjects = () => {
+ setProjectCache(
+ "value",
+ untrack(() => globalStore.project.map(sanitizeProject)),
+ )
+ }
+
+ const setProjects = (next: Project[] | ((draft: Project[]) => void)) => {
+ projectWritten = true
+ if (typeof next === "function") {
+ setGlobalStore("project", produce(next))
+ cacheProjects()
+ return
+ }
+ setGlobalStore("project", next)
+ cacheProjects()
+ }
+
+ const setBootStore = ((...input: unknown[]) => {
+ if (input[0] === "project" && Array.isArray(input[1])) {
+ setProjects(input[1] as Project[])
+ return input[1]
+ }
+ return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
+ }) as typeof setGlobalStore
+
+ const set = ((...input: unknown[]) => {
+ if (input[0] === "project" && (Array.isArray(input[1]) || typeof input[1] === "function")) {
+ setProjects(input[1] as Project[] | ((draft: Project[]) => void))
+ return input[1]
+ }
+ return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
+ }) as typeof setGlobalStore
+
+ if (projectInit instanceof Promise) {
+ void projectInit.then(() => {
+ if (!active) return
+ if (projectWritten) return
+ const cached = projectCache.value
+ if (cached.length === 0) return
+ setGlobalStore("project", cached)
+ })
+ }
+
const setSessionTodo = (sessionID: string, todos: Todo[] | undefined) => {
if (!sessionID) return
if (!todos) {
@@ -127,30 +175,6 @@ function createGlobalSync() {
return sdk
}
- createEffect(() => {
- if (!projectCacheReady()) return
- if (globalStore.project.length !== 0) return
- const cached = projectCache.value
- if (cached.length === 0) return
- setGlobalStore("project", cached)
- })
-
- createEffect(() => {
- if (!projectCacheReady()) return
- const projects = globalStore.project
- if (projects.length === 0) {
- const cachedLength = untrack(() => projectCache.value.length)
- if (cachedLength !== 0) return
- }
- setProjectCache("value", projects.map(sanitizeProject))
- })
-
- createEffect(() => {
- if (globalStore.reload !== "complete") return
- setGlobalStore("reload", undefined)
- queue.refresh()
- })
-
async function loadSessions(directory: string) {
const pending = sessionLoads.get(directory)
if (pending) return pending
@@ -259,13 +283,7 @@ function createGlobalSync() {
event,
project: globalStore.project,
refresh: queue.refresh,
- setGlobalProject(next) {
- if (typeof next === "function") {
- setGlobalStore("project", produce(next))
- return
- }
- setGlobalStore("project", next)
- },
+ setGlobalProject: setProjects,
})
if (event.type === "server.connected" || event.type === "global.disposed") {
for (const directory of Object.keys(children.children)) {
@@ -316,7 +334,7 @@ function createGlobalSync() {
unknownError: language.t("error.chain.unknown"),
invalidConfigurationError: language.t("error.server.invalidConfiguration"),
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
- setGlobalStore,
+ setGlobalStore: setBootStore,
})
}
@@ -340,7 +358,9 @@ function createGlobalSync() {
.update({ config })
.then(bootstrap)
.then(() => {
- setGlobalStore("reload", "complete")
+ queue.refresh()
+ setGlobalStore("reload", undefined)
+ queue.refresh()
})
.catch((error) => {
setGlobalStore("reload", undefined)
@@ -350,7 +370,7 @@ function createGlobalSync() {
return {
data: globalStore,
- set: setGlobalStore,
+ set,
get ready() {
return globalStore.ready
},
diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts
index 2fe5b7830..e2ada244f 100644
--- a/packages/app/src/context/global-sync/child-store.ts
+++ b/packages/app/src/context/global-sync/child-store.ts
@@ -1,4 +1,4 @@
-import { createRoot, createEffect, getOwner, onCleanup, runWithOwner, type Accessor, type Owner } from "solid-js"
+import { createRoot, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js"
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist"
import type { VcsInfo } from "@opencode-ai/sdk/v2/client"
@@ -131,8 +131,7 @@ export function createChildStoreManager(input: {
)
if (!vcs) throw new Error("Failed to create persisted cache")
const vcsStore = vcs[0]
- const vcsReady = vcs[3]
- vcsCache.set(directory, { store: vcsStore, setStore: vcs[1], ready: vcsReady })
+ vcsCache.set(directory, { store: vcsStore, setStore: vcs[1], ready: vcs[3] })
const meta = runWithOwner(input.owner, () =>
persisted(
@@ -154,10 +153,12 @@ export function createChildStoreManager(input: {
const init = () =>
createRoot((dispose) => {
+ const initialMeta = meta[0].value
+ const initialIcon = icon[0].value
const child = createStore<State>({
project: "",
- projectMeta: meta[0].value,
- icon: icon[0].value,
+ projectMeta: initialMeta,
+ icon: initialIcon,
provider: { all: [], connected: [], default: {} },
config: {},
path: { state: "", config: "", worktree: "", directory: "", home: "" },
@@ -181,16 +182,27 @@ export function createChildStoreManager(input: {
children[directory] = child
disposers.set(directory, dispose)
- createEffect(() => {
- if (!vcsReady()) return
+ const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
+ if (!(init instanceof Promise)) return
+ void init.then(() => {
+ if (children[directory] !== child) return
+ run()
+ })
+ }
+
+ onPersistedInit(vcs[2], () => {
const cached = vcsStore.value
if (!cached?.branch) return
child[1]("vcs", (value) => value ?? cached)
})
- createEffect(() => {
+
+ onPersistedInit(meta[2], () => {
+ if (child[0].projectMeta !== initialMeta) return
child[1]("projectMeta", meta[0].value)
})
- createEffect(() => {
+
+ onPersistedInit(icon[2], () => {
+ if (child[0].icon !== initialIcon) return
child[1]("icon", icon[0].value)
})
})
diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx
index 71f0294e7..5199e5a26 100644
--- a/packages/app/src/context/layout.tsx
+++ b/packages/app/src/context/layout.tsx
@@ -7,8 +7,10 @@ import { useServer } from "./server"
import { usePlatform } from "./platform"
import { Project } from "@opencode-ai/sdk/v2"
import { Persist, persisted, removePersisted } from "@/utils/persist"
+import { decode64 } from "@/utils/base64"
import { same } from "@/utils/same"
import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
+import { createPathHelpers } from "./file/path"
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
const DEFAULT_PANEL_WIDTH = 344
@@ -96,6 +98,38 @@ function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string):
return { all, active: tab }
}
+const sessionPath = (key: string) => {
+ const dir = key.split("/")[0]
+ if (!dir) return
+ const root = decode64(dir)
+ if (!root) return
+ return createPathHelpers(() => root)
+}
+
+const normalizeSessionTab = (path: ReturnType<typeof createPathHelpers> | undefined, tab: string) => {
+ if (!tab.startsWith("file://")) return tab
+ if (!path) return tab
+ return path.tab(tab)
+}
+
+const normalizeSessionTabList = (path: ReturnType<typeof createPathHelpers> | undefined, all: string[]) => {
+ const seen = new Set<string>()
+ return all.flatMap((tab) => {
+ const value = normalizeSessionTab(path, tab)
+ if (seen.has(value)) return []
+ seen.add(value)
+ return [value]
+ })
+}
+
+const normalizeStoredSessionTabs = (key: string, tabs: SessionTabs) => {
+ const path = sessionPath(key)
+ return {
+ all: normalizeSessionTabList(path, tabs.all),
+ active: tabs.active ? normalizeSessionTab(path, tabs.active) : tabs.active,
+ }
+}
+
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
name: "Layout",
init: () => {
@@ -147,12 +181,46 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}
})()
- if (migratedSidebar === sidebar && migratedReview === review && migratedFileTree === fileTree) return value
+ const sessionTabs = value.sessionTabs
+ const migratedSessionTabs = (() => {
+ if (!isRecord(sessionTabs)) return sessionTabs
+
+ let changed = false
+ const next = Object.fromEntries(
+ Object.entries(sessionTabs).map(([key, tabs]) => {
+ if (!isRecord(tabs) || !Array.isArray(tabs.all)) return [key, tabs]
+
+ const current = {
+ all: tabs.all.filter((tab): tab is string => typeof tab === "string"),
+ active: typeof tabs.active === "string" ? tabs.active : undefined,
+ }
+ const normalized = normalizeStoredSessionTabs(key, current)
+ if (current.all.length !== tabs.all.length) changed = true
+ if (!same(current.all, normalized.all) || current.active !== normalized.active) changed = true
+ if (tabs.active !== undefined && typeof tabs.active !== "string") changed = true
+ return [key, normalized]
+ }),
+ )
+
+ if (!changed) return sessionTabs
+ return next
+ })()
+
+ if (
+ migratedSidebar === sidebar &&
+ migratedReview === review &&
+ migratedFileTree === fileTree &&
+ migratedSessionTabs === sessionTabs
+ ) {
+ return value
+ }
+
return {
...value,
sidebar: migratedSidebar,
review: migratedReview,
fileTree: migratedFileTree,
+ sessionTabs: migratedSessionTabs,
}
}
@@ -745,22 +813,26 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
tabs(sessionKey: string | Accessor<string>) {
const key = createSessionKeyReader(sessionKey, ensureKey)
+ const path = createMemo(() => sessionPath(key()))
const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] })
+ const normalize = (tab: string) => normalizeSessionTab(path(), tab)
+ const normalizeAll = (all: string[]) => normalizeSessionTabList(path(), all)
return {
tabs,
active: createMemo(() => tabs().active),
all: createMemo(() => tabs().all.filter((tab) => tab !== "review")),
setActive(tab: string | undefined) {
const session = key()
+ const next = tab ? normalize(tab) : tab
if (!store.sessionTabs[session]) {
- setStore("sessionTabs", session, { all: [], active: tab })
+ setStore("sessionTabs", session, { all: [], active: next })
} else {
- setStore("sessionTabs", session, "active", tab)
+ setStore("sessionTabs", session, "active", next)
}
},
setAll(all: string[]) {
const session = key()
- const next = all.filter((tab) => tab !== "review")
+ const next = normalizeAll(all).filter((tab) => tab !== "review")
if (!store.sessionTabs[session]) {
setStore("sessionTabs", session, { all: next, active: undefined })
} else {
@@ -769,7 +841,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
async open(tab: string) {
const session = key()
- const next = nextSessionTabsForOpen(store.sessionTabs[session], tab)
+ const next = nextSessionTabsForOpen(store.sessionTabs[session], normalize(tab))
setStore("sessionTabs", session, next)
},
close(tab: string) {
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index cb194052d..f93d1f069 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -59,11 +59,11 @@ import { useLanguage, type Locale } from "@/context/language"
import {
childMapByParent,
displayName,
+ effectiveWorkspaceOrder,
errorMessage,
getDraggableId,
latestRootSession,
sortedRootSessions,
- syncWorkspaceOrder,
workspaceKey,
} from "./layout/helpers"
import { collectOpenProjectDeepLinks, deepLinkEvent, drainPendingDeepLinks } from "./layout/deep-links"
@@ -483,21 +483,6 @@ export default function Layout(props: ParentProps) {
createEffect(
on(
- () => ({ ready: pageReady(), project: currentProject() }),
- (value) => {
- if (!value.ready) return
- const project = value.project
- if (!project) return
- const last = server.projects.last()
- if (last === project.worktree) return
- server.projects.touch(project.worktree)
- },
- { defer: true },
- ),
- )
-
- createEffect(
- on(
() => ({ ready: pageReady(), layoutReady: layoutReady(), dir: params.dir, list: layout.projects.list() }),
(value) => {
if (!value.ready) return
@@ -554,29 +539,17 @@ export default function Layout(props: ParentProps) {
return layout.sidebar.workspaces(project.worktree)()
})
- createEffect(() => {
- if (!pageReady()) return
- if (!layoutReady()) return
+ const visibleSessionDirs = createMemo(() => {
const project = currentProject()
- if (!project) return
-
- const local = project.worktree
- const dirs = [project.worktree, ...(project.sandboxes ?? [])]
- const existing = store.workspaceOrder[project.worktree]
- const merged = syncWorkspaceOrder(local, dirs, existing)
- if (!existing) {
- setStore("workspaceOrder", project.worktree, merged)
- return
- }
-
- if (merged.length !== existing.length) {
- setStore("workspaceOrder", project.worktree, merged)
- return
- }
-
- if (merged.some((d, i) => d !== existing[i])) {
- setStore("workspaceOrder", project.worktree, merged)
- }
+ if (!project) return [] as string[]
+ if (!workspaceSetting()) return [project.worktree]
+
+ const activeDir = currentDir()
+ return workspaceIds(project).filter((directory) => {
+ const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree
+ const active = directory === activeDir
+ return expanded || active
+ })
})
createEffect(() => {
@@ -593,25 +566,17 @@ export default function Layout(props: ParentProps) {
})
const currentSessions = createMemo(() => {
- const project = currentProject()
- if (!project) return [] as Session[]
const now = Date.now()
- if (workspaceSetting()) {
- const dirs = workspaceIds(project)
- const activeDir = currentDir()
- const result: Session[] = []
- for (const dir of dirs) {
- const expanded = store.workspaceExpanded[dir] ?? dir === project.worktree
- const active = dir === activeDir
- if (!expanded && !active) continue
- const [dirStore] = globalSync.child(dir, { bootstrap: true })
- const dirSessions = sortedRootSessions(dirStore, now)
- result.push(...dirSessions)
- }
- return result
+ const dirs = visibleSessionDirs()
+ if (dirs.length === 0) return [] as Session[]
+
+ const result: Session[] = []
+ for (const dir of dirs) {
+ const [dirStore] = globalSync.child(dir, { bootstrap: true })
+ const dirSessions = sortedRootSessions(dirStore, now)
+ result.push(...dirSessions)
}
- const [projectStore] = globalSync.child(project.worktree)
- return sortedRootSessions(projectStore, now)
+ return result
})
type PrefetchQueue = {
@@ -826,7 +791,6 @@ export default function Layout(props: ParentProps) {
}
navigateToSession(session)
- queueMicrotask(() => scrollToSession(session.id, `${session.directory}:${session.id}`))
}
function navigateSessionByUnseen(offset: number) {
@@ -861,7 +825,6 @@ export default function Layout(props: ParentProps) {
}
navigateToSession(session)
- queueMicrotask(() => scrollToSession(session.id, `${session.directory}:${session.id}`))
return
}
}
@@ -1094,34 +1057,90 @@ export default function Layout(props: ParentProps) {
return meta?.worktree ?? directory
}
+ function activeProjectRoot(directory: string) {
+ return currentProject()?.worktree ?? projectRoot(directory)
+ }
+
+ function touchProjectRoute() {
+ const root = currentProject()?.worktree
+ if (!root) return
+ if (server.projects.last() !== root) server.projects.touch(root)
+ return root
+ }
+
+ function rememberSessionRoute(directory: string, id: string, root = activeProjectRoot(directory)) {
+ setStore("lastProjectSession", root, { directory, id, at: Date.now() })
+ return root
+ }
+
+ function clearLastProjectSession(root: string) {
+ if (!store.lastProjectSession[root]) return
+ setStore(
+ "lastProjectSession",
+ produce((draft) => {
+ delete draft[root]
+ }),
+ )
+ }
+
+ function syncSessionRoute(directory: string, id: string, root = activeProjectRoot(directory)) {
+ rememberSessionRoute(directory, id, root)
+ notification.session.markViewed(id)
+ const expanded = untrack(() => store.workspaceExpanded[directory])
+ if (expanded === false) {
+ setStore("workspaceExpanded", directory, true)
+ }
+ requestAnimationFrame(() => scrollToSession(id, `${directory}:${id}`))
+ return root
+ }
+
async function navigateToProject(directory: string | undefined) {
if (!directory) return
const root = projectRoot(directory)
server.projects.touch(root)
const project = layout.projects.list().find((item) => item.worktree === root)
- const dirs = Array.from(new Set([root, ...(store.workspaceOrder[root] ?? []), ...(project?.sandboxes ?? [])]))
+ let dirs = project
+ ? effectiveWorkspaceOrder(root, [root, ...(project.sandboxes ?? [])], store.workspaceOrder[root])
+ : [root]
+ const canOpen = (value: string | undefined) => {
+ if (!value) return false
+ return dirs.some((item) => workspaceKey(item) === workspaceKey(value))
+ }
+ const refreshDirs = async (target?: string) => {
+ if (!target || target === root || canOpen(target)) return canOpen(target)
+ const listed = await globalSDK.client.worktree
+ .list({ directory: root })
+ .then((x) => x.data ?? [])
+ .catch(() => [] as string[])
+ dirs = effectiveWorkspaceOrder(root, [root, ...listed], store.workspaceOrder[root])
+ return canOpen(target)
+ }
const openSession = async (target: { directory: string; id: string }) => {
+ if (!canOpen(target.directory)) return false
const resolved = await globalSDK.client.session
.get({ sessionID: target.id })
.then((x) => x.data)
.catch(() => undefined)
- const next = resolved?.directory ? resolved : target
- setStore("lastProjectSession", root, { directory: next.directory, id: next.id, at: Date.now() })
- navigateWithSidebarReset(`/${base64Encode(next.directory)}/session/${next.id}`)
+ if (!resolved?.directory) return false
+ if (!canOpen(resolved.directory)) return false
+ setStore("lastProjectSession", root, { directory: resolved.directory, id: resolved.id, at: Date.now() })
+ navigateWithSidebarReset(`/${base64Encode(resolved.directory)}/session/${resolved.id}`)
+ return true
}
const projectSession = store.lastProjectSession[root]
if (projectSession?.id) {
- await openSession(projectSession)
- return
+ await refreshDirs(projectSession.directory)
+ const opened = await openSession(projectSession)
+ if (opened) return
+ clearLastProjectSession(root)
}
const latest = latestRootSession(
dirs.map((item) => globalSync.child(item, { bootstrap: false })[0]),
Date.now(),
)
- if (latest) {
- await openSession(latest)
+ if (latest && (await openSession(latest))) {
return
}
@@ -1137,8 +1156,7 @@ export default function Layout(props: ParentProps) {
),
Date.now(),
)
- if (fetched) {
- await openSession(fetched)
+ if (fetched && (await openSession(fetched))) {
return
}
@@ -1240,9 +1258,17 @@ export default function Layout(props: ParentProps) {
}
}
- const deleteWorkspace = async (root: string, directory: string) => {
+ const deleteWorkspace = async (root: string, directory: string, leaveDeletedWorkspace = false) => {
if (directory === root) return
+ const current = currentDir()
+ const currentKey = workspaceKey(current)
+ const deletedKey = workspaceKey(directory)
+ const shouldLeave = leaveDeletedWorkspace || (!!params.dir && currentKey === deletedKey)
+ if (!leaveDeletedWorkspace && shouldLeave) {
+ navigateWithSidebarReset(`/${base64Encode(root)}/session`)
+ }
+
setBusy(directory, true)
const result = await globalSDK.client.worktree
@@ -1260,6 +1286,10 @@ export default function Layout(props: ParentProps) {
if (!result) return
+ if (workspaceKey(store.lastProjectSession[root]?.directory ?? "") === workspaceKey(directory)) {
+ clearLastProjectSession(root)
+ }
+
globalSync.set(
"project",
produce((draft) => {
@@ -1273,8 +1303,18 @@ export default function Layout(props: ParentProps) {
layout.projects.close(directory)
layout.projects.open(root)
- if (params.dir && currentDir() === directory) {
- navigateToProject(root)
+ if (shouldLeave) return
+
+ const nextCurrent = currentDir()
+ const nextKey = workspaceKey(nextCurrent)
+ const project = layout.projects.list().find((item) => item.worktree === root)
+ const dirs = project
+ ? effectiveWorkspaceOrder(root, [root, ...(project.sandboxes ?? [])], store.workspaceOrder[root])
+ : [root]
+ const valid = dirs.some((item) => workspaceKey(item) === nextKey)
+
+ if (params.dir && projectRoot(nextCurrent) === root && !valid) {
+ navigateWithSidebarReset(`/${base64Encode(root)}/session`)
}
}
@@ -1377,8 +1417,12 @@ export default function Layout(props: ParentProps) {
})
const handleDelete = () => {
+ const leaveDeletedWorkspace = !!params.dir && workspaceKey(currentDir()) === workspaceKey(props.directory)
+ if (leaveDeletedWorkspace) {
+ navigateWithSidebarReset(`/${base64Encode(props.root)}/session`)
+ }
dialog.close()
- void deleteWorkspace(props.root, props.directory)
+ void deleteWorkspace(props.root, props.directory, leaveDeletedWorkspace)
}
const description = () => {
@@ -1486,26 +1530,42 @@ export default function Layout(props: ParentProps) {
)
}
+ const activeRoute = {
+ session: "",
+ sessionProject: "",
+ }
+
createEffect(
on(
- () => ({ ready: pageReady(), dir: params.dir, id: params.id }),
- (value) => {
- if (!value.ready) return
- const dir = value.dir
- const id = value.id
- if (!dir || !id) return
+ () => [pageReady(), params.dir, params.id, currentProject()?.worktree] as const,
+ ([ready, dir, id]) => {
+ if (!ready || !dir) {
+ activeRoute.session = ""
+ activeRoute.sessionProject = ""
+ return
+ }
+
const directory = decode64(dir)
if (!directory) return
- const at = Date.now()
- setStore("lastProjectSession", projectRoot(directory), { directory, id, at })
- notification.session.markViewed(id)
- const expanded = untrack(() => store.workspaceExpanded[directory])
- if (expanded === false) {
- setStore("workspaceExpanded", directory, true)
+
+ const root = touchProjectRoute() ?? activeProjectRoot(directory)
+
+ if (!id) {
+ activeRoute.session = ""
+ activeRoute.sessionProject = ""
+ return
+ }
+
+ const session = `${dir}/${id}`
+ if (session !== activeRoute.session) {
+ activeRoute.session = session
+ activeRoute.sessionProject = syncSessionRoute(directory, id, root)
+ return
}
- requestAnimationFrame(() => scrollToSession(id, `${directory}:${id}`))
+
+ if (root === activeRoute.sessionProject) return
+ activeRoute.sessionProject = rememberSessionRoute(directory, id, root)
},
- { defer: true },
),
)
@@ -1516,40 +1576,29 @@ export default function Layout(props: ParentProps) {
const loadedSessionDirs = new Set<string>()
- createEffect(() => {
- const project = currentProject()
- const workspaces = workspaceSetting()
- const next = new Set<string>()
- if (!project) {
- loadedSessionDirs.clear()
- return
- }
-
- if (workspaces) {
- const activeDir = currentDir()
- const dirs = [project.worktree, ...(project.sandboxes ?? [])]
- for (const directory of dirs) {
- const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree
- const active = directory === activeDir
- if (!expanded && !active) continue
- next.add(directory)
- }
- }
-
- if (!workspaces) {
- next.add(project.worktree)
- }
+ createEffect(
+ on(
+ visibleSessionDirs,
+ (dirs) => {
+ if (dirs.length === 0) {
+ loadedSessionDirs.clear()
+ return
+ }
- for (const directory of next) {
- if (loadedSessionDirs.has(directory)) continue
- globalSync.project.loadSessions(directory)
- }
+ const next = new Set(dirs)
+ for (const directory of next) {
+ if (loadedSessionDirs.has(directory)) continue
+ globalSync.project.loadSessions(directory)
+ }
- loadedSessionDirs.clear()
- for (const directory of next) {
- loadedSessionDirs.add(directory)
- }
- })
+ loadedSessionDirs.clear()
+ for (const directory of next) {
+ loadedSessionDirs.add(directory)
+ }
+ },
+ { defer: true },
+ ),
+ )
function handleDragStart(event: unknown) {
const id = getDraggableId(event)
@@ -1583,14 +1632,11 @@ export default function Layout(props: ParentProps) {
const extra = directory && directory !== local && !dirs.includes(directory) ? directory : undefined
const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false
- const existing = store.workspaceOrder[project.worktree]
- if (!existing) return extra ? [...dirs, extra] : dirs
-
- const merged = syncWorkspaceOrder(local, dirs, existing)
- if (pending && extra) return [local, extra, ...merged.filter((directory) => directory !== local)]
- if (!extra) return merged
- if (pending) return merged
- return [...merged, extra]
+ const ordered = effectiveWorkspaceOrder(local, dirs, store.workspaceOrder[project.worktree])
+ if (pending && extra) return [local, extra, ...ordered.filter((item) => item !== local)]
+ if (!extra) return ordered
+ if (pending) return ordered
+ return [...ordered, extra]
}
const sidebarProject = createMemo(() => {
@@ -1623,7 +1669,11 @@ export default function Layout(props: ParentProps) {
const [item] = result.splice(fromIndex, 1)
if (!item) return
result.splice(toIndex, 0, item)
- setStore("workspaceOrder", project.worktree, result)
+ setStore(
+ "workspaceOrder",
+ project.worktree,
+ result.filter((directory) => workspaceKey(directory) !== workspaceKey(project.worktree)),
+ )
}
function handleWorkspaceDragEnd() {
@@ -1661,10 +1711,9 @@ export default function Layout(props: ParentProps) {
const existing = prev ?? []
const next = existing.filter((item) => {
const id = workspaceKey(item)
- if (id === root) return false
- return id !== key
+ return id !== root && id !== key
})
- return [local, created.directory, ...next]
+ return [created.directory, ...next]
})
globalSync.child(created.directory)
diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts
index 2c4b834be..42315e589 100644
--- a/packages/app/src/pages/layout/helpers.ts
+++ b/packages/app/src/pages/layout/helpers.ts
@@ -74,9 +74,29 @@ export const errorMessage = (err: unknown, fallback: string) => {
return fallback
}
-export const syncWorkspaceOrder = (local: string, dirs: string[], existing?: string[]) => {
- if (!existing) return dirs
- const keep = existing.filter((d) => d !== local && dirs.includes(d))
- const missing = dirs.filter((d) => d !== local && !existing.includes(d))
- return [local, ...missing, ...keep]
+export const effectiveWorkspaceOrder = (local: string, dirs: string[], persisted?: string[]) => {
+ const root = workspaceKey(local)
+ const live = new Map<string, string>()
+
+ for (const dir of dirs) {
+ const key = workspaceKey(dir)
+ if (key === root) continue
+ if (!live.has(key)) live.set(key, dir)
+ }
+
+ if (!persisted?.length) return [local, ...live.values()]
+
+ const result = [local]
+ for (const dir of persisted) {
+ const key = workspaceKey(dir)
+ if (key === root) continue
+ const match = live.get(key)
+ if (!match) continue
+ result.push(match)
+ live.delete(key)
+ }
+
+ return [...result, ...live.values()]
}
+
+export const syncWorkspaceOrder = effectiveWorkspaceOrder
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index 16b38cbd3..4f01badf4 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -347,24 +347,6 @@ export default function Page() {
if (path) file.load(path)
})
- createEffect(() => {
- const current = tabs().all()
- if (current.length === 0) return
-
- const next = normalizeTabs(current)
- if (same(current, next)) return
-
- tabs().setAll(next)
-
- const active = tabs().active()
- if (!active) return
- if (!active.startsWith("file://")) return
-
- const normalized = normalizeTab(active)
- if (active === normalized) return
- tabs().setActive(normalized)
- })
-
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
diff --git a/packages/app/src/pages/session/file-tabs.tsx b/packages/app/src/pages/session/file-tabs.tsx
index e92eee670..93264353b 100644
--- a/packages/app/src/pages/session/file-tabs.tsx
+++ b/packages/app/src/pages/session/file-tabs.tsx
@@ -67,6 +67,7 @@ export function FileTabContent(props: { tab: string }) {
let scroll: HTMLDivElement | undefined
let scrollFrame: number | undefined
+ let restoreFrame: number | undefined
let pending: { x: number; y: number } | undefined
let codeScroll: HTMLElement[] = []
let find: FileSearchHandle | null = null
@@ -349,6 +350,15 @@ export function FileTabContent(props: { tab: string }) {
if (el.scrollLeft !== s.x) el.scrollLeft = s.x
}
+ const queueRestore = () => {
+ if (restoreFrame !== undefined) return
+
+ restoreFrame = requestAnimationFrame(() => {
+ restoreFrame = undefined
+ restoreScroll()
+ })
+ }
+
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
if (codeScroll.length === 0) syncCodeScroll()
@@ -364,46 +374,29 @@ export function FileTabContent(props: { tab: string }) {
setNote("commenting", null)
}
- createEffect(
- on(
- () => state()?.loaded,
- (loaded) => {
- if (!loaded) return
- requestAnimationFrame(restoreScroll)
- },
- { defer: true },
- ),
- )
-
- createEffect(
- on(
- () => file.ready(),
- (ready) => {
- if (!ready) return
- requestAnimationFrame(restoreScroll)
- },
- { defer: true },
- ),
- )
+ let prev = {
+ loaded: false,
+ ready: false,
+ active: false,
+ }
- createEffect(
- on(
- () => tabs().active() === props.tab,
- (active) => {
- if (!active) return
- if (!state()?.loaded) return
- requestAnimationFrame(restoreScroll)
- },
- ),
- )
+ createEffect(() => {
+ const loaded = !!state()?.loaded
+ const ready = file.ready()
+ const active = tabs().active() === props.tab
+ const restore = (loaded && !prev.loaded) || (ready && !prev.ready) || (active && loaded && !prev.active)
+ prev = { loaded, ready, active }
+ if (!restore) return
+ queueRestore()
+ })
onCleanup(() => {
for (const item of codeScroll) {
item.removeEventListener("scroll", handleCodeScroll)
}
- if (scrollFrame === undefined) return
- cancelAnimationFrame(scrollFrame)
+ if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame)
+ if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
})
const renderFile = (source: string) => (
@@ -421,7 +414,7 @@ export function FileTabContent(props: { tab: string }) {
selectedLines={activeSelection()}
commentedLines={commentedLines()}
onRendered={() => {
- requestAnimationFrame(restoreScroll)
+ queueRestore()
}}
annotations={commentsUi.annotations()}
renderAnnotation={commentsUi.renderAnnotation}
@@ -440,7 +433,7 @@ export function FileTabContent(props: { tab: string }) {
mode: "auto",
path: path(),
current: state()?.content,
- onLoad: () => requestAnimationFrame(restoreScroll),
+ onLoad: queueRestore,
onError: (args: { kind: "image" | "audio" | "svg" }) => {
if (args.kind !== "svg") return
showToast({
diff --git a/packages/app/src/pages/session/review-tab.tsx b/packages/app/src/pages/session/review-tab.tsx
index 2b7eba324..142ee7ad9 100644
--- a/packages/app/src/pages/session/review-tab.tsx
+++ b/packages/app/src/pages/session/review-tab.tsx
@@ -1,4 +1,4 @@
-import { createEffect, on, onCleanup, type JSX } from "solid-js"
+import { createEffect, onCleanup, type JSX } from "solid-js"
import type { FileDiff } from "@opencode-ai/sdk/v2"
import { SessionReview } from "@opencode-ai/ui/session-review"
import type {
@@ -119,32 +119,12 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
})
}
- createEffect(
- on(
- () => props.diffs().length,
- () => queueRestore(),
- { defer: true },
- ),
- )
-
- createEffect(
- on(
- () => props.diffStyle,
- () => queueRestore(),
- { defer: true },
- ),
- )
-
- createEffect(
- on(
- () => layout.ready(),
- (ready) => {
- if (!ready) return
- queueRestore()
- },
- { defer: true },
- ),
- )
+ createEffect(() => {
+ props.diffs().length
+ props.diffStyle
+ if (!layout.ready()) return
+ queueRestore()
+ })
onCleanup(() => {
if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx
index 27ea4e6f3..49bed9490 100644
--- a/packages/app/src/pages/session/terminal-panel.tsx
+++ b/packages/app/src/pages/session/terminal-panel.tsx
@@ -56,9 +56,9 @@ export function TerminalPanel() {
on(
() => terminal.all().length,
(count, prevCount) => {
- if (prevCount !== undefined && prevCount > 0 && count === 0) {
- if (opened()) view().terminal.toggle()
- }
+ if (prevCount === undefined || prevCount <= 0 || count !== 0) return
+ if (!opened()) return
+ close()
},
),
)
diff --git a/packages/app/src/pages/session/use-session-hash-scroll.ts b/packages/app/src/pages/session/use-session-hash-scroll.ts
index 235714588..c5a7dde9e 100644
--- a/packages/app/src/pages/session/use-session-hash-scroll.ts
+++ b/packages/app/src/pages/session/use-session-hash-scroll.ts
@@ -1,4 +1,4 @@
-import { createEffect, createMemo, on, onCleanup } from "solid-js"
+import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { UserMessage } from "@opencode-ai/sdk/v2"
export const messageIdFromHash = (hash: string) => {
@@ -28,6 +28,7 @@ export const useSessionHashScroll = (input: {
const visibleUserMessages = createMemo(() => input.visibleUserMessages())
const messageById = createMemo(() => new Map(visibleUserMessages().map((m) => [m.id, m])))
const messageIndex = createMemo(() => new Map(visibleUserMessages().map((m, i) => [m.id, i])))
+ let pendingKey = ""
const clearMessageHash = () => {
if (!window.location.hash) return
@@ -130,15 +131,6 @@ export const useSessionHashScroll = (input: {
if (el) input.scheduleScrollState(el)
}
- createEffect(
- on(input.sessionKey, (key) => {
- if (!input.sessionID()) return
- const messageID = input.consumePendingMessage(key)
- if (!messageID) return
- input.setPendingMessage(messageID)
- }),
- )
-
createEffect(() => {
if (!input.sessionID() || !input.messagesReady()) return
requestAnimationFrame(() => applyHash("auto"))
@@ -150,7 +142,20 @@ export const useSessionHashScroll = (input: {
visibleUserMessages()
input.turnStart()
- const targetId = input.pendingMessage() ?? messageIdFromHash(window.location.hash)
+ let targetId = input.pendingMessage()
+ if (!targetId) {
+ const key = input.sessionKey()
+ if (pendingKey !== key) {
+ pendingKey = key
+ const next = input.consumePendingMessage(key)
+ if (next) {
+ input.setPendingMessage(next)
+ targetId = next
+ }
+ }
+ }
+
+ if (!targetId) targetId = messageIdFromHash(window.location.hash)
if (!targetId) return
if (input.currentMessageId() === targetId) return
@@ -162,9 +167,12 @@ export const useSessionHashScroll = (input: {
requestAnimationFrame(() => scrollToMessage(msg, "auto"))
})
- createEffect(() => {
- if (!input.sessionID() || !input.messagesReady()) return
- const handler = () => requestAnimationFrame(() => applyHash("auto"))
+ onMount(() => {
+ const handler = () => {
+ if (!input.sessionID() || !input.messagesReady()) return
+ requestAnimationFrame(() => applyHash("auto"))
+ }
+
window.addEventListener("hashchange", handler)
onCleanup(() => window.removeEventListener("hashchange", handler))
})