fix(app): workspace loading and persist ready state (#23046)

This commit is contained in:
Brendan Allan
2026-04-17 07:26:14 +00:00
committed by GitHub
parent d9950598d0
commit a726530735
6 changed files with 186 additions and 193 deletions
-1
View File
@@ -264,7 +264,6 @@ function createGlobalSync() {
children.pin(directory) children.pin(directory)
const promise = Promise.resolve().then(async () => { const promise = Promise.resolve().then(async () => {
const child = children.ensureChild(directory) const child = children.ensureChild(directory)
child[1]("bootstrapPromise", promise!)
const cache = children.vcsCache.get(directory) const cache = children.vcsCache.get(directory)
if (!cache) return if (!cache) return
const sdk = sdkFor(directory) const sdk = sdkFor(directory)
@@ -182,7 +182,6 @@ export function createChildStoreManager(input: {
limit: 5, limit: 5,
message: {}, message: {},
part: {}, part: {},
bootstrapPromise: Promise.resolve(),
}) })
children[directory] = child children[directory] = child
disposers.set(directory, dispose) disposers.set(directory, dispose)
@@ -72,7 +72,6 @@ export type State = {
part: { part: {
[messageID: string]: Part[] [messageID: string]: Part[]
} }
bootstrapPromise: Promise<void>
} }
export type VcsCache = { export type VcsCache = {
+180 -182
View File
@@ -13,7 +13,7 @@ import {
type Accessor, type Accessor,
} from "solid-js" } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener" 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 { useLayout, LocalProject } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync" import { useGlobalSync } from "@/context/global-sync"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
@@ -127,6 +127,7 @@ export default function Layout(props: ParentProps) {
const theme = useTheme() const theme = useTheme()
const language = useLanguage() const language = useLanguage()
const initialDirectory = decode64(params.dir) const initialDirectory = decode64(params.dir)
const location = useLocation()
const route = createMemo(() => { const route = createMemo(() => {
const slug = params.dir const slug = params.dir
if (!slug) return { slug, dir: "" } if (!slug) return { slug, dir: "" }
@@ -576,7 +577,7 @@ export default function Layout(props: ParentProps) {
return projects.find((p) => p.worktree === root) return projects.find((p) => p.worktree === root)
}) })
const [autoselecting] = createResource(async () => { const [autoselecting] = createResource(async () => {
await ready.promise await ready.promise
await layout.ready.promise await layout.ready.promise
@@ -2102,196 +2103,198 @@ export default function Layout(props: ParentProps) {
</Show> </Show>
} }
> >
<> {(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="shrink-0 pl-1 py-1">
<div class="flex flex-col min-w-0"> <div class="group/project flex items-start justify-between gap-2 py-2 pl-2 pr-0">
<InlineEditor <div class="flex flex-col min-w-0">
id={`project:${projectId()}`} <InlineEditor
value={projectName} id={`project:${projectId()}`}
onSave={(next) => { value={projectName}
const item = project() onSave={(next) => {
if (!item) return const item = project()
void renameProject(item, next) if (!item) return
}} void renameProject(item, next)
class="text-14-medium text-text-strong truncate" }}
displayClass="text-14-medium text-text-strong truncate" class="text-14-medium text-text-strong truncate"
stopPropagation displayClass="text-14-medium text-text-strong truncate"
/> stopPropagation
/>
<Tooltip <Tooltip
placement="bottom" placement="bottom"
gutter={2} gutter={2}
value={worktree()} value={worktree()}
class="shrink-0" class="shrink-0"
contentStyle={{ contentStyle={{
"max-width": "640px", "max-width": "640px",
transform: "translate3d(52px, 0, 0)", transform: "translate3d(52px, 0, 0)",
}} }}
> >
<span class="text-12-regular text-text-base truncate select-text"> <span class="text-12-regular text-text-base truncate select-text">
{worktree().replace(homedir(), "~")} {worktree().replace(homedir(), "~")}
</span> </span>
</Tooltip> </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>
</div> </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>
</div> </div>
</div>
<div class="flex-1 min-h-0 flex flex-col"> <div class="flex-1 min-h-0 flex flex-col">
<Show <Show
when={workspacesEnabled()} when={workspacesEnabled()}
fallback={ 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"> <div class="shrink-0 py-4">
<Button <Button
size="large" size="large"
icon="new-session" icon="plus-small"
class="w-full" class="w-full"
onClick={() => { onClick={() => {
const dir = worktree() const item = project()
if (!dir) return if (!item) return
navigateWithSidebarReset(`/${base64Encode(dir)}/session`) void createWorkspace(item)
}} }}
> >
{language.t("command.session.new")} {language.t("workspace.new")}
</Button> </Button>
</div> </div>
<div class="flex-1 min-h-0"> <div class="relative flex-1 min-h-0">
<LocalWorkspace <DragDropProvider
ctx={workspaceSidebarCtx} onDragStart={handleWorkspaceDragStart}
project={project()!} onDragEnd={handleWorkspaceDragEnd}
sortNow={sortNow} onDragOver={handleWorkspaceDragOver}
mobile={panelProps.mobile} 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>
</> </>
} </Show>
> </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> </Show>
<div <div
@@ -2355,14 +2358,9 @@ export default function Layout(props: ParentProps) {
/> />
) )
const [loading] = createResource(
() => route()?.store?.[0]?.bootstrapPromise,
(p) => p,
)
return ( 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"> <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 /> <Titlebar />
<div class="flex-1 min-h-0 min-w-0 flex"> <div class="flex-1 min-h-0 min-w-0 flex">
<div class="flex-1 min-h-0 relative"> <div class="flex-1 min-h-0 relative">
@@ -317,12 +317,11 @@ export const SortableWorkspace = (props: {
}) })
const open = createMemo(() => props.ctx.workspaceExpanded(props.directory, local())) const open = createMemo(() => props.ctx.workspaceExpanded(props.directory, local()))
const boot = createMemo(() => open() || active()) const boot = createMemo(() => open() || active())
const booted = createMemo((prev) => prev || workspaceStore.status === "complete", false)
const count = createMemo(() => sessions()?.length ?? 0) const count = createMemo(() => sessions()?.length ?? 0)
const hasMore = createMemo(() => workspaceStore.sessionTotal > count()) const hasMore = createMemo(() => workspaceStore.sessionTotal > count())
const query = useQuery(() => ({ ...loadSessionsQuery(props.project.worktree) }))
const busy = createMemo(() => props.ctx.isBusy(props.directory)) const busy = createMemo(() => props.ctx.isBusy(props.directory))
const wasBusy = createMemo((prev) => prev || busy(), false) const loading = () => query.isLoading
const loading = createMemo(() => open() && !booted() && count() === 0 && !wasBusy())
const touch = createMediaQuery("(hover: none)") const touch = createMediaQuery("(hover: none)")
const showNew = createMemo(() => !loading() && (touch() || count() === 0 || (active() && !params.id))) const showNew = createMemo(() => !loading() && (touch() || count() === 0 || (active() && !params.id)))
const loadMore = async () => { const loadMore = async () => {
@@ -427,7 +426,7 @@ export const SortableWorkspace = (props: {
mobile={props.mobile} mobile={props.mobile}
ctx={props.ctx} ctx={props.ctx}
showNew={showNew} showNew={showNew}
loading={loading} loading={() => query.isLoading && count() === 0}
sessions={sessions} sessions={sessions}
hasMore={hasMore} hasMore={hasMore}
loadMore={loadMore} loadMore={loadMore}
@@ -453,11 +452,10 @@ export const LocalWorkspace = (props: {
}) })
const slug = createMemo(() => base64Encode(props.project.worktree)) const slug = createMemo(() => base64Encode(props.project.worktree))
const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow())) 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 count = createMemo(() => sessions()?.length ?? 0)
const query = useQuery(() => ({ ...loadSessionsQuery(props.project.worktree) })) const query = useQuery(() => ({ ...loadSessionsQuery(props.project.worktree) }))
const loading = createMemo(() => query.isPending && count() === 0)
const hasMore = createMemo(() => workspace().store.sessionTotal > count()) const hasMore = createMemo(() => workspace().store.sessionTotal > count())
const loading = () => query.isLoading && count() === 0
const loadMore = async () => { const loadMore = async () => {
workspace().setStore("limit", (limit) => (limit ?? 0) + 5) workspace().setStore("limit", (limit) => (limit ?? 0) + 5)
await globalSync.project.loadSessions(props.project.worktree) await globalSync.project.loadSessions(props.project.worktree)
@@ -473,7 +471,7 @@ export const LocalWorkspace = (props: {
mobile={props.mobile} mobile={props.mobile}
ctx={props.ctx} ctx={props.ctx}
showNew={() => false} showNew={() => false}
loading={() => query.isLoading} loading={loading}
sessions={sessions} sessions={sessions}
hasMore={hasMore} hasMore={hasMore}
loadMore={loadMore} loadMore={loadMore}
+1 -1
View File
@@ -469,7 +469,7 @@ export function persisted<T>(
state, state,
setState, setState,
init, init,
Object.assign(() => ready() === true, { Object.assign(() => (ready.loading ? false : ready.latest === true), {
promise: init instanceof Promise ? init : undefined, promise: init instanceof Promise ? init : undefined,
}), }),
] ]