summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorBrendan Allan <[email protected]>2026-04-17 15:26:14 +0800
committerGitHub <[email protected]>2026-04-17 07:26:14 +0000
commita7265307355e7efed9e276da6625c96458009db6 (patch)
treeb938b4dae4fa7ffcf1f58e17aff499bc4c9da759
parentd9950598d0da16fc0f8e6c289050a9d3da055af7 (diff)
downloadopencode-a7265307355e7efed9e276da6625c96458009db6.tar.gz
opencode-a7265307355e7efed9e276da6625c96458009db6.zip
fix(app): workspace loading and persist ready state (#23046)
-rw-r--r--packages/app/src/context/global-sync.tsx1
-rw-r--r--packages/app/src/context/global-sync/child-store.ts1
-rw-r--r--packages/app/src/context/global-sync/types.ts1
-rw-r--r--packages/app/src/pages/layout.tsx364
-rw-r--r--packages/app/src/pages/layout/sidebar-workspace.tsx12
-rw-r--r--packages/app/src/utils/persist.ts2
6 files changed, 187 insertions, 194 deletions
diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx
index 1359b07b4..1a672639b 100644
--- a/packages/app/src/context/global-sync.tsx
+++ b/packages/app/src/context/global-sync.tsx
@@ -264,7 +264,6 @@ function createGlobalSync() {
children.pin(directory)
const promise = Promise.resolve().then(async () => {
const child = children.ensureChild(directory)
- child[1]("bootstrapPromise", promise!)
const cache = children.vcsCache.get(directory)
if (!cache) return
const sdk = sdkFor(directory)
diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts
index 6788e8cc5..3fe67e4fb 100644
--- a/packages/app/src/context/global-sync/child-store.ts
+++ b/packages/app/src/context/global-sync/child-store.ts
@@ -182,7 +182,6 @@ export function createChildStoreManager(input: {
limit: 5,
message: {},
part: {},
- bootstrapPromise: Promise.resolve(),
})
children[directory] = child
disposers.set(directory, dispose)
diff --git a/packages/app/src/context/global-sync/types.ts b/packages/app/src/context/global-sync/types.ts
index 28b3705d1..e3ec83c5e 100644
--- a/packages/app/src/context/global-sync/types.ts
+++ b/packages/app/src/context/global-sync/types.ts
@@ -72,7 +72,6 @@ export type State = {
part: {
[messageID: string]: Part[]
}
- bootstrapPromise: Promise<void>
}
export type VcsCache = {
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index 12a2bf763..6f6b3c555 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -13,7 +13,7 @@ import {
type Accessor,
} from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
-import { useNavigate, useParams } from "@solidjs/router"
+import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { useLayout, LocalProject } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync"
import { Persist, persisted } from "@/utils/persist"
@@ -127,6 +127,7 @@ export default function Layout(props: ParentProps) {
const theme = useTheme()
const language = useLanguage()
const initialDirectory = decode64(params.dir)
+ const location = useLocation()
const route = createMemo(() => {
const slug = params.dir
if (!slug) return { slug, dir: "" }
@@ -576,7 +577,7 @@ export default function Layout(props: ParentProps) {
return projects.find((p) => p.worktree === root)
})
-
+
const [autoselecting] = createResource(async () => {
await ready.promise
await layout.ready.promise
@@ -2102,196 +2103,198 @@ export default function Layout(props: ParentProps) {
</Show>
}
>
- <>
- <div class="shrink-0 pl-1 py-1">
- <div class="group/project flex items-start justify-between gap-2 py-2 pl-2 pr-0">
- <div class="flex flex-col min-w-0">
- <InlineEditor
- id={`project:${projectId()}`}
- value={projectName}
- onSave={(next) => {
- const item = project()
- if (!item) return
- void renameProject(item, next)
- }}
- class="text-14-medium text-text-strong truncate"
- displayClass="text-14-medium text-text-strong truncate"
- stopPropagation
- />
-
- <Tooltip
- placement="bottom"
- gutter={2}
- value={worktree()}
- class="shrink-0"
- contentStyle={{
- "max-width": "640px",
- transform: "translate3d(52px, 0, 0)",
- }}
- >
- <span class="text-12-regular text-text-base truncate select-text">
- {worktree().replace(homedir(), "~")}
- </span>
- </Tooltip>
- </div>
+ {(project) => (
+ <>
+ <div class="shrink-0 pl-1 py-1">
+ <div class="group/project flex items-start justify-between gap-2 py-2 pl-2 pr-0">
+ <div class="flex flex-col min-w-0">
+ <InlineEditor
+ id={`project:${projectId()}`}
+ value={projectName}
+ onSave={(next) => {
+ const item = project()
+ if (!item) return
+ void renameProject(item, next)
+ }}
+ class="text-14-medium text-text-strong truncate"
+ displayClass="text-14-medium text-text-strong truncate"
+ stopPropagation
+ />
+
+ <Tooltip
+ placement="bottom"
+ gutter={2}
+ value={worktree()}
+ class="shrink-0"
+ contentStyle={{
+ "max-width": "640px",
+ transform: "translate3d(52px, 0, 0)",
+ }}
+ >
+ <span class="text-12-regular text-text-base truncate select-text">
+ {worktree().replace(homedir(), "~")}
+ </span>
+ </Tooltip>
+ </div>
- <DropdownMenu modal={!sidebarHovering()}>
- <DropdownMenu.Trigger
- as={IconButton}
- icon="dot-grid"
- variant="ghost"
- data-action="project-menu"
- data-project={slug()}
- class="shrink-0 size-6 rounded-md transition-opacity data-[expanded]:bg-surface-base-active"
- classList={{
- "opacity-100": panelProps.mobile || merged(),
- "opacity-0 group-hover/project:opacity-100 group-focus-within/project:opacity-100 data-[expanded]:opacity-100":
- !panelProps.mobile && !merged(),
- }}
- aria-label={language.t("common.moreOptions")}
- />
- <DropdownMenu.Portal>
- <DropdownMenu.Content class="mt-1">
- <DropdownMenu.Item
- onSelect={() => {
- const item = project()
- if (!item) return
- showEditProjectDialog(item)
- }}
- >
- <DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
- </DropdownMenu.Item>
- <DropdownMenu.Item
- data-action="project-workspaces-toggle"
- data-project={slug()}
- disabled={!canToggle()}
- onSelect={() => {
- const item = project()
- if (!item) return
- toggleProjectWorkspaces(item)
- }}
- >
- <DropdownMenu.ItemLabel>
- {workspacesEnabled()
- ? language.t("sidebar.workspaces.disable")
- : language.t("sidebar.workspaces.enable")}
- </DropdownMenu.ItemLabel>
- </DropdownMenu.Item>
- <DropdownMenu.Item
- data-action="project-clear-notifications"
- data-project={slug()}
- disabled={unseenCount() === 0}
- onSelect={clearNotifications}
- >
- <DropdownMenu.ItemLabel>
- {language.t("sidebar.project.clearNotifications")}
- </DropdownMenu.ItemLabel>
- </DropdownMenu.Item>
- <DropdownMenu.Separator />
- <DropdownMenu.Item
- data-action="project-close-menu"
- data-project={slug()}
- onSelect={() => {
- const dir = worktree()
- if (!dir) return
- closeProject(dir)
- }}
- >
- <DropdownMenu.ItemLabel>{language.t("common.close")}</DropdownMenu.ItemLabel>
- </DropdownMenu.Item>
- </DropdownMenu.Content>
- </DropdownMenu.Portal>
- </DropdownMenu>
+ <DropdownMenu modal={!sidebarHovering()}>
+ <DropdownMenu.Trigger
+ as={IconButton}
+ icon="dot-grid"
+ variant="ghost"
+ data-action="project-menu"
+ data-project={slug()}
+ class="shrink-0 size-6 rounded-md transition-opacity data-[expanded]:bg-surface-base-active"
+ classList={{
+ "opacity-100": panelProps.mobile || merged(),
+ "opacity-0 group-hover/project:opacity-100 group-focus-within/project:opacity-100 data-[expanded]:opacity-100":
+ !panelProps.mobile && !merged(),
+ }}
+ aria-label={language.t("common.moreOptions")}
+ />
+ <DropdownMenu.Portal>
+ <DropdownMenu.Content class="mt-1">
+ <DropdownMenu.Item
+ onSelect={() => {
+ const item = project()
+ if (!item) return
+ showEditProjectDialog(item)
+ }}
+ >
+ <DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
+ </DropdownMenu.Item>
+ <DropdownMenu.Item
+ data-action="project-workspaces-toggle"
+ data-project={slug()}
+ disabled={!canToggle()}
+ onSelect={() => {
+ const item = project()
+ if (!item) return
+ toggleProjectWorkspaces(item)
+ }}
+ >
+ <DropdownMenu.ItemLabel>
+ {workspacesEnabled()
+ ? language.t("sidebar.workspaces.disable")
+ : language.t("sidebar.workspaces.enable")}
+ </DropdownMenu.ItemLabel>
+ </DropdownMenu.Item>
+ <DropdownMenu.Item
+ data-action="project-clear-notifications"
+ data-project={slug()}
+ disabled={unseenCount() === 0}
+ onSelect={clearNotifications}
+ >
+ <DropdownMenu.ItemLabel>
+ {language.t("sidebar.project.clearNotifications")}
+ </DropdownMenu.ItemLabel>
+ </DropdownMenu.Item>
+ <DropdownMenu.Separator />
+ <DropdownMenu.Item
+ data-action="project-close-menu"
+ data-project={slug()}
+ onSelect={() => {
+ const dir = worktree()
+ if (!dir) return
+ closeProject(dir)
+ }}
+ >
+ <DropdownMenu.ItemLabel>{language.t("common.close")}</DropdownMenu.ItemLabel>
+ </DropdownMenu.Item>
+ </DropdownMenu.Content>
+ </DropdownMenu.Portal>
+ </DropdownMenu>
+ </div>
</div>
- </div>
- <div class="flex-1 min-h-0 flex flex-col">
- <Show
- when={workspacesEnabled()}
- fallback={
+ <div class="flex-1 min-h-0 flex flex-col">
+ <Show
+ when={workspacesEnabled()}
+ fallback={
+ <>
+ <div class="shrink-0 py-4">
+ <Button
+ size="large"
+ icon="new-session"
+ class="w-full"
+ onClick={() => {
+ const dir = worktree()
+ if (!dir) return
+ navigateWithSidebarReset(`/${base64Encode(dir)}/session`)
+ }}
+ >
+ {language.t("command.session.new")}
+ </Button>
+ </div>
+ <div class="flex-1 min-h-0">
+ <LocalWorkspace
+ ctx={workspaceSidebarCtx}
+ project={project()}
+ sortNow={sortNow}
+ mobile={panelProps.mobile}
+ />
+ </div>
+ </>
+ }
+ >
<>
<div class="shrink-0 py-4">
<Button
size="large"
- icon="new-session"
+ icon="plus-small"
class="w-full"
onClick={() => {
- const dir = worktree()
- if (!dir) return
- navigateWithSidebarReset(`/${base64Encode(dir)}/session`)
+ const item = project()
+ if (!item) return
+ void createWorkspace(item)
}}
>
- {language.t("command.session.new")}
+ {language.t("workspace.new")}
</Button>
</div>
- <div class="flex-1 min-h-0">
- <LocalWorkspace
- ctx={workspaceSidebarCtx}
- project={project()!}
- sortNow={sortNow}
- mobile={panelProps.mobile}
- />
+ <div class="relative flex-1 min-h-0">
+ <DragDropProvider
+ onDragStart={handleWorkspaceDragStart}
+ onDragEnd={handleWorkspaceDragEnd}
+ onDragOver={handleWorkspaceDragOver}
+ collisionDetector={closestCenter}
+ >
+ <DragDropSensors />
+ <ConstrainDragXAxis />
+ <div
+ ref={(el) => {
+ if (!panelProps.mobile) scrollContainerRef = el
+ }}
+ class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]"
+ >
+ <SortableProvider ids={workspaces()}>
+ <For each={workspaces()}>
+ {(directory) => (
+ <SortableWorkspace
+ ctx={workspaceSidebarCtx}
+ directory={directory}
+ project={project()}
+ sortNow={sortNow}
+ mobile={panelProps.mobile}
+ />
+ )}
+ </For>
+ </SortableProvider>
+ </div>
+ <DragOverlay>
+ <WorkspaceDragOverlay
+ sidebarProject={sidebarProject}
+ activeWorkspace={() => store.activeWorkspace}
+ workspaceLabel={workspaceLabel}
+ />
+ </DragOverlay>
+ </DragDropProvider>
</div>
</>
- }
- >
- <>
- <div class="shrink-0 py-4">
- <Button
- size="large"
- icon="plus-small"
- class="w-full"
- onClick={() => {
- const item = project()
- if (!item) return
- void createWorkspace(item)
- }}
- >
- {language.t("workspace.new")}
- </Button>
- </div>
- <div class="relative flex-1 min-h-0">
- <DragDropProvider
- onDragStart={handleWorkspaceDragStart}
- onDragEnd={handleWorkspaceDragEnd}
- onDragOver={handleWorkspaceDragOver}
- collisionDetector={closestCenter}
- >
- <DragDropSensors />
- <ConstrainDragXAxis />
- <div
- ref={(el) => {
- if (!panelProps.mobile) scrollContainerRef = el
- }}
- class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]"
- >
- <SortableProvider ids={workspaces()}>
- <For each={workspaces()}>
- {(directory) => (
- <SortableWorkspace
- ctx={workspaceSidebarCtx}
- directory={directory}
- project={project()!}
- sortNow={sortNow}
- mobile={panelProps.mobile}
- />
- )}
- </For>
- </SortableProvider>
- </div>
- <DragOverlay>
- <WorkspaceDragOverlay
- sidebarProject={sidebarProject}
- activeWorkspace={() => store.activeWorkspace}
- workspaceLabel={workspaceLabel}
- />
- </DragOverlay>
- </DragDropProvider>
- </div>
- </>
- </Show>
- </div>
- </>
+ </Show>
+ </div>
+ </>
+ )}
</Show>
<div
@@ -2355,14 +2358,9 @@ export default function Layout(props: ParentProps) {
/>
)
- const [loading] = createResource(
- () => route()?.store?.[0]?.bootstrapPromise,
- (p) => p,
- )
-
return (
<div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
- {(autoselecting(), loading()) ?? ""}
+ {autoselecting() ?? ""}
<Titlebar />
<div class="flex-1 min-h-0 min-w-0 flex">
<div class="flex-1 min-h-0 relative">
diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx
index c1836fa8a..0202cfc3b 100644
--- a/packages/app/src/pages/layout/sidebar-workspace.tsx
+++ b/packages/app/src/pages/layout/sidebar-workspace.tsx
@@ -317,12 +317,11 @@ export const SortableWorkspace = (props: {
})
const open = createMemo(() => props.ctx.workspaceExpanded(props.directory, local()))
const boot = createMemo(() => open() || active())
- const booted = createMemo((prev) => prev || workspaceStore.status === "complete", false)
const count = createMemo(() => sessions()?.length ?? 0)
const hasMore = createMemo(() => workspaceStore.sessionTotal > count())
+ const query = useQuery(() => ({ ...loadSessionsQuery(props.project.worktree) }))
const busy = createMemo(() => props.ctx.isBusy(props.directory))
- const wasBusy = createMemo((prev) => prev || busy(), false)
- const loading = createMemo(() => open() && !booted() && count() === 0 && !wasBusy())
+ const loading = () => query.isLoading
const touch = createMediaQuery("(hover: none)")
const showNew = createMemo(() => !loading() && (touch() || count() === 0 || (active() && !params.id)))
const loadMore = async () => {
@@ -427,7 +426,7 @@ export const SortableWorkspace = (props: {
mobile={props.mobile}
ctx={props.ctx}
showNew={showNew}
- loading={loading}
+ loading={() => query.isLoading && count() === 0}
sessions={sessions}
hasMore={hasMore}
loadMore={loadMore}
@@ -453,11 +452,10 @@ export const LocalWorkspace = (props: {
})
const slug = createMemo(() => base64Encode(props.project.worktree))
const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow()))
- const booted = createMemo((prev) => prev || workspace().store.status === "complete", false)
const count = createMemo(() => sessions()?.length ?? 0)
const query = useQuery(() => ({ ...loadSessionsQuery(props.project.worktree) }))
- const loading = createMemo(() => query.isPending && count() === 0)
const hasMore = createMemo(() => workspace().store.sessionTotal > count())
+ const loading = () => query.isLoading && count() === 0
const loadMore = async () => {
workspace().setStore("limit", (limit) => (limit ?? 0) + 5)
await globalSync.project.loadSessions(props.project.worktree)
@@ -473,7 +471,7 @@ export const LocalWorkspace = (props: {
mobile={props.mobile}
ctx={props.ctx}
showNew={() => false}
- loading={() => query.isLoading}
+ loading={loading}
sessions={sessions}
hasMore={hasMore}
loadMore={loadMore}
diff --git a/packages/app/src/utils/persist.ts b/packages/app/src/utils/persist.ts
index dce0e94c3..0cac30cb1 100644
--- a/packages/app/src/utils/persist.ts
+++ b/packages/app/src/utils/persist.ts
@@ -469,7 +469,7 @@ export function persisted<T>(
state,
setState,
init,
- Object.assign(() => ready() === true, {
+ Object.assign(() => (ready.loading ? false : ready.latest === true), {
promise: init instanceof Promise ? init : undefined,
}),
]