From 8176bafc555e562ade48a675dffa3f38751ed8c9 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 2 Mar 2026 10:50:50 -0600 Subject: [PATCH] chore(app): solidjs refactoring (#13399) --- .../app/create-effect-simplification-spec.md | 515 ++++++++++++++++++ packages/app/src/components/file-tree.tsx | 6 - packages/app/src/components/prompt-input.tsx | 9 - .../src/components/session/session-header.tsx | 11 +- packages/app/src/components/terminal.tsx | 7 +- packages/app/src/context/global-sync.tsx | 96 ++-- .../src/context/global-sync/child-store.ts | 30 +- packages/app/src/context/layout.tsx | 82 ++- packages/app/src/pages/layout.tsx | 305 ++++++----- packages/app/src/pages/layout/helpers.ts | 30 +- packages/app/src/pages/session.tsx | 18 - packages/app/src/pages/session/file-tabs.tsx | 63 +-- packages/app/src/pages/session/review-tab.tsx | 34 +- .../app/src/pages/session/terminal-panel.tsx | 6 +- .../pages/session/use-session-hash-scroll.ts | 36 +- 15 files changed, 941 insertions(+), 307 deletions(-) create mode 100644 packages/app/create-effect-simplification-spec.md 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 = (props) => { setActive: setSlashActive, onInput: slashOnInput, onKeyDown: slashOnKeyDown, - refetch: slashRefetch, } = useFilteredList({ items: slashCommands, key: (x) => x?.id, @@ -648,14 +647,6 @@ export const PromptInput: Component = (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) }} > 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(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>() const sessionMeta = new Map() - 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({ 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 | 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 | undefined, tab: string) => { + if (!tab.startsWith("file://")) return tab + if (!path) return tab + return path.tab(tab) +} + +const normalizeSessionTabList = (path: ReturnType | undefined, all: string[]) => { + const seen = new Set() + 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) { 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" @@ -481,21 +481,6 @@ export default function Layout(props: ParentProps) { return projects.find((p) => p.worktree === root) }) - 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() }), @@ -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 + if (!project) return [] as string[] + if (!workspaceSetting()) return [project.worktree] - 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) - } + 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 } - requestAnimationFrame(() => scrollToSession(id, `${directory}:${id}`)) + + const session = `${dir}/${id}` + if (session !== activeRoute.session) { + activeRoute.session = session + activeRoute.sessionProject = syncSessionRoute(directory, id, root) + return + } + + 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() - createEffect(() => { - const project = currentProject() - const workspaces = workspaceSetting() - const next = new Set() - if (!project) { - loadedSessionDirs.clear() - return - } + createEffect( + on( + visibleSessionDirs, + (dirs) => { + if (dirs.length === 0) { + 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) - } - } + const next = new Set(dirs) + for (const directory of next) { + if (loadedSessionDirs.has(directory)) continue + globalSync.project.loadSessions(directory) + } - if (!workspaces) { - next.add(project.worktree) - } - - 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() + + 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 }, - ), - ) + let prev = { + loaded: false, + ready: false, + active: false, + } - createEffect( - on( - () => file.ready(), - (ready) => { - if (!ready) return - requestAnimationFrame(restoreScroll) - }, - { defer: true }, - ), - ) - - 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)) })