chore(app): solidjs refactoring (#13399)

pull/14877/head^2
Adam 2026-03-02 10:50:50 -06:00 committed by GitHub
parent 0a3a3216db
commit 8176bafc55
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 941 additions and 307 deletions

View File

@ -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

View File

@ -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 = createMemo(() => {
const nodes = file.tree.children(props.path) const nodes = file.tree.children(props.path)
const current = filter() const current = filter()

View File

@ -591,7 +591,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
setActive: setSlashActive, setActive: setSlashActive,
onInput: slashOnInput, onInput: slashOnInput,
onKeyDown: slashOnKeyDown, onKeyDown: slashOnKeyDown,
refetch: slashRefetch,
} = useFilteredList<SlashCommand>({ } = useFilteredList<SlashCommand>({
items: slashCommands, items: slashCommands,
key: (x) => x?.id, 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 // Auto-scroll active command into view when navigating with keyboard
createEffect(() => { createEffect(() => {
const activeId = slashActive() const activeId = slashActive()

View File

@ -306,11 +306,10 @@ export function SessionHeader() {
const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0]) const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0])
const opening = createMemo(() => openRequest.app !== undefined) const opening = createMemo(() => openRequest.app !== undefined)
createEffect(() => { const selectApp = (app: OpenApp) => {
const value = prefs.app if (!options().some((item) => item.id === app)) return
if (options().some((o) => o.id === value)) return setPrefs("app", app)
setPrefs("app", options()[0]?.id ?? "finder") }
})
const openDir = (app: OpenApp) => { const openDir = (app: OpenApp) => {
if (opening() || !canOpen() || !platform.openPath) return if (opening() || !canOpen() || !platform.openPath) return
@ -458,7 +457,7 @@ export function SessionHeader() {
value={current().id} value={current().id}
onChange={(value) => { onChange={(value) => {
if (!OPEN_APPS.includes(value as OpenApp)) return if (!OPEN_APPS.includes(value as OpenApp)) return
setPrefs("app", value as OpenApp) selectApp(value as OpenApp)
}} }}
> >
<For each={options()}> <For each={options()}>

View File

@ -1,7 +1,7 @@
import { type HexColor, resolveThemeVariant, useTheme, withAlpha } from "@opencode-ai/ui/theme" import { type HexColor, resolveThemeVariant, useTheme, withAlpha } from "@opencode-ai/ui/theme"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web" 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 { SerializeAddon } from "@/addons/serialize"
import { matchKeybind, parseKeybind } from "@/context/command" import { matchKeybind, parseKeybind } from "@/context/command"
import { useLanguage } from "@/context/language" 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 = () => { const scheduleFit = () => {
if (disposed) return if (disposed) return
@ -259,8 +259,7 @@ export const Terminal = (props: TerminalProps) => {
} }
createEffect(() => { createEffect(() => {
const colors = getTerminalColors() const colors = terminalColors()
setTerminalColors(colors)
if (!term) return if (!term) return
setOptionIfSupported(term, "theme", colors) setOptionIfSupported(term, "theme", colors)
}) })

View File

@ -11,7 +11,6 @@ import { showToast } from "@opencode-ai/ui/toast"
import { getFilename } from "@opencode-ai/util/path" import { getFilename } from "@opencode-ai/util/path"
import { import {
createContext, createContext,
createEffect,
getOwner, getOwner,
Match, Match,
onCleanup, onCleanup,
@ -35,7 +34,6 @@ import { trimSessions } from "./global-sync/session-trim"
import type { ProjectMeta } from "./global-sync/types" import type { ProjectMeta } from "./global-sync/types"
import { SESSION_RECENT_LIMIT } from "./global-sync/types" import { SESSION_RECENT_LIMIT } from "./global-sync/types"
import { sanitizeProject } from "./global-sync/utils" import { sanitizeProject } from "./global-sync/utils"
import { usePlatform } from "./platform"
import { formatServerError } from "@/utils/server-errors" import { formatServerError } from "@/utils/server-errors"
type GlobalStore = { type GlobalStore = {
@ -54,7 +52,6 @@ type GlobalStore = {
function createGlobalSync() { function createGlobalSync() {
const globalSDK = useGlobalSDK() const globalSDK = useGlobalSDK()
const platform = usePlatform()
const language = useLanguage() const language = useLanguage()
const owner = getOwner() const owner = getOwner()
if (!owner) throw new Error("GlobalSync must be created within owner") 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 sessionLoads = new Map<string, Promise<void>>()
const sessionMeta = new Map<string, { limit: number }>() const sessionMeta = new Map<string, { limit: number }>()
const [projectCache, setProjectCache, , projectCacheReady] = persisted( const [projectCache, setProjectCache, projectInit] = persisted(
Persist.global("globalSync.project", ["globalSync.project.v1"]), Persist.global("globalSync.project", ["globalSync.project.v1"]),
createStore({ value: [] as Project[] }), createStore({ value: [] as Project[] }),
) )
@ -80,6 +77,57 @@ function createGlobalSync() {
reload: undefined, 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) => { const setSessionTodo = (sessionID: string, todos: Todo[] | undefined) => {
if (!sessionID) return if (!sessionID) return
if (!todos) { if (!todos) {
@ -127,30 +175,6 @@ function createGlobalSync() {
return sdk 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) { async function loadSessions(directory: string) {
const pending = sessionLoads.get(directory) const pending = sessionLoads.get(directory)
if (pending) return pending if (pending) return pending
@ -259,13 +283,7 @@ function createGlobalSync() {
event, event,
project: globalStore.project, project: globalStore.project,
refresh: queue.refresh, refresh: queue.refresh,
setGlobalProject(next) { setGlobalProject: setProjects,
if (typeof next === "function") {
setGlobalStore("project", produce(next))
return
}
setGlobalStore("project", next)
},
}) })
if (event.type === "server.connected" || event.type === "global.disposed") { if (event.type === "server.connected" || event.type === "global.disposed") {
for (const directory of Object.keys(children.children)) { for (const directory of Object.keys(children.children)) {
@ -316,7 +334,7 @@ function createGlobalSync() {
unknownError: language.t("error.chain.unknown"), unknownError: language.t("error.chain.unknown"),
invalidConfigurationError: language.t("error.server.invalidConfiguration"), invalidConfigurationError: language.t("error.server.invalidConfiguration"),
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }), formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
setGlobalStore, setGlobalStore: setBootStore,
}) })
} }
@ -340,7 +358,9 @@ function createGlobalSync() {
.update({ config }) .update({ config })
.then(bootstrap) .then(bootstrap)
.then(() => { .then(() => {
setGlobalStore("reload", "complete") queue.refresh()
setGlobalStore("reload", undefined)
queue.refresh()
}) })
.catch((error) => { .catch((error) => {
setGlobalStore("reload", undefined) setGlobalStore("reload", undefined)
@ -350,7 +370,7 @@ function createGlobalSync() {
return { return {
data: globalStore, data: globalStore,
set: setGlobalStore, set,
get ready() { get ready() {
return globalStore.ready return globalStore.ready
}, },

View File

@ -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 { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import type { VcsInfo } from "@opencode-ai/sdk/v2/client" 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") if (!vcs) throw new Error("Failed to create persisted cache")
const vcsStore = vcs[0] const vcsStore = vcs[0]
const vcsReady = vcs[3] vcsCache.set(directory, { store: vcsStore, setStore: vcs[1], ready: vcs[3] })
vcsCache.set(directory, { store: vcsStore, setStore: vcs[1], ready: vcsReady })
const meta = runWithOwner(input.owner, () => const meta = runWithOwner(input.owner, () =>
persisted( persisted(
@ -154,10 +153,12 @@ export function createChildStoreManager(input: {
const init = () => const init = () =>
createRoot((dispose) => { createRoot((dispose) => {
const initialMeta = meta[0].value
const initialIcon = icon[0].value
const child = createStore<State>({ const child = createStore<State>({
project: "", project: "",
projectMeta: meta[0].value, projectMeta: initialMeta,
icon: icon[0].value, icon: initialIcon,
provider: { all: [], connected: [], default: {} }, provider: { all: [], connected: [], default: {} },
config: {}, config: {},
path: { state: "", config: "", worktree: "", directory: "", home: "" }, path: { state: "", config: "", worktree: "", directory: "", home: "" },
@ -181,16 +182,27 @@ export function createChildStoreManager(input: {
children[directory] = child children[directory] = child
disposers.set(directory, dispose) disposers.set(directory, dispose)
createEffect(() => { const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
if (!vcsReady()) return if (!(init instanceof Promise)) return
void init.then(() => {
if (children[directory] !== child) return
run()
})
}
onPersistedInit(vcs[2], () => {
const cached = vcsStore.value const cached = vcsStore.value
if (!cached?.branch) return if (!cached?.branch) return
child[1]("vcs", (value) => value ?? cached) child[1]("vcs", (value) => value ?? cached)
}) })
createEffect(() => {
onPersistedInit(meta[2], () => {
if (child[0].projectMeta !== initialMeta) return
child[1]("projectMeta", meta[0].value) child[1]("projectMeta", meta[0].value)
}) })
createEffect(() => {
onPersistedInit(icon[2], () => {
if (child[0].icon !== initialIcon) return
child[1]("icon", icon[0].value) child[1]("icon", icon[0].value)
}) })
}) })

View File

@ -7,8 +7,10 @@ import { useServer } from "./server"
import { usePlatform } from "./platform" import { usePlatform } from "./platform"
import { Project } from "@opencode-ai/sdk/v2" import { Project } from "@opencode-ai/sdk/v2"
import { Persist, persisted, removePersisted } from "@/utils/persist" import { Persist, persisted, removePersisted } from "@/utils/persist"
import { decode64 } from "@/utils/base64"
import { same } from "@/utils/same" import { same } from "@/utils/same"
import { createScrollPersistence, type SessionScroll } from "./layout-scroll" 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 AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
const DEFAULT_PANEL_WIDTH = 344 const DEFAULT_PANEL_WIDTH = 344
@ -96,6 +98,38 @@ function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string):
return { all, active: tab } 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({ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
name: "Layout", name: "Layout",
init: () => { 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 { return {
...value, ...value,
sidebar: migratedSidebar, sidebar: migratedSidebar,
review: migratedReview, review: migratedReview,
fileTree: migratedFileTree, fileTree: migratedFileTree,
sessionTabs: migratedSessionTabs,
} }
} }
@ -745,22 +813,26 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}, },
tabs(sessionKey: string | Accessor<string>) { tabs(sessionKey: string | Accessor<string>) {
const key = createSessionKeyReader(sessionKey, ensureKey) const key = createSessionKeyReader(sessionKey, ensureKey)
const path = createMemo(() => sessionPath(key()))
const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] }) const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] })
const normalize = (tab: string) => normalizeSessionTab(path(), tab)
const normalizeAll = (all: string[]) => normalizeSessionTabList(path(), all)
return { return {
tabs, tabs,
active: createMemo(() => tabs().active), active: createMemo(() => tabs().active),
all: createMemo(() => tabs().all.filter((tab) => tab !== "review")), all: createMemo(() => tabs().all.filter((tab) => tab !== "review")),
setActive(tab: string | undefined) { setActive(tab: string | undefined) {
const session = key() const session = key()
const next = tab ? normalize(tab) : tab
if (!store.sessionTabs[session]) { if (!store.sessionTabs[session]) {
setStore("sessionTabs", session, { all: [], active: tab }) setStore("sessionTabs", session, { all: [], active: next })
} else { } else {
setStore("sessionTabs", session, "active", tab) setStore("sessionTabs", session, "active", next)
} }
}, },
setAll(all: string[]) { setAll(all: string[]) {
const session = key() const session = key()
const next = all.filter((tab) => tab !== "review") const next = normalizeAll(all).filter((tab) => tab !== "review")
if (!store.sessionTabs[session]) { if (!store.sessionTabs[session]) {
setStore("sessionTabs", session, { all: next, active: undefined }) setStore("sessionTabs", session, { all: next, active: undefined })
} else { } else {
@ -769,7 +841,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}, },
async open(tab: string) { async open(tab: string) {
const session = key() const session = key()
const next = nextSessionTabsForOpen(store.sessionTabs[session], tab) const next = nextSessionTabsForOpen(store.sessionTabs[session], normalize(tab))
setStore("sessionTabs", session, next) setStore("sessionTabs", session, next)
}, },
close(tab: string) { close(tab: string) {

View File

@ -59,11 +59,11 @@ import { useLanguage, type Locale } from "@/context/language"
import { import {
childMapByParent, childMapByParent,
displayName, displayName,
effectiveWorkspaceOrder,
errorMessage, errorMessage,
getDraggableId, getDraggableId,
latestRootSession, latestRootSession,
sortedRootSessions, sortedRootSessions,
syncWorkspaceOrder,
workspaceKey, workspaceKey,
} from "./layout/helpers" } from "./layout/helpers"
import { collectOpenProjectDeepLinks, deepLinkEvent, drainPendingDeepLinks } from "./layout/deep-links" 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) 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( createEffect(
on( on(
() => ({ ready: pageReady(), layoutReady: layoutReady(), dir: params.dir, list: layout.projects.list() }), () => ({ 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)() return layout.sidebar.workspaces(project.worktree)()
}) })
createEffect(() => { const visibleSessionDirs = createMemo(() => {
if (!pageReady()) return
if (!layoutReady()) return
const project = currentProject() const project = currentProject()
if (!project) return if (!project) return [] as string[]
if (!workspaceSetting()) return [project.worktree]
const local = project.worktree const activeDir = currentDir()
const dirs = [project.worktree, ...(project.sandboxes ?? [])] return workspaceIds(project).filter((directory) => {
const existing = store.workspaceOrder[project.worktree] const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree
const merged = syncWorkspaceOrder(local, dirs, existing) const active = directory === activeDir
if (!existing) { return expanded || active
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)
}
}) })
createEffect(() => { createEffect(() => {
@ -593,25 +566,17 @@ export default function Layout(props: ParentProps) {
}) })
const currentSessions = createMemo(() => { const currentSessions = createMemo(() => {
const project = currentProject()
if (!project) return [] as Session[]
const now = Date.now() const now = Date.now()
if (workspaceSetting()) { const dirs = visibleSessionDirs()
const dirs = workspaceIds(project) if (dirs.length === 0) return [] as Session[]
const activeDir = currentDir()
const result: Session[] = [] const result: Session[] = []
for (const dir of dirs) { 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 [dirStore] = globalSync.child(dir, { bootstrap: true })
const dirSessions = sortedRootSessions(dirStore, now) const dirSessions = sortedRootSessions(dirStore, now)
result.push(...dirSessions) result.push(...dirSessions)
} }
return result return result
}
const [projectStore] = globalSync.child(project.worktree)
return sortedRootSessions(projectStore, now)
}) })
type PrefetchQueue = { type PrefetchQueue = {
@ -826,7 +791,6 @@ export default function Layout(props: ParentProps) {
} }
navigateToSession(session) navigateToSession(session)
queueMicrotask(() => scrollToSession(session.id, `${session.directory}:${session.id}`))
} }
function navigateSessionByUnseen(offset: number) { function navigateSessionByUnseen(offset: number) {
@ -861,7 +825,6 @@ export default function Layout(props: ParentProps) {
} }
navigateToSession(session) navigateToSession(session)
queueMicrotask(() => scrollToSession(session.id, `${session.directory}:${session.id}`))
return return
} }
} }
@ -1094,34 +1057,90 @@ export default function Layout(props: ParentProps) {
return meta?.worktree ?? directory 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) { async function navigateToProject(directory: string | undefined) {
if (!directory) return if (!directory) return
const root = projectRoot(directory) const root = projectRoot(directory)
server.projects.touch(root) server.projects.touch(root)
const project = layout.projects.list().find((item) => item.worktree === 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 }) => { const openSession = async (target: { directory: string; id: string }) => {
if (!canOpen(target.directory)) return false
const resolved = await globalSDK.client.session const resolved = await globalSDK.client.session
.get({ sessionID: target.id }) .get({ sessionID: target.id })
.then((x) => x.data) .then((x) => x.data)
.catch(() => undefined) .catch(() => undefined)
const next = resolved?.directory ? resolved : target if (!resolved?.directory) return false
setStore("lastProjectSession", root, { directory: next.directory, id: next.id, at: Date.now() }) if (!canOpen(resolved.directory)) return false
navigateWithSidebarReset(`/${base64Encode(next.directory)}/session/${next.id}`) 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] const projectSession = store.lastProjectSession[root]
if (projectSession?.id) { if (projectSession?.id) {
await openSession(projectSession) await refreshDirs(projectSession.directory)
return const opened = await openSession(projectSession)
if (opened) return
clearLastProjectSession(root)
} }
const latest = latestRootSession( const latest = latestRootSession(
dirs.map((item) => globalSync.child(item, { bootstrap: false })[0]), dirs.map((item) => globalSync.child(item, { bootstrap: false })[0]),
Date.now(), Date.now(),
) )
if (latest) { if (latest && (await openSession(latest))) {
await openSession(latest)
return return
} }
@ -1137,8 +1156,7 @@ export default function Layout(props: ParentProps) {
), ),
Date.now(), Date.now(),
) )
if (fetched) { if (fetched && (await openSession(fetched))) {
await openSession(fetched)
return 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 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) setBusy(directory, true)
const result = await globalSDK.client.worktree const result = await globalSDK.client.worktree
@ -1260,6 +1286,10 @@ export default function Layout(props: ParentProps) {
if (!result) return if (!result) return
if (workspaceKey(store.lastProjectSession[root]?.directory ?? "") === workspaceKey(directory)) {
clearLastProjectSession(root)
}
globalSync.set( globalSync.set(
"project", "project",
produce((draft) => { produce((draft) => {
@ -1273,8 +1303,18 @@ export default function Layout(props: ParentProps) {
layout.projects.close(directory) layout.projects.close(directory)
layout.projects.open(root) layout.projects.open(root)
if (params.dir && currentDir() === directory) { if (shouldLeave) return
navigateToProject(root)
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 handleDelete = () => {
const leaveDeletedWorkspace = !!params.dir && workspaceKey(currentDir()) === workspaceKey(props.directory)
if (leaveDeletedWorkspace) {
navigateWithSidebarReset(`/${base64Encode(props.root)}/session`)
}
dialog.close() dialog.close()
void deleteWorkspace(props.root, props.directory) void deleteWorkspace(props.root, props.directory, leaveDeletedWorkspace)
} }
const description = () => { const description = () => {
@ -1486,26 +1530,42 @@ export default function Layout(props: ParentProps) {
) )
} }
const activeRoute = {
session: "",
sessionProject: "",
}
createEffect( createEffect(
on( on(
() => ({ ready: pageReady(), dir: params.dir, id: params.id }), () => [pageReady(), params.dir, params.id, currentProject()?.worktree] as const,
(value) => { ([ready, dir, id]) => {
if (!value.ready) return if (!ready || !dir) {
const dir = value.dir activeRoute.session = ""
const id = value.id activeRoute.sessionProject = ""
if (!dir || !id) return return
}
const directory = decode64(dir) const directory = decode64(dir)
if (!directory) return if (!directory) return
const at = Date.now()
setStore("lastProjectSession", projectRoot(directory), { directory, id, at }) const root = touchProjectRoute() ?? activeProjectRoot(directory)
notification.session.markViewed(id)
const expanded = untrack(() => store.workspaceExpanded[directory]) if (!id) {
if (expanded === false) { activeRoute.session = ""
setStore("workspaceExpanded", directory, true) 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,30 +1576,16 @@ export default function Layout(props: ParentProps) {
const loadedSessionDirs = new Set<string>() const loadedSessionDirs = new Set<string>()
createEffect(() => { createEffect(
const project = currentProject() on(
const workspaces = workspaceSetting() visibleSessionDirs,
const next = new Set<string>() (dirs) => {
if (!project) { if (dirs.length === 0) {
loadedSessionDirs.clear() loadedSessionDirs.clear()
return return
} }
if (workspaces) { const next = new Set(dirs)
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)
}
for (const directory of next) { for (const directory of next) {
if (loadedSessionDirs.has(directory)) continue if (loadedSessionDirs.has(directory)) continue
globalSync.project.loadSessions(directory) globalSync.project.loadSessions(directory)
@ -1549,7 +1595,10 @@ export default function Layout(props: ParentProps) {
for (const directory of next) { for (const directory of next) {
loadedSessionDirs.add(directory) loadedSessionDirs.add(directory)
} }
}) },
{ defer: true },
),
)
function handleDragStart(event: unknown) { function handleDragStart(event: unknown) {
const id = getDraggableId(event) 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 extra = directory && directory !== local && !dirs.includes(directory) ? directory : undefined
const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false
const existing = store.workspaceOrder[project.worktree] const ordered = effectiveWorkspaceOrder(local, dirs, store.workspaceOrder[project.worktree])
if (!existing) return extra ? [...dirs, extra] : dirs if (pending && extra) return [local, extra, ...ordered.filter((item) => item !== local)]
if (!extra) return ordered
const merged = syncWorkspaceOrder(local, dirs, existing) if (pending) return ordered
if (pending && extra) return [local, extra, ...merged.filter((directory) => directory !== local)] return [...ordered, extra]
if (!extra) return merged
if (pending) return merged
return [...merged, extra]
} }
const sidebarProject = createMemo(() => { const sidebarProject = createMemo(() => {
@ -1623,7 +1669,11 @@ export default function Layout(props: ParentProps) {
const [item] = result.splice(fromIndex, 1) const [item] = result.splice(fromIndex, 1)
if (!item) return if (!item) return
result.splice(toIndex, 0, item) result.splice(toIndex, 0, item)
setStore("workspaceOrder", project.worktree, result) setStore(
"workspaceOrder",
project.worktree,
result.filter((directory) => workspaceKey(directory) !== workspaceKey(project.worktree)),
)
} }
function handleWorkspaceDragEnd() { function handleWorkspaceDragEnd() {
@ -1661,10 +1711,9 @@ export default function Layout(props: ParentProps) {
const existing = prev ?? [] const existing = prev ?? []
const next = existing.filter((item) => { const next = existing.filter((item) => {
const id = workspaceKey(item) const id = workspaceKey(item)
if (id === root) return false return id !== root && id !== key
return id !== key
}) })
return [local, created.directory, ...next] return [created.directory, ...next]
}) })
globalSync.child(created.directory) globalSync.child(created.directory)

View File

@ -74,9 +74,29 @@ export const errorMessage = (err: unknown, fallback: string) => {
return fallback return fallback
} }
export const syncWorkspaceOrder = (local: string, dirs: string[], existing?: string[]) => { export const effectiveWorkspaceOrder = (local: string, dirs: string[], persisted?: string[]) => {
if (!existing) return dirs const root = workspaceKey(local)
const keep = existing.filter((d) => d !== local && dirs.includes(d)) const live = new Map<string, string>()
const missing = dirs.filter((d) => d !== local && !existing.includes(d))
return [local, ...missing, ...keep] 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

View File

@ -347,24 +347,6 @@ export default function Page() {
if (path) file.load(path) 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 info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : [])) const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length)) const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))

View File

@ -67,6 +67,7 @@ export function FileTabContent(props: { tab: string }) {
let scroll: HTMLDivElement | undefined let scroll: HTMLDivElement | undefined
let scrollFrame: number | undefined let scrollFrame: number | undefined
let restoreFrame: number | undefined
let pending: { x: number; y: number } | undefined let pending: { x: number; y: number } | undefined
let codeScroll: HTMLElement[] = [] let codeScroll: HTMLElement[] = []
let find: FileSearchHandle | null = null let find: FileSearchHandle | null = null
@ -349,6 +350,15 @@ export function FileTabContent(props: { tab: string }) {
if (el.scrollLeft !== s.x) el.scrollLeft = s.x 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 }) => { const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
if (codeScroll.length === 0) syncCodeScroll() if (codeScroll.length === 0) syncCodeScroll()
@ -364,46 +374,29 @@ export function FileTabContent(props: { tab: string }) {
setNote("commenting", null) setNote("commenting", null)
} }
createEffect( let prev = {
on( loaded: false,
() => state()?.loaded, ready: false,
(loaded) => { active: false,
if (!loaded) return }
requestAnimationFrame(restoreScroll)
},
{ defer: true },
),
)
createEffect( createEffect(() => {
on( const loaded = !!state()?.loaded
() => file.ready(), const ready = file.ready()
(ready) => { const active = tabs().active() === props.tab
if (!ready) return const restore = (loaded && !prev.loaded) || (ready && !prev.ready) || (active && loaded && !prev.active)
requestAnimationFrame(restoreScroll) prev = { loaded, ready, active }
}, if (!restore) return
{ defer: true }, queueRestore()
), })
)
createEffect(
on(
() => tabs().active() === props.tab,
(active) => {
if (!active) return
if (!state()?.loaded) return
requestAnimationFrame(restoreScroll)
},
),
)
onCleanup(() => { onCleanup(() => {
for (const item of codeScroll) { for (const item of codeScroll) {
item.removeEventListener("scroll", handleCodeScroll) item.removeEventListener("scroll", handleCodeScroll)
} }
if (scrollFrame === undefined) return if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame)
cancelAnimationFrame(scrollFrame) if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
}) })
const renderFile = (source: string) => ( const renderFile = (source: string) => (
@ -421,7 +414,7 @@ export function FileTabContent(props: { tab: string }) {
selectedLines={activeSelection()} selectedLines={activeSelection()}
commentedLines={commentedLines()} commentedLines={commentedLines()}
onRendered={() => { onRendered={() => {
requestAnimationFrame(restoreScroll) queueRestore()
}} }}
annotations={commentsUi.annotations()} annotations={commentsUi.annotations()}
renderAnnotation={commentsUi.renderAnnotation} renderAnnotation={commentsUi.renderAnnotation}
@ -440,7 +433,7 @@ export function FileTabContent(props: { tab: string }) {
mode: "auto", mode: "auto",
path: path(), path: path(),
current: state()?.content, current: state()?.content,
onLoad: () => requestAnimationFrame(restoreScroll), onLoad: queueRestore,
onError: (args: { kind: "image" | "audio" | "svg" }) => { onError: (args: { kind: "image" | "audio" | "svg" }) => {
if (args.kind !== "svg") return if (args.kind !== "svg") return
showToast({ showToast({

View File

@ -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 type { FileDiff } from "@opencode-ai/sdk/v2"
import { SessionReview } from "@opencode-ai/ui/session-review" import { SessionReview } from "@opencode-ai/ui/session-review"
import type { import type {
@ -119,32 +119,12 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
}) })
} }
createEffect( createEffect(() => {
on( props.diffs().length
() => props.diffs().length, props.diffStyle
() => queueRestore(), if (!layout.ready()) return
{ defer: true },
),
)
createEffect(
on(
() => props.diffStyle,
() => queueRestore(),
{ defer: true },
),
)
createEffect(
on(
() => layout.ready(),
(ready) => {
if (!ready) return
queueRestore() queueRestore()
}, })
{ defer: true },
),
)
onCleanup(() => { onCleanup(() => {
if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame) if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)

View File

@ -56,9 +56,9 @@ export function TerminalPanel() {
on( on(
() => terminal.all().length, () => terminal.all().length,
(count, prevCount) => { (count, prevCount) => {
if (prevCount !== undefined && prevCount > 0 && count === 0) { if (prevCount === undefined || prevCount <= 0 || count !== 0) return
if (opened()) view().terminal.toggle() if (!opened()) return
} close()
}, },
), ),
) )

View File

@ -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" import { UserMessage } from "@opencode-ai/sdk/v2"
export const messageIdFromHash = (hash: string) => { export const messageIdFromHash = (hash: string) => {
@ -28,6 +28,7 @@ export const useSessionHashScroll = (input: {
const visibleUserMessages = createMemo(() => input.visibleUserMessages()) const visibleUserMessages = createMemo(() => input.visibleUserMessages())
const messageById = createMemo(() => new Map(visibleUserMessages().map((m) => [m.id, m]))) const messageById = createMemo(() => new Map(visibleUserMessages().map((m) => [m.id, m])))
const messageIndex = createMemo(() => new Map(visibleUserMessages().map((m, i) => [m.id, i]))) const messageIndex = createMemo(() => new Map(visibleUserMessages().map((m, i) => [m.id, i])))
let pendingKey = ""
const clearMessageHash = () => { const clearMessageHash = () => {
if (!window.location.hash) return if (!window.location.hash) return
@ -130,15 +131,6 @@ export const useSessionHashScroll = (input: {
if (el) input.scheduleScrollState(el) 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(() => { createEffect(() => {
if (!input.sessionID() || !input.messagesReady()) return if (!input.sessionID() || !input.messagesReady()) return
requestAnimationFrame(() => applyHash("auto")) requestAnimationFrame(() => applyHash("auto"))
@ -150,7 +142,20 @@ export const useSessionHashScroll = (input: {
visibleUserMessages() visibleUserMessages()
input.turnStart() 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 (!targetId) return
if (input.currentMessageId() === targetId) return if (input.currentMessageId() === targetId) return
@ -162,9 +167,12 @@ export const useSessionHashScroll = (input: {
requestAnimationFrame(() => scrollToMessage(msg, "auto")) requestAnimationFrame(() => scrollToMessage(msg, "auto"))
}) })
createEffect(() => { onMount(() => {
const handler = () => {
if (!input.sessionID() || !input.messagesReady()) return if (!input.sessionID() || !input.messagesReady()) return
const handler = () => requestAnimationFrame(() => applyHash("auto")) requestAnimationFrame(() => applyHash("auto"))
}
window.addEventListener("hashchange", handler) window.addEventListener("hashchange", handler)
onCleanup(() => window.removeEventListener("hashchange", handler)) onCleanup(() => window.removeEventListener("hashchange", handler))
}) })