feat(app): refresh session location dock (#56087)

This commit is contained in:
usrnk1
2026-10-16 16:23:11 +02:00
committed by GitHub
Unverified
parent 3a58a0c9cf
commit 9a7bfb063c
3 changed files with 204 additions and 26 deletions
@@ -107,7 +107,8 @@ test("replaces the composer when the location is not found and recovers by movin
})
await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`)
const status = page.getByRole("status").filter({ hasText: "Session location unavailable" })
const status = page.getByRole("group", { name: "Session location unavailable", exact: true })
await expect(status).toHaveAccessibleDescription(`${directory} Choose another directory to continue this session.`)
await expect(status).toContainText(directory)
await expect(page.getByText("Keep this session history", { exact: true })).toBeVisible()
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toHaveCount(0)
@@ -0,0 +1,73 @@
[data-component="session-location-missing"] {
display: flex;
flex-direction: column;
font-family: var(--v2-font-family-sans);
[data-slot="location-body"] {
padding: 8px;
background-color: var(--v2-background-bg-base);
border-radius: 10px;
box-shadow: var(--v2-elevation-raised);
}
[data-slot="location-status"] {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 4px;
> [data-slot="icon-svg"] {
flex-shrink: 0;
color: light-dark(var(--v2-yellow-800), var(--v2-yellow-700));
}
}
[data-slot="location-details"] {
display: flex;
flex-direction: column;
gap: 4px;
min-inline-size: 0;
}
[data-slot="location-title"] {
font-size: 13px;
font-weight: 530;
line-height: var(--line-height-base);
color: var(--v2-text-text-base);
}
[data-slot="location-path"] {
font-family: var(--v2-font-family-code);
font-size: 12px;
line-height: var(--line-height-base);
color: var(--v2-text-text-faint);
overflow-wrap: anywhere;
-webkit-user-select: text;
user-select: text;
}
[data-slot="location-description"] {
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-base);
color: var(--v2-text-text-muted);
}
[data-slot="location-footer"] {
display: flex;
flex-wrap: wrap;
box-sizing: border-box;
align-items: center;
justify-content: flex-end;
gap: 8px;
padding-block: 16px 8px;
padding-inline: 12px;
margin-block-start: -9px;
border: 0.5px solid var(--v2-border-border-base);
border-start-start-radius: 0;
border-start-end-radius: 0;
border-end-start-radius: 10px;
border-end-end-radius: 10px;
background-color: var(--v2-background-bg-layer-01);
}
}
@@ -2,16 +2,19 @@ import { Button } from "@opencode/ui/button"
import { useDialog } from "@opencode/ui/context/dialog"
import { DockShell, DockTray } from "@opencode/ui/dock-surface"
import { Icon } from "@opencode/ui/icon"
import { Loader } from "@opencode/ui/loader"
import { Menu } from "@opencode/ui/menu"
import { getFilename, sameDirectory } from "@opencode/util/path"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useMutation } from "@tanstack/solid-query"
import { createEffect, createResource, For, Show } from "solid-js"
import { createEffect, createResource, createUniqueId, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import { useData, useServer } from "@/runtime/server/current"
import { showToast } from "@/shell/notifications/toast"
import { createWorktree } from "@/workspaces/create"
import { useDirectoryPicker } from "@/workspaces/selection/picker"
import "./location-missing.css"
export function SessionLocationMissing(props: { sessionID: string; projectID: string; directory: string }) {
const language = useLanguage()
@@ -19,7 +22,7 @@ export function SessionLocationMissing(props: { sessionID: string; projectID: st
const data = useData()
const dialog = useDialog()
const pickDirectory = useDirectoryPicker()
const [state, setState] = createStore({ restoreFocus: false, worktreesOpen: false })
const [state, setState] = createStore({ restoreFocus: false, worktreesOpen: false, search: "" })
const project = () => data.project.get(props.projectID)
const [worktrees] = createResource(
@@ -39,10 +42,22 @@ export function SessionLocationMissing(props: { sessionID: string; projectID: st
const otherWorktrees = () =>
worktrees.latest.filter((item) => item.strategy && !sameDirectory(item.directory, props.directory))
const searchable = () => otherWorktrees().length >= 10
const visibleWorktrees = () => {
const query = state.search.trim().toLowerCase()
const matches = query
? otherWorktrees().filter((item) => getFilename(item.directory).toLowerCase().includes(query))
: otherWorktrees()
return matches.slice(0, 10)
}
let button: HTMLButtonElement | undefined
let searchInput: HTMLInputElement | undefined
const move = useMutation(() => ({
mutationFn: async (input: { directory?: string }) => {
mutationFn: async (input: { source: "worktree" | "directory"; directory?: string }) => {
// The missing worktree cannot resolve its own Location, so create from the project's saved checkout.
const current = project()
@@ -69,6 +84,9 @@ export function SessionLocationMissing(props: { sessionID: string; projectID: st
},
}))
const pending = (source: "worktree" | "directory") => move.isPending && move.variables?.source === source
const id = createUniqueId()
createEffect(() => {
if (!state.restoreFocus || move.isPending || dialog.active) return
setState("restoreFocus", false)
@@ -83,7 +101,7 @@ export function SessionLocationMissing(props: { sessionID: string; projectID: st
onSelect: (result) => {
const selected = Array.isArray(result) ? result[0] : result
if (selected) move.mutate({ directory: selected })
if (selected) move.mutate({ source: "directory", directory: selected })
if (!selected) setState("restoreFocus", true)
},
@@ -92,40 +110,117 @@ export function SessionLocationMissing(props: { sessionID: string; projectID: st
return (
<div data-component="session-location-missing">
<DockShell class="flex flex-col gap-2 p-3">
<div role="status" class="flex items-start gap-2 text-13-regular leading-[var(--line-height-base)]">
<Icon name="warning" class="shrink-0 text-icon-warning-base" />
<div class="min-w-0 flex flex-col gap-1">
<div class="font-medium text-text-strong">{language.t("session.location.unavailable")}</div>
<div class="break-all font-mono text-12-regular text-text-weak">{props.directory}</div>
<div class="text-text-base">{language.t("session.location.description")}</div>
<DockShell data-slot="location-body">
<div
role="group"
aria-labelledby={`${id}-title`}
aria-describedby={`${id}-path ${id}-description`}
data-slot="location-status"
>
<Icon name="warning" />
<div data-slot="location-details">
<div id={`${id}-title`} role="status" data-slot="location-title">
{language.t("session.location.unavailable")}
</div>
<div id={`${id}-path`} data-slot="location-path">
{props.directory}
</div>
<div id={`${id}-description`} data-slot="location-description">
{language.t("session.location.description")}
</div>
</div>
</div>
</DockShell>
<DockTray attach="top" class="flex flex-wrap justify-end gap-2 p-2 pt-[22px]">
<DockTray data-slot="location-footer">
<Show when={project()?.vcs === "git"}>
<Menu placement="top-end" onOpenChange={(open) => setState("worktreesOpen", open)}>
<Menu.Trigger as={Button} variant="neutral" disabled={move.isPending}>
{language.t("session.location.worktree")}
<Menu placement="top-end" onOpenChange={(open) => setState({ worktreesOpen: open, search: "" })}>
<Menu.Trigger
as={Button}
variant={pending("worktree") ? "loading" : "neutral"}
size="large"
disabled={move.isPending}
>
<Show when={pending("worktree")}>
<Loader />
</Show>
{language.t(pending("worktree") ? "session.location.moving" : "session.location.worktree")}
<Show when={!pending("worktree")}>
<Icon name="chevron-down" size="small" />
</Show>
</Menu.Trigger>
<Menu.Portal>
<Menu.Content class="max-h-80 max-w-[calc(100vw-32px)] overflow-y-auto">
<Menu.Item onSelect={() => move.mutate({})} disabled={move.isPending}>
<Icon name="workspace-new" />
{language.t("workspace.new")}
<Menu.Content
ref={(element: HTMLElement) =>
// Hovering an item moves focus to it, so send typing back to the search field before type-ahead runs.
makeEventListener(
element,
"keydown",
(event) => {
if (!searchInput || event.target === searchInput) return
if (event.ctrlKey || event.metaKey || event.altKey) return
if (event.key.length !== 1 && event.key !== "Backspace") return
if (event.key === " " && !state.search) return
event.stopPropagation()
searchInput.focus({ preventScroll: true })
},
{ capture: true },
)
}
class="max-h-[66.667dvh] w-[200px] overflow-y-auto"
>
<Menu.Item onSelect={() => move.mutate({ source: "worktree" })} disabled={move.isPending}>
<Icon name="plus" />
<span class="min-w-0 flex-1 truncate">{language.t("workspace.new")}</span>
</Menu.Item>
<Show when={worktrees.loading || otherWorktrees().length > 0}>
<Menu.Separator class="h-[0.5px]" />
</Show>
<Show when={worktrees.loading && otherWorktrees().length === 0}>
<Menu.Item disabled>{language.t("common.loading")}</Menu.Item>
</Show>
<For each={otherWorktrees()}>
<Show when={searchable()}>
<div class="flex h-7 items-center gap-2 rounded-sm ps-3 pe-2 text-v2-icon-icon-muted">
<Icon name="magnifying-glass" size="small" class="shrink-0" />
<input
// Worktrees load after the menu opens, so focus the field once it mounts.
ref={(element) => {
searchInput = element
requestAnimationFrame(() =>
requestAnimationFrame(() => element.focus({ preventScroll: true })),
)
}}
value={state.search}
placeholder={language.t("session.new.workspace.search.placeholder")}
aria-label={language.t("session.new.workspace.search.placeholder")}
class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
onInput={(event) => setState("search", event.currentTarget.value)}
onKeyDown={(event) => {
if (
event.key === "Escape" ||
event.key === "ArrowDown" ||
event.key === "ArrowUp" ||
event.key === "Enter"
)
return
event.stopPropagation()
}}
/>
</div>
</Show>
<Show when={state.search.trim() && visibleWorktrees().length === 0}>
<div class="px-3 py-4 text-center text-[13px] font-[440] leading-5 text-v2-text-text-muted">
{language.t("session.new.workspace.search.empty")}
</div>
</Show>
<For each={visibleWorktrees()}>
{(worktree) => (
<Menu.Item
title={worktree.directory}
onSelect={() => move.mutate({ directory: worktree.directory })}
onSelect={() => move.mutate({ source: "worktree", directory: worktree.directory })}
disabled={move.isPending}
>
<Icon name="workspace-isolated" />
<span class="truncate">{getFilename(worktree.directory)}</span>
<Icon name="outline-worktree" />
<span class="min-w-0 flex-1 truncate">{getFilename(worktree.directory)}</span>
</Menu.Item>
)}
</For>
@@ -133,8 +228,17 @@ export function SessionLocationMissing(props: { sessionID: string; projectID: st
</Menu.Portal>
</Menu>
</Show>
<Button ref={button} variant="contrast" onClick={choose} disabled={move.isPending}>
{language.t(move.isPending ? "session.location.moving" : "session.location.choose")}
<Button
ref={button}
variant={pending("directory") ? "loading" : "submit"}
size="large"
onClick={choose}
disabled={move.isPending}
>
<Show when={pending("directory")}>
<Loader />
</Show>
{language.t(pending("directory") ? "session.location.moving" : "session.location.choose")}
</Button>
</DockTray>
</div>