Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .agents/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,7 @@ src/
│ │ ├── page-search.tsx # Full-text search input + results dropdown (debounced, 300ms)
│ │ ├── favorites-section.tsx # Per-user favorites list + useFavorite hook for toggle
│ │ ├── focus-mode-hint.tsx # Floating hint shown when sidebar is hidden in focus mode
│ │ ├── mobile-header-title.tsx # Observes document.title and displays current page title in mobile header
│ │ ├── page-tree.tsx # Orchestrator: data fetching, state, delete dialog (uses extracted sub-modules)
│ │ ├── page-tree-item.tsx # Single tree node rendering + context menu
│ │ ├── page-tree-drag-layer.ts # usePageTreeDrag hook: drag-and-drop state + handlers
Expand Down
11 changes: 6 additions & 5 deletions .agents/quality.md

Large diffs are not rendered by default.

13 changes: 4 additions & 9 deletions .github/workflows/deploy-migrations.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,10 @@ jobs:
- uses: actions/checkout@v6
if: steps.check-secrets.outputs.skip == 'false'

# supabase/setup-cli@v1 still targets node20; keep FORCE_JAVASCRIPT_ACTIONS_TO_NODE24 until they ship a node24 version
# Pinned to 2.98.2: v2.99.0 changed asset naming (added version in filename),
# breaking setup-cli@v1's download URL construction for `latest`.
- uses: supabase/setup-cli@v1
- uses: supabase/setup-cli@v2
if: steps.check-secrets.outputs.skip == 'false'
with:
version: 2.98.2
version: 2.99.0

- name: Link staging project
if: steps.check-secrets.outputs.skip == 'false'
Expand Down Expand Up @@ -77,11 +74,9 @@ jobs:
steps:
- uses: actions/checkout@v6

# supabase/setup-cli@v1 still targets node20; keep FORCE_JAVASCRIPT_ACTIONS_TO_NODE24 until they ship a node24 version
# Pinned to 2.98.2: v2.99.0 changed asset naming, breaking setup-cli@v1.
- uses: supabase/setup-cli@v1
- uses: supabase/setup-cli@v2
with:
version: 2.98.2
version: 2.99.0

- name: Link Supabase project
run: supabase link --project-ref $SUPABASE_PROJECT_ID
Expand Down
171 changes: 171 additions & 0 deletions e2e/mobile-header-title.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { test, expect } from "./fixtures/auth";

const MOBILE_VIEWPORT = { width: 375, height: 667 };
const DESKTOP_VIEWPORT = { width: 1280, height: 720 };

