diff --git a/packages/app/e2e/regression/subagent-child-navigation.spec.ts b/packages/app/e2e/regression/subagent-child-navigation.spec.ts index 9d28b737488..cc0a34b5840 100644 --- a/packages/app/e2e/regression/subagent-child-navigation.spec.ts +++ b/packages/app/e2e/regression/subagent-child-navigation.spec.ts @@ -1,6 +1,6 @@ import type { OpenCodeEvent, SessionMessageInfo } from "@opencode/client/promise" import { timelinePresets } from "@opencode/session-ui/timeline/detail" -import { expect, test, type Page } from "@playwright/test" +import { expect, test, type Locator, type Page } from "@playwright/test" import { expectPath, SERVER, sessionHref } from "../utils/app" import { currentSession } from "../utils/mock-server" import { assistantMessage, session, sessionID, setupTimeline, textPart, userMessage } from "../utils/timeline" @@ -38,6 +38,32 @@ test("navigates to a subagent child session missing from the session list", asyn await Promise.all([expect(page).toHaveURL(sessionHref(parentID)), expectSessionTitle(page, parentTitle)]) }) +test("keeps the parent title anchored when opening a subagent", async ({ page }) => { + await setup(page) + + for (const direction of ["ltr", "rtl"] as const) { + await page.goto(sessionHref(parentID)) + await page.evaluate((direction) => (document.documentElement.dir = direction), direction) + await expectSessionTitle(page, parentTitle) + + const start = await titleInlineStart(page.locator("[data-session-title]").getByRole("heading", { name: parentTitle })) + + await page.getByRole("button", { name: "Used 1 Agent", exact: true }).click() + await page.locator(`a[href="${sessionHref(childID)}"]`).click() + await expectSessionTitle(page, taskDescription) + + const breadcrumb = page.locator('[data-slot="session-title-parent"]') + + await expect(breadcrumb).toHaveText(parentTitle) + await expect.poll(() => titleInlineStart(breadcrumb)).toBeCloseTo(start, 0) + await breadcrumb.click() + await expectSessionTitle(page, parentTitle) + await expect + .poll(() => titleInlineStart(page.locator("[data-session-title]").getByRole("heading", { name: parentTitle }))) + .toBeCloseTo(start, 0) + } +}) + test("navigates from a running subagent card and hides background controls in the child", async ({ page }) => { const runningChildID = "ses_running_child" await setupTimeline(page, { @@ -316,6 +342,19 @@ async function setup(page: Page, events?: () => OpenCodeEvent[], nestedDepth: 0 }), ) } + +function titleInlineStart(title: Locator) { + return title.evaluate((element) => { + const range = document.createRange() + + range.selectNodeContents(element) + + const bounds = range.getBoundingClientRect() + + return document.documentElement.dir === "rtl" ? bounds.right : bounds.left + }) +} + async function openChildFromParent(page: Page) { await page.goto(sessionHref(parentID)) await expectSessionTitle(page, parentTitle) diff --git a/packages/app/e2e/regression/timeline-stream.spec.ts b/packages/app/e2e/regression/timeline-stream.spec.ts index 808abaae6b6..75e324c57b5 100644 --- a/packages/app/e2e/regression/timeline-stream.spec.ts +++ b/packages/app/e2e/regression/timeline-stream.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Page } from "@playwright/test" +import { expect, test, type Locator, type Page } from "@playwright/test" import type { SessionMessageAssistant, SessionMessageInfo, ShellInfo } from "@opencode/client/promise" import { timelinePresets } from "@opencode/session-ui/timeline/detail" import { createTwoFilesPatch } from "diff" @@ -1012,6 +1012,147 @@ test.describe("background shortcut", () => { await expect(backgroundCard).toContainText("Background task (background)") }) + test("hides the running switcher when viewing the only running subagent", async ({ page }) => { + const childID = "ses_only_running_child" + + const timeline = await setupTimeline(page, { + sessionMessages: [user, completed], + sessions: [ + session(), + session({ id: childID, parentID: sessionID, title: "Only running task", agent: "explore" }), + ], + sessionStatus: { [childID]: { type: "busy" } }, + }) + + const header = page.locator("[data-session-title]") + const trigger = header.getByRole("button", { name: "1 working", exact: true }) + + await trigger.click() + await page.getByRole("menuitem", { name: /Only running task/ }).click() + await expect(page).toHaveURL(new RegExp(`/session/${childID}$`)) + await expect(header.getByRole("heading")).toHaveText("Only running task") + await expect(trigger).toHaveCount(0) + + const working = header.getByRole("status", { name: "Working", exact: true }) + + await expect(working.locator('[data-component="session-progress-indicator-v2"]')).toBeVisible() + const gap = await working.evaluate((element) => { + const title = document.createRange() + + title.selectNodeContents(element.nextElementSibling!) + + return title.getBoundingClientRect().left - element.getBoundingClientRect().right + }) + + expect(gap).toBe(8) + + const title = header.getByRole("heading").locator('span[dir="auto"]') + const before = await title.boundingBox() + const exit = await pauseExitAnimations(working) + + await timeline.transport.send(status("idle", 1, childID)) + await expect.poll(() => exit.evaluate((animations) => animations.length)).toBeGreaterThan(0) + await exit.evaluate((animations) => animations.forEach((animation) => (animation.currentTime = 100))) + await expect(working).toBeAttached() + + const midway = await title.boundingBox() + + expect(midway!.x).toBeLessThan(before!.x) + await exit.evaluate((animations) => animations.forEach((animation) => animation.finish())) + await expect(working).toHaveCount(0) + + const after = await title.boundingBox() + + expect(after!.x).toBeLessThan(midway!.x) + expect(after!.x).toBeCloseTo(before!.x - 24, 0) + await exit.dispose() + await header.locator('[data-slot="session-title-parent"]').click() + await expect(page).toHaveURL(new RegExp(`/session/${sessionID}$`)) + await expect(trigger).toHaveCount(0) + }) + + test("keeps the running menu closed when work restarts after all subagents finish", async ({ page }) => { + const childID = "ses_restarted_child" + const otherID = "ses_counted_child" + const timeline = await setupTimeline(page, { + sessionMessages: [user, completed], + sessions: [ + session(), + session({ id: childID, parentID: sessionID, title: "Restarted task", agent: "explore" }), + session({ id: otherID, parentID: sessionID, title: "Other task", agent: "build" }), + ], + sessionStatus: {}, + }) + + const trigger = page.locator("[data-session-title]").getByRole("button", { name: /^[12] working$/ }) + const menu = page.getByRole("menu", { name: "1 working", exact: true }) + + await timeline.transport.send(status("busy", 1, childID)) + await expect(trigger).toHaveAccessibleName("1 working") + await timeline.transport.send(status("busy", 1, otherID)) + await expect(trigger).toHaveAccessibleName("2 working") + await expect(trigger.locator("..")).toHaveCSS("animation-name", "none") + await timeline.transport.send(status("idle", 1, otherID)) + await expect(trigger).toHaveAccessibleName("1 working") + // Inspect the update itself, not the settled state after a replayed fade finishes. + expect(await trigger.evaluate((element) => getComputedStyle(element.parentElement!).animationName)).toBe("none") + + await trigger.click() + await expect(menu.getByRole("menuitem", { name: /Restarted task/ })).toBeVisible() + await timeline.transport.send(status("idle", 1, childID)) + await expect(trigger).toHaveCount(0) + await expect(menu).toHaveCount(0) + await timeline.transport.send(status("busy", 1, childID)) + await expect(trigger).toBeEnabled() + await expect(trigger).toHaveAttribute("aria-expanded", "false") + await expect(menu).toHaveCount(0) + await trigger.click() + await expect(menu.getByRole("menuitem", { name: /Restarted task/ })).toBeVisible() + }) + + test("keeps the subagent title anchored when its additional count disappears", async ({ page }) => { + const childID = "ses_collapsing_viewed" + const otherID = "ses_collapsing_sibling" + const timeline = await setupTimeline(page, { + sessionMessages: [user, completed], + sessions: [ + session(), + session({ id: childID, parentID: sessionID, title: "Viewed task", agent: "explore" }), + session({ id: otherID, parentID: sessionID, title: "Other task", agent: "build" }), + ], + sessionStatus: { [childID]: { type: "busy" }, [otherID]: { type: "busy" } }, + }) + + const header = page.locator("[data-session-title]") + await header.getByRole("button", { name: "2 working", exact: true }).click() + await page.getByRole("menuitem", { name: /Viewed task/ }).click() + await expect(header.getByRole("heading")).toHaveAccessibleName("Viewed task") + + const trigger = header.getByRole("button", { name: "Viewed task +1 working", exact: true }) + const title = header.getByRole("heading").locator('span[dir="auto"]') + + await expect(trigger).toBeEnabled() + + const before = await title.boundingBox() + const exit = await pauseExitAnimations(trigger.locator('[data-component="text-shimmer"]').locator("..")) + + await timeline.transport.send(status("idle", 1, otherID)) + await expect.poll(() => exit.evaluate((animations) => animations.length)).toBeGreaterThan(0) + await exit.evaluate((animations) => animations.forEach((animation) => (animation.currentTime = 75))) + + const midway = await title.boundingBox() + + expect(midway!.x).toBeCloseTo(before!.x, 0) + await exit.evaluate((animations) => animations.forEach((animation) => animation.finish())) + await expect(trigger).toHaveCount(0) + + const after = await title.boundingBox() + + expect(after!.x).toBeCloseTo(before!.x, 0) + await expect(header.getByRole("button", { name: "Viewed task", exact: true })).toBeDisabled() + await exit.dispose() + }) + test("moves between siblings and to the parent's shell from inside a subagent", async ({ page }) => { const siblings = [ { id: "ses_sibling_one", agent: "explore", description: "Draft TUI proposal" }, @@ -1093,12 +1234,36 @@ test.describe("background shortcut", () => { }) const header = page.locator("[data-session-title]") - const trigger = header.getByRole("button", { name: "3 running", exact: true }) - const list = page.getByRole("menu", { name: "3 running", exact: true }) + const trigger = header.getByRole("button", { name: /(?:^3 running$|\+2 running$)/ }) + const list = page.getByRole("menu", { name: /(?:^3 running$|\+2 running$)/ }) await trigger.click() await list.getByRole("menuitem", { name: /Draft TUI proposal/ }).click() await expect(page).toHaveURL(/\/session\/ses_sibling_one$/) + await expect(list).toHaveCount(0) + await expect(header.getByRole("heading")).toHaveAccessibleName("Draft TUI proposal") + await expect(trigger.locator('[data-component="text-shimmer"]')).toHaveCSS("font-variant-numeric", "tabular-nums") + + const countGap = await trigger.evaluate((element) => { + const title = document.createRange() + + title.selectNodeContents(element.querySelector('span[dir="auto"]')!) + + return ( + element.querySelector('[data-component="text-shimmer"]')!.getBoundingClientRect().left - + title.getBoundingClientRect().right + ) + }) + + expect(countGap).toBe(8) + + const breadcrumbOrder = await header + .locator('[data-slot="session-title-parent"], [data-slot="session-title-child"]') + .evaluateAll((elements) => elements.map((element) => element.getAttribute("data-slot"))) + + expect(breadcrumbOrder).toEqual(["session-title-parent", "session-title-child"]) + await expect(header.locator('[data-slot="session-title-separator"]')).toHaveCount(1) + await expect(header.getByRole("heading").getByRole("button", { name: "Draft TUI proposal +2 running" })).toBeEnabled() await trigger.click() await expect(list.getByRole("menuitem")).toHaveText([ @@ -1108,8 +1273,26 @@ test.describe("background shortcut", () => { ]) await expect(list.getByRole("menuitem", { name: /Draft TUI proposal/ })).toHaveAttribute("aria-current", "page") await expect(list.getByRole("menuitem", { name: /Fix context controls/ })).not.toHaveAttribute("aria-current") + + const requested = Promise.withResolvers() + const release = Promise.withResolvers() + + await page.route( + (url) => url.pathname === "/api/session/ses_sibling_two/message", + async (route) => { + requested.resolve() + await release.promise + await route.fallback() + }, + ) await list.getByRole("menuitem", { name: /Fix context controls/ }).click() - await expect(page).toHaveURL(/\/session\/ses_sibling_two$/) + await requested.promise + await Promise.all([ + expect(page).toHaveURL(/\/session\/ses_sibling_two$/), + expect(header.getByRole("heading")).toHaveAccessibleName("Fix context controls"), + expect(trigger).toBeEnabled(), + ]).finally(() => release.resolve()) + await expect(list).toHaveCount(0) // The shell call lives in the parent: the row opens it there, expanded. await trigger.click() @@ -1531,6 +1714,22 @@ test.describe("shell completion", () => { }) }) +function pauseExitAnimations(locator: Locator) { + return locator.evaluateHandle((element) => { + const animations: Animation[] = [] + const pause = (event: AnimationEvent) => { + if (event.target !== element || event.animationName !== "exit") return + + animations.push(...element.getAnimations()) + animations.forEach((animation) => animation.pause()) + element.removeEventListener("animationstart", pause) + } + + element.addEventListener("animationstart", pause) + return animations + }) +} + function runningSubagent(): SessionMessageAssistant { return { ...completed, diff --git a/packages/app/e2e/utils/timeline.ts b/packages/app/e2e/utils/timeline.ts index f96b31bb020..214a6f4c5aa 100644 --- a/packages/app/e2e/utils/timeline.ts +++ b/packages/app/e2e/utils/timeline.ts @@ -524,11 +524,11 @@ export function messageUpdated(info: SessionMessageAssistant) { }) } -export function status(type: SessionStatus["type"], attempt = 1) { - if (type === "busy") return makeEvent("session.execution.started", { sessionID }) - if (type === "idle") return makeEvent("session.execution.succeeded", { sessionID }) +export function status(type: SessionStatus["type"], attempt = 1, id = sessionID) { + if (type === "busy") return makeEvent("session.execution.started", { sessionID: id }) + if (type === "idle") return makeEvent("session.execution.succeeded", { sessionID: id }) return makeEvent("session.retry.scheduled", { - sessionID, + sessionID: id, assistantMessageID: assistantID, attempt, at: 1700000010000, diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index fe29d7ad0ce..a936f4728bc 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -516,10 +516,14 @@ export const dict = { "session.queue.attachments.one": "{{count}} attachment", "session.queue.attachments.other": "{{count}} attachments", "session.timeline.working": "Working", - "session.running.working.one": "{{count}} working…", - "session.running.working.other": "{{count}} working…", + "session.running.working.one": "{{count}} working", + "session.running.working.other": "{{count}} working", "session.running.running.one": "{{count}} running", "session.running.running.other": "{{count}} running", + "session.running.additionalWorking.one": "+{{count}} working", + "session.running.additionalWorking.other": "+{{count}} working", + "session.running.additionalRunning.one": "+{{count}} running", + "session.running.additionalRunning.other": "+{{count}} running", "session.running.stop.subagent": "Interrupt subagent", "session.running.stop.shell": "Kill shell command", diff --git a/packages/app/src/session/header/session-running-menu.tsx b/packages/app/src/session/header/session-running-menu.tsx index 361c2c526c3..d9eb415b8a7 100644 --- a/packages/app/src/session/header/session-running-menu.tsx +++ b/packages/app/src/session/header/session-running-menu.tsx @@ -4,14 +4,16 @@ import { Icon } from "@opencode/ui/icon" import { IconButton } from "@opencode/ui/icon-button" import { Menu } from "@opencode/ui/menu" import { TextShimmer } from "@opencode/ui/text-shimmer" -import { createMemo, For, Show } from "solid-js" +import { createEffect, createMemo, createUniqueId, For, on, Show } from "solid-js" import { createStore } from "solid-js/store" +import { createAnimatedPresence } from "@/runtime/animated-presence" import { useLanguage } from "@/runtime/i18n/language" import { useServerSDK } from "@/runtime/server/client" import { useServer } from "@/runtime/server/current" import { useOpenSessionRoute } from "@/session/session-identity-header" import { errorMessage } from "@/shell/layout/helpers" import { showToast } from "@/shell/notifications/toast" +import { SessionWorkingIndicator } from "./session-working-indicator" const neutral = "light-dark(var(--v2-text-text-base), #ffffff)" @@ -30,13 +32,19 @@ export function SessionRunningMenu(props: { owner?: string blocking: readonly { type: "shell" | "subagent"; partID: string; id?: string; label?: string }[] tasks: readonly BackgroundTask[] - onReveal: (target: string) => void + onReveal?: (target: string) => void + title?: string }) { const language = useLanguage() const openRoute = useOpenSessionRoute() const server = useServer() const sdk = useServerSDK() + const id = createUniqueId() const [menu, setMenu] = createStore({ open: false }) + const [appearance, setAppearance] = createStore<{ + group?: HTMLElement + entered: boolean + }>({ entered: false }) const sessionAgent = (id: string | undefined) => (id ? server.ctx.data.session.get(id)?.agent : undefined) // Foreground shells stay out: the timeline already shows them at the bottom. @@ -62,16 +70,42 @@ export function SessionRunningMenu(props: { ...props.tasks.flatMap((task) => (task.type === "shell" ? [{ ...task, key: task.id, target: task.id }] : [])), ]) + const viewing = (item: RunningItem) => !!item.sessionID && item.sessionID === props.sessionID + const label = createMemo(() => { - const count = items().length + const count = props.title ? items().filter((item) => !viewing(item)).length : items().length - if (items().some((item) => item.type === "shell")) return language.plural("session.running.running", count) + if (!count || (!props.title && count === 1 && viewing(items()[0]))) return undefined - return language.plural("session.running.working", count) + if (items().some((item) => item.type === "shell")) { + return language.plural(props.title ? "session.running.additionalRunning" : "session.running.running", count) + } + + return language.plural(props.title ? "session.running.additionalWorking" : "session.running.working", count) }) - // Inside a subagent, its own row is listed among its siblings. - const viewing = (item: RunningItem) => !!item.sessionID && item.sessionID === props.sessionID + const presence = createAnimatedPresence( + label, + () => appearance.group ?? null, + () => props.sessionID, + ) + // Label updates change presence state without changing visibility. + const visible = createMemo(presence.show) + + createEffect( + on( + visible, + (show) => { + if (show) { + setAppearance("entered", false) + return + } + + setMenu("open", false) + }, + { defer: true }, + ), + ) const open = (item: RunningItem) => { const current = props.sessionID @@ -83,7 +117,9 @@ export function SessionRunningMenu(props: { // Shell calls and starting subagent calls live in the owner's timeline, which reveals them once open. if (props.owner && props.owner !== current) return openRoute(current, props.owner, item.target) - props.onReveal(item.target) + if (props.onReveal) return props.onReveal(item.target) + + openRoute(current, current, item.target) } // A subagent can only be interrupted once its child session exists. @@ -106,99 +142,167 @@ export function SessionRunningMenu(props: { } return ( - 0}> - setMenu("open", open)}> - +
{ + if (!props.title) setAppearance("group", element) + }} + inert={!props.title && !presence.show()} + class="flex min-w-0 shrink-0 items-center gap-0.5 duration-150 ease-out motion-reduce:animate-none" + classList={{ + "max-w-[45%]": !!props.title, + "animate-in fade-in": !props.title && presence.animate() && presence.show() && !appearance.entered, + "animate-out fade-out fill-mode-forwards": !props.title && presence.animate() && !presence.show(), + }} + onAnimationEnd={(event) => { + if (event.target === event.currentTarget && presence.show()) setAppearance("entered", true) + }} + > + + + + setMenu("open", open)} > - - - - - - {(item) => ( - { - // Close before navigating: the router's transition would hold the close until this - // timeline detaches, and the menu would flash at the viewport origin. - setMenu("open", false) - open(item) - }} - onKeyDown={(event) => { - if ((event.key !== "Delete" && event.key !== "Backspace") || !stoppable(item)) return - - event.preventDefault() - stop(item) - }} - > - } - > - {/* Built-in agents have theme tokens; any other agent keeps the neutral color. */} - - - - {item.type === "shell" - ? language.t("ui.tool.shell") - : item.agent - ? `${item.agent[0].toUpperCase()}${item.agent.slice(1)}` - : language.t("ui.tool.agent.default")} - - {/* Menu rows end 6px in for trailing controls; text alone ends 12px in, like the leading edge. */} + + + + } + > +

+ + + + {props.title} + + setAppearance("group", element)} + class="shrink-0 ps-0.5 pe-1 tabular-nums text-v2-text-text-muted duration-150 ease-out motion-reduce:animate-none" classList={{ - "group-hover/running-item:me-0 group-data-[highlighted]/running-item:me-0 [@media(hover:none)]:me-0": - stoppable(item), + "animate-in fade-in": presence.animate() && presence.show() && !appearance.entered, + "animate-out fade-out fill-mode-forwards": presence.animate() && !presence.show(), + }} + onAnimationEnd={(event) => { + if (event.target === event.currentTarget && presence.show()) setAppearance("entered", true) }} > - {item.label} + - - {/* The button's own display rule outranks utilities, so this wrapper shows and hides it. */} - + +

+
+ + + + {(item) => ( + { + // Close before navigating: the router's transition would hold the close until this + // timeline detaches, and the menu would flash at the viewport origin. + setMenu("open", false) + open(item) + }} + onKeyDown={(event) => { + if ((event.key !== "Delete" && event.key !== "Backspace") || !stoppable(item)) return + + event.preventDefault() + stop(item) + }} + > + } + > + {/* Built-in agents have theme tokens; any other agent keeps the neutral color. */} + + + + {item.type === "shell" + ? language.t("ui.tool.shell") + : item.agent + ? `${item.agent[0].toUpperCase()}${item.agent.slice(1)}` + : language.t("ui.tool.agent.default")} - - - )} - - - -
+ {/* Menu rows end 6px in for trailing controls; text alone ends 12px in, like the leading edge. */} + + {item.label} + + + {/* The button's own display rule outranks utilities, so this wrapper shows and hides it. */} + + + + )} + + + +
+
) } diff --git a/packages/app/src/session/header/session-working-indicator.tsx b/packages/app/src/session/header/session-working-indicator.tsx new file mode 100644 index 00000000000..7c59f61f9ec --- /dev/null +++ b/packages/app/src/session/header/session-working-indicator.tsx @@ -0,0 +1,43 @@ +import { SessionProgressIndicatorV2 } from "@opencode/session-ui/v2/session-progress-indicator-v2" +import { Show } from "solid-js" +import { createStore } from "solid-js/store" +import { createAnimatedPresence } from "@/runtime/animated-presence" +import { useLanguage } from "@/runtime/i18n/language" +import { useServer } from "@/runtime/server/current" + +export function SessionWorkingIndicator(props: { sessionID?: string }) { + const language = useLanguage() + const server = useServer() + const [elements, setElements] = createStore<{ indicator?: HTMLSpanElement }>({}) + const presence = createAnimatedPresence( + () => { + const id = props.sessionID + + return id && server.ctx.data.session.get(id)?.parentID && server.ctx.data.session.status(id) === "running" + ? true + : undefined + }, + () => elements.indicator ?? null, + () => props.sessionID, + ) + + return ( + + setElements("indicator", element)} + role="status" + aria-label={language.t("session.timeline.working")} + data-visible={presence.show()} + // Cancel the header's 2px gap at the end of the collapse so unmounting doesn't nudge the title. + class="ms-1 me-0.5 flex h-4 w-4 shrink-0 items-center justify-center overflow-hidden text-v2-icon-icon-muted duration-200 ease-out data-[visible=false]:ms-0 data-[visible=false]:-me-0.5 data-[visible=false]:w-0 data-[visible=false]:opacity-0 motion-reduce:animate-none motion-reduce:transition-none" + classList={{ + "transition-[width,margin,opacity]": presence.animate(), + // Presence waits for animationend; transitions alone would unmount the icon immediately. + "animate-out fade-out fill-mode-forwards": presence.animate() && !presence.show(), + }} + > + + + + ) +} diff --git a/packages/app/src/session/screen.tsx b/packages/app/src/session/screen.tsx index 137f2e18529..d77c8abbd1e 100644 --- a/packages/app/src/session/screen.tsx +++ b/packages/app/src/session/screen.tsx @@ -34,6 +34,7 @@ import { TimelineSearchBar } from "./timeline/search-bar" import { ActiveSessionComposerRegion, createActiveSessionRegion } from "./composer/region" import { SessionIdentityHeader } from "./session-identity-header" import { SessionReviewToggle } from "./header/session-header-actions" +import { SessionRunningMenu } from "./header/session-running-menu" import { createAnimatedPresence } from "@/runtime/animated-presence" import { createTimelineCache } from "./timeline/cache" @@ -333,7 +334,15 @@ function SessionScreenContent(props: { - + + + {timelineView()} diff --git a/packages/app/src/session/session-identity-header.tsx b/packages/app/src/session/session-identity-header.tsx index eefa36716c8..2f4436723b4 100644 --- a/packages/app/src/session/session-identity-header.tsx +++ b/packages/app/src/session/session-identity-header.tsx @@ -26,6 +26,7 @@ import { isProjectDirectory, isWorkspaceDirectory } from "@/workspaces/paths" import { sessionHref } from "@/shell/routes/session" import { showToast } from "@/shell/notifications/toast" import { sessionTitle } from "./title" +import { SessionWorkingIndicator } from "./header/session-working-indicator" import "./session-identity-header.css" export function SessionTitleHeader(props: ParentProps) { @@ -253,16 +254,16 @@ export function SessionAncestorTrail(props: { data-slot={ancestor.direct ? "session-title-parent" : "session-title-ancestor"} data-session-id={ancestor.id} title={ancestor.title} - dir="auto" - class="max-w-[min(200px,40vw)] shrink-0 truncate pl-2 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:text-v2-text-text-muted" + class="max-w-[min(200px,40vw)] shrink-0 truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:text-v2-text-text-muted" + classList={{ "ps-1": index() === 0, "ps-2": index() > 0 }} onClick={() => open(props.sessionID, ancestor.id)} > - {ancestor.title} + {ancestor.title}