diff --git a/packages/app/e2e/session/session-composer-dock.spec.ts b/packages/app/e2e/session/session-composer-dock.spec.ts index ea7f19ab8..245d23df7 100644 --- a/packages/app/e2e/session/session-composer-dock.spec.ts +++ b/packages/app/e2e/session/session-composer-dock.spec.ts @@ -8,7 +8,14 @@ import { type ComposerStateProbeState, type ComposerWindow, } from "../../src/testing/session-composer" -import { cleanupSession, clearSessionDockSeed, closeSettingsPanel, openSettings, seedSessionQuestion } from "../actions" +import { + cleanupSession, + clearSessionDockSeed, + closeSettingsPanel, + openSettings, + openSidebar, + seedSessionQuestion, +} from "../actions" import { permissionDockSelector, promptSelector, @@ -1681,6 +1688,69 @@ test("todo dock stays hidden after same-count terminal session switch", async ({ ) }) +test("todo dock does not flash on home after navigating from a session", async ({ page, project }) => { + await project.open() + await withDockSession( + project.sdk, + "e2e composer dock no home flash", + async (session) => { + const dock = await todoDock(page, session.id) + await project.gotoSession(session.id) + await expect(page.locator(sessionComposerDockSelector)).toBeVisible() + + try { + await dock.open([ + { content: "first task", status: "pending", priority: "high" }, + { content: "second task", status: "in_progress", priority: "medium" }, + ]) + await dock.expectCollapsed(["pending", "in_progress"]) + await expect(page.locator('[data-component="session-todo-dock"]')).toBeVisible() + + await page.evaluate(() => { + const SELECTOR = '[data-component="session-todo-dock"]' + const records: string[] = [] + const observer = new MutationObserver((mutations) => { + for (const m of mutations) { + for (const node of Array.from(m.addedNodes)) { + if (node.nodeType !== Node.ELEMENT_NODE) continue + const el = node as Element + if (el.matches(SELECTOR) || el.querySelector(SELECTOR)) { + records.push(el.outerHTML.slice(0, 200)) + } + } + } + }) + observer.observe(document.body, { childList: true, subtree: true }) + ;(window as unknown as { + __todoDockObserver: { observer: MutationObserver; records: string[] } + }).__todoDockObserver = { observer, records } + }) + + await openSidebar(page) + await page.locator('[data-action="pawwork-session-new"]').click() + await expect(page.locator('[data-component="session-new-home"]')).toBeVisible() + + const flashes = await page.evaluate(() => { + const w = window as unknown as { + __todoDockObserver?: { observer: MutationObserver; records: string[] } + } + const o = w.__todoDockObserver + if (!o) return null + o.observer.disconnect() + delete w.__todoDockObserver + return o.records + }) + + expect(flashes, "session-todo-dock must not appear on home during the transition").toEqual([]) + await expect(page.locator('[data-component="session-todo-dock"]')).toHaveCount(0) + } finally { + await dock.clear() + } + }, + { trackSession: project.trackSession }, + ) +}) + test("submit to question dock keeps latest turn visible", async ({ page, llm, project, assistant }) => { const title = `e2e question scroll dock ${Date.now()}` const longReply = [ diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index f5e5754c9..5343c2a11 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -17,7 +17,7 @@ import { useShellSurface } from "@/context/shell-surface" import { useSync } from "@/context/sync" import { useTerminal } from "@/context/terminal" import { buildDesktopContext } from "@/utils/desktop-context" -import { createSessionComposerState } from "@/pages/session/composer" +import { createSessionComposerState, HomeComposerRegion } from "@/pages/session/composer" import { createExecutionScopeTracker, type ExecutionScope } from "@/pages/session/execution-scope" import { createSizing } from "@/pages/session/helpers" import { useSessionLayout } from "@/pages/session/session-layout" @@ -371,20 +371,16 @@ export default function Page() { ), ) - const renderComposerRegion = ( - variant: "session" | "home", - ctx?: { - onModeChange: (mode: "normal" | "shell") => void - }, - ) => ( + const renderComposerRegion = (ctx?: { + onModeChange: (mode: "normal" | "shell") => void + }) => ( { inputRef = el @@ -398,7 +394,7 @@ export default function Page() { onResponseSubmit={submitLatest} onModeChange={ctx?.onModeChange} followup={ - variant === "session" && timelineSessionID() && submitReady() && !timelineIsChildSession() + timelineSessionID() && submitReady() && !timelineIsChildSession() ? { queue: followups.queueEnabled, items: followups.followupDock(), @@ -430,6 +426,25 @@ export default function Page() { /> ) + const renderHomeComposerRegion = (ctx?: { + onModeChange: (mode: "normal" | "shell") => void + }) => ( + { + inputRef = el + }} + actionReady={workspaceSubmitReady()} + newSessionWorktree={newSessionWorktree.selected()} + onNewSessionWorktreeReset={newSessionWorktree.reset} + onSubmit={() => { + comments.clear() + submitLatest() + }} + onModeChange={ctx?.onModeChange} + setPromptDockRef={scrollDock.setPromptDockRef} + /> + ) + const retryOpenRouteSession = () => { const id = params.id if (!id) return @@ -477,8 +492,8 @@ export default function Page() { anchor={timelineInteraction.anchor} onRetryOpenSession={retryOpenRouteSession} onOpenNewSession={openNewRouteSession} - composerSession={renderComposerRegion("session")} - composerHome={(ctx) => renderComposerRegion("home", ctx)} + composerSession={renderComposerRegion()} + composerHome={renderHomeComposerRegion} canReview={canReview} reviewDiffs={reviewPanel.diffs} hasReview={reviewPanel.hasReview} diff --git a/packages/app/src/pages/session/composer/home-composer-region.tsx b/packages/app/src/pages/session/composer/home-composer-region.tsx new file mode 100644 index 000000000..91b118ee3 --- /dev/null +++ b/packages/app/src/pages/session/composer/home-composer-region.tsx @@ -0,0 +1,88 @@ +import { Show, createEffect, createMemo } from "solid-js" +import { DockCard, DockSegment } from "@opencode-ai/ui/dock-card" +import { PromptInput } from "@/components/prompt-input" +import { useLanguage } from "@/context/language" +import { usePrompt } from "@/context/prompt" +import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff" +import { useSessionRouteKey } from "@/pages/session/session-layout" + +export type HomeComposerRegionProps = { + inputRef: (el: HTMLDivElement) => void + actionReady: boolean + newSessionWorktree: string + onNewSessionWorktreeReset: () => void + onSubmit: () => void + onModeChange?: (mode: "normal" | "shell") => void + setPromptDockRef: (el: HTMLDivElement) => void +} + +export function HomeComposerRegion(props: HomeComposerRegionProps) { + const prompt = usePrompt() + const language = useLanguage() + const route = useSessionRouteKey() + + const handoffKey = route.layoutRouteKey + const handoffPrompt = createMemo(() => { + const key = handoffKey() + return key ? getSessionHandoff(key)?.prompt : undefined + }) + + const previewPrompt = () => + prompt + .current() + .map((part) => { + if (part.type === "file") return `[file:${part.path}]` + if (part.type === "agent") return `@${part.name}` + if (part.type === "image") return `[image:${part.filename}]` + return part.content + }) + .join("") + .trim() + + createEffect(() => { + if (!prompt.ready()) return + const key = handoffKey() + if (!key) return + setSessionHandoff(key, { prompt: previewPrompt() }) + }) + + return ( +
+
+ + + {handoffPrompt() || language.t("prompt.loading")} + + + } + > +
+ + props.actionReady} + /> + +
+
+
+
+ ) +} diff --git a/packages/app/src/pages/session/composer/index.ts b/packages/app/src/pages/session/composer/index.ts index b0069de53..af5ee92aa 100644 --- a/packages/app/src/pages/session/composer/index.ts +++ b/packages/app/src/pages/session/composer/index.ts @@ -1,2 +1,3 @@ export { SessionComposerRegion } from "./session-composer-region" +export { HomeComposerRegion, type HomeComposerRegionProps } from "./home-composer-region" export { createSessionComposerState } from "./session-composer-state" diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx index 8ed0e6a78..6e1a3e72c 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -18,7 +18,6 @@ import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock" import type { FollowupDraft } from "@/components/prompt-input/submit" export function SessionComposerRegion(props: { - variant?: "session" | "home" state: SessionComposerState ready: boolean actionReady?: boolean @@ -56,10 +55,8 @@ export function SessionComposerRegion(props: { const language = useLanguage() const route = useSessionRouteKey() const sync = useSync() - const displaySessionID = createMemo(() => (props.variant === "session" ? props.displaySessionID : route.params.id)) - const displaySessionKey = createMemo(() => - props.variant === "session" ? props.displaySessionKey : route.layoutRouteKey(), - ) + const displaySessionID = () => props.displaySessionID + const displaySessionKey = () => props.displaySessionKey const handoffPrompt = createMemo(() => { const key = displaySessionKey() @@ -68,7 +65,6 @@ export function SessionComposerRegion(props: { const info = createMemo(() => (displaySessionID() ? sync.session.get(displaySessionID()!) : undefined)) const parentID = createMemo(() => info()?.parentID) const child = createMemo(() => !!parentID()) - const home = createMemo(() => props.variant === "home") const showComposer = createMemo(() => !!props.state.permissionRequest() || !props.state.blocked() || child()) const previewPrompt = () => @@ -120,22 +116,15 @@ export function SessionComposerRegion(props: {
@@ -226,9 +215,8 @@ export function SessionComposerRegion(props: { export function SessionPageComposerRegion(props: { - variant: "session" | "home" state: ReturnType ready: boolean actionReady?: boolean diff --git a/packages/app/src/shell-frame-contract.test.ts b/packages/app/src/shell-frame-contract.test.ts index 4e22ecd12..10cdce8c5 100644 --- a/packages/app/src/shell-frame-contract.test.ts +++ b/packages/app/src/shell-frame-contract.test.ts @@ -156,7 +156,8 @@ test("session composer is docked outside the scroll-clipped timeline region", () const messageTimeline = read("./pages/session/message-timeline.tsx") expect(session).toContain("const renderComposerRegion = (") - expect(session).toContain('variant: "session" | "home"') + expect(session).toContain("const renderHomeComposerRegion = (") + expect(session).toMatch(/composerHome=\{[^}]*renderHomeComposerRegion/) expect(sessionMainView).toContain('
') expect(sessionMainView).toContain( "
\n ", @@ -164,6 +165,35 @@ test("session composer is docked outside the scroll-clipped timeline region", () expect(messageTimeline).toContain('"padding-bottom": "calc(var(--composer-dock-height, 0px) + 32px)"') }) +test("home composer region does not import session-only docks or composer state", () => { + const src = stripComments(read("./pages/session/composer/home-composer-region.tsx")) + const importDeclMatches = [ + ...src.matchAll(/^\s*import[\s\S]+?from\s+["'][^"']+["']\s*;?/gm), + ...src.matchAll(/^\s*import\s+["'][^"']+["']\s*;?/gm), + ] + const importsOnly = importDeclMatches.map((m) => m[0]).join("\n") + + const bannedSymbols = [ + "SessionTodoDock", + "SessionQuestionDock", + "SessionPermissionContent", + "SessionRevertDock", + "SessionFollowupDock", + "SessionComposerState", + ] + const bannedPaths = [ + "session-todo-dock", + "session-question-dock", + "session-permission-dock", + "session-revert-dock", + "session-followup-dock", + "session-composer-state", + ] + for (const token of [...bannedSymbols, ...bannedPaths]) { + expect(importsOnly, `home-composer-region must not import ${token}`).not.toContain(token) + } +}) + test("session header uses a view title on home and breadcrumb title in sessions", () => { const sessionHeader = read("./components/session/session-header.tsx")