Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions packages/client/src/solid/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1378,6 +1378,11 @@ export function createData(config: CreateDataInput) {
status(sessionID: string) {
return store.session.active[sessionID] ?? "idle"
},
active() {
return Object.entries(store.session.active).flatMap(([sessionID, status]) =>
status === "running" ? [sessionID] : [],
)
},
// Inputs are the pending user and synthetic items; compactions are control items.
input: {
list(sessionID: string) {
Expand Down Expand Up @@ -1726,6 +1731,11 @@ export function createData(config: CreateDataInput) {
list(sessionID: string) {
return store.session.permission[sessionID]
},
sessions() {
return Object.entries(store.session.permission).flatMap(([sessionID, requests]) =>
requests.length > 0 ? [sessionID] : [],
)
},
sync(sessionID: string) {
return sync.run(`session.permission:${sessionID}`, async () => {
setStore("session", "permission", sessionID, await api().permission.list({ sessionID }))
Expand All @@ -1751,6 +1761,11 @@ export function createData(config: CreateDataInput) {
const key = locationKey(ref)
return forms?.filter((form) => form.location && locationKey(form.location) === key)
},
sessions() {
return Object.entries(store.session.form).flatMap(([sessionID, forms]) =>
sessionID !== "global" && forms.length > 0 ? [sessionID] : [],
)
},
sync(sessionID: string, ref?: LocationRef) {
const key = `session.form:${sessionID}:${sessionID === "global" ? locationKey(ref ?? defaultLocation()) : ""}`
return sync.run(key, async () => {
Expand Down
74 changes: 52 additions & 22 deletions packages/tui/src/component/dialog-open.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { batch, createEffect, createMemo, createResource, createSignal, onCleanu
import type { OpenCodeEvent, SessionInfo } from "@opencode/client"
import path from "path"
import { useTerminalDimensions } from "@opentui/solid"
import type { RGBA } from "@opentui/core"
import { dialogWidth, useDialog } from "../ui/dialog"
import { DialogSelect, dialogSelectContentWidth, type DialogSelectRef } from "../ui/dialog-select"
import { DialogPrompt } from "../ui/dialog-prompt"
Expand All @@ -21,6 +20,7 @@ import { useToast } from "../ui/toast"
import { errorMessage } from "../util/error"
import { stringWidth } from "../util/string-width"
import { withTimestampedFallback } from "@opencode/util/session-title-fallback"
import { sessionStatusGutter } from "./dialog-session-list"
import { Spinner } from "./spinner"
import { projectName } from "../util/project"

Expand Down Expand Up @@ -156,28 +156,55 @@ export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions:
const currentSessionID = createMemo(() =>
route.data.type === "session" ? data.session.root(route.data.sessionID) : undefined,
)
const attention = (sessionID: string) => sessionTabs.status(sessionID).attention
const sessions = createMemo(() => {
const seen = new Set<string>()
const match = matched()
return [...data.session.list(), ...props.sessions, ...(match ? [match] : [])]
.filter((session) => {
if (session.parentID || seen.has(session.id)) return false
seen.add(session.id)
return true
})
.toSorted((a, b) => b.time.updated - a.time.updated)
const list = [...data.session.list(), ...props.sessions, ...(match ? [match] : [])].filter((session) => {
if (session.parentID || seen.has(session.id)) return false
seen.add(session.id)
return true
})
const attentionByID = new Map(list.map((session) => [session.id, Boolean(attention(session.id))]))
return list.toSorted((a, b) => {
const attentionA = attentionByID.get(a.id) ?? false
const attentionB = attentionByID.get(b.id) ?? false
if (attentionA !== attentionB) return attentionA ? -1 : 1
return b.time.updated - a.time.updated
})
})

createEffect(() => {
const knownRoots = new Set(props.sessions.map((session) => session.id))
const active = data.session.active()
active.forEach((id) => {
void data.session.permission.sync(id).catch(() => undefined)
void data.session.form.sync(id).catch(() => undefined)
})
new Set(
[
...data.session.permission.sessions(),
...data.session.form.sessions(),
...(recent() === true ? active : []),
].map((id) => data.session.root(id)),
).forEach((rootID) => {
if (!knownRoots.has(rootID) && !data.session.get(rootID)) {
void data.session.sync(rootID).catch(() => undefined)
}
})
})

const options = createMemo(() => {
const tabs = openTabs()
const currentID = currentSessionID()
// With an empty query the menu shows what is not already one keystroke away: open tabs are
// visible in the strip, so recents exclude them. Typing widens the pool to every session so
// matching a loaded tab by name still switches to it.
const recent = filter().trim()
? sessions()
: sessions()
.filter((session) => !tabs.has(session.id))
.slice(0, RECENT_LIMIT)
// visible in the strip, so recents exclude them unless a background session is awaiting input.
// Typing widens the pool to every session so matching a loaded tab by name still switches to it.
const candidates = sessions().filter(
(session) => !tabs.has(session.id) || (attention(session.id) && session.id !== currentID),
)
const attentionCount = candidates.filter((session) => attention(session.id)).length
const recent = filter().trim() ? sessions() : candidates.slice(0, Math.max(RECENT_LIMIT, attentionCount))
const sessionOptions = recent.map((session) => {
const project = data.project.get(session.projectID)
const name = projectName(project)
Expand All @@ -186,21 +213,24 @@ export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions:
name && session.location.directory !== project?.canonical && name.toLowerCase() !== basename.toLowerCase()
? `${name} · ${basename}`
: name || basename
const state = attention(session.id)
const running =
data.session.status(session.id) === "running" ||
data.session.family(session.id).some((id) => data.session.status(id) === "running")
!state &&
(data.session.status(session.id) === "running" ||
data.session.family(session.id).some((id) => data.session.status(id) === "running"))
return {
title: withTimestampedFallback(session),
searchText: `${session.id} ${session.location.directory}`,
value: { type: "session", sessionID: session.id } as OpenTarget,
category: "Sessions",
footer: `${label ? `${Locale.truncate(label, 30)} · ` : ""}${timeAgo(session.time.updated)}`,
onSelect: () => location.set(session.location),
gutter: running
? (color: RGBA) => <Spinner color={color} />
: tabs.has(session.id)
? () => <text fg={theme.hue.accent[200]}>▪</text>
: undefined,
gutter: sessionStatusGutter(
theme,
state,
running,
tabs.has(session.id) ? () => <text fg={theme.hue.accent[200]}>▪</text> : undefined,
),
}
})

Expand Down
41 changes: 31 additions & 10 deletions packages/tui/src/component/dialog-session-list.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { createMemo, createResource, createSignal, onMount, Show } from "solid-js"
import { createMemo, createResource, createSignal, onMount, Show, type JSX } from "solid-js"
import path from "path"
import type { SessionInfo } from "@opencode/client"
import { Project } from "@opencode/schema/project"
import { TextAttributes } from "@opentui/core"
import type { RGBA } from "@opentui/core"
import { TextAttributes, type RGBA } from "@opentui/core"
import { useDialog } from "../ui/dialog"
import { DialogSelect } from "../ui/dialog-select"
import { useRoute } from "../context/route"
Expand Down Expand Up @@ -161,6 +160,7 @@ export function DialogSessionList() {
: undefined
const slot = sessionTabs.enabled() ? undefined : slotByID.get(session.id)
const deleting = toDelete() === session.id
const attention = sessionTabs.status(session.id).attention
return {
title: deleting
? `Press ${shortcuts.get("session.delete")} again to confirm`
Expand All @@ -170,13 +170,14 @@ export function DialogSessionList() {
footer,
bg: deleting ? theme.background.action.destructive.focused : undefined,
fg: deleting ? theme.text.action.destructive.focused : undefined,
gutter:
data.session.status(session.id) === "running" ||
data.session.family(session.id).some((id) => data.session.status(id) === "running")
? (color: RGBA) => <Spinner color={color} />
: slot === undefined
? undefined
: () => <text fg={theme.hue.accent[200]}>{slot}</text>,
gutter: sessionStatusGutter(
theme,
attention,
!attention &&
(data.session.status(session.id) === "running" ||
data.session.family(session.id).some((id) => data.session.status(id) === "running")),
slot === undefined ? undefined : () => <text fg={theme.hue.accent[200]}>{slot}</text>,
),
}
}

Expand Down Expand Up @@ -297,3 +298,23 @@ function quickSwitchRange(first: string, last: string) {
if (first.endsWith("1") && last === `${prefix}9`) return `${prefix}1-9`
return `${first} through ${last}`
}

export function sessionStatusGutter(
theme: ReturnType<ReturnType<typeof useTheme>["surface"]>,
attention: "permission" | "question" | false,
running: boolean,
fallback?: () => JSX.Element,
) {
if (attention) {
return (color: RGBA) => (
<text
fg={color === theme.text.action.primary.focused ? color : theme.text.feedback.warning.base}
attributes={TextAttributes.BOLD}
>
{attention === "permission" ? "!" : "?"}
</text>
)
}
if (running) return (color: RGBA) => <Spinner color={color} />
return fallback
}
2 changes: 1 addition & 1 deletion packages/tui/src/context/session-tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
const family = (sessionID: string) => {
const session = root(sessionID)
const members = data.session.family(session)
return members.length > 0 ? members : [session]
return members.includes(session) ? members : [session, ...members]
}
const normalize = (value: TabsState) => ({
tabs: value.tabs.reduce<SessionTab[]>((tabs, tab) => {
Expand Down
96 changes: 96 additions & 0 deletions packages/tui/test/cli/tui/dialog-open.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -851,6 +851,102 @@ test("option arrows stay in the only visible section", async () => {
}
})

test("surfaces sessions awaiting permissions or questions at the top with attention icons", async () => {
const remote = { directory: "/tmp/opencode/remote" }
const fixture = await renderOpen((url) => {
if (url.pathname === "/api/session/active") {
return json({
data: {
ses_recent_1: { type: "running" },
ses_blocked_permission: { type: "running" },
ses_child_question: { type: "running" },
},
})
}
if (url.pathname === "/api/session/ses_child_question") {
return json({
data: {
...recentSession,
id: "ses_child_question",
parentID: "ses_parent_question",
title: "Child subagent",
location: remote,
time: { created: 1, updated: 2 },
},
})
}
if (url.pathname === "/api/session/ses_blocked_permission/permission") {
return json({
data: [
{
id: "per_shell",
sessionID: "ses_blocked_permission",
action: "shell",
resources: ["git status"],
},
],
})
}
if (url.pathname === "/api/session/ses_child_question/form") {
return json({
data: [
{
id: "frm_question",
sessionID: "ses_child_question",
title: "Questions",
fields: [{ key: "q0", type: "string", title: "Target" }],
},
],
})
}
if (url.pathname !== "/api/session") return undefined
return json({
data: [
...Array.from({ length: 9 }, (_, index) => ({
...recentSession,
id: `ses_recent_${index + 1}`,
title: `Recent session ${index + 1}`,
time: { created: 1, updated: 100 - index },
})),
{
...recentSession,
id: "ses_parent_question",
title: "Parent awaiting question",
location: remote,
time: { created: 1, updated: 5 },
},
{
...recentSession,
id: "ses_blocked_permission",
title: "Old session awaiting permission",
time: { created: 1, updated: 1 },
},
],
cursor: {},
})
})

try {
const frame = await fixture.app.waitForFrame(
(value) =>
value.includes("! Old session awaiting permission") &&
value.includes("? Parent awaiting question") &&
value.includes("Recent session 1"),
)
expect(frame.indexOf("? Parent awaiting question")).toBeLessThan(
frame.indexOf("! Old session awaiting permission"),
)
expect(frame.indexOf("! Old session awaiting permission")).toBeLessThan(frame.indexOf("Recent session 1"))

fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.route.data.type === "session")
expect(fixture.route.data).toEqual({ type: "session", sessionID: "ses_parent_question" })
expect(fixture.location.ref).toEqual(remote)
} finally {
await fixture.dispose()
}
})

async function renderOpen(
handler: FetchHandler,
beforeOpen?: (contexts: {
Expand Down
1 change: 1 addition & 0 deletions packages/tui/test/fixture/tui-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
})
if (url.pathname === "/api/form")
return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
if (/^\/api\/session\/[^/]+\/permission$/.test(url.pathname)) return json({ data: [] })
if (/^\/api\/session\/[^/]+\/form$/.test(url.pathname)) return json({ data: [] })
if (/^\/api\/experimental\/session\/[^/]+\/terminal$/.test(url.pathname)) return json({ data: [] })
if (
Expand Down
Loading