Compare commits

...

10 Commits

Author SHA1 Message Date
Kit Langton e5121e0251 fix(tui): avoid reactive location sync loop 2026-08-13 14:26:10 -04:00
Kit Langton 48b3635dfe Merge remote-tracking branch 'origin/v2' into recovery-panel 2026-08-13 14:00:42 -04:00
Kit Langton 53c94759c5 refactor(tui): tighten location recovery 2026-08-13 13:35:00 -04:00
Kit Langton 0a25525064 fix(tui): label recovery confirmation 2026-08-13 13:29:26 -04:00
Kit Langton f80639214e fix(tui): flatten filtered move results 2026-08-13 13:24:46 -04:00
Kit Langton d76195fe3b fix(tui): preserve move dialog title 2026-08-13 13:21:09 -04:00
Kit Langton ed70241753 refactor(tui): simplify directory recovery 2026-08-13 13:17:39 -04:00
Kit Langton 20ddb570cc fix(tui): focus recovery directory choices 2026-08-13 13:06:58 -04:00
Kit Langton 4a7444ecee refactor(tui): reuse session question panel 2026-08-13 12:57:42 -04:00
Kit Langton 6bafc34408 feat(tui): prototype missing location recovery 2026-08-13 12:17:32 -04:00
7 changed files with 221 additions and 92 deletions
@@ -31,6 +31,7 @@ type DialogMoveSessionProps = {
onSelect: (selection: MoveSessionSelection) => void
onCurrentChange?: (selection: MoveSessionSelection) => void
initialDirectories?: ReadonlyArray<ProjectDirectory>
fixture?: boolean
initialRemoving?: string
}
@@ -75,7 +76,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
})
const [directories, { refetch }] = createResource(
() => (props.initialRemoving ? undefined : props.projectID),
() => (props.fixture || props.initialRemoving ? undefined : props.projectID),
async (projectID, info): Promise<ReadonlyArray<ProjectDirectory> | undefined> => {
try {
const requestLocation = { directory: location()?.directory || paths.cwd }
@@ -110,11 +111,9 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
if (showError()) return
const directory = currentDirectory()
if (!directory) return
return (
directoryData()
?.filter((root) => contains(root.directory, directory))
.toSorted((a, b) => b.directory.length - a.directory.length)[0] ?? { directory }
)
return directoryData()
?.filter((root) => contains(root.directory, directory))
.toSorted((a, b) => b.directory.length - a.directory.length)[0]
})
const options = createMemo<DialogSelectOption<MoveSessionSelection | undefined>[]>(() => {
@@ -123,7 +122,6 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
const current = currentRoot()?.directory
if (directories.loading && !data && !current) return []
const roots = [...(data ?? [])]
if (current && !roots.some((item) => item.directory === current)) roots.unshift({ directory: current })
roots.sort((a, b) => {
if (a.directory === current) return -1
if (b.directory === current) return 1
@@ -139,15 +137,13 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
(session) => session.projectID === props.projectID && session.subpath && ![".", "/"].includes(session.subpath),
)
.map((session) => session.location.directory)
.filter((directory) => currentRoot() || directory !== currentDirectory())
.filter((directory) => !roots.some((root) => root.directory === directory))
.filter((directory, index, directories) => directories.indexOf(directory) === index)
.map((location) => ({
location,
root: roots
.filter((root) => {
const relative = path.relative(root.directory, location)
return relative && relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative)
})
.filter((root) => contains(root.directory, location))
.toSorted((a, b) => b.directory.length - a.directory.length)[0],
}))
.filter((item): item is { location: string; root: ProjectDirectory } => item.root !== undefined)
@@ -325,6 +321,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
</box>
}
renderFilter={!showError()}
flat={true}
options={options()}
emptyView={
showError() ? (
@@ -357,7 +354,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
}}
onMove={() => setToDelete(undefined)}
actions={
showError()
showError() || props.fixture
? []
: [
{
+18 -1
View File
@@ -7,6 +7,7 @@ const context = createContext<{
readonly current: LocationGetOutput | undefined
// The target location as set, available before the server-synced info in `current` arrives.
readonly ref: LocationRef | undefined
readonly error: { readonly location: LocationRef; readonly cause: unknown } | undefined
set: (location?: LocationRef) => void
}>()
@@ -14,16 +15,29 @@ export function LocationProvider(props: ParentProps) {
const client = useClient()
const data = useData()
const [ref, setRef] = createSignal<LocationRef>()
const [error, setError] = createSignal<{ readonly location: LocationRef; readonly cause: unknown }>()
let generation = 0
const current = createMemo(() => data.location.info(ref()))
function sync(location?: LocationRef) {
if (!location) return
const attempt = ++generation
const defaultLocation = data.location.default()
const target =
location.directory === defaultLocation.directory && location.workspaceID === defaultLocation.workspaceID
? undefined
: location
void data.location.sync(target).catch(() => undefined)
setError(undefined)
void data.location.sync(target).catch((cause) => {
const current = ref()
if (
generation !== attempt ||
current?.directory !== location.directory ||
current.workspaceID !== location.workspaceID
)
return
setError({ location, cause })
})
}
function set(location?: LocationRef) {
@@ -42,6 +56,9 @@ export function LocationProvider(props: ParentProps) {
get ref() {
return ref()
},
get error() {
return error()
},
set,
}}
>
@@ -3,6 +3,7 @@ import { useTerminalDimensions } from "@opentui/solid"
import { createSignal, For, type JSX } from "solid-js"
import { StoryFooter } from "./footer"
import { sessionTabsStory } from "./session-tabs"
import { sessionLocationMissingStory } from "./session-location-missing"
/**
* A story is a full-screen, fixture-driven simulation of a real production component. Stories own
@@ -14,7 +15,7 @@ export type Story = {
render: (context: Plugin.Context) => JSX.Element
}
const stories: Story[] = [sessionTabsStory]
const stories: Story[] = [sessionTabsStory, sessionLocationMissingStory]
function Commands(props: { context: Plugin.Context }) {
props.context.keymap.layer(() => ({
@@ -0,0 +1,80 @@
import type { Plugin } from "@opencode-ai/plugin/tui"
import { useTerminalDimensions } from "@opentui/solid"
import { TextAttributes } from "@opentui/core"
import { createSignal } from "solid-js"
import { DialogMoveSession } from "../../../component/dialog-move-session"
import { SessionLocationUnavailable } from "../../../routes/session/location-missing"
import type { Story } from "./index"
import { StoryFooter } from "./footer"
const directory = "/Users/kit/code/open-source/opencode-workerd-profile"
function SessionLocationMissingStory(props: { context: Plugin.Context }) {
const dimensions = useTerminalDimensions()
const theme = props.context.theme.contextual.elevated
const [message, setMessage] = createSignal("Choose another directory to continue")
const open = () =>
props.context.ui.dialog.show(() => (
<DialogMoveSession
projectID="fixture-project"
initialDirectories={[
{ directory: "/Users/kit/code/open-source/opencode" },
{
directory: "/Users/kit/code/open-source/opencode-instruction-rename",
strategy: "git_worktree",
},
]}
fixture
onSelect={(selection) => {
if (selection.type !== "directory") return
setMessage(`Selected ${selection.directory}`)
props.context.ui.dialog.clear()
}}
/>
))
props.context.keymap.layer(() => ({
commands: [
{
bind: "escape",
title: "Back to storybook",
group: "Storybook",
run: () => props.context.ui.router.navigate({ type: "plugin", name: "storybook" }),
},
],
}))
return (
<box width={dimensions().width} height={dimensions().height} backgroundColor={theme.background.default}>
<box paddingLeft={2} paddingRight={2} paddingTop={1} flexGrow={1}>
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
Workerd Modal workspace driver
</text>
<text fg={theme.text.subdued}>build · GPT-5.6 Sol (high)</text>
<box height={1} />
<text fg={theme.text.default}>You</text>
<text fg={theme.text.subdued}>Test the mounted workspace and verify the deployment.</text>
<box height={1} />
<text fg={theme.text.default}>Build · GPT-5.6 Sol (high)</text>
<text fg={theme.text.subdued}>The deployment is verified and the worktree is clean.</text>
<box flexGrow={1} />
<SessionLocationUnavailable directory={directory} onMove={open} />
</box>
<StoryFooter
context={props.context}
title="storybook / missing session directory"
status={message()}
controls={[
{ shortcut: "enter", label: "confirm" },
{ shortcut: "esc", label: "back" },
]}
/>
</box>
)
}
export const sessionLocationMissingStory: Story = {
id: "session-location-missing",
title: "Missing session directory",
render: (context) => <SessionLocationMissingStory context={context} />,
}
+14
View File
@@ -108,6 +108,7 @@ import { createSingleFlight } from "../../util/single-flight"
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
import { generateThinkingSyntax } from "./thinking-syntax"
import { createDelayedPresence } from "../../util/delayed-presence"
import { SessionLocationMissing } from "./location-missing"
addDefaultParsers(parsers.parsers)
@@ -1115,6 +1116,19 @@ export function Session() {
}}
</Show>
</Match>
<Match
when={
session() &&
currentLocation.error?.location.directory === session()!.location.directory &&
currentLocation.error?.location.workspaceID === session()!.location.workspaceID
}
>
<SessionLocationMissing
directory={session()!.location.directory}
projectID={session()!.projectID}
sessionID={route.sessionID}
/>
</Match>
<Match when={!disabled()}>
<Prompt
visible={true}
@@ -0,0 +1,36 @@
import { createMemo } from "solid-js"
import { useTuiPaths } from "../../context/runtime"
import { useTheme } from "../../context/theme"
import { Locale } from "../../util/locale"
import { abbreviateHome } from "../../util/path-format"
import { SessionQuestion } from "./permission"
import { usePromptMove } from "../../component/prompt/move"
export function SessionLocationMissing(props: { directory: string; projectID: string; sessionID: string }) {
const move = usePromptMove({ projectID: () => props.projectID, sessionID: () => props.sessionID })
return <SessionLocationUnavailable directory={props.directory} onMove={move.open} />
}
export function SessionLocationUnavailable(props: { directory: string; onMove: () => void }) {
const paths = useTuiPaths()
const theme = useTheme("elevated")
const directory = createMemo(() => Locale.truncateMiddle(abbreviateHome(props.directory, paths.home), 72))
return (
<SessionQuestion
id="session.location-missing"
group="Session recovery"
choicesLabel="Recovery actions"
instance={props.directory}
title="Session location unavailable"
body={
<box paddingLeft={1} gap={1}>
<text fg={theme.text.subdued}>{directory()}</text>
<text fg={theme.text.default}>Choose another directory to continue this session.</text>
</box>
}
options={{ move: "Choose directory" }}
onSelect={props.onMove}
/>
)
}
+62 -78
View File
@@ -141,7 +141,7 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
return (
<Switch>
<Match when={store.stage === "always"}>
<Prompt
<SessionQuestion
title="Always allow"
semanticLabel={`Always allow ${props.request.action}`}
instance={props.request.id}
@@ -235,7 +235,7 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
)
const body = (
<Prompt
<SessionQuestion
title="Permission required"
semanticLabel={permissionSemanticLabel(props.request.action, current.title)}
instance={props.request.id}
@@ -411,10 +411,13 @@ function RejectPrompt(props: {
)
}
function Prompt<const T extends Record<string, string>>(props: {
export function SessionQuestion<const T extends Record<string, string>>(props: {
title: string
semanticLabel?: string
instance: string
id?: string
group?: string
choicesLabel?: string
header?: JSX.Element
body: JSX.Element
options: T
@@ -431,86 +434,65 @@ function Prompt<const T extends Record<string, string>>(props: {
})
const narrow = createMemo(() => dimensions().width < 80)
const shortcuts = Keymap.useShortcuts()
const id = () => props.id ?? "session.permission"
const group = () => props.group ?? "Permission"
Keymap.createLayer(() => ({
mode: "base",
commands: [
{
id: "app.exit",
title: "Reject permission",
group: "Permission",
bind: false,
run() {
if (!props.escapeKey) return
props.onSelect(props.escapeKey)
},
},
{
id: "permission.prompt.fullscreen",
title: "Toggle permission fullscreen",
group: "Permission",
bind: false,
run() {
if (!props.fullscreen) return
setStore("expanded", (v) => !v)
},
},
{
bind: "left",
title: "Previous permission option",
group: "Permission",
run: () => {
const idx = keys.indexOf(store.selected)
const next = keys[(idx - 1 + keys.length) % keys.length]
setStore("selected", next)
},
},
{
bind: "h",
title: "Previous permission option",
group: "Permission",
run: () => {
const idx = keys.indexOf(store.selected)
const next = keys[(idx - 1 + keys.length) % keys.length]
setStore("selected", next)
},
},
{
bind: "right",
title: "Next permission option",
group: "Permission",
run: () => {
const idx = keys.indexOf(store.selected)
const next = keys[(idx + 1) % keys.length]
setStore("selected", next)
},
},
{
bind: "l",
title: "Next permission option",
group: "Permission",
run: () => {
const idx = keys.indexOf(store.selected)
const next = keys[(idx + 1) % keys.length]
setStore("selected", next)
},
},
{
bind: "return",
title: "Select permission option",
group: "Permission",
run: () => props.onSelect(store.selected),
},
...(props.escapeKey
? [
{
bind: "escape",
id: "app.exit",
title: "Reject permission",
group: "Permission",
group: group(),
bind: false as const,
run: () => props.onSelect(props.escapeKey!),
},
]
: []),
...(props.fullscreen
? [
{
id: "permission.prompt.fullscreen",
title: "Toggle permission fullscreen",
group: group(),
bind: false as const,
run: () => setStore("expanded", (value) => !value),
},
]
: []),
...(keys.length > 1
? [
{
bind: "left,h",
title: "Previous option",
group: group(),
run: () => {
const index = keys.indexOf(store.selected)
setStore("selected", keys[(index - 1 + keys.length) % keys.length])
},
},
{
bind: "right,l",
title: "Next option",
group: group(),
run: () => {
const index = keys.indexOf(store.selected)
setStore("selected", keys[(index + 1) % keys.length])
},
},
]
: []),
{
bind: "return",
title: "Select option",
group: group(),
run: () => props.onSelect(store.selected),
},
...(props.escapeKey
? [{ bind: "escape", title: "Reject permission", group: group(), run: () => props.onSelect(props.escapeKey!) }]
: []),
],
bindings: [...(props.escapeKey ? ["app.exit"] : []), ...(props.fullscreen ? ["permission.prompt.fullscreen"] : [])],
}))
@@ -520,7 +502,7 @@ function Prompt<const T extends Record<string, string>>(props: {
const content = () => (
<box
id="session.permission"
id={id()}
ref={SimulationSemantics.bind(() => ({
instance: props.instance,
role: "dialog",
@@ -571,11 +553,11 @@ function Prompt<const T extends Record<string, string>>(props: {
alignItems={narrow() ? "flex-start" : "center"}
>
<box
id="session.permission.actions"
id={`${id()}.actions`}
ref={SimulationSemantics.bind(() => ({
instance: props.instance,
role: "listbox",
label: "Permission choices",
label: props.choicesLabel ?? "Permission choices",
}))}
flexDirection="row"
gap={1}
@@ -584,7 +566,7 @@ function Prompt<const T extends Record<string, string>>(props: {
<For each={keys}>
{(option) => (
<box
id={`session.permission.action.${String(option)}`}
id={`${id()}.action.${String(option)}`}
ref={SimulationSemantics.bind(() => ({
instance: props.instance,
role: "option",
@@ -621,9 +603,11 @@ function Prompt<const T extends Record<string, string>>(props: {
{shortcuts.get("permission.prompt.fullscreen")} <span style={{ fg: theme.text.subdued }}>{hint()}</span>
</text>
</Show>
<text fg={theme.text.default}>
{"⇆"} <span style={{ fg: theme.text.subdued }}>select</span>
</text>
<Show when={keys.length > 1}>
<text fg={theme.text.default}>
{"⇆"} <span style={{ fg: theme.text.subdued }}>select</span>
</text>
</Show>
<text fg={theme.text.default}>
enter <span style={{ fg: theme.text.subdued }}>confirm</span>
</text>