test.describe("Mobile header page title", () => {
test("shows workspace name on workspace home at mobile viewport", async ({
authenticatedPage: page,
}) => {
await page.setViewportSize(MOBILE_VIEWPORT);
await page.goto("/");

// Wait for the app shell to load
const toggleButton = page.getByTestId("as-sidebar-toggle");
await expect(toggleButton).toBeVisible({ timeout: 15_000 });

// The mobile header title should display the workspace name
const titleEl = page.getByTestId("mobile-header-title");
await expect(titleEl).toBeVisible({ timeout: 10_000 });

// The title should be non-empty (workspace name)
const titleText = await titleEl.textContent();
expect(titleText).toBeTruthy();
expect(titleText!.trim().length).toBeGreaterThan(0);
});

test("updates title when navigating to a page via sidebar", async ({
authenticatedPage: page,
}) => {
// Start at desktop to get workspace slug, then switch to mobile
await page.goto("/");
await page.waitForURL((url) => !url.pathname.includes("/sign-in"), {
timeout: 15_000,
});

// Get workspace slug from URL
const currentUrl = new URL(page.url());
const pathParts = currentUrl.pathname.split("/").filter(Boolean);
const workspaceSlug = pathParts[0];
if (!workspaceSlug) {
test.skip(true, "Could not determine workspace slug");
return;
}

// Switch to mobile viewport
await page.setViewportSize(MOBILE_VIEWPORT);

// Open sidebar
const toggleButton = page.getByTestId("as-sidebar-toggle");
await expect(toggleButton).toBeVisible({ timeout: 10_000 });
await toggleButton.click();

const sheetContent = page.locator('[data-slot="sheet-content"]');
await expect(sheetContent).toBeVisible({ timeout: 5_000 });

// Wait for the page tree to load
const treeItem = sheetContent.locator('[role="treeitem"]').first();
try {
await expect(treeItem).toBeVisible({ timeout: 10_000 });
} catch {
// No pages — create one via sidebar
const newPageBtn = sheetContent.getByTestId("sb-new-page-btn");
if ((await newPageBtn.count()) > 0) {
await newPageBtn.click();
await page.waitForURL(
(url) => url.pathname.split("/").filter(Boolean).length >= 2,
{ timeout: 15_000 },
);
// After navigation, the mobile header title should be visible
const titleEl = page.getByTestId("mobile-header-title");
await expect(titleEl).toBeVisible({ timeout: 10_000 });
return;
}
test.skip(true, "No pages and no new-page button available");
return;
}

// Record URL before clicking
const urlBefore = page.url();

// Click to navigate
await treeItem.click();

// Wait for URL to change
await page.waitForURL((url) => url.href !== urlBefore, {
timeout: 15_000,
});

// The mobile header title should update to show the page title
const titleEl = page.getByTestId("mobile-header-title");
await expect(titleEl).toBeVisible({ timeout: 10_000 });

// Title should be non-empty
const titleText = await titleEl.textContent();
expect(titleText).toBeTruthy();
expect(titleText!.trim().length).toBeGreaterThan(0);
});

test("shows Settings on settings page", async ({
authenticatedPage: page,
}) => {
await page.goto("/");
await page.waitForURL((url) => !url.pathname.includes("/sign-in"), {
timeout: 15_000,
});

// Get workspace slug from URL
const currentUrl = new URL(page.url());
const pathParts = currentUrl.pathname.split("/").filter(Boolean);
const workspaceSlug = pathParts[0];
if (!workspaceSlug) {
test.skip(true, "Could not determine workspace slug");
return;
}

await page.setViewportSize(MOBILE_VIEWPORT);

// Navigate to settings
await page.goto(`/${workspaceSlug}/settings`);

// Wait for the settings page to load
const titleEl = page.getByTestId("mobile-header-title");
await expect(titleEl).toBeVisible({ timeout: 15_000 });

// Title should contain "Settings"
await expect(titleEl).toContainText("Settings", { timeout: 10_000 });
});

test("header is hidden on desktop viewport", async ({
authenticatedPage: page,
}) => {
await page.setViewportSize(DESKTOP_VIEWPORT);
await page.goto("/");

await page.waitForURL((url) => !url.pathname.includes("/sign-in"), {
timeout: 15_000,
});

// The entire mobile header (including the title) should be hidden on desktop
// because the header has md:hidden class
const mobileHeader = page.locator("header").filter({
has: page.getByTestId("as-sidebar-toggle"),
});

await expect(mobileHeader).toBeHidden({ timeout: 5_000 });
});

test("title element has truncation styles", async ({
authenticatedPage: page,
}) => {
await page.setViewportSize(MOBILE_VIEWPORT);
await page.goto("/");

const toggleButton = page.getByTestId("as-sidebar-toggle");
await expect(toggleButton).toBeVisible({ timeout: 15_000 });

const titleEl = page.getByTestId("mobile-header-title");
await expect(titleEl).toBeVisible({ timeout: 10_000 });

// Verify the element has overflow hidden via the truncate class
const overflow = await titleEl.evaluate(
(el) => window.getComputedStyle(el).overflow,
);
expect(overflow).toBe("hidden");

const textOverflow = await titleEl.evaluate(
(el) => window.getComputedStyle(el).textOverflow,
);
expect(textOverflow).toBe("ellipsis");
});
});
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
40 changes: 34 additions & 6 deletions src/components/sidebar/app-shell.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,26 +134,54 @@ export const SidebarCollapsed: Story = {
),
};

/** Mobile layout — sidebar as overlay sheet. */
/** Mobile layout — sidebar as overlay sheet with page title in header. */
export const MobileLayout: Story = {
render: () => (
<div className="h-[500px] w-[375px] overflow-hidden bg-background">
<header className="flex h-10 items-center gap-2 border-b border-overlay-border px-4">
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-sm text-muted-foreground hover:bg-overlay-hover"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-overlay-hover"
aria-label="Toggle sidebar"
>
<PanelLeft className="h-4 w-4" />
</button>
<span className="text-sm font-medium text-foreground">
My Workspace
<span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
Getting Started
</span>
</header>
<main className="flex-1 overflow-y-auto p-4">
<h1 className="text-xl font-bold text-foreground">Getting Started</h1>
<p className="mt-4 text-sm text-foreground">
Mobile layout with sidebar toggle and page title in the header.
</p>
</main>
</div>
),
};

