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
72 changes: 71 additions & 1 deletion packages/app/e2e/session/session-composer-dock.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 = [
Expand Down
47 changes: 31 additions & 16 deletions packages/app/src/pages/session.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createMemo, createEffect, createSignal, on } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
Expand All @@ -17,7 +17,7 @@
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"
Expand Down Expand Up @@ -371,20 +371,16 @@
),
)

const renderComposerRegion = (
variant: "session" | "home",
ctx?: {
onModeChange: (mode: "normal" | "shell") => void
},
) => (
const renderComposerRegion = (ctx?: {
onModeChange: (mode: "normal" | "shell") => void
}) => (
<SessionPageComposerRegion
variant={variant}
state={composer}
ready={!deferRender() && (variant === "home" ? timelineMessagesReady() : sessionActionReady())}
actionReady={variant === "home" ? workspaceSubmitReady() : submitReady()}
abortReady={variant === "home" ? true : sessionActionReady()}
displaySessionID={variant === "session" ? timelineSessionID() : undefined}
displaySessionKey={variant === "session" && timelineSessionID() ? timelineSessionKey() : undefined}
ready={!deferRender() && sessionActionReady()}
actionReady={submitReady()}
abortReady={sessionActionReady()}
displaySessionID={timelineSessionID()}
displaySessionKey={timelineSessionID() ? timelineSessionKey() : undefined}
centered={centered()}
inputRef={(el) => {
inputRef = el
Expand All @@ -398,7 +394,7 @@
onResponseSubmit={submitLatest}
onModeChange={ctx?.onModeChange}
followup={
variant === "session" && timelineSessionID() && submitReady() && !timelineIsChildSession()
timelineSessionID() && submitReady() && !timelineIsChildSession()
? {
queue: followups.queueEnabled,
items: followups.followupDock(),
Expand Down Expand Up @@ -430,6 +426,25 @@
/>
)

const renderHomeComposerRegion = (ctx?: {
onModeChange: (mode: "normal" | "shell") => void
}) => (
<HomeComposerRegion
inputRef={(el) => {
inputRef = el
}}
actionReady={workspaceSubmitReady()}
newSessionWorktree={newSessionWorktree.selected()}
onNewSessionWorktreeReset={newSessionWorktree.reset}
onSubmit={() => {
comments.clear()
submitLatest()
}}
Comment thread
Astro-Han marked this conversation as resolved.
onModeChange={ctx?.onModeChange}
setPromptDockRef={scrollDock.setPromptDockRef}
/>
)

const retryOpenRouteSession = () => {
const id = params.id
if (!id) return
Expand Down Expand Up @@ -477,8 +492,8 @@
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}
Expand Down
88 changes: 88 additions & 0 deletions packages/app/src/pages/session/composer/home-composer-region.tsx
Original file line number Diff line number Diff line change
@@ -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() })
})
Comment thread
Astro-Han marked this conversation as resolved.

return (
<div
ref={props.setPromptDockRef}
data-component="session-prompt-dock"
data-variant="home"
data-dock-kind="prompt"
class="w-full flex flex-col justify-center items-center pointer-events-none py-0 bg-transparent text-left"
>
<div
data-component="session-composer-column"
class="w-full pointer-events-auto px-3 md:max-w-[720px] md:mx-auto 2xl:max-w-[920px]"
>
<Show
when={prompt.ready()}
fallback={
<DockCard>
<DockSegment class="w-full min-h-32 md:min-h-40 px-4 py-3 text-body text-fg-weak whitespace-pre-wrap pointer-events-none">
{handoffPrompt() || language.t("prompt.loading")}
</DockSegment>
</DockCard>
}
>
<div class="relative z-30">
<DockCard class="overflow-visible!">
<PromptInput
ref={props.inputRef}
homeMode
newSessionWorktree={props.newSessionWorktree}
onNewSessionWorktreeReset={props.onNewSessionWorktreeReset}
onSubmit={props.onSubmit}
onModeChange={props.onModeChange}
actionReady={() => props.actionReady}
/>
</DockCard>
</div>
</Show>
</div>
</div>
)
}
1 change: 1 addition & 0 deletions packages/app/src/pages/session/composer/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { SessionComposerRegion } from "./session-composer-region"
export { HomeComposerRegion, type HomeComposerRegionProps } from "./home-composer-region"
export { createSessionComposerState } from "./session-composer-state"
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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 = () =>
Expand Down Expand Up @@ -120,22 +116,15 @@ export function SessionComposerRegion(props: {
<div
ref={props.setPromptDockRef}
data-component="session-prompt-dock"
data-variant={home() ? "home" : "session"}
data-variant="session"
data-dock-kind={dockKind()}
classList={{
"w-full flex flex-col justify-center items-center pointer-events-none": true,
"absolute inset-x-0 bottom-0 pb-6": !home(),
"py-0 bg-transparent": home(),
"text-left": home(),
}}
class="w-full flex flex-col justify-center items-center pointer-events-none absolute inset-x-0 bottom-0 pb-6"
>
<div
data-component="session-composer-column"
classList={{
"w-full pointer-events-auto": true,
"px-4 md:px-3": !home(),
"px-3": home(),
"md:max-w-[720px] md:mx-auto 2xl:max-w-[920px]": props.centered || home(),
"w-full pointer-events-auto px-4 md:px-3": true,
"md:max-w-[720px] md:mx-auto 2xl:max-w-[920px]": props.centered,
}}
>
<Show when={props.state.questionRequest()} keyed>
Expand Down Expand Up @@ -226,9 +215,8 @@ export function SessionComposerRegion(props: {
<Show when={!props.state.blocked()}>
<PromptInput
ref={props.inputRef}
homeMode={home()}
sessionID={displaySessionID()}
sessionIDControlled={!home()}
sessionIDControlled={true}
newSessionWorktree={props.newSessionWorktree}
onNewSessionWorktreeReset={props.onNewSessionWorktreeReset}
edit={props.followup?.edit}
Expand Down
1 change: 0 additions & 1 deletion packages/app/src/pages/session/session-composer-region.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { SessionComposerRegion, type createSessionComposerState } from "@/pages/
type ComposerRegionProps = ComponentProps<typeof SessionComposerRegion>

export function SessionPageComposerRegion(props: {
variant: "session" | "home"
state: ReturnType<typeof createSessionComposerState>
ready: boolean
actionReady?: boolean
Expand Down
32 changes: 31 additions & 1 deletion packages/app/src/shell-frame-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,14 +156,44 @@ 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('<div class="flex-1 min-h-0 overflow-hidden">')
expect(sessionMainView).toContain(
"</div>\n <Show when={props.activeSessionID && !showSessionOpeningState()}>",
)
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")

Expand Down