From 7152cd72c1a7fcfa2234f92ba99c3c52eb9452a7 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 1 Nov 2026 21:59:55 -0800 Subject: [PATCH 1/7] docs(d215): draw Artifacts day zero as one card, no filters; qualify Get started's height claim Adds s-artifacts after s-activity: day zero (total 0, no kind/pod/search filter, not loading, no error) drops the kind and pod segments, the search box and the head's count, and shows one bordered card with an ink key that opens the pod Activity's Get started step opens. Busy is prod's table, unchanged. Filtered-empty keeps the controls and the dashed line. Also qualifies three s-activity claims from #3316 that said the Get started placeholder is the same height without condition: it matches the two-step day-zero card; one or three steps shift by one row, and an empty result removes the card. The V2ActivityPage change is comment-only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015AKgpMD6Qh13f7fj6mbGT3 --- docs/design/artboards/d215/index.html | 68 ++++++++++++++++++- frontend/src/v2/components/V2ActivityPage.tsx | 4 +- 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/docs/design/artboards/d215/index.html b/docs/design/artboards/d215/index.html index baedabeb38..c315f4f0f8 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
    • Class names are the page's own (v2-activity__*), so the build copies them. The card marks redraw KindGlyph and the square keys ActGlyph; the rail and avatars stand in for the shell.
    • Day zero is one card above two quiet sections. Get started is the only filled key on the page (the current step); Needs you and Moved forward stay as dashed one-liners, never a second card.
    • Get started never pops in. Its steps wait on a second fetch (/api/registry/pods/:id/agents) and the connector count, so before this build it landed a beat after the composer and pushed Needs you down. While those are in flight on a day-zero account, the card renders as a placeholder: real head, two step rows of bars, the height of the two-step day-zero card (Scout seated, no connector). An account with one or three steps left shifts by one row when they land, and the card drops if the steps come back empty.
    • At 390 a card's acts are one row. Reply (or Approve) flexes to fill it and the 44px ✓ and ↗ squares sit on its right. Before this build the ≤760 rule .v2-activity__queue-actions > button { flex: 1 1 100% } stacked them, about 60px a card. Decision options may still stack full width; Other… and ↗ share the last row.
    • Moved forward names its window when empty. Today keeps Nothing yet today.; 7 days reads Nothing moved in the last 7 days. (new key, e.g. activity.movedForward.emptyWeek).

    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 pod Activity's Get started step opens (the first pod with an agent seated, else the account's first pod), with the same label. 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/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); From f2fd3bb74f5bdf0a155f192867ea90567a9f12cd Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 1 Nov 2026 22:04:08 -0800 Subject: [PATCH 2/7] feat(artifacts): day zero is one card with a way to act, not filters over nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new account saw the kind and pod segments, a search box with ⌘K and a "0" count above one dashed line. When the total is 0 and no kind, pod or search filter is set (and the page is not loading and has no error), the page now drops the controls and the count and shows one bordered card: "Nothing shared yet", one line on what lands here, and an ink key that opens the pod Activity's Get started step opens: the first pod holding a seat, else the account's oldest pod, with Activity's "Open {{pod}}" label. An account with no pods gets the card without the key. A filtered empty result keeps the controls and the dashed "Nothing matches" line. Busy is unchanged. Drawn in d215 s-artifacts. The unfiltered artifacts.empty string is replaced by artifacts.dayZero.* (en, zh-CN). The ≤760 44px full-width key is pinned by a layout invariant that resolves its enclosing media block. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015AKgpMD6Qh13f7fj6mbGT3 --- frontend/src/i18n/locales/en.json | 6 +- frontend/src/i18n/locales/zh-CN.json | 6 +- .../src/v2/__tests__/V2ArtifactsPage.test.tsx | 69 ++++++++++-- .../v2/__tests__/v2-layout-invariants.test.ts | 22 ++++ .../src/v2/components/V2ArtifactsPage.tsx | 103 ++++++++++++------ frontend/src/v2/v2.css | 9 ++ 6 files changed, 171 insertions(+), 44 deletions(-) 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..c04e5b024f 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..a0e51d4340 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 } 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,62 @@ 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 } })); - render(); - expect(await screen.findByText(/Nothing shared yet/)).toBeInTheDocument(); - render(); - expect(await screen.findByText(/Nothing matches/)).toBeInTheDocument(); + // Day zero (d215 s-artifacts): nothing shared and no filter set → no controls, no count, one card. + const renderEmpty = (initial, { pods = [], agents = {} } = {}) => { + axios.get.mockImplementation((url, config) => { + if (url === '/api/pods') return Promise.resolve({ data: pods }); + if (url === '/api/artifacts') return Promise.resolve({ data: { items: [], nextCursor: null, total: 0, limit: 50 } }); + const seat = /^\/api\/registry\/pods\/([^/]+)\/agents$/.exec(url); + if (seat) return Promise.resolve({ data: { agents: agents[seat[1]] || [] } }); + 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(); + // One pod is the answer either way, so no seat lookup is spent on it. + expect(axios.get).not.toHaveBeenCalledWith(expect.stringMatching(/^\/api\/registry\//), expect.anything()); + }); + + test('day zero picks the pod Activity’s Get started opens: the oldest pod with a seat, else the oldest pod', 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; the choice reads them oldest first. + renderEmpty('/v2/artifacts', { pods: [later, team, workspace], agents: { w1: [{ name: 'helper', internal: true }], p2: [{ name: 'scout' }], p3: [{ name: 'fern' }] } }); + expect(await screen.findByRole('link', { name: 'Open Launch room' })).toHaveAttribute('href', '/v2/pods/p2'); + cleanup(); + renderEmpty('/v2/artifacts', { pods: [later, workspace], agents: {} }); + expect(await screen.findByRole('link', { name: 'Open My Workspace' })).toHaveAttribute('href', '/v2/pods/w1'); + }); + + 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(); }); }); diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index cdb59a53b6..940145c1d0 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -1066,6 +1066,28 @@ 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'); + // 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/V2ArtifactsPage.tsx b/frontend/src/v2/components/V2ArtifactsPage.tsx index 5cda92c0bc..d698082a73 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' }, @@ -81,6 +81,7 @@ const V2ArtifactsPage: React.FC = () => { 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); + const [workspacePod, setWorkspacePod] = useState(null); const setParam = (key: string, value: string) => { const next = new URLSearchParams(searchParams); @@ -150,6 +151,29 @@ 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. + const dayZero = !loading && !error && total === 0 && items.length === 0 && !urlQ && !kind && !podId; + // The card's key opens the pod Activity's Get started step opens (V2ActivityPage speakPod): + // the first pod holding a seat, else the account's first pod. Activity reads the account's + // pods oldest first and checks at most 20; /api/pods lists by last update, so re-sort. + useEffect(() => { + if (!dayZero || pods.length === 0) return undefined; + let active = true; + const createdMs = (pod: PodRef) => (pod.createdAt ? Date.parse(pod.createdAt) : Number.POSITIVE_INFINITY); + const ordered = [...pods].sort((a, b) => createdMs(a) - createdMs(b)); + if (ordered.length === 1) { + setWorkspacePod(ordered[0]); + return undefined; + } + Promise.all(ordered.slice(0, 20).map((pod) => axios + .get<{ agents?: Array<{ internal?: boolean }> }>(`/api/registry/pods/${pod._id}/agents`, { headers }) + .then((res) => (Array.isArray(res.data?.agents) ? res.data.agents : []).some((agent) => !agent.internal)) + .catch(() => false))) + .then((seated) => { if (active) setWorkspacePod(ordered[seated.indexOf(true)] ?? ordered[0]); }); + return () => { active = false; }; + }, [dayZero, pods, headers]); + const openItem = async (item: ArtifactItem) => { if (item.kind === 'image') { setLightbox(item); return; } const url = await getSignedAttachmentUrl(`/api/uploads/${item.fileName}`); @@ -165,46 +189,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) => ( - - ))} + {!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) => ( + + ))} +
    + )} +
    +
    -
    { 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..b6342c2b25 100644 --- a/frontend/src/v2/v2.css +++ b/frontend/src/v2/v2.css @@ -5681,6 +5681,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 is a link to the pod Activity's Get started step opens. */ +.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); white-space: nowrap; } +.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 +5704,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. */ From 90fa83dd72789e1aa33e7b33634e4952e8005b97 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 1 Nov 2026 22:05:33 -0800 Subject: [PATCH 3/7] fix(artifacts): day zero opens the oldest pod with no per-pod lookups; qualify Get started's height comment Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015AKgpMD6Qh13f7fj6mbGT3 --- .../src/v2/__tests__/V2ArtifactsPage.test.tsx | 19 ++++++--------- .../src/v2/components/V2ArtifactsPage.tsx | 24 ++++--------------- frontend/src/v2/v2.css | 3 ++- 3 files changed, 14 insertions(+), 32 deletions(-) diff --git a/frontend/src/v2/__tests__/V2ArtifactsPage.test.tsx b/frontend/src/v2/__tests__/V2ArtifactsPage.test.tsx index a0e51d4340..9c67b407c2 100644 --- a/frontend/src/v2/__tests__/V2ArtifactsPage.test.tsx +++ b/frontend/src/v2/__tests__/V2ArtifactsPage.test.tsx @@ -126,12 +126,10 @@ describe('V2ArtifactsPage (direction C, PR 5)', () => { }); // Day zero (d215 s-artifacts): nothing shared and no filter set → no controls, no count, one card. - const renderEmpty = (initial, { pods = [], agents = {} } = {}) => { - axios.get.mockImplementation((url, config) => { + 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 } }); - const seat = /^\/api\/registry\/pods\/([^/]+)\/agents$/.exec(url); - if (seat) return Promise.resolve({ data: { agents: agents[seat[1]] || [] } }); return Promise.resolve({ data: {} }); }); return render(); @@ -151,19 +149,16 @@ describe('V2ArtifactsPage (direction C, PR 5)', () => { expect(container.querySelector('.v2-artifacts__controls')).toBeNull(); expect(container.querySelector('.v2-page-head__meta')).toBeNull(); expect(screen.queryByText(/Nothing matches/)).not.toBeInTheDocument(); - // One pod is the answer either way, so no seat lookup is spent on it. - expect(axios.get).not.toHaveBeenCalledWith(expect.stringMatching(/^\/api\/registry\//), expect.anything()); }); - test('day zero picks the pod Activity’s Get started opens: the oldest pod with a seat, else the oldest pod', async () => { + 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; the choice reads them oldest first. - renderEmpty('/v2/artifacts', { pods: [later, team, workspace], agents: { w1: [{ name: 'helper', internal: true }], p2: [{ name: 'scout' }], p3: [{ name: 'fern' }] } }); - expect(await screen.findByRole('link', { name: 'Open Launch room' })).toHaveAttribute('href', '/v2/pods/p2'); - cleanup(); - renderEmpty('/v2/artifacts', { pods: [later, workspace], agents: {} }); + // /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('day zero with no pods keeps the card and omits the key', async () => { diff --git a/frontend/src/v2/components/V2ArtifactsPage.tsx b/frontend/src/v2/components/V2ArtifactsPage.tsx index d698082a73..5b1a34bfdf 100644 --- a/frontend/src/v2/components/V2ArtifactsPage.tsx +++ b/frontend/src/v2/components/V2ArtifactsPage.tsx @@ -81,7 +81,6 @@ const V2ArtifactsPage: React.FC = () => { 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); - const [workspacePod, setWorkspacePod] = useState(null); const setParam = (key: string, value: string) => { const next = new URLSearchParams(searchParams); @@ -154,25 +153,12 @@ 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. const dayZero = !loading && !error && total === 0 && items.length === 0 && !urlQ && !kind && !podId; - // The card's key opens the pod Activity's Get started step opens (V2ActivityPage speakPod): - // the first pod holding a seat, else the account's first pod. Activity reads the account's - // pods oldest first and checks at most 20; /api/pods lists by last update, so re-sort. - useEffect(() => { - if (!dayZero || pods.length === 0) return undefined; - let active = true; + // 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); - const ordered = [...pods].sort((a, b) => createdMs(a) - createdMs(b)); - if (ordered.length === 1) { - setWorkspacePod(ordered[0]); - return undefined; - } - Promise.all(ordered.slice(0, 20).map((pod) => axios - .get<{ agents?: Array<{ internal?: boolean }> }>(`/api/registry/pods/${pod._id}/agents`, { headers }) - .then((res) => (Array.isArray(res.data?.agents) ? res.data.agents : []).some((agent) => !agent.internal)) - .catch(() => false))) - .then((seated) => { if (active) setWorkspacePod(ordered[seated.indexOf(true)] ?? ordered[0]); }); - return () => { active = false; }; - }, [dayZero, pods, headers]); + 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; } diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css index b6342c2b25..99542621b8 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); } From 9412adf870d1a72974117b49ecb398e2ffd8b059 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 1 Nov 2026 22:06:04 -0800 Subject: [PATCH 4/7] =?UTF-8?q?fix(i18n):=20Artifacts=20day-zero=20title?= =?UTF-8?q?=20says=20=E5=88=86=E4=BA=AB=20like=20its=20body?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015AKgpMD6Qh13f7fj6mbGT3 --- frontend/src/i18n/locales/zh-CN.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index c04e5b024f..58440aede7 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -2612,7 +2612,7 @@ "more": "还有{{count}}项", "loadingMore": "加载中…", "dayZero": { - "title": "还没有共享内容", + "title": "还没有分享内容", "body": "你和你的智能体在 Pod 里分享的文件会出现在这里,包括文档、图片和页面。", "open": "打开 {{pod}}" }, From 3b6eed7828a78fa79e75dde10afb3a1e062df37d Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 1 Nov 2026 22:09:40 -0800 Subject: [PATCH 5/7] fix(artifacts): day zero waits for the unfiltered answer, so clearing a filter keeps the controls and focus Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015AKgpMD6Qh13f7fj6mbGT3 --- docs/design/artboards/d215/index.html | 2 +- .../src/v2/__tests__/V2ArtifactsPage.test.tsx | 19 +++++++++++++++++++ .../src/v2/components/V2ArtifactsPage.tsx | 17 ++++++++++++++--- frontend/src/v2/v2.css | 2 +- 4 files changed, 35 insertions(+), 5 deletions(-) diff --git a/docs/design/artboards/d215/index.html b/docs/design/artboards/d215/index.html index c315f4f0f8..025a73745e 100644 --- a/docs/design/artboards/d215/index.html +++ b/docs/design/artboards/d215/index.html @@ -1610,7 +1610,7 @@

    Same look. Less said.

    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 pod Activity's Get started step opens (the first pod with an agent seated, else the account's first pod), with the same label. 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.
    +
    • 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.
    diff --git a/frontend/src/v2/__tests__/V2ArtifactsPage.test.tsx b/frontend/src/v2/__tests__/V2ArtifactsPage.test.tsx index 9c67b407c2..a565d67e07 100644 --- a/frontend/src/v2/__tests__/V2ArtifactsPage.test.tsx +++ b/frontend/src/v2/__tests__/V2ArtifactsPage.test.tsx @@ -161,6 +161,25 @@ describe('V2ArtifactsPage (direction C, PR 5)', () => { 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(); diff --git a/frontend/src/v2/components/V2ArtifactsPage.tsx b/frontend/src/v2/components/V2ArtifactsPage.tsx index 5b1a34bfdf..1d08a2b84f 100644 --- a/frontend/src/v2/components/V2ArtifactsPage.tsx +++ b/frontend/src/v2/components/V2ArtifactsPage.tsx @@ -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]); @@ -151,8 +159,11 @@ 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. - const dayZero = !loading && !error && total === 0 && items.length === 0 && !urlQ && !kind && !podId; + // 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. + const dayZero = !loading && !error && total === 0 && items.length === 0 && !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(() => { diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css index 99542621b8..f6acd01548 100644 --- a/frontend/src/v2/v2.css +++ b/frontend/src/v2/v2.css @@ -5684,7 +5684,7 @@ body.modern-ui.v2-canvas { .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 is a link to the pod Activity's Get started step opens. */ + 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); } From 94e54649063213d2883f75085d4b7b0a033cb41d Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 1 Nov 2026 22:13:59 -0800 Subject: [PATCH 6/7] fix(artifacts): a long pod name wraps inside the day-zero key; cover search and pod filtered-empty Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015AKgpMD6Qh13f7fj6mbGT3 --- frontend/src/v2/__tests__/V2ArtifactsPage.test.tsx | 10 ++++++++++ frontend/src/v2/__tests__/v2-layout-invariants.test.ts | 3 +++ frontend/src/v2/v2.css | 2 +- 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/frontend/src/v2/__tests__/V2ArtifactsPage.test.tsx b/frontend/src/v2/__tests__/V2ArtifactsPage.test.tsx index a565d67e07..0d0fb7a13d 100644 --- a/frontend/src/v2/__tests__/V2ArtifactsPage.test.tsx +++ b/frontend/src/v2/__tests__/V2ArtifactsPage.test.tsx @@ -197,4 +197,14 @@ describe('V2ArtifactsPage (direction C, PR 5)', () => { expect(screen.queryByRole('heading', { name: 'Nothing shared yet' })).not.toBeInTheDocument(); expect(screen.queryByRole('link', { name: /^Open / })).not.toBeInTheDocument(); }); + + 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 940145c1d0..46ca3314bd 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -1075,6 +1075,9 @@ describe('v2 layout invariants (CSS rule presence)', () => { 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 => { diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css index f6acd01548..d1613f1c83 100644 --- a/frontend/src/v2/v2.css +++ b/frontend/src/v2/v2.css @@ -5688,7 +5688,7 @@ body.modern-ui.v2-canvas { .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); white-space: nowrap; } +.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. */ From 2ba6c57a465619d5a4e82af3229e4395acd96939 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 1 Nov 2026 22:25:29 -0800 Subject: [PATCH 7/7] fix(artifacts): a search typed before the first answer keeps the search box; unique artboard heading ids Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015AKgpMD6Qh13f7fj6mbGT3 --- docs/design/artboards/d215/index.html | 2 +- .../src/v2/__tests__/V2ArtifactsPage.test.tsx | 20 ++++++++++++++++++- .../src/v2/components/V2ArtifactsPage.tsx | 3 ++- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/docs/design/artboards/d215/index.html b/docs/design/artboards/d215/index.html index 025a73745e..8141ff0283 100644 --- a/docs/design/artboards/d215/index.html +++ b/docs/design/artboards/d215/index.html @@ -1608,7 +1608,7 @@

    Same look. Less said.

    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
    +
    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.
    diff --git a/frontend/src/v2/__tests__/V2ArtifactsPage.test.tsx b/frontend/src/v2/__tests__/V2ArtifactsPage.test.tsx index 0d0fb7a13d..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, cleanup } 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'; @@ -198,6 +198,24 @@ describe('V2ArtifactsPage (direction C, PR 5)', () => { 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(); + 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'], diff --git a/frontend/src/v2/components/V2ArtifactsPage.tsx b/frontend/src/v2/components/V2ArtifactsPage.tsx index 1d08a2b84f..10004bb507 100644 --- a/frontend/src/v2/components/V2ArtifactsPage.tsx +++ b/frontend/src/v2/components/V2ArtifactsPage.tsx @@ -162,7 +162,8 @@ const V2ArtifactsPage: React.FC = () => { // 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. - const dayZero = !loading && !error && total === 0 && items.length === 0 && !urlQ && !kind && !podId + // 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.