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
27 changes: 27 additions & 0 deletions Docs/superpowers/plans/2026-07-03-chat-focus-fullscreen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
## Stage 1: Regression Coverage

**Goal**: Capture the expected fullscreen focus-mode behavior before implementation.
**Success Criteria**: Tests fail because focus mode does not request shell chrome hiding and still renders the top cockpit controls instead of a dedicated exit control.
**Tests**: Targeted Vitest coverage for shared layout shell overrides and Playground focus mode.
**Status**: Complete

## Stage 2: Shared Shell Override Hook

**Goal**: Reuse the existing OptionLayout shell override channel from route content without rendering a nested layout shell.
**Success Criteria**: Focus-mode content can request `hideHeader` and `hideSidebar` across WebUI and extension shells.
**Tests**: Shared layout shell override tests pass.
**Status**: Complete

## Stage 3: Focus Mode UX

**Goal**: Make focus mode hide non-chat chrome and provide a single clear escape hatch.
**Success Criteria**: Focus mode shows chat transcript, composer, and an `Exit focus` control only; clicking the control returns to cockpit mode.
**Tests**: Playground cockpit-control tests pass.
**Status**: Complete

## Stage 4: Verification And PR Prep

**Goal**: Validate the focused UI in tests and browser, then prepare the PR update.
**Success Criteria**: Targeted tests pass, browser screenshot shows fullscreen focus mode, task notes are updated, and changes are committed/pushed.
**Tests**: Targeted Vitest command plus visual browser check.
**Status**: Complete
57 changes: 33 additions & 24 deletions apps/packages/ui/src/components/Layouts/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -654,6 +654,11 @@ type LayoutShellGlobal = {
setOverrides?: (overrides: LayoutShellOverrides | null) => void
}

export type OptionLayoutShellOverrideRequest = Pick<
LayoutShellOverrides,
"hideHeader" | "hideSidebar"
>

const LayoutShellContext = React.createContext<LayoutShellContextValue>({
inShell: false
})
Expand All @@ -669,35 +674,35 @@ const getGlobalShell = (): LayoutShellGlobal | null => {
return scope.__tldwOptionShell
}

function NestedLayoutContent({
props,
shell,
globalShell
}: {
props: OptionLayoutProps
shell: LayoutShellContextValue
globalShell: LayoutShellGlobal | null
}) {
export function useOptionLayoutShellOverrides(
request: OptionLayoutShellOverrideRequest | null
) {
const shell = useContext(LayoutShellContext)
const globalShell = getGlobalShell()
const location = useLocation()
const { hideHeader, hideSidebar } = request ?? {}
const requestedOverrides = React.useMemo(() => {
const overrides: LayoutShellOverrides = {}
if (props.hideHeader) overrides.hideHeader = true
if (props.hideSidebar) overrides.hideSidebar = true
if (hideHeader) overrides.hideHeader = true
if (hideSidebar) overrides.hideSidebar = true
if (Object.keys(overrides).length === 0) return null

overrides.sourcePath = location.pathname
return overrides
}, [location.pathname, props.hideHeader, props.hideSidebar])
}, [hideHeader, hideSidebar, location.pathname])

