feat(ui): mobile document-flow layout for chat-style task thread — thread renders via scroll={false} in the page scroll with window auto-follow, sticky safe-area composer, 16px mobile editor text; desktop bounded chain untouched

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
scotttongandClaude Fable 5 committed 2026-08-07 16:56:55 -07:00
1 parent 1178efda8c
commit ad1ed3cca3
4 files changed
+78 -32

No files matched your search

+32 -6
View File
@@ -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 (
<div
className="flex h-(--tc-thread-max-h) min-h-0 flex-1 flex-col"
className={cn("flex flex-col", !isMobile && "h-(--tc-thread-max-h) min-h-0 flex-1")}
data-testid="task-chat-thread"
>
<div className="flex min-h-0 flex-1 flex-col">
<div className={cn("flex flex-col", !isMobile && "min-h-0 flex-1")}>
{items.length === 0 ? (
<div className="min-h-0 flex-1 overflow-y-auto">
<div className={isMobile ? undefined : "min-h-0 flex-1 overflow-y-auto"}>
{threadHeader ? (
<div
className="mx-auto flex w-full max-w-(--tc-shell-max-w) flex-col gap-6 px-4 pt-4"
@@ -392,11 +403,25 @@ export function TaskChatThread(props: TaskChatThreadProps) {
<div className="px-3 py-10 text-center text-sm text-muted-foreground">{emptyMessage}</div>
</div>
) : (
<TaskChatThreadView items={items} header={threadHeader} renderInteraction={renderInteraction} />
<TaskChatThreadView
items={items}
header={threadHeader}
renderInteraction={renderInteraction}
scroll={!isMobile}
/>
)}
</div>
{showComposer ? (
<div className="sticky bottom-0 z-10 mx-auto flex w-full max-w-(--tc-shell-max-w) flex-col gap-2 bg-background/80 px-1 pb-2 pt-1 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div
className={cn(
"sticky",
// Mobile mirrors the flag-off thread's dock: lifted above the
// safe-area inset (and clear of the auto-hiding bottom nav), above
// page content in the document-flow stacking context.
isMobile ? "bottom-(--sz-calc-8) z-20" : "bottom-0 z-10",
"mx-auto flex w-full max-w-(--tc-shell-max-w) flex-col gap-2 bg-background/80 px-1 pb-2 pt-1 backdrop-blur supports-[backdrop-filter]:bg-background/60",
)}
>
{composerAccessory}
<TaskChatComposer
onAdd={onAdd}
@@ -411,6 +436,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
reassignOptions={reassignOptions}
currentAssigneeValue={currentAssigneeValue}
issueStatus={issueStatus}
mobile={isMobile}
/>
{footer}
</div>
@@ -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"
}
/>
</div>
@@ -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 <TaskMessageScroller contentKey={taskChatContentKey(items)}>{body}</TaskMessageScroller>;
}
return <TaskMessageScroller contentKey={contentKey}>{body}</TaskMessageScroller>;
// 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);
}
+15 -6
View File
@@ -4558,10 +4558,13 @@ export function IssueDetail() {
<div
className={
taskChatShellEnabled
? // Fill main exactly so the outer page never scrolls — the thread's
// own viewport is the only scroll surface (h-full is inert on
// mobile, where main has no fixed height and the page scrolls).
"flex h-full min-h-0 w-full flex-col gap-6"
? 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
// thread's own viewport is the only scroll surface.
"flex h-full min-h-0 w-full flex-col gap-6"
: "max-w-3xl space-y-6"
}
>
@@ -4824,7 +4827,7 @@ export function IssueDetail() {
<Tabs
value={resolvedDetailTab}
onValueChange={setDetailTab}
className={taskChatShellEnabled ? "min-h-0 flex-1" : "space-y-3"}
className={taskChatShellEnabled ? (isMobile ? undefined : "min-h-0 flex-1") : "space-y-3"}
>
{/* 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. */}
<TabsContent
value="chat"
className={taskChatShellEnabled ? "-mx-4 md:-mx-6 flex min-h-0 flex-col" : undefined}
className={
taskChatShellEnabled
? isMobile
? "-mx-4"
: "-mx-4 md:-mx-6 flex min-h-0 flex-col"
: undefined
}
>
{resolvedDetailTab === "chat" ? (
<IssueDetailChatTab