/** Mobile layout — long title truncated with ellipsis. */
export const MobileLongTitle: Story = {
render: () => (
<div className="h-[500px] w-[375px] overflow-hidden bg-background">
<header className="flex h-10 items-center gap-2 border-b border-overlay-border px-4">
<button
type="button"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-overlay-hover"
aria-label="Toggle sidebar"
>
<PanelLeft className="h-4 w-4" />
</button>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
This Is a Very Long Page Title That Should Be Truncated
</span>
</header>
<main className="flex-1 overflow-y-auto p-4">
<h1 className="text-xl font-bold text-foreground">Page Title</h1>
<h1 className="text-xl font-bold text-foreground">
This Is a Very Long Page Title That Should Be Truncated
</h1>
<p className="mt-4 text-sm text-foreground">
Mobile layout with sidebar toggle in the header.
The header title truncates with ellipsis when it overflows.
</p>
</main>
</div>
Expand Down
2 changes: 2 additions & 0 deletions src/components/sidebar/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import dynamic from "next/dynamic";
import { SidebarProvider } from "@/components/sidebar/sidebar-context";
import { FocusModeHint } from "@/components/sidebar/focus-mode-hint";
import { MobileHeaderTitle } from "@/components/sidebar/mobile-header-title";
import type { ReactNode } from "react";

const AppSidebar = dynamic(
Expand Down Expand Up @@ -59,6 +60,7 @@ function AppShellInner({
<div className="flex flex-1 flex-col overflow-hidden">
<header className="flex h-10 shrink-0 items-center gap-2 border-b border-overlay-border px-4 md:hidden">
<SidebarToggle />
<MobileHeaderTitle />
</header>
<main id="main-content" tabIndex={-1} className="flex-1 overflow-y-auto focus:outline-none">{children}</main>
</div>
Expand Down
78 changes: 78 additions & 0 deletions src/components/sidebar/mobile-header-title.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import type { Meta, StoryObj } from "@storybook/react";
import { PanelLeft } from "lucide-react";

// MobileHeaderTitle reads document.title via MutationObserver — stories
// render the visual layout statically to show truncation and alignment.

const meta: Meta = {
title: "Sidebar/MobileHeaderTitle",
parameters: {
layout: "fullscreen",
},
};

export { meta as default };

type Story = StoryObj;

function MobileHeader({ title }: { title: string }) {
return (
<div className="w-[375px] bg-background">
<header className="flex h-10 shrink-0 items-center gap-2 border-b border-overlay-border px-4">
<button
type="button"
className="flex h-8 w-8 shrink-0 items-center justify-center text-muted-foreground hover:bg-overlay-hover"
aria-label="Toggle sidebar"
>
<PanelLeft className="h-4 w-4" />
</button>
<span
className="min-w-0 flex-1 truncate text-sm font-medium text-foreground"
data-testid="mobile-header-title"
>
{title}
</span>
</header>
</div>
);
}

/** Page title displayed next to the sidebar toggle. */
export const PageTitle: Story = {
render: () => <MobileHeader title="Getting Started" />,
};

/** Workspace home shows the workspace name. */
export const WorkspaceName: Story = {
render: () => <MobileHeader title="My Workspace" />,
};

/** Settings page shows the section name. */
export const SettingsPage: Story = {
render: () => <MobileHeader title="Settings — My Workspace" />,
};

/** Long title truncated with ellipsis. */
export const LongTitle: Story = {
render: () => (
<MobileHeader title="This Is a Very Long Page Title That Should Be Truncated With an Ellipsis on Small Screens" />
),
};

/** Empty title — component hidden when no title is available. */
export const EmptyTitle: Story = {
render: () => (
<div className="w-[375px] bg-background">
<header className="flex h-10 shrink-0 items-center gap-2 border-b border-overlay-border px-4">
<button
type="button"
className="flex h-8 w-8 shrink-0 items-center justify-center text-muted-foreground hover:bg-overlay-hover"
aria-label="Toggle sidebar"
>
<PanelLeft className="h-4 w-4" />
</button>
{/* MobileHeaderTitle returns null when title is empty */}
</header>
</div>
),
};
Loading
Loading