diff --git a/packages/app/e2e/regression/session-location-sync.spec.ts b/packages/app/e2e/regression/session-location-sync.spec.ts index 4fd9cd55baa..63c23653104 100644 --- a/packages/app/e2e/regression/session-location-sync.spec.ts +++ b/packages/app/e2e/regression/session-location-sync.spec.ts @@ -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) diff --git a/packages/app/src/session/composer/location-missing.css b/packages/app/src/session/composer/location-missing.css new file mode 100644 index 00000000000..da1895573a6 --- /dev/null +++ b/packages/app/src/session/composer/location-missing.css @@ -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); + } +} diff --git a/packages/app/src/session/composer/location-missing.tsx b/packages/app/src/session/composer/location-missing.tsx index c6ae1db1433..09b611bdd5b 100644 --- a/packages/app/src/session/composer/location-missing.tsx +++ b/packages/app/src/session/composer/location-missing.tsx @@ -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 (
- -
- -
-
{language.t("session.location.unavailable")}
-
{props.directory}
-
{language.t("session.location.description")}
+ +
+ +
+
+ {language.t("session.location.unavailable")} +
+
+ {props.directory} +
+
+ {language.t("session.location.description")} +
- + - setState("worktreesOpen", open)}> - - {language.t("session.location.worktree")} + setState({ worktreesOpen: open, search: "" })}> + + + + + {language.t(pending("worktree") ? "session.location.moving" : "session.location.worktree")} + + + - - move.mutate({})} disabled={move.isPending}> - - {language.t("workspace.new")} + + // 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" + > + move.mutate({ source: "worktree" })} disabled={move.isPending}> + + {language.t("workspace.new")} + 0}> + + {language.t("common.loading")} - + +
+ + { + 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() + }} + /> +
+
+ +
+ {language.t("session.new.workspace.search.empty")} +
+
+ {(worktree) => ( move.mutate({ directory: worktree.directory })} + onSelect={() => move.mutate({ source: "worktree", directory: worktree.directory })} disabled={move.isPending} > - - {getFilename(worktree.directory)} + + {getFilename(worktree.directory)} )} @@ -133,8 +228,17 @@ export function SessionLocationMissing(props: { sessionID: string; projectID: st
-