From ad1ed3cca36ee48d74b7a15c5d3aaa7236145ea6 Mon Sep 17 00:00:00 2001 From: scotttong Date: Sun, 2 Aug 2026 02:24:58 -0700 Subject: [PATCH] =?UTF-8?q?feat(ui):=20mobile=20document-flow=20layout=20f?= =?UTF-8?q?or=20chat-style=20task=20thread=20=E2=80=94=20thread=20renders?= =?UTF-8?q?=20via=20scroll=3D{false}=20in=20the=20page=20scroll=20with=20w?= =?UTF-8?q?indow=20auto-follow,=20sticky=20safe-area=20composer,=2016px=20?= =?UTF-8?q?mobile=20editor=20text;=20desktop=20bounded=20chain=20untouched?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- ui/src/components/TaskChatThread.tsx | 38 ++++++++++++++--- .../components/task-chat/TaskChatComposer.tsx | 9 +++- .../task-chat/TaskChatThreadView.tsx | 42 ++++++++++--------- ui/src/pages/IssueDetail.tsx | 21 +++++++--- 4 files changed, 78 insertions(+), 32 deletions(-) diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index 5c74721f620..df8ca8c647b 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -16,8 +16,11 @@ import type { TaskChatTurnItem, } from "@/components/task-chat/task-chat-model"; import { TaskChatInteractionCard } from "@/components/task-chat/TaskChatInteractionCard"; -import { TaskChatThreadView } from "@/components/task-chat/TaskChatThreadView"; +import { TaskChatThreadView, taskChatContentKey } from "@/components/task-chat/TaskChatThreadView"; import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer"; +import { useWindowAutoFollow } from "@/components/task-chat/useWindowAutoFollow"; +import { useSidebar } from "@/context/SidebarContext"; +import { cn } from "@/lib/utils"; import { useIssuePlanDocument } from "@/hooks/useIssuePlanDocument"; import { latestSameRunHandoffTimestamp, type IssueChatComment } from "@/lib/issue-chat-messages"; import { workModeInEffectAt } from "@/lib/issue-timeline-events"; @@ -373,14 +376,22 @@ export function TaskChatThread(props: TaskChatThreadProps) { ], ); + // Mobile (PAP-360): the app shell scrolls the DOCUMENT (Layout's main is + // overflow-visible with auto height), so the desktop bounded h-dvh chain + // collapses the absolute-inset transcript viewport to 0px. Render the thread + // in document flow instead (the same scroll={false} path the previews use) + // and track auto-follow against window scroll. Desktop stays byte-identical. + const { isMobile } = useSidebar(); + useWindowAutoFollow(isMobile ? taskChatContentKey(items) : 0, isMobile); + return (
-
+
{items.length === 0 ? ( -
+
{threadHeader ? (
{emptyMessage}
) : ( - + )}
{showComposer ? ( -
+
{composerAccessory} {footer}
diff --git a/ui/src/components/task-chat/TaskChatComposer.tsx b/ui/src/components/task-chat/TaskChatComposer.tsx index febb9f7f2ad..baeffdf4cb7 100644 --- a/ui/src/components/task-chat/TaskChatComposer.tsx +++ b/ui/src/components/task-chat/TaskChatComposer.tsx @@ -53,6 +53,8 @@ interface TaskChatComposerProps { reassignOptions?: InlineEntityOption[]; currentAssigneeValue?: string; issueStatus?: string; + /** Mobile document-flow host: 16px editor text so iOS doesn't zoom on focus. */ + mobile?: boolean; } /** Per-mode hue token (see ui/src/index.css `--tc-mode-*`). */ @@ -143,6 +145,7 @@ export function TaskChatComposer({ reassignOptions, currentAssigneeValue = "", issueStatus, + mobile = false, }: TaskChatComposerProps) { const [body, setBody] = useState(""); const [submitting, setSubmitting] = useState(false); @@ -327,7 +330,11 @@ export function TaskChatComposer({ onDropFile={canAcceptFiles ? attachNonImageFile : undefined} bordered={false} className={cn(disabled && "opacity-60")} - contentClassName="max-h-(--sz-28dvh) min-h-(--sz-48px) overflow-y-auto px-1 py-1 text-sm scrollbar-auto-hide" + contentClassName={ + mobile + ? "max-h-(--sz-28dvh) 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" + } />
diff --git a/ui/src/components/task-chat/TaskChatThreadView.tsx b/ui/src/components/task-chat/TaskChatThreadView.tsx index 99cb23fe08b..0799bdc71cc 100644 --- a/ui/src/components/task-chat/TaskChatThreadView.tsx +++ b/ui/src/components/task-chat/TaskChatThreadView.tsx @@ -99,24 +99,28 @@ export function TaskChatThreadView({ if (!scroll) return body; - // Include a cheap content signature so streaming growth (text lengthening - // without the item count changing) still advances the auto-follow key. - const signatureOf = (it: TaskChatItem): number => { - if (it.kind === "message") return it.text.length; - if (it.kind === "thinking") return it.lines.reduce((n, l) => n + l.length, 0); - if (it.kind === "tool") return (it.diff?.lines?.length ?? 0) + (it.status === "completed" ? 1 : 0); - if (it.kind === "turn") { - if (it.settled) return 1; - // The live parent row's header changes (gerund ↔ tool-state flashes) - // count too, so the collapsed single-line turn still advances the key. - const headerSig = it.liveStatus - ? it.liveStatus.label.length + (it.liveStatus.detail?.length ?? 0) - : 0; - return it.items.reduce((n, child) => n + signatureOf(child), it.items.length + headerSig); - } - return 1; - }; - const contentKey = items.reduce((acc, it) => acc + signatureOf(it), items.length); + return {body}; +} - return {body}; +// 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). +function signatureOf(it: TaskChatItem): number { + if (it.kind === "message") return it.text.length; + if (it.kind === "thinking") return it.lines.reduce((n, l) => n + l.length, 0); + if (it.kind === "tool") return (it.diff?.lines?.length ?? 0) + (it.status === "completed" ? 1 : 0); + if (it.kind === "turn") { + if (it.settled) return 1; + // The live parent row's header changes (gerund ↔ tool-state flashes) + // count too, so the collapsed single-line turn still advances the key. + const headerSig = it.liveStatus + ? it.liveStatus.label.length + (it.liveStatus.detail?.length ?? 0) + : 0; + return it.items.reduce((n, child) => n + signatureOf(child), it.items.length + headerSig); + } + return 1; +} + +export function taskChatContentKey(items: TaskChatItem[]): number { + return items.reduce((acc, it) => acc + signatureOf(it), items.length); } diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 80df9fb56ee..675fc379aca 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -4558,10 +4558,13 @@ export function IssueDetail() {
@@ -4824,7 +4827,7 @@ export function IssueDetail() { {/* Redesign: the chat IS the page — the Chat/Activity/Related-work tab strip is hidden and the thread renders as the only surface. */} @@ -4855,7 +4858,13 @@ export function IssueDetail() { scrollbar sits flush against the properties-pane border. */} {resolvedDetailTab === "chat" ? (