mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 17:45:53 +01:00
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:
1 parent
1178efda8c
commit
ad1ed3cca3
4 files changed
+78
-32
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user