mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-06 10:48:12 +02:00
fix(ui): stabilize mobile task reading and document navigation (#15228)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - People read tasks and send instructions from phones as well as desktop browsers. > - The mobile footer let page text show through its labels, and small text fields made Safari zoom on focus. > - Small scroll changes made the footer switch direction, and its changing page padding moved the conversation. > - Task pages also showed comments before question cards and run history arrived, so the composer and reading position moved again. > - Document links also used native navigation, which reset the reading position or reloaded a task through its UUID URL. Desktop tabs were crowded in the mobile drawer. > - This pull request keeps navigation steady, opens documents in the mounted task, and gives mobile readers a full-height panel with a vertical tab selector. > - The benefit is a stable task view and smoother scrolling on mobile. ## Linked Issues or Issue Description **What happened?** The mobile footer was translucent. Safari zoomed when a person focused a small text field. The footer switched abruptly while scrolling. A large task could show saved comments, then move the page again when a question card or run history arrived. In a local test with delayed responses, a late question moved the mobile composer by about 374 pixels. Opening a plan from the feed could reset the view or reload the task through a UUID link. The mobile document drawer left part of the feed exposed above small desktop tab controls. **Expected behavior** The footer has an opaque surface and moves smoothly after deliberate scrolling. Text fields do not cause automatic focus zoom. A task shows its initial conversation and composer together at the final scroll position. Background refreshes keep the existing conversation visible. Document links open in the mounted task with its existing cache and reading position. Mobile documents fill the viewport, show a clear close button, and offer a vertical list of open tabs. **Steps to reproduce** 1. Open a task with many long comments and a pending question in iOS Safari. 2. Delay its interactions, activity, and runs responses by different amounts. 3. Reload the page and watch the conversation and composer move as each response arrives. 4. Scroll down and back up, including small direction changes and edge bounce. 5. Focus the task composer, search field, and new-task title and description. 6. Open a plan or another task document from the feed, including a link that uses the task UUID. Close the panel and check the reading position. 7. Open several documents on a phone. Switch tabs and close both active and inactive tabs. **Paperclip version or commit** Developed from `1c07b5903` and rebased onto `59015846a`. **Deployment mode** Built from source. Tested in an isolated local test drive with iOS 26.5 Simulator Safari and Chrome. A temporary local proxy delayed independent responses for the layout test. Related work found in the duplicate search: - Refs #14727. It made saved replies appear before supporting history. This PR keeps its parallel requests and narrows the tradeoff in favor of a stable first layout. - Refs #14667. This open PR takes a different approach with per-run placeholders and retries. This PR fixes the observed question/composer movement and mobile navigation behavior. - Refs #13095 and #13597. These earlier fixes added task scroll anchors and skipped transcript waits for scheduled retries. - Refs #6550. Earlier mobile board polish. - Refs #9467. This related open PR changes list and generic tab reflow. The task-pane selector uses a separate component. ## What Changed - Give the mobile footer an opaque semantic surface. - Set a base-size floor for editable text on touch devices to prevent Safari focus zoom. Preserve larger title text. - Share mobile scroll tracking between both layouts. Accumulate scroll distance, ignore edge bounce and changed document bounds, and update once per frame. - Use shared motion tokens for the footer and composer. Keep page padding stable and honor reduced motion. - Wait for the initial question cards, attachments, work products, activity, runtime selection, plan, and relevant transcript history before the first reveal. Skip scheduled retries and older runs outside the initial comment window. - Bound the first reveal to 15 seconds. A stalled supporting request leaves saved conversation and the composer accessible with an explicit loading notice. - Keep concealed mobile history from stretching the document. Keep the composer mounted but concealed until the same reveal. Keep both visible during later refreshes. - Route first and repeated same-task document clicks in place. Recognize UUID and identifier links. Preserve the thread history entry and feed position. Keep modifier clicks, downloads, external links, and classic document behavior. - Give the mobile task panel the full viewport and safe-area padding. Use a visible X and 44-pixel touch controls. Replace the horizontal tab strip with a vertical selector that wraps titles and supports keyboard focus. - Add four interactive Storybook states for a few tabs, long names, many tabs, and the last tab. Reuse the production selector and tab controller. - Add navigation and tab regressions, update first-reveal regressions, and document the behavior in `DESIGN.md`. ## Verification - 392 tests passed across the seven focused task-loading, scroll, mobile-navigation, layout, and composer suites. After review fixes, all 339 tests across the four affected suites passed, including stalled-loading fallback on mobile and desktop and the motion-token catalog. - All 442 focused document, tab, task-thread, and scroll tests pass. The final click-propagation cleanup also passes all 136 task-detail tests. UI typecheck, UI production build, and `pnpm check:token-gates` passed. - All four cases in `artifact-tab-arrival.spec.ts` and `text-attachment-tabs.spec.ts` pass locally, covering desktop and mobile selection, composer focus, document rendering, and downloads of the original bytes. - `pnpm --filter @paperclipai/ui build-storybook` passed. Open the mobile tab stories under `Prototypes/Task detail/Mobile tabs`. - A local diagnostic proxy measured cached plan content at about 250 ms after the first click. The HTML load count and task request count did not change. Feed scroll stayed at the same position. First, repeated, and UUID document links were tested at phone and desktop widths. Task-reference links close their preview before the document reader opens. - In Chrome at desktop and phone widths, the delayed-response task showed one complete reveal. The late question no longer moved an already visible composer. - In iOS Simulator Safari, verified the large-task reload, opaque footer, navigation hide/reveal, and search/new-task/composer focus without automatic zoom. - Full workspace typecheck and build passed. The updated UI also passes typecheck, production build, and token gates. - All 54 checks pass on the final commit `bf5c9914e61833e7cc8794a69d72cf8c7057b952` (two additional checks are intentionally skipped). Greptile reviewed that commit at 5/5, and all review threads are resolved. - Full local `pnpm test:run` was attempted but stopped after server-fixture failures. Embedded PostgreSQL startup failure reproduced in an isolated native-interaction fixture after five startup attempts. The broad run also reported a rapid Slack callback ordering test failure. These server paths are unchanged by this PR, and their CI shards pass on the latest head. The full local suite is not claimed as passing. - A localhost proxy stalled the activity response for 30 seconds. Chrome revealed the available conversation after the 15-second deadline at both desktop and phone widths, kept the composer accessible, and cleared the loading notice when the response arrived. ## Risks Slow initial history requests can delay the first conversation reveal by up to 15 seconds. If that deadline expires, late data can change the available conversation while a loading notice remains visible. The reveal waits only for runs in the initial comment window, and later refreshes do not conceal an existing conversation. The larger editable text can change line wrapping on phones. Mobile navigation and composer motion use shared tokens and respect reduced-motion settings. Mobile tab selection changes the control layout. Document links retain URL history while sharing the task reading position; other tasks and external links keep their normal navigation behavior. I checked `ROADMAP.md`. This is a fix for existing UI behavior. ## Model Used OpenAI GPT-6 in Codex. The runtime does not expose a more specific model ID or context-window size. The agent used reasoning, code editing, terminal tools, and Chrome and iOS Simulator testing. ## 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 (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [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 --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
59015846ae
commit
7498705642
26 files changed
+853
-166
No files matched your search
@@ -43,6 +43,26 @@ action on the left and the primary action on the right. A step owns its whole
|
||||
footer: do not render Save & exit in a separate parent block below it. Check this
|
||||
alignment in every step and conditional state, not just the first screen.
|
||||
|
||||
## Mobile navigation and text fields
|
||||
|
||||
The fixed bottom navigation uses an opaque surface so scrolling content cannot
|
||||
show through its labels. On touch devices, editable controls use at least the
|
||||
16px base typography token to prevent Safari's automatic focus zoom. Larger
|
||||
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.
|
||||
|
||||
Task conversations reveal their initial comments, interaction cards, plan, and
|
||||
relevant run history together after positioning the latest message. Keep the
|
||||
mobile loading surface at a stable viewport height while that history loads;
|
||||
concealed content must not stretch the document. Background refreshes keep an
|
||||
already revealed conversation and composer mounted and visible.
|
||||
Bound the initial wait to 15 seconds. If a request stalls, reveal the available
|
||||
conversation and composer with a notice that some history is still loading.
|
||||
|
||||
## Contextual feedback
|
||||
|
||||
Task chat shows execution errors and waits only while they remain relevant.
|
||||
@@ -101,6 +121,8 @@ How-to guide for day-to-day UI changes: see `doc/design/CHANGING-THE-UI.md`.
|
||||
|
||||
## Motion tokens (Task Chat Redesign)
|
||||
|
||||
Mobile task panels fill the viewport within the safe area. Their top toolbar shows the current tab title, an open-tab count and selector, an add action, and an X to return to the feed. The selector lists tabs vertically with wrapping titles, an explicit current-tab check, and visible close controls. Each touch control uses the 44px size token. Desktop tabs keep their horizontal layout. Document links within the current task open through the router and retain the feed's reading position and query cache.
|
||||
|
||||
The redesigned task thread (flag `enableTaskChatRedesign`) is the first surface to
|
||||
tokenize motion. Principles — reasoning only; values live in `ui/src/index.css`:
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
import { expectTaskPanelTab } from "./helpers/task-panel-tabs";
|
||||
|
||||
async function json(response: Awaited<ReturnType<APIRequestContext["get"]>>) {
|
||||
expect(response.ok(), `${response.status()}: ${await response.text()}`).toBe(true);
|
||||
@@ -46,10 +47,15 @@ for (const mobile of [false, true]) {
|
||||
await page.getByRole("button", { name: "Show properties", exact: true }).click();
|
||||
}
|
||||
const panel = mobile ? page.getByTestId("mobile-task-side-panel") : page.locator("aside").filter({ has: page.getByRole("tab", { name: "Artifacts", exact: true }) });
|
||||
const artifacts = panel.getByRole("tab", { name: "Artifacts", exact: true });
|
||||
if (mobile) await panel.getByRole("button", { name: /^Switch tabs, \d+ open$/ }).click();
|
||||
const artifacts = mobile
|
||||
? page.getByRole("dialog", { name: "Open tabs", exact: true }).getByRole("button", { name: "Artifacts", exact: true })
|
||||
: panel.getByRole("tab", { name: "Artifacts", exact: true });
|
||||
await expect(artifacts).toBeVisible();
|
||||
await expect(artifacts).toHaveAttribute("aria-selected", "false");
|
||||
if (mobile) await expect(artifacts).not.toHaveAttribute("aria-current", "true");
|
||||
else await expect(artifacts).toHaveAttribute("aria-selected", "false");
|
||||
await artifacts.click();
|
||||
await expectTaskPanelTab(panel, mobile, "Artifacts");
|
||||
await expect(panel.getByRole("heading", { name: "Arriving report", level: 2, exact: true })).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath("artifact-opened-by-user.png"), fullPage: true });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
export async function expectTaskPanelTab(panel: Locator, mobile: boolean, name: string) {
|
||||
if (mobile) {
|
||||
await expect(panel.getByRole("tabpanel")).toHaveAccessibleName(name);
|
||||
await expect(panel.getByRole("button", { name: /^Switch tabs, \d+ open$/ })).toContainText(name);
|
||||
} else {
|
||||
await expect(panel.getByRole("tab", { name, exact: true })).toHaveAttribute("aria-selected", "true");
|
||||
}
|
||||
}
|
||||
|
||||
export async function selectTaskPanelTab(page: Page, panel: Locator, mobile: boolean, name: string) {
|
||||
if (mobile) {
|
||||
await panel.getByRole("button", { name: /^Switch tabs, \d+ open$/ }).click();
|
||||
await page.getByRole("dialog", { name: "Open tabs", exact: true }).getByRole("button", { name, exact: true }).click();
|
||||
} else {
|
||||
await panel.getByRole("tab", { name, exact: true }).click();
|
||||
}
|
||||
await expectTaskPanelTab(panel, mobile, name);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
import { expectTaskPanelTab, selectTaskPanelTab } from "./helpers/task-panel-tabs";
|
||||
|
||||
async function json(response: Awaited<ReturnType<APIRequestContext["get"]>>) {
|
||||
expect(response.ok(), `${response.status()}: ${await response.text()}`).toBe(true);
|
||||
@@ -38,7 +39,7 @@ for (const mobile of [false, true]) {
|
||||
await page.goto(`/${company.issuePrefix}/issues/${issue.identifier}`);
|
||||
await page.getByRole("link", { name: "Open AGENTS.md", exact: true }).click();
|
||||
const panel = mobile ? page.getByTestId("mobile-task-side-panel") : page.locator("aside").filter({ has: page.getByRole("tab", { name: "AGENTS.md", exact: true }) });
|
||||
await expect(panel.getByRole("tab", { name: "AGENTS.md", exact: true })).toHaveAttribute("aria-selected", "true");
|
||||
await expectTaskPanelTab(panel, mobile, "AGENTS.md");
|
||||
await expect(panel.getByRole("heading", { name: "File charter", exact: true, level: 1 })).toBeVisible();
|
||||
const raw = panel.getByRole("button", { name: "Raw", exact: true });
|
||||
await raw.click();
|
||||
@@ -50,18 +51,25 @@ for (const mobile of [false, true]) {
|
||||
const markdownDownload = page.waitForEvent("download");
|
||||
await panel.getByRole("link", { name: "Download AGENTS.md", exact: true }).click();
|
||||
expect(await fs.readFile((await (await markdownDownload).path())!)).toEqual(files[0]!.buffer);
|
||||
if (mobile) await panel.getByRole("button", { name: "Toggle side panel", exact: true }).click();
|
||||
if (mobile) await panel.getByRole("button", { name: "Close side panel", exact: true }).click();
|
||||
await page.getByRole("link", { name: "Open notes.txt", exact: true }).click();
|
||||
await expect(panel.getByRole("tab", { name: "notes.txt", exact: true })).toHaveAttribute("aria-selected", "true");
|
||||
await expectTaskPanelTab(panel, mobile, "notes.txt");
|
||||
await expect(panel.getByLabel("notes.txt raw text")).toContainText("<script>never execute</script>");
|
||||
await expect(panel.getByRole("group", { name: "Markdown view", exact: true })).toHaveCount(0);
|
||||
const textDownload = page.waitForEvent("download");
|
||||
await panel.getByRole("link", { name: "Download notes.txt", exact: true }).click();
|
||||
expect(await fs.readFile((await (await textDownload).path())!)).toEqual(files[1]!.buffer);
|
||||
await page.screenshot({ path: testInfo.outputPath("plain-text-download.png"), fullPage: true });
|
||||
await panel.getByRole("tab", { name: "AGENTS.md", exact: true }).click();
|
||||
await selectTaskPanelTab(page, panel, mobile, "AGENTS.md");
|
||||
await expect(panel.getByRole("heading", { name: "File charter", exact: true, level: 1 })).toBeVisible();
|
||||
await expect(panel.getByRole("tab", { name: "AGENTS.md", exact: true })).toHaveCount(1);
|
||||
if (mobile) {
|
||||
await panel.getByRole("button", { name: /^Switch tabs, \d+ open$/ }).click();
|
||||
const overview = page.getByRole("dialog", { name: "Open tabs", exact: true });
|
||||
await expect(overview.getByRole("button", { name: "AGENTS.md", exact: true })).toHaveCount(1);
|
||||
await overview.getByRole("button", { name: "AGENTS.md", exact: true }).click();
|
||||
} else {
|
||||
await expect(panel.getByRole("tab", { name: "AGENTS.md", exact: true })).toHaveCount(1);
|
||||
}
|
||||
await panel.getByRole("button", { name: "Open a new tab", exact: true }).click();
|
||||
await page.getByRole("option", { name: /^Artifacts(?: Already open)?$/ }).click();
|
||||
const markdownCard = panel.getByRole("article").filter({ has: page.getByRole("heading", { name: "Delivered AGENTS.md", exact: true }) });
|
||||
@@ -75,11 +83,11 @@ for (const mobile of [false, true]) {
|
||||
const textCard = panel.getByRole("article").filter({ has: page.getByRole("heading", { name: "Delivered notes.txt", exact: true }) });
|
||||
await textCard.getByRole("link", { name: "Download file", exact: true }).click();
|
||||
expect(await fs.readFile((await (await cardDownload).path())!)).toEqual(files[1]!.buffer);
|
||||
await expect(panel.getByRole("tab", { name: "Artifacts", exact: true })).toHaveAttribute("aria-selected", "true");
|
||||
await expectTaskPanelTab(panel, mobile, "Artifacts");
|
||||
await panel.getByRole("button", { name: "Open in tab: Delivered notes.txt", exact: true }).click();
|
||||
await expect(panel.getByRole("tab", { name: "notes.txt", exact: true })).toHaveAttribute("aria-selected", "true");
|
||||
await panel.getByRole("tab", { name: "Artifacts", exact: true }).click();
|
||||
await expectTaskPanelTab(panel, mobile, "notes.txt");
|
||||
await selectTaskPanelTab(page, panel, mobile, "Artifacts");
|
||||
await panel.getByRole("button", { name: "Open in tab: Delivered AGENTS.md", exact: true }).click();
|
||||
await expect(panel.getByRole("tab", { name: "AGENTS.md", exact: true })).toHaveAttribute("aria-selected", "true");
|
||||
await expectTaskPanelTab(panel, mobile, "AGENTS.md");
|
||||
});
|
||||
}
|
||||
@@ -45,6 +45,7 @@ import { useCompany } from "../context/CompanyContext";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
|
||||
import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory";
|
||||
import { useMobileNavVisibility } from "../hooks/useMobileNavVisibility";
|
||||
import { healthApi } from "../api/health";
|
||||
import {
|
||||
resolveArchivedCompanyBounce,
|
||||
@@ -172,12 +173,11 @@ export function Layout() {
|
||||
// app nav collapses to its rail throughout the Skills Store section (PAP-10879).
|
||||
const isSkillsRoute = isSkillsStoreRoute(location.pathname, companyPrefix);
|
||||
const onboardingTriggered = useRef(false);
|
||||
const lastMainScrollTop = useRef(0);
|
||||
const previousPathname = useRef<string | null>(null);
|
||||
const mainContentRef = useRef<HTMLElement | null>(null);
|
||||
const scrollMemory = useRef(new NavigationScrollMemory());
|
||||
const activeScrollKey = useRef<string>(location.key);
|
||||
const [mobileNavVisible, setMobileNavVisible] = useState(true);
|
||||
const mobileNavVisible = useMobileNavVisibility(isMobile, location.pathname);
|
||||
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
||||
const matchedCompany = useMemo(() => {
|
||||
if (!companyPrefix) return null;
|
||||
@@ -469,15 +469,6 @@ export function Layout() {
|
||||
onGoToInbox: () => navigate("/inbox"),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile) {
|
||||
setMobileNavVisible(true);
|
||||
return;
|
||||
}
|
||||
lastMainScrollTop.current = 0;
|
||||
setMobileNavVisible(true);
|
||||
}, [isMobile]);
|
||||
|
||||
// Swipe gesture to open/close sidebar on mobile
|
||||
useEffect(() => {
|
||||
if (!isMobile) return;
|
||||
@@ -523,41 +514,6 @@ export function Layout() {
|
||||
};
|
||||
}, [isMobile, sidebarOpen, setSidebarOpen]);
|
||||
|
||||
const updateMobileNavVisibility = useCallback((currentTop: number) => {
|
||||
const delta = currentTop - lastMainScrollTop.current;
|
||||
|
||||
if (currentTop <= 24) {
|
||||
setMobileNavVisible(true);
|
||||
} else if (delta > 8) {
|
||||
setMobileNavVisible(false);
|
||||
} else if (delta < -8) {
|
||||
setMobileNavVisible(true);
|
||||
}
|
||||
|
||||
lastMainScrollTop.current = currentTop;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile) {
|
||||
setMobileNavVisible(true);
|
||||
lastMainScrollTop.current = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const onScroll = () => {
|
||||
updateMobileNavVisibility(
|
||||
window.scrollY || document.documentElement.scrollTop || 0,
|
||||
);
|
||||
};
|
||||
|
||||
onScroll();
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("scroll", onScroll);
|
||||
};
|
||||
}, [isMobile, updateMobileNavVisibility]);
|
||||
|
||||
useEffect(() => {
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
|
||||
@@ -738,6 +694,12 @@ export function Layout() {
|
||||
"--tc-composer-bottom": mobileNavVisible
|
||||
? "var(--tc-composer-visible-nav-offset)"
|
||||
: "var(--sz-calc-8)",
|
||||
"--mobile-nav-motion-duration": mobileNavVisible
|
||||
? "var(--motion-mobile-nav-enter)"
|
||||
: "var(--motion-mobile-nav-exit)",
|
||||
"--mobile-nav-motion-ease": mobileNavVisible
|
||||
? "var(--motion-ease-out-expo)"
|
||||
: "var(--motion-ease-standard)",
|
||||
} as CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
@@ -747,7 +709,7 @@ export function Layout() {
|
||||
// changes (e.g. switching skill-detail tabs) don't widen/shift
|
||||
// when the vertical scrollbar appears or disappears (PAP-10907).
|
||||
isMobile
|
||||
? isTaskDetailRoute && mobileNavVisible
|
||||
? isTaskDetailRoute
|
||||
? "overflow-visible pb-(--tc-composer-visible-nav-offset)"
|
||||
: "overflow-visible pb-(--sz-calc-14)"
|
||||
: "overflow-auto [scrollbar-gutter:stable]",
|
||||
|
||||
@@ -40,6 +40,7 @@ import { useSidebar } from "../context/SidebarContext";
|
||||
import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
|
||||
import { useStreamlinedUiEnabled } from "../hooks/useStreamlinedUiEnabled";
|
||||
import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory";
|
||||
import { useMobileNavVisibility } from "../hooks/useMobileNavVisibility";
|
||||
import { healthApi } from "../api/health";
|
||||
import { resolveArchivedCompanyBounce, shouldSyncCompanySelectionFromRoute } from "../lib/company-selection";
|
||||
import { useOptionalToastActions } from "../context/ToastContext";
|
||||
@@ -136,12 +137,11 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
|
||||
? companyPathSegments[2]
|
||||
: null;
|
||||
const onboardingTriggered = useRef(false);
|
||||
const lastMainScrollTop = useRef(0);
|
||||
const previousPathname = useRef<string | null>(null);
|
||||
const mainContentRef = useRef<HTMLElement | null>(null);
|
||||
const scrollMemory = useRef(new NavigationScrollMemory());
|
||||
const activeScrollKey = useRef<string>(location.key);
|
||||
const [mobileNavVisible, setMobileNavVisible] = useState(true);
|
||||
const mobileNavVisible = useMobileNavVisibility(isMobile, location.pathname);
|
||||
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
||||
const matchedCompany = useMemo(() => {
|
||||
if (!companyPrefix) return null;
|
||||
@@ -461,15 +461,6 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
|
||||
onGoToInbox: () => navigate("/inbox"),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile) {
|
||||
setMobileNavVisible(true);
|
||||
return;
|
||||
}
|
||||
lastMainScrollTop.current = 0;
|
||||
setMobileNavVisible(true);
|
||||
}, [isMobile]);
|
||||
|
||||
// Swipe gesture to open/close sidebar on mobile
|
||||
useEffect(() => {
|
||||
if (!isMobile) return;
|
||||
@@ -515,39 +506,6 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
|
||||
};
|
||||
}, [isMobile, sidebarOpen, setSidebarOpen]);
|
||||
|
||||
const updateMobileNavVisibility = useCallback((currentTop: number) => {
|
||||
const delta = currentTop - lastMainScrollTop.current;
|
||||
|
||||
if (currentTop <= 24) {
|
||||
setMobileNavVisible(true);
|
||||
} else if (delta > 8) {
|
||||
setMobileNavVisible(false);
|
||||
} else if (delta < -8) {
|
||||
setMobileNavVisible(true);
|
||||
}
|
||||
|
||||
lastMainScrollTop.current = currentTop;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile) {
|
||||
setMobileNavVisible(true);
|
||||
lastMainScrollTop.current = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const onScroll = () => {
|
||||
updateMobileNavVisibility(window.scrollY || document.documentElement.scrollTop || 0);
|
||||
};
|
||||
|
||||
onScroll();
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("scroll", onScroll);
|
||||
};
|
||||
}, [isMobile, updateMobileNavVisibility]);
|
||||
|
||||
useEffect(() => {
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
|
||||
@@ -743,6 +701,12 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
|
||||
"--tc-composer-bottom": mobileNavVisible
|
||||
? "var(--tc-composer-visible-nav-offset)"
|
||||
: "var(--tc-composer-hidden-nav-offset)",
|
||||
"--mobile-nav-motion-duration": mobileNavVisible
|
||||
? "var(--motion-mobile-nav-enter)"
|
||||
: "var(--motion-mobile-nav-exit)",
|
||||
"--mobile-nav-motion-ease": mobileNavVisible
|
||||
? "var(--motion-ease-out-expo)"
|
||||
: "var(--motion-ease-standard)",
|
||||
} as CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
@@ -757,9 +721,7 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
|
||||
// when the vertical scrollbar appears or disappears (PAP-10907).
|
||||
isMobile
|
||||
? isTaskDetailRoute
|
||||
? mobileNavVisible
|
||||
? "overflow-visible pb-(--tc-composer-visible-nav-offset)"
|
||||
: "overflow-visible pb-(--tc-composer-hidden-nav-offset)"
|
||||
? "overflow-visible pb-(--tc-composer-visible-nav-offset)"
|
||||
: "overflow-visible pb-(--sz-calc-14)"
|
||||
: "overflow-auto [scrollbar-gutter:stable]",
|
||||
)}
|
||||
|
||||
@@ -88,9 +88,10 @@ export function MobileBottomNav({ visible }: MobileBottomNavProps) {
|
||||
return (
|
||||
<nav
|
||||
className={cn(
|
||||
"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",
|
||||
"mobile-bottom-nav fixed bottom-0 left-0 right-0 z-30 bg-muted md:hidden pb-(--sz-safe-bottom)",
|
||||
)}
|
||||
data-visible={visible}
|
||||
inert={!visible}
|
||||
aria-label="Mobile navigation"
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -312,6 +312,7 @@ it.each([true, false])("interleaves browser sessions with their requests and pre
|
||||
});
|
||||
|
||||
it("coordinates first reveal while keeping the composer and visible history mounted through refresh", async () => {
|
||||
sidebarState.isMobile = true;
|
||||
const props = {
|
||||
issueId: "coordinated-issue",
|
||||
comments: [],
|
||||
@@ -321,17 +322,61 @@ it("coordinates first reveal while keeping the composer and visible history moun
|
||||
const composer = container.querySelector('[data-testid="mock-editor"]');
|
||||
expect(composer).not.toBeNull();
|
||||
expect(container.querySelector('[aria-busy="true"]')).not.toBeNull();
|
||||
expect(container.querySelector('.task-chat-history-pending')).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="task-chat-composer-dock"]')?.hasAttribute("inert")).toBe(true);
|
||||
render(<TaskChatThread {...props} initialHistoryPending={false} />);
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
});
|
||||
expect(container.querySelector('[aria-busy="false"]')).not.toBeNull();
|
||||
expect(container.querySelector('.task-chat-history-pending')).toBeNull();
|
||||
expect(container.querySelector('[data-testid="task-chat-composer-dock"]')?.hasAttribute("inert")).toBe(false);
|
||||
expect(container.querySelector('[data-testid="mock-editor"]')).toBe(composer);
|
||||
render(<TaskChatThread {...props} initialHistoryPending />);
|
||||
expect(
|
||||
container.querySelector('[data-testid="task-chat-history-loading"]'),
|
||||
).toBeNull();
|
||||
expect(container.querySelector('[data-testid="mock-editor"]')).toBe(composer);
|
||||
expect(container.querySelector('.task-chat-history-pending')).toBeNull();
|
||||
});
|
||||
|
||||
it.each([true, false])("reveals saved conversation when initial history stalls (mobile=%s)", async (mobile) => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
sidebarState.isMobile = mobile;
|
||||
const props = {
|
||||
issueId: "stalled-issue",
|
||||
comments: [{
|
||||
id: "saved-comment", body: "An already saved reply.", runId: null,
|
||||
companyId: "company", issueId: "stalled-issue", authorType: "agent" as const,
|
||||
authorAgentId: "agent", authorUserId: null, presentation: null, metadata: null,
|
||||
createdAt: new Date("2025-01-01T10:00:20Z"), updatedAt: new Date("2025-01-01T10:00:20Z"),
|
||||
}],
|
||||
onAdd: async () => {},
|
||||
};
|
||||
render(<TaskChatThread {...props} initialHistoryPending />);
|
||||
const composer = container.querySelector('[data-testid="mock-editor"]');
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(14_999); });
|
||||
expect(container.querySelector('[data-testid="task-chat-history-loading"]')).not.toBeNull();
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(1); });
|
||||
expect(container.querySelector('[data-testid="task-chat-history-loading"]')).toBeNull();
|
||||
expect(container.querySelector('[data-testid="task-chat-history-content"]')?.hasAttribute("inert")).toBe(false);
|
||||
expect(container.querySelector('[data-testid="task-chat-composer-dock"]')?.hasAttribute("inert")).toBe(false);
|
||||
expect(container.textContent).toContain("An already saved reply.");
|
||||
expect(container.textContent).toContain("Some task history is still loading.");
|
||||
expect(container.querySelector('[data-testid="mock-editor"]')).toBe(composer);
|
||||
render(<TaskChatThread {...props} initialHistoryPending={false} />);
|
||||
expect(container.textContent).not.toContain("Some task history is still loading.");
|
||||
expect(container.querySelector('[data-testid="mock-editor"]')).toBe(composer);
|
||||
|
||||
// A timeout on one task must not bypass coordination on the next task.
|
||||
render(<TaskChatThread {...props} issueId="next-issue" initialHistoryPending />);
|
||||
expect(container.querySelector('[data-testid="task-chat-history-loading"]')).not.toBeNull();
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(15_000); });
|
||||
expect(container.querySelector('[data-testid="task-chat-history-loading"]')).toBeNull();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
describe.each(["legacy", "native"] as const)("%s task history readiness", (runtimeMode) => {
|
||||
@@ -370,8 +415,25 @@ describe.each(["legacy", "native"] as const)("%s task history readiness", (runti
|
||||
expect(container.textContent).toContain("Thread message 1");
|
||||
});
|
||||
|
||||
it("does not delay the first reveal for an older run outside the loaded comment window", () => {
|
||||
render(<TaskChatThread
|
||||
issueId="issue-1"
|
||||
comments={createLongThreadComments()}
|
||||
onAdd={async () => {}}
|
||||
linkedRuns={[{
|
||||
...retryRun,
|
||||
runId: "older-run",
|
||||
status: "succeeded",
|
||||
createdAt: "2026-08-01T12:00:00.000Z",
|
||||
startedAt: "2026-08-01T12:00:00.000Z",
|
||||
finishedAt: "2026-08-01T12:01:00.000Z",
|
||||
}]}
|
||||
/>);
|
||||
expect(container.querySelector('[aria-busy="false"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it.each(["running", "succeeded"])(
|
||||
"reveals durable comments before a %s run finishes hydrating",
|
||||
"reveals durable comments together with a %s run's initial history",
|
||||
async (status) => {
|
||||
const props = {
|
||||
issueId: "issue-1",
|
||||
@@ -388,10 +450,10 @@ describe.each(["legacy", "native"] as const)("%s task history readiness", (runti
|
||||
],
|
||||
};
|
||||
render(<TaskChatThread {...props} />);
|
||||
expect(container.querySelector('[aria-busy="false"]')).not.toBeNull();
|
||||
expect(container.querySelector('[aria-busy="true"]')).not.toBeNull();
|
||||
expect(
|
||||
container.querySelector('[data-testid="task-chat-history-loading"]'),
|
||||
).toBeNull();
|
||||
).not.toBeNull();
|
||||
expect(container.textContent).toContain("Thread message 1");
|
||||
|
||||
transcriptState.hydratedRunIds = new Set(["started-run"]);
|
||||
|
||||
@@ -244,6 +244,7 @@ function isRunnerResponseComment(params: {
|
||||
// off to (e.g. a stopped run with no tool activity). Normal completions hand off
|
||||
// well within this as soon as the settled turn/comment lands.
|
||||
const SETTLING_TAIL_MAX_MS = 15_000;
|
||||
const INITIAL_HISTORY_REVEAL_TIMEOUT_MS = 15_000;
|
||||
const EMPTY_LIVE_ISSUE_IDS: ReadonlySet<string> = new Set<string>();
|
||||
const LONG_THREAD_BLOCKER_REPEAT_COUNT = 4;
|
||||
|
||||
@@ -2859,12 +2860,11 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
? !hydratedLogRunIds.has(run.id)
|
||||
: logsAreInitiallyHydrating;
|
||||
});
|
||||
// Durable messages are useful immediately. Tool history can fill in around
|
||||
// their stable anchors without concealing already-loaded replies. A thread
|
||||
// with only runtime output still waits for that output before showing empty.
|
||||
const historyPending = initialHistoryPending || (
|
||||
comments.length === 0 && !issueBrief?.description && (planLoading || transcriptHistoryPending)
|
||||
);
|
||||
// Hydrating a run inserts activity around its saved reply and can move the
|
||||
// latest viewport substantially. Wait for the initial comment window's run
|
||||
// history and plan before revealing; older runs outside that window do not
|
||||
// delay it, and the latch below keeps subsequent refreshes visible.
|
||||
const historyPending = initialHistoryPending || planLoading || transcriptHistoryPending;
|
||||
const historyError =
|
||||
initialHistoryError ||
|
||||
planError ||
|
||||
@@ -2878,6 +2878,19 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
() => (historyPending ? undefined : issueId),
|
||||
);
|
||||
const historyRevealed = revealedIssue === issueId;
|
||||
const [expiredHistoryWait, setExpiredHistoryWait] = useState<{ issueId: typeof issueId } | null>(null);
|
||||
const historyWaitExpired = expiredHistoryWait !== null && expiredHistoryWait.issueId === issueId;
|
||||
// Supporting requests can stall without rejecting. Bound the first reveal
|
||||
// independently of their pending states so saved conversation and the
|
||||
// composer stay accessible, with an explicit incomplete-history notice.
|
||||
useEffect(() => {
|
||||
if (historyRevealed) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
setExpiredHistoryWait({ issueId });
|
||||
setRevealedIssue(issueId);
|
||||
}, INITIAL_HISTORY_REVEAL_TIMEOUT_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [historyRevealed, issueId]);
|
||||
// Mount and measure the real thread while concealed, then reveal in one
|
||||
// commit. A frame also lets ancestor navigation scroll restoration finish.
|
||||
// Readiness is latched per issue: refetches never hide existing conversation.
|
||||
@@ -2899,7 +2912,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
|
||||
return (
|
||||
<TaskChatExpansionState.Provider value={expansionState.current}>
|
||||
<TaskChatScrollReady.Provider value={!historyPending}>
|
||||
<TaskChatScrollReady.Provider value={!historyPending || historyWaitExpired}>
|
||||
<TaskChatWindowScroll
|
||||
contentKey={isMobile ? autoFollowContentKey : 0}
|
||||
enabled={isMobile && historyRevealed}
|
||||
@@ -2908,25 +2921,31 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
mode={streamlinedUiEnabled ? "streamlined" : "production"}
|
||||
>
|
||||
<div
|
||||
className={cn("flex flex-col", !isMobile && "min-h-0 flex-1")}
|
||||
className={cn(
|
||||
"flex flex-col",
|
||||
!isMobile && "min-h-0 flex-1",
|
||||
isMobile && !historyRevealed && "task-chat-history-pending",
|
||||
)}
|
||||
data-testid="task-chat-thread"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex flex-col",
|
||||
!isMobile && "min-h-0 flex-1",
|
||||
(!isMobile || !historyRevealed) && "min-h-0 flex-1",
|
||||
)}
|
||||
aria-busy={!historyRevealed}
|
||||
>
|
||||
{historyError ? (
|
||||
{historyError || (historyPending && historyWaitExpired) ? (
|
||||
<div
|
||||
role="status"
|
||||
className="absolute inset-x-0 top-0 z-20 mx-auto flex w-full max-w-(--tc-shell-max-w) items-center gap-2 border border-border bg-background px-4 py-2 text-sm text-muted-foreground"
|
||||
>
|
||||
Some task history could not be loaded.
|
||||
<Button variant="ghost" size="sm" onClick={retryHistory}>
|
||||
Retry
|
||||
</Button>
|
||||
{historyError ? "Some task history could not be loaded." : "Some task history is still loading."}
|
||||
{historyError ? (
|
||||
<Button variant="ghost" size="sm" onClick={retryHistory}>
|
||||
Retry
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{!historyRevealed ? (
|
||||
@@ -2945,6 +2964,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
data-testid="task-chat-history-content"
|
||||
className={cn(
|
||||
"flex flex-col",
|
||||
!isMobile && "min-h-0 flex-1",
|
||||
@@ -3103,7 +3123,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
</div>
|
||||
) : null}
|
||||
{showComposer ? (
|
||||
<TaskChatComposerDock mobile={isMobile} streamlined={streamlinedUiEnabled}>
|
||||
<TaskChatComposerDock mobile={isMobile} streamlined={streamlinedUiEnabled} concealed={!historyRevealed}>
|
||||
{composerAccessory}
|
||||
{tailTurnStatus ? (
|
||||
<TaskChatTurnStatusIsland model={tailTurnStatus} />
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { SidePanelMobileTabs } from "./SidePanelMobileTabs";
|
||||
import { useSidePanelTabs } from "./use-side-panel-tabs";
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const longTitle = "Bookmarked post formats and fourteen model-harness tweets";
|
||||
|
||||
function Fixture() {
|
||||
const controller = useSidePanelTabs({ initialState: {
|
||||
tabs: [{ id: "properties", type: "view", label: "Properties", payload: {} }, { id: "plan", type: "document", label: longTitle, payload: {} }],
|
||||
activeTabId: "plan",
|
||||
} });
|
||||
return <><SidePanelMobileTabs tabs={controller.tabs} activeTabId={controller.activeTabId} onActiveTabChange={controller.selectTab} onCloseTab={controller.closeTab} /><output>{controller.activeTabId ?? "empty"}</output></>;
|
||||
}
|
||||
|
||||
describe("SidePanelMobileTabs", () => {
|
||||
let root: Root;
|
||||
let container: HTMLDivElement;
|
||||
beforeEach(async () => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
await act(async () => root.render(<Fixture />));
|
||||
});
|
||||
afterEach(() => { act(() => root.unmount()); container.remove(); });
|
||||
function button(label: string) {
|
||||
const result = Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find((item) => item.getAttribute("aria-label") === label);
|
||||
expect(result).toBeDefined();
|
||||
return result!;
|
||||
}
|
||||
async function click(label: string) { await act(async () => button(label).click()); }
|
||||
|
||||
it("shows full titles, selects a tab, and returns focus to the title selector", async () => {
|
||||
await click("Switch tabs, 2 open");
|
||||
expect(button(longTitle).textContent).toContain(longTitle);
|
||||
expect(button(longTitle).getAttribute("aria-current")).toBe("true");
|
||||
await act(async () => {
|
||||
button(longTitle).focus();
|
||||
button(longTitle).dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
|
||||
});
|
||||
expect(document.activeElement).toBe(button("Properties"));
|
||||
await click("Properties");
|
||||
expect(container.querySelector("output")?.textContent).toBe("properties");
|
||||
expect(document.querySelector('ul[aria-label="Open tabs"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the current tab on inactive close and offers an empty state after the last close", async () => {
|
||||
await click("Switch tabs, 2 open");
|
||||
await click("Close Properties");
|
||||
expect(container.querySelector("output")?.textContent).toBe("plan");
|
||||
expect(button("Switch tabs, 1 open").getAttribute("aria-expanded")).toBe("true");
|
||||
await click(`Close ${longTitle}`);
|
||||
expect(container.querySelector("output")?.textContent).toBe("empty");
|
||||
expect(button("Switch tabs, 0 open").disabled).toBe(true);
|
||||
expect(document.querySelector('ul[aria-label="Open tabs"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("chooses a neighbor and keeps keyboard focus in the overview when closing the active tab", async () => {
|
||||
await click("Switch tabs, 2 open");
|
||||
await click(`Close ${longTitle}`);
|
||||
expect(container.querySelector("output")?.textContent).toBe("properties");
|
||||
expect(document.activeElement).toBe(button("Properties"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useRef, useState, type KeyboardEvent, type ReactNode } from "react";
|
||||
import { Check, ChevronDown, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { SidePanelTabItem } from "./types";
|
||||
|
||||
interface SidePanelMobileTabsProps {
|
||||
tabs: SidePanelTabItem[];
|
||||
activeTabId: string | null;
|
||||
onActiveTabChange: (id: string) => void;
|
||||
onCloseTab: (id: string) => void;
|
||||
addControl?: ReactNode;
|
||||
}
|
||||
|
||||
/** A readable title and a vertical overview replace crowded desktop tabs. */
|
||||
export function SidePanelMobileTabs({ tabs, activeTabId, onActiveTabChange, onCloseTab, addControl }: SidePanelMobileTabsProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const listRef = useRef<HTMLUListElement>(null);
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
const activeTab = tabs.find((tab) => tab.id === activeTabId);
|
||||
|
||||
function moveFocus(event: KeyboardEvent<HTMLButtonElement>, id: string) {
|
||||
const enabled = tabs.filter((tab) => !tab.disabled);
|
||||
const index = enabled.findIndex((tab) => tab.id === id);
|
||||
let next: number;
|
||||
switch (event.key) {
|
||||
case "ArrowDown": next = (index + 1) % enabled.length; break;
|
||||
case "ArrowUp": next = (index - 1 + enabled.length) % enabled.length; break;
|
||||
case "Home": next = 0; break;
|
||||
case "End": next = enabled.length - 1; break;
|
||||
default: return;
|
||||
}
|
||||
event.preventDefault();
|
||||
listRef.current?.querySelectorAll<HTMLButtonElement>("[data-mobile-tab-select]:not(:disabled)")[next]?.focus();
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={headerRef} className="flex min-w-0 flex-1 items-center gap-1">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-(--sz-44px) min-w-0 flex-1 justify-start gap-2 px-2"
|
||||
aria-label={`Switch tabs, ${tabs.length} open`}
|
||||
disabled={tabs.length === 0}
|
||||
>
|
||||
<span id={activeTab ? `side-panel-tab-${activeTab.id}` : undefined} className="min-w-0 flex-1 truncate text-left">{activeTab?.label ?? "Choose a tab"}</span>
|
||||
<span className="shrink-0 rounded-md bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">{tabs.length}</span>
|
||||
<ChevronDown aria-hidden className="shrink-0" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
aria-label="Open tabs"
|
||||
align="start"
|
||||
className="w-(--side-panel-mobile-tabs-width) max-w-(--radix-popover-content-available-width) p-2"
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
const buttons = listRef.current?.querySelectorAll<HTMLButtonElement>("[data-mobile-tab-select]:not(:disabled)");
|
||||
const selected = Array.from(buttons ?? []).find((button) => button.dataset.mobileTabSelect === activeTabId);
|
||||
(selected ?? buttons?.[0])?.focus();
|
||||
}}
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (tabs.length !== 0) return;
|
||||
const addButton = headerRef.current?.querySelector<HTMLButtonElement>('[aria-label="Open a new tab"]');
|
||||
if (addButton) { event.preventDefault(); addButton.focus(); }
|
||||
}}
|
||||
>
|
||||
<p className="px-2 py-2 text-xs font-medium text-muted-foreground">Open tabs</p>
|
||||
<ul ref={listRef} aria-label="Open tabs" className="max-h-(--side-panel-mobile-tabs-max-height) overflow-y-auto overscroll-contain">
|
||||
{tabs.map((tab) => (
|
||||
<li key={tab.id} className={cn("flex items-center rounded-md", tab.id === activeTabId && "bg-muted")}>
|
||||
<button
|
||||
type="button"
|
||||
data-mobile-tab-select={tab.id}
|
||||
disabled={tab.disabled}
|
||||
aria-current={tab.id === activeTabId ? "true" : undefined}
|
||||
aria-label={tab.ariaLabel ?? tab.label}
|
||||
onKeyDown={(event) => moveFocus(event, tab.id)}
|
||||
onClick={() => { onActiveTabChange(tab.id); setOpen(false); }}
|
||||
className="flex min-h-(--sz-44px) min-w-0 flex-1 items-center gap-3 rounded-md px-2 py-3 text-left text-sm hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
|
||||
>
|
||||
<span aria-hidden className="shrink-0 text-muted-foreground [&_svg]:size-4">{tab.icon}</span>
|
||||
<span className="min-w-0 flex-1 break-words">{tab.label}</span>
|
||||
{tab.id === activeTabId ? <Check aria-hidden className="size-4 shrink-0" /> : null}
|
||||
</button>
|
||||
{tab.closable !== false ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-(--sz-44px) shrink-0"
|
||||
aria-label={`Close ${tab.label}`}
|
||||
disabled={tab.disabled}
|
||||
onClick={() => {
|
||||
// Focus the neighboring selector before removing the
|
||||
// close button, so keyboard users stay in the overview.
|
||||
const buttons = Array.from(listRef.current?.querySelectorAll<HTMLButtonElement>("[data-mobile-tab-select]:not(:disabled)") ?? []);
|
||||
const index = buttons.findIndex((button) => button.dataset.mobileTabSelect === tab.id);
|
||||
(buttons[index + 1] ?? buttons[index - 1])?.focus();
|
||||
onCloseTab(tab.id);
|
||||
if (tabs.length === 1) setOpen(false);
|
||||
}}
|
||||
><X aria-hidden /></Button>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{addControl}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ export { SidePanelFrame, SidePanelToggleButton, SidePanelWindowControls } from "
|
||||
export { SidePanelLauncher } from "./SidePanelLauncher";
|
||||
export { SidePanelTab } from "./SidePanelTab";
|
||||
export { SidePanelTabs } from "./SidePanelTabs";
|
||||
export { SidePanelMobileTabs } from "./SidePanelMobileTabs";
|
||||
export { useScrollbarWhileScrolling } from "./use-scrollbar-while-scrolling";
|
||||
export {
|
||||
normalizeSidePanelTabsState,
|
||||
|
||||
@@ -5,17 +5,21 @@ interface TaskChatComposerDockProps {
|
||||
children: ReactNode;
|
||||
mobile: boolean;
|
||||
streamlined: boolean;
|
||||
concealed?: boolean;
|
||||
}
|
||||
|
||||
/** The task thread and its Storybook phone previews share the same composer spacing. */
|
||||
export function TaskChatComposerDock({ children, mobile, streamlined }: TaskChatComposerDockProps) {
|
||||
export function TaskChatComposerDock({ children, mobile, streamlined, concealed = false }: TaskChatComposerDockProps) {
|
||||
return <div
|
||||
data-testid="task-chat-composer-dock"
|
||||
inert={concealed}
|
||||
aria-hidden={concealed || undefined}
|
||||
className={cn(
|
||||
"sticky flex max-w-(--tc-shell-max-w) flex-col gap-2",
|
||||
concealed && "invisible",
|
||||
mobile
|
||||
? cn(
|
||||
"bottom-(--tc-composer-bottom) z-20 w-auto px-1 pb-1 transition-[bottom] duration-200 ease-out",
|
||||
"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",
|
||||
|
||||
@@ -64,6 +64,8 @@ export const MOTION_TOKENS: MotionTokenDef[] = [
|
||||
{ name: "--motion-interstitial-dwell", group: "States", kind: "time", min: 0, max: 10000, step: 100 },
|
||||
{ name: "--motion-scroll-pill-enter", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
|
||||
{ name: "--motion-scroll-pill-exit", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
|
||||
{ name: "--motion-mobile-nav-enter", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
|
||||
{ name: "--motion-mobile-nav-exit", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
|
||||
{ name: "--motion-scrollbar-idle-delay", group: "States", kind: "time", min: 0, max: 2000, step: 10 },
|
||||
{ name: "--motion-pane-glide", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
|
||||
{ name: "--motion-side-panel-tab", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { taskChatScrollEntry } from "./scroll-navigation";
|
||||
|
||||
describe("document scroll navigation", () => {
|
||||
it("keeps the original reading entry across multiple document links", () => {
|
||||
const original = { key: "inbox-entry", hash: "#comment-2", pathname: "/PAP/issues/PAP-1", state: null };
|
||||
const first = { ...original, key: "plan-entry", hash: "#document-plan", state: { taskDocumentScrollEntry: taskChatScrollEntry(original) } };
|
||||
const second = { ...original, key: "qa-entry", hash: "#document-qa", state: { taskDocumentScrollEntry: taskChatScrollEntry(first) } };
|
||||
expect(taskChatScrollEntry(second)).toEqual({ key: "inbox-entry", hash: "#comment-2", pathname: original.pathname });
|
||||
expect(taskChatScrollEntry(first)).toEqual(taskChatScrollEntry(original));
|
||||
});
|
||||
it.each([null, {}, { taskDocumentScrollEntry: { key: 1, hash: null } }, { taskDocumentScrollEntry: { key: "old-task", hash: "", pathname: "/PAP/issues/PAP-2" } }])("uses normal history identity without a valid preserved entry for this task", (state) => {
|
||||
expect(taskChatScrollEntry({ key: "entry", hash: "#document-plan", pathname: "/PAP/issues/PAP-1", state })).toEqual({ key: "entry", hash: "#document-plan", pathname: "/PAP/issues/PAP-1" });
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,16 @@ import { createContext, useContext } from "react";
|
||||
export const TaskChatScrollNavigation = createContext<{ key: string; restore: boolean; hash: string } | null>(null);
|
||||
export const TaskChatScrollReady = createContext(true);
|
||||
|
||||
/** Document navigation opens a pane without starting a new thread reading. */
|
||||
export function taskChatScrollEntry(location: { key: string; hash: string; pathname: string; state: unknown }): { key: string; hash: string; pathname: string } {
|
||||
const saved = location.state && typeof location.state === "object" && "taskDocumentScrollEntry" in location.state
|
||||
? location.state.taskDocumentScrollEntry : null;
|
||||
if (saved && typeof saved === "object" && "key" in saved && "hash" in saved && "pathname" in saved && saved.pathname === location.pathname && typeof saved.key === "string" && typeof saved.hash === "string") {
|
||||
return { key: saved.key, hash: saved.hash, pathname: location.pathname };
|
||||
}
|
||||
return { key: location.key, hash: location.hash, pathname: location.pathname };
|
||||
}
|
||||
|
||||
// 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 }>();
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
ListTree,
|
||||
Plus,
|
||||
SlidersHorizontal,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { fileResourcesApi } from "@/api/file-resources";
|
||||
import { IssueProperties } from "@/components/IssueProperties";
|
||||
@@ -42,6 +43,7 @@ import {
|
||||
SidePanelLauncher,
|
||||
SidePanelToggleButton,
|
||||
SidePanelTabs,
|
||||
SidePanelMobileTabs,
|
||||
useScrollbarWhileScrolling,
|
||||
useSidePanelTabs,
|
||||
type SidePanelLauncherItem,
|
||||
@@ -97,6 +99,7 @@ export interface TaskSidePanelProps {
|
||||
onAddSubIssue?: () => void;
|
||||
onUpdate: (data: Record<string, unknown>) => void;
|
||||
inline?: boolean;
|
||||
mobile?: boolean;
|
||||
hasActiveRun?: boolean;
|
||||
externalObjects?: IssueExternalObjectGroup[];
|
||||
externalObjectsLoading?: boolean;
|
||||
@@ -241,6 +244,7 @@ export function TaskSidePanel({
|
||||
onAddSubIssue,
|
||||
onUpdate,
|
||||
inline = false,
|
||||
mobile = false,
|
||||
hasActiveRun = false,
|
||||
externalObjects,
|
||||
externalObjectsLoading,
|
||||
@@ -677,7 +681,9 @@ export function TaskSidePanel({
|
||||
size="icon-sm"
|
||||
className={cn(
|
||||
"shrink-0 text-muted-foreground hover:text-foreground focus-visible:text-foreground",
|
||||
streamlinedTabs
|
||||
mobile
|
||||
? "size-(--sz-44px) rounded-md"
|
||||
: streamlinedTabs
|
||||
? "h-(--side-panel-tab-height) w-(--side-panel-tab-height) rounded-md"
|
||||
: "h-(--side-panel-tab-height) w-(--side-panel-tab-height) rounded-(--side-panel-control-radius)",
|
||||
)}
|
||||
@@ -688,7 +694,15 @@ export function TaskSidePanel({
|
||||
)}
|
||||
/>
|
||||
);
|
||||
const tabStrip = (
|
||||
const tabStrip = mobile ? (
|
||||
<SidePanelMobileTabs
|
||||
tabs={visualTabs}
|
||||
activeTabId={controller.activeTabId}
|
||||
onActiveTabChange={selectTab}
|
||||
onCloseTab={closeTab}
|
||||
addControl={launcherControl}
|
||||
/>
|
||||
) : (
|
||||
<SidePanelTabs
|
||||
tabs={visualTabs}
|
||||
activeTabId={controller.activeTabId}
|
||||
@@ -807,7 +821,11 @@ export function TaskSidePanel({
|
||||
<div className="flex h-(--side-panel-header-height) shrink-0 items-center gap-1 px-2">
|
||||
{tabStrip}
|
||||
{onRequestClose ? (
|
||||
<SidePanelToggleButton open onToggle={onRequestClose} />
|
||||
mobile ? (
|
||||
<Button variant="ghost" size="icon" className="size-(--sz-44px) shrink-0" aria-label="Close side panel" onClick={onRequestClose}>
|
||||
<X aria-hidden />
|
||||
</Button>
|
||||
) : <SidePanelToggleButton open onToggle={onRequestClose} />
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { MobileNavScrollTracker } from "../lib/mobile-nav-scroll";
|
||||
|
||||
export function useMobileNavVisibility(enabled: boolean, pathname: string): boolean {
|
||||
const [visible, setVisible] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setVisible(true);
|
||||
if (!enabled) return;
|
||||
|
||||
const styles = getComputedStyle(document.documentElement);
|
||||
const distance = (token: string) => Number.parseFloat(styles.getPropertyValue(token)) || 0;
|
||||
const scrollBounds = () => {
|
||||
const root = document.scrollingElement ?? document.documentElement;
|
||||
return {
|
||||
top: window.scrollY || root.scrollTop || 0,
|
||||
max: Math.max(0, root.scrollHeight - root.clientHeight),
|
||||
};
|
||||
};
|
||||
const initial = scrollBounds();
|
||||
const tracker = new MobileNavScrollTracker({
|
||||
topZone: distance("--mobile-nav-scroll-top-zone"),
|
||||
hideDistance: distance("--mobile-nav-scroll-hide-distance"),
|
||||
showDistance: distance("--mobile-nav-scroll-show-distance"),
|
||||
}, initial.top, initial.max);
|
||||
let frame: number | null = null;
|
||||
const onScroll = () => {
|
||||
if (frame !== null) return;
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = null;
|
||||
const { top, max } = scrollBounds();
|
||||
const previous = tracker.visible;
|
||||
const next = tracker.update(top, max);
|
||||
if (next !== previous) setVisible(next);
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => {
|
||||
window.removeEventListener("scroll", onScroll);
|
||||
if (frame !== null) cancelAnimationFrame(frame);
|
||||
};
|
||||
}, [enabled, pathname]);
|
||||
|
||||
return visible;
|
||||
}
|
||||
@@ -260,6 +260,13 @@
|
||||
--mobile-entity-picker-visual-viewport-height: 100dvh;
|
||||
--mobile-entity-picker-backdrop: color-mix(in oklab, var(--foreground) 50%, transparent);
|
||||
|
||||
/* Deliberate scroll distances (CSS pixels) and shared nav/composer motion. */
|
||||
--mobile-nav-scroll-top-zone: 24px;
|
||||
--mobile-nav-scroll-hide-distance: 32px;
|
||||
--mobile-nav-scroll-show-distance: 16px;
|
||||
--motion-mobile-nav-enter: var(--motion-duration-slow);
|
||||
--motion-mobile-nav-exit: var(--motion-duration-base);
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────
|
||||
Motion tokens — chat-style task thread (default; classic legacy view sits
|
||||
behind enableClassicTaskInterface).
|
||||
@@ -315,6 +322,8 @@
|
||||
task, project, agent, and future hosts render the same Codex-like chrome. */
|
||||
--side-panel-header-height: var(--sz-60px);
|
||||
--side-panel-tab-height: var(--sz-30px);
|
||||
--side-panel-mobile-tabs-width: min(calc(100vw - var(--spacing) * 4), 24rem);
|
||||
--side-panel-mobile-tabs-max-height: 60dvh;
|
||||
--side-panel-streamlined-tab-min-width: var(--sz-72px);
|
||||
--side-panel-streamlined-tab-max-width: var(--sz-160px);
|
||||
--side-panel-tab-edge-fade-width: var(--sz-32px);
|
||||
@@ -559,6 +568,16 @@
|
||||
label {
|
||||
touch-action: manipulation;
|
||||
}
|
||||
@media (pointer: coarse) {
|
||||
input,
|
||||
select,
|
||||
textarea,
|
||||
[contenteditable=""],
|
||||
[contenteditable="true"],
|
||||
[contenteditable="plaintext-only"] {
|
||||
font-size: max(var(--text-base), 1em);
|
||||
}
|
||||
}
|
||||
/* Let font-mono (utilities layer) override for monospace editors */
|
||||
.paperclip-mdxeditor [class*="_placeholder_"],
|
||||
.paperclip-mdxeditor-content {
|
||||
@@ -592,6 +611,30 @@ select:where(:not([multiple]):not(.appearance-none)):where(:not([size]), [size="
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
/* iOS Safari zooms when focusing text smaller than 16px. Floor the small
|
||||
typography tokens on editable controls, preserving larger title sizes. */
|
||||
input,
|
||||
select,
|
||||
textarea,
|
||||
[contenteditable=""],
|
||||
[contenteditable="true"],
|
||||
[contenteditable="plaintext-only"] {
|
||||
--text-nano: var(--text-base);
|
||||
--text-micro: var(--text-base);
|
||||
--text-xs: var(--text-base);
|
||||
--text-compact: var(--text-base);
|
||||
--text-sm: var(--text-base);
|
||||
}
|
||||
|
||||
/* Rich-text editors can inherit a smaller size from their surrounding
|
||||
document styles rather than using a typography utility. Keep this more
|
||||
specific than the editor's content class. */
|
||||
html [contenteditable=""],
|
||||
html [contenteditable="true"],
|
||||
html [contenteditable="plaintext-only"] {
|
||||
font-size: max(var(--text-base), 1em);
|
||||
}
|
||||
|
||||
button,
|
||||
[role="button"],
|
||||
input,
|
||||
@@ -623,6 +666,32 @@ select:where(:not([multiple]):not(.appearance-none)):where(:not([size]), [size="
|
||||
}
|
||||
}
|
||||
|
||||
/* Keep the nav on the compositor; the composer follows the same easing and
|
||||
duration while the page retains its reserved bottom space. */
|
||||
.mobile-bottom-nav {
|
||||
transform: translateY(0);
|
||||
transition: transform var(--motion-mobile-nav-enter) var(--motion-ease-out-expo);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.mobile-bottom-nav[data-visible="false"] {
|
||||
transform: translateY(100%);
|
||||
transition-duration: var(--motion-mobile-nav-exit);
|
||||
transition-timing-function: var(--motion-ease-standard);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mobile-nav-composer {
|
||||
transition: bottom var(--mobile-nav-motion-duration, var(--motion-mobile-nav-enter)) var(--mobile-nav-motion-ease, var(--motion-ease-out-expo));
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:root {
|
||||
--motion-mobile-nav-enter: 0ms;
|
||||
--motion-mobile-nav-exit: 0ms;
|
||||
}
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
@@ -932,6 +1001,7 @@ select:where(:not([multiple]):not(.appearance-none)):where(:not([size]), [size="
|
||||
.tc-enter-marker { animation: tc-fade-in var(--motion-marker-enter) var(--motion-ease-out) both; }
|
||||
.tc-enter-tool { animation: tc-fade-rise var(--motion-tool-enter) var(--motion-ease-out-expo) both; }
|
||||
.tc-enter-status { animation: tc-status-in var(--motion-status-enter) var(--motion-ease-out-expo) both; }
|
||||
.mobile-task-side-panel { animation-duration: var(--motion-duration-fast); }
|
||||
.tc-enter-cot-line { animation: tc-fade-rise var(--motion-duration-fast) var(--motion-ease-standard) both; }
|
||||
.tc-notice-chevron { transition-duration: var(--motion-duration-fast); }
|
||||
.tc-reveal-diff { animation: tc-fade-in var(--motion-diff-reveal) var(--motion-ease-standard) both; }
|
||||
@@ -3030,6 +3100,25 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* Concealed mobile history must not grow the document between responses.
|
||||
Restore normal flow and position the finished conversation before paint. */
|
||||
.task-chat-history-pending {
|
||||
position: relative;
|
||||
height: var(--tc-thread-max-h);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.task-chat-history-pending [data-testid="task-chat-history-content"] {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.task-chat-history-pending > [data-testid="task-chat-composer-dock"] {
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
}
|
||||
|
||||
/* Task recovery panel uses the shared spacing, radius, and type scales. */
|
||||
.execution-blocker-notice {
|
||||
gap: calc(var(--spacing) * 2);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveIssueDocumentDeepLink } from "./issue-document-deep-link";
|
||||
import { resolveIssueDocumentDeepLink, sameIssueDocumentHash } from "./issue-document-deep-link";
|
||||
|
||||
describe("resolveIssueDocumentDeepLink", () => {
|
||||
it("preserves continuation-summary routing", () => {
|
||||
@@ -47,3 +47,14 @@ describe("resolveIssueDocumentDeepLink", () => {
|
||||
expect(resolveIssueDocumentDeepLink("#document-%E0%A4%A")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sameIssueDocumentHash", () => {
|
||||
const current = new URL("https://paperclip.example/PAP/issues/PAP-1");
|
||||
const references = ["PAP-1", "issue-uuid"];
|
||||
it.each(["#document-plan", "/PAP/issues/PAP-1#document-plan", "/PAP/issues/issue-uuid#document-plan", "/issues/issue-uuid#document-plan"])("opens a current-task document in place: %s", (href) => {
|
||||
expect(sameIssueDocumentHash(href, current, references)).toBe("#document-plan");
|
||||
});
|
||||
it.each(["https://other.example/PAP/issues/PAP-1#document-plan", "/OTHER/issues/issue-uuid#document-plan", "/PAP/issues/PAP-2#document-plan", "/PAP/issues/PAP-1?file=other#document-plan", "/PAP/issues/PAP-1#comment-1", "/PAP/issues/%E0%A4%A#document-plan"])("leaves other navigation alone: %s", (href) => {
|
||||
expect(sameIssueDocumentHash(href, current, references)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -27,3 +27,19 @@ export function resolveIssueDocumentDeepLink(hash: string): IssueDocumentDeepLin
|
||||
}
|
||||
return { kind: "properties-pane", tab: "document", documentKey: target.documentKey, maximize };
|
||||
}
|
||||
|
||||
/** Resolve document links to this task, including its UUID/identifier aliases. */
|
||||
export function sameIssueDocumentHash(href: string, currentUrl: URL, issueReferences: string[]): string | null {
|
||||
let target: URL;
|
||||
try { target = new URL(href, currentUrl); } catch { return null; }
|
||||
if (target.origin !== currentUrl.origin || target.search !== currentUrl.search || !resolveIssueDocumentDeepLink(target.hash)) return null;
|
||||
if (target.pathname === currentUrl.pathname) return target.hash;
|
||||
const route = /^(?:\/([^/]+))?\/issues\/([^/]+)\/?$/;
|
||||
const current = currentUrl.pathname.match(route);
|
||||
const next = target.pathname.match(route);
|
||||
if (!current || !next || (next[1] && next[1] !== current[1])) return null;
|
||||
try {
|
||||
const reference = decodeURIComponent(next[2]!).toLowerCase();
|
||||
return issueReferences.some((id) => id.toLowerCase() === reference) ? target.hash : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { MobileNavScrollTracker } from "./mobile-nav-scroll";
|
||||
|
||||
function tracker(top = 0) {
|
||||
return new MobileNavScrollTracker({ topZone: 24, hideDistance: 32, showDistance: 16 }, top, 1000);
|
||||
}
|
||||
|
||||
describe("mobile navigation scroll response", () => {
|
||||
it("hides after deliberate downward movement, including slow scrolling", () => {
|
||||
const nav = tracker(100);
|
||||
for (let top = 101; top < 132; top++) expect(nav.update(top, 1000)).toBe(true);
|
||||
expect(nav.update(132, 1000)).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores small reversals and reveals after a deliberate upward scroll", () => {
|
||||
const nav = tracker(100);
|
||||
expect(nav.update(200, 1000)).toBe(false);
|
||||
expect(nav.update(195, 1000)).toBe(false);
|
||||
expect(nav.update(198, 1000)).toBe(false);
|
||||
expect(nav.update(183, 1000)).toBe(false);
|
||||
expect(nav.update(182, 1000)).toBe(true);
|
||||
expect(nav.update(187, 1000)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps navigation available near the top and through top rubber-banding", () => {
|
||||
const nav = tracker();
|
||||
expect(nav.update(-40, 1000)).toBe(true);
|
||||
expect(nav.update(24, 1000)).toBe(true);
|
||||
expect(nav.update(100, 1000)).toBe(false);
|
||||
expect(nav.update(20, 1000)).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the same top-zone threshold for sparse and frequent events", () => {
|
||||
const slow = tracker();
|
||||
const fast = tracker();
|
||||
for (let top = 1; top <= 55; top++) expect(slow.update(top, 1000)).toBe(true);
|
||||
expect(fast.update(55, 1000)).toBe(true);
|
||||
expect(slow.update(56, 1000)).toBe(false);
|
||||
expect(fast.update(56, 1000)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not reveal when bottom overscroll rebounds", () => {
|
||||
const nav = tracker(900);
|
||||
expect(nav.update(1000, 1000)).toBe(false);
|
||||
expect(nav.update(1040, 1000)).toBe(false);
|
||||
expect(nav.update(1000, 1000)).toBe(false);
|
||||
expect(nav.update(984, 1000)).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores scroll corrections caused by changing document bounds", () => {
|
||||
const nav = tracker(900);
|
||||
expect(nav.update(1000, 1000)).toBe(false);
|
||||
expect(nav.update(940, 940)).toBe(false);
|
||||
expect(nav.update(924, 940)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
interface MobileNavScrollOptions {
|
||||
topZone: number;
|
||||
hideDistance: number;
|
||||
showDistance: number;
|
||||
}
|
||||
|
||||
/** Accumulate deliberate scrolling, independent of scroll-event frequency. */
|
||||
export class MobileNavScrollTracker {
|
||||
visible = true;
|
||||
private direction = 0;
|
||||
private distance = 0;
|
||||
|
||||
constructor(
|
||||
private options: MobileNavScrollOptions,
|
||||
private lastTop: number,
|
||||
private lastMaxTop: number,
|
||||
) {}
|
||||
|
||||
update(scrollTop: number, maxTop: number): boolean {
|
||||
// Safari rubber-banding must not count as a change in scroll direction.
|
||||
const top = Math.max(0, Math.min(scrollTop, maxTop));
|
||||
const delta = top - Math.max(this.lastTop, this.options.topZone);
|
||||
const boundsChanged = maxTop !== this.lastMaxTop;
|
||||
this.lastTop = top;
|
||||
this.lastMaxTop = maxTop;
|
||||
|
||||
if (top <= this.options.topZone) {
|
||||
this.visible = true;
|
||||
this.direction = 0;
|
||||
this.distance = 0;
|
||||
} else if (boundsChanged) {
|
||||
// Content loading or the keyboard resizing the viewport is not a swipe.
|
||||
this.direction = 0;
|
||||
this.distance = 0;
|
||||
} else if (delta !== 0) {
|
||||
const direction = Math.sign(delta);
|
||||
if (direction !== this.direction) this.distance = 0;
|
||||
this.direction = direction;
|
||||
this.distance += Math.abs(delta);
|
||||
const threshold = direction > 0 ? this.options.hideDistance : this.options.showDistance;
|
||||
if (this.distance >= threshold) {
|
||||
this.visible = direction < 0;
|
||||
this.distance = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return this.visible;
|
||||
}
|
||||
}
|
||||
@@ -121,6 +121,7 @@ const mockInstanceSettingsApi = vi.hoisted(() => ({
|
||||
|
||||
const mockNavigate = vi.hoisted(() => vi.fn());
|
||||
const mockLocation = vi.hoisted(() => ({
|
||||
key: "task-entry",
|
||||
pathname: "/issues/PAP-1",
|
||||
search: "",
|
||||
hash: "",
|
||||
@@ -1582,7 +1583,7 @@ describe("IssueDetail", () => {
|
||||
expect(windowOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["comments", "description", "empty"])("reveals %s without waiting for supporting history unless the thread is empty", async (content) => {
|
||||
it.each(["comments", "description", "empty"])("coordinates %s with the initial cards and supporting history", async (content) => {
|
||||
const history = createDeferred<[]>();
|
||||
mockIssuesApi.get.mockResolvedValue(createIssue({
|
||||
description: content === "description" ? "Saved task description" : null,
|
||||
@@ -1600,7 +1601,7 @@ describe("IssueDetail", () => {
|
||||
});
|
||||
await waitForAssertion(() => {
|
||||
expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0]).toMatchObject({
|
||||
initialHistoryPending: content === "empty",
|
||||
initialHistoryPending: true,
|
||||
});
|
||||
});
|
||||
// Resolving metadata fills the same thread rather than replacing its content.
|
||||
@@ -2101,6 +2102,49 @@ describe("IssueDetail", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("routes the first document click through the mounted task, including UUID aliases", async () => {
|
||||
mockLocation.pathname = "/PAP/issues/PAP-1";
|
||||
mockLocation.state = { from: "inbox" };
|
||||
mockIssuesApi.get.mockResolvedValue(createIssue());
|
||||
await act(async () => {
|
||||
root.render(<QueryClientProvider client={queryClient}><IssueDetail /></QueryClientProvider>);
|
||||
});
|
||||
await waitForAssertion(() => expect(queryClient.getQueryData(queryKeys.issues.detail("PAP-1"))).toBeDefined());
|
||||
const getCalls = mockIssuesApi.get.mock.calls.length;
|
||||
const link = document.createElement("a");
|
||||
link.href = "/PAP/issues/issue-1#document-plan";
|
||||
const closePreview = vi.fn((event: Event) => expect(event.defaultPrevented).toBe(true));
|
||||
link.addEventListener("click", closePreview);
|
||||
container.appendChild(link);
|
||||
const event = new MouseEvent("click", { bubbles: true, cancelable: true });
|
||||
await act(async () => { link.dispatchEvent(event); });
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(closePreview).toHaveBeenCalledTimes(1);
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/PAP/issues/PAP-1#document-plan", {
|
||||
preventScrollReset: true,
|
||||
state: { from: "inbox", taskDocumentScrollEntry: { key: "task-entry", hash: "", pathname: "/PAP/issues/PAP-1" } },
|
||||
});
|
||||
expect(mockIssuesApi.get).toHaveBeenCalledTimes(getCalls);
|
||||
});
|
||||
|
||||
it.each(["modifier", "new-tab", "download"])("preserves native document-link behavior for %s", async (kind) => {
|
||||
mockIssuesApi.get.mockResolvedValue(createIssue());
|
||||
await act(async () => {
|
||||
root.render(<QueryClientProvider client={queryClient}><IssueDetail /></QueryClientProvider>);
|
||||
});
|
||||
const link = document.createElement("a");
|
||||
link.href = "#document-plan";
|
||||
if (kind === "new-tab") link.target = "_blank";
|
||||
if (kind === "download") link.download = "plan.md";
|
||||
container.appendChild(link);
|
||||
const event = new MouseEvent("click", { bubbles: true, cancelable: true, metaKey: kind === "modifier" });
|
||||
// Keep jsdom from following the native link after observing our handler.
|
||||
link.addEventListener("click", () => expect(event.defaultPrevented).toBe(false), { once: true });
|
||||
link.addEventListener("click", (click) => click.preventDefault(), { once: true });
|
||||
await act(async () => { link.dispatchEvent(event); });
|
||||
expect(mockNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("replays document routing when the current same-page hash is clicked again", async () => {
|
||||
mockLocation.hash = "#document-qa-evidence";
|
||||
mockIssuesApi.get.mockResolvedValue(createIssue());
|
||||
@@ -2335,7 +2379,7 @@ describe("IssueDetail", () => {
|
||||
'[data-testid="mobile-task-side-panel"]',
|
||||
);
|
||||
expect(panel).not.toBeNull();
|
||||
expect(panel?.className).toContain("max-h-(--sz-85dvh)");
|
||||
expect(panel?.className).toContain("inset-0 h-dvh max-h-dvh");
|
||||
expect(panel?.className).toContain("w-full");
|
||||
expect(panel?.className).toContain("max-w-none");
|
||||
expect(panel?.textContent).toContain("Task side panel");
|
||||
|
||||
@@ -14,7 +14,7 @@ import type { TaskComposerPause } from "../components/task-chat/TaskChatPausedTa
|
||||
import { TaskDetailTasksPanel } from "@/components/task-detail/TaskDetailTasksPanel";
|
||||
import { EmailThreadProvider } from "../components/EmailMessageCard";
|
||||
import { EmailTaskActivity } from "../components/EmailTaskActivity";
|
||||
import { TaskChatScrollNavigation } from "@/components/task-chat/scroll-navigation";
|
||||
import { TaskChatScrollNavigation, taskChatScrollEntry } from "@/components/task-chat/scroll-navigation";
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
@@ -300,7 +300,7 @@ import {
|
||||
buildAnsweredQuestionsDeliveryText,
|
||||
buildIssueThreadInteractionSummary,
|
||||
} from "../lib/issue-thread-interactions";
|
||||
import { resolveIssueDocumentDeepLink } from "../lib/issue-document-deep-link";
|
||||
import { resolveIssueDocumentDeepLink, sameIssueDocumentHash } from "../lib/issue-document-deep-link";
|
||||
import {
|
||||
buildIssueSiblingNavigation,
|
||||
shouldRenderRichSubIssuesSection,
|
||||
@@ -2414,9 +2414,8 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
) : (
|
||||
<TaskChatScrollNavigation.Provider
|
||||
value={{
|
||||
key: scrollLocation.key,
|
||||
...taskChatScrollEntry(scrollLocation),
|
||||
restore: scrollNavigationType === "POP",
|
||||
hash: scrollLocation.hash,
|
||||
}}
|
||||
>
|
||||
<EmailThreadProvider companyId={companyId} issueId={issueId}>
|
||||
@@ -2430,15 +2429,13 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
initialHistoryPending={!!issueId && (
|
||||
initialHistoryPending ||
|
||||
commentsInitialLoading ||
|
||||
// Saved replies and the description can render while supporting
|
||||
// history loads. Only an otherwise empty thread needs that data
|
||||
// before deciding whether it has anything to show.
|
||||
((classicTaskInterfaceEnabled || (commentsForThread.length === 0 && !issueBrief?.description)) && (
|
||||
initialMetadataPending ||
|
||||
activityPending ||
|
||||
linkedRunsPending ||
|
||||
!runtimeSelectionKnown
|
||||
)))
|
||||
// These responses add rows or change the composer takeover.
|
||||
// Coordinate the first reveal so each response does not move a
|
||||
// conversation the reader has already started looking at.
|
||||
initialMetadataPending ||
|
||||
activityPending ||
|
||||
linkedRunsPending ||
|
||||
!runtimeSelectionKnown)
|
||||
}
|
||||
initialHistoryError={
|
||||
initialHistoryError ||
|
||||
@@ -6076,12 +6073,12 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
[clearPanelMaximizeRequest],
|
||||
);
|
||||
|
||||
// React Router does not emit a location update when the user clicks a link
|
||||
// whose hash is already current. Capture that repeated intent so a manually
|
||||
// collapsed document reopens and scrolls back into view.
|
||||
// Keep first and repeated document clicks inside the mounted task and its
|
||||
// query cache. Native anchors reset thread scroll or reload alias URLs.
|
||||
useEffect(() => {
|
||||
const handleSameHashDocumentClick = (event: MouseEvent) => {
|
||||
const handleDocumentClick = (event: MouseEvent) => {
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.button !== 0 ||
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
@@ -6092,31 +6089,46 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) return;
|
||||
const anchor = target.closest<HTMLAnchorElement>("a[href]");
|
||||
if (!anchor) return;
|
||||
if (!anchor || anchor.hasAttribute("download") || (anchor.target && anchor.target !== "_self")) return;
|
||||
const rawHref = anchor.getAttribute("href");
|
||||
if (!rawHref) return;
|
||||
|
||||
let targetUrl: URL;
|
||||
try {
|
||||
targetUrl = new URL(rawHref, window.location.href);
|
||||
} catch {
|
||||
return;
|
||||
const currentUrl = new URL(`${location.pathname}${location.search}${location.hash}`, window.location.origin);
|
||||
const hash = sameIssueDocumentHash(rawHref, currentUrl, [issue?.id, issue?.identifier, issueId].filter((id): id is string => !!id));
|
||||
if (!hash) return;
|
||||
const route = resolveIssueDocumentDeepLink(hash);
|
||||
if (route?.kind === "properties-pane" && (!taskInterfaceSettingsLoaded || !taskChatShellEnabled)) return;
|
||||
event.preventDefault();
|
||||
// Let link handlers close any hovered task preview. Router links and
|
||||
// popover triggers honor defaultPrevented and skip their navigation.
|
||||
if (hash === location.hash) {
|
||||
routeIssueDocumentDeepLink(hash);
|
||||
} else {
|
||||
navigate(`${location.pathname}${location.search}${hash}`, {
|
||||
preventScrollReset: true,
|
||||
state: {
|
||||
...(location.state && typeof location.state === "object" ? location.state : {}),
|
||||
taskDocumentScrollEntry: taskChatScrollEntry(location),
|
||||
},
|
||||
});
|
||||
}
|
||||
const sameIssue =
|
||||
rawHref.startsWith("#") ||
|
||||
(targetUrl.pathname === location.pathname &&
|
||||
targetUrl.search === location.search);
|
||||
if (!sameIssue || targetUrl.hash !== location.hash) return;
|
||||
routeIssueDocumentDeepLink(targetUrl.hash);
|
||||
};
|
||||
|
||||
document.addEventListener("click", handleSameHashDocumentClick, true);
|
||||
document.addEventListener("click", handleDocumentClick, true);
|
||||
return () =>
|
||||
document.removeEventListener("click", handleSameHashDocumentClick, true);
|
||||
document.removeEventListener("click", handleDocumentClick, true);
|
||||
}, [
|
||||
issue?.id,
|
||||
issue?.identifier,
|
||||
issueId,
|
||||
navigate,
|
||||
location.hash,
|
||||
location.key,
|
||||
location.pathname,
|
||||
location.search,
|
||||
location.state,
|
||||
taskChatShellEnabled,
|
||||
taskInterfaceSettingsLoaded,
|
||||
routeIssueDocumentDeepLink,
|
||||
]);
|
||||
|
||||
@@ -8262,7 +8274,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
showCloseButton={!taskChatShellEnabled}
|
||||
className={cn(
|
||||
taskChatShellEnabled
|
||||
? "h-(--sz-85dvh) max-h-(--sz-85dvh) w-full max-w-none gap-0 p-0 pb-(--sz-safe-bottom)"
|
||||
? "mobile-task-side-panel inset-0 h-dvh max-h-dvh w-full max-w-none gap-0 border-0 p-0 pt-(--sz-safe-top) pb-(--sz-safe-bottom)"
|
||||
: documentDeepLink?.documentKey === "plan"
|
||||
? "inset-0 h-dvh w-screen max-w-none gap-0 border-0 p-0 sm:max-w-none"
|
||||
: "max-h-(--sz-85dvh) pb-(--sz-safe-bottom)",
|
||||
@@ -8295,6 +8307,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
onAddSubIssue={openNewSubIssue}
|
||||
onUpdate={(data) => updateIssue.mutate(data)}
|
||||
inline
|
||||
mobile
|
||||
hasActiveRun={resolvedHasActiveRun}
|
||||
externalObjects={
|
||||
externalObjectsState.isEnabled
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { userEvent, within } from "storybook/test";
|
||||
import { Box, FileText, Lightbulb, Plus, SlidersHorizontal, X } from "lucide-react";
|
||||
import { SidePanelLauncher, SidePanelMobileTabs, useSidePanelTabs, type SidePanelTabRecord } from "@/components/side-panel";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const basicTabs: SidePanelTabRecord[] = [
|
||||
{ id: "properties", type: "view", label: "Properties", payload: {}, contentMode: "padded" },
|
||||
{ id: "plan", type: "document", label: "Plan", payload: {}, contentMode: "prose" },
|
||||
{ id: "artifacts", type: "view", label: "Artifacts", payload: {}, contentMode: "padded" },
|
||||
];
|
||||
const longTitle = "Bookmarked post formats and 14 model-harness tweets";
|
||||
const documentTabs = [
|
||||
...basicTabs,
|
||||
{ id: "research", type: "document", label: longTitle, payload: {}, contentMode: "prose" as const },
|
||||
{ id: "qa", type: "document", label: "Release verification and mobile accessibility evidence", payload: {}, contentMode: "prose" as const },
|
||||
];
|
||||
|
||||
function Prototype({ initialTabs = basicTabs, activeTabId = "plan" }: { initialTabs?: SidePanelTabRecord[]; activeTabId?: string }) {
|
||||
const controller = useSidePanelTabs({ initialState: { tabs: initialTabs, activeTabId } });
|
||||
const [closed, setClosed] = useState(false);
|
||||
const active = controller.tabs.find((tab) => tab.id === controller.activeTabId);
|
||||
if (closed) return <div className="p-6"><Button onClick={() => setClosed(false)}>Reopen task panel</Button></div>;
|
||||
return (
|
||||
<div className="mx-auto flex h-dvh w-full max-w-sm flex-col bg-background text-foreground">
|
||||
<div className="flex h-(--side-panel-header-height) shrink-0 items-center gap-1 border-b px-2">
|
||||
<SidePanelMobileTabs
|
||||
tabs={controller.tabs.map((tab) => ({ ...tab, icon: tab.id === "properties" ? <SlidersHorizontal /> : tab.id === "plan" ? <Lightbulb /> : tab.id === "artifacts" ? <Box /> : <FileText /> }))}
|
||||
activeTabId={controller.activeTabId}
|
||||
onActiveTabChange={controller.selectTab}
|
||||
onCloseTab={controller.closeTab}
|
||||
addControl={<SidePanelLauncher presentation="popover" sections={[{ id: "documents", label: "Task tabs", items: documentTabs.map((tab) => ({ id: tab.id, label: tab.label, alreadyOpen: controller.tabs.some((open) => open.id === tab.id) })) }]} onSelect={(item) => controller.openTab(documentTabs.find((tab) => tab.id === item.id)!)} trigger={<Button variant="ghost" size="icon" className="size-(--sz-44px) shrink-0" aria-label="Open a new tab"><Plus aria-hidden /></Button>} />}
|
||||
/>
|
||||
<Button variant="ghost" size="icon" className="size-(--sz-44px) shrink-0" aria-label="Close side panel" onClick={() => setClosed(true)}><X aria-hidden /></Button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto p-6" role={active ? "tabpanel" : undefined} aria-labelledby={active ? `side-panel-tab-${active.id}` : undefined}>
|
||||
{active ? <>
|
||||
<h1 className="text-xl font-semibold">{active.label}</h1>
|
||||
<p className="mt-2 text-xs text-muted-foreground">Revision 4 · Updated today</p>
|
||||
<h2 className="mt-8 text-lg font-semibold">A readable mobile task panel</h2>
|
||||
<p className="mt-3 text-sm leading-relaxed">Tap the title to see all open tabs. Full document names wrap in the vertical list. The check marks your current tab, and each row has its own close button.</p>
|
||||
<p className="mt-4 text-sm leading-relaxed">The top-right X returns to the task feed. Use + to open another task document. Closing an inactive tab keeps this document selected.</p>
|
||||
</> : <p className="text-sm text-muted-foreground">All tabs are closed. Use + to open a tab.</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Prototypes/Task detail/Mobile tabs",
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
docs: { description: { component: "A task toolbar with a vertical open-tab overview. The current document stays readable; titles wrap in the overview, selection is explicit, close actions stay visible, and controls have 44px touch targets. Inspired by Apple’s toolbar and safe-area guidance: https://developer.apple.com/design/human-interface-guidelines/toolbars and https://developer.apple.com/design/human-interface-guidelines/layout." } },
|
||||
},
|
||||
globals: { viewport: { value: "mobile", isRotated: false }, theme: "light" },
|
||||
} satisfies Meta;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const ThreeTabs: Story = { render: () => <Prototype /> };
|
||||
export const LongDocumentTitles: Story = {
|
||||
render: () => <Prototype initialTabs={documentTabs} activeTabId="research" />,
|
||||
play: async () => { await userEvent.click(await within(document.body).findByRole("button", { name: "Switch tabs, 5 open" })); },
|
||||
};
|
||||
export const ManyTabs: Story = {
|
||||
render: () => <Prototype initialTabs={[...documentTabs, ...Array.from({ length: 10 }, (_, index) => ({ id: `output-${index}`, type: "document", label: `Research output ${index + 1}: decisions, evidence, and next steps`, payload: {} }))]} activeTabId="research" />,
|
||||
play: async () => { await userEvent.click(await within(document.body).findByRole("button", { name: "Switch tabs, 15 open" })); },
|
||||
};
|
||||
export const LastTab: Story = { render: () => <Prototype initialTabs={[basicTabs[1]!]} /> };
|
||||
Reference in new issue
Block a user