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 (