React.useEffect(() => {
if (!requestedOverrides) return
Comment thread
rmusser01 marked this conversation as resolved.

let timeoutId: ReturnType<typeof setTimeout> | null = null
let appliedOverrides = false
let appliedSetOverrides:
| ((overrides: LayoutShellOverrides | null) => void)
| null = null
const applyOverrides = () => {
const setOverrides = shell.setOverrides || globalShell?.setOverrides
if (!setOverrides) return false
setOverrides(requestedOverrides)
appliedOverrides = true
appliedSetOverrides = setOverrides
return true
}

Expand All @@ -709,11 +714,21 @@ function NestedLayoutContent({

return () => {
if (timeoutId) clearTimeout(timeoutId)
if (!appliedOverrides) return
const setOverrides = shell.setOverrides || globalShell?.setOverrides
setOverrides?.(null)
appliedSetOverrides?.(null)
}
}, [globalShell?.setOverrides, requestedOverrides, shell.setOverrides])
}

function NestedLayoutContent({ props }: { props: OptionLayoutProps }) {
const requestedOverrides = React.useMemo(() => {
const overrides: OptionLayoutShellOverrideRequest = {}
if (props.hideHeader) overrides.hideHeader = true
if (props.hideSidebar) overrides.hideSidebar = true
if (Object.keys(overrides).length === 0) return null
return overrides
}, [props.hideHeader, props.hideSidebar])

useOptionLayoutShellOverrides(requestedOverrides)

return (
<DemoModeProvider>
Expand Down Expand Up @@ -790,13 +805,7 @@ export default function OptionLayout(props: OptionLayoutProps) {
(globalShell?.ownerId == null || globalShell.ownerId !== ownerId)

if (shell.inShell || externalShell || isNextApp) {
return (
<NestedLayoutContent
props={props}
shell={shell}
globalShell={globalShell}
/>
)
return <NestedLayoutContent props={props} />
}

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@

import React from "react"
import { MemoryRouter } from "react-router-dom"
import { render } from "@testing-library/react"
import { render, waitFor } from "@testing-library/react"
import { afterEach, describe, expect, it, vi } from "vitest"

import OptionLayout from "../Layout"
import OptionLayout, { useOptionLayoutShellOverrides } from "../Layout"

const storeMessageOptionMock = vi.hoisted(() =>
vi.fn(() => ({ historyId: null, serverChatId: null }))
Expand Down Expand Up @@ -94,4 +94,98 @@ describe("OptionLayout shell overrides", () => {
expect(otherOwnerSetOverrides).not.toHaveBeenCalled()
vi.useRealTimers()
})

it("lets route content request header and sidebar shell hiding", async () => {
const setOverrides = vi.fn()
const externalShell: {
mounted: boolean
ownerId: string
setOverrides?: (overrides: unknown) => void
} = {
mounted: true,
ownerId: "root-shell",
setOverrides
}
;(
globalThis as typeof globalThis & {
__tldwOptionShell?: typeof externalShell
}
).__tldwOptionShell = externalShell

function FocusRouteContent() {
useOptionLayoutShellOverrides({
hideHeader: true,
hideSidebar: true
})

return <div>Focus route</div>
}

const { unmount } = render(
<MemoryRouter initialEntries={["/chat"]}>
<FocusRouteContent />
</MemoryRouter>
)

await waitFor(() => {
expect(setOverrides).toHaveBeenCalledWith({
hideHeader: true,
hideSidebar: true,
sourcePath: "/chat"
})
})

unmount()

expect(setOverrides).toHaveBeenLastCalledWith(null)
})

it("clears the same shell setter that accepted route content overrides", async () => {
const originalSetOverrides = vi.fn()
const replacementSetOverrides = vi.fn()
const externalShell: {
mounted: boolean
ownerId: string
setOverrides?: (overrides: unknown) => void
} = {
mounted: true,
ownerId: "root-shell",
setOverrides: originalSetOverrides
}
;(
globalThis as typeof globalThis & {
__tldwOptionShell?: typeof externalShell
}
).__tldwOptionShell = externalShell

function FocusRouteContent() {
useOptionLayoutShellOverrides({
hideHeader: true,
hideSidebar: true
})

return <div>Focus route</div>
}

const { unmount } = render(
<MemoryRouter initialEntries={["/chat"]}>
<FocusRouteContent />
</MemoryRouter>
)

await waitFor(() => {
expect(originalSetOverrides).toHaveBeenCalledWith({
hideHeader: true,
hideSidebar: true,
sourcePath: "/chat"
})
})

externalShell.setOverrides = replacementSetOverrides

unmount()

expect(originalSetOverrides).toHaveBeenLastCalledWith(null)
expect(replacementSetOverrides).not.toHaveBeenCalled()
})
})
52 changes: 41 additions & 11 deletions apps/packages/ui/src/components/Option/Playground/Playground.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
import { buildPlaygroundCompositionPreviewSummary } from "./playground-composition-preview";
import { ChatErrorBoundary } from "@/components/Common/Playground/ChatErrorBoundary";
import { hasVisibleAssistantResponse } from "@/components/Common/Playground/message-visibility";
import { useOptionLayoutShellOverrides } from "@/components/Layouts/Layout";
import { useMessageOption } from "@/hooks/useMessageOption";
import { usePlaygroundSessionPersistence } from "@/hooks/usePlaygroundSessionPersistence";
import { shouldRestorePersistedPlaygroundSession } from "@/hooks/playground-session-restore";
Expand All @@ -66,6 +67,7 @@ import {
ChevronDown,
Keyboard,
Maximize2,
Minimize2,
PanelRightOpen,
Search,
X,
Expand Down Expand Up @@ -761,14 +763,23 @@ export const Playground = () => {
chatLayoutMode === "focus" || chatLayoutMode === "cockpit"
? chatLayoutMode
: defaultChatLayoutMode;
const focusModeActive = normalizedChatLayoutMode === "focus";
useOptionLayoutShellOverrides(
focusModeActive
? {
hideHeader: true,
hideSidebar: true,
}
: null,
);
const normalizedCockpitContextRailVisible =
cockpitContextRailVisible !== false;
const normalizedCockpitRuntimeRailVisible =
cockpitRuntimeRailVisible !== false;
const mobileCockpitComposerConstrained =
isMobileViewport && normalizedChatLayoutMode === "cockpit";
isMobileViewport && !focusModeActive;
const cockpitRailCollapsedWideContent =
normalizedChatLayoutMode === "cockpit" &&
!focusModeActive &&
(!normalizedCockpitContextRailVisible ||
!normalizedCockpitRuntimeRailVisible);
const chatContentWidthClassName = cockpitRailCollapsedWideContent
Expand All @@ -781,15 +792,18 @@ export const Playground = () => {
[setChatLayoutMode],
);
const nextChatLayoutMode: PlaygroundCockpitMode =
normalizedChatLayoutMode === "focus" ? "cockpit" : "focus";
focusModeActive ? "cockpit" : "focus";
const chatLayoutToggleLabel =
normalizedChatLayoutMode === "focus"
focusModeActive
? toText(t("playground:cockpit.showPanels", "Show cockpit panels"))
: toText(t("playground:cockpit.enterFocus", "Enter focus chat"));
const chatLayoutToggleText =
normalizedChatLayoutMode === "focus"
focusModeActive
? toText(t("playground:cockpit.cockpit", "Cockpit"))
: toText(t("playground:cockpit.focus", "Focus"));
const exitFocusLabel = toText(
t("playground:cockpit.exitFocus", "Exit focus"),
);

React.useEffect(() => {
setRouteContext({ routeId: "chat", surface: "webui" });
Expand Down Expand Up @@ -3514,6 +3528,20 @@ export const Playground = () => {
</div>
)}

{focusModeActive && (
<button
type="button"
data-testid="playground-focus-exit"
aria-label={exitFocusLabel}
title={exitFocusLabel}
onClick={() => handleChatLayoutModeChange("cockpit")}
className="fixed right-3 top-3 z-50 inline-flex min-h-[34px] items-center gap-2 rounded-full border border-border bg-surface/95 px-3 py-1.5 text-xs font-semibold text-text shadow-lg backdrop-blur transition hover:bg-surface2 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus sm:right-4 sm:top-4"
>
<Minimize2 className="h-3.5 w-3.5" aria-hidden="true" />
<span>{exitFocusLabel}</span>
</button>
)}

<div className="relative z-10 flex h-full min-h-0 w-full">
<PlaygroundCockpitShell
mode={normalizedChatLayoutMode}
Expand All @@ -3530,7 +3558,7 @@ export const Playground = () => {
data-testid="playground-chat-shell"
className="flex h-full min-h-0 min-w-0 flex-1 flex-col"
>
{parentMeta?.parentHistoryId && (
{!focusModeActive && parentMeta?.parentHistoryId && (
<div className="flex w-full justify-center px-5 pt-2">
<div className="inline-flex flex-wrap items-center justify-center gap-2">
<button
Expand Down Expand Up @@ -3575,7 +3603,8 @@ export const Playground = () => {
</div>
</div>
)}
<div className="px-2 pt-1 sm:px-4 sm:pt-2">
{!focusModeActive && (
<div className="px-2 pt-1 sm:px-4 sm:pt-2">
<div
className={`mx-auto flex w-full ${chatContentWidthClassName} items-center justify-between gap-2 text-[10px] text-text-muted sm:text-[11px]`}
>
Expand Down Expand Up @@ -3613,7 +3642,7 @@ export const Playground = () => {
type="button"
data-testid="playground-chat-layout-mode-trigger"
aria-label={chatLayoutToggleLabel}
aria-pressed={normalizedChatLayoutMode === "focus"}
aria-pressed={focusModeActive}
onClick={() => handleChatLayoutModeChange(nextChatLayoutMode)}
title={chatLayoutToggleLabel}
className="inline-flex min-h-[26px] min-w-[26px] items-center justify-center gap-1 rounded-full border border-border bg-surface2 px-1.5 py-0.5 text-text hover:bg-surface sm:px-2"
Expand Down Expand Up @@ -3919,6 +3948,7 @@ export const Playground = () => {
/>
) : null}
</div>
)}
<div
ref={containerRef}
data-testid={
Expand Down Expand Up @@ -3970,7 +4000,7 @@ export const Playground = () => {
: ""
}`}
>
{!isMobileViewport ? (
{!isMobileViewport && !focusModeActive ? (
<div
className={`mx-auto w-full ${chatContentWidthClassName} px-4 pt-2 text-[11px] text-text-muted`}
>
Expand Down Expand Up @@ -4055,7 +4085,7 @@ export const Playground = () => {
</div>
</div>
</PlaygroundCockpitShell>
{shouldShowArtifactsEdgeExpand && (
{!focusModeActive && shouldShowArtifactsEdgeExpand && (
<button
ref={artifactsEdgeExpandRef}
type="button"
Expand Down Expand Up @@ -4084,7 +4114,7 @@ export const Playground = () => {
</span>
</button>
)}
{artifactsOpen && (
{!focusModeActive && artifactsOpen && (
<>
<div className="hidden h-full w-[36%] min-w-[280px] max-w-[520px] shrink-0 lg:flex">
{renderArtifactsPanel()}
Expand Down
Loading
Loading