Skip to content

Commit ec8b981

Browse files
authored
feat(app): better subagent experience (#20708)
1 parent 65318a8 commit ec8b981

18 files changed

Lines changed: 831 additions & 551 deletions

packages/app/e2e/session/session-child-navigation.spec.ts

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { seedSessionTask, withSession } from "../actions"
22
import { test, expect } from "../fixtures"
33
import { inputMatch } from "../prompt/mock"
4-
import { promptSelector } from "../selectors"
54

65
test("task tool child-session link does not trigger stale show errors", async ({ page, llm, project }) => {
76
test.setTimeout(120_000)
@@ -30,15 +29,33 @@ test("task tool child-session link does not trigger stale show errors", async ({
3029

3130
await project.gotoSession(session.id)
3231

33-
const link = page
34-
.locator("a.subagent-link")
32+
const header = page.locator("[data-session-title]")
33+
await expect(header.getByRole("button", { name: "More options" })).toBeVisible({ timeout: 30_000 })
34+
35+
const card = page
36+
.locator('[data-component="task-tool-card"]')
3537
.filter({ hasText: /open child session/i })
3638
.first()
37-
await expect(link).toBeVisible({ timeout: 30_000 })
38-
await link.click()
39+
await expect(card).toBeVisible({ timeout: 30_000 })
40+
await card.click()
3941

4042
await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 })
41-
await expect(page.locator(promptSelector)).toBeVisible({ timeout: 30_000 })
43+
await expect(header.locator('[data-slot="session-title-parent"]')).toHaveText(session.title)
44+
await expect(header.locator('[data-slot="session-title-child"]')).toHaveText(taskInput.description)
45+
await expect(header.locator('[data-slot="session-title-separator"]')).toHaveText("/")
46+
await expect
47+
.poll(
48+
() =>
49+
header.locator('[data-slot="session-title-separator"]').evaluate((el) => ({
50+
left: getComputedStyle(el).paddingLeft,
51+
right: getComputedStyle(el).paddingRight,
52+
})),
53+
{ timeout: 30_000 },
54+
)
55+
.toEqual({ left: "8px", right: "8px" })
56+
await expect(header.getByRole("button", { name: "More options" })).toHaveCount(0)
57+
await expect(page.getByText("Subagent sessions cannot be prompted.")).toBeVisible({ timeout: 30_000 })
58+
await expect(page.getByRole("button", { name: "Back to main session." })).toBeVisible({ timeout: 30_000 })
4259
await expect.poll(() => errs, { timeout: 5_000 }).toEqual([])
4360
})
4461
} finally {

packages/app/src/i18n/en.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,8 @@ export const dict = {
238238
"prompt.mode.shell": "Shell",
239239
"prompt.mode.normal": "Prompt",
240240
"prompt.mode.shell.exit": "esc to exit",
241+
"session.child.promptDisabled": "Subagent sessions cannot be prompted.",
242+
"session.child.backToParent": "Back to main session.",
241243

242244
"prompt.example.1": "Fix a TODO in the codebase",
243245
"prompt.example.2": "What is the tech stack of this project?",

packages/app/src/index.css

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,46 @@
11
@import "@opencode-ai/ui/styles/tailwind";
22

33
@layer components {
4+
@keyframes session-progress-whip {
5+
0% {
6+
clip-path: inset(0 100% 0 0 round 999px);
7+
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
8+
}
9+
10+
48% {
11+
clip-path: inset(0 0 0 0 round 999px);
12+
animation-timing-function: cubic-bezier(0.65, 0, 0.35, 1);
13+
}
14+
15+
100% {
16+
clip-path: inset(0 0 0 100% round 999px);
17+
}
18+
}
19+
20+
[data-component="session-progress"] {
21+
position: absolute;
22+
inset: 0 0 auto;
23+
height: 2px;
24+
overflow: hidden;
25+
pointer-events: none;
26+
opacity: 1;
27+
transition: opacity 220ms ease-out;
28+
}
29+
30+
[data-component="session-progress"][data-state="hiding"] {
31+
opacity: 0;
32+
}
33+
34+
[data-component="session-progress-bar"] {
35+
width: 100%;
36+
height: 100%;
37+
border-radius: 999px;
38+
background: var(--session-progress-color);
39+
clip-path: inset(0 100% 0 0 round 999px);
40+
animation: session-progress-whip var(--session-progress-ms, 1800ms) infinite;
41+
will-change: clip-path;
42+
}
43+
444
[data-component="getting-started"] {
545
container-type: inline-size;
646
container-name: getting-started;

packages/app/src/pages/layout.tsx

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,6 @@ export default function Layout(props: ParentProps) {
150150
const [state, setState] = createStore({
151151
autoselect: !initialDirectory,
152152
busyWorkspaces: {} as Record<string, boolean>,
153-
hoverSession: undefined as string | undefined,
154153
hoverProject: undefined as string | undefined,
155154
scrollSessionKey: undefined as string | undefined,
156155
nav: undefined as HTMLElement | undefined,
@@ -194,7 +193,6 @@ export default function Layout(props: ParentProps) {
194193
onActivate: (directory) => {
195194
globalSync.child(directory)
196195
setState("hoverProject", directory)
197-
setState("hoverSession", undefined)
198196
},
199197
})
200198

@@ -231,7 +229,6 @@ export default function Layout(props: ParentProps) {
231229
aim.reset()
232230
}
233231
const clearHoverProjectSoon = () => queueMicrotask(() => setHoverProject(undefined))
234-
const setHoverSession = (id: string | undefined) => setState("hoverSession", id)
235232

236233
const disarm = () => {
237234
if (navLeave.current === undefined) return
@@ -241,7 +238,6 @@ export default function Layout(props: ParentProps) {
241238

242239
const reset = () => {
243240
disarm()
244-
setState("hoverSession", undefined)
245241
setHoverProject(undefined)
246242
}
247243

@@ -252,7 +248,6 @@ export default function Layout(props: ParentProps) {
252248
navLeave.current = window.setTimeout(() => {
253249
navLeave.current = undefined
254250
setHoverProject(undefined)
255-
setState("hoverSession", undefined)
256251
}, 300)
257252
}
258253

@@ -1972,9 +1967,6 @@ export default function Layout(props: ParentProps) {
19721967
navList: currentSessions,
19731968
sidebarExpanded,
19741969
sidebarHovering,
1975-
nav: () => state.nav,
1976-
hoverSession: () => state.hoverSession,
1977-
setHoverSession,
19781970
clearHoverProjectSoon,
19791971
prefetchSession,
19801972
archiveSession,
@@ -2003,7 +1995,6 @@ export default function Layout(props: ParentProps) {
20031995
sidebarOpened: () => layout.sidebar.opened(),
20041996
sidebarHovering,
20051997
hoverProject: () => state.hoverProject,
2006-
nav: () => state.nav,
20071998
onProjectMouseEnter: (worktree, event) => aim.enter(worktree, event),
20081999
onProjectMouseLeave: (worktree) => aim.leave(worktree),
20092000
onProjectFocus: (worktree) => aim.activate(worktree),
@@ -2022,15 +2013,10 @@ export default function Layout(props: ParentProps) {
20222013
sessionProps: {
20232014
navList: currentSessions,
20242015
sidebarExpanded,
2025-
sidebarHovering,
2026-
nav: () => state.nav,
2027-
hoverSession: () => state.hoverSession,
2028-
setHoverSession,
20292016
clearHoverProjectSoon,
20302017
prefetchSession,
20312018
archiveSession,
20322019
},
2033-
setHoverSession,
20342020
}
20352021

20362022
const SidebarPanel = (panelProps: {
@@ -2041,7 +2027,6 @@ export default function Layout(props: ParentProps) {
20412027
const project = panelProps.project
20422028
const merged = createMemo(() => panelProps.mobile || (panelProps.merged ?? layout.sidebar.opened()))
20432029
const hover = createMemo(() => !panelProps.mobile && panelProps.merged === false && !layout.sidebar.opened())
2044-
const popover = createMemo(() => !!panelProps.mobile || panelProps.merged === false || layout.sidebar.opened())
20452030
const empty = createMemo(() => !params.dir && layout.projects.list().length === 0)
20462031
const projectName = createMemo(() => {
20472032
const item = project()
@@ -2243,7 +2228,6 @@ export default function Layout(props: ParentProps) {
22432228
project={project()!}
22442229
sortNow={sortNow}
22452230
mobile={panelProps.mobile}
2246-
popover={popover()}
22472231
/>
22482232
</div>
22492233
</>
@@ -2288,7 +2272,6 @@ export default function Layout(props: ParentProps) {
22882272
project={project()!}
22892273
sortNow={sortNow}
22902274
mobile={panelProps.mobile}
2291-
popover={popover()}
22922275
/>
22932276
)}
22942277
</For>

packages/app/src/pages/layout/helpers.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
} from "./deep-links"
99
import { type Session } from "@opencode-ai/sdk/v2/client"
1010
import {
11+
childSessionOnPath,
1112
displayName,
1213
effectiveWorkspaceOrder,
1314
errorMessage,
@@ -198,6 +199,19 @@ describe("layout workspace helpers", () => {
198199
expect(result?.id).toBe("root")
199200
})
200201

202+
test("finds the direct child on the active session path", () => {
203+
const list = [
204+
session({ id: "root", directory: "/workspace" }),
205+
session({ id: "child", directory: "/workspace", parentID: "root" }),
206+
session({ id: "leaf", directory: "/workspace", parentID: "child" }),
207+
]
208+
209+
expect(childSessionOnPath(list, "root", "leaf")?.id).toBe("child")
210+
expect(childSessionOnPath(list, "child", "leaf")?.id).toBe("leaf")
211+
expect(childSessionOnPath(list, "root", "root")).toBeUndefined()
212+
expect(childSessionOnPath(list, "root", "other")).toBeUndefined()
213+
})
214+
201215
test("formats fallback project display name", () => {
202216
expect(displayName({ worktree: "/tmp/app" })).toBe("app")
203217
expect(displayName({ worktree: "/tmp/app", name: "My App" })).toBe("My App")

packages/app/src/pages/layout/helpers.ts

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -46,18 +46,17 @@ export function hasProjectPermissions<T>(
4646
return Object.values(request ?? {}).some((list) => list?.some(include))
4747
}
4848

49-
export const childMapByParent = (sessions: Session[] | undefined) => {
50-
const map = new Map<string, string[]>()
51-
for (const session of sessions ?? []) {
52-
if (!session.parentID) continue
53-
const existing = map.get(session.parentID)
54-
if (existing) {
55-
existing.push(session.id)
56-
continue
57-
}
58-
map.set(session.parentID, [session.id])
49+
export const childSessionOnPath = (sessions: Session[] | undefined, rootID: string, activeID?: string) => {
50+
if (!activeID || activeID === rootID) return
51+
const map = new Map((sessions ?? []).map((session) => [session.id, session]))
52+
let id = activeID
53+
54+
while (id) {
55+
const session = map.get(id)
56+
if (!session?.parentID) return
57+
if (session.parentID === rootID) return session
58+
id = session.parentID
5959
}
60-
return map
6160
}
6261

6362
export const displayName = (project: { name?: string; worktree: string }) =>

0 commit comments

Comments
 (0)