fix(ui): refine mobile task surfaces (#13122)

This commit is contained in:
scotttong 2026-09-09 16:35:38 -07:00 committed by GitHub
parent 5488a79eb5
commit 2d45f42e47
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 362 additions and 67 deletions

View File

@ -349,10 +349,10 @@ function BlockedInboxRow({
const mobileMeta = (
<span className="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-muted-foreground">
<span data-testid="blocked-row-age-mobile">{stoppedAge}</span>
{presentation === "legacy" && <span data-testid="blocked-row-age-mobile">{stoppedAge}</span>}
{ownerName ? (
<>
<span aria-hidden="true">·</span>
{presentation === "legacy" && <span aria-hidden="true">·</span>}
<span
className={cn(isAgent ? "font-medium text-foreground/90" : null)}
data-testid="blocked-row-owner-mobile"
@ -397,6 +397,7 @@ function BlockedInboxRow({
/>
}
mobileMeta={mobileMeta}
mobileTitleMeta={presentation === "task" ? <span data-testid="blocked-row-age-mobile">{stoppedAge}</span> : undefined}
desktopTrailing={desktopTrailing}
trailingMeta={presentation === "task" && showUpdatedColumn ? stoppedAge : null}
/>

View File

@ -6,6 +6,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { BreadcrumbProvider, useBreadcrumbs } from "../context/BreadcrumbContext";
import { BreadcrumbBar } from "./BreadcrumbBar";
const viewport = vi.hoisted(() => ({ isMobile: false }));
vi.mock("@/lib/router", () => ({
Link: ({ children, className, to }: { children: ReactNode; className?: string; to: string }) => (
<a className={className} href={to}>{children}</a>
@ -15,7 +17,7 @@ vi.mock("@/lib/router", () => ({
vi.mock("../context/SidebarContext", () => ({
useSidebar: () => ({
collapsed: false,
isMobile: false,
isMobile: viewport.isMobile,
toggleCollapsed: vi.fn(),
toggleSidebar: vi.fn(),
}),
@ -44,11 +46,13 @@ function TaskBreadcrumbs({
panelControl,
taskDetailLayout = false,
identifier = "PAP-16679",
sourceHref = "/issues",
}: {
onOpen?: () => void;
panelControl?: { open: boolean; onToggle: () => void };
taskDetailLayout?: boolean;
identifier?: string;
sourceHref?: string;
}) {
const {
setBreadcrumbs,
@ -58,7 +62,7 @@ function TaskBreadcrumbs({
useEffect(() => {
setBreadcrumbs([
{ label: "Tasks", href: "/issues" },
{ label: "Tasks", href: sourceHref },
{
label: "Hire your first engineer and create a hiring plan",
identifier,
@ -84,6 +88,7 @@ function TaskBreadcrumbs({
setBreadcrumbPanelControl,
setBreadcrumbToolbar,
setBreadcrumbs,
sourceHref,
]);
return <BreadcrumbBar taskDetailLayout={taskDetailLayout} />;
@ -97,6 +102,7 @@ describe("BreadcrumbBar", () => {
let root: Root;
beforeEach(() => {
viewport.isMobile = false;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
@ -107,6 +113,23 @@ describe("BreadcrumbBar", () => {
container.remove();
});
it("shows only the title followed by its identifier for a company-scoped mobile task header", async () => {
viewport.isMobile = true;
await act(async () => {
root.render(
<BreadcrumbProvider>
<TaskBreadcrumbs identifier="TES-3" sourceHref="/TES/issues" />
</BreadcrumbProvider>,
);
});
expect(container.querySelector('a[href="/TES/issues"]')).toBeNull();
const identifier = container.querySelector('[data-slot="task-title-identifier"]');
expect(identifier?.textContent).toBe("TES-3");
expect(identifier?.previousElementSibling?.textContent).toBe("Hire your first engineer and create a hiring plan");
expect(identifier?.previousElementSibling?.className).toContain("truncate");
expect(container.querySelector('button[aria-label="Open sidebar"]')).not.toBeNull();
});
it("renders a page toolbar in the same persistent row as the task breadcrumb", async () => {
const onOpen = vi.fn();
await act(async () => {

View File

@ -108,6 +108,23 @@ export function BreadcrumbBar({ taskDetailLayout = false }: { taskDetailLayout?:
</Button>
);
const currentCrumb = breadcrumbs[breadcrumbs.length - 1];
if (isMobile && breadcrumbs[0]?.label === "Tasks" && currentCrumb.identifier) {
return (
<div className="h-(--sz-60px) shrink-0 flex items-center border-b border-border px-4">
{menuButton}
<h1 className="flex min-w-0 flex-1 items-center gap-1.5 text-sm">
{currentCrumb.leading ? (
<span className="flex shrink-0 items-center">{currentCrumb.leading}</span>
) : null}
<span className="min-w-0 truncate" title={currentCrumb.label}>{currentCrumb.label}</span>
<CrumbIdentifier identifier={currentCrumb.identifier} />
</h1>
{globalToolbarSlots}
</div>
);
}
const breadcrumbTrail = (
<div className="min-w-0 overflow-hidden flex-1">
<Breadcrumb className="min-w-0 overflow-hidden">

View File

@ -31,6 +31,8 @@ interface InlineEntitySelectorProps {
disabled?: boolean;
/** Optional test id forwarded to the trigger button. */
triggerTestId?: string;
/** Optional slot name used by consuming surfaces for scoped presentation rules. */
triggerDataSlot?: string;
}
const EMPTY_RECENT_OPTION_IDS: string[] = [];
@ -54,6 +56,7 @@ export const InlineEntitySelector = forwardRef<HTMLButtonElement, InlineEntitySe
openOnFocus = true,
disabled = false,
triggerTestId,
triggerDataSlot,
},
ref,
) {
@ -121,6 +124,7 @@ export const InlineEntitySelector = forwardRef<HTMLButtonElement, InlineEntitySe
type="button"
disabled={disabled}
data-testid={triggerTestId}
data-slot={triggerDataSlot}
className={cn(
"inline-flex min-w-0 items-center gap-1 rounded-md border border-border bg-muted/40 px-2 py-1 text-sm font-medium text-foreground transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50 disabled:pointer-events-none",
className,

View File

@ -230,21 +230,23 @@ describe("IssueRow", () => {
act(() => root.unmount());
});
it("keeps canonical leading geometry independent of unread state", () => {
it("keeps read and unread rows aligned while allowing a smaller plain-row gutter", () => {
const root = createRoot(container);
act(() => {
root.render(
<>
<IssueRow issue={createIssue({ id: "read" })} presentation="task" unreadState="hidden" />
<IssueRow issue={createIssue({ id: "plain" })} presentation="task" />
<IssueRow issue={createIssue({ id: "unread" })} presentation="task" unreadState="visible" />
</>,
);
});
const rows = Array.from(container.querySelectorAll('[data-slot="task-row"]'));
const unreadSlot = rows[0]?.querySelector('[data-testid="issue-row-unread-slot"]');
expect(rows).toHaveLength(2);
expect(rows[0]?.className).toBe(rows[1]?.className);
expect(rows).toHaveLength(3);
expect(rows[0]?.className).toBe(rows[2]?.className);
expect(rows[1]?.className).toContain("pl-2 sm:pl-4");
expect(unreadSlot).not.toBeNull();
expect(unreadSlot?.className).toContain("absolute");
expect(unreadSlot?.querySelector('button[aria-label="Mark as read"]')).toBeNull();

View File

@ -48,6 +48,8 @@ export interface IssueRowProps {
desktopMetaLeading?: ReactNode;
desktopLeadingSpacer?: boolean;
mobileMeta?: ReactNode;
/** Compact mobile timestamp beside the title in canonical task lists. */
mobileTitleMeta?: ReactNode;
desktopTrailing?: ReactNode;
/**
* Optional pre-fetched external-object summary. Renders a compact severity
@ -130,6 +132,7 @@ export function IssueRow({
desktopMetaLeading,
desktopLeadingSpacer = false,
mobileMeta,
mobileTitleMeta,
desktopTrailing,
externalObjectSummary,
trailingMeta,
@ -233,7 +236,8 @@ export function IssueRow({
data-slot="task-row"
data-unread={isUnread ? "true" : undefined}
className={cn(
"group relative flex min-w-0 items-start gap-2 rounded-lg py-2.5 pl-4 pr-2 text-sm no-underline text-inherit sm:items-center sm:py-2",
"group relative flex min-w-0 items-start gap-2 rounded-lg py-2.5 pr-2 text-sm no-underline text-inherit sm:items-center sm:py-2",
showUnreadSlot ? "pl-4" : "pl-2 sm:pl-4",
"[&_button]:relative [&_button]:z-10",
selected ? "bg-accent/50 hover:bg-accent/50" : "hover:bg-accent/50",
checklistCurrentStep && "bg-primary/5",
@ -263,7 +267,7 @@ export function IssueRow({
</span>
) : null}
<span data-slot="task-row-leading" className="flex shrink-0 items-center gap-1 pt-px sm:pt-0">
<span data-slot="task-row-leading" className="flex shrink-0 items-start self-stretch gap-1 pt-px sm:items-center sm:pt-0">
{treeGuides > 0
? Array.from({ length: treeGuides }, (_, level) => {
const gapForChevron = chevronInGuide && level === treeGuides - 1;
@ -272,7 +276,7 @@ export function IssueRow({
key={`task-guide-${level}`}
data-slot="task-row-tree-guide"
aria-hidden="true"
className="relative hidden w-4 shrink-0 self-stretch sm:block"
className="relative block w-4 shrink-0 self-stretch"
>
<span
data-slot="task-row-tree-connector"
@ -306,7 +310,7 @@ export function IssueRow({
</span>
<span className="flex min-w-0 flex-1 flex-col gap-1 sm:flex-row sm:items-center sm:gap-2">
<span data-slot="task-row-title-cluster" className="flex min-w-0 flex-1 items-start gap-1.5 sm:items-center">
<span data-slot="task-row-title-cluster" className="flex min-w-0 flex-1 items-baseline gap-1.5 sm:items-center">
<span
data-slot="task-row-title"
className={cn(
@ -318,6 +322,11 @@ export function IssueRow({
{issue.title}{titleSuffix}
</span>
{recoveryIndicator}
{mobileTitleMeta ? (
<span className="ml-auto shrink-0 whitespace-nowrap text-right text-xs text-muted-foreground sm:hidden">
{mobileTitleMeta}
</span>
) : null}
</span>
{checklistDependencyChips ? (
<span className="flex flex-wrap gap-1">{checklistDependencyChips}</span>

View File

@ -541,7 +541,7 @@ function IssueSearchInput({
}, [draftValue, onDebouncedChange]);
return (
<div className="relative w-48 sm:w-64 md:w-80">
<div className="relative w-full sm:w-64 md:w-80">
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={draftValue}
@ -1726,9 +1726,10 @@ function StreamlinedIssuesList({
{/* Toolbar */}
<IssuesToolbar
className="paperclip-task-list-toolbar"
ariaLabel={toolbarPresentation === "collection" ? "Task controls" : undefined}
context={(
<Button size="sm" variant="outline" onClick={() => openCreateIssueDialog()}>
<Button size="sm" variant="outline" aria-label={createButtonLabel} onClick={() => openCreateIssueDialog()}>
<Plus className="h-4 w-4 sm:mr-1" />
<span className="hidden sm:inline">{createButtonLabel}</span>
</Button>
@ -2006,7 +2007,7 @@ function StreamlinedIssuesList({
onUpdateIssue={onUpdateIssue}
/>
) : (
<>
<div className="-mx-2 sm:mx-0">
{groupedContent.map((group) => {
if (remainingRowsToRender <= 0) return null;
return (
@ -2154,10 +2155,8 @@ function StreamlinedIssuesList({
<div
key={issue.id}
data-issue-row-id={issue.id}
// Desktop indentation comes from IssueRow's treeGuides
// (vertical connector slots); mobile keeps a plain
// padding indent (guides are sm-only).
className={depth > 0 ? MOBILE_TREE_INDENT[Math.min(depth, MOBILE_TREE_INDENT.length - 1)] : undefined}
// Canonical rows use the same tree-guide slots at every width.
className={rowPresentation === "legacy" && depth > 0 ? MOBILE_TREE_INDENT[Math.min(depth, MOBILE_TREE_INDENT.length - 1)] : undefined}
style={useDeferredRowRendering
? {
contentVisibility: "auto",
@ -2231,8 +2230,11 @@ function StreamlinedIssuesList({
)
) : undefined}
statusSlot={rowPresentation === "task" ? (
<span className="inline-flex items-center" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
<span className="relative inline-flex items-start self-stretch sm:items-center" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
<StatusIcon status={issue.status} size="md" blockerAttention={issue.blockerAttention} onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
{hasChildren && isExpanded ? (
<span aria-hidden="true" className="pointer-events-none absolute top-5 -bottom-2.5 left-1/2 w-px bg-border sm:hidden" />
) : null}
</span>
) : undefined}
metadata={rowPresentation === "task" ? (
@ -2286,7 +2288,8 @@ function StreamlinedIssuesList({
/>
</>
) : undefined}
mobileMeta={issueActivityText(issue).toLowerCase()}
mobileTitleMeta={rowPresentation === "task" ? issueActivityTimestamp(issue) : undefined}
mobileMeta={rowPresentation === "legacy" ? issueActivityText(issue).toLowerCase() : undefined}
trailingMeta={rowPresentation === "task"
&& visibleIssueColumnSet.has("updated")
&& availableIssueColumnSet.has("updated")
@ -2489,7 +2492,7 @@ function StreamlinedIssuesList({
</p>
</div>
)}
</>
</div>
)}
</div>
);

View File

@ -738,7 +738,7 @@ export function Layout() {
? ({
"--tc-composer-bottom": mobileNavVisible
? "var(--sz-calc-14)"
: "var(--sz-calc-8)",
: "var(--tc-composer-hidden-nav-offset)",
} as CSSProperties)
: undefined
}
@ -752,7 +752,9 @@ export function Layout() {
// changes (e.g. switching skill-detail tabs) don't widen/shift
// when the vertical scrollbar appears or disappears (PAP-10907).
isMobile
? "overflow-visible pb-(--sz-calc-14)"
? isTaskDetailRoute && !mobileNavVisible
? "overflow-visible pb-(--tc-composer-hidden-nav-offset)"
: "overflow-visible pb-(--sz-calc-14)"
: "overflow-auto [scrollbar-gutter:stable]",
)}
>

View File

@ -2,7 +2,7 @@ import { useMemo } from "react";
import { NavLink, useLocation } from "@/lib/router";
import {
House,
CircleDot,
CircleCheck,
SquarePen,
Users,
Inbox,
@ -44,8 +44,8 @@ export function MobileBottomNav({ visible }: MobileBottomNavProps) {
const items = useMemo<MobileNavItem[]>(
() => [
{ type: "link", to: "/dashboard", label: "Home", icon: House },
{ type: "link", to: "/issues", label: "Tasks", icon: CircleDot },
{ type: "action", label: "Create", icon: SquarePen, onClick: () => openNewIssue() },
{ type: "link", to: "/issues", label: "Tasks", icon: CircleCheck },
{ type: "action", label: "New Task", icon: SquarePen, onClick: () => openNewIssue() },
{ type: "link", to: "/agents/all", label: "Agents", icon: Users },
{
type: "link",
@ -61,7 +61,7 @@ export function MobileBottomNav({ visible }: MobileBottomNavProps) {
return (
<nav
className={cn(
"fixed bottom-0 left-0 right-0 z-30 border-t border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/85 transition-transform duration-200 ease-out md:hidden pb-(--sz-safe-bottom)",
"fixed bottom-0 left-0 right-0 z-30 bg-border/50 transition-transform duration-200 ease-out dark:bg-muted md:hidden pb-(--sz-safe-bottom)",
visible ? "translate-y-0" : "translate-y-full",
)}
aria-label="Mobile navigation"

View File

@ -186,11 +186,13 @@ vi.mock("./InlineEntitySelector", async () => {
{
value: string;
placeholder?: string;
className?: string;
triggerDataSlot?: string;
renderTriggerValue?: (option: { id: string; label: string } | null) => ReactNode;
}
>(function InlineEntitySelectorMock({ value, placeholder, renderTriggerValue }, ref) {
>(function InlineEntitySelectorMock({ value, placeholder, className, triggerDataSlot, renderTriggerValue }, ref) {
return (
<button ref={ref} type="button">
<button ref={ref} type="button" className={className} data-slot={triggerDataSlot}>
{(renderTriggerValue?.(value ? { id: value, label: value } : null) ?? value) || placeholder}
</button>
);
@ -412,6 +414,31 @@ describe("NewIssueDialog", () => {
act(() => rerendered.root.unmount());
});
it("uses the compact composer control proportions for mobile task fields", async () => {
const { root } = renderDialog(container);
await flush();
const compactControls = Array.from(
container.querySelectorAll<HTMLElement>('[data-slot="new-issue-compact-control"]'),
);
const prefix = compactControls.find((control) => control.textContent === "PAP");
const assignee = compactControls.find((control) => control.textContent === "Assignee");
const project = compactControls.find((control) => control.textContent === "Project");
const status = compactControls.find((control) => control.textContent?.trim() === "Todo");
const upload = compactControls.find((control) => control.textContent?.trim() === "Upload");
const mode = compactControls.find((control) => control.hasAttribute("data-issue-work-mode-chip"));
const more = container.querySelector<HTMLElement>('[data-testid="new-issue-more-menu-trigger"]');
expect(prefix?.className).toContain("p-1.5");
for (const control of [assignee, project, status, upload, mode]) {
expect(control?.className).toContain("h-8");
expect(control?.className).toContain("px-2.5");
}
expect(more?.className).toContain("size-8");
act(() => root.unmount());
});
it("submits parent and goal context for sub-issues", async () => {
mockProjectsApi.list.mockResolvedValue([
{

View File

@ -1390,7 +1390,8 @@ export function NewIssueDialog() {
<Popover open={companyOpen} onOpenChange={setCompanyOpen}>
<PopoverTrigger asChild>
<button
className="px-1.5 py-0.5 rounded bg-muted text-xs font-semibold cursor-pointer hover:opacity-80 transition-opacity"
data-slot="new-issue-compact-control"
className="rounded bg-muted p-1.5 text-xs font-semibold cursor-pointer hover:opacity-80 transition-opacity sm:px-1.5 sm:py-0.5"
disabled={isSubIssueMode}
>
{dialogCompany?.issuePrefix ?? ""}
@ -1478,6 +1479,8 @@ export function NewIssueDialog() {
options={assigneeOptions}
recentOptionIds={recentAssigneeOptionIds}
placeholder="Assignee"
className="h-8 px-2.5 py-0 sm:h-auto sm:px-2 sm:py-1"
triggerDataSlot="new-issue-compact-control"
disablePortal
noneLabel="No assignee"
searchPlaceholder="Search assignees..."
@ -1537,6 +1540,8 @@ export function NewIssueDialog() {
options={projectOptions}
recentOptionIds={recentProjectIds}
placeholder="Project"
className="h-8 px-2.5 py-0 sm:h-auto sm:px-2 sm:py-1"
triggerDataSlot="new-issue-compact-control"
disablePortal
noneLabel="No project"
searchPlaceholder="Search projects..."
@ -2079,7 +2084,10 @@ export function NewIssueDialog() {
{/* Status chip */}
<Popover open={statusOpen} onOpenChange={setStatusOpen}>
<PopoverTrigger asChild>
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors">
<button
data-slot="new-issue-compact-control"
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2.5 py-0 text-xs hover:bg-accent/50 transition-colors sm:h-auto sm:px-2 sm:py-1"
>
<CircleDot className={cn("h-3 w-3", currentStatus.color)} />
{currentStatus.label}
</button>
@ -2161,7 +2169,8 @@ export function NewIssueDialog() {
multiple
/>
<button
className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors text-muted-foreground"
data-slot="new-issue-compact-control"
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2.5 py-0 text-xs hover:bg-accent/50 transition-colors text-muted-foreground sm:h-auto sm:px-2 sm:py-1"
onClick={() => stageFileInputRef.current?.click()}
disabled={createIssue.isPending}
>
@ -2175,9 +2184,10 @@ export function NewIssueDialog() {
<button
type="button"
data-issue-work-mode-chip={workMode}
data-slot="new-issue-compact-control"
aria-keyshortcuts="Meta+Period Control+Period"
className={cn(
"inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs transition-colors",
"inline-flex h-8 items-center gap-1.5 rounded-md border px-2.5 py-0 text-xs transition-colors sm:h-auto sm:px-2 sm:py-1",
currentWorkMode.classes.chip,
)}
>
@ -2217,7 +2227,8 @@ export function NewIssueDialog() {
<button
type="button"
data-testid="new-issue-more-menu-trigger"
className="inline-flex items-center justify-center rounded-md border border-border p-1 text-xs text-muted-foreground transition-colors hover:bg-accent/50"
data-slot="new-issue-compact-control"
className="inline-flex size-8 items-center justify-center rounded-md border border-border p-0 text-xs text-muted-foreground transition-colors hover:bg-accent/50 sm:size-auto sm:p-1"
>
<MoreHorizontal className="h-3 w-3" />
</button>

View File

@ -1,4 +1,5 @@
import type { ReactNode } from "react";
import { ChevronDown } from "lucide-react";
import { TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useSidebar } from "../context/SidebarContext";
@ -19,18 +20,21 @@ export function PageTabBar({ items, value, onValueChange, align = "center" }: Pa
if (isMobile && value !== undefined && onValueChange) {
return (
<select
value={value}
onChange={(e) => onValueChange(e.target.value)}
className="h-9 rounded-md border border-border bg-background px-2 py-1 text-base focus:outline-none focus:ring-1 focus:ring-ring"
aria-label="Page section"
>
{items.map((item) => (
<option key={item.value} value={item.value}>
{typeof item.label === "string" ? item.label : item.value}
</option>
))}
</select>
<div className="relative inline-flex">
<select
value={value}
onChange={(e) => onValueChange(e.target.value)}
className="h-9 appearance-none rounded-md border border-border bg-background pl-3 pr-9 py-1 text-base focus:outline-none focus:ring-1 focus:ring-ring"
aria-label="Page section"
>
{items.map((item) => (
<option key={item.value} value={item.value}>
{typeof item.label === "string" ? item.label : item.value}
</option>
))}
</select>
<ChevronDown aria-hidden="true" className="pointer-events-none absolute right-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
</div>
);
}

View File

@ -0,0 +1,94 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ScrollToBottom } from "./ScrollToBottom";
vi.mock("../context/SidebarContext", () => ({
useSidebar: () => ({ isMobile: true }),
}));
vi.mock("../context/PanelContext", () => ({
usePanel: () => ({ panelVisible: false, panelContent: null }),
}));
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean })
.IS_REACT_ACT_ENVIRONMENT = true;
describe("ScrollToBottom mobile composer docking", () => {
let host: HTMLDivElement;
let main: HTMLElement;
let dock: HTMLDivElement;
let root: Root;
beforeEach(() => {
Object.defineProperty(document.documentElement, "scrollHeight", {
configurable: true,
value: 1000,
});
Object.defineProperty(window, "innerHeight", {
configurable: true,
value: 500,
});
Object.defineProperty(window, "scrollY", {
configurable: true,
value: 0,
});
main = document.createElement("main");
main.id = "main-content";
dock = document.createElement("div");
dock.dataset.testid = "task-chat-composer-dock";
main.appendChild(dock);
document.body.appendChild(main);
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
});
afterEach(() => {
act(() => root.unmount());
document.body.replaceChildren();
vi.restoreAllMocks();
});
it("moves with the active mobile composer dock", async () => {
await act(async () => {
root.render(<ScrollToBottom />);
});
const initialButton = dock.querySelector<HTMLButtonElement>(
'button[aria-label="Scroll to bottom"]',
);
expect(initialButton).not.toBeNull();
expect(initialButton?.classList).toContain("absolute");
expect(host.querySelector('button[aria-label="Scroll to bottom"]')).toBeNull();
await act(async () => {
dock.remove();
await Promise.resolve();
});
const fallbackButton = host.querySelector<HTMLButtonElement>(
'button[aria-label="Scroll to bottom"]',
);
expect(fallbackButton).not.toBeNull();
expect(fallbackButton?.classList).toContain("fixed");
const replacementDock = document.createElement("div");
replacementDock.dataset.testid = "task-chat-composer-dock";
await act(async () => {
main.appendChild(replacementDock);
await Promise.resolve();
});
const replacementButton = replacementDock.querySelector<HTMLButtonElement>(
'button[aria-label="Scroll to bottom"]',
);
expect(replacementButton).not.toBeNull();
expect(replacementButton?.classList).toContain("absolute");
expect(host.querySelector('button[aria-label="Scroll to bottom"]')).toBeNull();
});
});

View File

@ -1,5 +1,7 @@
import { useCallback, useEffect, useState } from "react";
import { ArrowDown } from "lucide-react";
import { createPortal } from "react-dom";
import { useSidebar } from "../context/SidebarContext";
import { usePanel } from "../context/PanelContext";
import { cn } from "../lib/utils";
@ -36,6 +38,24 @@ function distanceFromBottom(target: ReturnType<typeof resolveScrollTarget>) {
export function ScrollToBottom() {
const [visible, setVisible] = useState(false);
const { panelVisible, panelContent } = usePanel();
const { isMobile } = useSidebar();
const [composerDock, setComposerDock] = useState<HTMLElement | null>(null);
useEffect(() => {
if (!isMobile) {
setComposerDock(null);
return;
}
const main = document.getElementById("main-content");
if (!main) return;
const findDock = () => setComposerDock(
main.querySelector<HTMLElement>('[data-testid="task-chat-composer-dock"]'),
);
findDock();
const observer = new MutationObserver(findDock);
observer.observe(main, { childList: true, subtree: true });
return () => observer.disconnect();
}, [isMobile]);
useEffect(() => {
const check = () => {
@ -70,16 +90,22 @@ export function ScrollToBottom() {
if (!visible) return null;
return (
const button = (
<button
data-slot="icon-button"
onClick={scroll}
className={cn(
"fixed bottom-(--sz-calc-21) right-6 z-40 flex h-9 w-9 items-center justify-center rounded-full border border-border bg-background shadow-md hover:bg-accent transition-(--tp-background-color-right) duration-200 md:bottom-6",
panelVisible && panelContent && "md:right-(--sz-calc-22)",
"z-40 flex h-9 w-9 items-center justify-center rounded-full border border-border bg-background shadow-md hover:bg-accent transition-(--tp-background-color-right) duration-200",
isMobile && composerDock
? "absolute bottom-full left-1/2 -translate-x-1/2 mb-3"
: "fixed bottom-(--sz-calc-21) right-6 md:bottom-6",
!isMobile && panelVisible && panelContent && "md:right-(--sz-calc-22)",
)}
aria-label="Scroll to bottom"
>
<ArrowDown className="h-4 w-4" />
</button>
);
// Anchoring to the dock follows editor growth and the nav's sticky offset.
return isMobile && composerDock ? createPortal(button, composerDock) : button;
}

View File

@ -363,7 +363,7 @@ describe("TaskChatThread draft pass-through", () => {
expect(scroller?.firstElementChild?.classList).toContain("pt-3");
});
it("keeps the composer dock aligned with the thread's horizontal padding", () => {
it("lets the mobile composer dock use the full thread width", () => {
render(
<TaskChatThread
comments={[
@ -393,7 +393,8 @@ describe("TaskChatThread draft pass-through", () => {
);
expect(thread?.classList).not.toContain("h-(--tc-thread-max-h)");
expect(thread?.classList).toContain("flex-1");
expect(dock?.classList).toContain("px-4");
expect(dock?.classList).toContain("px-2");
expect(dock?.classList).toContain("md:px-0");
expect(dock?.classList).not.toContain("px-1");
expect(dock?.classList).not.toContain("-mt-(--radius-task-composer)");
expect(dock?.classList).not.toContain("pt-1");
@ -1834,8 +1835,8 @@ describe("TaskChatThread composer alignment", () => {
expect(dock?.classList).not.toContain("-mt-(--radius-task-composer)");
expect(composer?.classList).not.toContain("border");
expect(composer?.classList).toContain("bg-card");
expect(send?.classList).toContain("rounded-md");
expect(send?.classList).not.toContain("rounded-full");
expect(send?.classList).toContain("rounded-full");
expect(send?.classList).not.toContain("rounded-md");
});
});

View File

@ -2684,10 +2684,10 @@ export function TaskChatThread(props: TaskChatThreadProps) {
isMobile
? "bottom-(--tc-composer-bottom) z-20 transition-[bottom] duration-200 ease-out"
: "bottom-0 z-10",
"mx-auto flex w-full max-w-(--tc-shell-max-w) flex-col gap-2 px-4 pb-2",
"mx-auto flex w-full max-w-(--tc-shell-max-w) flex-col gap-2 px-2 pb-2 md:px-4",
streamlinedUiEnabled && "md:px-0 md:pb-0",
(!streamlinedUiEnabled || isMobile) &&
"bg-background/80 pt-1 backdrop-blur supports-[backdrop-filter]:bg-background/60",
"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",
)}
>
{composerAccessory}

View File

@ -353,10 +353,11 @@ describe("TaskChatComposer", () => {
);
});
it("reserves enough mobile editor height for a wrapped two-line placeholder", () => {
it("uses a compact mobile editor that can grow with the message", () => {
render(<TaskChatComposer onAdd={vi.fn()} workMode="standard" mobile />);
expect(editable().dataset.contentClassName).toContain("min-h-(--sz-72px)");
expect(editable().dataset.contentClassName).toContain("min-h-(--sz-48px)");
expect(editable().dataset.contentClassName).toContain("max-h-(--sz-28dvh)");
});
it("submits the trimmed body on Cmd+Enter and clears the draft", async () => {

View File

@ -280,7 +280,12 @@ const MODE_DESCRIPTION: Partial<Record<IssueWorkMode, string>> = {
};
/** v7 per-mode placeholder copy; `{agent}` is the pending assignee's name. */
function modePlaceholder(mode: IssueWorkMode, agentName: string): string {
function modePlaceholder(mode: IssueWorkMode, agentName: string, mobile: boolean): string {
if (mobile) {
if (mode === "planning") return `Plan with ${agentName}`;
if (mode === "ask") return `Ask ${agentName}`;
return `Message ${agentName}`;
}
switch (mode) {
case "planning":
return `Plan with ${agentName} — shapes the plan doc, no code changes…`;
@ -502,7 +507,7 @@ export function TaskChatComposer({
assigneeLabel === "Unassigned" ? "the agent" : assigneeLabel;
const effectivePlaceholder = queuedEdit
? "Edit queued message…"
: (placeholder ?? modePlaceholder(pendingMode, assigneeName));
: (placeholder ?? modePlaceholder(pendingMode, assigneeName, mobile));
const goalUnavailable = runnerGoalCapability?.availability !== "available";
const goalCommandOption: ActionCommandOption = {
id: "action:goal",
@ -837,6 +842,7 @@ export function TaskChatComposer({
streamlined
? "paperclip-task-chat-composer rounded-(--radius-task-composer) border border-border bg-card p-(--sz-18px) shadow-(--shadow-task-composer) dark:border-0 dark:bg-muted dark:shadow-none"
: "paperclip-task-chat-composer rounded-xl bg-card p-(--sz-18px)",
mobile && "p-3",
)}
onKeyDownCapture={(e) => {
// Capture mode shortcuts on the wrapper so they work while the rich
@ -977,7 +983,7 @@ export function TaskChatComposer({
className={cn(disabled && "opacity-60")}
contentClassName={
mobile
? "max-h-(--sz-28dvh) min-h-(--sz-72px) 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-base scrollbar-auto-hide"
: "max-h-(--sz-28dvh) min-h-(--sz-48px) overflow-y-auto px-1 py-1 text-sm scrollbar-auto-hide"
}
/>
@ -1100,6 +1106,7 @@ export function TaskChatComposer({
)}
style={{ "--sc": modeHue(pendingMode) } as CSSProperties}
data-testid="task-chat-composer-mode"
data-slot="task-chat-mode-trigger"
data-pending-work-mode={pendingMode}
>
{modeMeta.label}
@ -1248,14 +1255,15 @@ export function TaskChatComposer({
: "Send"
}
className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center transition-transform hover:scale-105 disabled:scale-100",
"flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition-transform hover:scale-105 disabled:scale-100",
streamlined
? "rounded-full bg-foreground text-background disabled:bg-foreground disabled:text-background disabled:opacity-100"
: "rounded-md bg-primary text-primary-foreground disabled:bg-muted disabled:text-muted-foreground",
? "bg-foreground text-background disabled:bg-foreground disabled:text-background disabled:opacity-100"
: "bg-primary text-primary-foreground disabled:bg-muted disabled:text-muted-foreground",
)}
data-testid={
showStop ? "task-chat-composer-stop" : "task-chat-composer-send"
}
data-slot="icon-button"
>
{submitting || (showStop && stopControl.stopping) ? (
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />

View File

@ -354,7 +354,7 @@ export function TaskChatThreadView({
const body = (
<div
className={cn(
"mx-auto flex w-full max-w-(--tc-shell-max-w) flex-col px-4 py-4",
"paperclip-mobile-thread mx-auto flex w-full max-w-(--tc-shell-max-w) flex-col px-2 py-4 md:px-4",
streamlined ? "md:px-0" : "gap-5",
className,
)}

View File

@ -348,6 +348,8 @@
composer's action row never hides behind the nav. Desktop and the classic
thread never override it and fall through to the safe-area default. */
--tc-composer-bottom: var(--sz-calc-8);
/* Together with the dock's pb-2, match the page's p-4 side gutters. */
--tc-composer-hidden-nav-offset: calc(var(--sz-safe-bottom) + var(--spacing) * 2);
}
.dark {
@ -542,6 +544,8 @@
[data-slot="toggle"],
[data-slot="checkbox"],
[data-slot="icon-button"],
[data-slot="task-chat-mode-trigger"],
[data-slot="new-issue-compact-control"],
[data-size="xs"],
[data-size="icon-xs"],
[data-size="icon-sm"],
@ -555,6 +559,55 @@
}
}
/* Mobile collection toolbars and square agent actions. */
@media (width < 40rem) {
[role="toolbar"][aria-label="Inbox controls"] [data-slot="tabs-list"][data-variant="line"] [data-slot="tabs-trigger"]::after {
bottom: var(--spacing);
}
[role="toolbar"][aria-label="Inbox controls"] button[data-size="icon"],
[role="group"][aria-label="Agent view"] > button {
width: var(--sz-44px);
height: var(--sz-44px);
min-height: var(--sz-44px);
padding: 0;
flex-shrink: 0;
}
.paperclip-task-list-toolbar > :first-child {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
}
.paperclip-task-list-toolbar [data-slot="collection-toolbar-search"] {
grid-column: 1;
grid-row: 1;
}
.paperclip-task-list-toolbar [data-slot="collection-toolbar-search"] input {
height: var(--sz-44px);
}
.paperclip-task-list-toolbar [data-slot="collection-toolbar-context"] {
grid-column: 2;
grid-row: 1;
}
.paperclip-task-list-toolbar [data-slot="collection-toolbar-context"] button,
.paperclip-task-list-toolbar [data-slot="collection-toolbar-controls"] button {
width: var(--sz-44px);
height: var(--sz-44px);
min-height: var(--sz-44px);
padding: 0;
flex-shrink: 0;
}
.paperclip-task-list-toolbar > :first-child > :last-child {
grid-column: 1 / -1;
grid-row: 2;
}
}
/* Dark mode scrollbars */
.dark {
color-scheme: dark;
@ -1950,6 +2003,12 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before {
padding-left: 0.2rem;
}
@media (width < 48rem) {
.paperclip-mobile-thread .paperclip-markdown :where(ul, ol) {
padding-left: calc(var(--spacing) * 5);
}
}
.paperclip-markdown li > :where(p, ul, ol) {
margin-top: 0.3rem;
margin-bottom: 0.3rem;

View File

@ -2689,6 +2689,7 @@ function StreamlinedInbox() {
{actionError && <p className="text-sm text-destructive">{actionError}</p>}
{tab === "blocked" ? (
<div className="-mx-2 sm:mx-0">
<BlockedInboxView
companyId={selectedCompanyId!}
searchQuery={searchQuery}
@ -2707,6 +2708,7 @@ function StreamlinedInbox() {
showUpdatedColumn={visibleIssueColumnSet.has("updated") && availableIssueColumnSet.has("updated")}
presentation={streamlinedUiEnabled ? "task" : "legacy"}
/>
</div>
) : null}
{tab !== "blocked" && !allLoaded && visibleSections.length === 0 && (
@ -2736,7 +2738,7 @@ function StreamlinedInbox() {
<div>
<div
ref={listRef}
className="overflow-hidden"
className="-mx-2 overflow-hidden sm:mx-0"
onPointerDownCapture={noteInboxSortInteraction}
onWheelCapture={noteInboxSortInteraction}
>
@ -2816,7 +2818,7 @@ function StreamlinedInbox() {
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform", isExpanded && "rotate-90")} />
</button>
) : streamlinedUiEnabled ? (
<span data-slot="task-row-disclosure-spacer" className="h-4 w-4 shrink-0" aria-hidden="true" />
<span data-slot="task-row-disclosure-spacer" className={cn("h-4 w-4 shrink-0", !nestingEnabled && "hidden sm:block")} aria-hidden="true" />
) : undefined}
statusSlot={streamlinedUiEnabled ? rowStatusIcon : undefined}
metadata={streamlinedUiEnabled ? (
@ -2868,7 +2870,8 @@ function StreamlinedInbox() {
({childCount} sub-task{childCount !== 1 ? "s" : ""})
</span>
) : undefined}
mobileMeta={issueActivityText(issue).toLowerCase()}
mobileTitleMeta={streamlinedUiEnabled ? issueActivityTimestamp(issue) : undefined}
mobileMeta={streamlinedUiEnabled ? undefined : issueActivityText(issue).toLowerCase()}
mobileLeading={!streamlinedUiEnabled ? (
depth === 0 && hasChildren && collapseParentId ? (
<button