fix(ui): keep iPhone task chat stable while typing and streaming (#17232)

## Thinking Path

> - Paperclip keeps human-agent conversations attached to tasks.
> - People need to read and reply comfortably from a phone.
> - The mobile task page scrolled the document and positioned its
composer with a sticky navigation offset.
> - Safari's keyboard resize and later focus pan could move that
document again, causing overlap, gaps, and jumps while editing.
> - This change gives mobile and desktop the same message scroller and a
normal flex footer.
> - CSS owns the layout. A small VisualViewport adapter handles the
visible height and Safari's stale document focus scroll.
> - Streaming no longer measures every historical row while following
the bottom.

## Linked Issues or Issue Description

Fixes https://github.com/paperclipai/paperclip/issues/16497

Related: https://github.com/paperclipai/paperclip/pull/15245 also
addresses keyboard positioning. This change additionally removes mobile
document auto-follow and reduces historical-row measurement work.

## What Changed

### One scroll owner and a natural-flow composer

Before:

```text
Document scroll + mobile auto-follow
  └─ Conversation + sticky composer + animated navigation offset
```

After:

```text
Height-constrained conversation shell
  ├─ Header
  ├─ Message scroller — owns follow/reading position
  └─ Composer — normal flex footer
Navigation stays at the layout viewport bottom, behind the keyboard
```

- Reuse TaskMessageScroller on mobile. Delete the separate 136-line
useWindowAutoFollow implementation and its superseded tests.
- Keep the composer in normal flex flow. Remove its sticky bottom
positioning and navigation-offset animation.
- Constrain the mobile conversation with CSS. Classic tasks and other
pages retain their existing layout.
- Add a 47-line useMobileVisualViewport hook. It changes shell height on
resize, not on typing or incoming messages. It never translates the
shell or hides navigation.
- Reject Safari's stale document focus pan only for an unzoomed,
keyboard-open conversation. Inner message and editor scrolling stay
native.
- Keep a saved reading anchor when reading older messages. Skip
historical-row measurements while following the bottom.
- Update tests and the existing mobile streaming fixture. Keep only two
layout-contract sentences in DESIGN.md.

**Scope:** 19 files, +367 / -482 lines: 115 fewer lines overall.
Production UI/CSS alone is +158 / -190 lines. No API, database, or
dependency changes.

## Verification

Bottom-follow is also saved explicitly for Back navigation. A new
regression covers returning after rotation changes message wrapping,
without restoring historical-row scans.

### Physical iPhone acceptance

Tested on an iPhone 15 Pro running iOS 26.7.1. The reporter confirmed:

- Opening the keyboard positions the composer correctly immediately,
before any typing.
- The installed web app works.
- Long drafts, insertion, deletion, and sending are acceptable. The
reporter described long drafts as not perfect but good enough.
- Reading older messages during incoming output preserves position;
returning to the bottom resumes following.
- Rotation, keyboard close/reopen, and attachment/picker transitions
work.

Remote inspection corroborated the corrected open-keyboard state:
visible height 358px, composer bottom 358px, document scrollY 0.
Navigation remained display:block at y=631px, behind the keyboard.

Earlier candidates failed device testing. The decisive trace showed a
correct viewport resize followed about one frame later by a stale 337px
native document focus pan. Typing caused Safari to correct it. Tests now
cover that event ordering, the reverse ordering, and zoom panning.

### Performance evidence

The existing TaskChatPerfHarness supplied 200 historical responses,
4,000 collapsed tools, and 30 updates at 100ms intervals.

| Measurement | Before | After |
| --- | ---: | ---: |
| Historical row geometry reads | 6,000 | 0 |
| Renderer main-thread work | 331.2ms | 273.5ms |
| Largest task | 27.1ms | 12.3ms |

This is one instrumented Chromium development run on the Mac, not a
phone benchmark or a statistical speedup claim. The zero-row-measurement
result also has a regression test that failed with 6,000 reads before
the change.

To reproduce, serve ui/tests/task-chat-perf.html with the UI Vite
server. Record 30 streaming updates at the bottom. Inspect
TaskMessageScroller and row getBoundingClientRect calls. Existing
historical-render tests also confirm that unchanged Markdown does not
reparse on live-tail updates.

### Automated and full-app checks

- [Current-head
CI](https://github.com/paperclipai/paperclip/actions/runs/39862524031)
is green: 54 successful checks and 2 skipped checks. Greptile rates the
current head 5/5, confirms the Back-navigation fix, and has no
unresolved findings.

- **601 focused tests pass** across the task page, layout, composer,
scroller, viewport adapter, and historical-render coverage.
- UI typecheck and production build pass.
- Token gates, Storybook catalog checks, and catalog/map structural
tests pass.
- Independent review of the final source tree passed with no findings.
- The full source app ran on macOS with an isolated database and saved
conversation history. A reply sent through the real UI persisted after
reload. Provider execution was not part of this layout test.
- **Full local repository checks did not pass.** Build/typecheck stop at
missing Rust cargo. The full test run reported 142 failed server tests
and 3 failed suites; missing cargo is confirmed in those three suites.
The other server failures were not diagnosed in this UI task.
Current-head CI now supplies the repository-wide evidence; these local
failures remain disclosed.

## Risks

- Mobile conversations now share the desktop scroll owner. Navigation,
loading, history restoration, and reading-position tests cover that
change.
- The keyboard-state threshold is a 120px viewport reduction.
Accessory-only keyboards retain the normal navigation spacing.
- The document-scroll fallback is scoped to rendered conversations with
an open keyboard at scale 1. It does not handle inner scrolling or
prevent zoom panning.
- Long-draft behavior is accepted, with minor polish still possible.
- The UI change is reversible and requires no data migration.

## Model Used

OpenAI GPT-6 Astra, exact model ID gpt-6-astra, through OpenCode.
Capabilities used: reasoning, code editing, tests, Chromium performance
tracing, and Safari remote inspection. The harness does not expose the
context-window size. An independent general-purpose subagent reviewed
the final tree; its serving model ID was not exposed.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with Fixes / Closes /
Refs OR (b) described the issue in-PR following the relevant issue
template
- [x] I have not referenced internal/instance-local Paperclip issues or
links
- [x] My branch name describes the change and contains no internal
Paperclip ticket id or instance-derived details
- [x] I have run focused tests locally and they pass; repository-wide
failures are disclosed above
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
This commit is contained in:
Clément authored and GitHub committed 2026-10-26 08:44:39 -07:00
1 parent 5823e9a664
commit df5a78cf91
19 files changed
+367 -482

No files matched your search

+6 -2
View File
@@ -60,8 +60,12 @@ title sizes remain larger.
The bottom navigation responds to accumulated scrolling, ignoring small
reversals and Safari's edge bounce. It glides out and eases back in with shared
motion tokens; the task composer follows the same motion. Keep page padding
stable while the navigation moves, and honor reduced-motion preferences.
motion tokens. Keep page padding stable while the navigation moves, and honor
reduced-motion preferences.
Mobile task conversations size their shell to the visible viewport, with one
message scroller and a normal flex footer for the composer. Navigation stays at
the layout viewport bottom, behind the keyboard.
Cold task opens reveal the instruction and recent conversation independently of
plans, related metadata, and historical run activity. Show the latest human
+9 -4
View File
@@ -45,6 +45,7 @@ import { useSidebar } from "../context/SidebarContext";
import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory";
import { useMobileNavVisibility } from "../hooks/useMobileNavVisibility";
import { useMobileVisualViewport } from "../hooks/useMobileVisualViewport";
import { healthApi } from "../api/health";
import {
resolveArchivedCompanyBounce,
@@ -178,6 +179,7 @@ export function Layout() {
const scrollMemory = useRef(new NavigationScrollMemory());
const activeScrollKey = useRef<string>(location.key);
const mobileNavVisible = useMobileNavVisibility(isMobile, location.pathname);
const mobileViewportRef = useMobileVisualViewport(isMobile);
const [shortcutsOpen, setShortcutsOpen] = useState(false);
const matchedCompany = useMemo(() => {
if (!companyPrefix) return null;
@@ -588,8 +590,10 @@ export function Layout() {
return (
<ChatSetupSidebarProvider>
<div
ref={mobileViewportRef}
data-mobile={isMobile ? "true" : undefined}
className={cn(
"bg-background text-foreground pt-(--sz-safe-top)",
"paperclip-app-shell bg-background text-foreground pt-(--sz-safe-top)",
// overflow-x-clip on mobile keeps a stray wide descendant from making the
// whole viewport scroll horizontally. clip (not hidden) leaves overflow-y
// computed as visible, so native body scroll + the sticky breadcrumb keep
@@ -609,7 +613,7 @@ export function Layout() {
<DevRestartBanner devServer={health?.devServer} />
<div
className={cn(
"min-h-0 flex-1",
"app-main-row min-h-0 flex-1",
isMobile ? "w-full" : "flex overflow-clip",
)}
>
@@ -663,12 +667,13 @@ export function Layout() {
<div
className={cn(
"flex min-w-0 flex-col",
"app-content-column flex min-w-0 flex-col",
isMobile ? "w-full" : "h-full flex-1",
)}
>
<div
className={cn(
"app-breadcrumb",
isMobile &&
"sticky top-0 z-20 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/85",
)}
@@ -681,7 +686,7 @@ export function Layout() {
</div>
) : null}
</div>
<div className={cn(isMobile ? "block" : "flex flex-1 min-h-0")}>
<div className={cn("app-content-row", isMobile ? "block" : "flex flex-1 min-h-0")}>
<main
id="main-content"
ref={mainContentRef}
+9 -3
View File
@@ -40,6 +40,7 @@ import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
import { useStreamlinedUiEnabled } from "../hooks/useStreamlinedUiEnabled";
import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory";
import { useMobileNavVisibility } from "../hooks/useMobileNavVisibility";
import { useMobileVisualViewport } from "../hooks/useMobileVisualViewport";
import { healthApi } from "../api/health";
import { resolveArchivedCompanyBounce, shouldSyncCompanySelectionFromRoute } from "../lib/company-selection";
import { useOptionalToastActions } from "../context/ToastContext";
@@ -143,6 +144,7 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
const scrollMemory = useRef(new NavigationScrollMemory());
const activeScrollKey = useRef<string>(location.key);
const mobileNavVisible = useMobileNavVisibility(isMobile, location.pathname);
const mobileViewportRef = useMobileVisualViewport(isMobile);
const [shortcutsOpen, setShortcutsOpen] = useState(false);
const matchedCompany = useMemo(() => {
if (!companyPrefix) return null;
@@ -574,8 +576,10 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
return (
<ChatSetupSidebarProvider>
<div
ref={mobileViewportRef}
data-mobile={isMobile ? "true" : undefined}
className={cn(
"bg-background text-foreground pt-(--sz-safe-top)",
"paperclip-app-shell bg-background text-foreground pt-(--sz-safe-top)",
// overflow-x-clip on mobile keeps a stray wide descendant from making the
// whole viewport scroll horizontally. clip (not hidden) leaves overflow-y
// computed as visible, so native body scroll + the sticky breadcrumb keep
@@ -591,7 +595,7 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
</a>
<WorktreeBanner />
<DevRestartBanner devServer={health?.devServer} />
<div className={cn("min-h-0 flex-1", isMobile ? "w-full" : "flex overflow-clip")}>
<div className={cn("app-main-row min-h-0 flex-1", isMobile ? "w-full" : "flex overflow-clip")}>
{isMobile && sidebarOpen && (
<button
type="button"
@@ -656,9 +660,10 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
</SecondarySidebar>
) : null}
<div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "h-full flex-1")}>
<div className={cn("app-content-column flex min-w-0 flex-col", isMobile ? "w-full" : "h-full flex-1")}>
<div
className={cn(
"app-breadcrumb",
!isMobile && useStreamlinedTaskDetailShell && "hidden",
isMobile && "sticky top-0 z-20 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/85",
)}
@@ -672,6 +677,7 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
) : null}
</div>
<div className={cn(
"app-content-row",
isMobile ? "block" : "flex flex-1 min-h-0",
!isMobile && useStreamlinedTaskDetailShell && "streamlined-task-detail-surface",
)}>
+9 -13
View File
@@ -3535,7 +3535,7 @@ describe("TaskChatThread composer alignment", () => {
const dock = container
.querySelector('[data-testid="mock-editor"]')
?.closest("div.sticky") as HTMLElement | null;
?.closest('[data-testid="task-chat-composer-dock"]') as HTMLElement | null;
expect(dock?.className).toContain("w-full");
expect(dock?.className).toContain("max-w-(--tc-shell-max-w)");
@@ -4319,26 +4319,22 @@ describe("TaskChatThread Paperclip Runner queue", () => {
});
});
describe("TaskChatThread mobile composer dock (PAP-495)", () => {
it("pins the composer to the nav-aware bottom offset so its action row clears the auto-hiding bottom nav", () => {
describe("TaskChatThread mobile composer dock", () => {
it("scrolls messages independently of the composer on mobile", () => {
sidebarState.isMobile = true;
renderThread({
comments: [],
comments: createLongThreadComments(),
onAdd: async () => {},
draftKey: "task-chat-draft:issue-mobile",
});
const dock = container
.querySelector('[data-testid="mock-editor"]')
?.closest("div.sticky") as HTMLElement | null;
const dock = container.querySelector('[data-testid="task-chat-composer-dock"]');
const scroller = container.querySelector('[data-testid="task-chat-scroller"]');
expect(dock).not.toBeNull();
// Bottom offset comes from --tc-composer-bottom (Layout raises it to the nav
// height while the nav is on screen) — NOT the raw safe-area dock, which is
// what let the nav occlude the action row before PAP-495.
expect(dock?.className).toContain("bottom-(--tc-composer-bottom)");
expect(dock?.className).not.toContain("bottom-(--sz-calc-8)");
expect(scroller).not.toBeNull();
expect(scroller?.textContent).toContain("Thread message 1");
expect(scroller?.contains(dock)).toBe(false);
});
});
+4 -15
View File
@@ -110,7 +110,6 @@ import {
useRunnerGoalControl,
} from "@/components/task-chat/RunnerGoalWidget";
import { TaskChatQueuedMessages } from "@/components/task-chat/TaskChatQueuedMessages";
import { TaskChatWindowScroll } from "@/components/task-chat/useWindowAutoFollow";
import { useSidebar } from "@/context/SidebarContext";
import { useStreamlinedUiEnabled } from "@/hooks/useStreamlinedUiEnabled";
import { useDismissedTaskQuestions } from "@/hooks/useDismissedTaskQuestions";
@@ -3040,25 +3039,19 @@ export function TaskChatThread(props: TaskChatThreadProps) {
<TaskChatRequestHistoryContext.Provider value={issueId ?? "unknown"}>
<TaskChatExpansionState.Provider value={expansionState.current}>
<TaskChatScrollReady.Provider value={!historyPending || historyWaitExpired}>
<TaskChatWindowScroll
contentKey={isMobile ? autoFollowContentKey : 0}
enabled={isMobile && historyRevealed}
/>
<TaskChatPresentationProvider
mode={streamlinedUiEnabled ? "streamlined" : "production"}
>
<div
className={cn(
"flex flex-col",
!isMobile && "min-h-0 flex-1",
"flex min-h-0 flex-1 flex-col",
isMobile && !historyRevealed && "task-chat-history-pending",
)}
data-testid="task-chat-thread"
>
<div
className={cn(
"relative flex flex-col",
(!isMobile || !historyRevealed) && "min-h-0 flex-1",
"relative flex min-h-0 flex-1 flex-col",
)}
aria-busy={!historyRevealed}
>
@@ -3093,8 +3086,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
<div
data-testid="task-chat-history-content"
className={cn(
"flex flex-col",
!isMobile && "min-h-0 flex-1",
"flex min-h-0 flex-1 flex-col",
!historyRevealed && "invisible",
)}
inert={!historyRevealed}
@@ -3103,9 +3095,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
<>
{items.length === 0 && !tailRunId && !optimisticRunnerStartup ? (
<div
className={
isMobile ? undefined : "min-h-0 flex-1 overflow-y-auto"
}
className="min-h-0 flex-1 overflow-y-auto"
>
{threadHeaderWithBlockers ? (
<div
@@ -3241,7 +3231,6 @@ export function TaskChatThread(props: TaskChatThreadProps) {
}
contentKey={`${autoFollowContentKey}:${historyRevealed}`}
className={isMobile ? undefined : "pt-3"}
scroll={!isMobile}
/>
)}
</>
@@ -856,7 +856,7 @@ describe("TaskChatComposer", () => {
render(<TaskChatComposer onAdd={vi.fn()} workMode="standard" mobile />);
expect(editable().dataset.contentClassName).toContain("min-h-(--sz-48px)");
expect(editable().dataset.contentClassName).toContain("max-h-(--sz-28dvh)");
expect(editable().dataset.contentClassName).toContain("max-h-(--tc-composer-editor-max-height)");
});
it("submits the trimmed body on Cmd+Enter and clears the draft", async () => {
@@ -1375,7 +1375,7 @@ export function TaskChatComposer({
className={cn(disabled && "opacity-60")}
contentClassName={
mobile
? "max-h-(--sz-28dvh) min-h-(--sz-48px) overflow-y-auto px-1 py-1 text-base scrollbar-auto-hide"
? "max-h-(--tc-composer-editor-max-height) min-h-(--sz-48px) overflow-y-auto px-1 py-1 text-base scrollbar-auto-hide"
: "max-h-(--sz-28dvh) min-h-(--sz-48px) overflow-y-auto px-1 py-1 text-sm scrollbar-auto-hide"
}
/>
@@ -8,7 +8,7 @@ interface TaskChatComposerDockProps {
concealed?: boolean;
}
/** The task thread and its Storybook phone previews share the same composer spacing. */
/** A normal flex footer beside the message scroller, on desktop and mobile. */
export function TaskChatComposerDock({ children, mobile, streamlined, concealed = false }: TaskChatComposerDockProps) {
return <div
data-testid="task-chat-composer-dock"
@@ -16,14 +16,11 @@ export function TaskChatComposerDock({ children, mobile, streamlined, concealed
inert={concealed}
aria-hidden={concealed || undefined}
className={cn(
"sticky flex max-w-(--tc-shell-max-w) flex-col gap-2",
"flex max-w-(--tc-shell-max-w) shrink-0 flex-col gap-2",
concealed && "invisible",
mobile
? cn(
"mobile-nav-composer bottom-(--tc-composer-bottom) z-20 w-auto px-1 pb-1",
streamlined ? "-mx-2" : "mx-2",
)
: "bottom-0 z-10 mx-auto w-full px-1 pb-1 md:px-4 md:pb-2",
? cn("w-auto px-1 pb-1", streamlined ? "-mx-2" : "mx-2")
: "mx-auto w-full px-1 pb-1 md:px-4 md:pb-2",
streamlined && "md:px-0 md:pb-0",
(!streamlined || mobile) &&
"bg-background/80 pt-1 backdrop-blur supports-[backdrop-filter]:bg-background/60 dark:bg-transparent dark:backdrop-blur-none dark:supports-[backdrop-filter]:bg-transparent",
@@ -435,7 +435,7 @@ export function TaskChatThreadView({
// Cheap content signature so streaming growth (text lengthening without the
// item count changing) still advances the auto-follow key. Shared by the
// desktop scroller above and the mobile window-scroll follow (TaskChatThread).
// shared message scroller on desktop and mobile (TaskChatThread).
function signatureOf(it: TaskChatItem): number {
if (it.kind === "message") return it.text.length + (it.attachedTurn ? 1 : 0);
if (it.kind === "thinking") return it.lines.reduce((n, l) => n + l.length, 0);
@@ -125,6 +125,36 @@ describe("TaskMessageScroller", () => {
vi.unstubAllGlobals();
});
it("does not measure historical rows on each streaming update while following the bottom", () => {
let initialized = false;
let rowMeasurements = 0;
const update = (tick: number) => flushSync(() => root.render(
<TaskMessageScroller contentKey={tick}>
<div ref={(node) => {
if (node && !initialized) {
fakeGeometry(node.parentElement!, { scrollHeight: 20000, clientHeight: 400 });
initialized = true;
}
}}>
{Array.from({ length: 200 }, (_, index) => <div key={index} data-thread-anchor={`history-${index}`} ref={(node) => {
if (node) node.getBoundingClientRect = () => {
rowMeasurements++;
const top = index * 100 - scroller().scrollTop;
return { top, bottom: top + 100, height: 100 } as DOMRect;
};
}}>History {index}</div>)}
<p>Live response {tick}</p>
</div>
</TaskMessageScroller>,
));
update(0);
rowMeasurements = 0;
for (let tick = 1; tick <= 30; tick++) update(tick);
expect(container.textContent).toContain("Live response 30");
expect(scroller().scrollTop).toBe(20000);
expect(rowMeasurements).toBe(0);
});
it("applies same-task hash changes and restores each history entry without remounting", async () => {
let initialized = false;
function navigate(key: string, hash: string, restore = false) {
@@ -178,6 +208,42 @@ describe("TaskMessageScroller", () => {
expect(scroller().scrollTop).toBe(150);
});
it("restores bottom-follow on Back after the viewport and message heights change", () => {
const configured = new WeakSet<HTMLElement>();
function mount(restore: boolean, scrollHeight: number, clientHeight: number) {
flushSync(() => root.render(
<TaskChatScrollNavigation.Provider value={{ key: "rotated-bottom-entry", hash: "", restore }}>
<TaskMessageScroller contentKey={scrollHeight}>
<div ref={(node) => {
const viewport = node?.parentElement;
if (viewport && !configured.has(viewport)) {
fakeGeometry(viewport, { scrollHeight, clientHeight });
let top = 0;
Object.defineProperty(viewport, "scrollTop", {
configurable: true,
get: () => top,
set: (value: number) => {
top = Math.max(0, Math.min(value, viewport.scrollHeight - viewport.clientHeight));
},
});
configured.add(viewport);
}
}}>Latest response</div>
</TaskMessageScroller>
</TaskChatScrollNavigation.Provider>,
));
}
mount(false, 1000, 400);
flushSync(() => root.render(null));
// Rotation changed wrapping and the available message viewport while away.
mount(true, 2000, 500);
expect(scroller().scrollTop).toBe(1500);
expect(pill()).toBeNull();
Object.defineProperty(scroller(), "scrollHeight", { value: 2400, configurable: true });
mount(true, 2400, 500);
expect(scroller().scrollTop).toBe(1900);
});
it("renders children inside the scroll container, pill hidden, scrolled to bottom on mount", () => {
render();
const el = scroller();
@@ -116,12 +116,17 @@ export function TaskMessageScroller({ children, contentKey, className }: TaskMes
return true;
}, [scrollToBottom]);
const rememberAnchor = useCallback(() => {
const rememberAnchor = useCallback((capture = true) => {
const el = ref.current;
if (!el || el.clientHeight === 0) return;
const rect = el.getBoundingClientRect();
anchorRef.current = readThreadScrollAnchor(el, rect.top, rect.bottom);
if (initialPositionApplied.current) navigation.remember(el.scrollTop, anchorRef.current);
if (pinnedRef.current) {
// Following the bottom needs only an offset, not a scan of every old row.
anchorRef.current = null;
} else if (capture) {
const rect = el.getBoundingClientRect();
anchorRef.current = readThreadScrollAnchor(el, rect.top, rect.bottom);
}
if (initialPositionApplied.current) navigation.remember(el.scrollTop, anchorRef.current, pinnedRef.current);
}, [navigation.key, navigation.hash, navigation.ready]);
const reconcileContent = useCallback(() => {
@@ -140,18 +145,20 @@ export function TaskMessageScroller({ children, contentKey, className }: TaskMes
const delta = threadScrollAnchorDelta(el, anchorRef.current, el.getBoundingClientRect().top);
if (delta) el.scrollTop += delta;
}
rememberAnchor();
// Compensation preserves the existing reading anchor. Recapture only when
// the reader scrolls or navigation selects a different position.
rememberAnchor(false);
}, [rememberAnchor, scrollToBottom, hidePill]);
const handleScroll = useCallback(() => {
if (!ref.current?.clientHeight) return;
rememberAnchor();
showScrollbarWhileScrolling();
// A growing composer shrinks this viewport. Some browsers dispatch the
// resulting scroll event before ResizeObserver, so preserve the previous
// pinned state here instead of mistaking the layout change for a user
// scroll away from the bottom.
if (followViewportResize()) {
rememberAnchor(false);
hidePill();
return;
}
@@ -164,9 +171,11 @@ export function TaskMessageScroller({ children, contentKey, className }: TaskMes
pinnedRef.current = true;
hidePill();
}
rememberAnchor();
return;
}
pinnedRef.current = pinned;
rememberAnchor();
if (pinned) hidePill();
else showPill();
}, [
@@ -16,7 +16,7 @@ export function taskChatScrollEntry(location: { key: string; hash: string; pathn
// Scoped to browser-history entries, not issues: opening the same task from a
// new Inbox click starts at latest, while Back restores the previous reading.
const positions = new Map<string, { top: number; anchor: ThreadScrollAnchor | null }>();
const positions = new Map<string, { top: number; anchor: ThreadScrollAnchor | null; followBottom: boolean }>();
export function useTaskChatScrollNavigation() {
const navigation = useContext(TaskChatScrollNavigation);
@@ -32,6 +32,9 @@ export function useTaskChatScrollNavigation() {
if (!navigation) return null;
if (navigation.restore && positionKey && positions.has(positionKey)) {
const saved = positions.get(positionKey)!;
// A saved pixel offset goes stale when rotation changes text wrapping.
// Bottom-follow needs no row measurements, even across navigation.
if (saved.followBottom) return Math.max(0, root.scrollHeight - root.clientHeight);
if (saved.anchor && [...root.querySelectorAll<HTMLElement>("[data-thread-anchor]")].some((row) => row.dataset.threadAnchor === saved.anchor?.id)) {
return scrollTop + threadScrollAnchorDelta(root, saved.anchor, viewportTop);
}
@@ -45,9 +48,9 @@ export function useTaskChatScrollNavigation() {
}
return null;
},
remember(top: number, anchor: ThreadScrollAnchor | null) {
remember(top: number, anchor: ThreadScrollAnchor | null, followBottom: boolean) {
if (!positionKey || !ready) return;
positions.set(positionKey, { top, anchor });
positions.set(positionKey, { top, anchor, followBottom });
if (positions.size > 100) positions.delete(positions.keys().next().value!);
},
};
@@ -1,263 +0,0 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useWindowAutoFollow } from "./useWindowAutoFollow";
import { TaskChatScrollNavigation } from "./scroll-navigation";
function Host({ contentKey, enabled }: { contentKey: unknown; enabled: boolean }) {
useWindowAutoFollow(contentKey, enabled);
return <div data-testid="task-chat-thread">thread</div>;
}
/**
* jsdom has no layout: document scroll geometry is faked on the scrolling
* element, window.scrollTo is stubbed to record calls and update scrollY.
*/
describe("useWindowAutoFollow", () => {
let container: HTMLDivElement;
let root: Root;
let scrollToCalls: number[];
function fakeWindowGeometry({ scrollHeight = 2000, innerHeight = 800 } = {}) {
const el = document.scrollingElement ?? document.documentElement;
let currentScrollHeight = scrollHeight;
Object.defineProperty(el, "scrollHeight", {
get: () => currentScrollHeight,
configurable: true,
});
Object.defineProperty(window, "innerHeight", { value: innerHeight, configurable: true });
return {
setScrollHeight(value: number) {
currentScrollHeight = value;
},
};
}
function setWindowScrollY(y: number) {
Object.defineProperty(window, "scrollY", { value: y, configurable: true });
}
function render(contentKey: unknown, enabled = true) {
flushSync(() => {
root.render(<Host contentKey={contentKey} enabled={enabled} />);
});
}
/** Fire a window scroll event and flush the continuous-priority update. */
function scrollWindowTo(y: number) {
setWindowScrollY(y);
window.dispatchEvent(new Event("scroll"));
return new Promise<void>((resolve) => {
setTimeout(resolve);
});
}
/** Wait out the mount effect's rAF re-follow so it can't leak into asserts. */
function flushRaf() {
return new Promise<void>((resolve) => {
requestAnimationFrame(() => setTimeout(resolve));
});
}
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
scrollToCalls = [];
fakeWindowGeometry();
setWindowScrollY(0);
vi.stubGlobal("scrollTo", (options?: ScrollToOptions | number) => {
const top = typeof options === "number" ? options : (options?.top ?? 0);
scrollToCalls.push(top);
// Mirror the browser: a follow to the bottom leaves the window pinned.
setWindowScrollY(Math.max(0, top - window.innerHeight));
});
});
afterEach(() => {
flushSync(() => root.unmount());
container.remove();
vi.unstubAllGlobals();
});
it("reapplies same-task targets and POP positions on mobile without remounting", async () => {
vi.stubGlobal("scrollTo", (options: ScrollToOptions) => setWindowScrollY(Math.min(1200, options.top ?? 0)));
function MobileThread() {
useWindowAutoFollow("unchanged", true);
return <div data-testid="task-chat-thread">{[100, 500].map((top, index) => (
<div key={index} id={`mobile-comment-${index}`} data-thread-anchor={`mobile-comment-${index}`} ref={(node) => {
if (node) node.getBoundingClientRect = () => ({ top: top - window.scrollY, bottom: top + 100 - window.scrollY, height: 100 } as DOMRect);
}}>Comment {index}</div>
))}</div>;
}
const navigate = (key: string, hash: string, restore = false) => flushSync(() => root.render(
<TaskChatScrollNavigation.Provider value={{ key, hash, restore }}><MobileThread /></TaskChatScrollNavigation.Provider>,
));
navigate("mobile-entry-one", "#mobile-comment-0");
const thread = container.firstElementChild;
expect(window.scrollY).toBe(100);
await scrollWindowTo(150);
navigate("mobile-entry-two", "#mobile-comment-1");
expect(container.firstElementChild).toBe(thread);
expect(window.scrollY).toBe(500);
navigate("mobile-entry-one", "#mobile-comment-0", true);
expect(window.scrollY).toBe(150);
navigate("mobile-entry-one", "#mobile-comment-1", true);
expect(window.scrollY).toBe(500);
});
it("owns browser restoration only while the mobile thread is enabled", () => {
window.history.scrollRestoration = "auto";
render(0);
expect(window.history.scrollRestoration).toBe("manual");
render(0, false);
expect(window.history.scrollRestoration).toBe("auto");
});
it("scrolls the window to the bottom on mount", () => {
render(0);
expect(scrollToCalls).toContain(2000);
});
it("follows content growth while pinned to the bottom", async () => {
render(0);
await flushRaf();
await scrollWindowTo(1200); // pinned: 2000 - 1200 - 800 = 0
scrollToCalls = [];
render(1);
expect(scrollToCalls).toContain(2000);
});
it("holds position when the user has scrolled up", async () => {
render(0);
await flushRaf();
await scrollWindowTo(100); // far from the bottom
scrollToCalls = [];
render(1);
expect(scrollToCalls).toHaveLength(0);
});
it("follows document growth while pinned on mobile", async () => {
let triggerResize = () => {};
vi.stubGlobal(
"ResizeObserver",
class {
constructor(callback: ResizeObserverCallback) {
triggerResize = () =>
callback([], this as unknown as ResizeObserver);
}
observe() {}
disconnect() {}
},
);
const geometry = fakeWindowGeometry();
render(0);
await flushRaf();
await scrollWindowTo(1200);
scrollToCalls = [];
geometry.setScrollHeight(2300);
triggerResize();
expect(scrollToCalls).toContain(2300);
});
it("holds document position through mobile composer growth when scrolled up", async () => {
let triggerResize = () => {};
vi.stubGlobal(
"ResizeObserver",
class {
constructor(callback: ResizeObserverCallback) {
triggerResize = () =>
callback([], this as unknown as ResizeObserver);
}
observe() {}
disconnect() {}
},
);
const geometry = fakeWindowGeometry();
render(0);
await flushRaf();
await scrollWindowTo(100);
scrollToCalls = [];
geometry.setScrollHeight(2300);
triggerResize();
expect(scrollToCalls).toHaveLength(0);
});
it("follows a late thread resize without a React content update or body resize", async () => {
const observed = new Set<Element>();
let resizeThread = () => {};
vi.stubGlobal("ResizeObserver", class {
constructor(callback: ResizeObserverCallback) {
resizeThread = () => {
const thread = container.querySelector('[data-testid="task-chat-thread"]')!;
if (observed.has(thread)) callback([{ target: thread } as ResizeObserverEntry], this as unknown as ResizeObserver);
};
}
observe(element: Element) { observed.add(element); }
disconnect() { observed.clear(); }
});
const geometry = fakeWindowGeometry();
render(0);
scrollToCalls = [];
geometry.setScrollHeight(2400);
resizeThread();
expect(scrollToCalls).toContain(2400);
expect(window.scrollY).toBe(1600);
});
it("keeps following when a scroll event arrives after growth before reconciliation", async () => {
const geometry = fakeWindowGeometry();
render(0);
geometry.setScrollHeight(2400);
await scrollWindowTo(1200); // Still at the old bottom; the user did not move up.
scrollToCalls = [];
render(1);
expect(scrollToCalls).toContain(2400);
});
it("holds an upward user scroll during growth and resumes after returning to the bottom", async () => {
const geometry = fakeWindowGeometry();
render(0);
geometry.setScrollHeight(2400);
await scrollWindowTo(900);
scrollToCalls = [];
render(1);
expect(scrollToCalls).toHaveLength(0);
await scrollWindowTo(1600);
geometry.setScrollHeight(2700);
render(2);
expect(scrollToCalls).toContain(2700);
});
it("follows viewport resizing while pinned and holds it while reading older output", async () => {
render(0);
Object.defineProperty(window, "innerHeight", { value: 600, configurable: true });
window.dispatchEvent(new Event("resize"));
expect(window.scrollY).toBe(1400);
await scrollWindowTo(500);
scrollToCalls = [];
Object.defineProperty(window, "innerHeight", { value: 700, configurable: true });
window.dispatchEvent(new Event("resize"));
expect(scrollToCalls).toHaveLength(0);
});
it("does nothing when disabled", () => {
render(0, false);
expect(scrollToCalls).toHaveLength(0);
render(1, false);
expect(scrollToCalls).toHaveLength(0);
});
});
@@ -1,136 +0,0 @@
import { readThreadScrollAnchor, threadScrollAnchorDelta, type ThreadScrollAnchor } from "./scroll-anchor";
import { useEffect, useLayoutEffect, useRef } from "react";
import { useTaskChatScrollNavigation } from "./scroll-navigation";
const PIN_THRESHOLD_PX = 48;
function scrollingElement(): Element | null {
return document.scrollingElement ?? document.documentElement;
}
function windowPinned(): boolean {
const el = scrollingElement();
if (!el) return true;
return el.scrollHeight - window.scrollY - window.innerHeight <= PIN_THRESHOLD_PX;
}
function scrollWindowToBottom(): void {
const el = scrollingElement();
if (!el) return;
window.scrollTo({ top: el.scrollHeight, left: 0, behavior: "auto" });
}
/**
* Window-scroll counterpart of TaskMessageScroller's auto-follow rule, for the
* mobile document-flow thread (the mobile app shell scrolls the document, not
* an inner viewport — see Layout.tsx). While the window is pinned to the
* bottom (within a small threshold) content growth follows with INSTANT
* scroll; once the user scrolls up we hold their position.
*
* The conversation enables this hook after navigation has settled and before
* its coordinated reveal, so initial positioning happens before paint.
*/
export function useWindowAutoFollow(contentKey: unknown, enabled: boolean): void {
const pinnedRef = useRef(true);
const lastScrollYRef = useRef(window.scrollY);
const navigation = useTaskChatScrollNavigation();
const initialPositionApplied = useRef(false);
const appliedNavigation = useRef({ key: navigation.key, hash: navigation.hash });
const anchorRef = useRef<ThreadScrollAnchor | null>(null);
const rememberAnchor = () => {
lastScrollYRef.current = window.scrollY;
const root = document.querySelector('[data-testid="task-chat-thread"]');
if (root) anchorRef.current = readThreadScrollAnchor(root, 0, window.innerHeight);
if (initialPositionApplied.current) navigation.remember(window.scrollY, anchorRef.current);
};
const reconcile = () => {
if (pinnedRef.current) scrollWindowToBottom();
else {
const root = document.querySelector('[data-testid="task-chat-thread"]');
if (root) {
const delta = threadScrollAnchorDelta(root, anchorRef.current, 0);
if (delta) window.scrollTo({ top: window.scrollY + delta, behavior: "auto" });
}
}
rememberAnchor();
};
useEffect(() => {
if (!enabled) return;
const onScroll = () => {
// Content or viewport growth can leave a gap below the old bottom before
// ResizeObserver runs. Only an upward scroll gives up existing follow
// intent; a scroll event at the same position must not strand the output.
pinnedRef.current = windowPinned() || (pinnedRef.current && window.scrollY >= lastScrollYRef.current);
rememberAnchor();
};
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", reconcile);
return () => {
window.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", reconcile);
};
}, [enabled, navigation.key, navigation.hash, navigation.ready]);
useLayoutEffect(() => {
if (!enabled || typeof ResizeObserver === "undefined") return;
let previousScrollHeight = scrollingElement()?.scrollHeight ?? null;
const observer = new ResizeObserver(() => {
const nextScrollHeight = scrollingElement()?.scrollHeight ?? null;
if (
nextScrollHeight == null ||
nextScrollHeight === previousScrollHeight
) {
return;
}
previousScrollHeight = nextScrollHeight;
reconcile();
});
// The mobile body is viewport-sized even when its contents overflow. Watch
// the thread box too, so streamed rows, images and expanding tools follow
// after their own layout changes without needing a new parent content key.
observer.observe(document.body);
const thread = document.querySelector('[data-testid="task-chat-thread"]');
if (thread) observer.observe(thread);
return () => observer.disconnect();
}, [enabled, navigation.key, navigation.hash, navigation.ready]);
// Follow new content only when already pinned; otherwise hold position.
useLayoutEffect(() => {
if (!enabled) return;
if (appliedNavigation.current.key !== navigation.key || appliedNavigation.current.hash !== navigation.hash) {
appliedNavigation.current = { key: navigation.key, hash: navigation.hash };
initialPositionApplied.current = false;
}
if (navigation.ready && !initialPositionApplied.current) {
const root = document.querySelector('[data-testid="task-chat-thread"]');
const top = root ? navigation.initialPosition(root, 0, window.scrollY) : null;
if (top !== null) {
window.scrollTo({ top, behavior: "auto" });
pinnedRef.current = windowPinned();
rememberAnchor();
}
initialPositionApplied.current = true;
}
reconcile();
}, [contentKey, enabled, navigation.key, navigation.hash, navigation.ready]);
useLayoutEffect(() => {
if (!enabled) return;
// One owner for document-flow compensation, as on the desktop viewport.
const previousRestoration = window.history.scrollRestoration;
window.history.scrollRestoration = "manual";
document.documentElement.classList.add("task-chat-window-scroll");
return () => {
document.documentElement.classList.remove("task-chat-window-scroll");
window.history.scrollRestoration = previousRestoration;
};
}, [enabled]);
}
/** Mount below TaskChatScrollReady so mobile navigation also waits for targets
* fetched after the conversation's first reveal. */
export function TaskChatWindowScroll({ contentKey, enabled }: { contentKey: unknown; enabled: boolean }) {
useWindowAutoFollow(contentKey, enabled);
return null;
}
@@ -0,0 +1,116 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { useMobileVisualViewport } from "./useMobileVisualViewport";
function Host({ mobile = true, conversation = true }: { mobile?: boolean; conversation?: boolean }) {
const ref = useMobileVisualViewport(mobile);
return <div ref={ref}><div data-task-chat-shell={conversation ? "" : undefined}>Chat shell</div></div>;
}
let root: Root;
let container: HTMLDivElement;
let viewport: EventTarget & { height: number; offsetTop: number; scale: number };
beforeEach(() => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("innerHeight", 695);
Object.defineProperty(document.documentElement, "clientHeight", { configurable: true, value: 695 });
viewport = Object.assign(new EventTarget(), { height: 695, offsetTop: 0, scale: 1 });
vi.stubGlobal("visualViewport", viewport);
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
Reflect.deleteProperty(document.documentElement, "clientHeight");
document.documentElement.scrollTop = 0;
vi.unstubAllGlobals();
});
it("resizes the shell from the visible height when iOS innerHeight shrinks too", () => {
act(() => root.render(<Host />));
const shell = container.firstElementChild as HTMLElement;
act(() => {
vi.stubGlobal("innerHeight", 358);
viewport.height = 358;
viewport.offsetTop = 337;
viewport.dispatchEvent(new Event("resize"));
});
expect(shell.style.getPropertyValue("--mobile-chat-viewport-height")).toBe("358px");
expect(shell.dataset.keyboardOpen).toBe("true");
// Panning must not translate or resize the shell a second time.
act(() => { viewport.offsetTop = 185; viewport.dispatchEvent(new Event("scroll")); });
expect(shell.style.top).toBe("");
expect(shell.style.transform).toBe("");
expect(shell.style.getPropertyValue("--mobile-chat-viewport-height")).toBe("358px");
act(() => { viewport.height = 695; viewport.offsetTop = 0; viewport.dispatchEvent(new Event("resize")); });
expect(shell.style.getPropertyValue("--mobile-chat-viewport-height")).toBe("695px");
expect(shell.dataset.keyboardOpen).toBeUndefined();
});
it("preserves pinch zoom and leaves desktop sizing to CSS", () => {
act(() => root.render(<Host />));
const shell = container.firstElementChild as HTMLElement;
act(() => { viewport.scale = 2; viewport.height = 350; viewport.dispatchEvent(new Event("resize")); });
expect(shell.style.getPropertyValue("--mobile-chat-viewport-height")).toBe("695px");
act(() => root.render(<Host mobile={false} />));
expect(shell.style.getPropertyValue("--mobile-chat-viewport-height")).toBe("");
expect(shell.dataset.keyboardOpen).toBeUndefined();
});
it("does not invalidate shell styles for typing, scrolling, or unchanged viewport events", () => {
act(() => root.render(<Host />));
const shell = container.firstElementChild as HTMLElement;
const observer = new MutationObserver(() => {});
observer.observe(shell, { attributes: true });
act(() => {
for (let i = 0; i < 30; i++) {
shell.dispatchEvent(new Event("input", { bubbles: true }));
viewport.dispatchEvent(new Event("scroll"));
viewport.dispatchEvent(new Event("resize"));
}
});
expect(observer.takeRecords()).toHaveLength(0);
observer.disconnect();
});
it("rejects Safari's stale document focus scroll after the keyboard has resized the conversation", () => {
act(() => root.render(<Host />));
act(() => {
viewport.height = 358;
// Some openings pan before the resize event; others pan one frame later.
document.documentElement.scrollTop = 337;
viewport.dispatchEvent(new Event("resize"));
});
expect(document.documentElement.scrollTop).toBe(0);
document.documentElement.scrollTop = 337;
document.dispatchEvent(new Event("scroll"));
expect(document.documentElement.scrollTop).toBe(0);
});
it("leaves ordinary pages and inner message scrolling alone", () => {
viewport.height = 358;
act(() => root.render(<Host conversation={false} />));
document.documentElement.scrollTop = 337;
document.dispatchEvent(new Event("scroll"));
expect(document.documentElement.scrollTop).toBe(337);
act(() => root.render(<Host />));
const messages = container.querySelector('[data-task-chat-shell]') as HTMLElement;
messages.scrollTop = 250;
messages.dispatchEvent(new Event("scroll"));
expect(messages.scrollTop).toBe(250);
});
it("does not suppress zoom panning when the keyboard was already open", () => {
viewport.height = 358;
act(() => root.render(<Host />));
viewport.scale = 2;
viewport.height = 179;
viewport.dispatchEvent(new Event("resize"));
document.documentElement.scrollTop = 120;
document.dispatchEvent(new Event("scroll"));
expect(document.documentElement.scrollTop).toBe(120);
});
+47
View File
@@ -0,0 +1,47 @@
import { useLayoutEffect, useRef } from "react";
/** Size the mobile conversation shell, never translate it with Safari's pan.
* CSS owns the fixed shell and its flex layout; messages own their scrolling. */
export function useMobileVisualViewport(enabled: boolean) {
const ref = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const shell = ref.current;
const viewport = window.visualViewport;
if (!enabled || !shell || !viewport) return;
const restoreDocumentPosition = () => {
if (viewport.scale !== 1 || shell.dataset.keyboardOpen !== "true" || !shell.querySelector("[data-task-chat-shell]")) return;
const page = document.scrollingElement ?? document.documentElement;
if (page.scrollTop !== 0) page.scrollTop = 0;
};
const update = () => {
if (viewport.scale !== 1 || !Number.isFinite(viewport.height) || viewport.height <= 0) return;
// Read geometry before writing styles, avoiding a forced layout after our
// own height update. Nothing here subscribes to input or thread updates.
const keyboardOpen = document.documentElement.clientHeight - viewport.height > 120;
shell.style.setProperty("--mobile-chat-viewport-height", `${viewport.height}px`);
// innerHeight can shrink with visualViewport on iOS, so it is not the
// keyboard-closed baseline. The layout viewport remains stable.
if (keyboardOpen) {
shell.dataset.keyboardOpen = "true";
} else {
delete shell.dataset.keyboardOpen;
}
restoreDocumentPosition();
};
update();
viewport.addEventListener("resize", update);
window.addEventListener("resize", update);
// Safari can apply its pre-keyboard focus pan AFTER the resize. The fixed
// document is not a scroll surface. Listen without capture so scrolls in
// the message list/editor never reach this fallback.
document.addEventListener("scroll", restoreDocumentPosition);
return () => {
viewport.removeEventListener("resize", update);
window.removeEventListener("resize", update);
document.removeEventListener("scroll", restoreDocumentPosition);
shell.style.removeProperty("--mobile-chat-viewport-height");
delete shell.dataset.keyboardOpen;
};
}, [enabled]);
return ref;
}
+58 -4
View File
@@ -583,6 +583,8 @@
leaves 6px below the dock; its pb-1 adds 4px for a 10px card-to-nav gap.
Desktop and the classic thread use the safe-area default. */
--tc-composer-bottom: var(--sz-calc-8);
--mobile-chat-viewport-height: 100dvh;
--tc-composer-editor-max-height: min(var(--sz-28dvh), calc(var(--mobile-chat-viewport-height) * 0.4));
--tc-composer-visible-nav-offset: calc(4rem + var(--spacing) * 1.5 + var(--sz-safe-bottom));
/* With the dock's pb-1, leave 12px below the card when the nav hides. */
--tc-composer-hidden-nav-offset: calc(var(--sz-safe-bottom) + var(--spacing) * 2);
@@ -887,6 +889,62 @@ select:where(:not([multiple]):not(.appearance-none)):where(:not([size]), [size="
transition: bottom var(--mobile-nav-motion-duration, var(--motion-mobile-nav-enter)) var(--mobile-nav-motion-ease, var(--motion-ease-out-expo));
}
/* Mobile conversations use the same single message scroller + normal footer as
desktop. Opt in from the rendered task shell, so classic tasks, lists, and
the empty agent-chat picker retain their existing document layout. */
html:has(.paperclip-app-shell[data-mobile="true"] [data-task-chat-shell]),
html:has(.paperclip-app-shell[data-mobile="true"] [data-task-chat-shell]) body {
position: fixed;
inset: 0;
width: 100%;
}
html:has(.paperclip-app-shell[data-mobile="true"] [data-task-chat-shell]),
html:has(.paperclip-app-shell[data-mobile="true"] [data-task-chat-shell]) body,
html:has(.paperclip-app-shell[data-mobile="true"] [data-task-chat-shell]) #root {
height: 100%;
overflow: hidden !important;
overscroll-behavior: none;
}
.paperclip-app-shell[data-mobile="true"]:has([data-task-chat-shell]) {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
height: var(--mobile-chat-viewport-height);
min-height: 0;
overflow: hidden;
}
.paperclip-app-shell[data-mobile="true"]:has([data-task-chat-shell]) :is(.app-main-row, .app-content-row) {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
.paperclip-app-shell[data-mobile="true"]:has([data-task-chat-shell]) .app-content-column {
flex: 1;
min-height: 0;
}
.paperclip-app-shell[data-mobile="true"]:has([data-task-chat-shell]) .app-breadcrumb {
flex-shrink: 0;
}
.paperclip-app-shell[data-mobile="true"]:has([data-task-chat-shell]) #main-content {
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
padding-bottom: var(--tc-composer-visible-nav-offset);
}
.paperclip-app-shell[data-mobile="true"][data-keyboard-open="true"]:has([data-task-chat-shell]) #main-content {
padding-bottom: 0;
}
@media (prefers-reduced-motion: reduce) {
:root {
--motion-mobile-nav-enter: 0ms;
@@ -3292,10 +3350,6 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
scrollbar-gutter: stable;
}
.task-chat-window-scroll {
overflow-anchor: none;
}
.task-chat-loading-shell .animate-pulse {
animation: none;
}
+4 -10
View File
@@ -7882,11 +7882,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
data-task-chat-shell={taskChatShellEnabled ? "" : undefined}
className={
taskChatShellEnabled
? isMobile
? // Mobile shell scrolls the DOCUMENT (main is overflow-visible,
// auto height) — the thread renders in normal flow (PAP-360).
"flex w-full flex-col gap-6"
: // Fill main exactly so the outer page never scrolls — the
? // Fill main exactly so the outer page never scrolls — the
// thread's own viewport is the only scroll surface.
// Keep status banners close to the transcript. A full section
// gap here shortens the pinned message viewport enough to
@@ -8180,9 +8176,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
onValueChange={setDetailTab}
className={
taskChatShellEnabled
? isMobile
? undefined
: "min-h-0 flex-1"
? "min-h-0 flex-1"
: "space-y-3"
}
>
@@ -8222,8 +8216,8 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
taskChatShellEnabled
? isMobile
? streamlinedTaskDetailEnabled
? undefined
: "-mx-4"
? "flex min-h-0 flex-1 flex-col"
: "-mx-4 flex min-h-0 flex-1 flex-col"
: streamlinedTaskDetailEnabled
? "flex min-h-0 flex-col"
: "-mx-4 -mt-4 md:-mx-6 md:-mt-6 flex min-h-0 flex-col"
@@ -1,10 +1,10 @@
import { useEffect, useState, type CSSProperties } from "react";
import { useEffect, useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { MobileBottomNav } from "@/components/MobileBottomNav";
import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer";
import { TaskChatComposerDock } from "@/components/task-chat/TaskChatComposerDock";
import { TaskChatThreadView } from "@/components/task-chat/TaskChatThreadView";
import { useWindowAutoFollow } from "@/components/task-chat/useWindowAutoFollow";
import { useMobileVisualViewport } from "@/hooks/useMobileVisualViewport";
function StreamingResponse({ running }: { running: boolean }) {
const [lines, setLines] = useState(16);
@@ -13,7 +13,7 @@ function StreamingResponse({ running }: { running: boolean }) {
const timer = window.setTimeout(() => setLines(lines + 1), 500);
return () => window.clearTimeout(timer);
}, [running, lines]);
return <TaskChatThreadView scroll={false} items={[
return <TaskChatThreadView items={[
{ id: "request", kind: "message", author: "human", text: "Give me a detailed progress report as you work." },
{
id: "response", kind: "message", author: "agent", authorName: "Researcher", streaming: running,
@@ -24,21 +24,19 @@ function StreamingResponse({ running }: { running: boolean }) {
function MobileFollow() {
const [running, setRunning] = useState(true);
// The child updates independently, exercising late layout growth as well as
// normal streaming without relying on a parent content-key notification.
useWindowAutoFollow(0, true);
return <div className="flex min-h-dvh flex-col bg-background text-foreground">
<header className="flex h-12 shrink-0 items-center px-4 text-sm font-medium">Streaming progress report</header>
<main className="flex flex-1 flex-col p-4 pb-(--tc-composer-visible-nav-offset)"
style={{ "--tc-composer-bottom": "var(--tc-composer-visible-nav-offset)" } as CSSProperties}>
<div data-testid="task-chat-thread" className="-mx-4 flex flex-col">
const viewportRef = useMobileVisualViewport(true);
return <div ref={viewportRef} data-mobile="true" className="paperclip-app-shell flex min-h-dvh flex-col bg-background text-foreground">
<div className="app-main-row"><div className="app-content-column flex flex-col">
<header className="app-breadcrumb flex h-12 shrink-0 items-center px-4 text-sm font-medium">iOS layout — focus-scroll correction</header>
<div className="app-content-row"><main id="main-content" className="flex flex-1 flex-col p-4">
<div data-task-chat-shell="" data-testid="task-chat-thread" className="-mx-4 flex min-h-0 flex-1 flex-col">
<StreamingResponse running={running} />
<TaskChatComposerDock mobile streamlined={false}>
<TaskChatComposer mobile workMode="standard" onAdd={async () => setRunning(true)}
onStop={running ? async () => setRunning(false) : undefined} />
</TaskChatComposerDock>
</div>
</main>
</main></div></div></div>
<MobileBottomNav visible />
</div>;
}
@@ -56,7 +54,7 @@ const meta = {
],
"implementation": "production",
"boundary": "fixtures",
"notes": "The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work."
"notes": "Production mobile viewport sizing, message scroller, and composer with bounded synthetic streaming. Physical iPhone keyboard acceptance is required."
},
},
globals: { theme: "light", viewport: { value: "mobile", isRotated: false } },