diff --git a/docs/design/artboards/d215/index.html b/docs/design/artboards/d215/index.html index baedabeb38..8141ff0283 100644 --- a/docs/design/artboards/d215/index.html +++ b/docs/design/artboards/d215/index.html @@ -871,6 +871,56 @@ .app.m .v2-activity .v2-activity__moved-line { grid-template-columns: minmax(0, 1fr) auto; } .app.m .v2-activity .v2-activity__moved-line span { grid-column: 1 / -1; grid-row: 2; } /* activity:end */ +/* artifacts: the live v2-artifacts__* names (V2ArtifactsPage) and V2PageHead's v2-page-head__*, + redrawn in the connectors family. Scoped to .v2-artifacts, the page's root. New names: __zero, __open. */ +.v2-artifacts { display: flex; flex-direction: column; gap: 20px; min-width: 0; } +.v2-artifacts button { all: unset; box-sizing: border-box; cursor: pointer; } +.v2-artifacts .info, .v2-artifacts__head .info { all: unset; cursor: help; position: relative; display: inline-grid; place-items: center; width: 18px; height: 18px; border-radius: 50%; color: var(--ph); } +.v2-artifacts .v2-page-head__identity { display: flex; align-items: center; gap: 10px; min-width: 0; } +.v2-artifacts .v2-artifacts__title { margin: 0; font: 800 30px/1.05 var(--f-display); letter-spacing: -0.03em; color: var(--ink); } +.v2-artifacts .v2-page-head__meta, .v2-artifacts__head .v2-page-head__meta { font: 500 12px/16px var(--f-mono); color: var(--mu); white-space: nowrap; } +.v2-artifacts .v2-artifacts__controls { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } +.v2-artifacts .v2-artifacts__seg { display: flex; gap: 2px; padding: 3px; border-radius: 9px; background: var(--sf); box-shadow: inset 0 0 0 1px var(--d); min-width: 0; } +.v2-artifacts .v2-artifacts__seg-button { display: inline-flex; align-items: center; height: 28px; padding: 0 10px; border-radius: 7px; font: 600 11.5px/1 var(--f-mono); color: var(--se); white-space: nowrap; } +.v2-artifacts .v2-artifacts__seg-button--active { background: var(--t); color: var(--ink); } +.v2-artifacts .v2-artifacts__search { display: flex; align-items: center; gap: 8px; min-width: 220px; height: 36px; margin-left: auto; padding: 0 10px; border-radius: 9px; box-shadow: inset 0 0 0 1px var(--d); background: var(--sf); } +.v2-artifacts .v2-artifacts__search-input { flex: 1; font-size: 13px; color: var(--ph); } +.v2-artifacts .v2-artifacts__search-key { font: 500 11px/16px var(--f-mono); color: var(--mu); } +.v2-artifacts .v2-artifacts__table-wrap { border-radius: 10px; box-shadow: inset 0 0 0 1px var(--d); background: var(--sf); overflow: hidden; } +.v2-artifacts .v2-artifacts__table { width: 100%; border-collapse: collapse; font-size: 13px; line-height: 18px; color: var(--ink); } +.v2-artifacts .v2-artifacts__table th { padding: 8px 12px; border-bottom: 1px solid var(--d); font: 500 11px/16px var(--f-mono); color: var(--mu); text-align: left; text-transform: lowercase; white-space: nowrap; } +.v2-artifacts .v2-artifacts__table td { padding: 9px 12px; border-bottom: 1px solid var(--d); vertical-align: middle; } +.v2-artifacts .v2-artifacts__table tr:last-child td { border-bottom: 0; } +.v2-artifacts .v2-artifacts__file { display: flex; align-items: center; gap: 10px; min-width: 0; } +.v2-artifacts .v2-artifacts__ext { display: inline-flex; align-items: center; justify-content: center; min-width: 34px; height: 20px; padding: 0 5px; border-radius: 5px; box-shadow: inset 0 0 0 1px var(--d); font: 500 11px/14px var(--f-mono); color: var(--se); } +.v2-artifacts .v2-artifacts__name { font-weight: 600; color: var(--ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; } +.v2-artifacts .v2-artifacts__pod-line { display: none; font: 500 11px/16px var(--f-mono); color: var(--se); } +.v2-artifacts .v2-artifacts__mono { font: 500 11px/16px var(--f-mono); color: var(--se); white-space: nowrap; } +.v2-artifacts .v2-artifacts__foot { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.v2-artifacts .v2-artifacts__more { font: 600 12px/16px var(--f-body); color: var(--c-text); } +.v2-artifacts .v2-artifacts__zero { width: 100%; max-width: 760px; padding: 14px; border-radius: 10px; background: var(--sf); box-shadow: inset 0 0 0 1px var(--d); } +.v2-artifacts .v2-artifacts__zero h2 { margin: 0; font: 600 14px/20px var(--f-body); letter-spacing: 0; color: var(--ink); } +.v2-artifacts .v2-artifacts__zero p { margin: 2px 0 0; font-size: 14px; line-height: 20px; color: var(--se); } +.v2-artifacts .v2-artifacts__open { display: inline-flex; align-items: center; justify-content: center; height: 32px; margin-top: 12px; padding: 0 13px; border-radius: 9px; font: 600 13px/1 var(--f-body); color: var(--on-fill); background: linear-gradient(180deg, var(--fill-a), var(--fill-b)); box-shadow: inset 0 0 0 1px var(--fill-edge); text-decoration: none; white-space: nowrap; } +/* 390: head is the top bar; filters stack; the pod folds under the name; the day-zero key is full width, 44px */ +.app.m .mtop.v2-artifacts__head { padding: 0 12px 0 16px; gap: 8px; } +.app.m .mtop.v2-artifacts__head h1 { font-size: 22px; } +.app.m .mtop.v2-artifacts__head .v2-page-head__meta { margin-left: auto; font-size: 11.5px; } +.app.m .v2-artifacts { gap: 12px; } +.app.m .v2-artifacts .v2-artifacts__controls { flex-direction: column; align-items: stretch; } +.app.m .v2-artifacts .v2-artifacts__seg-button { flex: 1 1 0; justify-content: center; height: 36px; } +.app.m .v2-artifacts .v2-artifacts__search { margin-left: 0; min-width: 0; height: 44px; } +.app.m .v2-artifacts .v2-artifacts__table { table-layout: fixed; } +.app.m .v2-artifacts .v2-artifacts__table th:nth-child(3), .app.m .v2-artifacts .v2-artifacts__table td:nth-child(3), +.app.m .v2-artifacts .v2-artifacts__table th:nth-child(4), .app.m .v2-artifacts .v2-artifacts__table td:nth-child(4), +.app.m .v2-artifacts .v2-artifacts__table th:nth-child(6), .app.m .v2-artifacts .v2-artifacts__table td:nth-child(6) { display: none; } +.app.m .v2-artifacts .v2-artifacts__table th:nth-child(2), .app.m .v2-artifacts .v2-artifacts__table td:nth-child(2) { width: 64px; } +.app.m .v2-artifacts .v2-artifacts__table th:nth-child(5), .app.m .v2-artifacts .v2-artifacts__table td:nth-child(5) { width: 52px; } +.app.m .v2-artifacts .v2-artifacts__file { flex-wrap: wrap; row-gap: 2px; } +.app.m .v2-artifacts .v2-artifacts__pod-line { display: block; flex: 1 1 100%; } +.app.m .v2-artifacts .v2-artifacts__name { white-space: normal; overflow-wrap: anywhere; } +.app.m .v2-artifacts .v2-artifacts__open { display: flex; width: 100%; height: 44px; font-size: 14px; } +/* artifacts:end */ /* first run: the live v2-first-run__* and v2-chat__start* names. The dialog sits inside .app, over the pod. */ .v2-chat__starts { display: grid; grid-template-columns: minmax(0, max-content); gap: 6px; margin: 2px 0 6px 48px; } .v2-chat__start { all: unset; box-sizing: border-box; cursor: pointer; display: inline-flex; align-items: center; min-height: 32px; padding: 6px 12px; border-radius: 10px; font: 500 13px/1.35 var(--f-body); color: var(--ink); background: linear-gradient(180deg, var(--key-a), var(--key-b)); box-shadow: inset 0 0 0 1px var(--key-edge), var(--sh-key); } @@ -1074,7 +1124,7 @@

Same look. Less said.

  • One screen firstRepeated blocks become a table; rare settings collapse.
  • @@ -1541,16 +1591,28 @@

    Same look. Less said.

    Day zero · 3907 days selected

    Activity

    0 need you · 0 moved
    Post in
    Write a message. Mention an agent to wake it…
    ⌘↵ to send

    Get started

    2 steps · until your first ask arrives
    Say something to it

    Say hello in the pod where it lives. If it doesn't answer, @mention it.

    Connect a channel

    Slack or Telegram — the agent answers where your team already talks.

    Needs you

    Nothing needs you.

    Moved forward

    Nothing moved in the last 7 days.
    Pods
    Needs you
    Team
    Settings
    Busy · 390scrolled to Needs you

    Activity

    4 need you · 5 moved

    Needs you

    4
    Northwind · 12m
    Which launch date goes in the customer email?
    Recommended
    Northwind · 40m
    Send the October invoices to 14 customers
    Field notes · 1h
    Fern
    Northwind · 2h
    Juno Park

    Moved forward

    Northwind3
    IvyReconciled the October ledger; two refunds flagged for review.
    SageDrafted the launch email in two versions.
    AlexMoved “Pricing page copy” to Done.
    Field notes2
    FernSummarized six customer interviews into one doc.
    Juno ParkAdded notes from the Friday call.
    Pods
    4Needs you
    Team
    Settings

    Each state, one line

    -
    Day zero · TodayDrawn. One pod, only Scout: 2 steps, current key Open My Workspace, both sections empty.
    Step copy"Say something to it" no longer claims an @mention is needed: Scout answers a plain hello in My Workspace (walk 11-02). The line now reads "Say hello in the pod where it lives. If it doesn't answer, @mention it."
    Day zero · 7 daysDrawn at 390. Moved forward reads "Nothing moved in the last 7 days."
    Get started · checkingDrawn beside the loaded card at the same height. Reserved only when hasEverHadAttention === false, the queue is 0 and the scope is All pods.
    BusyDrawn. One decision (Recommended under the first option), one approval, two mentions, two pod groups.
    No agent yet (step 1)Not drawn. The composer stays hidden and Hire an agent is the current key; same card, three rows.
    Reply open, ruled, Other…Not drawn. The editor takes the full card width, as prod does.
    Loading, error, count unavailableNot drawn. They keep prod's spinner, __error and Retry.
    +Day zero · 7 daysDrawn at 390. Moved forward reads "Nothing moved in the last 7 days."Get started · checkingDrawn beside the loaded card, at the height of the two-step day-zero card. A one- or three-step result shifts by one row when it lands, and an empty result removes the card. Reserved only when hasEverHadAttention === false, the queue is 0 and the scope is All pods.BusyDrawn. One decision (Recommended under the first option), one approval, two mentions, two pod groups.No agent yet (step 1)Not drawn. The composer stays hidden and Hire an agent is the current key; same card, three rows.Reply open, ruled, Other…Not drawn. The editor takes the full card width, as prod does.Loading, error, count unavailableNot drawn. They keep prod's spinner, __error and Retry.
    Day zero · 390, scrolled7 days, both empty

    Activity

    0 need you · 0 moved

    Get started

    2 steps · until your first ask arrives
    Say something to it

    Say hello in the pod where it lives. If it doesn't answer, @mention it.

    Connect a channel

    Slack or Telegram — the agent answers where your team already talks.

    Needs you

    Nothing needs you.

    Moved forward

    Nothing moved in the last 7 days.
    Pods
    Needs you
    Team
    Settings
    Get started · checkingfirst paint, steps in flight

    Get started

    checking…
    -
    Get started · loadedsame height, nothing moves

    Get started

    2 steps · until your first ask arrives
    Say something to it

    Say hello in the pod where it lives. If it doesn't answer, @mention it.

    Connect a channel

    Slack or Telegram — the agent answers where your team already talks.

    +
    Get started · loadedtwo steps: same height, nothing moves

    Get started

    2 steps · until your first ask arrives
    Say something to it

    Say hello in the pod where it lives. If it doesn't answer, @mention it.

    Connect a channel

    Slack or Telegram — the agent answers where your team already talks.

    +
    +

    Artifacts

    /v2/artifacts
    +
    +
    Day zero · 1440nothing shared, no filter set

    Artifacts

    Nothing shared yet

    Files you and your agents share in a pod land here: docs, images and pages.

    Open My Workspace
    +
    Busy · 144014 files across 2 pods

    Artifacts

    14
    FileKindPodShared byWhenSize
    Northwind
    docNorthwindFern12m2.1 MB
    Northwind
    imageNorthwindJuno Park1h412 KB
    Field notes
    pageField notesScout3h—
    Field notes
    docField notesFern1d18 KB
    Northwind
    docNorthwindAlex Chen2d96 KB
    Field notes
    imageField notesJuno Park4d1.4 MB
    6 of 14 · newest first
    +
    +
    +
    Day zero · 390key full width, 44px

    Artifacts

    Nothing shared yet

    Files you and your agents share in a pod land here: docs, images and pages.

    Open My Workspace
    Pods
    Needs you
    Team
    Settings
    +
    Busy · 390pod folds under the name

    Artifacts

    14
    FileKindPodShared byWhenSize
    Northwind
    docNorthwindFern12m2.1 MB
    Northwind
    imageNorthwindJuno Park1h412 KB
    Field notes
    pageField notesScout3h—
    Field notes
    docField notesFern1d18 KB
    Northwind
    docNorthwindAlex Chen2d96 KB
    Field notes
    imageField notesJuno Park4d1.4 MB
    6 of 14 · newest first
    Pods
    Needs you
    Team
    Settings
    +
    • Class names are the page's own (v2-artifacts__*), so the build copies them. New names: __zero (the card) and __open (its ink key).
    • Day zero has nothing to filter, so it shows no filters. It applies when the total is 0 and no kind, pod or search filter is set, and the page is not loading and has no error. Then the kind segment, the pod segment, the search box and the head's count all drop, and one bordered card (not dashed) takes their place.
    • The card is the only act on the page. Its key opens the account's oldest pod (signup creates My Workspace first), labelled with that pod's name. An account with no pods gets the card without the key.
    • A filtered empty result keeps the controls. With a kind, pod or search set, the page keeps prod's dashed line, Nothing matches. Clear a filter or the search., under the controls, so the way out stays on screen.
    • At 390 the key is full width and 44px tall. Busy is prod's table, unchanged: the pod folds under the name as a mono line, and pod, shared by and size hide.

    Each state, one line

    Day zeroDrawn at 1440 and 390. Total 0, no kind, pod or search set, not loading, no error: no controls, no count, the card.
    BusyDrawn at 1440 and 390. Prod's table and foot, unchanged.
    Filtered, emptyNot drawn. Controls stay; prod's dashed emptyFiltered line under them.
    No podsNot drawn. The day-zero card without its key.
    Loading, errorNot drawn. They keep prod's spinner and __error line.
    +
    +

    First run

    /v2/pods/:id · Scout welcome + Guide dialog
    helper words welcome – → 16dialog – → 68scroll 390 – → 1.0×
    diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 71c4a5dd41..a4a36eea72 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2609,7 +2609,11 @@ "foot": "{{shown}} of {{total}} · newest first", "more": "{{count}} more", "loadingMore": "Loading…", - "empty": "Nothing shared yet. Attach a file in any pod and it lands here.", + "dayZero": { + "title": "Nothing shared yet", + "body": "Files you and your agents share in a pod land here: docs, images and pages.", + "open": "Open {{pod}}" + }, "emptyFiltered": "Nothing matches. Clear a filter or the search.", "loadFailed": "Could not load artifacts." }, diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 33f6ba9dbd..58440aede7 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -2611,7 +2611,11 @@ "foot": "{{shown}} / {{total}} · 最新在前", "more": "还有{{count}}项", "loadingMore": "加载中…", - "empty": "还没有共享内容。在任意 Pod 里附加文件,就会出现在这里。", + "dayZero": { + "title": "还没有分享内容", + "body": "你和你的智能体在 Pod 里分享的文件会出现在这里,包括文档、图片和页面。", + "open": "打开 {{pod}}" + }, "emptyFiltered": "没有匹配项。清除筛选或搜索。", "loadFailed": "无法加载产物。" }, diff --git a/frontend/src/v2/__tests__/V2ArtifactsPage.test.tsx b/frontend/src/v2/__tests__/V2ArtifactsPage.test.tsx index dd469feafb..c1412e1f7a 100644 --- a/frontend/src/v2/__tests__/V2ArtifactsPage.test.tsx +++ b/frontend/src/v2/__tests__/V2ArtifactsPage.test.tsx @@ -1,7 +1,7 @@ // @ts-nocheck // Artifacts, direction C (ScaleArtifacts.dc.html) — PR 5. import React from 'react'; -import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor, within, cleanup, act } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import axios from 'axios'; import V2ArtifactsPage, { extOf, formatSize, whenLabel } from '../components/V2ArtifactsPage'; @@ -75,6 +75,10 @@ describe('V2ArtifactsPage (direction C, PR 5)', () => { expect(within(rows[3]).getAllByText('Connectors v2').length).toBeGreaterThan(0); expect(screen.getByText('3 of 3 · newest first')).toBeInTheDocument(); expect(axios.get).toHaveBeenCalledWith('/api/artifacts', expect.objectContaining({ params: { limit: 50 } })); + // Busy is unchanged: the controls and the count stay; no day-zero card. + expect(screen.getByRole('group', { name: 'Kind' })).toBeInTheDocument(); + expect(screen.getByRole('searchbox', { name: 'Search files' })).toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: 'Nothing shared yet' })).not.toBeInTheDocument(); }); test('kind, pod and search filters travel through the URL to the one query', async () => { @@ -121,11 +125,104 @@ describe('V2ArtifactsPage (direction C, PR 5)', () => { open.mockRestore(); }); - test('empty states are honest: nothing shared vs nothing matching', async () => { - axios.get.mockImplementation((url) => Promise.resolve({ data: url === '/api/pods' ? [] : { items: [], nextCursor: null, total: 0 } })); + // Day zero (d215 s-artifacts): nothing shared and no filter set → no controls, no count, one card. + const renderEmpty = (initial, { pods = [] } = {}) => { + axios.get.mockImplementation((url) => { + if (url === '/api/pods') return Promise.resolve({ data: pods }); + if (url === '/api/artifacts') return Promise.resolve({ data: { items: [], nextCursor: null, total: 0, limit: 50 } }); + return Promise.resolve({ data: {} }); + }); + return render(); + }; + const workspace = { _id: 'w1', name: 'My Workspace', createdAt: '2026-10-01T00:00:00.000Z' }; + + test('day zero: no kind, pod or search controls and no count; one card whose key opens My Workspace', async () => { + const { container } = renderEmpty('/v2/artifacts', { pods: [workspace] }); + expect(await screen.findByRole('heading', { name: 'Nothing shared yet' })).toBeInTheDocument(); + expect(screen.getByText('Files you and your agents share in a pod land here: docs, images and pages.')).toBeInTheDocument(); + const open = await screen.findByRole('link', { name: 'Open My Workspace' }); + expect(open).toHaveAttribute('href', '/v2/pods/w1'); + expect(open).toHaveClass('v2-artifacts__open'); + expect(screen.queryByRole('group', { name: 'Kind' })).not.toBeInTheDocument(); + expect(screen.queryByRole('group', { name: 'Pod' })).not.toBeInTheDocument(); + expect(screen.queryByRole('searchbox')).not.toBeInTheDocument(); + expect(container.querySelector('.v2-artifacts__controls')).toBeNull(); + expect(container.querySelector('.v2-page-head__meta')).toBeNull(); + expect(screen.queryByText(/Nothing matches/)).not.toBeInTheDocument(); + }); + + test('day zero opens the oldest pod, the one signup creates, whatever order /api/pods lists', async () => { + const team = { _id: 'p2', name: 'Launch room', createdAt: '2026-10-05T00:00:00.000Z' }; + const later = { _id: 'p3', name: 'Field notes', createdAt: '2026-10-09T00:00:00.000Z' }; + // /api/pods lists by last update, newest first. + renderEmpty('/v2/artifacts', { pods: [later, team, workspace] }); + expect(await screen.findByRole('link', { name: 'Open My Workspace' })).toHaveAttribute('href', '/v2/pods/w1'); + // No extra lookups: the choice is made from /api/pods alone. + expect(axios.get.mock.calls.map(([url]) => url).sort()).toEqual(['/api/artifacts', '/api/pods']); + }); + + test('clearing a filter on a busy account never shows day zero or drops the controls', async () => { + // YC S27 prep holds nothing, so ?podId=p3 is a filtered empty result on an account with files. + const { container } = renderPage('/v2/artifacts?podId=p3'); + expect(await screen.findByText('Nothing matches. Clear a filter or the search.')).toBeInTheDocument(); + const seen = []; + const observer = new MutationObserver((records) => records.forEach((record) => { + record.addedNodes.forEach((node) => { if (node.classList) seen.push(`add:${node.className}`); }); + record.removedNodes.forEach((node) => { if (node.classList) seen.push(`rm:${node.className}`); }); + })); + observer.observe(container, { childList: true, subtree: true }); + const all = within(screen.getByRole('group', { name: 'Pod' })).getByRole('button', { name: 'All pods' }); + all.focus(); + fireEvent.click(all); + await screen.findByRole('button', { name: 'workspace-at-scale.html' }); + observer.disconnect(); + expect(seen.filter((entry) => /v2-artifacts__(zero|controls)/.test(entry))).toEqual([]); + expect(document.activeElement).toBe(within(screen.getByRole('group', { name: 'Pod' })).getByRole('button', { name: 'All pods' })); + }); + + test('day zero with no pods keeps the card and omits the key', async () => { + renderEmpty('/v2/artifacts'); + expect(await screen.findByRole('heading', { name: 'Nothing shared yet' })).toBeInTheDocument(); + await waitFor(() => expect(axios.get).toHaveBeenCalledWith('/api/pods', expect.anything())); + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + }); + + test('a filtered empty result keeps the controls, the count and the dashed line, not the card', async () => { + const { container } = renderEmpty('/v2/artifacts?kind=image', { pods: [workspace] }); + expect(await screen.findByText('Nothing matches. Clear a filter or the search.')).toBeInTheDocument(); + expect(screen.getByRole('group', { name: 'Kind' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Images' })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByRole('searchbox', { name: 'Search files' })).toBeInTheDocument(); + expect(container.querySelector('.v2-page-head__meta')).toHaveTextContent('0'); + expect(screen.queryByRole('heading', { name: 'Nothing shared yet' })).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: /^Open / })).not.toBeInTheDocument(); + }); + + test('typing a search before the first empty answer lands keeps the search box and focus', async () => { + let answer; + axios.get.mockImplementation((url) => { + if (url === '/api/pods') return Promise.resolve({ data: [workspace] }); + if (url === '/api/artifacts') return new Promise((resolve) => { answer = resolve; }); + return Promise.resolve({ data: {} }); + }); render(); - expect(await screen.findByText(/Nothing shared yet/)).toBeInTheDocument(); - render(); - expect(await screen.findByText(/Nothing matches/)).toBeInTheDocument(); + const search = screen.getByRole('searchbox', { name: 'Search files' }); + search.focus(); + fireEvent.change(search, { target: { value: 'pl' } }); + // The unfiltered answer lands inside the 250ms debounce, before ?q reaches the URL. + await act(async () => { answer({ data: { items: [], nextCursor: null, total: 0, limit: 50 } }); }); + expect(screen.queryByRole('heading', { name: 'Nothing shared yet' })).not.toBeInTheDocument(); + expect(screen.getByRole('searchbox', { name: 'Search files' })).toBe(search); + expect(document.activeElement).toBe(search); + }); + + test.each([ + ['a search', '/v2/artifacts?q=unmatched'], + ['a pod', '/v2/artifacts?podId=w1'], + ])('%s with no matches keeps the controls and the filtered line, never day zero', async (_label, url) => { + const { container } = renderEmpty(url, { pods: [workspace] }); + expect(await screen.findByText('Nothing matches. Clear a filter or the search.')).toBeInTheDocument(); + expect(container.querySelector('.v2-artifacts__controls')).not.toBeNull(); + expect(screen.queryByRole('heading', { name: 'Nothing shared yet' })).not.toBeInTheDocument(); }); }); diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index cdb59a53b6..46ca3314bd 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -1066,6 +1066,31 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(ruleBody(v2, '.v2-root button.v2-workspace-inspector__all-files')).toContain('color: var(--v2-accent-text)'); }); + test('Artifacts day zero (d215 s-artifacts): one bordered card, an ink key, 44px full width at ≤760', () => { + const zero = ruleBody(v2, '.v2-artifacts__zero'); + // Bordered, not dashed: the dashed line is the filtered-empty state. + expect(zero).toContain('border: 1px solid var(--v2-border)'); + expect(zero).toContain('border-radius: var(--v2-radius)'); + const key = ruleBody(v2, '.v2-root a.v2-artifacts__open'); + expect(key).toContain('background: var(--v2-ink)'); + expect(key).toContain('color: var(--v2-on-ink)'); + expect(key).toContain('min-height: 32px'); + // Pod names have no length limit: the label wraps inside the card instead of running past it. + expect(key).toContain('overflow-wrap: anywhere'); + expect(key).not.toContain('nowrap'); + // The 44px rule must sit INSIDE a ≤760 block, not at top level or in a narrower query: + // resolve the media block that actually encloses it (the enclosingMedia pattern above). + const enclosingMedia = (rule: string): string | null => { + const at = v2.indexOf(rule); + expect(at).toBeGreaterThan(-1); + const open = v2.lastIndexOf('@media', at); + if (open === -1) return null; + const close = v2.indexOf('\n}', open); + return close > at ? v2.slice(open, v2.indexOf('{', open)).trim() : null; + }; + expect(enclosingMedia('.v2-root a.v2-artifacts__open { display: flex; width: 100%; min-height: 44px; }')).toBe('@media (max-width: 760px)'); + }); + test('workspace inspector is the small three-card replacement, with a phone sheet rather than legacy tabs', () => { // TASK-129 replaces the 86KB tabbed inspector in the same cutover as the // artboard cards. Keeping the old component anywhere in the route would diff --git a/frontend/src/v2/components/V2ActivityPage.tsx b/frontend/src/v2/components/V2ActivityPage.tsx index 141727d1e4..b78a14a87a 100644 --- a/frontend/src/v2/components/V2ActivityPage.tsx +++ b/frontend/src/v2/components/V2ActivityPage.tsx @@ -603,7 +603,9 @@ const V2ActivityPage: React.FC = () => { }, [seatedPods, recap]); // Get started never pops in (d215 s-activity, walk 11-02): on an account the card can show for, // its steps wait on the roster and the connector count. While either is in flight the card - // holds its place as a same-height placeholder, so Needs you does not move when they land. + // holds its place as a placeholder the height of the two-step day-zero card, so in that case + // Needs you does not move when they land. A one- or three-step result shifts it by one row, + // and an empty result removes the card. // Eligibility is the card's own gate, so a failed or unknown history still shows nothing. const startEligible = queueCount === 0 && recap?.hasEverHadAttention === false && podId === 'all'; const startPending = startEligible && (seatPods === null || connectorCount === null); diff --git a/frontend/src/v2/components/V2ArtifactsPage.tsx b/frontend/src/v2/components/V2ArtifactsPage.tsx index 5cda92c0bc..10004bb507 100644 --- a/frontend/src/v2/components/V2ArtifactsPage.tsx +++ b/frontend/src/v2/components/V2ArtifactsPage.tsx @@ -3,7 +3,7 @@ // GET /api/artifacts. The inspector's Files pane is the same query with // podId fixed; this page is the query with podId as a filter. import React, { useEffect, useMemo, useState } from 'react'; -import { useSearchParams } from 'react-router-dom'; +import { Link, useSearchParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import axios from 'axios'; import { getSignedAttachmentUrl } from '../../utils/signedAttachmentUrl'; @@ -24,7 +24,7 @@ export interface ArtifactItem { createdAt: string | null; } interface ArtifactsResponse { items: ArtifactItem[]; nextCursor: string | null; total: number; limit: number } -interface PodRef { _id: string; name: string } +interface PodRef { _id: string; name: string; createdAt?: string } const KINDS: Array<{ key: '' | ArtifactKind; label: string }> = [ { key: '', label: 'all' }, { key: 'image', label: 'images' }, { key: 'doc', label: 'docs' }, { key: 'page', label: 'pages' }, @@ -63,6 +63,9 @@ export const shortPodName = (name: string | null | undefined): string => ( (name || '').trim().split(/\s*[·—–:|]\s*/)[0].trim() || (name || '').trim() ); +const artifactsQueryKey = (podId: string, kind: string, q: string): string => JSON.stringify([podId, kind, q]); +const UNFILTERED = artifactsQueryKey('', '', ''); + const sharedByLabel = (item: ArtifactItem): string => item.sharedBy.displayName || item.sharedBy.username || '—'; const V2ArtifactsPage: React.FC = () => { @@ -78,6 +81,9 @@ const V2ArtifactsPage: React.FC = () => { const [loading, setLoading] = useState(true); const [loadingMore, setLoadingMore] = useState(false); const [error, setError] = useState(null); + // The query the shown result answers. Until a new one lands, items and total still belong to + // the previous filter, so day zero must not read them as the unfiltered answer. + const [loadedQuery, setLoadedQuery] = useState(null); const [podMenuOpen, setPodMenuOpen] = useState(false); // ux-lead 66462: an image row opens the chat lightbox; pages and docs open in a new tab. const [lightbox, setLightbox] = useState(null); @@ -113,6 +119,7 @@ const V2ArtifactsPage: React.FC = () => { let active = true; setLoading(true); setError(null); + const queryKey = artifactsQueryKey(podId, kind, urlQ); const params: Record = { limit: 50 }; if (podId) params.podId = podId; if (kind) params.kind = kind; @@ -121,10 +128,11 @@ const V2ArtifactsPage: React.FC = () => { .then((res) => { if (!active) return; setItems(res.data.items || []); + setLoadedQuery(queryKey); setTotal(typeof res.data.total === 'number' ? res.data.total : (res.data.items || []).length); setNextCursor(res.data.nextCursor || null); }) - .catch(() => { if (active) { setError(t('artifacts.loadFailed')); setItems([]); setTotal(0); setNextCursor(null); } }) + .catch(() => { if (active) { setError(t('artifacts.loadFailed')); setItems([]); setTotal(0); setNextCursor(null); setLoadedQuery(queryKey); } }) .finally(() => { if (active) setLoading(false); }); return () => { active = false; }; }, [headers, podId, kind, urlQ, t]); @@ -150,6 +158,20 @@ const V2ArtifactsPage: React.FC = () => { } }; + // Day zero (d215 s-artifacts): nothing shared and nothing filtered, so there is nothing to + // filter — no controls, no count, one card. A filtered empty result keeps the controls, and so + // does the render between clearing a filter and its answer landing (the result is still the + // filtered one), or the controls unmount under the person's focus. + // A search typed before its debounce reaches the URL counts too, or the input unmounts mid-word. + const dayZero = !loading && !error && total === 0 && items.length === 0 && !q && !urlQ && !kind && !podId + && loadedQuery === UNFILTERED; + // The card's key opens the account's oldest pod: signup creates My Workspace first. /api/pods + // lists by last update, so pick by createdAt rather than position. + const workspacePod = useMemo(() => { + const createdMs = (pod: PodRef) => (pod.createdAt ? Date.parse(pod.createdAt) : Number.POSITIVE_INFINITY); + return pods.reduce((oldest, pod) => (!oldest || createdMs(pod) < createdMs(oldest) ? pod : oldest), null); + }, [pods]); + const openItem = async (item: ArtifactItem) => { if (item.kind === 'image') { setLightbox(item); return; } const url = await getSignedAttachmentUrl(`/api/uploads/${item.fileName}`); @@ -165,46 +187,55 @@ const V2ArtifactsPage: React.FC = () => { className="v2-artifacts__head" titleClassName="v2-artifacts__title" title={t('artifacts.title')} - meta={!loading ? total : undefined} + meta={!loading && !dayZero ? total : undefined} hint={t('artifacts.meta')} hintLabel={t('artifacts.about')} /> -
    -
    - {KINDS.map((option) => ( - - ))} -
    -
    { if (event.key === 'Escape') setPodMenuOpen(false); }}> - - {scopedPods.slice(0, 2).map((pod) => ( - - ))} - {scopedPods.length > 2 && ( - - )} - {podMenuOpen && ( -
    - {scopedPods.slice(2).map((pod) => ( - - ))} -
    - )} + {!dayZero && ( +
    +
    + {KINDS.map((option) => ( + + ))} +
    +
    { if (event.key === 'Escape') setPodMenuOpen(false); }}> + + {scopedPods.slice(0, 2).map((pod) => ( + + ))} + {scopedPods.length > 2 && ( + + )} + {podMenuOpen && ( +
    + {scopedPods.slice(2).map((pod) => ( + + ))} +
    + )} +
    +
    - -
    + )} {error &&
    {error}
    } {loading && !error &&
    } - {!loading && !error && items.length === 0 && ( -
    {urlQ || kind || podId ? t('artifacts.emptyFiltered') : t('artifacts.empty')}
    + {dayZero && ( +
    +

    {t('artifacts.dayZero.title')}

    +

    {t('artifacts.dayZero.body')}

    + {workspacePod && {t('artifacts.dayZero.open', { pod: workspacePod.name })}} +
    + )} + {!loading && !error && items.length === 0 && (urlQ || kind || podId) && ( +
    {t('artifacts.emptyFiltered')}
    )} {!loading && items.length > 0 && (
    diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css index 3b8228f601..d1613f1c83 100644 --- a/frontend/src/v2/v2.css +++ b/frontend/src/v2/v2.css @@ -5621,7 +5621,8 @@ body.modern-ui.v2-canvas { } /* Get started, checking (d215 s-activity): while the roster and connector count are in flight the card holds its place — real head, two step rows of bars on the same line boxes as the loaded - rows (20px lines, 32px key; 44px full width at ≤760) — so nothing below it moves. */ + rows (20px lines, 32px key; 44px full width at ≤760) — so for the two-step day-zero card nothing + below it moves; one or three steps shift it by a row. */ .v2-activity__start--pending .v2-activity__start-num--pending { background: var(--v2-surface-hover); } .v2-activity__start-bar { display: block; position: relative; height: 20px; } .v2-activity__start-bar::after { content: ""; position: absolute; left: 0; top: 5px; width: 92%; height: 10px; border-radius: 5px; background: var(--v2-surface-hover); } @@ -5681,6 +5682,14 @@ body.modern-ui.v2-canvas { .v2-artifacts__empty { padding: 18px 20px; border: 1px dashed var(--v2-border); border-radius: var(--v2-radius); color: var(--v2-text-secondary); font: 400 13px/18px var(--v2-font); } .v2-artifacts__error { padding: 12px 14px; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); color: var(--v2-text-primary); font: 400 13px/18px var(--v2-font); } .v2-artifacts__loading { display: grid; place-items: center; padding: 24px; } +/* Day zero (d215 s-artifacts): nothing shared and no filter set, so the page drops its controls and + count for one bordered card — Get started's card grammar (14px 20px lines, ink key, 32px at desktop, + 44 full width at ≤760). The key links to the account's oldest pod, the one signup creates. */ +.v2-artifacts__zero { width: min(100%, 760px); padding: 14px; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: var(--v2-surface); } +.v2-artifacts__zero h2 { margin: 0; color: var(--v2-text-primary); font: 600 14px/20px var(--v2-font); } +.v2-artifacts__zero p { margin: 2px 0 0; color: var(--v2-text-secondary); font: 400 14px/20px var(--v2-font); } +.v2-root a.v2-artifacts__open { display: inline-flex; align-items: center; justify-content: center; min-height: 32px; margin-top: 12px; padding: 0 12px; border: 1px solid var(--v2-ink); border-radius: var(--v2-radius-sm); background: var(--v2-ink); color: var(--v2-on-ink); font: 600 13px/18px var(--v2-font); text-align: center; overflow-wrap: anywhere; } +.v2-root a.v2-artifacts__open:hover { background: var(--v2-ink-hover); } /* ≤760 (ux-lead 66462): no sideways scroll — keep file / kind / when, fold the pod under the name as a mono line, hide pod / shared-by / size; the head stays display 32. */ @media (max-width: 760px) { @@ -5696,6 +5705,7 @@ body.modern-ui.v2-canvas { .v2-artifacts__file { flex-wrap: wrap; row-gap: 2px; } .v2-artifacts__pod-line { display: block; flex: 1 1 100%; } .v2-root button.v2-artifacts__name { white-space: normal; overflow-wrap: anywhere; } + .v2-root a.v2-artifacts__open { display: flex; width: 100%; min-height: 44px; } } /* Public Privacy and Terms pages (TASK-530). Outside .v2-root, so no root-scoped selectors. */